增加租户套餐
This commit is contained in:
Vendored
+5
@@ -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']
|
||||
|
||||
@@ -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,上传后由后端加密保存' },
|
||||
|
||||
Reference in New Issue
Block a user