index.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. <template>
  2. <!-- 单根节点:AppMain 的 <transition mode="out-in"> 要求组件根为元素,
  3. 多根 fragment 会导致切换动画无法完成、后续路由挂载卡死 -->
  4. <div class="unified-merchant">
  5. <el-card>
  6. <template #header>
  7. <div class="card-header">
  8. <span>商户配置</span>
  9. <!-- 只有超管能建号:判据用的是 sys_user.is_superuser,与后端
  10. @PreAuthorize("@perm.hasRole('SUPERUSER')") 是同一个 —— 不要改用
  11. v-hasPerm(那判的是角色授权,与端点实际校验的身份不是一回事)。
  12. spec §9.5 明确不为 save/delete 造权限点。 -->
  13. <el-button v-if="isSuperAdmin" type="primary" icon="Plus" @click="openCreate">
  14. 新增配置
  15. </el-button>
  16. </div>
  17. </template>
  18. <el-alert
  19. class="mb-4"
  20. type="info"
  21. :closable="false"
  22. show-icon
  23. :title="
  24. isSuperAdmin
  25. ? '上游商户号与应用密钥由运营完成报备后录入;商户号全局唯一,新增时请选择归属租户。编辑时密钥留空表示不修改。'
  26. : '这里显示的是本租户已配置的上游商户号(只读)。如需新增或修改,请联系平台运营。'
  27. "
  28. />
  29. <div class="mb-4">
  30. <el-form :inline="true" :model="searchForm">
  31. <!-- 租户筛选只对超管有意义:非超管的查询被后端按租户钉死,只会返回自己的行 -->
  32. <el-form-item v-if="isSuperAdmin" label="租户">
  33. <el-select
  34. v-model="searchForm.tenant_id"
  35. placeholder="全部租户"
  36. clearable
  37. filterable
  38. style="width: 180px"
  39. >
  40. <el-option
  41. v-for="t in tenantList"
  42. :key="t.id"
  43. :label="t.name || String(t.id)"
  44. :value="t.id"
  45. />
  46. </el-select>
  47. </el-form-item>
  48. <el-form-item label="商户号">
  49. <el-input
  50. v-model="searchForm.mch_no"
  51. placeholder="商户号"
  52. clearable
  53. style="width: 180px"
  54. @keyup.enter="handleSearch"
  55. />
  56. </el-form-item>
  57. <el-form-item label="商户名称">
  58. <el-input
  59. v-model="searchForm.merchant_name"
  60. placeholder="商户名称"
  61. clearable
  62. style="width: 180px"
  63. @keyup.enter="handleSearch"
  64. />
  65. </el-form-item>
  66. <el-form-item label="状态">
  67. <el-select
  68. v-model="searchForm.status"
  69. placeholder="全部状态"
  70. clearable
  71. style="width: 120px"
  72. >
  73. <el-option
  74. v-for="s in STATUS_OPTIONS"
  75. :key="s.value"
  76. :label="s.label"
  77. :value="s.value"
  78. />
  79. </el-select>
  80. </el-form-item>
  81. <el-form-item>
  82. <el-button type="primary" @click="handleSearch">查询</el-button>
  83. <el-button @click="handleReset">重置</el-button>
  84. </el-form-item>
  85. </el-form>
  86. </div>
  87. <el-table v-loading="loading" :data="list" border stripe>
  88. <template #empty>
  89. <el-empty description="暂无数据" />
  90. </template>
  91. <!-- 租户列只对超管有意义:非超管的查询被后端按租户钉死,整列都是同一个值 -->
  92. <el-table-column v-if="isSuperAdmin" label="租户" min-width="140">
  93. <template #default="{ row }">{{ tenantName(row.tenant_id) }}</template>
  94. </el-table-column>
  95. <el-table-column prop="mch_no" label="商户号" min-width="160" />
  96. <el-table-column label="商户名称" min-width="160">
  97. <template #default="{ row }">{{ row.merchant_name || "-" }}</template>
  98. </el-table-column>
  99. <el-table-column prop="app_id" label="应用 ID" min-width="200" show-overflow-tooltip />
  100. <el-table-column label="IfCode" width="130">
  101. <template #default="{ row }">{{ row.channel_user_if_code || "-" }}</template>
  102. </el-table-column>
  103. <el-table-column label="状态" width="90">
  104. <template #default="{ row }">
  105. <el-tag :type="row.status === '0' ? 'success' : 'info'">
  106. {{ row.status === "0" ? "启用" : "停用" }}
  107. </el-tag>
  108. </template>
  109. </el-table-column>
  110. <el-table-column label="更新时间" width="170">
  111. <template #default="{ row }">
  112. {{ row.updated_time ? dayjs(row.updated_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
  113. </template>
  114. </el-table-column>
  115. <el-table-column label="操作" width="170" fixed="right">
  116. <template #default="{ row }">
  117. <el-button v-if="isSuperAdmin" size="small" type="primary" @click="openEdit(row)">
  118. 编辑
  119. </el-button>
  120. <el-button v-if="isSuperAdmin" size="small" type="danger" @click="handleDelete(row)">
  121. 删除
  122. </el-button>
  123. <span v-if="!isSuperAdmin" class="readonly-hint">只读</span>
  124. </template>
  125. </el-table-column>
  126. </el-table>
  127. <div class="mt-4 flex justify-end">
  128. <el-pagination
  129. v-model:current-page="pageNo"
  130. v-model:page-size="pageSize"
  131. :total="total"
  132. :page-sizes="[10, 20, 50, 100]"
  133. layout="total, sizes, prev, pager, next, jumper"
  134. @size-change="load"
  135. @current-change="load"
  136. />
  137. </div>
  138. </el-card>
  139. <el-dialog
  140. v-model="dialogVisible"
  141. :title="form.id ? '编辑商户配置' : '新增商户配置'"
  142. width="600px"
  143. @closed="handleDialogClosed"
  144. >
  145. <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
  146. <!-- 只新增时显示:归属不可改(后端修改分支忽略 tenant_id),
  147. 旧数据即使没有 tenant_id 也不该挡住改备注 -->
  148. <el-form-item v-if="!form.id" label="租户" prop="tenant_id">
  149. <el-select
  150. v-model="form.tenant_id"
  151. placeholder="选择该配置归属的租户"
  152. filterable
  153. style="width: 100%"
  154. >
  155. <el-option
  156. v-for="t in tenantList"
  157. :key="t.id"
  158. :label="t.name || String(t.id)"
  159. :value="t.id"
  160. />
  161. </el-select>
  162. </el-form-item>
  163. <el-form-item label="商户号" prop="mch_no">
  164. <el-input
  165. v-model="form.mch_no"
  166. placeholder="上游分配的唯一商户号"
  167. maxlength="64"
  168. show-word-limit
  169. :disabled="!!form.id"
  170. />
  171. </el-form-item>
  172. <el-form-item label="应用 ID" prop="app_id">
  173. <el-input
  174. v-model="form.app_id"
  175. placeholder="上游分配的应用 ID"
  176. maxlength="64"
  177. :disabled="!!form.id"
  178. />
  179. <!-- 后端对「已有交易」的配置会拒绝换号;前端直接锁死输入,避免用户白填一次 -->
  180. <div v-if="form.id" class="form-hint">
  181. 已产生的交易会锁定商户号与应用 ID,如需更换请新建配置
  182. </div>
  183. </el-form-item>
  184. <el-form-item label="应用密钥" prop="app_secret">
  185. <!-- ★ 永不回显:响应里没有 app_secret,编辑时这里**始终是空的**。
  186. 留空 = 保留原值(后端按 blank 判断),不是「清空密钥」。
  187. autocomplete=new-password 防止浏览器把登录密码/旧密钥填进来。 -->
  188. <el-input
  189. v-model="form.app_secret"
  190. type="password"
  191. autocomplete="new-password"
  192. :placeholder="secretPlaceholder"
  193. maxlength="128"
  194. />
  195. <div v-if="form.id" class="form-hint">
  196. {{ editingSecretSet ? "已配置,留空不修改" : "尚未配置密钥,请填写" }}
  197. </div>
  198. </el-form-item>
  199. <el-form-item label="商户名称" prop="merchant_name">
  200. <el-input v-model="form.merchant_name" maxlength="128" />
  201. </el-form-item>
  202. <el-form-item label="渠道 IfCode" prop="channel_user_if_code">
  203. <el-select v-model="form.channel_user_if_code" style="width: 100%">
  204. <el-option
  205. v-for="o in CHANNEL_USER_IF_CODE_OPTIONS"
  206. :key="o.value"
  207. :label="o.label"
  208. :value="o.value"
  209. />
  210. </el-select>
  211. </el-form-item>
  212. <el-form-item label="状态" prop="status">
  213. <el-radio-group v-model="form.status">
  214. <el-radio v-for="s in STATUS_OPTIONS" :key="s.value" :value="s.value">
  215. {{ s.label }}
  216. </el-radio>
  217. </el-radio-group>
  218. </el-form-item>
  219. <el-form-item label="备注" prop="description">
  220. <el-input v-model="form.description" type="textarea" :rows="3" />
  221. </el-form-item>
  222. </el-form>
  223. <template #footer>
  224. <el-button @click="dialogVisible = false">取消</el-button>
  225. <el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
  226. </template>
  227. </el-dialog>
  228. </div>
  229. </template>
  230. <script setup lang="ts">
  231. // ★ 与 sys_menu.route_name 逐字一致(`db/unified-menu.sql`):
  232. // keep-alive 的 include 匹配的是组件名,不写这一行会按文件名推成 "index",
  233. // `keep_alive=true` 静默失效(乐收赢先例 bf09016)。
  234. defineOptions({
  235. name: "payment-unified-merchant",
  236. inheritAttrs: false,
  237. });
  238. import { computed, onMounted, reactive, ref } from "vue";
  239. import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
  240. import dayjs from "dayjs";
  241. import UnifiedAPI, {
  242. type UnifiedMerchantSaveParams,
  243. type UnifiedMerchantVO,
  244. } from "@/api/module_payment/unified";
  245. import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
  246. import { useUserStore } from "@/store";
  247. const userStore = useUserStore();
  248. /**
  249. * 超管判据 = `sys_user.is_superuser`,与后端 `@perm.hasRole('SUPERUSER')` 同一个来源。
  250. *
  251. * ★ 不要换成 `v-hasPerm`:那读的是角色/菜单权限串,与端点实际校验的**身份**不是一回事。
  252. * spec §9.5 也明确不建 `merchant:save` / `merchant:delete` 权限行。
  253. */
  254. const isSuperAdmin = computed(() => userStore.basicInfo.is_superuser === true);
  255. /** 状态枚举:商户配置行是字符串 "0" 启用 / "1" 停用(DDL 默认值 "0") */
  256. const STATUS_OPTIONS = [
  257. { value: "0", label: "启用" },
  258. { value: "1", label: "停用" },
  259. ];
  260. /** 渠道用户 ID 跳转的 ifCode(spec §4.7):服务商 / 自有公众号 / 自动 */
  261. const CHANNEL_USER_IF_CODE_OPTIONS = [
  262. { value: "fuioupay", label: "服务商模式(fuioupay)" },
  263. { value: "wxpay", label: "自有公众号(wxpay)" },
  264. { value: "AUTO", label: "自动(AUTO)" },
  265. ];
  266. /**
  267. * 租户下拉数据。只有超管会用到,非超管不请求(那个接口对他们也未必有权限)。
  268. *
  269. * ★ 类型把 `id` 收窄成**必有**:`TenantTable.id` 是可选的 `number | undefined`,
  270. * 而 `el-option` 的 `value` 不收 `undefined`(vue-tsc TS2322)。
  271. * 下拉里出现一个选不中的租户在赋值那一刻就不成立(下面 onMounted 里过滤)。
  272. */
  273. const tenantList = ref<(TenantTable & { id: number })[]>([]);
  274. const tenantName = (id?: number) => {
  275. if (id === undefined || id === null) {
  276. return "-";
  277. }
  278. const t = tenantList.value.find((x) => x.id === id);
  279. return t ? t.name || String(id) : String(id);
  280. };
  281. const list = ref<UnifiedMerchantVO[]>([]);
  282. const total = ref(0);
  283. const pageNo = ref(1);
  284. const pageSize = ref(20);
  285. const loading = ref(false);
  286. const dialogVisible = ref(false);
  287. const saving = ref(false);
  288. const formRef = ref<FormInstance>();
  289. /** 编辑中的那一行是否已配置密钥 —— 只用于提示文案,密钥本身永远不回传/不回显 */
  290. const editingSecretSet = ref(false);
  291. const searchForm = reactive({
  292. tenant_id: undefined as number | undefined,
  293. mch_no: "",
  294. merchant_name: "",
  295. status: undefined as string | undefined,
  296. });
  297. const defaultForm = (): UnifiedMerchantSaveParams => ({
  298. id: undefined,
  299. tenant_id: undefined,
  300. mch_no: "",
  301. app_id: "",
  302. app_secret: "",
  303. merchant_name: "",
  304. channel_user_if_code: "fuioupay",
  305. status: "0",
  306. description: "",
  307. });
  308. const form = reactive<UnifiedMerchantSaveParams>(defaultForm());
  309. /**
  310. * 密钥提示:新增必填;编辑时「已配置,留空不修改」/「尚未配置,请填写」。
  311. * ★ 这里只说状态,不提供查看原密钥的入口 —— 响应里根本没有这个字段。
  312. */
  313. const secretPlaceholder = computed(() => {
  314. if (!form.id) {
  315. return "请输入上游应用密钥";
  316. }
  317. return editingSecretSet.value ? "已配置,留空不修改" : "未配置,请填写";
  318. });
  319. /**
  320. * 租户只在**新增**时必填:后端对修改一律忽略 tenant_id(归属不可改),
  321. * 编辑态那一格整个不渲染,校验它没有意义。
  322. * 新增时 app_secret 必填(后端要求 tenant_id/mch_no/app_id/app_secret)。
  323. */
  324. const rules = computed<FormRules>(() => ({
  325. tenant_id: form.id ? [] : [{ required: true, message: "请选择租户", trigger: "change" }],
  326. mch_no: [{ required: true, message: "请填写商户号", trigger: "blur" }],
  327. app_id: [{ required: true, message: "请填写应用 ID", trigger: "blur" }],
  328. app_secret: form.id ? [] : [{ required: true, message: "请填写应用密钥", trigger: "blur" }],
  329. }));
  330. async function load() {
  331. loading.value = true;
  332. try {
  333. const res = await UnifiedAPI.merchantPage({
  334. tenant_id: searchForm.tenant_id || undefined,
  335. mch_no: searchForm.mch_no || undefined,
  336. merchant_name: searchForm.merchant_name || undefined,
  337. status: searchForm.status || undefined,
  338. page_no: pageNo.value,
  339. page_size: pageSize.value,
  340. });
  341. // 后端序列化的分页键是 `list`,全局 TS 声明写的是 `items` —— 两个都读,
  342. // 只读 items 会静默拿到空数组(HAR 商户页同一个坑)。
  343. list.value = res.data.data?.list || res.data.data?.items || [];
  344. total.value = res.data.data?.total || 0;
  345. } finally {
  346. loading.value = false;
  347. }
  348. }
  349. function handleSearch() {
  350. pageNo.value = 1;
  351. load();
  352. }
  353. function handleReset() {
  354. searchForm.tenant_id = undefined;
  355. searchForm.mch_no = "";
  356. searchForm.merchant_name = "";
  357. searchForm.status = undefined;
  358. handleSearch();
  359. }
  360. function openCreate() {
  361. editingSecretSet.value = false;
  362. Object.assign(form, defaultForm());
  363. dialogVisible.value = true;
  364. }
  365. function openEdit(row: UnifiedMerchantVO) {
  366. editingSecretSet.value = row.app_secret_set === true;
  367. // ★ app_secret 有意不取:响应里没有它,表单里它永远是空的(留空 = 不修改)
  368. Object.assign(form, defaultForm(), {
  369. id: row.id,
  370. tenant_id: row.tenant_id,
  371. mch_no: row.mch_no,
  372. app_id: row.app_id,
  373. merchant_name: row.merchant_name,
  374. channel_user_if_code: row.channel_user_if_code || "fuioupay",
  375. status: row.status || "0",
  376. description: row.description,
  377. });
  378. dialogVisible.value = true;
  379. }
  380. function handleDialogClosed() {
  381. formRef.value?.clearValidate();
  382. editingSecretSet.value = false;
  383. Object.assign(form, defaultForm());
  384. }
  385. async function handleSubmit() {
  386. const valid = await formRef.value?.validate().catch(() => false);
  387. if (!valid) {
  388. return;
  389. }
  390. const payload: UnifiedMerchantSaveParams = { ...form };
  391. // 编辑时留空 = 保留原值:不发空串,语义更明确(后端两种都按 blank 处理)
  392. if (payload.id && !payload.app_secret) {
  393. delete payload.app_secret;
  394. }
  395. saving.value = true;
  396. try {
  397. await UnifiedAPI.merchantSave(payload);
  398. ElMessage.success("保存成功");
  399. dialogVisible.value = false;
  400. load();
  401. } finally {
  402. saving.value = false;
  403. }
  404. }
  405. async function handleDelete(row: UnifiedMerchantVO) {
  406. const owner = isSuperAdmin.value ? `(租户 ${tenantName(row.tenant_id)})` : "";
  407. try {
  408. await ElMessageBox.confirm(
  409. `删除后该商户号将无法下单。若已有交易,后端会拒绝删除,请改用「停用」。确定删除「${row.mch_no}」${owner}?`,
  410. "提示",
  411. { type: "warning" }
  412. );
  413. } catch {
  414. return;
  415. }
  416. try {
  417. await UnifiedAPI.merchantDelete(row.id as string);
  418. } catch {
  419. // 拦截器已经用后端原话弹过「已有交易,不能删除,请停用」(400),
  420. // 这里不再叠一条 toast;列表保持不变,运营可以改为编辑 → 停用。
  421. return;
  422. }
  423. ElMessage.success("删除成功");
  424. load();
  425. }
  426. onMounted(async () => {
  427. load();
  428. if (isSuperAdmin.value) {
  429. try {
  430. const res = await TenantAPI.listTenant({ page_no: 1, page_size: 100 });
  431. // 后端分页键是 `list`,全局声明写的是 `items` —— 两个都读(同 load)
  432. const page = res.data.data as unknown as {
  433. list?: TenantTable[];
  434. items?: TenantTable[];
  435. };
  436. tenantList.value = (page?.list || page?.items || []).filter(
  437. (t): t is TenantTable & { id: number } => typeof t.id === "number"
  438. );
  439. } catch {
  440. // 租户列表加载失败不阻塞列表查询(租户列退化成显示 tenant_id 数字)
  441. }
  442. }
  443. });
  444. </script>
  445. <style lang="scss" scoped>
  446. .card-header {
  447. display: flex;
  448. justify-content: space-between;
  449. align-items: center;
  450. }
  451. .form-hint {
  452. margin-top: 2px;
  453. font-size: 12px;
  454. line-height: 1.5;
  455. color: var(--el-text-color-secondary);
  456. }
  457. .readonly-hint {
  458. color: var(--el-text-color-secondary);
  459. }
  460. </style>