| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492 |
- <template>
- <!-- 单根节点:AppMain 的 <transition mode="out-in"> 要求组件根为元素,
- 多根 fragment 会导致切换动画无法完成、后续路由挂载卡死 -->
- <div class="unified-merchant">
- <el-card>
- <template #header>
- <div class="card-header">
- <span>商户配置</span>
- <!-- 只有超管能建号:判据用的是 sys_user.is_superuser,与后端
- @PreAuthorize("@perm.hasRole('SUPERUSER')") 是同一个 —— 不要改用
- v-hasPerm(那判的是角色授权,与端点实际校验的身份不是一回事)。
- spec §9.5 明确不为 save/delete 造权限点。 -->
- <el-button v-if="isSuperAdmin" type="primary" icon="Plus" @click="openCreate">
- 新增配置
- </el-button>
- </div>
- </template>
- <el-alert
- class="mb-4"
- type="info"
- :closable="false"
- show-icon
- :title="
- isSuperAdmin
- ? '上游商户号与应用密钥由运营完成报备后录入;商户号全局唯一,新增时请选择归属租户。编辑时密钥留空表示不修改。'
- : '这里显示的是本租户已配置的上游商户号(只读)。如需新增或修改,请联系平台运营。'
- "
- />
- <div class="mb-4">
- <el-form :inline="true" :model="searchForm">
- <!-- 租户筛选只对超管有意义:非超管的查询被后端按租户钉死,只会返回自己的行 -->
- <el-form-item v-if="isSuperAdmin" label="租户">
- <el-select
- v-model="searchForm.tenant_id"
- placeholder="全部租户"
- clearable
- filterable
- style="width: 180px"
- >
- <el-option
- v-for="t in tenantList"
- :key="t.id"
- :label="t.name || String(t.id)"
- :value="t.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="商户号">
- <el-input
- v-model="searchForm.mch_no"
- placeholder="商户号"
- clearable
- style="width: 180px"
- @keyup.enter="handleSearch"
- />
- </el-form-item>
- <el-form-item label="商户名称">
- <el-input
- v-model="searchForm.merchant_name"
- placeholder="商户名称"
- clearable
- style="width: 180px"
- @keyup.enter="handleSearch"
- />
- </el-form-item>
- <el-form-item label="状态">
- <el-select
- v-model="searchForm.status"
- placeholder="全部状态"
- clearable
- style="width: 120px"
- >
- <el-option
- v-for="s in STATUS_OPTIONS"
- :key="s.value"
- :label="s.label"
- :value="s.value"
- />
- </el-select>
- </el-form-item>
- <el-form-item>
- <el-button type="primary" @click="handleSearch">查询</el-button>
- <el-button @click="handleReset">重置</el-button>
- </el-form-item>
- </el-form>
- </div>
- <el-table v-loading="loading" :data="list" border stripe>
- <template #empty>
- <el-empty description="暂无数据" />
- </template>
- <!-- 租户列只对超管有意义:非超管的查询被后端按租户钉死,整列都是同一个值 -->
- <el-table-column v-if="isSuperAdmin" label="租户" min-width="140">
- <template #default="{ row }">{{ tenantName(row.tenant_id) }}</template>
- </el-table-column>
- <el-table-column prop="mch_no" label="商户号" min-width="160" />
- <el-table-column label="商户名称" min-width="160">
- <template #default="{ row }">{{ row.merchant_name || "-" }}</template>
- </el-table-column>
- <el-table-column prop="app_id" label="应用 ID" min-width="200" show-overflow-tooltip />
- <el-table-column label="IfCode" width="130">
- <template #default="{ row }">{{ row.channel_user_if_code || "-" }}</template>
- </el-table-column>
- <el-table-column label="状态" width="90">
- <template #default="{ row }">
- <el-tag :type="row.status === '0' ? 'success' : 'info'">
- {{ row.status === "0" ? "启用" : "停用" }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column label="更新时间" width="170">
- <template #default="{ row }">
- {{ row.updated_time ? dayjs(row.updated_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
- </template>
- </el-table-column>
- <el-table-column label="操作" width="170" fixed="right">
- <template #default="{ row }">
- <el-button v-if="isSuperAdmin" size="small" type="primary" @click="openEdit(row)">
- 编辑
- </el-button>
- <el-button v-if="isSuperAdmin" size="small" type="danger" @click="handleDelete(row)">
- 删除
- </el-button>
- <span v-if="!isSuperAdmin" class="readonly-hint">只读</span>
- </template>
- </el-table-column>
- </el-table>
- <div class="mt-4 flex justify-end">
- <el-pagination
- v-model:current-page="pageNo"
- v-model:page-size="pageSize"
- :total="total"
- :page-sizes="[10, 20, 50, 100]"
- layout="total, sizes, prev, pager, next, jumper"
- @size-change="load"
- @current-change="load"
- />
- </div>
- </el-card>
- <el-dialog
- v-model="dialogVisible"
- :title="form.id ? '编辑商户配置' : '新增商户配置'"
- width="600px"
- @closed="handleDialogClosed"
- >
- <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
- <!-- 只新增时显示:归属不可改(后端修改分支忽略 tenant_id),
- 旧数据即使没有 tenant_id 也不该挡住改备注 -->
- <el-form-item v-if="!form.id" label="租户" prop="tenant_id">
- <el-select
- v-model="form.tenant_id"
- placeholder="选择该配置归属的租户"
- filterable
- 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>
- <el-form-item label="商户号" prop="mch_no">
- <el-input
- v-model="form.mch_no"
- placeholder="上游分配的唯一商户号"
- maxlength="64"
- show-word-limit
- :disabled="!!form.id"
- />
- </el-form-item>
- <el-form-item label="应用 ID" prop="app_id">
- <el-input
- v-model="form.app_id"
- placeholder="上游分配的应用 ID"
- maxlength="64"
- :disabled="!!form.id"
- />
- <!-- 后端对「已有交易」的配置会拒绝换号;前端直接锁死输入,避免用户白填一次 -->
- <div v-if="form.id" class="form-hint">
- 已产生的交易会锁定商户号与应用 ID,如需更换请新建配置
- </div>
- </el-form-item>
- <el-form-item label="应用密钥" prop="app_secret">
- <!-- ★ 永不回显:响应里没有 app_secret,编辑时这里**始终是空的**。
- 留空 = 保留原值(后端按 blank 判断),不是「清空密钥」。
- autocomplete=new-password 防止浏览器把登录密码/旧密钥填进来。 -->
- <el-input
- v-model="form.app_secret"
- type="password"
- autocomplete="new-password"
- :placeholder="secretPlaceholder"
- maxlength="128"
- />
- <div v-if="form.id" class="form-hint">
- {{ editingSecretSet ? "已配置,留空不修改" : "尚未配置密钥,请填写" }}
- </div>
- </el-form-item>
- <el-form-item label="商户名称" prop="merchant_name">
- <el-input v-model="form.merchant_name" maxlength="128" />
- </el-form-item>
- <el-form-item label="渠道 IfCode" prop="channel_user_if_code">
- <el-select v-model="form.channel_user_if_code" style="width: 100%">
- <el-option
- v-for="o in CHANNEL_USER_IF_CODE_OPTIONS"
- :key="o.value"
- :label="o.label"
- :value="o.value"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="状态" prop="status">
- <el-radio-group v-model="form.status">
- <el-radio v-for="s in STATUS_OPTIONS" :key="s.value" :value="s.value">
- {{ s.label }}
- </el-radio>
- </el-radio-group>
- </el-form-item>
- <el-form-item label="备注" prop="description">
- <el-input v-model="form.description" type="textarea" :rows="3" />
- </el-form-item>
- </el-form>
- <template #footer>
- <el-button @click="dialogVisible = false">取消</el-button>
- <el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
- </template>
- </el-dialog>
- </div>
- </template>
- <script setup lang="ts">
- // ★ 与 sys_menu.route_name 逐字一致(`db/unified-menu.sql`):
- // keep-alive 的 include 匹配的是组件名,不写这一行会按文件名推成 "index",
- // `keep_alive=true` 静默失效(乐收赢先例 bf09016)。
- defineOptions({
- name: "payment-unified-merchant",
- inheritAttrs: false,
- });
- import { computed, onMounted, reactive, ref } from "vue";
- import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
- import dayjs from "dayjs";
- import UnifiedAPI, {
- type UnifiedMerchantSaveParams,
- type UnifiedMerchantVO,
- } from "@/api/module_payment/unified";
- 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`:那读的是角色/菜单权限串,与端点实际校验的**身份**不是一回事。
- * spec §9.5 也明确不建 `merchant:save` / `merchant:delete` 权限行。
- */
- const isSuperAdmin = computed(() => userStore.basicInfo.is_superuser === true);
- /** 状态枚举:商户配置行是字符串 "0" 启用 / "1" 停用(DDL 默认值 "0") */
- const STATUS_OPTIONS = [
- { value: "0", label: "启用" },
- { value: "1", label: "停用" },
- ];
- /** 渠道用户 ID 跳转的 ifCode(spec §4.7):服务商 / 自有公众号 / 自动 */
- const CHANNEL_USER_IF_CODE_OPTIONS = [
- { value: "fuioupay", label: "服务商模式(fuioupay)" },
- { value: "wxpay", label: "自有公众号(wxpay)" },
- { value: "AUTO", label: "自动(AUTO)" },
- ];
- /**
- * 租户下拉数据。只有超管会用到,非超管不请求(那个接口对他们也未必有权限)。
- *
- * ★ 类型把 `id` 收窄成**必有**:`TenantTable.id` 是可选的 `number | undefined`,
- * 而 `el-option` 的 `value` 不收 `undefined`(vue-tsc TS2322)。
- * 下拉里出现一个选不中的租户在赋值那一刻就不成立(下面 onMounted 里过滤)。
- */
- 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);
- };
- const list = ref<UnifiedMerchantVO[]>([]);
- const total = ref(0);
- const pageNo = ref(1);
- const pageSize = ref(20);
- const loading = ref(false);
- const dialogVisible = ref(false);
- const saving = ref(false);
- const formRef = ref<FormInstance>();
- /** 编辑中的那一行是否已配置密钥 —— 只用于提示文案,密钥本身永远不回传/不回显 */
- const editingSecretSet = ref(false);
- const searchForm = reactive({
- tenant_id: undefined as number | undefined,
- mch_no: "",
- merchant_name: "",
- status: undefined as string | undefined,
- });
- const defaultForm = (): UnifiedMerchantSaveParams => ({
- id: undefined,
- tenant_id: undefined,
- mch_no: "",
- app_id: "",
- app_secret: "",
- merchant_name: "",
- channel_user_if_code: "fuioupay",
- status: "0",
- description: "",
- });
- const form = reactive<UnifiedMerchantSaveParams>(defaultForm());
- /**
- * 密钥提示:新增必填;编辑时「已配置,留空不修改」/「尚未配置,请填写」。
- * ★ 这里只说状态,不提供查看原密钥的入口 —— 响应里根本没有这个字段。
- */
- const secretPlaceholder = computed(() => {
- if (!form.id) {
- return "请输入上游应用密钥";
- }
- return editingSecretSet.value ? "已配置,留空不修改" : "未配置,请填写";
- });
- /**
- * 租户只在**新增**时必填:后端对修改一律忽略 tenant_id(归属不可改),
- * 编辑态那一格整个不渲染,校验它没有意义。
- * 新增时 app_secret 必填(后端要求 tenant_id/mch_no/app_id/app_secret)。
- */
- const rules = computed<FormRules>(() => ({
- tenant_id: form.id ? [] : [{ required: true, message: "请选择租户", trigger: "change" }],
- mch_no: [{ required: true, message: "请填写商户号", trigger: "blur" }],
- app_id: [{ required: true, message: "请填写应用 ID", trigger: "blur" }],
- app_secret: form.id ? [] : [{ required: true, message: "请填写应用密钥", trigger: "blur" }],
- }));
- async function load() {
- loading.value = true;
- try {
- const res = await UnifiedAPI.merchantPage({
- tenant_id: searchForm.tenant_id || undefined,
- mch_no: searchForm.mch_no || undefined,
- merchant_name: searchForm.merchant_name || undefined,
- status: searchForm.status || undefined,
- page_no: pageNo.value,
- page_size: pageSize.value,
- });
- // 后端序列化的分页键是 `list`,全局 TS 声明写的是 `items` —— 两个都读,
- // 只读 items 会静默拿到空数组(HAR 商户页同一个坑)。
- list.value = res.data.data?.list || res.data.data?.items || [];
- total.value = res.data.data?.total || 0;
- } finally {
- loading.value = false;
- }
- }
- function handleSearch() {
- pageNo.value = 1;
- load();
- }
- function handleReset() {
- searchForm.tenant_id = undefined;
- searchForm.mch_no = "";
- searchForm.merchant_name = "";
- searchForm.status = undefined;
- handleSearch();
- }
- function openCreate() {
- editingSecretSet.value = false;
- Object.assign(form, defaultForm());
- dialogVisible.value = true;
- }
- function openEdit(row: UnifiedMerchantVO) {
- editingSecretSet.value = row.app_secret_set === true;
- // ★ app_secret 有意不取:响应里没有它,表单里它永远是空的(留空 = 不修改)
- Object.assign(form, defaultForm(), {
- id: row.id,
- tenant_id: row.tenant_id,
- mch_no: row.mch_no,
- app_id: row.app_id,
- merchant_name: row.merchant_name,
- channel_user_if_code: row.channel_user_if_code || "fuioupay",
- status: row.status || "0",
- description: row.description,
- });
- dialogVisible.value = true;
- }
- function handleDialogClosed() {
- formRef.value?.clearValidate();
- editingSecretSet.value = false;
- Object.assign(form, defaultForm());
- }
- async function handleSubmit() {
- const valid = await formRef.value?.validate().catch(() => false);
- if (!valid) {
- return;
- }
- const payload: UnifiedMerchantSaveParams = { ...form };
- // 编辑时留空 = 保留原值:不发空串,语义更明确(后端两种都按 blank 处理)
- if (payload.id && !payload.app_secret) {
- delete payload.app_secret;
- }
- saving.value = true;
- try {
- await UnifiedAPI.merchantSave(payload);
- ElMessage.success("保存成功");
- dialogVisible.value = false;
- load();
- } finally {
- saving.value = false;
- }
- }
- async function handleDelete(row: UnifiedMerchantVO) {
- const owner = isSuperAdmin.value ? `(租户 ${tenantName(row.tenant_id)})` : "";
- try {
- await ElMessageBox.confirm(
- `删除后该商户号将无法下单。若已有交易,后端会拒绝删除,请改用「停用」。确定删除「${row.mch_no}」${owner}?`,
- "提示",
- { type: "warning" }
- );
- } catch {
- return;
- }
- try {
- await UnifiedAPI.merchantDelete(row.id as string);
- } catch {
- // 拦截器已经用后端原话弹过「已有交易,不能删除,请停用」(400),
- // 这里不再叠一条 toast;列表保持不变,运营可以改为编辑 → 停用。
- return;
- }
- ElMessage.success("删除成功");
- load();
- }
- onMounted(async () => {
- load();
- if (isSuperAdmin.value) {
- try {
- const res = await TenantAPI.listTenant({ page_no: 1, page_size: 100 });
- // 后端分页键是 `list`,全局声明写的是 `items` —— 两个都读(同 load)
- const page = res.data.data as unknown as {
- list?: TenantTable[];
- items?: TenantTable[];
- };
- 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>
- .card-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .form-hint {
- margin-top: 2px;
- font-size: 12px;
- line-height: 1.5;
- color: var(--el-text-color-secondary);
- }
- .readonly-hint {
- color: var(--el-text-color-secondary);
- }
- </style>
|