| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- <template>
- <div class="app-container">
- <!-- ★ 文案分两种身份(2026-09-30):录入商户号已经改成**超管代录**,
- 租户看不到新增/编辑/删除按钮,所以对他们说「请联系平台运营」才是能照做的指引;
- 只留超管那句会让租户在页面上找一个不存在的按钮。 -->
- <el-alert
- type="info"
- :closable="false"
- style="margin-bottom: 12px"
- :title="
- isSuperAdmin
- ? '手工登记商户号。收款前必须先在这里登记一行 —— 下单时提示「未配置商户号」就是这里还没登记。新增时要选择归属租户;企业 ID 留空 = 整个租户共用这个号;填了 = 只有这家企业用。'
- : '这里显示的是本租户已登记的商户号(只读)。商户号由平台运营录入,如需新增或修改请联系平台运营。'
- "
- />
- <el-card shadow="never">
- <el-form :inline="true" :model="query">
- <el-form-item label="关键字">
- <el-input
- v-model="query.keyword"
- placeholder="商户号 / 商户名 / 企业ID"
- clearable
- style="width: 260px"
- @keyup.enter="handleSearch"
- />
- </el-form-item>
- <el-form-item>
- <el-button type="primary" @click="handleSearch">查询</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">
- <template #default="{ row }">
- <el-tag v-if="!row.enterprise_id" type="warning" size="small">租户级</el-tag>
- <span v-else>{{ row.enterprise_id }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="mch_no" label="商户号" min-width="160" />
- <el-table-column prop="shop_no" label="门店号" min-width="140" />
- <el-table-column prop="merchant_name" label="商户名称" min-width="180" />
- <el-table-column prop="review_status" label="审核状态" width="120" />
- <el-table-column label="状态" width="100">
- <template #default="{ row }">
- <el-tag :type="row.status === 'ENABLED' ? 'success' : 'info'">
- {{ row.status === "ENABLED" ? "启用" : "停用" }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="created_time" label="创建时间" width="180" />
- <el-table-column label="操作" width="140" fixed="right">
- <template #default="{ row }">
- <!-- 两个按钮都挂 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>
- <el-pagination
- v-model:current-page="query.pageNo"
- v-model:page-size="query.pageSize"
- :total="total"
- :page-sizes="[10, 20, 50]"
- layout="total, sizes, prev, pager, next, jumper"
- style="margin-top: 12px; justify-content: flex-end"
- @change="loadData"
- />
- </el-card>
- <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">
- <el-input
- v-model="form.enterprise_id"
- placeholder="可空 —— 留空即租户级(该租户下所有下单可用)"
- />
- </el-form-item>
- <el-form-item label="商户号" prop="mch_no">
- <el-input v-model="form.mch_no" placeholder="请输入商户号" />
- </el-form-item>
- <el-form-item label="门店号">
- <el-input v-model="form.shop_no" placeholder="可空" />
- </el-form-item>
- <el-form-item label="商户名称">
- <el-input v-model="form.merchant_name" />
- </el-form-item>
- <el-form-item label="备注">
- <el-input v-model="form.description" type="textarea" :rows="2" />
- </el-form-item>
- </el-form>
- <template #footer>
- <el-button @click="formVisible = false">取消</el-button>
- <el-button type="primary" @click="handleSubmit">保存</el-button>
- </template>
- </el-dialog>
- </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 永不生效(且不报错)。
- // ★ 本名取自**活库** sys_menu.route_name(id=271,2026-09-23 只读实测;读数记在
- // `java/src/main/resources/db/lsy-cashier-menu.sql` 头部)。要改就改页面,别改库。
- defineOptions({
- name: "CollectionMerchant",
- inheritAttrs: false,
- });
- 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: "" });
- const list = ref<any[]>([]);
- const total = ref(0);
- const loading = ref(false);
- const formVisible = ref(false);
- const formRef = ref();
- /**
- * 表单**只认**这 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",
- "merchant_name",
- "description",
- ] as const;
- /**
- * ★ 键名必须 snake_case:后端 `@RequestBody LsyMerchantEntity` 走全局 SNAKE_CASE 反序列化。
- *
- * ★ 可空列(V1.8 DDL:`shop_no` / `merchant_name` / `description`,2026-09-22 起加上
- * `enterprise_id`)的初值是 `null` 而**不是** `""`:
- * 这四列的语义是「空 ⇒ 库里 NULL」,而 `""` 与 `NULL` 在库里是两回事 ——
- * 表格里都显示空白,但 `WHERE shop_no IS NULL` 查不到 `''`,于是数据悄悄查不出来。
- * 初值给 `""` 会让「打开新增表单、什么都不填直接保存」写出空串。
- * `enterprise_id` 上这条尤其致命:租户级映射就是靠 `enterprise_id IS NULL` 找的,
- * 存成 `''` 之后下单方直接报「未配置商户号」。
- * `el-input` 把 `null` 当空串显示(不会报错也不会显示 "null"),界面无差别。
- */
- 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,
- merchant_name: null,
- description: null,
- };
- }
- /**
- * ★ 白名单投影 —— **不要**改回 `Object.assign(form, row)` / `{ ...row }`。
- *
- * 服务端回的是**整行**:除这 6 个键外还带 status / review_status / created_time / updated_time /
- * tenant_id,以及**以后新增的任何字段**。整行摊进表单会静默写坏数据(每一跳都有依据):
- * · `status` / `review_status` —— 新增时,从上一行残留的非空值会**直接进 INSERT**
- * (后端只在空值时才补默认值);编辑时后端已改成定向更新、不写这两列,
- * 但**不要依赖它**:白名单与后端定向更新是两处独立防线,各守一段(前端守「新增不得继承上一行」);
- * · `created_time` / `updated_time` —— `AutoFillMetaObjectHandler` 用 `strictInsertFill`,
- * **只在字段为 null 时**填充 ⇒ 新建的行会继承**别人那一行**的创建时间;
- * · 将来新加的字段同理,而且不会有任何报错。
- * 按白名单逐键取值之后,「服务端多返回一个字段」在模型这一层就已经不成立了。
- *
- * ★ 只跳过 `undefined`(键不存在),**不跳过 `null`**:服务端回 `null`(可空列本来就是 NULL)
- * 就原样留在模型里 —— 不要 `?? ""` / `|| ""` 折叠,那会把库里的 NULL 变成空串写回去(D-2)。
- * 用户清空(`""`)与未填(`null`)都由后端 `LsyMerchantService.save` 收敛成 NULL;
- * 前端只保证「不制造空串」,不做这层转换(收敛点只能有一个)。
- */
- function buildForm(row?: any): Record<string, any> {
- const next = emptyForm();
- if (row) {
- for (const key of FORM_FIELDS) {
- if (row[key] !== undefined) {
- next[key] = row[key];
- }
- }
- }
- return next;
- }
- const form = ref<Record<string, any>>(emptyForm());
- // ★ `enterprise_id` 没有规则 —— 它是选填的(留空 = 租户级)。
- // 不要加回 required:那会把「配租户级映射」这条正常路径堵死在表单校验上,
- // 而服务端早就不认这个前提了(LsyMerchantService.save 只校验 mch_no)。
- //
- // ★ `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;
- try {
- const res: any = await getLsyMerchantPage(query);
- // 两层 `.data.data`(响应拦截器不解包);分页字段名是 `list`
- list.value = res.data.data?.list || [];
- total.value = res.data.data?.total || 0;
- } finally {
- loading.value = false;
- }
- }
- function handleSearch() {
- query.pageNo = 1;
- loadData();
- }
- /**
- * ★ 每次打开都**换一个新对象**(`form.value = …`),而不是往旧对象里 assign —— 两道机制各自独立:
- * · 白名单(`buildForm`)挡住「服务端多返回的字段」;
- * · 换对象挡住「上一次编辑残留的值」:旧值在新对象里**物理上无处可存**。
- *
- * ★ `id` 是**字符串**(后端 `LsyMerchantEntity.getId()` 挂了 `@JsonSerialize(ToStringSerializer)`):
- * 19 位雪花超 JS 安全整数,number 会在 `JSON.parse` 那一刻丢精度且不可逆 ⇒ 编辑/删除打不中目标行。
- * 原样回传即可,**不要** `Number(row.id)` / `parseInt(row.id)`。
- */
- function openForm(row?: any) {
- form.value = buildForm(row);
- formVisible.value = true;
- }
- async function handleSubmit() {
- await formRef.value?.validate();
- await saveLsyMerchant(form.value);
- // 成功提示由 `utils/request.ts` 的响应拦截器统一弹(非 GET 成功响应 → `ElMessage.success(data.msg)`),
- // 这里**不能**再弹一次,否则同一个操作出两个 toast。
- formVisible.value = false;
- loadData();
- }
- async function handleDelete(row: any) {
- const scope = row.enterprise_id ? `企业「${row.enterprise_id}」` : "本租户";
- await ElMessageBox.confirm(`确认删除登记「${row.mch_no}」?删除后${scope}将无法下单。`, "提示", {
- type: "warning",
- });
- await deleteLsyMerchant(row.id);
- // 成功提示同上:由拦截器统一弹,页面不重复弹。
- 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>
|