소스 검색

feat(乐收赢): 商户映射页按超管身份给出增删改,新增时选租户

配套后端那两个提交。页面本身是「傻瓜式」的那一半:租户打开这一页应当**看到一条能照做的
指引**,而不是一个点了必然 403 的按钮。

- 判据是 `is_superuser`,与后端 `@perm.hasRole('SUPERUSER')` **同一个来源**。
  刻意**不**用 `v-hasPerm` —— 后者判的是角色/菜单授权,与端点实际校验的身份不是一回事
  (后端 save/delete 已从 `@perm.hasAny(...)` 改成 `hasRole(...)`,权限串对这两个端点不再起作用)。
- 新增/编辑/删除按钮挂 `v-if="isSuperAdmin"`;操作列对非超管给一句「只读」,
  而不是留一个**空白操作格**(空白与「这一行没有操作」无法区分,租户会以为是自己没权限点)。
- 顶部提示文案按身份分两种:对租户说「由平台运营录入,如需新增或修改请联系平台运营」——
  只留超管那句会让租户在页面上找一个不存在的按钮。
- 新增时多一个**必填**的租户下拉;编辑态置灰(后端编辑分支的 SET 列表里没有 `tenant_id`,
  改归属等于让同一笔业务横跨两个租户 —— 置灰是第二道,不是唯一那道)。
- 租户列只对超管渲染:非超管的查询被后端按租户钉死,整列会是同一个值。

## 两处踩过坑的细节

- `tenant_id` 初值给 `null` 而不是 `""`:后端判的是 `tenantId == null`,
  空串会绕过那道判断并以 `""` 进 INSERT —— 而 `tenant_id` 是 bigint,在 PG 上直接是类型错误,
  报错位置离现场很远。加了这一格就必须同时进 `FORM_FIELDS` 白名单,否则 `buildForm`
  会把它丢掉,下拉在编辑态就是空的。
- `TenantTable.id` 是可选的 `number | undefined`,而 `el-option` 的 `value` 不收 `undefined`
  (vue-tsc 报 TS2322,会把 `pnpm ts:check` 的基线从 289 条推到 290)。收窄 ref 的类型
  并 filter 掉没有 id 的行:这不是为绕过类型检查 —— 租户 id 是主键,缺它只可能是响应被截断
  或字段被改名的**已经坏了**的数据,而这种行一旦进下拉就是个「选中了却什么也提交不上去」的选项。
  复核:`pnpm ts:check` 输出与基线**逐字节相同**(289 条,与本仓历史遗留基线一致)。
alphaH 2 주 전
부모
커밋
c54673f4ba
1개의 변경된 파일과 134개의 추가작업 그리고 11개의 파일을 삭제
  1. 134 11
      frontend/src/views/module_payment/collection/merchant/index.vue

+ 134 - 11
frontend/src/views/module_payment/collection/merchant/index.vue

@@ -1,10 +1,17 @@
 <template>
   <div class="app-container">
+    <!-- ★ 文案分两种身份(2026-09-30):录入商户号已经改成**超管代录**,
+         租户看不到新增/编辑/删除按钮,所以对他们说「请联系平台运营」才是能照做的指引;
+         只留超管那句会让租户在页面上找一个不存在的按钮。 -->
     <el-alert
       type="info"
       :closable="false"
       style="margin-bottom: 12px"
-      title="手工登记商户号。收款前必须先在这里登记一行 —— 下单时提示「未配置商户号」就是这里还没登记。企业 ID 留空 = 整个租户共用这个号;填了 = 只有这家企业用。"
+      :title="
+        isSuperAdmin
+          ? '手工登记商户号。收款前必须先在这里登记一行 —— 下单时提示「未配置商户号」就是这里还没登记。新增时要选择归属租户;企业 ID 留空 = 整个租户共用这个号;填了 = 只有这家企业用。'
+          : '这里显示的是本租户已登记的商户号(只读)。商户号由平台运营录入,如需新增或修改请联系平台运营。'
+      "
     />
 
     <el-card shadow="never">
@@ -20,13 +27,20 @@
         </el-form-item>
         <el-form-item>
           <el-button type="primary" @click="handleSearch">查询</el-button>
-          <el-button type="success" @click="openForm(null)">新增</el-button>
+          <!-- 判据是 is_superuser(与后端 @perm.hasRole('SUPERUSER') 同一个来源),
+               **不是** v-hasPerm —— 后者判的是角色/菜单授权,与端点实际校验的身份不是一回事。
+               见后端 LeshouyingMerchantController 的类 javadoc。 -->
+          <el-button v-if="isSuperAdmin" type="success" @click="openForm(null)">新增</el-button>
         </el-form-item>
       </el-form>
     </el-card>
 
     <el-card shadow="never" style="margin-top: 12px">
       <el-table v-loading="loading" :data="list" border stripe>
+        <!-- 租户列只对超管有意义:非超管的查询被后端按租户钉死,整列会是同一个值 -->
+        <el-table-column v-if="isSuperAdmin" label="租户" min-width="140">
+          <template #default="{ row }">{{ tenantName(row.tenant_id) }}</template>
+        </el-table-column>
         <!-- ★ 留空的 enterprise_id 必须显示成「租户级」而不是空白格:
              空单元格与「这一列没数据」无法区分,管理员看不出哪一行是租户级兜底映射。 -->
         <el-table-column label="企业 ID" min-width="180">
@@ -49,8 +63,15 @@
         <el-table-column prop="created_time" label="创建时间" width="180" />
         <el-table-column label="操作" width="140" fixed="right">
           <template #default="{ row }">
-            <el-button link type="primary" @click="openForm(row)">编辑</el-button>
-            <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
+            <!-- 两个按钮都挂 isSuperAdmin:后端 save/delete 已经只认超管身份,
+                 非超管点下去必 403 —— 显示一个点了必然失败的入口比不显示更糟。 -->
+            <el-button v-if="isSuperAdmin" link type="primary" @click="openForm(row)">
+              编辑
+            </el-button>
+            <el-button v-if="isSuperAdmin" link type="danger" @click="handleDelete(row)">
+              删除
+            </el-button>
+            <span v-if="!isSuperAdmin" class="readonly-hint">只读</span>
           </template>
         </el-table-column>
       </el-table>
@@ -68,6 +89,27 @@
 
     <el-dialog v-model="formVisible" :title="form.id ? '编辑商户号' : '登记商户号'" width="560px">
       <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
+        <!-- ★ 归属(2026-09-30):新增时**必填** —— 超管 bypass 了租户拦截器,
+             不选租户的后果不是报错,而是这一行静默落到超管自己那个租户上,
+             而它决定钱进哪个乐收赢商户号。
+             ★ 编辑态置灰:归属改不了(后端编辑分支的 SET 列表里没有 tenant_id,
+             改归属等于让同一笔业务横跨两个租户)。置灰是第二道,不是唯一那道。 -->
+        <el-form-item label="租户" prop="tenant_id">
+          <el-select
+            v-model="form.tenant_id"
+            placeholder="选择该商户号归属的租户"
+            filterable
+            :disabled="!!form.id"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="t in tenantList"
+              :key="t.id"
+              :label="t.name || String(t.id)"
+              :value="t.id"
+            />
+          </el-select>
+        </el-form-item>
         <!-- ★ 选填(2026-09-22 去企业化):留空 ⇒ 这一行是**租户级**映射,
              下单时不带 enterprise_id 的请求都会落到它上面。 -->
         <el-form-item label="企业 ID" prop="enterprise_id">
@@ -108,13 +150,45 @@ defineOptions({
   inheritAttrs: false,
 });
 
-import { onMounted, reactive, ref } from "vue";
-import { ElMessageBox } from "element-plus";
+import { computed, onMounted, reactive, ref } from "vue";
+import { ElMessageBox, type FormRules } from "element-plus";
 import {
   getLsyMerchantPage,
   saveLsyMerchant,
   deleteLsyMerchant,
 } from "@/api/module_payment/collection";
+import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
+import { useUserStore } from "@/store";
+
+const userStore = useUserStore();
+
+/**
+ * 超管判据 = `sys_user.is_superuser`,与后端 `@perm.hasRole('SUPERUSER')` 同一个来源。
+ *
+ * ★ 不要换成 `v-hasPerm`:那读的是角色/菜单权限串,与端点实际校验的**身份**不是一回事
+ *   (后端 save/delete 已从 `@perm.hasAny('module_payment:leshouying:merchant:list')`
+ *   改成 `@perm.hasRole('SUPERUSER')`,权限串对这两个端点不再起作用)。
+ *   同款判据与注释见汇安融商户页(`module_payment/har/merchant/index.vue`)。
+ */
+const isSuperAdmin = computed(() => userStore.basicInfo.is_superuser === true);
+
+/**
+ * 租户下拉数据。只有超管会用到,非超管不请求(那个接口对他们也未必有权限)。
+ *
+ * ★ 类型把 `id` 收窄成**必有**(`TenantTable.id` 是可选的 `number | undefined`):
+ *   `el-option` 的 `value` 不收 `undefined`(vue-tsc 报 TS2322),
+ *   而 `sys_tenant.id` 是主键、本来就不可能为空 —— 收窄不为绕过类型检查,
+ *   是让「下拉里出现一个选不中的租户」在赋值那一刻就不成立(见下面 onMounted 里的 filter)。
+ */
+const tenantList = ref<(TenantTable & { id: number })[]>([]);
+
+const tenantName = (id?: number) => {
+  if (id === undefined || id === null) {
+    return "-";
+  }
+  const t = tenantList.value.find((x) => x.id === id);
+  return t ? t.name || String(id) : String(id);
+};
 
 /** 查询参数 camelCase / 响应与请求体字段 snake_case —— 见 `api/module_payment/collection.ts` 文件头 ★ */
 const query = reactive({ pageNo: 1, pageSize: 20, keyword: "" });
@@ -125,11 +199,17 @@ const formVisible = ref(false);
 const formRef = ref();
 
 /**
- * 表单**只认**这 6 个键(`id` + 5 个业务字段)。其余字段(含服务端将来新增的)一律不得进入表单模型 ——
+ * 表单**只认**这 7 个键(`id` + `tenant_id` + 5 个业务字段)。其余字段(含服务端将来新增的)一律不得进入表单模型 ——
  * `buildForm` 是按这张白名单**逐键取值**的,不是把服务端整行摊进来。
+ *
+ * ★ `tenant_id` 是 2026-09-30 加进来的(超管代录要选归属)。加了它就会**在编辑态被回传**,
+ *   而后端编辑分支一律忽略这个值(改归属在结构上不成立,见 `LsyMerchantService#save` 的
+ *   javadoc)—— 那不是「多传了一个没用的键」:不放进白名单的话,`buildForm` 会把它丢掉,
+ *   下拉框在编辑态就是空的。
  */
 const FORM_FIELDS = [
   "id",
+  "tenant_id",
   "enterprise_id",
   "mch_no",
   "shop_no",
@@ -152,6 +232,10 @@ const FORM_FIELDS = [
 function emptyForm(): Record<string, any> {
   return {
     id: null,
+    // 同样给 null 而不是 "":后端判的是 `tenantId == null`("请选择租户"),
+    // 空串会绕过那道判断并以 "" 进 INSERT —— 而 tenant_id 是 bigint,""
+    // 在 PG 上直接是类型错误(报错位置离现场很远)。
+    tenant_id: null,
     enterprise_id: null,
     mch_no: "",
     shop_no: null,
@@ -195,9 +279,16 @@ const form = ref<Record<string, any>>(emptyForm());
 // ★ `enterprise_id` 没有规则 —— 它是选填的(留空 = 租户级)。
 //   不要加回 required:那会把「配租户级映射」这条正常路径堵死在表单校验上,
 //   而服务端早就不认这个前提了(LsyMerchantService.save 只校验 mch_no)。
-const rules = {
-  mch_no: [{ required: true, message: "商户号必填", trigger: "blur" }],
-};
+//
+// ★ `tenant_id` 只在**新增**时必填(与后端 `save` 的新增分支同一条件)。
+//   编辑态不加规则:归属改不了(下拉已置灰),而给旧数据卡一条必填会让超管
+//   连备注都改不了 —— 那道校验挡不住任何东西,只会挡住正常操作。
+const rules = computed<FormRules>(() => ({
+  tenant_id: form.value.id
+    ? []
+    : [{ required: true, message: "请选择租户", trigger: "change" as const }],
+  mch_no: [{ required: true, message: "商户号必填", trigger: "blur" as const }],
+}));
 
 async function loadData() {
   loading.value = true;
@@ -249,5 +340,37 @@ async function handleDelete(row: any) {
   loadData();
 }
 
-onMounted(loadData);
+onMounted(async () => {
+  loadData();
+  if (isSuperAdmin.value) {
+    try {
+      const res: any = await TenantAPI.listTenant({ page_no: 1, page_size: 100 });
+      // ★ 两个键都读:后端 `PageResult` 的字段名是 `list`,而全局 TS 声明写的是 `items`
+      //   (同款实测记在 `har/merchant/index.vue`)—— 只读 `items` 会**静默**拿到空数组,
+      //   表现为超管的租户下拉是空的,于是根本建不了号。
+      const page = res.data.data as
+        | { list?: TenantTable[]; items?: TenantTable[] }
+        | undefined;
+      // ★ filter 掉没有 id 的行:租户 id 是主键,缺它只可能是响应被截断/字段被改名的**已经坏了**的数据,
+      //   而这种行一旦进下拉就是个「选中了却什么也提交不上去」的选项(`tenant_id: undefined`
+      //   会被 JSON.stringify 整个丢掉 ⇒ 后端报「请选择租户」,而用户明明选了)。
+      tenantList.value = (page?.list || page?.items || []).filter(
+        (t): t is TenantTable & { id: number } => typeof t.id === "number"
+      );
+    } catch {
+      // 拉不到租户列表不阻塞列表查询:那一列会退化成显示 tenant_id 数字。
+      // ★ 但新增会因此建不出来(下拉空 ⇒ 必填校验过不去),失败是响亮的(提交时红字提示),
+      //   不是静默写错租户 —— 这个方向是可接受的。
+    }
+  }
+});
 </script>
+
+<style lang="scss" scoped>
+/* 非超管的操作列:给一句明确的「只读」,而不是留一个空白的操作格
+   (空白格与「这一行没有操作」无法区分,租户会以为是自己没权限点) */
+.readonly-hint {
+  color: var(--el-text-color-placeholder);
+  font-size: 12px;
+}
+</style>