index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. <template>
  2. <div class="app-container">
  3. <!-- ★ 文案分两种身份(2026-09-30):录入商户号已经改成**超管代录**,
  4. 租户看不到新增/编辑/删除按钮,所以对他们说「请联系平台运营」才是能照做的指引;
  5. 只留超管那句会让租户在页面上找一个不存在的按钮。 -->
  6. <el-alert
  7. type="info"
  8. :closable="false"
  9. style="margin-bottom: 12px"
  10. :title="
  11. isSuperAdmin
  12. ? '手工登记商户号。收款前必须先在这里登记一行 —— 下单时提示「未配置商户号」就是这里还没登记。新增时要选择归属租户;企业 ID 留空 = 整个租户共用这个号;填了 = 只有这家企业用。'
  13. : '这里显示的是本租户已登记的商户号(只读)。商户号由平台运营录入,如需新增或修改请联系平台运营。'
  14. "
  15. />
  16. <el-card shadow="never">
  17. <el-form :inline="true" :model="query">
  18. <el-form-item label="关键字">
  19. <el-input
  20. v-model="query.keyword"
  21. placeholder="商户号 / 商户名 / 企业ID"
  22. clearable
  23. style="width: 260px"
  24. @keyup.enter="handleSearch"
  25. />
  26. </el-form-item>
  27. <el-form-item>
  28. <el-button type="primary" @click="handleSearch">查询</el-button>
  29. <!-- 判据是 is_superuser(与后端 @perm.hasRole('SUPERUSER') 同一个来源),
  30. **不是** v-hasPerm —— 后者判的是角色/菜单授权,与端点实际校验的身份不是一回事。
  31. 见后端 LeshouyingMerchantController 的类 javadoc。 -->
  32. <el-button v-if="isSuperAdmin" type="success" @click="openForm(null)">新增</el-button>
  33. </el-form-item>
  34. </el-form>
  35. </el-card>
  36. <el-card shadow="never" style="margin-top: 12px">
  37. <el-table v-loading="loading" :data="list" border stripe>
  38. <!-- 租户列只对超管有意义:非超管的查询被后端按租户钉死,整列会是同一个值 -->
  39. <el-table-column v-if="isSuperAdmin" label="租户" min-width="140">
  40. <template #default="{ row }">{{ tenantName(row.tenant_id) }}</template>
  41. </el-table-column>
  42. <!-- ★ 留空的 enterprise_id 必须显示成「租户级」而不是空白格:
  43. 空单元格与「这一列没数据」无法区分,管理员看不出哪一行是租户级兜底映射。 -->
  44. <el-table-column label="企业 ID" min-width="180">
  45. <template #default="{ row }">
  46. <el-tag v-if="!row.enterprise_id" type="warning" size="small">租户级</el-tag>
  47. <span v-else>{{ row.enterprise_id }}</span>
  48. </template>
  49. </el-table-column>
  50. <el-table-column prop="mch_no" label="商户号" min-width="160" />
  51. <el-table-column prop="shop_no" label="门店号" min-width="140" />
  52. <el-table-column prop="merchant_name" label="商户名称" min-width="180" />
  53. <el-table-column prop="review_status" label="审核状态" width="120" />
  54. <el-table-column label="状态" width="100">
  55. <template #default="{ row }">
  56. <el-tag :type="row.status === 'ENABLED' ? 'success' : 'info'">
  57. {{ row.status === "ENABLED" ? "启用" : "停用" }}
  58. </el-tag>
  59. </template>
  60. </el-table-column>
  61. <el-table-column prop="created_time" label="创建时间" width="180" />
  62. <el-table-column label="操作" width="140" fixed="right">
  63. <template #default="{ row }">
  64. <!-- 两个按钮都挂 isSuperAdmin:后端 save/delete 已经只认超管身份,
  65. 非超管点下去必 403 —— 显示一个点了必然失败的入口比不显示更糟。 -->
  66. <el-button v-if="isSuperAdmin" link type="primary" @click="openForm(row)">
  67. 编辑
  68. </el-button>
  69. <el-button v-if="isSuperAdmin" link type="danger" @click="handleDelete(row)">
  70. 删除
  71. </el-button>
  72. <span v-if="!isSuperAdmin" class="readonly-hint">只读</span>
  73. </template>
  74. </el-table-column>
  75. </el-table>
  76. <el-pagination
  77. v-model:current-page="query.pageNo"
  78. v-model:page-size="query.pageSize"
  79. :total="total"
  80. :page-sizes="[10, 20, 50]"
  81. layout="total, sizes, prev, pager, next, jumper"
  82. style="margin-top: 12px; justify-content: flex-end"
  83. @change="loadData"
  84. />
  85. </el-card>
  86. <el-dialog v-model="formVisible" :title="form.id ? '编辑商户号' : '登记商户号'" width="560px">
  87. <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
  88. <!-- ★ 归属(2026-09-30):新增时**必填** —— 超管 bypass 了租户拦截器,
  89. 不选租户的后果不是报错,而是这一行静默落到超管自己那个租户上,
  90. 而它决定钱进哪个乐收赢商户号。
  91. ★ 编辑态置灰:归属改不了(后端编辑分支的 SET 列表里没有 tenant_id,
  92. 改归属等于让同一笔业务横跨两个租户)。置灰是第二道,不是唯一那道。 -->
  93. <el-form-item label="租户" prop="tenant_id">
  94. <el-select
  95. v-model="form.tenant_id"
  96. placeholder="选择该商户号归属的租户"
  97. filterable
  98. :disabled="!!form.id"
  99. style="width: 100%"
  100. >
  101. <el-option
  102. v-for="t in tenantList"
  103. :key="t.id"
  104. :label="t.name || String(t.id)"
  105. :value="t.id"
  106. />
  107. </el-select>
  108. </el-form-item>
  109. <!-- ★ 选填(2026-09-22 去企业化):留空 ⇒ 这一行是**租户级**映射,
  110. 下单时不带 enterprise_id 的请求都会落到它上面。 -->
  111. <el-form-item label="企业 ID" prop="enterprise_id">
  112. <el-input
  113. v-model="form.enterprise_id"
  114. placeholder="可空 —— 留空即租户级(该租户下所有下单可用)"
  115. />
  116. </el-form-item>
  117. <el-form-item label="商户号" prop="mch_no">
  118. <el-input v-model="form.mch_no" placeholder="请输入商户号" />
  119. </el-form-item>
  120. <el-form-item label="门店号">
  121. <el-input v-model="form.shop_no" placeholder="可空" />
  122. </el-form-item>
  123. <el-form-item label="商户名称">
  124. <el-input v-model="form.merchant_name" />
  125. </el-form-item>
  126. <el-form-item label="备注">
  127. <el-input v-model="form.description" type="textarea" :rows="2" />
  128. </el-form-item>
  129. </el-form>
  130. <template #footer>
  131. <el-button @click="formVisible = false">取消</el-button>
  132. <el-button type="primary" @click="handleSubmit">保存</el-button>
  133. </template>
  134. </el-dialog>
  135. </div>
  136. </template>
  137. <script setup lang="ts">
  138. // ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
  139. // 而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
  140. // 不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
  141. // ★ 本名取自**活库** sys_menu.route_name(id=271,2026-09-23 只读实测;读数记在
  142. // `java/src/main/resources/db/lsy-cashier-menu.sql` 头部)。要改就改页面,别改库。
  143. defineOptions({
  144. name: "CollectionMerchant",
  145. inheritAttrs: false,
  146. });
  147. import { computed, onMounted, reactive, ref } from "vue";
  148. import { ElMessageBox, type FormRules } from "element-plus";
  149. import {
  150. getLsyMerchantPage,
  151. saveLsyMerchant,
  152. deleteLsyMerchant,
  153. } from "@/api/module_payment/collection";
  154. import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
  155. import { useUserStore } from "@/store";
  156. const userStore = useUserStore();
  157. /**
  158. * 超管判据 = `sys_user.is_superuser`,与后端 `@perm.hasRole('SUPERUSER')` 同一个来源。
  159. *
  160. * ★ 不要换成 `v-hasPerm`:那读的是角色/菜单权限串,与端点实际校验的**身份**不是一回事
  161. * (后端 save/delete 已从 `@perm.hasAny('module_payment:leshouying:merchant:list')`
  162. * 改成 `@perm.hasRole('SUPERUSER')`,权限串对这两个端点不再起作用)。
  163. * 同款判据与注释见汇安融商户页(`module_payment/har/merchant/index.vue`)。
  164. */
  165. const isSuperAdmin = computed(() => userStore.basicInfo.is_superuser === true);
  166. /**
  167. * 租户下拉数据。只有超管会用到,非超管不请求(那个接口对他们也未必有权限)。
  168. *
  169. * ★ 类型把 `id` 收窄成**必有**(`TenantTable.id` 是可选的 `number | undefined`):
  170. * `el-option` 的 `value` 不收 `undefined`(vue-tsc 报 TS2322),
  171. * 而 `sys_tenant.id` 是主键、本来就不可能为空 —— 收窄不为绕过类型检查,
  172. * 是让「下拉里出现一个选不中的租户」在赋值那一刻就不成立(见下面 onMounted 里的 filter)。
  173. */
  174. const tenantList = ref<(TenantTable & { id: number })[]>([]);
  175. const tenantName = (id?: number) => {
  176. if (id === undefined || id === null) {
  177. return "-";
  178. }
  179. const t = tenantList.value.find((x) => x.id === id);
  180. return t ? t.name || String(id) : String(id);
  181. };
  182. /** 查询参数 camelCase / 响应与请求体字段 snake_case —— 见 `api/module_payment/collection.ts` 文件头 ★ */
  183. const query = reactive({ pageNo: 1, pageSize: 20, keyword: "" });
  184. const list = ref<any[]>([]);
  185. const total = ref(0);
  186. const loading = ref(false);
  187. const formVisible = ref(false);
  188. const formRef = ref();
  189. /**
  190. * 表单**只认**这 7 个键(`id` + `tenant_id` + 5 个业务字段)。其余字段(含服务端将来新增的)一律不得进入表单模型 ——
  191. * `buildForm` 是按这张白名单**逐键取值**的,不是把服务端整行摊进来。
  192. *
  193. * ★ `tenant_id` 是 2026-09-30 加进来的(超管代录要选归属)。加了它就会**在编辑态被回传**,
  194. * 而后端编辑分支一律忽略这个值(改归属在结构上不成立,见 `LsyMerchantService#save` 的
  195. * javadoc)—— 那不是「多传了一个没用的键」:不放进白名单的话,`buildForm` 会把它丢掉,
  196. * 下拉框在编辑态就是空的。
  197. */
  198. const FORM_FIELDS = [
  199. "id",
  200. "tenant_id",
  201. "enterprise_id",
  202. "mch_no",
  203. "shop_no",
  204. "merchant_name",
  205. "description",
  206. ] as const;
  207. /**
  208. * ★ 键名必须 snake_case:后端 `@RequestBody LsyMerchantEntity` 走全局 SNAKE_CASE 反序列化。
  209. *
  210. * ★ 可空列(V1.8 DDL:`shop_no` / `merchant_name` / `description`,2026-09-22 起加上
  211. * `enterprise_id`)的初值是 `null` 而**不是** `""`:
  212. * 这四列的语义是「空 ⇒ 库里 NULL」,而 `""` 与 `NULL` 在库里是两回事 ——
  213. * 表格里都显示空白,但 `WHERE shop_no IS NULL` 查不到 `''`,于是数据悄悄查不出来。
  214. * 初值给 `""` 会让「打开新增表单、什么都不填直接保存」写出空串。
  215. * `enterprise_id` 上这条尤其致命:租户级映射就是靠 `enterprise_id IS NULL` 找的,
  216. * 存成 `''` 之后下单方直接报「未配置商户号」。
  217. * `el-input` 把 `null` 当空串显示(不会报错也不会显示 "null"),界面无差别。
  218. */
  219. function emptyForm(): Record<string, any> {
  220. return {
  221. id: null,
  222. // 同样给 null 而不是 "":后端判的是 `tenantId == null`("请选择租户"),
  223. // 空串会绕过那道判断并以 "" 进 INSERT —— 而 tenant_id 是 bigint,""
  224. // 在 PG 上直接是类型错误(报错位置离现场很远)。
  225. tenant_id: null,
  226. enterprise_id: null,
  227. mch_no: "",
  228. shop_no: null,
  229. merchant_name: null,
  230. description: null,
  231. };
  232. }
  233. /**
  234. * ★ 白名单投影 —— **不要**改回 `Object.assign(form, row)` / `{ ...row }`。
  235. *
  236. * 服务端回的是**整行**:除这 6 个键外还带 status / review_status / created_time / updated_time /
  237. * tenant_id,以及**以后新增的任何字段**。整行摊进表单会静默写坏数据(每一跳都有依据):
  238. * · `status` / `review_status` —— 新增时,从上一行残留的非空值会**直接进 INSERT**
  239. * (后端只在空值时才补默认值);编辑时后端已改成定向更新、不写这两列,
  240. * 但**不要依赖它**:白名单与后端定向更新是两处独立防线,各守一段(前端守「新增不得继承上一行」);
  241. * · `created_time` / `updated_time` —— `AutoFillMetaObjectHandler` 用 `strictInsertFill`,
  242. * **只在字段为 null 时**填充 ⇒ 新建的行会继承**别人那一行**的创建时间;
  243. * · 将来新加的字段同理,而且不会有任何报错。
  244. * 按白名单逐键取值之后,「服务端多返回一个字段」在模型这一层就已经不成立了。
  245. *
  246. * ★ 只跳过 `undefined`(键不存在),**不跳过 `null`**:服务端回 `null`(可空列本来就是 NULL)
  247. * 就原样留在模型里 —— 不要 `?? ""` / `|| ""` 折叠,那会把库里的 NULL 变成空串写回去(D-2)。
  248. * 用户清空(`""`)与未填(`null`)都由后端 `LsyMerchantService.save` 收敛成 NULL;
  249. * 前端只保证「不制造空串」,不做这层转换(收敛点只能有一个)。
  250. */
  251. function buildForm(row?: any): Record<string, any> {
  252. const next = emptyForm();
  253. if (row) {
  254. for (const key of FORM_FIELDS) {
  255. if (row[key] !== undefined) {
  256. next[key] = row[key];
  257. }
  258. }
  259. }
  260. return next;
  261. }
  262. const form = ref<Record<string, any>>(emptyForm());
  263. // ★ `enterprise_id` 没有规则 —— 它是选填的(留空 = 租户级)。
  264. // 不要加回 required:那会把「配租户级映射」这条正常路径堵死在表单校验上,
  265. // 而服务端早就不认这个前提了(LsyMerchantService.save 只校验 mch_no)。
  266. //
  267. // ★ `tenant_id` 只在**新增**时必填(与后端 `save` 的新增分支同一条件)。
  268. // 编辑态不加规则:归属改不了(下拉已置灰),而给旧数据卡一条必填会让超管
  269. // 连备注都改不了 —— 那道校验挡不住任何东西,只会挡住正常操作。
  270. const rules = computed<FormRules>(() => ({
  271. tenant_id: form.value.id
  272. ? []
  273. : [{ required: true, message: "请选择租户", trigger: "change" as const }],
  274. mch_no: [{ required: true, message: "商户号必填", trigger: "blur" as const }],
  275. }));
  276. async function loadData() {
  277. loading.value = true;
  278. try {
  279. const res: any = await getLsyMerchantPage(query);
  280. // 两层 `.data.data`(响应拦截器不解包);分页字段名是 `list`
  281. list.value = res.data.data?.list || [];
  282. total.value = res.data.data?.total || 0;
  283. } finally {
  284. loading.value = false;
  285. }
  286. }
  287. function handleSearch() {
  288. query.pageNo = 1;
  289. loadData();
  290. }
  291. /**
  292. * ★ 每次打开都**换一个新对象**(`form.value = …`),而不是往旧对象里 assign —— 两道机制各自独立:
  293. * · 白名单(`buildForm`)挡住「服务端多返回的字段」;
  294. * · 换对象挡住「上一次编辑残留的值」:旧值在新对象里**物理上无处可存**。
  295. *
  296. * ★ `id` 是**字符串**(后端 `LsyMerchantEntity.getId()` 挂了 `@JsonSerialize(ToStringSerializer)`):
  297. * 19 位雪花超 JS 安全整数,number 会在 `JSON.parse` 那一刻丢精度且不可逆 ⇒ 编辑/删除打不中目标行。
  298. * 原样回传即可,**不要** `Number(row.id)` / `parseInt(row.id)`。
  299. */
  300. function openForm(row?: any) {
  301. form.value = buildForm(row);
  302. formVisible.value = true;
  303. }
  304. async function handleSubmit() {
  305. await formRef.value?.validate();
  306. await saveLsyMerchant(form.value);
  307. // 成功提示由 `utils/request.ts` 的响应拦截器统一弹(非 GET 成功响应 → `ElMessage.success(data.msg)`),
  308. // 这里**不能**再弹一次,否则同一个操作出两个 toast。
  309. formVisible.value = false;
  310. loadData();
  311. }
  312. async function handleDelete(row: any) {
  313. const scope = row.enterprise_id ? `企业「${row.enterprise_id}」` : "本租户";
  314. await ElMessageBox.confirm(`确认删除登记「${row.mch_no}」?删除后${scope}将无法下单。`, "提示", {
  315. type: "warning",
  316. });
  317. await deleteLsyMerchant(row.id);
  318. // 成功提示同上:由拦截器统一弹,页面不重复弹。
  319. loadData();
  320. }
  321. onMounted(async () => {
  322. loadData();
  323. if (isSuperAdmin.value) {
  324. try {
  325. const res: any = await TenantAPI.listTenant({ page_no: 1, page_size: 100 });
  326. // ★ 两个键都读:后端 `PageResult` 的字段名是 `list`,而全局 TS 声明写的是 `items`
  327. // (同款实测记在 `har/merchant/index.vue`)—— 只读 `items` 会**静默**拿到空数组,
  328. // 表现为超管的租户下拉是空的,于是根本建不了号。
  329. const page = res.data.data as
  330. | { list?: TenantTable[]; items?: TenantTable[] }
  331. | undefined;
  332. // ★ filter 掉没有 id 的行:租户 id 是主键,缺它只可能是响应被截断/字段被改名的**已经坏了**的数据,
  333. // 而这种行一旦进下拉就是个「选中了却什么也提交不上去」的选项(`tenant_id: undefined`
  334. // 会被 JSON.stringify 整个丢掉 ⇒ 后端报「请选择租户」,而用户明明选了)。
  335. tenantList.value = (page?.list || page?.items || []).filter(
  336. (t): t is TenantTable & { id: number } => typeof t.id === "number"
  337. );
  338. } catch {
  339. // 拉不到租户列表不阻塞列表查询:那一列会退化成显示 tenant_id 数字。
  340. // ★ 但新增会因此建不出来(下拉空 ⇒ 必填校验过不去),失败是响亮的(提交时红字提示),
  341. // 不是静默写错租户 —— 这个方向是可接受的。
  342. }
  343. }
  344. });
  345. </script>
  346. <style lang="scss" scoped>
  347. /* 非超管的操作列:给一句明确的「只读」,而不是留一个空白的操作格
  348. (空白格与「这一行没有操作」无法区分,租户会以为是自己没权限点) */
  349. .readonly-hint {
  350. color: var(--el-text-color-placeholder);
  351. font-size: 12px;
  352. }
  353. </style>