|
@@ -15,6 +15,16 @@
|
|
|
</template>
|
|
</template>
|
|
|
</el-alert>
|
|
</el-alert>
|
|
|
|
|
|
|
|
|
|
+ <!-- 信封说成功、却没有商户号:既不能说成功(拿不到号),也不能说失败(可能已经建出来了) -->
|
|
|
|
|
+ <el-alert
|
|
|
|
|
+ v-if="submitNoMchNo"
|
|
|
|
|
+ type="warning"
|
|
|
|
|
+ :closable="false"
|
|
|
|
|
+ show-icon
|
|
|
|
|
+ style="margin-bottom: 12px"
|
|
|
|
|
+ title="提交已被受理,但响应里没有商户号 —— 请先核对是否已建号,直接重提会再建一个商户。"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
<el-alert
|
|
<el-alert
|
|
|
v-if="cascadeFailed"
|
|
v-if="cascadeFailed"
|
|
|
type="error"
|
|
type="error"
|
|
@@ -228,10 +238,7 @@
|
|
|
// ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
|
|
// ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
|
|
|
// 而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
|
|
// 而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
|
|
|
// 不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
|
|
// 不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
|
|
|
-defineOptions({
|
|
|
|
|
- name: "CollectionOnboarding",
|
|
|
|
|
- inheritAttrs: false,
|
|
|
|
|
-});
|
|
|
|
|
|
|
+defineOptions({ name: "CollectionOnboarding" });
|
|
|
|
|
|
|
|
import { computed, onMounted, reactive, ref } from "vue";
|
|
import { computed, onMounted, reactive, ref } from "vue";
|
|
|
import {
|
|
import {
|
|
@@ -424,6 +431,8 @@ const formRef = ref();
|
|
|
const form = reactive<Record<string, string>>(emptyForm());
|
|
const form = reactive<Record<string, string>>(emptyForm());
|
|
|
const submitting = ref(false);
|
|
const submitting = ref(false);
|
|
|
const createdMchNo = ref("");
|
|
const createdMchNo = ref("");
|
|
|
|
|
+/** ★ 提交被受理、却**没回商户号**:不当成功(拿不到号),也不当失败(可能已经建出来了)。 */
|
|
|
|
|
+const submitNoMchNo = ref(false);
|
|
|
const cascadeFailed = ref(false);
|
|
const cascadeFailed = ref(false);
|
|
|
|
|
|
|
|
/** 默认**全部展开**(理由见模板上的注释)。 */
|
|
/** 默认**全部展开**(理由见模板上的注释)。 */
|
|
@@ -476,26 +485,34 @@ const mcc3Name = computed(
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 拉一次级联腿。**失败不在这里弹文案** —— `utils/request.ts` 的拦截器已经弹过服务端的 `msg`
|
|
|
|
|
|
|
+ * 拉一次级联腿。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★★ 返回 **`T[] | null`**:`null` = **请求失败**;空数组 = **上游真的回了 0 条**。
|
|
|
|
|
+ * 这两件事**必须**分辨 —— 上游的失败形态恰恰是「0 条且不报错」(§四),而「0 条」在类目
|
|
|
|
|
+ * 被读作「这一级就是末级」⇒ 两者共用一个值,失败就会把一级/二级的 code 当末级写进
|
|
|
|
|
+ * **必填、且要进上游签名报文**的 `merchant_mcc_code`,页面上「已选:<一级名>」还完全正常。
|
|
|
|
|
+ *
|
|
|
|
|
+ * **失败不在这里弹文案** —— `utils/request.ts` 的拦截器已经弹过服务端的 `msg`
|
|
|
* 并 `Promise.reject` 了;这里只把失败落成页面上一条**常驻**提示(toast 会自己消失),
|
|
* 并 `Promise.reject` 了;这里只把失败落成页面上一条**常驻**提示(toast 会自己消失),
|
|
|
* 并把控制台里的一句上下文补上(拦截器不知道这是哪一组级联)。
|
|
* 并把控制台里的一句上下文补上(拦截器不知道这是哪一组级联)。
|
|
|
*/
|
|
*/
|
|
|
-async function loadCascade<T>(fetcher: () => Promise<unknown>): Promise<T[]> {
|
|
|
|
|
|
|
+async function loadCascade<T>(fetcher: () => Promise<unknown>): Promise<T[] | null> {
|
|
|
try {
|
|
try {
|
|
|
const res: any = await fetcher();
|
|
const res: any = await fetcher();
|
|
|
return res.data?.data || [];
|
|
return res.data?.data || [];
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
console.warn("级联数据加载失败:", e);
|
|
console.warn("级联数据加载失败:", e);
|
|
|
cascadeFailed.value = true;
|
|
cascadeFailed.value = true;
|
|
|
- return [];
|
|
|
|
|
|
|
+ return null;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function loadProvinces() {
|
|
async function loadProvinces() {
|
|
|
// ★ `level="1"` ⇒ **字面上就不给 `parentCode` 这个键**(不是给空串、也不是给 undefined)。
|
|
// ★ `level="1"` ⇒ **字面上就不给 `parentCode` 这个键**(不是给空串、也不是给 undefined)。
|
|
|
- provinceOptions.value = await loadCascade<OnboardingArea>(() =>
|
|
|
|
|
- getOnboardingAreas({ level: "1" })
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ // ★ 空值兜底(`?? []`)只针对**失败**(`null`);行政区这三级的「空列表」没有「末级」语义,
|
|
|
|
|
+ // 不像类目那样会被读成结论,所以这里不必再区分。
|
|
|
|
|
+ provinceOptions.value =
|
|
|
|
|
+ (await loadCascade<OnboardingArea>(() => getOnboardingAreas({ level: "1" }))) ?? [];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function handleProvinceChange() {
|
|
async function handleProvinceChange() {
|
|
@@ -505,22 +522,25 @@ async function handleProvinceChange() {
|
|
|
cityOptions.value = [];
|
|
cityOptions.value = [];
|
|
|
countyOptions.value = [];
|
|
countyOptions.value = [];
|
|
|
if (!form[AREA_FIELDS.province.key]) return;
|
|
if (!form[AREA_FIELDS.province.key]) return;
|
|
|
- cityOptions.value = await loadCascade<OnboardingArea>(() =>
|
|
|
|
|
- getOnboardingAreas({ level: "2", parentCode: form[AREA_FIELDS.province.key] })
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ cityOptions.value =
|
|
|
|
|
+ (await loadCascade<OnboardingArea>(() =>
|
|
|
|
|
+ getOnboardingAreas({ level: "2", parentCode: form[AREA_FIELDS.province.key] })
|
|
|
|
|
+ )) ?? [];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function handleCityChange() {
|
|
async function handleCityChange() {
|
|
|
form[AREA_FIELDS.county.key] = "";
|
|
form[AREA_FIELDS.county.key] = "";
|
|
|
countyOptions.value = [];
|
|
countyOptions.value = [];
|
|
|
if (!form[AREA_FIELDS.city.key]) return;
|
|
if (!form[AREA_FIELDS.city.key]) return;
|
|
|
- countyOptions.value = await loadCascade<OnboardingArea>(() =>
|
|
|
|
|
- getOnboardingAreas({ level: "3", parentCode: form[AREA_FIELDS.city.key] })
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ countyOptions.value =
|
|
|
|
|
+ (await loadCascade<OnboardingArea>(() =>
|
|
|
|
|
+ getOnboardingAreas({ level: "3", parentCode: form[AREA_FIELDS.city.key] })
|
|
|
|
|
+ )) ?? [];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function loadMccLevel1() {
|
|
async function loadMccLevel1() {
|
|
|
- mccOptions1.value = await loadCascade<OnboardingMcc>(() => getOnboardingMcc({ level: "1" }));
|
|
|
|
|
|
|
+ mccOptions1.value =
|
|
|
|
|
+ (await loadCascade<OnboardingMcc>(() => getOnboardingMcc({ level: "1" }))) ?? [];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function setLeaf(code: string, name: string) {
|
|
function setLeaf(code: string, name: string) {
|
|
@@ -534,10 +554,32 @@ function clearLeaf() {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * ★ **末级 = 「下一级返回空列表」的那一级**,不是写死的第三级。
|
|
|
|
|
- * 类目树的深度是联调残余项(一级就到底 / 两级到底 / 三级到底都可能),
|
|
|
|
|
- * 写死「一定取第三级」会让浅树永远选不出值。
|
|
|
|
|
|
|
+ * 取**下一级**类目选项,并按 ★「**末级 = 下一级返回空列表的那一级**」判定落点
|
|
|
|
|
+ * (不是写死的第三级:类目树的深度是联调残余项 —— 一级就到底 / 两级到底 / 三级到底都可能,
|
|
|
|
|
+ * 写死「一定取第三级」会让浅树永远选不出值)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★★ 返回 `false` = **加载失败** ⇒ **不判定末级**。这是本页最要紧的一处分支:
|
|
|
|
|
+ * 失败与「真的空」共用一个值时,一级/二级的 code 会被当成末级写进必填、且要进上游
|
|
|
|
|
+ * 签名报文的 `merchant_mcc_code`,而页面上「已选:<一级名>」看起来完全正常。
|
|
|
|
|
+ * ★ 只**取数与判定**,不碰任何选中值 ⇒ 「用户换级时的清空」留在各自的 `handleMccNChange`,
|
|
|
|
|
+ * 而 `redriveMcc` 能在**不丢用户已选**的前提下复用同一份判定(一处规则,一处实现)。
|
|
|
*/
|
|
*/
|
|
|
|
|
+async function loadMccLevel(level: Exclude<OnboardingLevel, "1">): Promise<boolean> {
|
|
|
|
|
+ // 查 `level` 这一级时,父级 = 上一级**当前选中**的值;父级没选 ⇒ 无判定可言(不是失败)
|
|
|
|
|
+ const parentCode = level === "2" ? mcc1.value : mcc2.value;
|
|
|
|
|
+ const parentName = level === "2" ? mcc1Name.value : mcc2Name.value;
|
|
|
|
|
+ if (!parentCode) return true;
|
|
|
|
|
+
|
|
|
|
|
+ const options = await loadCascade<OnboardingMcc>(() =>
|
|
|
|
|
+ getOnboardingMcc({ level, parentId: parentCode })
|
|
|
|
|
+ );
|
|
|
|
|
+ if (options === null) return false; // ★ 失败:不判定末级
|
|
|
|
|
+ if (level === "2") mccOptions2.value = options;
|
|
|
|
|
+ else mccOptions3.value = options;
|
|
|
|
|
+ if (options.length === 0) setLeaf(parentCode, parentName);
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function handleMcc1Change() {
|
|
async function handleMcc1Change() {
|
|
|
mcc2.value = "";
|
|
mcc2.value = "";
|
|
|
mcc3.value = "";
|
|
mcc3.value = "";
|
|
@@ -548,14 +590,10 @@ async function handleMcc1Change() {
|
|
|
|
|
|
|
|
mccLoading.value = true;
|
|
mccLoading.value = true;
|
|
|
try {
|
|
try {
|
|
|
- mccOptions2.value = await loadCascade<OnboardingMcc>(() =>
|
|
|
|
|
- getOnboardingMcc({ level: "2", parentId: mcc1.value })
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ await loadMccLevel("2");
|
|
|
} finally {
|
|
} finally {
|
|
|
mccLoading.value = false;
|
|
mccLoading.value = false;
|
|
|
}
|
|
}
|
|
|
- // 一级就是末级
|
|
|
|
|
- if (mccOptions2.value.length === 0) setLeaf(mcc1.value, mcc1Name.value);
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function handleMcc2Change() {
|
|
async function handleMcc2Change() {
|
|
@@ -566,14 +604,10 @@ async function handleMcc2Change() {
|
|
|
|
|
|
|
|
mccLoading.value = true;
|
|
mccLoading.value = true;
|
|
|
try {
|
|
try {
|
|
|
- mccOptions3.value = await loadCascade<OnboardingMcc>(() =>
|
|
|
|
|
- getOnboardingMcc({ level: "3", parentId: mcc2.value })
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ await loadMccLevel("3");
|
|
|
} finally {
|
|
} finally {
|
|
|
mccLoading.value = false;
|
|
mccLoading.value = false;
|
|
|
}
|
|
}
|
|
|
- // 二级就是末级
|
|
|
|
|
- if (mccOptions3.value.length === 0) setLeaf(mcc2.value, mcc2Name.value);
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function handleMcc3Change() {
|
|
function handleMcc3Change() {
|
|
@@ -584,10 +618,43 @@ function handleMcc3Change() {
|
|
|
setLeaf(mcc3.value, mcc3Name.value);
|
|
setLeaf(mcc3.value, mcc3Name.value);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function reloadCascades() {
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 重驱动类目链 —— 只给 `reloadCascades` 用:按**当前已选**逐级重取选项、重判末级,
|
|
|
|
|
+ * 且**不清**下游已选值(重载不该让人把三级重选一遍)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★ 必须**显式**驱动:`el-select` 只在**值变化**时才触发 `@change` ⇒ 让用户「再点一次同一个
|
|
|
|
|
+ * 一级值」不会重跑 `handleMcc1Change`,旧的(可能是错的)末级就会一直留着。
|
|
|
|
|
+ * ★ 每往下走一级都要问「下游那个已选值在**重取后**的选项里还在吗」:不在就不能拿它当下一级的
|
|
|
|
|
+ * 父级 —— 那会上游回 0 条、又被判成末级,正是本轮要修的那类错。
|
|
|
|
|
+ */
|
|
|
|
|
+async function redriveMcc() {
|
|
|
|
|
+ if (!mcc1.value) return;
|
|
|
|
|
+ if (!(await loadMccLevel("2"))) return;
|
|
|
|
|
+ if (!mcc2.value || !mccOptions2.value.some((m) => m.mcc_code === mcc2.value)) return;
|
|
|
|
|
+ if (!(await loadMccLevel("3"))) return;
|
|
|
|
|
+ // 三级仍是重取后选项里的那一项才算数;不在 ⇒ 末级留空(显示「尚未选到最末一级」),不猜
|
|
|
|
|
+ if (mcc3.value && mccOptions3.value.some((m) => m.mcc_code === mcc3.value)) {
|
|
|
|
|
+ setLeaf(mcc3.value, mcc3Name.value);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function reloadCascades() {
|
|
|
cascadeFailed.value = false;
|
|
cascadeFailed.value = false;
|
|
|
- loadProvinces();
|
|
|
|
|
- loadMccLevel1();
|
|
|
|
|
|
|
+ // ★ 先把**已经落到必填字段上**的末级清掉:重新加载再失败时,若留着旧值,页面就成了
|
|
|
|
|
+ // 「一条红色的未能加载」+ 一个上游会照收、且可能不是末级的 code。
|
|
|
|
|
+ clearLeaf();
|
|
|
|
|
+ mccOptions2.value = [];
|
|
|
|
|
+ mccOptions3.value = [];
|
|
|
|
|
+
|
|
|
|
|
+ await loadProvinces();
|
|
|
|
|
+ await loadMccLevel1();
|
|
|
|
|
+
|
|
|
|
|
+ mccLoading.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await redriveMcc();
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ mccLoading.value = false;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ==================== 上传 ====================
|
|
// ==================== 上传 ====================
|
|
@@ -598,11 +665,20 @@ function reloadCascades() {
|
|
|
* ⇒ 页面**不传** `name`(传错服务端的 `@RequestParam("file")` 就收不到)。
|
|
* ⇒ 页面**不传** `name`(传错服务端的 `@RequestParam("file")` 就收不到)。
|
|
|
* ★ 应答是**裸字符串**(`Result<String>`),不是对象 —— 同时**不再弹成功提示**:
|
|
* ★ 应答是**裸字符串**(`Result<String>`),不是对象 —— 同时**不再弹成功提示**:
|
|
|
* 拦截器已经弹过(非 GET 成功响应自动弹)。
|
|
* 拦截器已经弹过(非 GET 成功响应自动弹)。
|
|
|
- * ★ 抛异常时组件既有的 `catch` 会走 `onError` ⇒ 这里**不写**错误处理。
|
|
|
|
|
|
|
+ * ★ 抛异常时组件既有的 `catch` 会走 `onError` ⇒ 这里**不写**错误处理(但**要**在拿不到
|
|
|
|
|
+ * 地址时抛,见下)。
|
|
|
*/
|
|
*/
|
|
|
async function uploadOnboardingFile(fd: FormData): Promise<string> {
|
|
async function uploadOnboardingFile(fd: FormData): Promise<string> {
|
|
|
const res: any = await uploadOnboardingImage(fd);
|
|
const res: any = await uploadOnboardingImage(fd);
|
|
|
- return res.data?.data || "";
|
|
|
|
|
|
|
+ const url = res.data?.data;
|
|
|
|
|
+ // ★★ 拿不到地址时**不能**当成功返回 `""`:组件会把 `file_url: ""` 交给 `onSuccess` 并 `emit`
|
|
|
|
|
+ // ⇒ 上传框看起来是成功的(拦截器的成功 toast 也弹过了),字段却是空的,
|
|
|
|
|
+ // 直到服务端必填校验才暴露。抛出去 ⇒ 组件既有的 `catch` 走 `onError`(就地报错)。
|
|
|
|
|
+ // 顺带把「形状变了」(回的压根不是字符串)也挡住 —— 同样是拿不到地址。
|
|
|
|
|
+ if (typeof url !== "string" || url === "") {
|
|
|
|
|
+ throw new Error("上传接口未返回图片地址");
|
|
|
|
|
+ }
|
|
|
|
|
+ return url;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ==================== 提交 ====================
|
|
// ==================== 提交 ====================
|
|
@@ -632,10 +708,16 @@ async function handleSubmit() {
|
|
|
|
|
|
|
|
submitting.value = true;
|
|
submitting.value = true;
|
|
|
createdMchNo.value = "";
|
|
createdMchNo.value = "";
|
|
|
|
|
+ submitNoMchNo.value = false;
|
|
|
try {
|
|
try {
|
|
|
const res: any = await createOnboardingMerchant(buildCreateBody());
|
|
const res: any = await createOnboardingMerchant(buildCreateBody());
|
|
|
// 商户号读 `res.data.data.mch_no`(两层 `.data` —— 响应拦截器不解包)
|
|
// 商户号读 `res.data.data.mch_no`(两层 `.data` —— 响应拦截器不解包)
|
|
|
- createdMchNo.value = res.data?.data?.mch_no || "";
|
|
|
|
|
|
|
+ const mchNo = res.data?.data?.mch_no;
|
|
|
|
|
+ createdMchNo.value = typeof mchNo === "string" ? mchNo : "";
|
|
|
|
|
+ // ★★ 信封说成功(拦截器已弹过成功提示)却没回商户号 ⇒ 本页**唯一的产出**没了,不能静默。
|
|
|
|
|
+ // 但**不**当失败往下走:那会诱导重提,而商户很可能已经建出来了 ⇒ 落成一条常驻提示,
|
|
|
|
|
+ // 让人先去核对。这就是为什么它不走上面那个 `catch`(那条路的语义是「这次没成」)。
|
|
|
|
|
+ submitNoMchNo.value = createdMchNo.value === "";
|
|
|
} catch {
|
|
} catch {
|
|
|
// 失败文案由拦截器统一弹(必填缺失清单就在 `msg` 里)⇒ 页面**不重复弹**,
|
|
// 失败文案由拦截器统一弹(必填缺失清单就在 `msg` 里)⇒ 页面**不重复弹**,
|
|
|
// 也不把它当成功往下走。这里只是不让 rejection 逃逸成未处理的 Promise 拒绝。
|
|
// 也不把它当成功往下走。这里只是不让 rejection 逃逸成未处理的 Promise 拒绝。
|
|
@@ -656,6 +738,7 @@ function handleReset() {
|
|
|
mccOptions3.value = [];
|
|
mccOptions3.value = [];
|
|
|
clearLeaf();
|
|
clearLeaf();
|
|
|
createdMchNo.value = "";
|
|
createdMchNo.value = "";
|
|
|
|
|
+ submitNoMchNo.value = false;
|
|
|
formRef.value?.clearValidate();
|
|
formRef.value?.clearValidate();
|
|
|
}
|
|
}
|
|
|
|
|
|