Sfoglia il codice sorgente

feat(进件): 管理端建号页 —— 41 个字段 + 两套级联 + 复用既有上传组件(Task 9)

页面 `views/module_payment/collection/onboarding/index.vue`。路径**逐字**对齐菜单 SQL 的
`component_path`(`'module_payment/collection/onboarding/index'`),`defineOptions.name` 取菜单的
`route_name`(`CollectionOnboarding`)—— 路由由 `import.meta.glob` + DB 菜单树生成,不写这两处
就是**静默 404 / keep-alive 永不生效**,且都不报错。菜单 SQL 未执行,页面此刻点不到(预期内)。

结构上的三个取舍:

- **商户类别七选一常驻页首、不进折叠**:线上值 = 枚举**常量名**,显示文案逐字取枚举第三个构造参数。
  它是必填矩阵的分档依据(`category` 为空 ⇒ 矩阵报 `merchant_type`/`account_type` 缺失),
  埋在折叠里会让操作者先填一堆资料再发现选错了类别。
- **字段清单只有一份 `SECTIONS`**:折叠渲染与报文键白名单都从它推导。两处各写一份必然漂移,
  而漂移在这里是硬失败 —— 本仓 `FAIL_ON_UNKNOWN_PROPERTIES=true` ⇒ 多送**一个**键就是 HTTP 500。
  41 个键(36 必填 + 5 个可选上传)与 `category` 之外的键**无处可送**:`tenant_id` / `rate_code` /
  `merchant_type` / `account_type` / `enterprise_id` 根本不在这张表里。
- **不做「按类别条件显隐」**:那要求页面再抄一份必填矩阵(两个真值来源);整表全部呈现,
  缺哪项由服务端的 400 文案按中文标签**逐个列出**。页面因此只钉 `category` 一条校验规则。

两套级联:省/市/区(上一级参数 `parentCode`)与行业类目(上一级参数 `parentId`,**不发** `merchantType`)。
两条腿的参数名不同是线上的既有形状,不是笔误 —— 键名进上游签名原文。类目取「下一级返回空列表」
的那一级,不写死第三级:树深是联调残余项,写死会让浅树永远选不出值。填的是上一级的 `area_code`
(不是 GB 行政码 —— 填 `110000` 上游回 0 条**且不报错**)。

为什么复用既有上传组件而不是新写一个(`components/Upload/SingleImageUpload.vue` 只加 26 行、0 删):

- 该组件的护栏与我们要的**逐位相同**(`maxFileSize=2` 按 `size > maxFileSize*1024*1024` 判 ⇒ 正好
  2097152 字节;四个扩展名一致且大小写不敏感),只差「打哪个端点、应答怎么读」。
  新写一个平行组件 = 复制它 361 行,评审会按「逐字重复的逻辑块」判它。
- 它回的 `UploadFilePath` 是**对象**,而 9b① 的新端点回**裸字符串** ⇒ 加一个可选 `uploader` prop:
  给了值走它、不给值走原路径。**默认路径逐字不变**(仍打 `ParamsAPI.uploadFile`、仍取 `file_url`),
  两个文件里的 3 个既有调用点一行未动。
- 容器上限是 10MB(`spring.servlet.multipart.max-file-size`):超过 10MB 的请求在到达控制器**之前**
  就被容器拒 ⇒ HTTP 500 而不是 400(`GlobalExceptionHandler` 没有那条分支)。所以本地这 2 MiB
  必须先拦住 —— 这道护栏不是锦上添花,是「人话」与「服务器内部错误」的差别。

两条线上行为记进代码注释(踩错都是「编译过、构建过、运行期静默出错」):

- **请求体里空白字段整个省掉,不送空串**:后端只剔 `null`,而空串**会进上游签名原文并照发**
  ⇒ 送 `""` 与不送在上游是两件事。
- 成功与失败(含 400 必填清单)的 toast 由 `utils/request.ts` 的拦截器统一弹,页面**不重复弹**;
  商户号则**常驻在页面上**(toast 会消失,而它是这一页唯一的产出)。

验证:`pnpm ts:check` 与动手前基线**逐行相同**(287 error TS / 49 文件 / exit 2,命中 onboarding
0 处);并已用变异实测确认这两个新文件**确实被检查**(注入 2 处类型错 ⇒ 287 → 289、两个文件都被
点名),否则「没有新增错误」是个空读数。`pnpm build` 成功,产物里能查到本页 chunk。
alphaH 3 settimane fa
parent
commit
c48c253089

+ 127 - 0
frontend/src/api/module_payment/onboarding.ts

@@ -0,0 +1,127 @@
+import request from "@/utils/request";
+
+/**
+ * 进件(建号)—— 管理端接口。
+ *
+ * ★ 三条线上契约**指回** `api/module_payment/collection.ts:3-22`(本文件与它走同一个
+ *   `utils/request.ts`、同一套后端 Jackson 配置),**不在这里复述第二份会漂移的副本**:
+ *   ① 查询参数是 camelCase;② 响应字段是 snake_case;③ POST body 也是 snake_case。
+ *   ⇒ 下面 `parentCode` / `parentId` / `level` 全是**线参名**,逐字如此
+ *     (后端的 `@RequestParam` 没写 `name`,而 `pom.xml` 开了 `-parameters`)。
+ *
+ * ★ 两条级联腿的「上一级」参数名**不一样**:`/areas` 用 `parentCode`、`/mcc` 用 `parentId`。
+ *   那是线上的既有形状,**不是笔误**,不要「顺手统一」。
+ *
+ * ★★ 请求体里**空白的字段要整个省掉,不能送空串**(`buildCreateBody` 见页面):
+ *   后端只剔 `null`,而**空串会进上游签名原文并照发** ——
+ *   `LeshouyingSigner` 文件头第 1 条逐字:「参数值为 null → 不参与;为空字符串 → 参与」,
+ *   且 `LeshouyingClient.signedPayload` 的过滤条件也**只**是 `e.getValue() != null`。
+ *   ⇒ 送 `""` 与不送,在上游是**两件事**。别改成「空串照发」。
+ */
+
+/** `/areas` 与 `/mcc` 的 `level` 白名单:只接受这三个值,**整个参数缺席 ⇒ 信封 code=400**。 */
+export type OnboardingLevel = "1" | "2" | "3";
+
+// ==================== 商户类别(七选一) ====================
+
+/**
+ * 线上 `category` 的取值 = 枚举**常量名**(裁定 R-T9e 逐字:「线上值定为常量名」)。
+ *
+ * ★ `label` 逐字抄自 `LsyMerchantCategory` 各常量**第三个构造参数**(程序取用走 `getLabel()`)——
+ *   在 `java/…/leshouying/enums/LsyMerchantCategory.java` 的枚举常量那一行。
+ *   Java 与本页各存一份是跨运行时不可避免的副本;**以那份为准**。
+ * ★ 只送 `value`(常量名),**不送 `label`** —— 这样「显示文案改动」与「协议值」解耦。
+ */
+export const MERCHANT_CATEGORY_OPTIONS = [
+  { value: "ENTERPRISE_PUBLIC", label: "企业对公" },
+  { value: "ENTERPRISE_PRIVATE_LEGAL", label: "企业对私法人" },
+  { value: "ENTERPRISE_PRIVATE_NON_LEGAL", label: "企业对私非法人" },
+  { value: "INDIVIDUAL_PUBLIC_LEGAL", label: "个体工商户对公法人" },
+  { value: "INDIVIDUAL_PRIVATE_LEGAL", label: "个体工商户对私法人" },
+  { value: "INDIVIDUAL_PRIVATE_NON_LEGAL", label: "个体工商户对私非法人" },
+  { value: "MICRO", label: "小微商户" },
+] as const;
+
+// ==================== 应答形状(★ 逐字,别按上游文档推) ====================
+
+/** `/areas` 数组项 —— **五个键**,逐字来自 `LsyAreaItemDTO`(SNAKE_CASE 序列化)。 */
+export interface OnboardingArea {
+  area_name: string;
+  area_code: string;
+  province_code: string;
+  city_code: string;
+  county_code: string;
+}
+
+/** `/mcc` 数组项 —— **恰好两个键**,逐字来自 `LsyMccItemDTO`。 */
+export interface OnboardingMcc {
+  mcc_name: string;
+  mcc_code: string;
+}
+
+// ==================== 四条腿 ====================
+
+/**
+ * 省/市/区三级联动。
+ *
+ * ★ `level` **必送**(`"1"` / `"2"` / `"3"`)。整个参数缺席回的是**信封 `code=400`**
+ *   (HTTP 状态码仍是 200:本平台 `BusinessException` 刻意不挂 `@ResponseStatus`)
+ *   ⇒ 页面只按 `res.data.code` 判成败,**不要**判 HTTP 状态码。
+ * ★ `level="1"` 时**不传 `parentCode`**;`level="2"`/`"3"` 时传**上一级选中项的 `area_code`**。
+ *   填的是**上一级的 `area_code`**(如 `"1"`),**不是** GB 行政码 —— 填 `"110000"` 上游回 0 条**且不报错**。
+ *
+ * ★ 实测(2026-09-23,`qs.stringify(…, { indices: false })` 即 `request.ts:19` 那个 paramsSerializer):
+ *   `{level:"1", parentCode:undefined}` → **`"level=1"`**(undefined 被整键丢弃)。
+ *   本文件不依赖这条:页面在 1 级调用处**字面上就不给这个键**(见 `loadProvinces`)。
+ */
+export function getOnboardingAreas(params: { level: OnboardingLevel; parentCode?: string }) {
+  return request({ url: "/payment/collection/onboarding/areas", method: "get", params });
+}
+
+/**
+ * 行业类目三级联动。
+ *
+ * ★ 与 `/areas` 唯一的形式差异是**上一级参数叫 `parentId`**(见文件头)。
+ * ★ `merchantType` 本端点收,但**页面不发**(裁定 R-T9f′):那个参数的值域在两页之间互相冲突
+ *   (`LsyMerchantCategory:34-39` 的裁定段;「两页的值域互相冲突」六个字只在代码注释里),
+ *   且它在本接口**非必填** ⇒ 不发比发错安全。**不要**为了「补齐参数」把它加回来。
+ *   ★ 服务端对它的处理是「空白归一成 `null`」(控制器 `StrUtil.blankToDefault(merchantType, null)`),
+ *     所以「不发」与「发空串」在那边同效 —— 但另外两个参数**逐字转交、不做归一化**。
+ */
+export function getOnboardingMcc(params: { level: OnboardingLevel; parentId?: string }) {
+  return request({ url: "/payment/collection/onboarding/mcc", method: "get", params });
+}
+
+/**
+ * 资质图片上传。
+ *
+ * ★ 应答是 **`Result<String>`**,`data` 是**裸字符串**(图片地址)——
+ *   **不是** `{file_path, file_name, origin_name, file_url}` 那种对象。
+ *   页面读 `res.data.data` 直接拿到地址(这就是复用 `SingleImageUpload` 时要加一个
+ *   `uploader` prop 的原因:那条既有路径打的是平台自己的 OSS,回的是对象)。
+ * ★ `formData` 由 `SingleImageUpload` 建好:部件名 = 它的 `name` prop,**默认就是逐字的 `file`**
+ *   ⇒ 页面**不要**再传 `name`(传错就是服务端 `@RequestParam("file")` 收不到 ⇒ 400)。
+ * ★ 成功时**拦截器已经弹过 `msg`**(`utils/request.ts:69-75`:非 GET 成功响应自动弹)
+ *   ⇒ 调用方**不要**再 `ElMessage.success`。
+ */
+export function uploadOnboardingImage(body: FormData) {
+  return request({
+    url: "/payment/collection/onboarding/upload-image",
+    method: "post",
+    data: body,
+    // 覆盖实例级默认的 `application/json`(与既有 `ParamsAPI.uploadFile` 同一写法)
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+}
+
+/**
+ * 建号。**JSON body**,键名 snake_case(见文件头 ★)。
+ *
+ * ★ 应答是 `Result<LsyMerchantEntity>` ⇒ 商户号读 **`res.data.data.mch_no`**。
+ * ★ 成功时拦截器已弹过 `msg` ⇒ 页面**不要**再 `ElMessage.success`(否则同一操作两个 toast);
+ *   但 **400 也走同一条拦截器**(弹 `msg` 并 `Promise.reject`)⇒ 页面**不要**再弹一遍,
+ *   也**不要**把 reject 当成功往下走(`await` 会抛)。
+ */
+export function createOnboardingMerchant(data: Record<string, unknown>) {
+  return request({ url: "/payment/collection/onboarding/create", method: "post", data });
+}

+ 26 - 0
frontend/src/components/Upload/SingleImageUpload.vue

@@ -128,6 +128,22 @@ const props = defineProps({
     type: Boolean,
     default: true,
   },
+
+  /**
+   * 自定义上传实现(**可选**):给了值就走它、**直接拿到图片地址字符串**;
+   * 不给值时走既有的平台上传(打 `ParamsAPI.uploadFile`、从 `UploadFilePath` 取 `file_url`)——
+   * 两种走法的护栏(`handleBeforeUpload`)与成功/失败回调(`onSuccess` / `onError`)
+   * **完全相同**,故默认路径**逐字不变**,既有调用点一个字都不用改。
+   *
+   * 入参是已经建好的 `FormData`(部件名 = `name`,默认 `file`),实现只需返回地址字符串 ——
+   * 用于那些**应答形状不是 `UploadFilePath`** 的上传端点(例如回裸字符串的)。
+   * 实现抛异常会落进 `handleUpload` 既有的 `catch`(`onError` + 重抛)
+   * ⇒ 实现里**不要**再写一层错误处理。
+   */
+  uploader: {
+    type: Function as PropType<(fd: FormData) => Promise<string>>,
+    default: null,
+  },
 });
 
 // 接收字符串类型的modelValue,保持与现有代码的兼容性
@@ -228,6 +244,16 @@ async function handleUpload(options: UploadRequestOptions) {
       formData.append(key, String(value));
     }
 
+    // ★ 自定义上传实现(可选):给了 `uploader` 就走它,直接拿地址字符串。
+    //   放在这里而不是别处:此时的 `formData` 已按上面的规则建好(含 `props.data`),
+    //   下面那段默认路径(`ParamsAPI.uploadFile` + `UploadFilePath`)**逐字不变**。
+    //   抛出的异常不在这里接 —— 外层既有的 `catch` 会走 `onError` 并重抛。
+    if (props.uploader) {
+      const url = await props.uploader(formData);
+      onSuccess({ file_name: file.name, file_url: url } as UploadFilePath);
+      return;
+    }
+
     const response = await ParamsAPI.uploadFile(formData);
 
     if (response.data.code === 0 && response.data) {

+ 713 - 0
frontend/src/views/module_payment/collection/onboarding/index.vue

@@ -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>