增加租户套餐

This commit is contained in:
2026-09-15 17:28:08 +08:00
parent a82b300b1a
commit 71a14c46f9
37 changed files with 3855 additions and 178 deletions
+5
View File
@@ -24,6 +24,8 @@ declare module 'vue' {
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
@@ -49,6 +51,7 @@ declare module 'vue' {
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
@@ -65,6 +68,8 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
+135
View File
@@ -0,0 +1,135 @@
import request from "@/utils/request";
/*************************************************
* 租户套餐(功能开通 + 用户数配额)相关接口
*************************************************/
/** 套餐列表 */
export function getTenantPackageList(params) {
return request({
url: "/platform/tenantPackage/list",
method: "get",
params,
});
}
/** 套餐下拉(仅启用套餐) */
export function getTenantPackageSelectList() {
return request({
url: "/platform/tenantPackage/select/list",
method: "get",
});
}
/** 套餐详情(含功能模块) */
export function getTenantPackageDetail(id) {
return request({
url: `/platform/tenantPackage/detail/${id}`,
method: "get",
});
}
/** 可加入套餐的功能模块清单 */
export function getTenantPackageModuleOptions() {
return request({
url: "/platform/tenantPackage/moduleOptions",
method: "get",
});
}
/** 新增套餐 */
export function createTenantPackage(data) {
return request({
url: "/platform/tenantPackage/create",
method: "post",
data,
});
}
/** 编辑套餐 */
export function updateTenantPackage(id, data) {
return request({
url: `/platform/tenantPackage/edit/${id}`,
method: "post",
data,
});
}
/** 删除套餐 */
export function deleteTenantPackage(id) {
return request({
url: `/platform/tenantPackage/delete/${id}`,
method: "delete",
});
}
/** 用户数加购规格列表(enabled=1 仅启用) */
export function getQuotaPackageList(params) {
return request({
url: "/platform/tenantQuotaPackage/list",
method: "get",
params,
});
}
/** 新增加购规格 */
export function createQuotaPackage(data) {
return request({
url: "/platform/tenantQuotaPackage/create",
method: "post",
data,
});
}
/** 编辑加购规格 */
export function updateQuotaPackage(id, data) {
return request({
url: `/platform/tenantQuotaPackage/edit/${id}`,
method: "post",
data,
});
}
/** 删除加购规格 */
export function deleteQuotaPackage(id) {
return request({
url: `/platform/tenantQuotaPackage/delete/${id}`,
method: "delete",
});
}
/** 为租户设置套餐 */
export function setTenantPackage(data) {
return request({
url: "/platform/tenant/setPackage",
method: "post",
data,
});
}
/** 租户用户数配额信息(含套餐、已用、可加购规格) */
export function getTenantQuotaInfo(tid) {
return request({
url: "/platform/tenant/quotaInfo",
method: "get",
params: { tid },
});
}
/** 租户用户数增购(type=1 单个增购 / type=2 按加购规格) */
export function rechargeTenantQuota(data) {
return request({
url: "/platform/tenant/rechargeQuota",
method: "post",
data,
});
}
/** 租户用户数增购记录 */
export function getTenantQuotaOrders(tid) {
return request({
url: "/platform/tenant/quotaOrders",
method: "get",
params: { tid },
});
}
@@ -0,0 +1,311 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑租户套餐' : '添加租户套餐'"
width="680px"
@close="handleClose"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="套餐名称" prop="name">
<el-input v-model="form.name" placeholder="如:ERP套餐" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="套餐编码" prop="code">
<el-input
v-model="form.code"
placeholder="程序识别用,如 erp"
maxlength="50"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="功能模块" prop="modules">
<div class="module-box">
<div class="module-toolbar">
<el-checkbox
:model-value="isAllModuleChecked"
:indeterminate="isModuleIndeterminate"
@change="handleCheckAllModules"
>
全选
</el-checkbox>
<span class="module-tip">勾选后,绑定该套餐的租户可看到对应功能菜单</span>
</div>
<el-checkbox-group v-model="form.modules" class="module-group">
<el-checkbox
v-for="item in moduleOptions"
:key="item.code"
:label="item.code"
:disabled="item.status === 0"
>
<span class="module-name">{{ item.name }}</span>
<span class="module-code">{{ item.path || item.code }}</span>
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="moduleOptions.length === 0" description="暂无可选功能模块" :image-size="60" />
</div>
</el-form-item>
<el-form-item label="包含用户数" prop="user_quota">
<el-input-number v-model="form.user_quota" :min="1" :max="99999" controls-position="right" />
<span class="field-tip">租户绑定套餐后的用户数上限(默认 20)</span>
</el-form-item>
<el-form-item label="单用户增购价" prop="extra_user_price">
<el-input-number
v-model="form.extra_user_price"
:min="0"
:max="999999"
:precision="2"
controls-position="right"
/>
<span class="field-tip">元/人,单个增购用户数时的默认单价(如 200)</span>
</el-form-item>
<el-form-item label="套餐售价" prop="price">
<el-input-number
v-model="form.price"
:min="0"
:max="9999999"
:precision="2"
controls-position="right"
/>
<span class="field-tip">元,仅作记录展示,可填 0</span>
</el-form-item>
<el-form-item label="套餐说明" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="套餐包含的功能说明"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
</el-form-item>
<el-form-item label="设为默认套餐" prop="is_default">
<el-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
<span class="field-tip">新建租户未指定套餐时默认使用</span>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" maxlength="200" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from "vue";
import { ElMessage } from "element-plus";
import {
createTenantPackage,
updateTenantPackage,
getTenantPackageDetail,
getTenantPackageModuleOptions,
} from "@/api/tenantPackage";
const props = withDefaults(
defineProps<{
modelValue: boolean;
pkg?: any;
}>(),
{
modelValue: false,
pkg: null,
}
);
const emit = defineEmits(["update:modelValue", "success"]);
const visible = ref(false);
const isEdit = ref(false);
const submitting = ref(false);
const formRef = ref();
const moduleOptions = ref<any[]>([]);
const emptyForm = () => ({
id: 0,
name: "",
code: "",
description: "",
price: 0,
user_quota: 20,
extra_user_price: 200,
is_default: 0,
sort: 0,
status: 1,
remark: "",
modules: [] as string[],
});
const form = ref<any>(emptyForm());
const rules = {
name: [{ required: true, message: "请输入套餐名称", trigger: "blur" }],
code: [{ required: true, message: "请输入套餐编码", trigger: "blur" }],
modules: [
{
validator: (_rule: any, _value: any, callback: any) => {
if (form.value.modules.length === 0) {
callback(new Error("请至少勾选一个功能模块"));
return;
}
callback();
},
trigger: "change",
},
],
};
const isAllModuleChecked = computed(
() => moduleOptions.value.length > 0 && form.value.modules.length === moduleOptions.value.length
);
const isModuleIndeterminate = computed(
() => form.value.modules.length > 0 && form.value.modules.length < moduleOptions.value.length
);
const handleCheckAllModules = (checked: boolean) => {
form.value.modules = checked ? moduleOptions.value.map((m) => m.code) : [];
};
const loadModuleOptions = async () => {
try {
const res = await getTenantPackageModuleOptions();
if (res.code === 200) {
moduleOptions.value = res.data?.list || [];
}
} catch {
moduleOptions.value = [];
}
};
const loadDetail = async (id: number) => {
const res = await getTenantPackageDetail(id);
if (res.code === 200 && res.data) {
const d = res.data;
const codes = (d.modules || []).map((m: any) => m.module_code);
form.value = {
id: d.id,
name: d.name || "",
code: d.code || "",
description: d.description || "",
price: Number(d.price || 0),
user_quota: Number(d.user_quota || 20),
extra_user_price: Number(d.extra_user_price || 0),
is_default: Number(d.is_default || 0),
sort: Number(d.sort || 0),
status: Number(d.status ?? 1),
remark: d.remark || "",
modules: codes,
};
}
};
watch(
() => props.modelValue,
async (val) => {
visible.value = val;
if (!val) return;
await loadModuleOptions();
if (props.pkg) {
isEdit.value = true;
await loadDetail(props.pkg.id);
} else {
isEdit.value = false;
form.value = emptyForm();
}
nextTick(() => formRef.value?.clearValidate());
}
);
watch(visible, (val) => emit("update:modelValue", val));
const handleClose = () => {
visible.value = false;
form.value = emptyForm();
formRef.value?.clearValidate();
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
submitting.value = true;
try {
const payload = { ...form.value };
delete payload.id;
const res = isEdit.value
? await updateTenantPackage(form.value.id, payload)
: await createTenantPackage(payload);
if (res.code === 200) {
ElMessage.success(isEdit.value ? "保存成功" : "添加成功");
emit("success");
handleClose();
} else {
ElMessage.error(res.msg || "操作失败");
}
} catch (error: any) {
ElMessage.error(error?.message || "操作失败");
} finally {
submitting.value = false;
}
};
</script>
<style scoped>
.module-box {
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: 4px;
padding: 10px 12px;
}
.module-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.module-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.module-group {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.module-name {
margin-right: 6px;
}
.module-code {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.field-tip {
margin-left: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,159 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑用户数加购套餐' : '添加用户数加购套餐'"
width="520px"
@close="handleClose"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="110px">
<el-form-item label="规格名称" prop="name">
<el-input v-model="form.name" placeholder="如:5人套餐" maxlength="50" />
</el-form-item>
<el-form-item label="包含用户数" prop="user_count">
<el-input-number v-model="form.user_count" :min="1" :max="99999" controls-position="right" />
<span class="field-tip">个,增购一次增加的用户数</span>
</el-form-item>
<el-form-item label="售价" prop="price">
<el-input-number
v-model="form.price"
:min="0"
:max="9999999"
:precision="2"
controls-position="right"
/>
<span class="field-tip">元,如 5 人 500 元</span>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" maxlength="200" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { createQuotaPackage, updateQuotaPackage } from "@/api/tenantPackage";
const props = withDefaults(
defineProps<{
modelValue: boolean;
item?: any;
}>(),
{
modelValue: false,
item: null,
}
);
const emit = defineEmits(["update:modelValue", "success"]);
const visible = ref(false);
const isEdit = ref(false);
const submitting = ref(false);
const formRef = ref();
const emptyForm = () => ({
id: 0,
name: "",
user_count: 5,
price: 0,
sort: 0,
status: 1,
remark: "",
});
const form = ref<any>(emptyForm());
const rules = {
name: [{ required: true, message: "请输入规格名称", trigger: "blur" }],
user_count: [{ required: true, message: "请输入包含用户数", trigger: "blur" }],
};
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (!val) return;
if (props.item) {
isEdit.value = true;
form.value = {
id: props.item.id,
name: props.item.name || "",
user_count: Number(props.item.user_count || 1),
price: Number(props.item.price || 0),
sort: Number(props.item.sort || 0),
status: Number(props.item.status ?? 1),
remark: props.item.remark || "",
};
} else {
isEdit.value = false;
form.value = emptyForm();
}
formRef.value?.clearValidate();
}
);
watch(visible, (val) => emit("update:modelValue", val));
const handleClose = () => {
visible.value = false;
form.value = emptyForm();
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
submitting.value = true;
try {
const payload = { ...form.value };
delete payload.id;
const res = isEdit.value
? await updateQuotaPackage(form.value.id, payload)
: await createQuotaPackage(payload);
if (res.code === 200) {
ElMessage.success(isEdit.value ? "保存成功" : "添加成功");
emit("success");
handleClose();
} else {
ElMessage.error(res.msg || "操作失败");
}
} catch (error: any) {
ElMessage.error(error?.message || "操作失败");
} finally {
submitting.value = false;
}
};
</script>
<style scoped>
.field-tip {
margin-left: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,321 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>租户套餐</h2>
<div class="header-actions">
<el-button v-if="activeTab === 'package'" type="primary" @click="handleAddPackage">
<el-icon><Plus /></el-icon>
添加套餐
</el-button>
<el-button v-else type="primary" @click="handleAddQuota">
<el-icon><Plus /></el-icon>
添加加购套餐
</el-button>
<el-button @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<el-alert type="info" :closable="false" show-icon class="tip-alert">
<template #default>
<p>1、套餐决定租户能开通哪些功能:勾选「功能模块」后,绑定该套餐的租户端只展示对应功能菜单(如 ERP 套餐开通进销存)。</p>
<p>2、所有租户都有用户数上限(默认 20 人):达到上限后无法再开账号,可在「租户管理 → 租户详情 → 套餐与用户数」中按单个(如 200 元/人)或加购套餐(如 5 人 500 元)增购。</p>
</template>
</el-alert>
<el-tabs v-model="activeTab" class="package-tabs">
<!-- 功能套餐 -->
<el-tab-pane label="功能套餐" name="package">
<el-table :data="packages" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="name" label="套餐名称" min-width="150" align="center">
<template #default="{ row }">
<span>{{ row.name }}</span>
<el-tag v-if="Number(row.is_default) === 1" type="success" size="small" class="ml6">
默认
</el-tag>
</template>
</el-table-column>
<el-table-column prop="code" label="套餐编码" width="120" align="center">
<template #default="{ row }">
<el-tag size="small" type="info" effect="plain">{{ row.code }}</el-tag>
</template>
</el-table-column>
<el-table-column label="开通功能" min-width="260">
<template #default="{ row }">
<el-tag
v-for="m in row.modules"
:key="m.module_code"
size="small"
class="module-tag"
>
{{ m.module_name || m.module_code }}
</el-tag>
<span v-if="!row.modules || row.modules.length === 0" class="muted">未配置功能</span>
</template>
</el-table-column>
<el-table-column prop="user_quota" label="包含用户数" width="110" align="center">
<template #default="{ row }">{{ row.user_quota }} 人</template>
</el-table-column>
<el-table-column label="单用户增购价" width="120" align="center">
<template #default="{ row }">{{ formatMoney(row.extra_user_price) }}</template>
</el-table-column>
<el-table-column prop="tenant_count" label="使用租户" width="90" align="center">
<template #default="{ row }">{{ row.tenant_count || 0 }}</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="Number(row.status) === 1 ? 'success' : 'danger'" size="small">
{{ Number(row.status) === 1 ? "启用" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEditPackage(row)">
编辑
</el-button>
<el-button size="small" type="danger" link @click="handleDeletePackage(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 用户数加购套餐 -->
<el-tab-pane label="用户数加购套餐" name="quota">
<el-table :data="quotaPackages" style="width: 100%" v-loading="quotaLoading">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="name" label="规格名称" min-width="160" align="center" />
<el-table-column prop="user_count" label="包含用户数" width="120" align="center">
<template #default="{ row }">{{ row.user_count }} 人</template>
</el-table-column>
<el-table-column label="售价" width="140" align="center">
<template #default="{ row }">{{ formatMoney(row.price) }}</template>
</el-table-column>
<el-table-column label="折算单价" width="140" align="center">
<template #default="{ row }">{{ formatMoney(unitPrice(row)) }}/人</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="Number(row.status) === 1 ? 'success' : 'danger'" size="small">
{{ Number(row.status) === 1 ? "启用" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEditQuota(row)">
编辑
</el-button>
<el-button size="small" type="danger" link @click="handleDeleteQuota(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<PackageEditDialog v-model="packageDialogVisible" :pkg="currentPackage" @success="loadPackages" />
<QuotaEditDialog v-model="quotaDialogVisible" :item="currentQuota" @success="loadQuotaPackages" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Refresh } from "@element-plus/icons-vue";
import {
getTenantPackageList,
deleteTenantPackage,
getQuotaPackageList,
deleteQuotaPackage,
} from "@/api/tenantPackage";
import PackageEditDialog from "./components/edit.vue";
import QuotaEditDialog from "./components/quotaEdit.vue";
const activeTab = ref("package");
const loading = ref(false);
const quotaLoading = ref(false);
const packages = ref<any[]>([]);
const quotaPackages = ref<any[]>([]);
const packageDialogVisible = ref(false);
const quotaDialogVisible = ref(false);
const currentPackage = ref<any>(null);
const currentQuota = ref<any>(null);
const formatMoney = (v: any) => `¥${Number(v || 0).toFixed(2)}`;
const unitPrice = (row: any) => {
const count = Number(row.user_count || 0);
if (count <= 0) return 0;
return Number(row.price || 0) / count;
};
const loadPackages = async () => {
loading.value = true;
try {
const res = await getTenantPackageList();
if (res.code === 200) {
packages.value = res.data?.list || [];
} else {
ElMessage.error(res.msg || "获取套餐列表失败");
}
} catch (e: any) {
ElMessage.error(e?.message || "获取套餐列表失败");
} finally {
loading.value = false;
}
};
const loadQuotaPackages = async () => {
quotaLoading.value = true;
try {
const res = await getQuotaPackageList();
if (res.code === 200) {
quotaPackages.value = res.data?.list || [];
} else {
ElMessage.error(res.msg || "获取加购套餐失败");
}
} catch (e: any) {
ElMessage.error(e?.message || "获取加购套餐失败");
} finally {
quotaLoading.value = false;
}
};
const refresh = () => {
loadPackages();
loadQuotaPackages();
};
const handleAddPackage = () => {
currentPackage.value = null;
packageDialogVisible.value = true;
};
const handleEditPackage = (row: any) => {
currentPackage.value = { ...row };
packageDialogVisible.value = true;
};
const handleDeletePackage = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要删除套餐「${row.name}」吗?删除后绑定该套餐的租户将失去对应功能。`,
"警告",
{ type: "warning" }
);
} catch {
return;
}
try {
const res = await deleteTenantPackage(row.id);
if (res.code === 200) {
ElMessage.success("删除成功");
loadPackages();
} else {
ElMessage.error(res.msg || "删除失败");
}
} catch (e: any) {
ElMessage.error(e?.message || "删除失败");
}
};
const handleAddQuota = () => {
currentQuota.value = null;
quotaDialogVisible.value = true;
};
const handleEditQuota = (row: any) => {
currentQuota.value = { ...row };
quotaDialogVisible.value = true;
};
const handleDeleteQuota = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要删除加购套餐「${row.name}」吗?`, "警告", {
type: "warning",
});
} catch {
return;
}
try {
const res = await deleteQuotaPackage(row.id);
if (res.code === 200) {
ElMessage.success("删除成功");
loadQuotaPackages();
} else {
ElMessage.error(res.msg || "删除失败");
}
} catch (e: any) {
ElMessage.error(e?.message || "删除失败");
}
};
onMounted(refresh);
</script>
<style scoped lang="less">
.container-box {
padding: 4px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.header-actions {
display: flex;
gap: 12px;
}
.tip-alert {
margin-bottom: 8px;
p {
margin: 4px 0;
font-size: 13px;
line-height: 1.6;
}
}
.package-tabs {
margin-top: 8px;
}
.module-tag {
margin: 0 6px 6px 0;
}
.ml6 {
margin-left: 6px;
}
.muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
:deep(.el-empty) {
padding: 4px 0;
}
</style>
@@ -0,0 +1,389 @@
<template>
<div class="tenant-package-tab" v-loading="loading">
<el-descriptions :column="2" border class="quota-desc">
<el-descriptions-item label="当前套餐">
<el-select
v-model="selectedPackageId"
placeholder="请选择套餐"
size="small"
style="width: 220px"
@change="handleSetPackage"
>
<el-option
v-for="p in packageOptions"
:key="p.id"
:label="p.name"
:value="p.id"
/>
</el-select>
</el-descriptions-item>
<el-descriptions-item label="开通功能">
<template v-if="info.module_names && info.module_names.length">
<el-tag v-for="(m, i) in info.module_names" :key="i" size="small" class="module-tag">
{{ m }}
</el-tag>
</template>
<span v-else class="muted">未配置功能</span>
</el-descriptions-item>
<el-descriptions-item label="用户数使用">
<span class="quota-text">
<b :class="{ danger: info.remaining <= 0 }">{{ info.used }}</b> / {{ info.quota }} 人
</span>
<el-progress
:percentage="usedPercent"
:status="info.remaining <= 0 ? 'exception' : undefined"
:stroke-width="8"
class="quota-progress"
/>
<span class="muted">剩余 {{ info.remaining }} 个名额</span>
</el-descriptions-item>
<el-descriptions-item label="增购单价">
<span>{{ formatMoney(info.extra_user_price) }}/人</span>
<el-button type="primary" link class="ml8" @click="openRecharge">增购用户数</el-button>
</el-descriptions-item>
</el-descriptions>
<el-alert
v-if="info.remaining <= 0"
type="warning"
show-icon
:closable="false"
class="quota-alert"
title="该租户用户数已达上限,新增账号前请先增购用户数"
/>
<div class="section-title">增购记录</div>
<el-table :data="orders" style="width: 100%" size="small" v-loading="ordersLoading">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="增购方式" width="110" align="center">
<template #default="{ row }">
<el-tag size="small" :type="Number(row.type) === 2 ? 'success' : 'info'" effect="plain">
{{ Number(row.type) === 2 ? "套餐增购" : "单个增购" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="package_name" label="规格" min-width="120" align="center" />
<el-table-column label="用户数" width="90" align="center">
<template #default="{ row }">+{{ row.user_count }}</template>
</el-table-column>
<el-table-column label="单价" width="110" align="center">
<template #default="{ row }">{{ formatMoney(row.unit_price) }}</template>
</el-table-column>
<el-table-column label="金额" width="110" align="center">
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="上限变化" width="130" align="center">
<template #default="{ row }">
{{ row.quota_before }} → <b>{{ row.quota_after }}</b>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="120" align="center" />
<el-table-column label="时间" min-width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
</el-table>
<!-- 增购用户数 -->
<el-dialog v-model="rechargeVisible" title="增购用户数" width="520px" destroy-on-close>
<el-form :model="rechargeForm" label-width="100px">
<el-form-item label="增购方式">
<el-radio-group v-model="rechargeForm.type">
<el-radio :label="1">单个增购</el-radio>
<el-radio :label="2">按套餐增购</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="rechargeForm.type === 1">
<el-form-item label="增购数量">
<el-input-number
v-model="rechargeForm.user_count"
:min="1"
:max="9999"
controls-position="right"
/>
<span class="field-tip">个用户</span>
</el-form-item>
<el-form-item label="单价">
<el-input-number
v-model="rechargeForm.unit_price"
:min="0"
:max="999999"
:precision="2"
controls-position="right"
/>
<span class="field-tip">元/人(默认为套餐设置的单用户增购价)</span>
</el-form-item>
</template>
<template v-else>
<el-form-item label="加购套餐">
<el-select v-model="rechargeForm.quota_package_id" placeholder="请选择" style="width: 100%">
<el-option
v-for="q in info.quota_packages || []"
:key="q.id"
:label="`${q.name}(${q.user_count}人 / ${formatMoney(q.price)})`"
:value="q.id"
/>
</el-select>
</el-form-item>
</template>
<el-form-item label="应付金额">
<span class="amount-text">{{ formatMoney(rechargeAmount) }}</span>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="rechargeForm.remark" maxlength="200" placeholder="选填,如:客户已付款" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rechargeVisible = false">取消</el-button>
<el-button type="primary" :loading="recharging" @click="submitRecharge">确认增购</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { formatDateTime } from "@/utils/datetime";
import {
getTenantQuotaInfo,
getTenantQuotaOrders,
getTenantPackageSelectList,
setTenantPackage,
rechargeTenantQuota,
} from "@/api/tenantPackage";
const props = defineProps<{
/** 当前租户 ID,为空时不请求 */
tid: number | null;
}>();
const loading = ref(false);
const ordersLoading = ref(false);
const recharging = ref(false);
const info = ref<any>({});
const orders = ref<any[]>([]);
const packageOptions = ref<any[]>([]);
const selectedPackageId = ref<number | undefined>(undefined);
const rechargeVisible = ref(false);
const rechargeForm = ref({
type: 1,
user_count: 1,
unit_price: 0,
quota_package_id: undefined as number | undefined,
remark: "",
});
const formatMoney = (v: any) => `¥${Number(v || 0).toFixed(2)}`;
const usedPercent = computed(() => {
const quota = Number(info.value.quota || 0);
const used = Number(info.value.used || 0);
if (quota <= 0) return 0;
return Math.min(100, Math.round((used / quota) * 100));
});
const rechargeAmount = computed(() => {
if (Number(rechargeForm.value.type) === 2) {
const pkg = (info.value.quota_packages || []).find(
(q: any) => Number(q.id) === Number(rechargeForm.value.quota_package_id)
);
return Number(pkg?.price || 0);
}
return Number(rechargeForm.value.user_count || 0) * Number(rechargeForm.value.unit_price || 0);
});
const loadInfo = async () => {
const tid = props.tid;
if (tid == null) return;
loading.value = true;
try {
const res = await getTenantQuotaInfo(tid);
if (res.code === 200) {
info.value = res.data || {};
selectedPackageId.value = Number(info.value.package_id || 0) || undefined;
} else {
ElMessage.error(res.msg || "获取租户套餐信息失败");
}
} catch (e: any) {
ElMessage.error(e?.message || "获取租户套餐信息失败");
} finally {
loading.value = false;
}
};
const loadOrders = async () => {
const tid = props.tid;
if (tid == null) return;
ordersLoading.value = true;
try {
const res = await getTenantQuotaOrders(tid);
if (res.code === 200) {
orders.value = res.data?.list || [];
}
} catch {
orders.value = [];
} finally {
ordersLoading.value = false;
}
};
const loadPackageOptions = async () => {
try {
const res = await getTenantPackageSelectList();
if (res.code === 200) {
packageOptions.value = res.data || [];
}
} catch {
packageOptions.value = [];
}
};
const refresh = () => {
loadInfo();
loadOrders();
};
watch(
() => props.tid,
(val) => {
if (val != null) {
refresh();
if (packageOptions.value.length === 0) loadPackageOptions();
}
},
{ immediate: true }
);
const handleSetPackage = async (id: number) => {
const tid = props.tid;
if (tid == null || !id) return;
try {
await ElMessageBox.confirm(
"调整套餐会改变该租户可用的功能模块(用户数上限只增不减),确定继续吗?",
"提示",
{ type: "warning" }
);
} catch {
// 取消时回滚选择
selectedPackageId.value = Number(info.value.package_id || 0) || undefined;
return;
}
try {
const res = await setTenantPackage({ tid, package_id: id, sync_quota: true });
if (res.code === 200) {
ElMessage.success("套餐设置成功");
refresh();
} else {
ElMessage.error(res.msg || "设置失败");
selectedPackageId.value = Number(info.value.package_id || 0) || undefined;
}
} catch (e: any) {
ElMessage.error(e?.message || "设置失败");
}
};
const openRecharge = () => {
rechargeForm.value = {
type: 1,
user_count: 1,
unit_price: Number(info.value.extra_user_price || 0),
quota_package_id: (info.value.quota_packages || [])[0]?.id,
remark: "",
};
rechargeVisible.value = true;
};
const submitRecharge = async () => {
const tid = props.tid;
if (tid == null) return;
const payload: any = {
tid,
type: rechargeForm.value.type,
remark: rechargeForm.value.remark,
};
if (Number(rechargeForm.value.type) === 2) {
if (!rechargeForm.value.quota_package_id) {
ElMessage.warning("请选择加购套餐");
return;
}
payload.quota_package_id = rechargeForm.value.quota_package_id;
} else {
if (Number(rechargeForm.value.user_count) <= 0) {
ElMessage.warning("请输入增购数量");
return;
}
payload.user_count = rechargeForm.value.user_count;
payload.unit_price = rechargeForm.value.unit_price;
}
recharging.value = true;
try {
const res = await rechargeTenantQuota(payload);
if (res.code === 200) {
ElMessage.success(`增购成功,本次增加 ${res.data?.add || 0} 个用户数`);
rechargeVisible.value = false;
refresh();
} else {
ElMessage.error(res.msg || "增购失败");
}
} catch (e: any) {
ElMessage.error(e?.message || "增购失败");
} finally {
recharging.value = false;
}
};
defineExpose({ refresh });
</script>
<style scoped>
.quota-desc {
margin-bottom: 12px;
}
.module-tag {
margin: 0 6px 6px 0;
}
.quota-text b {
font-size: 16px;
}
.quota-text b.danger {
color: var(--el-color-danger);
}
.quota-progress {
margin: 6px 0 2px;
max-width: 220px;
}
.muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.ml8 {
margin-left: 8px;
}
.quota-alert {
margin-bottom: 12px;
}
.section-title {
margin: 16px 0 8px;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.amount-text {
font-size: 16px;
font-weight: 600;
color: var(--el-color-danger);
}
.field-tip {
margin-left: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -1,7 +1,14 @@
<template>
<div class="tenant-users-tab">
<div class="section-header">
<div class="section-title">用户列表</div>
<div class="section-title">
用户列表
<span v-if="quotaInfo" class="quota-badge">
用户数
<b :class="{ danger: Number(quotaInfo.remaining) <= 0 }">{{ quotaInfo.used }}</b>
/ {{ quotaInfo.quota }}
</span>
</div>
<el-button type="primary" size="small" :disabled="!tid" @click="handleAddUser">
<el-icon><Plus /></el-icon>
添加用户
@@ -121,6 +128,7 @@ import { ref, reactive, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { getTenantUserList, editTenantUser, deleteTenantUser } from "@/api/tenantUser";
import { getTenantQuotaInfo } from "@/api/tenantPackage";
import { getOrganizationList } from "@/api/erp";
import AddUser from "./adduser.vue";
@@ -134,6 +142,25 @@ const usersLoading = ref(false);
const userSearchKeyword = ref("");
const addUserRef = ref<{ open: (tenantId: number) => void } | null>(null);
// 租户用户数配额(已用/上限):用于展示与新增前的拦截
const quotaInfo = ref<any>(null);
const loadQuotaInfo = async () => {
const id = props.tid;
if (id == null) {
quotaInfo.value = null;
return;
}
try {
const res = await getTenantQuotaInfo(id);
if (res.code === 200) {
quotaInfo.value = res.data || null;
}
} catch {
quotaInfo.value = null;
}
};
const orgTree = ref<any[]>([]);
const orgNameMap = ref<Record<number, string>>({});
@@ -234,6 +261,8 @@ const refreshTenantUsers = async () => {
} finally {
usersLoading.value = false;
}
// 用户数统计随增删变化,同步刷新配额展示
loadQuotaInfo();
};
const loadUsersForTid = async (tid: number) => {
@@ -259,9 +288,11 @@ watch(
loadOrgs();
if (id == null) {
tenantUsers.value = [];
quotaInfo.value = null;
return;
}
loadUsersForTid(id);
loadQuotaInfo();
},
{ immediate: true }
);
@@ -276,9 +307,17 @@ const resetUserSearch = () => {
};
const handleAddUser = () => {
if (props.tid != null) {
addUserRef.value?.open(props.tid);
if (props.tid == null) return;
// 用户数上限校验:达到上限时提示到「套餐与用户数」增购
if (quotaInfo.value && Number(quotaInfo.value.quota) > 0 && Number(quotaInfo.value.used) >= Number(quotaInfo.value.quota)) {
ElMessageBox.alert(
`该租户用户数已达上限(${quotaInfo.value.used}/${quotaInfo.value.quota}),请先在「套餐与用户数」中增购用户数。`,
"用户数已达上限",
{ type: "warning", confirmButtonText: "我知道了" }
).catch(() => {});
return;
}
addUserRef.value?.open(props.tid);
};
const openPasswordDialog = (row: any) => {
@@ -419,6 +458,22 @@ defineExpose({
color: #303133;
}
.quota-badge {
margin-left: 12px;
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
b {
color: var(--el-color-primary);
font-size: 13px;
}
b.danger {
color: var(--el-color-danger);
}
}
.user-search-form {
margin-bottom: 8px;
padding: 8px 12px;
@@ -38,6 +38,16 @@
<el-descriptions-item label="创建时间">
{{ detailData.create_time ? formatDateTime(detailData.create_time) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="租户套餐">
<el-tag size="small" type="warning" effect="plain">
{{ detailData.package_name || '未绑定' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用户数">
<span>
{{ detailData.user_used ?? 0 }} / {{ detailData.user_quota ?? 0 }} 人
</span>
</el-descriptions-item>
</el-descriptions>
<el-divider />
@@ -53,6 +63,11 @@
<TenantUsersTab :tid="detailTenantId" />
</div>
</el-tab-pane>
<el-tab-pane label="套餐与用户数" name="package">
<div class="tab-pane-inner">
<TenantPackageTab :tid="detailTenantId" />
</div>
</el-tab-pane>
<!-- 后续功能:在此继续增加 <el-tab-pane label="..." name="...">...</el-tab-pane> -->
</el-tabs>
</div>
@@ -68,6 +83,7 @@
import { ref } from "vue";
import { getTenantDetail } from "@/api/tenant";
import TenantUsersTab from "./TenantUsersTab.vue";
import TenantPackageTab from "./TenantPackageTab.vue";
import { formatDateTime } from "@/utils/datetime";
const visible = ref(false);
@@ -24,6 +24,21 @@
<el-form-item label="租户地址" prop="address">
<el-input v-model="formData.address" type="textarea" placeholder="请输入地址" />
</el-form-item>
<el-form-item label="租户套餐" prop="package_id">
<el-select v-model="formData.package_id" placeholder="请选择套餐" clearable
style="width: 100%" @change="handlePackageChange">
<el-option v-for="p in packageOptions" :key="p.id" :label="p.name" :value="p.id">
<span>{{ p.name }}</span>
<span class="option-tip">{{ packageSummary(p) }}</span>
</el-option>
</el-select>
<div class="form-tip">决定租户端可用的功能模块(套餐在「基础设置 → 租户套餐」维护)</div>
</el-form-item>
<el-form-item label="用户数上限" prop="user_quota">
<el-input-number v-model="formData.user_quota" :min="1" :max="99999"
controls-position="right" />
<div class="form-tip">默认 20 人;达到上限后需增购用户数才能继续开账号</div>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
@@ -40,9 +55,10 @@
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { createTenant, editTenant, getTenantDetail, checkTenantCode } from '@/api/tenant';
import { getTenantPackageSelectList } from '@/api/tenantPackage';
const emit = defineEmits(['success']);
const visible = ref(false);
@@ -50,6 +66,39 @@ const loading = ref(false);
const submitting = ref(false);
const formRef = ref();
// 套餐下拉:套餐决定租户端开通的功能模块与初始用户数
const packageOptions = ref<any[]>([]);
const loadPackages = async () => {
try {
const res = await getTenantPackageSelectList();
if (res.code === 200) {
packageOptions.value = res.data || [];
}
} catch {
packageOptions.value = [];
}
};
onMounted(loadPackages);
const packageSummary = (p: any) => {
const names = (p.modules || []).map((m: any) => m.module_name || m.module_code).join('、');
return `${names || '未配置功能'} · ${p.user_quota || 0}人`;
};
// 选择套餐时联动用户数上限(取套餐包含人数与当前值的较大者,避免回收已购用户数)
const handlePackageChange = (id: number) => {
const pkg = packageOptions.value.find((p) => Number(p.id) === Number(id));
if (!pkg) return;
const quota = Number(pkg.user_quota || 20);
if (!formData.id) {
formData.user_quota = quota;
} else if (quota > Number(formData.user_quota || 0)) {
formData.user_quota = quota;
}
};
const initialData = {
id: null,
tenant_name: '',
@@ -58,7 +107,9 @@ const initialData = {
contact_phone: '',
contact_email: '',
address: '',
status: 1
status: 1,
package_id: undefined as number | undefined,
user_quota: 20
};
const formData = reactive({ ...initialData });
@@ -191,4 +242,20 @@ const handleClosed = () => {
};
defineExpose({ open });
</script>
</script>
<style scoped>
.option-tip {
float: right;
margin-left: 16px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
margin-top: 4px;
}
</style>
@@ -62,6 +62,13 @@
<!-- 租户列表表格 -->
<el-table :data="tenants" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" align="center" fixed="left" />
<el-table-column label="租户套餐" min-width="130" align="center">
<template #default="scope">
<el-tag size="small" type="warning" effect="plain">
{{ scope.row.package_name || "未绑定" }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="tenant_name"
label="租户名称"
@@ -104,6 +111,19 @@
min-width="300"
align="center"
/>
<el-table-column label="用户数" width="110" align="center">
<template #default="scope">
<span
:class="{
'quota-full':
Number(scope.row.user_used || 0) >=
Number(scope.row.user_quota || 0),
}"
>
{{ scope.row.user_used ?? 0 }} / {{ scope.row.user_quota ?? 0 }}
</span>
</template>
</el-table-column>
<el-table-column prop="status" label="租户状态" width="80" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">{{
@@ -284,6 +304,11 @@ onMounted(() => {
}
}
.quota-full {
color: var(--el-color-danger);
font-weight: 600;
}
// 用 Element Plus 变量,亮色下 #f5f7fa、暗色下 #0a0a0a,自动随主题切换
.search-form {
background: var(--el-bg-color-page, #f5f7fa);
@@ -94,7 +94,12 @@
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<el-switch
:model-value="form.enabled"
active-text="启用"
inactive-text="停用"
@change="(v: any) => handleToggle(v)"
/>
<span class="field-tip inline">停用后收银台不再展示支付宝。</span>
</el-form-item>
</el-form>
@@ -114,8 +119,12 @@ import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
import {
getChannelConfig,
saveChannelConfig,
testChannelConnection,
toggleChannel
} from '@/api/payment'
const CHANNEL = 'alipay'
@@ -167,17 +176,20 @@ function toggleSecret(key: 'app_private_key') {
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
appid: '2021003123456789',
app_private_key: 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9f2c7b1a4d6e8f0a',
alipay_public_key:
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt9f2c7b1a4d6e8f0a3c5b7d9e1f4a6c8',
sign_type: 'RSA2',
enabled: true
})
try {
const res: any = await getChannelConfig(CHANNEL)
if (res?.code === 200 && res.data) {
const cfg = res.data.config || {}
form.appid = cfg.appid || ''
// 敏感字段后端只回掩码,原样放进表单表示「不修改」
form.app_private_key = cfg.app_private_key || ''
form.alipay_public_key = cfg.alipay_public_key || ''
form.sign_type = cfg.sign_type || 'RSA2'
form.enabled = !!res.data.enabled
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '加载渠道配置失败')
}
}
async function handleSave() {
@@ -189,12 +201,22 @@ async function handleSave() {
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求(app_private_key 为掩码时表示不修改)
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
// app_private_key 为掩码 / 空串时后端保持原值不变
await saveChannelConfig(CHANNEL, {
merchant_no: form.appid,
config: {
appid: form.appid,
app_private_key: form.app_private_key,
alipay_public_key: form.alipay_public_key,
sign_type: form.sign_type,
// 页面未提供沙箱开关,平台收款默认正式环境
is_production: '1'
}
})
ElMessage.success('支付宝配置已保存')
await loadConfig() // 重新拉取,刷新敏感字段掩码回显
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
} finally {
saving.value = false
}
@@ -203,12 +225,16 @@ async function handleSave() {
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:APPID 与 RSA2 签名校验通过')
const res: any = await testChannelConnection(CHANNEL, {
config: {
appid: form.appid,
app_private_key: form.app_private_key,
alipay_public_key: form.alipay_public_key
}
})
ElMessage.success(res?.data?.message || '连接成功:APPID 与 RSA2 签名校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查 APPID、应用私钥与支付宝公钥')
ElMessage.error(error?.response?.data?.msg || error?.message || '连接失败,请检查 APPID、应用私钥与支付宝公钥')
} finally {
testing.value = false
}
@@ -224,9 +250,17 @@ async function handleToggle(enable: boolean) {
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`支付宝已${enable ? '启用' : '停用'}`)
try {
const res: any = await toggleChannel(CHANNEL, enable)
if (res?.code === 200) {
form.enabled = enable
ElMessage.success(`支付宝已${enable ? '启用' : '停用'}`)
} else {
ElMessage.error(res?.msg || '操作失败')
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '操作失败')
}
}
async function copyText(text: string) {
@@ -48,7 +48,7 @@
</el-form-item>
<el-form-item label="继承收单机构">
<el-input :model-value="form.acq_ins_code" readonly placeholder="读取自银联配置" />
<el-input :model-value="acqDisplay" readonly placeholder="读取自银联配置" />
</el-form-item>
<el-form-item label="在收银台展示云闪付标识">
@@ -72,7 +72,12 @@
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<el-switch
:model-value="form.enabled"
active-text="启用"
inactive-text="停用"
@change="(v: any) => handleToggle(v)"
/>
<span class="field-tip inline">需先启用「银联」渠道,云闪付才会在收银台生效。</span>
</el-form-item>
</el-form>
@@ -88,12 +93,16 @@
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { computed, reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
import {
getChannelConfig,
saveChannelConfig,
testChannelConnection,
toggleChannel
} from '@/api/payment'
const CHANNEL = 'cloudpay'
@@ -120,20 +129,33 @@ const rules: FormRules = {
/* ---------------- 加载 / 保存 / 测试 ---------------- */
/** 继承收单机构展示为「代码 - 名称」 */
const acqDisplay = computed(() => {
const opt = acqOptions.find((o) => o.code === form.acq_ins_code)
return opt ? `${opt.code} - ${opt.name}` : form.acq_ins_code
})
async function loadConfig() {
// TODO: 接口联通后替换为真实请求(商户号/收单机构建议由后端从银联配置继承返回)
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
// const unionpay = await getChannelConfig('unionpay')
// unionpayReady.value = !!unionpay?.data?.mer_id
Object.assign(form, {
mer_id: '898110158000000',
acq_ins_code: '0402 - 中国建设银行',
show_logo: true,
logo_positions: ['pc', 'h5'],
enabled: false
})
unionpayReady.value = false
try {
const [mine, unionpay]: any[] = await Promise.all([
getChannelConfig(CHANNEL),
getChannelConfig('unionpay')
])
const selfCfg = mine?.data?.config || {}
const upCfg = unionpay?.data?.config || {}
// 继承参数优先读自身,缺省回落到银联配置
form.mer_id = selfCfg.mer_id || upCfg.mer_id || ''
form.acq_ins_code = selfCfg.acq_ins_code || upCfg.acq_ins_code || ''
const extra = mine?.data?.extra || {}
form.show_logo = extra.show_logo !== false
form.logo_positions = Array.isArray(extra.logo_positions) && extra.logo_positions.length
? extra.logo_positions
: ['pc', 'h5']
form.enabled = !!mine?.data?.enabled
unionpayReady.value = !!upCfg.mer_id
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '加载渠道配置失败')
}
}
async function handleSave() {
@@ -145,12 +167,16 @@ async function handleSave() {
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
// 继承参数写入自身 config,保证启用校验(参数非空)可通过;展示开关存于扩展参数
await saveChannelConfig(CHANNEL, {
merchant_no: form.mer_id,
config: { mer_id: form.mer_id, acq_ins_code: form.acq_ins_code },
extra: { show_logo: form.show_logo, logo_positions: form.logo_positions }
})
ElMessage.success('云闪付配置已保存')
await loadConfig() // 重新拉取,刷新继承状态
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
} finally {
saving.value = false
}
@@ -159,12 +185,12 @@ async function handleSave() {
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求(后端会校验银联参数是否可用)
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:云闪付通道可用(继承银联参数)')
const res: any = await testChannelConnection(CHANNEL, {
config: { mer_id: form.mer_id, acq_ins_code: form.acq_ins_code }
})
ElMessage.success(res?.data?.message || '连接成功:云闪付通道可用(继承银联参数)')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请先完成银联配置')
ElMessage.error(error?.response?.data?.msg || error?.message || '连接失败,请先完成银联配置')
} finally {
testing.value = false
}
@@ -184,9 +210,17 @@ async function handleToggle(enable: boolean) {
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`云闪付已${enable ? '启用' : '停用'}`)
try {
const res: any = await toggleChannel(CHANNEL, enable)
if (res?.code === 200) {
form.enabled = enable
ElMessage.success(`云闪付已${enable ? '启用' : '停用'}`)
} else {
ElMessage.error(res?.msg || '操作失败')
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '操作失败')
}
}
async function copyText(text: string) {
@@ -88,7 +88,12 @@
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<el-switch
:model-value="form.enabled"
active-text="启用"
inactive-text="停用"
@change="(v: any) => handleToggle(v)"
/>
<span class="field-tip inline">仅当有海外租户时才建议启用。</span>
</el-form-item>
</el-form>
@@ -108,8 +113,12 @@ import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
import {
getChannelConfig,
saveChannelConfig,
testChannelConnection,
toggleChannel
} from '@/api/payment'
const CHANNEL = 'paypal'
@@ -161,16 +170,21 @@ function toggleSecret(key: 'client_secret') {
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
client_id: '',
client_secret: '',
webhook_id: '',
env: 'sandbox',
enabled: false
})
try {
const res: any = await getChannelConfig(CHANNEL)
if (res?.code === 200 && res.data) {
const cfg = res.data.config || {}
form.client_id = cfg.client_id || ''
// 敏感字段后端只回掩码,原样放进表单表示「不修改」
form.client_secret = cfg.client_secret || ''
form.webhook_id = cfg.webhook_id || ''
// 运行环境存于扩展参数 extra.env
form.env = res.data.extra?.env || 'sandbox'
form.enabled = !!res.data.enabled
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '加载渠道配置失败')
}
}
async function handleSave() {
@@ -182,12 +196,20 @@ async function handleSave() {
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求(client_secret 为掩码时表示不修改)
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
// client_secret 为掩码 / 空串时后端保持原值不变;env 存于扩展参数
await saveChannelConfig(CHANNEL, {
merchant_no: form.client_id,
config: {
client_id: form.client_id,
client_secret: form.client_secret,
webhook_id: form.webhook_id
},
extra: { env: form.env }
})
ElMessage.success('PayPal 配置已保存')
await loadConfig() // 重新拉取,刷新敏感字段掩码回显
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
} finally {
saving.value = false
}
@@ -196,12 +218,17 @@ async function handleSave() {
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:OAuth2 凭证校验通过')
const res: any = await testChannelConnection(CHANNEL, {
config: {
client_id: form.client_id,
client_secret: form.client_secret,
webhook_id: form.webhook_id
},
extra: { env: form.env }
})
ElMessage.success(res?.data?.message || '连接成功:OAuth2 凭证校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查 Client ID / Secret 与运行环境')
ElMessage.error(error?.response?.data?.msg || error?.message || '连接失败,请检查 Client ID / Secret 与运行环境')
} finally {
testing.value = false
}
@@ -217,9 +244,17 @@ async function handleToggle(enable: boolean) {
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`PayPal 已${enable ? '启用' : '停用'}`)
try {
const res: any = await toggleChannel(CHANNEL, enable)
if (res?.code === 200) {
form.enabled = enable
ElMessage.success(`PayPal 已${enable ? '启用' : '停用'}`)
} else {
ElMessage.error(res?.msg || '操作失败')
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '操作失败')
}
}
async function copyText(text: string) {
@@ -88,7 +88,12 @@
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<el-switch
:model-value="form.enabled"
active-text="启用"
inactive-text="停用"
@change="(v: any) => handleToggle(v)"
/>
<span class="field-tip inline">停用后收银台不再展示银联。</span>
</el-form-item>
</el-form>
@@ -108,8 +113,13 @@ import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel, uploadChannelCert } from '@/api/payment'
import {
getChannelConfig,
saveChannelConfig,
testChannelConnection,
toggleChannel,
uploadChannelCert
} from '@/api/payment'
const CHANNEL = 'unionpay'
@@ -145,17 +155,27 @@ const rules: FormRules = {
/* ---------------- 加载 / 保存 / 测试 ---------------- */
/** 从证书路径取文件名用于展示 */
function certFileName(path?: string) {
if (!path) return ''
const parts = String(path).split(/[\\/]/)
return parts[parts.length - 1] || ''
}
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
mer_id: '',
acq_ins_code: '',
cert_file: '',
enabled: false
})
certFile.value = ''
try {
const res: any = await getChannelConfig(CHANNEL)
if (res?.code === 200 && res.data) {
const cfg = res.data.config || {}
form.mer_id = cfg.mer_id || ''
form.acq_ins_code = cfg.acq_ins_code || ''
form.cert_file = cfg.cert_file || ''
form.enabled = !!res.data.enabled
certFile.value = certFileName(res.data.cert_files?.cert_path)
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '加载渠道配置失败')
}
}
async function handleSave() {
@@ -167,12 +187,21 @@ async function handleSave() {
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
// 证书密码(cert_password)为掩码 / 空串时后端保持原值不变
await saveChannelConfig(CHANNEL, {
merchant_no: form.mer_id,
config: {
mer_id: form.mer_id,
acq_ins_code: form.acq_ins_code,
cert_file: form.cert_file,
// 页面未提供沙箱开关,平台收款默认正式环境
is_production: '1'
}
})
ElMessage.success('银联配置已保存')
await loadConfig() // 重新拉取,刷新掩码与证书回显
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
} finally {
saving.value = false
}
@@ -181,12 +210,15 @@ async function handleSave() {
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:商户号、收单机构与证书校验通过')
const res: any = await testChannelConnection(CHANNEL, {
config: {
mer_id: form.mer_id,
acq_ins_code: form.acq_ins_code
}
})
ElMessage.success(res?.data?.message || '连接成功:商户号、收单机构与证书校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查商户号、收单机构与证书')
ElMessage.error(error?.response?.data?.msg || error?.message || '连接失败,请检查商户号、收单机构与证书')
} finally {
testing.value = false
}
@@ -202,16 +234,34 @@ async function handleToggle(enable: boolean) {
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`银联已${enable ? '启用' : '停用'}`)
try {
const res: any = await toggleChannel(CHANNEL, enable)
if (res?.code === 200) {
form.enabled = enable
ElMessage.success(`银联已${enable ? '启用' : '停用'}`)
} else {
ElMessage.error(res?.msg || '操作失败')
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '操作失败')
}
}
function handleCertChange(file: any) {
// TODO: 接口联通后替换为 uploadChannelCert(CHANNEL, file.raw, 'cert')
certFile.value = file?.name || ''
form.cert_file = certFile.value
ElMessage.success(`已选择证书:${certFile.value}`)
async function handleCertChange(file: any) {
const raw = file?.raw
if (!raw) return
try {
const res: any = await uploadChannelCert(CHANNEL, raw, 'cert')
if (res?.code === 200) {
certFile.value = res.data?.file || file?.name || ''
form.cert_file = certFile.value
ElMessage.success('证书已上传,保存配置后生效')
} else {
ElMessage.error(res?.msg || '证书上传失败')
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '证书上传失败')
}
}
async function copyText(text: string) {
@@ -18,7 +18,7 @@
<li>
<b>去哪拿参数:</b>登录微信商户平台
<a href="https://pay.weixin.qq.com" target="_blank" rel="noopener">pay.weixin.qq.com</a>
→ 「账户中心 → 商户信息」取<b>商户号 mch_id</b>;「产品中心 → 开发配置」取并绑定<b>APPID</b>;
→ 「账户中心 → 商户信息」取<b>微信支付商户号</b>;「产品中心 → 开发配置」取并绑定<b>APPID</b>;
「账户中心 → API 安全」申请<b>APIv3 密钥</b>并下载<b>API 证书</b>(apiclient_cert.pem / apiclient_key.pem)。
</li>
<li>
@@ -44,7 +44,7 @@
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" class="config-form">
<el-form-item label="商户号 mch_id" prop="mch_id">
<el-form-item label="微信支付商户号" prop="mch_id">
<el-input v-model="form.mch_id" placeholder="请输入微信支付商户号,如 1620888999" clearable />
</el-form-item>
@@ -71,6 +71,32 @@
<div class="field-tip">回显为掩码表示已保存;点击「修改」后留空保存,表示保持原密钥不变。</div>
</el-form-item>
<el-form-item label="证书序列号" prop="cert_serial_no">
<el-input v-model="form.cert_serial_no" placeholder="40 位商户 API 证书序列号" clearable />
<div class="field-tip">
微信商户平台 → 「账户中心 → API 安全 → API 证书」→ 查看证书序列号;与下方上传的 apiclient 证书须为同一批。
</div>
</el-form-item>
<el-form-item label="微信支付公钥 ID" prop="pub_key_id">
<el-input v-model="form.pub_key_id" placeholder="形如 PUB_KEY_ID_0116105446482025072500211934..." clearable />
<div class="field-tip">
微信商户平台 → 「账户中心 → API 安全 → 微信支付公钥」查看;2024 年后新开通商户为公钥模式,须与下方公钥一并填写。
</div>
</el-form-item>
<el-form-item label="微信支付公钥" prop="pub_key">
<el-input
v-model="form.pub_key"
type="textarea"
:rows="5"
placeholder="-----BEGIN PUBLIC KEY----- 开头的公钥 PEM 内容,公钥模式必填"
/>
<div class="field-tip">
公钥为公开信息,可明文保存;老商户(平台证书模式)留空即可。若填了公钥 ID 却收到「平台证书已过期失效」错误,说明公钥内容未填。
</div>
</el-form-item>
<el-form-item label="商户证书">
<div class="upload-col">
<div class="upload-row">
@@ -108,7 +134,12 @@
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<el-switch
:model-value="form.enabled"
active-text="启用"
inactive-text="停用"
@change="(v: any) => handleToggle(v)"
/>
<span class="field-tip inline">停用后收银台不再展示微信支付。</span>
</el-form-item>
</el-form>
@@ -128,8 +159,13 @@ import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel, uploadChannelCert } from '@/api/payment'
import {
getChannelConfig,
saveChannelConfig,
testChannelConnection,
toggleChannel,
uploadChannelCert
} from '@/api/payment'
const CHANNEL = 'wechat'
@@ -143,7 +179,10 @@ const testing = ref(false)
const form = reactive({
mch_id: '',
appid: '',
cert_serial_no: '',
api_v3_key: '',
pub_key_id: '',
pub_key: '',
enabled: true
})
@@ -152,6 +191,7 @@ const certFiles = reactive<{ cert: string; key: string }>({ cert: '', key: '' })
const rules: FormRules = {
mch_id: [{ required: true, message: '请输入商户号', trigger: 'blur' }],
appid: [{ required: true, message: '请输入 APPID', trigger: 'blur' }],
cert_serial_no: [{ required: true, message: '请输入商户 API 证书序列号', trigger: 'blur' }],
api_v3_key: [{ required: true, message: '请输入 APIv3 密钥', trigger: 'blur' }]
}
@@ -181,18 +221,32 @@ function toggleSecret(key: 'api_v3_key') {
/* ---------------- 加载 / 保存 / 测试 ---------------- */
/** 从证书路径取文件名用于展示 */
function certFileName(path?: string) {
if (!path) return ''
const parts = String(path).split(/[\\/]/)
return parts[parts.length - 1] || ''
}
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
mch_id: '1620888999',
appid: 'wx9a2c7b1a4d6e8f0a',
api_v3_key: 'WXv3Key9f2c7b1a4d6e8f0a3c5b7d9e',
enabled: true
})
certFiles.cert = 'apiclient_cert.pem'
certFiles.key = 'apiclient_key.pem'
try {
const res: any = await getChannelConfig(CHANNEL)
if (res?.code === 200 && res.data) {
const cfg = res.data.config || {}
form.mch_id = cfg.mch_id || ''
form.appid = cfg.appid || ''
form.cert_serial_no = cfg.cert_serial_no || ''
form.pub_key_id = cfg.pub_key_id || ''
form.pub_key = cfg.pub_key || ''
// 敏感字段后端只回掩码,原样放进表单表示「不修改」
form.api_v3_key = cfg.api_v3_key || ''
form.enabled = !!res.data.enabled
certFiles.cert = certFileName(res.data.cert_files?.cert_path)
certFiles.key = certFileName(res.data.cert_files?.key_path)
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '加载渠道配置失败')
}
}
async function handleSave() {
@@ -204,12 +258,22 @@ async function handleSave() {
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求(api_v3_key 为掩码时表示不修改)
// await saveChannelConfig(CHANNEL, { ...form, cert_files: certFiles })
await new Promise((resolve) => setTimeout(resolve, 400))
// api_v3_key 为掩码 / 空串时后端保持原值不变
await saveChannelConfig(CHANNEL, {
merchant_no: form.mch_id,
config: {
mch_id: form.mch_id,
appid: form.appid,
cert_serial_no: form.cert_serial_no,
api_v3_key: form.api_v3_key,
pub_key_id: form.pub_key_id,
pub_key: form.pub_key
}
})
ElMessage.success('微信支付配置已保存')
await loadConfig() // 重新拉取,刷新敏感字段掩码回显
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
} finally {
saving.value = false
}
@@ -218,12 +282,20 @@ async function handleSave() {
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:商户证书与 APIv3 密钥校验通过')
// 支持携带未保存参数测试,掩码 / 空串由后端还原为已保存原值
const res: any = await testChannelConnection(CHANNEL, {
config: {
mch_id: form.mch_id,
appid: form.appid,
cert_serial_no: form.cert_serial_no,
api_v3_key: form.api_v3_key,
pub_key_id: form.pub_key_id,
pub_key: form.pub_key
}
})
ElMessage.success(res?.data?.message || '连接成功:商户证书与 APIv3 密钥校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查商户号、密钥与证书')
ElMessage.error(error?.response?.data?.msg || error?.message || '连接失败,请检查商户号、密钥与证书')
} finally {
testing.value = false
}
@@ -239,15 +311,33 @@ async function handleToggle(enable: boolean) {
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`微信支付已${enable ? '启用' : '停用'}`)
try {
const res: any = await toggleChannel(CHANNEL, enable)
if (res?.code === 200) {
form.enabled = enable
ElMessage.success(`微信支付已${enable ? '启用' : '停用'}`)
} else {
ElMessage.error(res?.msg || '操作失败')
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '操作失败')
}
}
function handleCertChange(file: any, certType: 'cert' | 'key') {
// TODO: 接口联通后替换为 uploadChannelCert(CHANNEL, file.raw, certType)
certFiles[certType] = file?.name || ''
ElMessage.success(`已选择证书:${file?.name || ''}`)
async function handleCertChange(file: any, certType: 'cert' | 'key') {
const raw = file?.raw
if (!raw) return
try {
const res: any = await uploadChannelCert(CHANNEL, raw, certType)
if (res?.code === 200) {
certFiles[certType] = res.data?.file || file?.name || ''
ElMessage.success('证书已上传,保存配置后生效')
} else {
ElMessage.error(res?.msg || '证书上传失败')
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || error?.message || '证书上传失败')
}
}
async function copyText(text: string) {
@@ -119,14 +119,14 @@ const channels: ChannelDoc[] = [
name: '微信支付',
status: 'enabled',
where: [
'登录微信商户平台,进入「账户中心 → 商户信息」,记录商户号 mch_id。',
'登录微信商户平台,进入「账户中心 → 商户信息」,记录微信支付商户号。',
'进入「产品中心 → 开发配置」,绑定需要收款的 APPID(公众号 / 小程序 / APP)。',
'进入「账户中心 → API 安全」,申请 APIv3 密钥(32 位,只在申请时展示)。',
'同页「API 证书」下载证书文件,取 apiclient_cert.pem 与 apiclient_key.pem。'
],
links: [{ text: '微信商户平台', url: 'https://pay.weixin.qq.com' }],
fields: [
{ label: '商户号 mch_id', required: true, tip: '微信支付商户号,纯数字,注意不要填成 APPID' },
{ label: '微信支付商户号', required: true, tip: '微信支付商户号,纯数字,注意不要填成 APPID' },
{ label: 'APPID', required: true, tip: '必须与该商户号完成绑定,否则拉起支付会报商户号与 AppID 不匹配' },
{ label: 'APIv3 密钥', required: true, secret: true, tip: '32 位字符串,用于解密回调与接口签名,丢失只能重新申请' },
{ label: '商户证书', required: true, secret: true, tip: 'apiclient_cert.pem + apiclient_key.pem,上传后由后端加密保存' },