|
@@ -0,0 +1,713 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="app-container">
|
|
|
|
|
+ <el-alert
|
|
|
|
|
+ type="info"
|
|
|
|
|
+ :closable="false"
|
|
|
|
|
+ style="margin-bottom: 12px"
|
|
|
|
|
+ title="按所选商户类别填写资料,提交后由服务端建号。必填项按类别分档,漏填时服务端会在提交后一次列全(按中文字段名)—— 照那份清单补齐即可。"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 商户号是本页唯一的产出:toast 会自己消失,这里**常驻**到下一次提交 -->
|
|
|
|
|
+ <el-alert v-if="createdMchNo" type="success" :closable="false" style="margin-bottom: 12px">
|
|
|
|
|
+ <template #title>
|
|
|
|
|
+ 提交成功,商户号:
|
|
|
|
|
+ <strong class="mch-no">{{ createdMchNo }}</strong>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-alert>
|
|
|
|
|
+
|
|
|
|
|
+ <el-alert
|
|
|
|
|
+ v-if="cascadeFailed"
|
|
|
|
|
+ type="error"
|
|
|
|
|
+ :closable="false"
|
|
|
|
|
+ show-icon
|
|
|
|
|
+ style="margin-bottom: 12px"
|
|
|
|
|
+ title="省市区 / 行业类目 未能加载,这两组级联为空时无法完成填写"
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #default>
|
|
|
|
|
+ <el-button link type="primary" @click="reloadCascades">重新加载</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-alert>
|
|
|
|
|
+
|
|
|
|
|
+ <el-card shadow="never">
|
|
|
|
|
+ <el-form
|
|
|
|
|
+ ref="formRef"
|
|
|
|
|
+ :model="form"
|
|
|
|
|
+ :rules="rules"
|
|
|
|
|
+ label-width="260px"
|
|
|
|
|
+ label-position="right"
|
|
|
|
|
+ v-loading="submitting"
|
|
|
|
|
+ >
|
|
|
|
|
+ <!-- 商户类别:全部资料的**分档依据**,不进折叠 —— 它是这一页的入口 -->
|
|
|
|
|
+ <el-form-item label="商户类别" prop="category">
|
|
|
|
|
+ <el-select v-model="form.category" placeholder="请选择商户类别" style="width: 320px">
|
|
|
|
|
+ <el-option
|
|
|
|
|
+ v-for="c in MERCHANT_CATEGORY_OPTIONS"
|
|
|
|
|
+ :key="c.value"
|
|
|
|
|
+ :label="c.label"
|
|
|
|
|
+ :value="c.value"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 默认**全部展开**:41 个框里 36 个必填,折叠收起会把必填项藏起来 -->
|
|
|
|
|
+ <el-collapse v-model="activeSections">
|
|
|
|
|
+ <el-collapse-item v-for="s in SECTIONS" :key="s.key" :name="s.key">
|
|
|
|
|
+ <template #title>
|
|
|
|
|
+ <span class="section-title">{{ s.title }}</span>
|
|
|
|
|
+ <span v-if="s.hint" class="section-hint">{{ s.hint }}</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 纯文本组 -->
|
|
|
|
|
+ <el-row v-if="s.kind === 'text'" :gutter="20">
|
|
|
|
|
+ <el-col v-for="f in s.fields" :key="f.key" :span="12">
|
|
|
|
|
+ <el-form-item :label="f.label">
|
|
|
|
|
+ <el-input v-model="form[f.key]" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ </el-row>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 省/市/区三级联动:填的是**上一级选中项的 `area_code`**,不是 GB 行政码 -->
|
|
|
|
|
+ <el-row v-else-if="s.kind === 'address'" :gutter="20">
|
|
|
|
|
+ <el-col :span="8">
|
|
|
|
|
+ <el-form-item :label="AREA_FIELDS.province.label">
|
|
|
|
|
+ <el-select
|
|
|
|
|
+ v-model="form[AREA_FIELDS.province.key]"
|
|
|
|
|
+ placeholder="请选择省级"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ style="width: 100%"
|
|
|
|
|
+ @change="handleProvinceChange"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-option
|
|
|
|
|
+ v-for="a in provinceOptions"
|
|
|
|
|
+ :key="a.area_code"
|
|
|
|
|
+ :label="a.area_name"
|
|
|
|
|
+ :value="a.area_code"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ <el-col :span="8">
|
|
|
|
|
+ <el-form-item :label="AREA_FIELDS.city.label">
|
|
|
|
|
+ <el-select
|
|
|
|
|
+ v-model="form[AREA_FIELDS.city.key]"
|
|
|
|
|
+ placeholder="请先选择省级"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ :disabled="!form[AREA_FIELDS.province.key]"
|
|
|
|
|
+ style="width: 100%"
|
|
|
|
|
+ @change="handleCityChange"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-option
|
|
|
|
|
+ v-for="a in cityOptions"
|
|
|
|
|
+ :key="a.area_code"
|
|
|
|
|
+ :label="a.area_name"
|
|
|
|
|
+ :value="a.area_code"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ <el-col :span="8">
|
|
|
|
|
+ <el-form-item :label="AREA_FIELDS.county.label">
|
|
|
|
|
+ <el-select
|
|
|
|
|
+ v-model="form[AREA_FIELDS.county.key]"
|
|
|
|
|
+ placeholder="请先选择市级"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ :disabled="!form[AREA_FIELDS.city.key]"
|
|
|
|
|
+ style="width: 100%"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-option
|
|
|
|
|
+ v-for="a in countyOptions"
|
|
|
|
|
+ :key="a.area_code"
|
|
|
|
|
+ :label="a.area_name"
|
|
|
|
|
+ :value="a.area_code"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ <el-col v-for="f in s.fields" :key="f.key" :span="12">
|
|
|
|
|
+ <el-form-item :label="f.label">
|
|
|
|
|
+ <el-input v-model="form[f.key]" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ </el-row>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 行业类目三级联动:只收**末级**的 `mcc_code` 进 `merchant_mcc_code` -->
|
|
|
|
|
+ <template v-else-if="s.kind === 'mcc'">
|
|
|
|
|
+ <el-row :gutter="20">
|
|
|
|
|
+ <el-col :span="8">
|
|
|
|
|
+ <el-form-item label="一级行业">
|
|
|
|
|
+ <el-select
|
|
|
|
|
+ v-model="mcc1"
|
|
|
|
|
+ placeholder="请选择一级行业"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ style="width: 100%"
|
|
|
|
|
+ @change="handleMcc1Change"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-option
|
|
|
|
|
+ v-for="m in mccOptions1"
|
|
|
|
|
+ :key="m.mcc_code"
|
|
|
|
|
+ :label="m.mcc_name"
|
|
|
|
|
+ :value="m.mcc_code"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ <el-col :span="8">
|
|
|
|
|
+ <el-form-item label="二级行业">
|
|
|
|
|
+ <el-select
|
|
|
|
|
+ v-model="mcc2"
|
|
|
|
|
+ placeholder="请先选择一级行业"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ :disabled="!mcc1"
|
|
|
|
|
+ :loading="mccLoading"
|
|
|
|
|
+ style="width: 100%"
|
|
|
|
|
+ @change="handleMcc2Change"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-option
|
|
|
|
|
+ v-for="m in mccOptions2"
|
|
|
|
|
+ :key="m.mcc_code"
|
|
|
|
|
+ :label="m.mcc_name"
|
|
|
|
|
+ :value="m.mcc_code"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ <el-col :span="8">
|
|
|
|
|
+ <el-form-item label="三级行业">
|
|
|
|
|
+ <el-select
|
|
|
|
|
+ v-model="mcc3"
|
|
|
|
|
+ placeholder="请先选择二级行业"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ :disabled="!mcc2"
|
|
|
|
|
+ :loading="mccLoading"
|
|
|
|
|
+ style="width: 100%"
|
|
|
|
|
+ @change="handleMcc3Change"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-option
|
|
|
|
|
+ v-for="m in mccOptions3"
|
|
|
|
|
+ :key="m.mcc_code"
|
|
|
|
|
+ :label="m.mcc_name"
|
|
|
|
|
+ :value="m.mcc_code"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ </el-row>
|
|
|
|
|
+ <el-form-item :label="MCC_FIELD.label">
|
|
|
|
|
+ <div class="picked">
|
|
|
|
|
+ 已选:
|
|
|
|
|
+ <strong>{{ leafLabel || "尚未选到最末一级" }}</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 上传组:每格 = 标签 + 图片框(图片框自己就地显示图片本身,不把地址当文本渲染) -->
|
|
|
|
|
+ <div v-else class="upload-grid">
|
|
|
|
|
+ <div v-for="f in s.fields" :key="f.key" class="upload-cell">
|
|
|
|
|
+ <div class="upload-cell__label">{{ f.label }}</div>
|
|
|
|
|
+ <SingleImageUpload
|
|
|
|
|
+ v-model="form[f.key]"
|
|
|
|
|
+ :accept="UPLOAD_ACCEPT"
|
|
|
|
|
+ :max-file-size="UPLOAD_MAX_MB"
|
|
|
|
|
+ :uploader="uploadOnboardingFile"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-collapse-item>
|
|
|
|
|
+ </el-collapse>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="form-actions">
|
|
|
|
|
+ <el-button @click="handleReset">重置</el-button>
|
|
|
|
|
+ <el-button type="primary" :loading="submitting" @click="handleSubmit">提交进件</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+// ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
|
|
|
|
|
+// 而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
|
|
|
|
|
+// 不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
|
|
|
|
|
+defineOptions({
|
|
|
|
|
+ name: "CollectionOnboarding",
|
|
|
|
|
+ inheritAttrs: false,
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+import { computed, onMounted, reactive, ref } from "vue";
|
|
|
|
|
+import {
|
|
|
|
|
+ MERCHANT_CATEGORY_OPTIONS,
|
|
|
|
|
+ createOnboardingMerchant,
|
|
|
|
|
+ getOnboardingAreas,
|
|
|
|
|
+ getOnboardingMcc,
|
|
|
|
|
+ uploadOnboardingImage,
|
|
|
|
|
+ type OnboardingArea,
|
|
|
|
|
+ type OnboardingLevel,
|
|
|
|
|
+ type OnboardingMcc,
|
|
|
|
|
+} from "@/api/module_payment/onboarding";
|
|
|
|
|
+import SingleImageUpload from "@/components/Upload/SingleImageUpload.vue";
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * ★ 上传护栏 —— 与后端**同阈值**,两边不一致的后果是「页面放行、服务端回信封 400」:
|
|
|
|
|
+ * · 大小 `2`(组件按 `size > maxFileSize * 1024 * 1024` 判 ⇒ 恰好 **2097152** 字节)
|
|
|
|
|
+ * —— 后端 `LeshouyingProperties` 逐字 `2L * 1024 * 1024`;
|
|
|
|
|
+ * · 扩展名四个 —— 后端 `LsyOnboardingService` 逐字,且用 `toLowerCase` ⇒ 大小写不敏感。
|
|
|
|
|
+ * ★ 这道护栏不是锦上添花:容器上限是 10MB(`spring.servlet.multipart.max-file-size`),
|
|
|
|
|
+ * **超过 10MB 的会被容器在到达控制器之前拒成 HTTP 500**(不是 400)⇒ 本地这 2 MiB 必须先拦住。
|
|
|
|
|
+ */
|
|
|
|
|
+const UPLOAD_MAX_MB = 2;
|
|
|
|
|
+const UPLOAD_ACCEPT = ".jpg,.jpeg,.png,.bmp";
|
|
|
|
|
+
|
|
|
|
|
+/** 一个字段:`key` 逐字 = 线上报文键名(snake_case),`label` 逐字 = 服务端那张中文标签表。 */
|
|
|
|
|
+interface Field {
|
|
|
|
|
+ key: string;
|
|
|
|
|
+ label: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Section {
|
|
|
|
|
+ key: string;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ kind: "text" | "address" | "mcc" | "images";
|
|
|
|
|
+ fields: Field[];
|
|
|
|
|
+ hint?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 省/市/区三个下拉各自绑哪个字段 —— 标签逐字取服务端那张表。 */
|
|
|
|
|
+const AREA_FIELDS = {
|
|
|
|
|
+ province: { key: "address_province", label: "商户地址省code" },
|
|
|
|
|
+ city: { key: "address_city", label: "商户地址市code" },
|
|
|
|
|
+ county: { key: "address_county", label: "商户地址区县code" },
|
|
|
|
|
+} as const;
|
|
|
|
|
+
|
|
|
|
|
+/** 行业类目的落点字段 —— 级联选到**末级**后写进它。 */
|
|
|
|
|
+const MCC_FIELD: Field = { key: "merchant_mcc_code", label: "商户行业类目code" };
|
|
|
|
|
+
|
|
|
|
|
+/** 上传组的图片格式/大小提示 —— 写在**分组标题旁一次**,不逐框挂(17 个框各挂一条太吵)。 */
|
|
|
|
|
+const UPLOAD_HINT = "支持 jpg/jpeg/png/bmp、单张不超过 2MB";
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * ★★ 本页的**唯一**一张字段清单:分区折叠的渲染、以及建号报文的键白名单,都从它推导
|
|
|
|
|
+ * (见 `CREATE_KEYS`)—— 两处各写一份必然漂移。
|
|
|
|
|
+ *
|
|
|
|
|
+ * `label` **逐字**抄自服务端那张中文标签表(覆盖该页 62 个参数、javadoc 明说用途之一是
|
|
|
|
|
+ * 「前端进件页的表单字段标签」),**不要简化**:格式提示(如「(格式YYYYMMDD)长期传20991231」)
|
|
|
|
|
+ * 是上游原文里就有的,裁掉等于发明原文没有的简写。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 顺序照计划里 §三 那张表;`category` 是入口、不在这张表里(它渲染在折叠之外)。
|
|
|
|
|
+ */
|
|
|
|
|
+const SECTIONS: Section[] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "name",
|
|
|
|
|
+ title: "商户名称",
|
|
|
|
|
+ kind: "text",
|
|
|
|
|
+ fields: [
|
|
|
|
|
+ { key: "merchant_name", label: "商户名称" },
|
|
|
|
|
+ { key: "merchant_name_short", label: "商户简称" },
|
|
|
|
|
+ { key: "merchant_name_business", label: "商户注册名称 小微填姓名,个体企业填营业执照" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "contact",
|
|
|
|
|
+ title: "联系人",
|
|
|
|
|
+ kind: "text",
|
|
|
|
|
+ fields: [
|
|
|
|
|
+ { key: "contact_name", label: "商户签约负责人" },
|
|
|
|
|
+ { key: "contact_phone", label: "商户签约负责人电话" },
|
|
|
|
|
+ { key: "contact_email", label: "商户签约负责人邮箱" },
|
|
|
|
|
+ { key: "contact_service", label: "商户客服电话" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "address",
|
|
|
|
|
+ title: "商户地址",
|
|
|
|
|
+ kind: "address",
|
|
|
|
|
+ fields: [{ key: "address_street", label: "商户地址具体街道门牌号" }],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "settle",
|
|
|
|
|
+ title: "结算信息",
|
|
|
|
|
+ kind: "text",
|
|
|
|
|
+ fields: [
|
|
|
|
|
+ { key: "account_idcard_num", label: "持卡人身份证号" },
|
|
|
|
|
+ { key: "account_idcard_expire", label: "持卡人身份证结束时间(格式YYYYMMDD)长期传20991231" },
|
|
|
|
|
+ { key: "account_card_name", label: "结算卡账户名" },
|
|
|
|
|
+ { key: "account_card_num", label: "结算卡卡号" },
|
|
|
|
|
+ { key: "account_card_phone", label: "结算卡预留手机号" },
|
|
|
|
|
+ { key: "account_bank_code", label: "结算银行支行联行号" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "mcc",
|
|
|
|
|
+ title: "行业类目",
|
|
|
|
|
+ kind: "mcc",
|
|
|
|
|
+ fields: [MCC_FIELD],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "license",
|
|
|
|
|
+ title: "资质证照",
|
|
|
|
|
+ kind: "text",
|
|
|
|
|
+ fields: [
|
|
|
|
|
+ { key: "license_type", label: "营业执照类型" },
|
|
|
|
|
+ { key: "license_no", label: "营业执照编号" },
|
|
|
|
|
+ { key: "license_expire", label: "营业执照结束(格式YYYYMMDD)长期传20991231" },
|
|
|
|
|
+ { key: "legal_name", label: "法人名称" },
|
|
|
|
|
+ { key: "legal_cert_no", label: "法人证件号" },
|
|
|
|
|
+ { key: "legal_cert_expire", label: "法人证件结束时间(格式YYYYMMDD)长期传20991231" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "pictures",
|
|
|
|
|
+ title: "资质图片",
|
|
|
|
|
+ kind: "images",
|
|
|
|
|
+ hint: UPLOAD_HINT,
|
|
|
|
|
+ fields: [
|
|
|
|
|
+ { key: "picture_license", label: "营业执照" },
|
|
|
|
|
+ { key: "picture_idcard_a", label: "负责人身份证正面" },
|
|
|
|
|
+ { key: "picture_idcard_b", label: "负责人身份证反面" },
|
|
|
|
|
+ { key: "picture_bankcard_a", label: "银行卡正面" },
|
|
|
|
|
+ { key: "picture_bankcard_b", label: "银行卡反面" },
|
|
|
|
|
+ { key: "picture_outside", label: "门头照片" },
|
|
|
|
|
+ { key: "picture_inside", label: "内部前台照片" },
|
|
|
|
|
+ { key: "picture_open_account", label: "开户许可证照片" },
|
|
|
|
|
+ { key: "picture_unincorporated", label: "入账非法人证明照片" },
|
|
|
|
|
+ { key: "picture_bank_idcard_a", label: "对私账户身份证正面照片" },
|
|
|
|
|
+ { key: "picture_bank_idcard_b", label: "对私账户身份证反面照片" },
|
|
|
|
|
+ // ★ `hander` 是上游的拼写,**照抄**(键名进上游签名原文),别「修好」它
|
|
|
|
|
+ { key: "picture_idcard_hander", label: "手持身份证" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "materials",
|
|
|
|
|
+ title: "其他材料",
|
|
|
|
|
+ kind: "images",
|
|
|
|
|
+ hint: UPLOAD_HINT,
|
|
|
|
|
+ fields: [
|
|
|
|
|
+ { key: "lease_contract", label: "租赁合同" },
|
|
|
|
|
+ { key: "picture_tripartite", label: "三联单合同照片" },
|
|
|
|
|
+ { key: "picture_organization", label: "组织机构代码证照片" },
|
|
|
|
|
+ { key: "picture_tax_registration", label: "税务登记证照片" },
|
|
|
|
|
+ { key: "picture_other", label: "其他证明材料(选填)" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 建号报文的**键白名单 = 41 个**(36 必填 + 5 个可选上传)+ `category`。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★★ 这叫 `buildCreateBody` 是**逐键取值**的,不是 `{ ...form }` —— 后者会静默写坏数据,
|
|
|
|
|
+ * 而且这里的代价是**硬失败**:本仓 `FAIL_ON_UNKNOWN_PROPERTIES` 是 `true`
|
|
|
|
|
+ * (`JacksonConfig` 绕过了 Spring 的 builder)⇒ 多送**一个**键就是 **HTTP 500**(不是 400)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★ 五个**绝不送**的键根本不在这张表里,也就无处可送(§五):
|
|
|
|
|
+ * `tenant_id`(控制器 `rejectBodySuppliedTenantId` 当场拒)、
|
|
|
|
|
+ * `rate_code`(`rejectBodySuppliedRateCode` 当场拒)、
|
|
|
|
|
+ * `merchant_type` / `account_type`(由 `category` **派生**,DTO 上根本没有这两个字段)、
|
|
|
|
|
+ * `enterprise_id`(控制器内类的本地业务字段,非必填,本任务不发)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★ 键名一律 snake_case(见 `api/module_payment/onboarding.ts` 文件头指回的三条契约)。
|
|
|
|
|
+ */
|
|
|
|
|
+const CREATE_KEYS: string[] = [
|
|
|
|
|
+ ...SECTIONS.flatMap((s) => s.fields.map((f) => f.key)),
|
|
|
|
|
+ AREA_FIELDS.province.key,
|
|
|
|
|
+ AREA_FIELDS.city.key,
|
|
|
|
|
+ AREA_FIELDS.county.key,
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+function emptyForm(): Record<string, string> {
|
|
|
|
|
+ const f: Record<string, string> = { category: "" };
|
|
|
|
|
+ for (const key of CREATE_KEYS) {
|
|
|
|
|
+ f[key] = "";
|
|
|
|
|
+ }
|
|
|
|
|
+ return f;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const formRef = ref();
|
|
|
|
|
+const form = reactive<Record<string, string>>(emptyForm());
|
|
|
|
|
+const submitting = ref(false);
|
|
|
|
|
+const createdMchNo = ref("");
|
|
|
|
|
+const cascadeFailed = ref(false);
|
|
|
|
|
+
|
|
|
|
|
+/** 默认**全部展开**(理由见模板上的注释)。 */
|
|
|
|
|
+const activeSections = ref<string[]>(SECTIONS.map((s) => s.key));
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * ★ 页面**只**钉 `category` 一条规则,**不做**必填矩阵的副本。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 为什么只钉它:类别为空时服务端报的缺失项是两个**派生**字段(商户类型 / 账户类型),
|
|
|
|
|
+ * 它们 DTO 上没有、页面上也没有输入框 ⇒ 操作者会看到两个自己从未见过的名字。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 其余必填项一个都不在页面校验:必填集是「**按类别分档**」的,页面再抄一份就是
|
|
|
|
|
+ * **两个真值来源**(抄错一格的症状是「页面拦住了一个其实必填的提交」,且不报错);
|
|
|
|
|
+ * 而服务端的 400 文案会**一次性**按中文标签列出缺哪几项
|
|
|
|
|
+ * (`LsyOnboardingService` 逐字「进件必填项缺失(共 N 项):…」)⇒ 那份清单才是判据。
|
|
|
|
|
+ */
|
|
|
|
|
+const rules = {
|
|
|
|
|
+ category: [{ required: true, message: "请选择商户类别", trigger: "change" }],
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// ==================== 级联:省/市/区 ====================
|
|
|
|
|
+// ★ 每一级的 `parentCode` 都是**上一级选中项的 `area_code`**(如 `"1"`),
|
|
|
|
|
+// **不是** GB 行政码 —— 填 `"110000"` 上游回 0 条**且不报错**(空下拉、无提示)。
|
|
|
|
|
+
|
|
|
|
|
+const provinceOptions = ref<OnboardingArea[]>([]);
|
|
|
|
|
+const cityOptions = ref<OnboardingArea[]>([]);
|
|
|
|
|
+const countyOptions = ref<OnboardingArea[]>([]);
|
|
|
|
|
+
|
|
|
|
|
+// ==================== 级联:行业类目 ====================
|
|
|
|
|
+// ★ 与 `/areas` 唯一的形式差异:上一级参数叫 **`parentId`**(不是 `parentCode`)。
|
|
|
|
|
+// `merchantType` 本端点收但**页面不发** —— 那个参数的值域在两页之间互相冲突,且它非必填。
|
|
|
|
|
+
|
|
|
|
|
+const mcc1 = ref("");
|
|
|
|
|
+const mcc2 = ref("");
|
|
|
|
|
+const mcc3 = ref("");
|
|
|
|
|
+const mccOptions1 = ref<OnboardingMcc[]>([]);
|
|
|
|
|
+const mccOptions2 = ref<OnboardingMcc[]>([]);
|
|
|
|
|
+const mccOptions3 = ref<OnboardingMcc[]>([]);
|
|
|
|
|
+const mccLoading = ref(false);
|
|
|
|
|
+const leafLabel = ref("");
|
|
|
|
|
+
|
|
|
|
|
+const mcc1Name = computed(
|
|
|
|
|
+ () => mccOptions1.value.find((m) => m.mcc_code === mcc1.value)?.mcc_name || ""
|
|
|
|
|
+);
|
|
|
|
|
+const mcc2Name = computed(
|
|
|
|
|
+ () => mccOptions2.value.find((m) => m.mcc_code === mcc2.value)?.mcc_name || ""
|
|
|
|
|
+);
|
|
|
|
|
+const mcc3Name = computed(
|
|
|
|
|
+ () => mccOptions3.value.find((m) => m.mcc_code === mcc3.value)?.mcc_name || ""
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 拉一次级联腿。**失败不在这里弹文案** —— `utils/request.ts` 的拦截器已经弹过服务端的 `msg`
|
|
|
|
|
+ * 并 `Promise.reject` 了;这里只把失败落成页面上一条**常驻**提示(toast 会自己消失),
|
|
|
|
|
+ * 并把控制台里的一句上下文补上(拦截器不知道这是哪一组级联)。
|
|
|
|
|
+ */
|
|
|
|
|
+async function loadCascade<T>(fetcher: () => Promise<unknown>): Promise<T[]> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res: any = await fetcher();
|
|
|
|
|
+ return res.data?.data || [];
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.warn("级联数据加载失败:", e);
|
|
|
|
|
+ cascadeFailed.value = true;
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadProvinces() {
|
|
|
|
|
+ // ★ `level="1"` ⇒ **字面上就不给 `parentCode` 这个键**(不是给空串、也不是给 undefined)。
|
|
|
|
|
+ provinceOptions.value = await loadCascade<OnboardingArea>(() =>
|
|
|
|
|
+ getOnboardingAreas({ level: "1" })
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleProvinceChange() {
|
|
|
|
|
+ // 省变了 ⇒ 市、区两级全部清空:旧值在新树里无处可存
|
|
|
|
|
+ form[AREA_FIELDS.city.key] = "";
|
|
|
|
|
+ form[AREA_FIELDS.county.key] = "";
|
|
|
|
|
+ cityOptions.value = [];
|
|
|
|
|
+ countyOptions.value = [];
|
|
|
|
|
+ if (!form[AREA_FIELDS.province.key]) return;
|
|
|
|
|
+ cityOptions.value = await loadCascade<OnboardingArea>(() =>
|
|
|
|
|
+ getOnboardingAreas({ level: "2", parentCode: form[AREA_FIELDS.province.key] })
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleCityChange() {
|
|
|
|
|
+ form[AREA_FIELDS.county.key] = "";
|
|
|
|
|
+ countyOptions.value = [];
|
|
|
|
|
+ if (!form[AREA_FIELDS.city.key]) return;
|
|
|
|
|
+ countyOptions.value = await loadCascade<OnboardingArea>(() =>
|
|
|
|
|
+ getOnboardingAreas({ level: "3", parentCode: form[AREA_FIELDS.city.key] })
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadMccLevel1() {
|
|
|
|
|
+ mccOptions1.value = await loadCascade<OnboardingMcc>(() => getOnboardingMcc({ level: "1" }));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setLeaf(code: string, name: string) {
|
|
|
|
|
+ form[MCC_FIELD.key] = code;
|
|
|
|
|
+ leafLabel.value = name;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearLeaf() {
|
|
|
|
|
+ form[MCC_FIELD.key] = "";
|
|
|
|
|
+ leafLabel.value = "";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * ★ **末级 = 「下一级返回空列表」的那一级**,不是写死的第三级。
|
|
|
|
|
+ * 类目树的深度是联调残余项(一级就到底 / 两级到底 / 三级到底都可能),
|
|
|
|
|
+ * 写死「一定取第三级」会让浅树永远选不出值。
|
|
|
|
|
+ */
|
|
|
|
|
+async function handleMcc1Change() {
|
|
|
|
|
+ mcc2.value = "";
|
|
|
|
|
+ mcc3.value = "";
|
|
|
|
|
+ mccOptions2.value = [];
|
|
|
|
|
+ mccOptions3.value = [];
|
|
|
|
|
+ clearLeaf();
|
|
|
|
|
+ if (!mcc1.value) return;
|
|
|
|
|
+
|
|
|
|
|
+ mccLoading.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ mccOptions2.value = await loadCascade<OnboardingMcc>(() =>
|
|
|
|
|
+ getOnboardingMcc({ level: "2", parentId: mcc1.value })
|
|
|
|
|
+ );
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ mccLoading.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 一级就是末级
|
|
|
|
|
+ if (mccOptions2.value.length === 0) setLeaf(mcc1.value, mcc1Name.value);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleMcc2Change() {
|
|
|
|
|
+ mcc3.value = "";
|
|
|
|
|
+ mccOptions3.value = [];
|
|
|
|
|
+ clearLeaf();
|
|
|
|
|
+ if (!mcc2.value) return;
|
|
|
|
|
+
|
|
|
|
|
+ mccLoading.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ mccOptions3.value = await loadCascade<OnboardingMcc>(() =>
|
|
|
|
|
+ getOnboardingMcc({ level: "3", parentId: mcc2.value })
|
|
|
|
|
+ );
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ mccLoading.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 二级就是末级
|
|
|
|
|
+ if (mccOptions3.value.length === 0) setLeaf(mcc2.value, mcc2Name.value);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleMcc3Change() {
|
|
|
|
|
+ if (!mcc3.value) {
|
|
|
|
|
+ clearLeaf();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ setLeaf(mcc3.value, mcc3Name.value);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function reloadCascades() {
|
|
|
|
|
+ cascadeFailed.value = false;
|
|
|
|
|
+ loadProvinces();
|
|
|
|
|
+ loadMccLevel1();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ==================== 上传 ====================
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 交给 `SingleImageUpload` 的自定义上传实现(它的 `uploader` prop)。
|
|
|
|
|
+ * ★ 入参是组件已建好的 `FormData`:部件名 = 组件的 `name` prop = **默认的 `file`**
|
|
|
|
|
+ * ⇒ 页面**不传** `name`(传错服务端的 `@RequestParam("file")` 就收不到)。
|
|
|
|
|
+ * ★ 应答是**裸字符串**(`Result<String>`),不是对象 —— 同时**不再弹成功提示**:
|
|
|
|
|
+ * 拦截器已经弹过(非 GET 成功响应自动弹)。
|
|
|
|
|
+ * ★ 抛异常时组件既有的 `catch` 会走 `onError` ⇒ 这里**不写**错误处理。
|
|
|
|
|
+ */
|
|
|
|
|
+async function uploadOnboardingFile(fd: FormData): Promise<string> {
|
|
|
|
|
+ const res: any = await uploadOnboardingImage(fd);
|
|
|
|
|
+ return res.data?.data || "";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ==================== 提交 ====================
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * ★ 空白字段**整个省掉**,不送空串:后端只剔 `null`,而**空串会进上游签名原文并照发**
|
|
|
|
|
+ * ⇒ 送 `""` 与不送,在上游是两件事。
|
|
|
|
|
+ * ★ 必填项留空时省掉它 → 落到 DTO 的 `null` → 服务端必填矩阵按中文标签报出来(400)✓
|
|
|
|
|
+ */
|
|
|
|
|
+function buildCreateBody(): Record<string, string> {
|
|
|
|
|
+ const body: Record<string, string> = {};
|
|
|
|
|
+ if (form.category) body.category = form.category;
|
|
|
|
|
+ for (const key of CREATE_KEYS) {
|
|
|
|
|
+ const value = (form[key] ?? "").trim();
|
|
|
|
|
+ if (value !== "") body[key] = value;
|
|
|
|
|
+ }
|
|
|
|
|
+ return body;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleSubmit() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await formRef.value?.validate();
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // 校验没过:`el-form-item` 已就地标红,不再弹 toast
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ submitting.value = true;
|
|
|
|
|
+ createdMchNo.value = "";
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res: any = await createOnboardingMerchant(buildCreateBody());
|
|
|
|
|
+ // 商户号读 `res.data.data.mch_no`(两层 `.data` —— 响应拦截器不解包)
|
|
|
|
|
+ createdMchNo.value = res.data?.data?.mch_no || "";
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // 失败文案由拦截器统一弹(必填缺失清单就在 `msg` 里)⇒ 页面**不重复弹**,
|
|
|
|
|
+ // 也不把它当成功往下走。这里只是不让 rejection 逃逸成未处理的 Promise 拒绝。
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ submitting.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleReset() {
|
|
|
|
|
+ Object.assign(form, emptyForm());
|
|
|
|
|
+ // 级联的**选项**是独立的 ref、不随表单模型复位,得单独清
|
|
|
|
|
+ cityOptions.value = [];
|
|
|
|
|
+ countyOptions.value = [];
|
|
|
|
|
+ mcc1.value = "";
|
|
|
|
|
+ mcc2.value = "";
|
|
|
|
|
+ mcc3.value = "";
|
|
|
|
|
+ mccOptions2.value = [];
|
|
|
|
|
+ mccOptions3.value = [];
|
|
|
|
|
+ clearLeaf();
|
|
|
|
|
+ createdMchNo.value = "";
|
|
|
|
|
+ formRef.value?.clearValidate();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ // 两组级联与表单无关,进页面就拉(失败由 `loadCascade` 落成页面上那条常驻提示)
|
|
|
|
|
+ loadProvinces();
|
|
|
|
|
+ loadMccLevel1();
|
|
|
|
|
+});
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="scss">
|
|
|
|
|
+.section-title {
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.section-hint {
|
|
|
|
|
+ margin-left: 12px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ font-weight: 400;
|
|
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.picked {
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 自适应列数:上传框比文本输入宽,"一行几个"由容器宽度决定,不写死
|
|
|
|
|
+.upload-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
|
|
|
+ gap: 12px 16px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-cell {
|
|
|
|
|
+ &__label {
|
|
|
|
|
+ margin-bottom: 6px;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ line-height: 1.4;
|
|
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.form-actions {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ justify-content: flex-end;
|
|
|
|
|
+ padding-top: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.mch-no {
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ letter-spacing: 0.5px;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|