增加租户套餐
This commit is contained in:
Vendored
+1
@@ -54,6 +54,7 @@ declare module 'vue' {
|
|||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||||
|
|||||||
@@ -82,3 +82,14 @@ export function checkTenantCode(tenant_code) {
|
|||||||
params: { tenant_code }
|
params: { tenant_code }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前租户的用户数配额(套餐、已用/上限、剩余)
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function getTenantQuotaInfo() {
|
||||||
|
return request({
|
||||||
|
url: '/backend/tenant/quotaInfo',
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -354,7 +354,7 @@ const handleExport = async (row) => {
|
|||||||
|
|
||||||
const money = (value) => Number(value || 0).toFixed(2);
|
const money = (value) => Number(value || 0).toFixed(2);
|
||||||
// 日期显示统一为:YYYY-MM-DD
|
// 日期显示统一为:YYYY-MM-DD
|
||||||
const formatDate = (value: any) => {
|
const formatDate = (value) => {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
return formatDateUtil(value);
|
return formatDateUtil(value);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container-box">
|
<div class="container-box">
|
||||||
<div class="header-bar">
|
<div class="header-bar">
|
||||||
<h2>用户管理</h2>
|
<h2>
|
||||||
|
用户管理
|
||||||
|
<el-tag v-if="quotaInfo" size="small" :type="quotaFull ? 'danger' : 'info'" effect="plain"
|
||||||
|
class="quota-tag">
|
||||||
|
用户数 {{ quotaInfo.used }} / {{ quotaInfo.quota }}
|
||||||
|
</el-tag>
|
||||||
|
</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button type="primary" @click="handleAddUser">
|
<el-button type="primary" @click="handleAddUser">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
@@ -20,6 +26,9 @@
|
|||||||
|
|
||||||
<el-divider></el-divider>
|
<el-divider></el-divider>
|
||||||
|
|
||||||
|
<el-alert v-if="quotaFull" type="warning" show-icon :closable="false" class="quota-alert"
|
||||||
|
title="用户数已达上限,新增账号前请联系平台管理员增购用户数" />
|
||||||
|
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<el-form inline>
|
<el-form inline>
|
||||||
<el-form-item label="租户筛选">
|
<el-form-item label="租户筛选">
|
||||||
@@ -91,10 +100,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Plus, Refresh } from "@element-plus/icons-vue";
|
import { Plus, Refresh } from "@element-plus/icons-vue";
|
||||||
import { getTenantUsers, deleteUser } from "@/api/user";
|
import { getTenantUsers, deleteUser } from "@/api/user";
|
||||||
|
import { getTenantQuotaInfo } from "@/api/tenant";
|
||||||
import { getAllRoles } from "@/api/role";
|
import { getAllRoles } from "@/api/role";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import UserEditDialog from "./components/userEdit.vue";
|
import UserEditDialog from "./components/userEdit.vue";
|
||||||
@@ -150,9 +160,30 @@ const isEdit = ref(false);
|
|||||||
const currentUserId = ref<number | undefined>(undefined);
|
const currentUserId = ref<number | undefined>(undefined);
|
||||||
const currentUser = ref<User | undefined>(undefined);
|
const currentUser = ref<User | undefined>(undefined);
|
||||||
|
|
||||||
|
// 用户数配额(由平台端配置:默认 20,可增购)
|
||||||
|
const quotaInfo = ref<any>(null);
|
||||||
|
const quotaFull = computed(
|
||||||
|
() =>
|
||||||
|
!!quotaInfo.value &&
|
||||||
|
Number(quotaInfo.value.quota) > 0 &&
|
||||||
|
Number(quotaInfo.value.used) >= Number(quotaInfo.value.quota)
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchQuotaInfo = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getTenantQuotaInfo();
|
||||||
|
if (res?.code === 200) {
|
||||||
|
quotaInfo.value = res.data || null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
quotaInfo.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
//刷新
|
//刷新
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
|
fetchQuotaInfo();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取角色列表
|
// 获取角色列表
|
||||||
@@ -206,6 +237,15 @@ const fetchUsers = async () => {
|
|||||||
|
|
||||||
// 添加用户
|
// 添加用户
|
||||||
const handleAddUser = () => {
|
const handleAddUser = () => {
|
||||||
|
// 用户数达到上限时不允许再新增,提示联系平台增购
|
||||||
|
if (quotaFull.value) {
|
||||||
|
ElMessageBox.alert(
|
||||||
|
`当前租户用户数已达上限(${quotaInfo.value.used}/${quotaInfo.value.quota}),请联系平台管理员增购用户数。`,
|
||||||
|
"用户数已达上限",
|
||||||
|
{ type: "warning", confirmButtonText: "我知道了" }
|
||||||
|
).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
editDialogVisible.value = true;
|
editDialogVisible.value = true;
|
||||||
if (userEditRef.value) {
|
if (userEditRef.value) {
|
||||||
@@ -243,6 +283,7 @@ const handleEditSuccess = () => {
|
|||||||
editDialogVisible.value = false;
|
editDialogVisible.value = false;
|
||||||
ElMessage.success(isEdit.value ? "编辑成功" : "添加成功");
|
ElMessage.success(isEdit.value ? "编辑成功" : "添加成功");
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
fetchQuotaInfo();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 密码修改成功回调
|
// 密码修改成功回调
|
||||||
@@ -268,6 +309,7 @@ const handleDelete = async (user: User) => {
|
|||||||
await deleteUser(user.id);
|
await deleteUser(user.id);
|
||||||
ElMessage.success("删除成功");
|
ElMessage.success("删除成功");
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
fetchQuotaInfo();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error("删除失败");
|
ElMessage.error("删除失败");
|
||||||
}
|
}
|
||||||
@@ -278,6 +320,7 @@ onMounted(async () => {
|
|||||||
selectedTenantId.value = Number(authStore.user.tid || 0);
|
selectedTenantId.value = Number(authStore.user.tid || 0);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
fetchRoles();
|
fetchRoles();
|
||||||
|
fetchQuotaInfo();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -286,6 +329,15 @@ onMounted(async () => {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quota-tag {
|
||||||
|
margin-left: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEKzCCAxOgAwIBAgIUSiAuYZmyPcbL72sYhc5224ydH6MwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
|
||||||
|
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
|
||||||
|
Q0EwHhcNMjYwOTE1MDcyMjQzWhcNMzEwOTE0MDcyMjQzWjCBhDETMBEGA1UEAwwK
|
||||||
|
MTYxMDU0NDY0ODEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTAwLgYDVQQL
|
||||||
|
DCfov57kupHmuK/kupHms73lub/lkYrkvKDlqpLmnInpmZDlhazlj7gxCzAJBgNV
|
||||||
|
BAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEP
|
||||||
|
ADCCAQoCggEBAKaX6Mr2rQvDSWa2JFfJnwerZjiSV2wiZUvGUF23YtyArHNBaUef
|
||||||
|
kYps1cMmGAN43+Vc1DPd4dkRxqs8LQzDOY4Qwk47/XPICOaOu02x9fafMUDYixgq
|
||||||
|
RTd6lBOxOt9clN86cR8rU8fnh9XJSya4R1eFgkH35LEPa0qwGpF+W5OfXmtKTSam
|
||||||
|
6ffsIvWIns0zb9m8J584Rs5Sunjco6rlHXNQC40oejCXKRfIrQlGnEJA5rYwHl5u
|
||||||
|
jsdVuigLQew1bP9Z3w5e5FfUXfCAhFO5tG8bLJRW74m9fIiU782xadlidvjGdx0w
|
||||||
|
NHxK6wTaa01qXuAmB7oJOj7r3zBvW9Jmct0CAwEAAaOBuTCBtjAJBgNVHRMEAjAA
|
||||||
|
MAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2
|
||||||
|
Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJD
|
||||||
|
MDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJC
|
||||||
|
MjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQB5
|
||||||
|
VOT+FUud8JYeVJrKjXWiL3UT0Pu5t2rXa/frpm2JW/esIlv7x8Q6uGB0aH49L/g4
|
||||||
|
zvDGAerg33e5sfMwzD5JlQX/C22jNM5q1tvDTfA7a5PJDc2nCUmLiBGmuj5Ga6m0
|
||||||
|
6QCbLnE8BEx6bJrH49VCeopQ42fPlzVFVCSnslPrQu+digXLKT3YNO7Lw8bwNpZ1
|
||||||
|
3iciVkdiV5ooFOZusohnhX6CluzXr/lyqDDnHobyy87yGOkbl8nnwCt1yauH2im1
|
||||||
|
yezUPcs83IcTbfLZKBEFykJsWKc3ORiss6cCNXQ327+2VjyMq0L8yiLwpbb4iGcF
|
||||||
|
ZJVKyyn+bDfYhA3a5+xy
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCml+jK9q0Lw0lm
|
||||||
|
tiRXyZ8Hq2Y4kldsImVLxlBdt2LcgKxzQWlHn5GKbNXDJhgDeN/lXNQz3eHZEcar
|
||||||
|
PC0MwzmOEMJOO/1zyAjmjrtNsfX2nzFA2IsYKkU3epQTsTrfXJTfOnEfK1PH54fV
|
||||||
|
yUsmuEdXhYJB9+SxD2tKsBqRfluTn15rSk0mpun37CL1iJ7NM2/ZvCefOEbOUrp4
|
||||||
|
3KOq5R1zUAuNKHowlykXyK0JRpxCQOa2MB5ebo7HVbooC0HsNWz/Wd8OXuRX1F3w
|
||||||
|
gIRTubRvGyyUVu+JvXyIlO/NsWnZYnb4xncdMDR8SusE2mtNal7gJge6CTo+698w
|
||||||
|
b1vSZnLdAgMBAAECggEAG+0XHv7e3zvrUAZ0bSrlN7npts4BTaUuaddinXMiZWhE
|
||||||
|
gEWUymbbVOMNqlr2Z46Zqa83+0HUnDi4nvfFbYBEY6Y3jDnOMNx1cYC3sTyFyMPC
|
||||||
|
0+X42yIq3DP2Ip7xCYRRiGIZmGoA71/Gi3Xk3SbC/s7omPpfIbc9+xUuPvl5jHND
|
||||||
|
SzU1c4/wJk+HZcNEMmrEb+L9Wh9or1eJyjwynfOTvK75sOWBo2kyLMKOwnqBlic4
|
||||||
|
hqedNGoWnNLqH9hxd+uUY9pSPRsutSHALh665OwmEGmLZQsl4kD2mVsRaFshpKZS
|
||||||
|
bSwiarlk6EscgSm4oiqYCUWHNXON2kduArb0/S3wwQKBgQDeEV68kYQxjywcTxsK
|
||||||
|
gv9y2sulFjo8cIC4+Q5a6OimhHkEkNfwh4Oyyn960CXXfnlttdYDhZyUOM1I+sqh
|
||||||
|
cgMrwynFGU2rc8iviYI/1j1D5s/rOkicz9kWthYUhZtet189nOjdXidL/6aHkEGj
|
||||||
|
v2gmGzMThinE8NutjlUR81PqOQKBgQDADItnXbCvaJ4mryCurO+lUSnl6sxLXNYg
|
||||||
|
lwPoDKR7ZqTrHnKvwwPzzz/i7J69YsaeMeO7ZlQrJPAFvC0RoA/Ck5oUGTMPxy/E
|
||||||
|
9kgcyn/dhyNRyiC13lE/IgLS0Bzk9/nU2BnS0Ssf6ECK4G9aROdfaGbxJoaxPhg0
|
||||||
|
AbaPrqDdxQKBgE31LRvnvc+kImkhwC11hXXtp3YiEnmrSRUmocp6X0ot0UowCw6u
|
||||||
|
lCZM/mm/7rjy9m/0WNhNrIVAV5jaKg55NvHDBsPhuSmKcRZmoKoGbOWMfstmX51l
|
||||||
|
PiHB9Ihpyvx6RV4c2ob0CbmdWvnWQ6TXihlujQS6oak4oPYRtH2jvX7JAoGAJuOj
|
||||||
|
awgO2kZOGDlIXfbJBGn+GP/F5NDkz26lLN06hX6QkMf2o17TMxDZuAMAVgtleu1R
|
||||||
|
V0Zubtoo/JBRdAs8hcNL5RwBOgW0x6azjZj38Y0G+u36V7mwLB+gAL6By1/nqFEm
|
||||||
|
UYlnuStXvoeaJOZlD+3dQqHxjnyrfASIymmrS50CgYEAgHjnKRwTK5RexE6qJDf3
|
||||||
|
QoKT+B+t5rrLtzaGgxTGBqf67bhWTBu6TgMVe/9am+K8dbZDZP+bMeDJ9j+MSnIb
|
||||||
|
W/359RaTI7TNI+Tx9aipXRFDtduhXTaOhsvRgDk6+s6rhhzfYmNYXqhclQFkQDFy
|
||||||
|
DsjbwjonGVj5L+GLV3EenuE=
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
+1
-1
@@ -61,4 +61,4 @@ payment_secret_key = f656fe85ed6a1caba9f19966d8cdde46114709969bea5875cde7664d4ad
|
|||||||
# TODO: 填你自己服务器的公网 HTTPS 域名(如 https://api.yunzer.com.cn),不是申请来的。
|
# TODO: 填你自己服务器的公网 HTTPS 域名(如 https://api.yunzer.com.cn),不是申请来的。
|
||||||
# 要求:公网可访问、HTTPS、无鉴权无重定向;微信 JSAPI 还需在商户平台「支付授权目录」登记同一域名。
|
# 要求:公网可访问、HTTPS、无鉴权无重定向;微信 JSAPI 还需在商户平台「支付授权目录」登记同一域名。
|
||||||
# 本地开发收不到渠道回调,留空即可(回调地址退化为相对路径 /api/payment/callback/{渠道},由网关补齐域名)。
|
# 本地开发收不到渠道回调,留空即可(回调地址退化为相对路径 /api/payment/callback/{渠道},由网关补齐域名)。
|
||||||
payment_callback_base = https://api.yunzer.com.cn
|
payment_callback_base = https://api.yunzer.cn
|
||||||
@@ -2,6 +2,7 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -373,6 +374,20 @@ func (c *BackendAdminUserController) AddUser() {
|
|||||||
status = *p.Status
|
status = *p.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用户数配额校验:达到上限后需要平台端为租户增购用户数才能继续添加
|
||||||
|
if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
} else if !check.Allowed {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 400,
|
||||||
|
"msg": fmt.Sprintf("当前租户用户数已达上限(%d/%d),请联系平台管理员增购用户数", check.Used, check.Quota),
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id, err := services.BindTenantUser(
|
id, err := services.BindTenantUser(
|
||||||
p.Tid,
|
p.Tid,
|
||||||
p.Uid,
|
p.Uid,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"server/models"
|
"server/models"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
"server/services"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -91,12 +93,15 @@ func (c *BackendMenuController) GetBackendMenu() {
|
|||||||
// 否则仅返回角色允许访问的菜单(含其祖先节点,保证树结构完整)。
|
// 否则仅返回角色允许访问的菜单(含其祖先节点,保证树结构完整)。
|
||||||
idStr := c.Ctx.Input.Param(":id")
|
idStr := c.Ctx.Input.Param(":id")
|
||||||
uid, _ := strconv.ParseUint(idStr, 10, 64)
|
uid, _ := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
tid := uint64(0)
|
||||||
if uid > 0 {
|
if uid > 0 {
|
||||||
var tu models.SystemTenantUser
|
var tu models.SystemTenantUser
|
||||||
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
Filter("id", uid).
|
Filter("id", uid).
|
||||||
Filter("delete_time__isnull", true).
|
Filter("delete_time__isnull", true).
|
||||||
One(&tu); e == nil && tu.GroupID > 0 {
|
One(&tu); e == nil {
|
||||||
|
tid = tu.Tid
|
||||||
|
if tu.GroupID > 0 {
|
||||||
var role models.AdminRole
|
var role models.AdminRole
|
||||||
if re := models.Orm.QueryTable(new(models.AdminRole)).
|
if re := models.Orm.QueryTable(new(models.AdminRole)).
|
||||||
Filter("id", tu.GroupID).
|
Filter("id", tu.GroupID).
|
||||||
@@ -106,11 +111,56 @@ func (c *BackendMenuController) GetBackendMenu() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 套餐功能开通过滤:仅展示租户套餐已包含的功能模块对应的菜单(未包含的整块菜单不展示)
|
||||||
|
backendMenus = filterMenusByTenantPackage(c.Ctx.Request.Header.Get("Authorization"), backendMenus, tid)
|
||||||
|
|
||||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(backendMenus, 0)}
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(backendMenus, 0)}
|
||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backendJWTTenantID 从 Authorization 的 Bearer Token 中解析租户ID;取不到时返回 0。
|
||||||
|
// 说明:backend 端登录时 token 里写入了租户ID(GenerateToken 的 tenantId),
|
||||||
|
// 用它做套餐功能过滤比依赖前端传参更可靠。
|
||||||
|
func backendJWTTenantID(auth string) uint64 {
|
||||||
|
auth = strings.TrimSpace(auth)
|
||||||
|
if auth == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
claims, err := jwtutil.ParseToken(parts[1])
|
||||||
|
if err != nil || claims == nil || claims.TenantId <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return uint64(claims.TenantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterMenusByTenantPackage 按租户套餐包含的功能模块过滤菜单。
|
||||||
|
// tid 优先取 token 中的租户ID,其次用调用方传入的兜底值;两者都取不到时不处理(保持原行为)。
|
||||||
|
func filterMenusByTenantPackage(authHeader string, menus []models.SystemMenu, tid uint64) []models.SystemMenu {
|
||||||
|
if jwtTid := backendJWTTenantID(authHeader); jwtTid > 0 {
|
||||||
|
tid = jwtTid
|
||||||
|
}
|
||||||
|
if tid == 0 {
|
||||||
|
return menus
|
||||||
|
}
|
||||||
|
allModules, err := services.ListEnabledModules()
|
||||||
|
if err != nil || len(allModules) == 0 {
|
||||||
|
return menus
|
||||||
|
}
|
||||||
|
allowed := services.GetTenantModuleCodes(tid)
|
||||||
|
// 安全兜底:租户没有绑定套餐或套餐未配置任何功能模块时不做过滤,
|
||||||
|
// 避免因套餐数据缺失导致租户端菜单整体消失。
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return menus
|
||||||
|
}
|
||||||
|
return services.FilterMenusByTenantModules(menus, allModules, allowed)
|
||||||
|
}
|
||||||
|
|
||||||
// parseRightsToSet 将角色 rights 解析为菜单 ID 集合(兼容 JSON 数组 / 逗号分隔)
|
// parseRightsToSet 将角色 rights 解析为菜单 ID 集合(兼容 JSON 数组 / 逗号分隔)
|
||||||
func parseRightsToSet(raw string) map[uint64]bool {
|
func parseRightsToSet(raw string) map[uint64]bool {
|
||||||
set := map[uint64]bool{}
|
set := map[uint64]bool{}
|
||||||
@@ -267,6 +317,10 @@ func (c *BackendMenuController) GetAssignableMenus() {
|
|||||||
menus = filterMenusByView(menus, cid)
|
menus = filterMenusByView(menus, cid)
|
||||||
}
|
}
|
||||||
menus = filterAssignableMenus(menus)
|
menus = filterAssignableMenus(menus)
|
||||||
|
// 租户端:仅可分配当前租户套餐已开通的功能模块下的菜单
|
||||||
|
if cid == 2 {
|
||||||
|
menus = filterMenusByTenantPackage(c.Ctx.Request.Header.Get("Authorization"), menus, 0)
|
||||||
|
}
|
||||||
|
|
||||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(menus, 0)}
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(menus, 0)}
|
||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"server/services"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackendTenantQuotaController 租户端「用户数配额」信息(只读)
|
||||||
|
// 供租户端用户管理页展示「已用/上限」;增购由平台端操作。
|
||||||
|
type BackendTenantQuotaController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuotaInfo GET /backend/tenant/quotaInfo
|
||||||
|
// 返回当前登录租户的用户数使用情况与生效套餐。
|
||||||
|
func (c *BackendTenantQuotaController) GetQuotaInfo() {
|
||||||
|
tid := backendJWTTenantID(c.Ctx.Request.Header.Get("Authorization"))
|
||||||
|
if tid == 0 {
|
||||||
|
// 兜底:从 tid 参数取(部分历史调用未带 token 场景)
|
||||||
|
if v, err := c.GetUint64("tid"); err == nil {
|
||||||
|
tid = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tid == 0 {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 401, "msg": "未登录或非法请求"}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := services.GetTenantQuotaInfo(tid)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "租户不存在"}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modules, _ := services.GetTenantPackageModules(tid)
|
||||||
|
moduleCodes := make([]string, 0, len(modules))
|
||||||
|
moduleNames := make([]string, 0, len(modules))
|
||||||
|
for _, m := range modules {
|
||||||
|
moduleCodes = append(moduleCodes, m.ModuleCode)
|
||||||
|
moduleNames = append(moduleNames, m.ModuleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"tid": info.Tid,
|
||||||
|
"package_id": info.EffectivePackageID,
|
||||||
|
"package_name": info.PackageName,
|
||||||
|
"quota": info.Quota,
|
||||||
|
"used": info.Used,
|
||||||
|
"remaining": info.Remaining,
|
||||||
|
"extra_user_price": info.ExtraUserPrice,
|
||||||
|
"module_codes": moduleCodes,
|
||||||
|
"module_names": moduleNames,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
|
"server/services"
|
||||||
|
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
)
|
)
|
||||||
@@ -27,12 +29,45 @@ type tenantDTO struct {
|
|||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Worktime string `json:"worktime"`
|
Worktime string `json:"worktime"`
|
||||||
Status int8 `json:"status"`
|
Status int8 `json:"status"`
|
||||||
|
// 套餐与用户数配额
|
||||||
|
PackageID uint64 `json:"package_id"`
|
||||||
|
PackageName string `json:"package_name"`
|
||||||
|
UserQuota int `json:"user_quota"`
|
||||||
|
UserUsed int `json:"user_used"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||||
UpdateTime *time.Time `json:"update_time,omitempty"`
|
UpdateTime *time.Time `json:"update_time,omitempty"`
|
||||||
DeleteTime *time.Time `json:"delete_time,omitempty"`
|
DeleteTime *time.Time `json:"delete_time,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tenantPackageNameMap 套餐ID -> 套餐名称
|
||||||
|
func tenantPackageNameMap() map[uint64]string {
|
||||||
|
result := map[uint64]string{}
|
||||||
|
var rows []models.SystemTenantPackage
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
All(&rows, "ID", "Name"); err == nil {
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.ID] = r.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenantUserCountMap 租户ID -> 有效用户数
|
||||||
|
func tenantUserCountMap() map[uint64]int {
|
||||||
|
result := map[uint64]int{}
|
||||||
|
var rows []models.SystemTenantUser
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
All(&rows, "ID", "Tid"); err == nil {
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.Tid]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func stringValue(s *string) string {
|
func stringValue(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -57,6 +92,8 @@ func toTenantDTO(t models.SystemTenant) tenantDTO {
|
|||||||
Address: stringValue(t.Address),
|
Address: stringValue(t.Address),
|
||||||
Worktime: stringValue(t.Worktime),
|
Worktime: stringValue(t.Worktime),
|
||||||
Status: t.Status,
|
Status: t.Status,
|
||||||
|
PackageID: t.PackageID,
|
||||||
|
UserQuota: t.UserQuota,
|
||||||
Remark: stringValue(t.Remark),
|
Remark: stringValue(t.Remark),
|
||||||
CreateTime: &ct,
|
CreateTime: &ct,
|
||||||
UpdateTime: &ut,
|
UpdateTime: &ut,
|
||||||
@@ -110,9 +147,14 @@ func (c *PlatformTenantController) GetTenant() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
packageNames := tenantPackageNameMap()
|
||||||
|
userCounts := tenantUserCountMap()
|
||||||
list := make([]tenantDTO, 0, len(rows))
|
list := make([]tenantDTO, 0, len(rows))
|
||||||
for _, t := range rows {
|
for _, t := range rows {
|
||||||
list = append(list, toTenantDTO(t))
|
dto := toTenantDTO(t)
|
||||||
|
dto.PackageName = packageNames[t.PackageID]
|
||||||
|
dto.UserUsed = userCounts[t.ID]
|
||||||
|
list = append(list, dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
@@ -144,10 +186,19 @@ func (c *PlatformTenantController) GetTenantDetail() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dto := toTenantDTO(t)
|
||||||
|
if pkg, err := services.GetTenantPackage(t.ID); err == nil && pkg != nil {
|
||||||
|
dto.PackageID = pkg.ID
|
||||||
|
dto.PackageName = pkg.Name
|
||||||
|
}
|
||||||
|
if used, err := services.CountTenantUsers(t.ID); err == nil {
|
||||||
|
dto.UserUsed = used
|
||||||
|
}
|
||||||
|
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"msg": "success",
|
"msg": "success",
|
||||||
"data": toTenantDTO(t),
|
"data": dto,
|
||||||
}
|
}
|
||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
@@ -161,6 +212,9 @@ type tenantPayload struct {
|
|||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Worktime string `json:"worktime"`
|
Worktime string `json:"worktime"`
|
||||||
Status *int8 `json:"status"`
|
Status *int8 `json:"status"`
|
||||||
|
// 套餐与用户数上限(可选)
|
||||||
|
PackageID *uint64 `json:"package_id"`
|
||||||
|
UserQuota *int `json:"user_quota"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +236,16 @@ func (c *PlatformTenantController) parseTenantPayload() (tenantPayload, error) {
|
|||||||
p.Status = &tmp
|
p.Status = &tmp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s := strings.TrimSpace(c.GetString("package_id")); s != "" {
|
||||||
|
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
|
||||||
|
p.PackageID = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(c.GetString("user_quota")); s != "" {
|
||||||
|
if v, err := strconv.Atoi(s); err == nil {
|
||||||
|
p.UserQuota = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ĺŚćĺ
łéŽĺ掾为犺ďźĺ°čŻäť JSON body č§ŁćďźeditTenant éťčޤ JSONďź?
|
// ĺŚćĺ
łéŽĺ掾为犺ďźĺ°čŻäť JSON body č§ŁćďźeditTenant éťčޤ JSONďź?
|
||||||
if p.TenantName == "" && p.TenantCode == "" {
|
if p.TenantName == "" && p.TenantCode == "" {
|
||||||
@@ -226,6 +290,36 @@ func (c *PlatformTenantController) CreateTenant() {
|
|||||||
status = *p.Status
|
status = *p.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 套餐与用户数上限:未指定套餐时使用默认套餐,用户数取其包含用户数(兜底 20)
|
||||||
|
packageID := uint64(0)
|
||||||
|
userQuota := services.DefaultTenantUserQuota
|
||||||
|
if p.PackageID != nil && *p.PackageID > 0 {
|
||||||
|
packageID = *p.PackageID
|
||||||
|
}
|
||||||
|
if packageID == 0 {
|
||||||
|
if pkg, err := services.GetDefaultTenantPackage(); err == nil && pkg != nil {
|
||||||
|
packageID = pkg.ID
|
||||||
|
if pkg.UserQuota > 0 {
|
||||||
|
userQuota = pkg.UserQuota
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if packageID > 0 {
|
||||||
|
var pkg models.SystemTenantPackage
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("id", packageID).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&pkg); err == nil && pkg.UserQuota > 0 {
|
||||||
|
userQuota = pkg.UserQuota
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.UserQuota != nil && *p.UserQuota > 0 {
|
||||||
|
userQuota = *p.UserQuota
|
||||||
|
}
|
||||||
|
if userQuota <= 0 {
|
||||||
|
userQuota = services.DefaultTenantUserQuota
|
||||||
|
}
|
||||||
|
|
||||||
t := models.SystemTenant{
|
t := models.SystemTenant{
|
||||||
TenantCode: p.TenantCode,
|
TenantCode: p.TenantCode,
|
||||||
TenantName: p.TenantName,
|
TenantName: p.TenantName,
|
||||||
@@ -235,6 +329,8 @@ func (c *PlatformTenantController) CreateTenant() {
|
|||||||
Address: stringPtr(p.Address),
|
Address: stringPtr(p.Address),
|
||||||
Worktime: stringPtr(p.Worktime),
|
Worktime: stringPtr(p.Worktime),
|
||||||
Status: status,
|
Status: status,
|
||||||
|
PackageID: packageID,
|
||||||
|
UserQuota: userQuota,
|
||||||
Remark: stringPtr(p.Remark),
|
Remark: stringPtr(p.Remark),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +378,37 @@ func (c *PlatformTenantController) EditTenant() {
|
|||||||
if p.Status != nil {
|
if p.Status != nil {
|
||||||
update["status"] = *p.Status
|
update["status"] = *p.Status
|
||||||
}
|
}
|
||||||
|
if p.PackageID != nil {
|
||||||
|
pkgID := *p.PackageID
|
||||||
|
if pkgID > 0 {
|
||||||
|
var pkg models.SystemTenantPackage
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("id", pkgID).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&pkg); err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "套餐不存在"}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update["package_id"] = pkgID
|
||||||
|
}
|
||||||
|
if p.UserQuota != nil {
|
||||||
|
// 用户数上限只增不减:不允许调低到当前已开用户数以下
|
||||||
|
used, _ := services.CountTenantUsers(id)
|
||||||
|
quota := *p.UserQuota
|
||||||
|
if quota > 0 && quota < used {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 400,
|
||||||
|
"msg": fmt.Sprintf("用户数上限不能小于该租户已开用户数(%d)", used),
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if quota > 0 {
|
||||||
|
update["user_quota"] = quota
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err = models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", id).Update(update)
|
_, err = models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", id).Update(update)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,837 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
"server/services"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlatformTenantPackageController 平台端「租户套餐」管理
|
||||||
|
//
|
||||||
|
// 两块能力:
|
||||||
|
// 1. 功能套餐(yz_system_tenant_package + yz_system_tenant_package_module):
|
||||||
|
// 勾选套餐包含的功能模块(erp / oa / crm / cms ...),租户绑定后按其开通功能。
|
||||||
|
// 2. 用户数配额(yz_system_tenant_user_quota_package + yz_system_tenant_quota_order):
|
||||||
|
// 每个租户有用户数上限(默认 20),支持按单个用户或按加购套餐增购。
|
||||||
|
type PlatformTenantPackageController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformTenantPackageController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||||
|
c.Ctx.Output.SetStatus(httpStatus)
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformTenantPackageController) jsonOK(data interface{}, msg string) {
|
||||||
|
if msg == "" {
|
||||||
|
msg = "success"
|
||||||
|
}
|
||||||
|
resp := map[string]interface{}{"code": 200, "msg": msg}
|
||||||
|
if data != nil {
|
||||||
|
resp["data"] = data
|
||||||
|
}
|
||||||
|
c.Data["json"] = resp
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentOperator 解析当前平台账号名(用于增购记录留痕;取不到时返回空串,不阻断请求)
|
||||||
|
func (c *PlatformTenantPackageController) currentOperator() string {
|
||||||
|
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
claims, err := jwtutil.ParseToken(parts[1])
|
||||||
|
if err != nil || claims == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(claims.Username) != "" {
|
||||||
|
return claims.Username
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformTenantPackageController) readBody(target interface{}) bool {
|
||||||
|
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, target); err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUint64Param(raw string) uint64 {
|
||||||
|
v, _ := strconv.ParseUint(strings.TrimSpace(raw), 10, 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 功能套餐 ============================
|
||||||
|
|
||||||
|
type tenantPackageModuleItem struct {
|
||||||
|
ModuleCode string `json:"module_code"`
|
||||||
|
ModuleName string `json:"module_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantPackageDTO struct {
|
||||||
|
models.SystemTenantPackage
|
||||||
|
Modules []tenantPackageModuleItem `json:"modules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantPackagePayload struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Price *float64 `json:"price"`
|
||||||
|
UserQuota *int `json:"user_quota"`
|
||||||
|
ExtraUserPrice *float64 `json:"extra_user_price"`
|
||||||
|
IsDefault *int8 `json:"is_default"`
|
||||||
|
Sort *int `json:"sort"`
|
||||||
|
Status *int8 `json:"status"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
Modules []string `json:"modules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPackageModules 批量加载套餐模块(packageID -> 模块列表)
|
||||||
|
func loadPackageModules(packageIDs []uint64) map[uint64][]tenantPackageModuleItem {
|
||||||
|
result := map[uint64][]tenantPackageModuleItem{}
|
||||||
|
if len(packageIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var rows []models.SystemTenantPackageModule
|
||||||
|
_, err := models.Orm.QueryTable(new(models.SystemTenantPackageModule)).
|
||||||
|
Filter("package_id__in", packageIDs).
|
||||||
|
OrderBy("id").
|
||||||
|
All(&rows)
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.PackageID] = append(result[r.PackageID], tenantPackageModuleItem{
|
||||||
|
ModuleCode: r.ModuleCode,
|
||||||
|
ModuleName: r.ModuleName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackageList GET /platform/tenantPackage/list
|
||||||
|
func (c *PlatformTenantPackageController) GetPackageList() {
|
||||||
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
status := strings.TrimSpace(c.GetString("status"))
|
||||||
|
|
||||||
|
qs := models.Orm.QueryTable(new(models.SystemTenantPackage)).Filter("delete_time__isnull", true)
|
||||||
|
if keyword != "" {
|
||||||
|
qs = qs.Filter("name__icontains", keyword)
|
||||||
|
}
|
||||||
|
if status == "0" || status == "1" {
|
||||||
|
v, _ := strconv.Atoi(status)
|
||||||
|
qs = qs.Filter("status", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []models.SystemTenantPackage
|
||||||
|
if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]uint64, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
ids = append(ids, r.ID)
|
||||||
|
}
|
||||||
|
moduleMap := loadPackageModules(ids)
|
||||||
|
|
||||||
|
// 每个套餐的租户数
|
||||||
|
tenantCount := map[uint64]int{}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
var tenants []models.SystemTenant
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.SystemTenant)).All(&tenants, "ID", "PackageID"); err == nil {
|
||||||
|
for _, t := range tenants {
|
||||||
|
tenantCount[t.PackageID]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
modules := moduleMap[r.ID]
|
||||||
|
if modules == nil {
|
||||||
|
modules = []tenantPackageModuleItem{}
|
||||||
|
}
|
||||||
|
list = append(list, map[string]interface{}{
|
||||||
|
"id": r.ID,
|
||||||
|
"name": r.Name,
|
||||||
|
"code": r.Code,
|
||||||
|
"description": r.Description,
|
||||||
|
"price": r.Price,
|
||||||
|
"user_quota": r.UserQuota,
|
||||||
|
"extra_user_price": r.ExtraUserPrice,
|
||||||
|
"is_default": r.IsDefault,
|
||||||
|
"sort": r.Sort,
|
||||||
|
"status": r.Status,
|
||||||
|
"remark": r.Remark,
|
||||||
|
"create_time": r.CreateTime,
|
||||||
|
"update_time": r.UpdateTime,
|
||||||
|
"modules": modules,
|
||||||
|
"module_codes": moduleCodes(modules),
|
||||||
|
"tenant_count": tenantCount[r.ID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.jsonOK(map[string]interface{}{"list": list, "total": len(list)}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleCodes(items []tenantPackageModuleItem) []string {
|
||||||
|
codes := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
codes = append(codes, it.ModuleCode)
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackageSelectList GET /platform/tenantPackage/select/list
|
||||||
|
func (c *PlatformTenantPackageController) GetPackageSelectList() {
|
||||||
|
var rows []models.SystemTenantPackage
|
||||||
|
_, err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Filter("status", 1).
|
||||||
|
OrderBy("sort", "id").
|
||||||
|
All(&rows)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]uint64, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
ids = append(ids, r.ID)
|
||||||
|
}
|
||||||
|
moduleMap := loadPackageModules(ids)
|
||||||
|
|
||||||
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
modules := moduleMap[r.ID]
|
||||||
|
if modules == nil {
|
||||||
|
modules = []tenantPackageModuleItem{}
|
||||||
|
}
|
||||||
|
list = append(list, map[string]interface{}{
|
||||||
|
"id": r.ID,
|
||||||
|
"name": r.Name,
|
||||||
|
"code": r.Code,
|
||||||
|
"description": r.Description,
|
||||||
|
"price": r.Price,
|
||||||
|
"user_quota": r.UserQuota,
|
||||||
|
"extra_user_price": r.ExtraUserPrice,
|
||||||
|
"is_default": r.IsDefault,
|
||||||
|
"modules": modules,
|
||||||
|
"module_codes": moduleCodes(modules),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.jsonOK(list, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackageDetail GET /platform/tenantPackage/detail/:id
|
||||||
|
func (c *PlatformTenantPackageController) GetPackageDetail() {
|
||||||
|
id := parseUint64Param(c.Ctx.Input.Param(":id"))
|
||||||
|
if id == 0 {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row models.SystemTenantPackage
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&row); err != nil {
|
||||||
|
c.jsonErr(404, 404, "套餐不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modules := loadPackageModules([]uint64{id})[id]
|
||||||
|
if modules == nil {
|
||||||
|
modules = []tenantPackageModuleItem{}
|
||||||
|
}
|
||||||
|
c.jsonOK(tenantPackageDTO{SystemTenantPackage: row, Modules: modules}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModuleOptions GET /platform/tenantPackage/moduleOptions
|
||||||
|
// 可加入套餐的功能模块清单(取 yz_system_modules 中启用且未删除的模块)
|
||||||
|
func (c *PlatformTenantPackageController) GetModuleOptions() {
|
||||||
|
var rows []models.SystemModules
|
||||||
|
_, err := models.Orm.QueryTable(new(models.SystemModules)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Filter("status", 1).
|
||||||
|
OrderBy("sort", "id").
|
||||||
|
All(&rows)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
list = append(list, map[string]interface{}{
|
||||||
|
"id": r.ID,
|
||||||
|
"name": r.Name,
|
||||||
|
"code": r.Code,
|
||||||
|
"path": r.Path,
|
||||||
|
"icon": r.Icon,
|
||||||
|
"description": r.Description,
|
||||||
|
"type": r.Type,
|
||||||
|
"sort": r.Sort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"list": list, "total": len(list)}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// savePackageModules 覆盖式保存套餐模块
|
||||||
|
func savePackageModules(packageID uint64, modules []string) error {
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.SystemTenantPackageModule)).
|
||||||
|
Filter("package_id", packageID).
|
||||||
|
Delete(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(modules) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
nameMap := map[string]string{}
|
||||||
|
var allModules []models.SystemModules
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.SystemModules)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
All(&allModules, "ID", "Name", "Code"); err == nil {
|
||||||
|
for _, m := range allModules {
|
||||||
|
nameMap[m.Code] = m.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, code := range modules {
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
if code == "" || seen[code] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[code] = true
|
||||||
|
row := &models.SystemTenantPackageModule{
|
||||||
|
PackageID: packageID,
|
||||||
|
ModuleCode: code,
|
||||||
|
ModuleName: nameMap[code],
|
||||||
|
}
|
||||||
|
if _, err := models.Orm.Insert(row); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePackage POST /platform/tenantPackage/create
|
||||||
|
func (c *PlatformTenantPackageController) CreatePackage() {
|
||||||
|
var p tenantPackagePayload
|
||||||
|
if !c.readBody(&p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Name = strings.TrimSpace(p.Name)
|
||||||
|
p.Code = strings.TrimSpace(p.Code)
|
||||||
|
if p.Name == "" {
|
||||||
|
c.jsonErr(400, 400, "套餐名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Code == "" {
|
||||||
|
c.jsonErr(400, 400, "套餐编码不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cnt, _ := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("code", p.Code).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Count()
|
||||||
|
if cnt > 0 {
|
||||||
|
c.jsonErr(400, 400, "套餐编码已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
row := &models.SystemTenantPackage{
|
||||||
|
Name: p.Name,
|
||||||
|
Code: p.Code,
|
||||||
|
Description: strings.TrimSpace(p.Description),
|
||||||
|
Price: valueFloat(p.Price, 0),
|
||||||
|
UserQuota: valueInt(p.UserQuota, services.DefaultTenantUserQuota),
|
||||||
|
ExtraUserPrice: valueFloat(p.ExtraUserPrice, services.DefaultTenantExtraUserPrice),
|
||||||
|
IsDefault: valueInt8(p.IsDefault, 0),
|
||||||
|
Sort: valueInt(p.Sort, 0),
|
||||||
|
Status: valueInt8(p.Status, 1),
|
||||||
|
Remark: strings.TrimSpace(p.Remark),
|
||||||
|
UpdateTime: &now,
|
||||||
|
}
|
||||||
|
if row.UserQuota <= 0 {
|
||||||
|
row.UserQuota = services.DefaultTenantUserQuota
|
||||||
|
}
|
||||||
|
id, err := models.Orm.Insert(row)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "创建失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := savePackageModules(uint64(id), p.Modules); err != nil {
|
||||||
|
c.jsonErr(500, 500, "套餐功能保存失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.IsDefault == 1 {
|
||||||
|
clearOtherDefaultPackages(uint64(id))
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"id": uint64(id)}, "创建成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditPackage POST /platform/tenantPackage/edit/:id
|
||||||
|
func (c *PlatformTenantPackageController) EditPackage() {
|
||||||
|
id := parseUint64Param(c.Ctx.Input.Param(":id"))
|
||||||
|
if id == 0 {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p tenantPackagePayload
|
||||||
|
if !c.readBody(&p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Name = strings.TrimSpace(p.Name)
|
||||||
|
p.Code = strings.TrimSpace(p.Code)
|
||||||
|
if p.Name == "" {
|
||||||
|
c.jsonErr(400, 400, "套餐名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Code != "" {
|
||||||
|
cnt, _ := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("code", p.Code).
|
||||||
|
Filter("id__ne", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Count()
|
||||||
|
if cnt > 0 {
|
||||||
|
c.jsonErr(400, 400, "套餐编码已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
update := map[string]interface{}{
|
||||||
|
"name": p.Name,
|
||||||
|
"description": strings.TrimSpace(p.Description),
|
||||||
|
"extra_user_price": valueFloat(p.ExtraUserPrice, services.DefaultTenantExtraUserPrice),
|
||||||
|
"sort": valueInt(p.Sort, 0),
|
||||||
|
"status": valueInt8(p.Status, 1),
|
||||||
|
"remark": strings.TrimSpace(p.Remark),
|
||||||
|
"update_time": now,
|
||||||
|
}
|
||||||
|
if p.Code != "" {
|
||||||
|
update["code"] = p.Code
|
||||||
|
}
|
||||||
|
if p.Price != nil {
|
||||||
|
update["price"] = *p.Price
|
||||||
|
}
|
||||||
|
if p.UserQuota != nil {
|
||||||
|
quota := *p.UserQuota
|
||||||
|
if quota <= 0 {
|
||||||
|
quota = services.DefaultTenantUserQuota
|
||||||
|
}
|
||||||
|
update["user_quota"] = quota
|
||||||
|
}
|
||||||
|
if p.IsDefault != nil {
|
||||||
|
update["is_default"] = *p.IsDefault
|
||||||
|
update["status"] = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(update); err != nil {
|
||||||
|
c.jsonErr(500, 500, "编辑失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Modules != nil {
|
||||||
|
if err := savePackageModules(id, p.Modules); err != nil {
|
||||||
|
c.jsonErr(500, 500, "套餐功能保存失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.IsDefault != nil && *p.IsDefault == 1 {
|
||||||
|
clearOtherDefaultPackages(id)
|
||||||
|
}
|
||||||
|
c.jsonOK(nil, "编辑成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearOtherDefaultPackages 保证全局只有一个默认套餐
|
||||||
|
func clearOtherDefaultPackages(keepID uint64) {
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("id__ne", keepID).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(map[string]interface{}{"is_default": 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePackage DELETE /platform/tenantPackage/delete/:id(软删)
|
||||||
|
func (c *PlatformTenantPackageController) DeletePackage() {
|
||||||
|
id := parseUint64Param(c.Ctx.Input.Param(":id"))
|
||||||
|
if id == 0 {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 有租户正在使用该套餐时不允许删除
|
||||||
|
used, _ := models.Orm.QueryTable(new(models.SystemTenant)).Filter("package_id", id).Count()
|
||||||
|
if used > 0 {
|
||||||
|
c.jsonErr(400, 400, fmt.Sprintf("该套餐已被 %d 个租户使用,请先调整租户套餐", used))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
n, err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(map[string]interface{}{"delete_time": now, "update_time": now, "is_default": 0})
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "删除失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
c.jsonErr(404, 404, "套餐不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.SystemTenantPackageModule)).Filter("package_id", id).Delete()
|
||||||
|
c.jsonOK(nil, "删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueFloat(v *float64, def float64) float64 {
|
||||||
|
if v == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueInt(v *int, def int) int {
|
||||||
|
if v == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 用户数加购套餐 ============================
|
||||||
|
|
||||||
|
type quotaPackagePayload struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
UserCount *int `json:"user_count"`
|
||||||
|
Price *float64 `json:"price"`
|
||||||
|
Sort *int `json:"sort"`
|
||||||
|
Status *int8 `json:"status"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuotaPackageList GET /platform/tenantQuotaPackage/list
|
||||||
|
func (c *PlatformTenantPackageController) GetQuotaPackageList() {
|
||||||
|
onlyEnabled := c.GetString("enabled") == "1"
|
||||||
|
qs := models.Orm.QueryTable(new(models.SystemTenantUserQuotaPackage)).Filter("delete_time__isnull", true)
|
||||||
|
if onlyEnabled {
|
||||||
|
qs = qs.Filter("status", 1)
|
||||||
|
}
|
||||||
|
var rows []models.SystemTenantUserQuotaPackage
|
||||||
|
if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"list": rows, "total": len(rows)}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateQuotaPackage POST /platform/tenantQuotaPackage/create
|
||||||
|
func (c *PlatformTenantPackageController) CreateQuotaPackage() {
|
||||||
|
var p quotaPackagePayload
|
||||||
|
if !c.readBody(&p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Name = strings.TrimSpace(p.Name)
|
||||||
|
if p.Name == "" {
|
||||||
|
c.jsonErr(400, 400, "规格名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.UserCount == nil || *p.UserCount <= 0 {
|
||||||
|
c.jsonErr(400, 400, "包含用户数必须大于 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
row := &models.SystemTenantUserQuotaPackage{
|
||||||
|
Name: p.Name,
|
||||||
|
UserCount: *p.UserCount,
|
||||||
|
Price: valueFloat(p.Price, 0),
|
||||||
|
Sort: valueInt(p.Sort, 0),
|
||||||
|
Status: valueInt8(p.Status, 1),
|
||||||
|
Remark: strings.TrimSpace(p.Remark),
|
||||||
|
UpdateTime: &now,
|
||||||
|
}
|
||||||
|
id, err := models.Orm.Insert(row)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "创建失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"id": uint64(id)}, "创建成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditQuotaPackage POST /platform/tenantQuotaPackage/edit/:id
|
||||||
|
func (c *PlatformTenantPackageController) EditQuotaPackage() {
|
||||||
|
id := parseUint64Param(c.Ctx.Input.Param(":id"))
|
||||||
|
if id == 0 {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p quotaPackagePayload
|
||||||
|
if !c.readBody(&p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Name = strings.TrimSpace(p.Name)
|
||||||
|
if p.Name == "" {
|
||||||
|
c.jsonErr(400, 400, "规格名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.UserCount != nil && *p.UserCount <= 0 {
|
||||||
|
c.jsonErr(400, 400, "包含用户数必须大于 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
update := map[string]interface{}{
|
||||||
|
"name": p.Name,
|
||||||
|
"sort": valueInt(p.Sort, 0),
|
||||||
|
"status": valueInt8(p.Status, 1),
|
||||||
|
"remark": strings.TrimSpace(p.Remark),
|
||||||
|
"update_time": now,
|
||||||
|
}
|
||||||
|
if p.UserCount != nil {
|
||||||
|
update["user_count"] = *p.UserCount
|
||||||
|
}
|
||||||
|
if p.Price != nil {
|
||||||
|
update["price"] = *p.Price
|
||||||
|
}
|
||||||
|
n, err := models.Orm.QueryTable(new(models.SystemTenantUserQuotaPackage)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(update)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "编辑失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
c.jsonErr(404, 404, "规格不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(nil, "编辑成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteQuotaPackage DELETE /platform/tenantQuotaPackage/delete/:id(软删)
|
||||||
|
func (c *PlatformTenantPackageController) DeleteQuotaPackage() {
|
||||||
|
id := parseUint64Param(c.Ctx.Input.Param(":id"))
|
||||||
|
if id == 0 {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
n, err := models.Orm.QueryTable(new(models.SystemTenantUserQuotaPackage)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "删除失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
c.jsonErr(404, 404, "规格不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(nil, "删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 租户套餐 / 用户数配额 ============================
|
||||||
|
|
||||||
|
type setTenantPackagePayload struct {
|
||||||
|
Tid uint64 `json:"tid"`
|
||||||
|
PackageID uint64 `json:"package_id"`
|
||||||
|
SyncQuota *bool `json:"sync_quota"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTenantPackage POST /platform/tenant/setPackage
|
||||||
|
// 为租户绑定套餐;sync_quota 为 true(默认)时按套餐包含用户数与现有上限取较大值同步。
|
||||||
|
func (c *PlatformTenantPackageController) SetTenantPackage() {
|
||||||
|
var p setTenantPackagePayload
|
||||||
|
if !c.readBody(&p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Tid == 0 || p.PackageID == 0 {
|
||||||
|
c.jsonErr(400, 400, "租户与套餐不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncQuota := true
|
||||||
|
if p.SyncQuota != nil {
|
||||||
|
syncQuota = *p.SyncQuota
|
||||||
|
}
|
||||||
|
newQuota, err := services.ApplyPackageToTenant(p.Tid, p.PackageID, syncQuota)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"user_quota": newQuota}, "套餐设置成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenantQuotaInfo GET /platform/tenant/quotaInfo?tid=1
|
||||||
|
// 返回租户套餐、用户数使用情况、可增购规格与单价
|
||||||
|
func (c *PlatformTenantPackageController) GetTenantQuotaInfo() {
|
||||||
|
tid, _ := c.GetUint64("tid")
|
||||||
|
if tid == 0 {
|
||||||
|
c.jsonErr(400, 400, "tid 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := services.GetTenantQuotaInfo(tid)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(404, 404, "租户不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var quotaPackages []models.SystemTenantUserQuotaPackage
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.SystemTenantUserQuotaPackage)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Filter("status", 1).
|
||||||
|
OrderBy("sort", "id").
|
||||||
|
All("aPackages)
|
||||||
|
|
||||||
|
modules, _ := services.GetTenantPackageModules(tid)
|
||||||
|
moduleList := make([]tenantPackageModuleItem, 0, len(modules))
|
||||||
|
for _, m := range modules {
|
||||||
|
moduleList = append(moduleList, tenantPackageModuleItem{ModuleCode: m.ModuleCode, ModuleName: m.ModuleName})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.jsonOK(map[string]interface{}{
|
||||||
|
"tid": info.Tid,
|
||||||
|
"package_id": info.PackageID,
|
||||||
|
"effective_package_id": info.EffectivePackageID,
|
||||||
|
"package_name": info.PackageName,
|
||||||
|
"modules": moduleList,
|
||||||
|
"quota": info.Quota,
|
||||||
|
"used": info.Used,
|
||||||
|
"remaining": info.Remaining,
|
||||||
|
"extra_user_price": info.ExtraUserPrice,
|
||||||
|
"quota_packages": quotaPackages,
|
||||||
|
}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
type rechargeQuotaPayload struct {
|
||||||
|
Tid uint64 `json:"tid"`
|
||||||
|
Type int8 `json:"type"` // 1 单个增购 2 套餐增购
|
||||||
|
QuotaPackageID uint64 `json:"quota_package_id"`
|
||||||
|
UserCount int `json:"user_count"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RechargeTenantQuota POST /platform/tenant/rechargeQuota
|
||||||
|
// 用户数增购:单个增购(单价 x 人数)或 按加购套餐(如 5人/500元)
|
||||||
|
func (c *PlatformTenantPackageController) RechargeTenantQuota() {
|
||||||
|
var p rechargeQuotaPayload
|
||||||
|
if !c.readBody(&p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Tid == 0 {
|
||||||
|
c.jsonErr(400, 400, "租户不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order := models.SystemTenantQuotaOrder{
|
||||||
|
Tid: p.Tid,
|
||||||
|
Type: p.Type,
|
||||||
|
Operator: c.currentOperator(),
|
||||||
|
Remark: strings.TrimSpace(p.Remark),
|
||||||
|
}
|
||||||
|
if order.Type != 2 {
|
||||||
|
order.Type = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.Type == 2 {
|
||||||
|
var pkg models.SystemTenantUserQuotaPackage
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenantUserQuotaPackage)).
|
||||||
|
Filter("id", p.QuotaPackageID).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&pkg); err != nil {
|
||||||
|
c.jsonErr(400, 400, "加购规格不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkg.UserCount <= 0 {
|
||||||
|
c.jsonErr(400, 400, "加购规格用户数配置有误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order.QuotaPackageID = pkg.ID
|
||||||
|
order.PackageName = pkg.Name
|
||||||
|
order.UserCount = pkg.UserCount
|
||||||
|
order.Amount = pkg.Price
|
||||||
|
order.UnitPrice = round2(pkg.Price / float64(pkg.UserCount))
|
||||||
|
} else {
|
||||||
|
if p.UserCount <= 0 {
|
||||||
|
c.jsonErr(400, 400, "增购用户数必须大于 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unitPrice := p.UnitPrice
|
||||||
|
if unitPrice <= 0 {
|
||||||
|
// 未传单价时取租户套餐的单个用户增购价
|
||||||
|
if info, err := services.GetTenantQuotaInfo(p.Tid); err == nil && info.ExtraUserPrice > 0 {
|
||||||
|
unitPrice = info.ExtraUserPrice
|
||||||
|
} else {
|
||||||
|
unitPrice = services.DefaultTenantExtraUserPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order.UserCount = p.UserCount
|
||||||
|
order.UnitPrice = unitPrice
|
||||||
|
order.Amount = round2(unitPrice * float64(p.UserCount))
|
||||||
|
order.PackageName = "单个增购"
|
||||||
|
}
|
||||||
|
|
||||||
|
before, after, err := services.IncreaseTenantQuota(p.Tid, order.UserCount)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order.QuotaBefore = before
|
||||||
|
order.QuotaAfter = after
|
||||||
|
|
||||||
|
if _, err := models.Orm.Insert(&order); err != nil {
|
||||||
|
c.jsonErr(500, 500, "增购记录写入失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := services.GetTenantQuotaInfo(p.Tid); err == nil {
|
||||||
|
c.jsonOK(map[string]interface{}{
|
||||||
|
"quota": info.Quota,
|
||||||
|
"used": info.Used,
|
||||||
|
"order": order,
|
||||||
|
"amount": order.Amount,
|
||||||
|
"add": order.UserCount,
|
||||||
|
}, "增购成功")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"quota": after, "add": order.UserCount, "amount": order.Amount}, "增购成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuotaOrders GET /platform/tenant/quotaOrders?tid=1
|
||||||
|
func (c *PlatformTenantPackageController) GetQuotaOrders() {
|
||||||
|
tid, _ := c.GetUint64("tid")
|
||||||
|
if tid == 0 {
|
||||||
|
c.jsonErr(400, 400, "tid 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []models.SystemTenantQuotaOrder
|
||||||
|
_, err := models.Orm.QueryTable(new(models.SystemTenantQuotaOrder)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
OrderBy("-id").
|
||||||
|
All(&rows)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"list": rows, "total": len(rows)}, "获取成功")
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -186,6 +187,20 @@ func (c *PlatformTenantUserController) CreateTenantUser() {
|
|||||||
p.Uid = uid
|
p.Uid = uid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用户数配额校验:租户用户数达到上限时不允许新增(增购用户数后放开)
|
||||||
|
if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
} else if !check.Allowed {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 400,
|
||||||
|
"msg": fmt.Sprintf("该租户用户数已达上限(%d/%d),请先为租户增购用户数", check.Used, check.Quota),
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isDefault := int8(0)
|
isDefault := int8(0)
|
||||||
status := int8(1)
|
status := int8(1)
|
||||||
if p.IsDefault != nil {
|
if p.IsDefault != nil {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- 租户套餐 + 用户数配额(平台端「租户套餐」功能)
|
||||||
|
--
|
||||||
|
-- 业务说明:
|
||||||
|
-- 1. 套餐(yz_system_tenant_package)决定租户能开通哪些功能模块
|
||||||
|
-- (模块取自 yz_system_modules,如 erp / oa / crm / cms),
|
||||||
|
-- 租户绑定套餐后,租户端菜单按套餐包含的模块过滤(未包含的整块菜单不展示)。
|
||||||
|
-- 2. 所有租户都有用户数上限(yz_system_tenant.user_quota,默认 20),
|
||||||
|
-- 租户用户数达到上限后不允许再新增账号,需要先增购用户数:
|
||||||
|
-- · 单个增购:按套餐的 extra_user_price(如 200 元/人)
|
||||||
|
-- · 套餐增购:按 yz_system_tenant_user_quota_package 配置的规格(如 5人/500元)
|
||||||
|
-- 每次增购写入 yz_system_tenant_quota_order 记录,并累加到租户的 user_quota。
|
||||||
|
--
|
||||||
|
-- 本脚本可重复执行:建表用 IF NOT EXISTS,补列/预置数据均做存在性判断。
|
||||||
|
-- 运行:mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < create_tenant_package.sql
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- 1) 租户套餐表
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_system_tenant_package` (
|
||||||
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
|
`name` varchar(64) NOT NULL DEFAULT '' COMMENT '套餐名称,如:ERP套餐',
|
||||||
|
`code` varchar(64) NOT NULL DEFAULT '' COMMENT '套餐编码(唯一,程序识别用)',
|
||||||
|
`description` varchar(500) NOT NULL DEFAULT '' COMMENT '套餐说明',
|
||||||
|
`price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '套餐售价(元)',
|
||||||
|
`user_quota` int(11) NOT NULL DEFAULT 20 COMMENT '套餐包含的用户数(新租户绑定套餐后初始化的用户数上限)',
|
||||||
|
`extra_user_price` decimal(10,2) NOT NULL DEFAULT 200.00 COMMENT '单个用户增购价格(元/人)',
|
||||||
|
`is_default` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否默认套餐:1是 0否(新建租户未指定套餐时使用)',
|
||||||
|
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前',
|
||||||
|
`status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用',
|
||||||
|
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||||
|
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_code` (`code`, `delete_time`),
|
||||||
|
KEY `idx_delete_time` (`delete_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户套餐表(功能模块 + 用户数配额)';
|
||||||
|
|
||||||
|
-- 2) 套餐-功能模块关联表(一个套餐勾选多个模块,模块取自 yz_system_modules.code)
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_system_tenant_package_module` (
|
||||||
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
|
`package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '套餐ID 关联 yz_system_tenant_package(id)',
|
||||||
|
`module_code` varchar(64) NOT NULL DEFAULT '' COMMENT '功能模块编码 关联 yz_system_modules(code),如 erp/oa/crm/cms',
|
||||||
|
`module_name` varchar(64) NOT NULL DEFAULT '' COMMENT '功能模块名称(冗余,便于展示)',
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_package_id` (`package_id`),
|
||||||
|
KEY `idx_module_code` (`module_code`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户套餐-功能模块关联表';
|
||||||
|
|
||||||
|
-- 3) 用户数加购套餐表(平台可维护的「人/元」规格,如 5人 500元)
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_system_tenant_user_quota_package` (
|
||||||
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
|
`name` varchar(64) NOT NULL DEFAULT '' COMMENT '规格名称,如:5人套餐',
|
||||||
|
`user_count` int(11) NOT NULL DEFAULT 0 COMMENT '包含用户数',
|
||||||
|
`price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '售价(元)',
|
||||||
|
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前',
|
||||||
|
`status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用',
|
||||||
|
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||||
|
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_delete_time` (`delete_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户用户数加购套餐表';
|
||||||
|
|
||||||
|
-- 4) 租户用户数增购记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_system_tenant_quota_order` (
|
||||||
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
|
`tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '租户ID 关联 yz_system_tenant(id)',
|
||||||
|
`type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '增购方式:1单个增购 2套餐增购',
|
||||||
|
`quota_package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '加购套餐ID(type=2 时有效)',
|
||||||
|
`package_name` varchar(64) NOT NULL DEFAULT '' COMMENT '加购规格名称(冗余)',
|
||||||
|
`user_count` int(11) NOT NULL DEFAULT 0 COMMENT '本次增购用户数',
|
||||||
|
`unit_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '单价(元/人,type=1 时使用)',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '本次金额(元)',
|
||||||
|
`quota_before` int(11) NOT NULL DEFAULT 0 COMMENT '增购前用户数上限',
|
||||||
|
`quota_after` int(11) NOT NULL DEFAULT 0 COMMENT '增购后用户数上限',
|
||||||
|
`operator` varchar(64) NOT NULL DEFAULT '' COMMENT '操作人(平台账号)',
|
||||||
|
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_tid` (`tid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户用户数增购记录表';
|
||||||
|
|
||||||
|
-- 5) 租户表补列:套餐ID + 用户数上限
|
||||||
|
SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant');
|
||||||
|
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant' AND COLUMN_NAME = 'package_id');
|
||||||
|
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant` ADD COLUMN `package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT ''套餐ID 关联 yz_system_tenant_package(id),0-未绑定''', 'DO 0');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant');
|
||||||
|
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant' AND COLUMN_NAME = 'user_quota');
|
||||||
|
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant` ADD COLUMN `user_quota` int(11) NOT NULL DEFAULT 20 COMMENT ''用户数上限(默认20,增购后累加)''', 'DO 0');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 存量租户兜底:用户数上限为 0 的按默认 20 初始化
|
||||||
|
UPDATE `yz_system_tenant` SET `user_quota` = 20 WHERE `user_quota` <= 0;
|
||||||
|
|
||||||
|
-- 存量租户兜底:用户数上限小于已开用户数时,按已开用户数抬高(避免历史租户立刻被锁死)
|
||||||
|
UPDATE `yz_system_tenant` t
|
||||||
|
SET t.`user_quota` = (
|
||||||
|
SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM `yz_system_tenant_user` u WHERE u.`tid` = t.`id` AND u.`delete_time` IS NULL) c
|
||||||
|
)
|
||||||
|
WHERE t.`user_quota` < (
|
||||||
|
SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM `yz_system_tenant_user` u WHERE u.`tid` = t.`id` AND u.`delete_time` IS NULL) c2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6) 预置套餐(幂等:按 code 判断,不存在才插入)
|
||||||
|
INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`)
|
||||||
|
SELECT '基础版', 'basic', '基础功能:系统管理、基础设置、便捷工具', 0.00, 20, 200.00, 1, 1, 1, '默认套餐,新建租户自动绑定', NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'basic' AND `delete_time` IS NULL) t);
|
||||||
|
|
||||||
|
INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`)
|
||||||
|
SELECT 'ERP套餐', 'erp', '基础功能 + 进销存(ERP)', 0.00, 20, 200.00, 0, 2, 1, '', NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'erp' AND `delete_time` IS NULL) t);
|
||||||
|
|
||||||
|
INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`)
|
||||||
|
SELECT 'CRM套餐', 'crm', '基础功能 + 客户关系管理(CRM)', 0.00, 20, 200.00, 0, 3, 1, '', NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'crm' AND `delete_time` IS NULL) t);
|
||||||
|
|
||||||
|
INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`)
|
||||||
|
SELECT '企业全功能版', 'all', '基础功能 + 进销存 + OA + CRM + 网站管理', 0.00, 20, 200.00, 0, 9, 1, '', NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL) t);
|
||||||
|
|
||||||
|
-- 7) 预置套餐功能模块(按套餐 code + 模块 code 幂等插入)
|
||||||
|
-- 注意:MySQL 的派生表不能引用外层字段,因此这里把「已存在的关联」整表materialize 成 ex,
|
||||||
|
-- 再在 WHERE 中与外层套餐/模块做关联,既避免了「不能 SELECT 目标表」,也保证可重复执行。
|
||||||
|
-- 基础模块:基础设置(basicSettings)、系统管理(systemManager)、便捷工具(bianjiegongju)
|
||||||
|
INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`)
|
||||||
|
SELECT p.`id`, m.`code`, m.`name`, NOW()
|
||||||
|
FROM `yz_system_tenant_package` p
|
||||||
|
JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` IN ('basicSettings', 'systemManager', 'bianjiegongju')
|
||||||
|
WHERE p.`code` IN ('basic', 'erp', 'crm', 'all') AND p.`delete_time` IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex
|
||||||
|
WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code`
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ERP套餐:附加 erp 模块
|
||||||
|
INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`)
|
||||||
|
SELECT p.`id`, m.`code`, m.`name`, NOW()
|
||||||
|
FROM `yz_system_tenant_package` p
|
||||||
|
JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` = 'erp'
|
||||||
|
WHERE p.`code` = 'erp' AND p.`delete_time` IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex
|
||||||
|
WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code`
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CRM套餐:附加 crm 模块
|
||||||
|
INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`)
|
||||||
|
SELECT p.`id`, m.`code`, m.`name`, NOW()
|
||||||
|
FROM `yz_system_tenant_package` p
|
||||||
|
JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` = 'crm'
|
||||||
|
WHERE p.`code` = 'crm' AND p.`delete_time` IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex
|
||||||
|
WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code`
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 企业全功能版:附加 erp / oa / crm / cms
|
||||||
|
INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`)
|
||||||
|
SELECT p.`id`, m.`code`, m.`name`, NOW()
|
||||||
|
FROM `yz_system_tenant_package` p
|
||||||
|
JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` IN ('erp', 'oa', 'crm', 'cms')
|
||||||
|
WHERE p.`code` = 'all' AND p.`delete_time` IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex
|
||||||
|
WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code`
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 8) 预置用户数加购套餐(5人 500元;可按需在平台端维护更多规格)
|
||||||
|
INSERT INTO `yz_system_tenant_user_quota_package` (`name`, `user_count`, `price`, `sort`, `status`, `remark`, `create_time`, `update_time`)
|
||||||
|
SELECT '5人套餐', 5, 500.00, 1, 1, '一次性增购 5 个用户数', NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_user_quota_package` WHERE `name` = '5人套餐' AND `delete_time` IS NULL) t);
|
||||||
|
|
||||||
|
INSERT INTO `yz_system_tenant_user_quota_package` (`name`, `user_count`, `price`, `sort`, `status`, `remark`, `create_time`, `update_time`)
|
||||||
|
SELECT '10人套餐', 10, 900.00, 2, 1, '一次性增购 10 个用户数', NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_user_quota_package` WHERE `name` = '10人套餐' AND `delete_time` IS NULL) t);
|
||||||
|
|
||||||
|
-- 9) 存量租户兜底:未绑定套餐的存量租户绑定「企业全功能版」,
|
||||||
|
-- 避免上线后老租户因套餐未配置而看不到原有功能菜单;
|
||||||
|
-- 之后可在「租户管理 → 编辑 / 套餐与用户数」中按实际购买情况改为 ERP/CRM 等套餐。
|
||||||
|
-- (若没有 all 套餐,则退回绑定默认套餐)
|
||||||
|
UPDATE `yz_system_tenant` t
|
||||||
|
SET t.`package_id` = (SELECT p.`id` FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL ORDER BY `sort`, `id` LIMIT 1) p)
|
||||||
|
WHERE (t.`package_id` IS NULL OR t.`package_id` = 0)
|
||||||
|
AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL) p2);
|
||||||
|
|
||||||
|
UPDATE `yz_system_tenant` t
|
||||||
|
SET t.`package_id` = (SELECT p.`id` FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `is_default` = 1 AND `delete_time` IS NULL ORDER BY `sort`, `id` LIMIT 1) p)
|
||||||
|
WHERE (t.`package_id` IS NULL OR t.`package_id` = 0)
|
||||||
|
AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `is_default` = 1 AND `delete_time` IS NULL) p2);
|
||||||
|
|
||||||
|
-- 10) 平台端菜单:基础设置 → 租户套餐(仅平台端可见)
|
||||||
|
-- pid=17 为「基础设置」菜单;若环境中 17 不是基础设置,请在平台端「菜单管理」里手工挂载本页面:
|
||||||
|
-- 组件路径 /basicSettings/tenantpackage/index.vue,路径 /basicSettings/tenantpackage
|
||||||
|
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `permission`, `creater`, `remark`, `create_time`, `update_time`)
|
||||||
|
SELECT 17, '租户套餐', '/basicSettings/tenantpackage', '/basicSettings/tenantpackage/index.vue', 'Box', 4, 1, 1, '[1]', 2, '', 'system', '租户功能套餐与用户数加购规格', NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/basicSettings/tenantpackage' AND `delete_time` IS NULL) t)
|
||||||
|
AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `id` = 17 AND `delete_time` IS NULL) t2);
|
||||||
|
|
||||||
|
-- 11) 【按需执行】存量租户套餐调整
|
||||||
|
-- 本脚本第 9 步只处理「package_id 为空/0」的租户;若存量租户已被绑定为「基础版」,
|
||||||
|
-- 上线菜单按套餐过滤后,这些租户将看不到 进销存/OA/CRM/网站管理 等菜单。
|
||||||
|
-- 如需要让老租户保留原有全部功能,按下面的语句把它们改为「企业全功能版」
|
||||||
|
-- (或改成各自实际购买的套餐;也可直接在平台端「租户管理 → 编辑」里选择套餐)。
|
||||||
|
--
|
||||||
|
-- UPDATE `yz_system_tenant`
|
||||||
|
-- SET `package_id` = (SELECT p.`id` FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL ORDER BY `sort`, `id` LIMIT 1) p)
|
||||||
|
-- WHERE `id` IN (1, 2); -- ← 改成你的租户ID,或去掉 WHERE 批量处理
|
||||||
|
|
||||||
|
-- 校验:套餐与模块
|
||||||
|
-- SELECT p.id, p.name, p.code, p.user_quota, p.extra_user_price, GROUP_CONCAT(pm.module_code) AS modules
|
||||||
|
-- FROM yz_system_tenant_package p LEFT JOIN yz_system_tenant_package_module pm ON pm.package_id = p.id
|
||||||
|
-- WHERE p.delete_time IS NULL GROUP BY p.id;
|
||||||
|
-- SELECT id, tenant_name, package_id, user_quota FROM yz_system_tenant;
|
||||||
@@ -35,6 +35,11 @@ func Init(_ string) {
|
|||||||
orm.RegisterModel(
|
orm.RegisterModel(
|
||||||
new(SystemTenant),
|
new(SystemTenant),
|
||||||
new(SystemTenantUser),
|
new(SystemTenantUser),
|
||||||
|
// 租户套餐 / 用户数配额(docs/sql/create_tenant_package.sql)
|
||||||
|
new(SystemTenantPackage),
|
||||||
|
new(SystemTenantPackageModule),
|
||||||
|
new(SystemTenantUserQuotaPackage),
|
||||||
|
new(SystemTenantQuotaOrder),
|
||||||
new(BackendOrganization),
|
new(BackendOrganization),
|
||||||
new(BackendEmployee),
|
new(BackendEmployee),
|
||||||
new(BackendPosition),
|
new(BackendPosition),
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type SystemTenant struct {
|
|||||||
Address *string `orm:"column(address);size(255);null" json:"address"`
|
Address *string `orm:"column(address);size(255);null" json:"address"`
|
||||||
Worktime *string `orm:"column(worktime);size(255);null" json:"worktime"`
|
Worktime *string `orm:"column(worktime);size(255);null" json:"worktime"`
|
||||||
Status int8 `orm:"column(status);default(1)" json:"status"`
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
|
// PackageID 套餐ID:关联 yz_system_tenant_package(id),0 表示未绑定(视为默认套餐)
|
||||||
|
PackageID uint64 `orm:"column(package_id);default(0)" json:"package_id"`
|
||||||
|
// UserQuota 用户数上限:默认 20,增购后累加。租户用户数达到上限后不允许再新增账号。
|
||||||
|
UserQuota int `orm:"column(user_quota);default(20)" json:"user_quota"`
|
||||||
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
|
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
|
||||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// SystemTenantPackage 租户套餐表 yz_system_tenant_package
|
||||||
|
// 套餐 = 可开通的功能模块集合 + 用户数配额(含用户数 / 单用户增购价)
|
||||||
|
type SystemTenantPackage struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Name string `orm:"column(name);size(64)" json:"name"`
|
||||||
|
Code string `orm:"column(code);size(64)" json:"code"`
|
||||||
|
Description string `orm:"column(description);size(500)" json:"description"`
|
||||||
|
Price float64 `orm:"column(price);digits(10);decimals(2)" json:"price"`
|
||||||
|
UserQuota int `orm:"column(user_quota);default(20)" json:"user_quota"`
|
||||||
|
ExtraUserPrice float64 `orm:"column(extra_user_price);digits(10);decimals(2)" json:"extra_user_price"`
|
||||||
|
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
|
||||||
|
Sort int `orm:"column(sort);default(0)" json:"sort"`
|
||||||
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
|
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SystemTenantPackage) TableName() string {
|
||||||
|
return "yz_system_tenant_package"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemTenantPackageModule 套餐-功能模块关联表 yz_system_tenant_package_module
|
||||||
|
// module_code 对应 yz_system_modules.code(如 erp / oa / crm / cms)
|
||||||
|
type SystemTenantPackageModule struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
PackageID uint64 `orm:"column(package_id)" json:"package_id"`
|
||||||
|
ModuleCode string `orm:"column(module_code);size(64)" json:"module_code"`
|
||||||
|
ModuleName string `orm:"column(module_name);size(64)" json:"module_name"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SystemTenantPackageModule) TableName() string {
|
||||||
|
return "yz_system_tenant_package_module"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemTenantUserQuotaPackage 用户数加购套餐表 yz_system_tenant_user_quota_package
|
||||||
|
// 平台可维护的规格,如「5人套餐 / 500元」;租户增购时选择该规格即增加对应人数。
|
||||||
|
type SystemTenantUserQuotaPackage struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Name string `orm:"column(name);size(64)" json:"name"`
|
||||||
|
UserCount int `orm:"column(user_count);default(0)" json:"user_count"`
|
||||||
|
Price float64 `orm:"column(price);digits(10);decimals(2)" json:"price"`
|
||||||
|
Sort int `orm:"column(sort);default(0)" json:"sort"`
|
||||||
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
|
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SystemTenantUserQuotaPackage) TableName() string {
|
||||||
|
return "yz_system_tenant_user_quota_package"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemTenantQuotaOrder 租户用户数增购记录表 yz_system_tenant_quota_order
|
||||||
|
type SystemTenantQuotaOrder struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||||
|
Type int8 `orm:"column(type);default(1)" json:"type"` // 1单个增购 2套餐增购
|
||||||
|
QuotaPackageID uint64 `orm:"column(quota_package_id);default(0)" json:"quota_package_id"`
|
||||||
|
PackageName string `orm:"column(package_name);size(64)" json:"package_name"`
|
||||||
|
UserCount int `orm:"column(user_count);default(0)" json:"user_count"`
|
||||||
|
UnitPrice float64 `orm:"column(unit_price);digits(10);decimals(2)" json:"unit_price"`
|
||||||
|
Amount float64 `orm:"column(amount);digits(10);decimals(2)" json:"amount"`
|
||||||
|
QuotaBefore int `orm:"column(quota_before);default(0)" json:"quota_before"`
|
||||||
|
QuotaAfter int `orm:"column(quota_after);default(0)" json:"quota_after"`
|
||||||
|
Operator string `orm:"column(operator);size(64)" json:"operator"`
|
||||||
|
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SystemTenantQuotaOrder) TableName() string {
|
||||||
|
return "yz_system_tenant_quota_order"
|
||||||
|
}
|
||||||
@@ -123,6 +123,9 @@ func RegisterAuthRoutes() {
|
|||||||
beego.Router("/backend/deleteUser/:id", &controllers.BackendAdminUserController{}, "delete:DeleteUser")
|
beego.Router("/backend/deleteUser/:id", &controllers.BackendAdminUserController{}, "delete:DeleteUser")
|
||||||
beego.Router("/backend/changePassword", &controllers.BackendAdminUserController{}, "post:ChangePassword")
|
beego.Router("/backend/changePassword", &controllers.BackendAdminUserController{}, "post:ChangePassword")
|
||||||
|
|
||||||
|
// 租户用户数配额(只读:用户管理页展示已用/上限;增购在平台端)
|
||||||
|
beego.Router("/backend/tenant/quotaInfo", &controllers.BackendTenantQuotaController{}, "get:GetQuotaInfo")
|
||||||
|
|
||||||
// 组织架构(组织 / 员工 / 职位)
|
// 组织架构(组织 / 员工 / 职位)
|
||||||
// 组织架构是租户端通用基础数据:进销存(erp)与办公自动化(oa)两套界面读写同一份数据,
|
// 组织架构是租户端通用基础数据:进销存(erp)与办公自动化(oa)两套界面读写同一份数据,
|
||||||
// 数据按 JWT 中的租户ID 隔离。两个前缀注册到同一个控制器,便于两端各自的菜单与权限配置。
|
// 数据按 JWT 中的租户ID 隔离。两个前缀注册到同一个控制器,便于两端各自的菜单与权限配置。
|
||||||
|
|||||||
@@ -61,6 +61,28 @@ func Register() {
|
|||||||
beego.Router("/platform/tenantUser/edit/:id", &controllers.PlatformTenantUserController{}, "post:EditTenantUser")
|
beego.Router("/platform/tenantUser/edit/:id", &controllers.PlatformTenantUserController{}, "post:EditTenantUser")
|
||||||
beego.Router("/platform/tenantUser/delete/:id", &controllers.PlatformTenantUserController{}, "delete:DeleteTenantUser")
|
beego.Router("/platform/tenantUser/delete/:id", &controllers.PlatformTenantUserController{}, "delete:DeleteTenantUser")
|
||||||
|
|
||||||
|
// 租户套餐(yz_system_tenant_package / yz_system_tenant_package_module)
|
||||||
|
// 注意:固定路径需先于 /:id 通配路径注册
|
||||||
|
beego.Router("/platform/tenantPackage/list", &controllers.PlatformTenantPackageController{}, "get:GetPackageList")
|
||||||
|
beego.Router("/platform/tenantPackage/select/list", &controllers.PlatformTenantPackageController{}, "get:GetPackageSelectList")
|
||||||
|
beego.Router("/platform/tenantPackage/moduleOptions", &controllers.PlatformTenantPackageController{}, "get:GetModuleOptions")
|
||||||
|
beego.Router("/platform/tenantPackage/create", &controllers.PlatformTenantPackageController{}, "post:CreatePackage")
|
||||||
|
beego.Router("/platform/tenantPackage/edit/:id", &controllers.PlatformTenantPackageController{}, "post:EditPackage")
|
||||||
|
beego.Router("/platform/tenantPackage/delete/:id", &controllers.PlatformTenantPackageController{}, "delete:DeletePackage")
|
||||||
|
beego.Router("/platform/tenantPackage/detail/:id", &controllers.PlatformTenantPackageController{}, "get:GetPackageDetail")
|
||||||
|
|
||||||
|
// 用户数加购规格(yz_system_tenant_user_quota_package)
|
||||||
|
beego.Router("/platform/tenantQuotaPackage/list", &controllers.PlatformTenantPackageController{}, "get:GetQuotaPackageList")
|
||||||
|
beego.Router("/platform/tenantQuotaPackage/create", &controllers.PlatformTenantPackageController{}, "post:CreateQuotaPackage")
|
||||||
|
beego.Router("/platform/tenantQuotaPackage/edit/:id", &controllers.PlatformTenantPackageController{}, "post:EditQuotaPackage")
|
||||||
|
beego.Router("/platform/tenantQuotaPackage/delete/:id", &controllers.PlatformTenantPackageController{}, "delete:DeleteQuotaPackage")
|
||||||
|
|
||||||
|
// 租户套餐绑定 / 用户数配额(增购记录 yz_system_tenant_quota_order)
|
||||||
|
beego.Router("/platform/tenant/setPackage", &controllers.PlatformTenantPackageController{}, "post:SetTenantPackage")
|
||||||
|
beego.Router("/platform/tenant/quotaInfo", &controllers.PlatformTenantPackageController{}, "get:GetTenantQuotaInfo")
|
||||||
|
beego.Router("/platform/tenant/rechargeQuota", &controllers.PlatformTenantPackageController{}, "post:RechargeTenantQuota")
|
||||||
|
beego.Router("/platform/tenant/quotaOrders", &controllers.PlatformTenantPackageController{}, "get:GetQuotaOrders")
|
||||||
|
|
||||||
// 平台管理员用户管理(yz_system_admin_user)
|
// 平台管理员用户管理(yz_system_admin_user)
|
||||||
beego.Router("/platform/getAllUsers", &controllers.PlatformAdminUserController{}, "get:GetAllUsers")
|
beego.Router("/platform/getAllUsers", &controllers.PlatformAdminUserController{}, "get:GetAllUsers")
|
||||||
beego.Router("/platform/getUserInfo/:id", &controllers.PlatformAdminUserController{}, "get:GetUserInfo")
|
beego.Router("/platform/getUserInfo/:id", &controllers.PlatformAdminUserController{}, "get:GetUserInfo")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"server/models"
|
"server/models"
|
||||||
|
|
||||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
|
||||||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||||
"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
|
"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
|
||||||
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
|
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
|
||||||
@@ -35,16 +37,26 @@ import (
|
|||||||
// appid 公众号 / 小程序 APPID
|
// appid 公众号 / 小程序 APPID
|
||||||
// cert_serial_no 商户 API 证书序列号
|
// cert_serial_no 商户 API 证书序列号
|
||||||
// api_v3_key APIv3 密钥
|
// api_v3_key APIv3 密钥
|
||||||
|
// pub_key_id 微信支付公钥 ID(PUB_KEY_ID_ 开头;公钥模式必填,与 pub_key 成对)
|
||||||
|
// pub_key 微信支付公钥 PEM 内容(公钥模式必填)
|
||||||
//
|
//
|
||||||
// 证书(cert_json):
|
// 证书(cert_json):
|
||||||
//
|
//
|
||||||
// key_path apiclient_key.pem 上传后的服务端路径(不入库内容,见渠道证书上传接口)
|
// key_path apiclient_key.pem 上传后的服务端路径(不入库内容,见渠道证书上传接口)
|
||||||
type WechatChannel struct{}
|
type WechatChannel struct{}
|
||||||
|
|
||||||
|
// connectTestOutTradeNo 测试连接用的探测单号:查询不存在的订单,返回 404 即视为鉴权链路通过
|
||||||
|
const connectTestOutTradeNo = "CONNECT_TEST_NO_0000000000000001"
|
||||||
|
|
||||||
func (c *WechatChannel) Code() string { return ChannelWechat }
|
func (c *WechatChannel) Code() string { return ChannelWechat }
|
||||||
func (c *WechatChannel) Name() string { return "微信支付" }
|
func (c *WechatChannel) Name() string { return "微信支付" }
|
||||||
|
|
||||||
// buildClient 创建微信支付 APIv3 客户端(带自动证书更新与加解密能力)
|
// buildClient 创建微信支付 APIv3 客户端(带自动证书更新与加解密能力)
|
||||||
|
//
|
||||||
|
// 初始化模式自动选择:
|
||||||
|
// - 配置了「微信支付公钥」(pub_key_id + pub_key)时走公钥模式:
|
||||||
|
// 2024 年后新开通商户默认使用公钥,平台证书下载接口对其停用(403 NOT_ENOUGH);
|
||||||
|
// - 否则回退平台证书模式(自动下载并轮换平台证书,老商户适用)。
|
||||||
func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, error) {
|
func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, error) {
|
||||||
mchID := cfg.Get("mch_id")
|
mchID := cfg.Get("mch_id")
|
||||||
serial := cfg.Get("cert_serial_no")
|
serial := cfg.Get("cert_serial_no")
|
||||||
@@ -66,6 +78,23 @@ func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, e
|
|||||||
return nil, "", fmt.Errorf("解析商户私钥失败: %w", err)
|
return nil, "", fmt.Errorf("解析商户私钥失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pubKeyID := cfg.Get("pub_key_id")
|
||||||
|
pubKeyPEM := cfg.Get("pub_key")
|
||||||
|
if pubKeyID != "" && pubKeyPEM != "" {
|
||||||
|
pubKey, perr := utils.LoadPublicKey(pubKeyPEM)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, "", fmt.Errorf("解析微信支付公钥失败: %w", perr)
|
||||||
|
}
|
||||||
|
client, cerr := core.NewClient(context.Background(),
|
||||||
|
option.WithMerchantCredential(mchID, serial, privateKey),
|
||||||
|
option.WithWechatPayPublicKeyAuthCipher(mchID, serial, privateKey, pubKeyID, pubKey),
|
||||||
|
)
|
||||||
|
if cerr != nil {
|
||||||
|
return nil, "", fmt.Errorf("创建微信支付客户端失败: %w", cerr)
|
||||||
|
}
|
||||||
|
return client, mchID, nil
|
||||||
|
}
|
||||||
|
|
||||||
client, err := core.NewClient(context.Background(),
|
client, err := core.NewClient(context.Background(),
|
||||||
option.WithMerchantCredential(mchID, serial, privateKey),
|
option.WithMerchantCredential(mchID, serial, privateKey),
|
||||||
option.WithWechatPayAutoAuthCipher(mchID, serial, privateKey, apiV3Key),
|
option.WithWechatPayAutoAuthCipher(mchID, serial, privateKey, apiV3Key),
|
||||||
@@ -212,9 +241,21 @@ func (c *WechatChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *C
|
|||||||
mchID := cfg.Get("mch_id")
|
mchID := cfg.Get("mch_id")
|
||||||
apiV3Key := cfg.Get("api_v3_key")
|
apiV3Key := cfg.Get("api_v3_key")
|
||||||
|
|
||||||
// 平台证书由 WithWechatPayAutoAuthCipher 注册的下载器维护,直接复用其证书访问器验签
|
// 验签器按初始化模式选择:公钥模式用微信支付公钥,平台证书模式用下载器维护的证书访问器
|
||||||
|
var verifier auth.Verifier
|
||||||
|
pubKeyID := cfg.Get("pub_key_id")
|
||||||
|
pubKeyPEM := cfg.Get("pub_key")
|
||||||
|
if pubKeyID != "" && pubKeyPEM != "" {
|
||||||
|
pubKey, perr := utils.LoadPublicKey(pubKeyPEM)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("解析微信支付公钥失败: %w", perr)
|
||||||
|
}
|
||||||
|
verifier = verifiers.NewSHA256WithRSAPubkeyVerifier(pubKeyID, *pubKey)
|
||||||
|
} else {
|
||||||
visitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
|
visitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
|
||||||
handler, err := notify.NewRSANotifyHandler(apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor))
|
verifier = verifiers.NewSHA256WithRSAVerifier(visitor)
|
||||||
|
}
|
||||||
|
handler, err := notify.NewRSANotifyHandler(apiV3Key, verifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建微信通知处理器失败: %w", err)
|
return nil, fmt.Errorf("创建微信通知处理器失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -284,11 +325,25 @@ func (c *WechatChannel) Refund(ctx context.Context, order *models.PlatformPaymen
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *WechatChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
|
func (c *WechatChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
|
||||||
client, _, err := c.buildClient(cfg)
|
client, mchID, err := c.buildClient(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
// 平台证书接口只做签名校验,不产生任何交易
|
// 公钥模式:/v3/certificates 已对其停用,改查一笔不存在的订单验证鉴权(纯读,不产生交易)
|
||||||
|
if cfg.Get("pub_key_id") != "" && cfg.Get("pub_key") != "" {
|
||||||
|
_, qerr := client.Get(ctx,
|
||||||
|
consts.WechatPayAPIServer+"/v3/pay/transactions/out-trade-no/"+connectTestOutTradeNo+"?mchid="+mchID)
|
||||||
|
if qerr == nil {
|
||||||
|
return "连接成功:商户私钥签名与微信支付公钥验签均通过(公钥模式)", nil
|
||||||
|
}
|
||||||
|
var apiErr *core.APIError
|
||||||
|
if errors.As(qerr, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
// 订单不存在属预期结果:说明请求签名与应答验签链路均已通过
|
||||||
|
return "连接成功:商户私钥签名与微信支付公钥验签均通过(公钥模式)", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("微信凭证校验失败: %w", qerr)
|
||||||
|
}
|
||||||
|
// 平台证书模式:平台证书接口只做签名校验,不产生任何交易
|
||||||
result, err := client.Get(ctx, consts.WechatPayAPIServer+"/v3/certificates")
|
result, err := client.Get(ctx, consts.WechatPayAPIServer+"/v3/certificates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("微信凭证校验失败: %w", err)
|
return "", fmt.Errorf("微信凭证校验失败: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTenantUserQuota 租户默认用户数上限(未配置套餐/套餐未设置时兜底)
|
||||||
|
const DefaultTenantUserQuota = 20
|
||||||
|
|
||||||
|
// DefaultTenantExtraUserPrice 单个用户增购默认单价(元/人)
|
||||||
|
const DefaultTenantExtraUserPrice = 200.00
|
||||||
|
|
||||||
|
// ErrTenantQuotaExceeded 用户数已达上限
|
||||||
|
var ErrTenantQuotaExceeded = errors.New("tenant user quota exceeded")
|
||||||
|
|
||||||
|
// CountTenantUsers 统计租户有效用户数(未软删)
|
||||||
|
func CountTenantUsers(tid uint64) (int, error) {
|
||||||
|
if tid == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
cnt, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Count()
|
||||||
|
return int(cnt), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenantPackage 获取租户生效套餐:优先取绑定套餐,未绑定/已删除时回退到默认套餐
|
||||||
|
func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
|
||||||
|
if tid == 0 {
|
||||||
|
return nil, errors.New("tid 不能为空")
|
||||||
|
}
|
||||||
|
var tenant models.SystemTenant
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tenant.PackageID > 0 {
|
||||||
|
var pkg models.SystemTenantPackage
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("id", tenant.PackageID).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&pkg); err == nil {
|
||||||
|
return &pkg, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GetDefaultTenantPackage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultTenantPackage 获取默认套餐(is_default=1 优先,其次按排序取第一个启用套餐)
|
||||||
|
func GetDefaultTenantPackage() (*models.SystemTenantPackage, error) {
|
||||||
|
var pkg models.SystemTenantPackage
|
||||||
|
err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Filter("status", 1).
|
||||||
|
Filter("is_default", 1).
|
||||||
|
OrderBy("sort", "id").
|
||||||
|
One(&pkg)
|
||||||
|
if err == nil {
|
||||||
|
return &pkg, nil
|
||||||
|
}
|
||||||
|
var fallback models.SystemTenantPackage
|
||||||
|
if err2 := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Filter("status", 1).
|
||||||
|
OrderBy("sort", "id").
|
||||||
|
One(&fallback); err2 != nil {
|
||||||
|
return nil, err2
|
||||||
|
}
|
||||||
|
return &fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenantPackageModules 获取租户套餐包含的功能模块(按套餐未绑定则取默认套餐)
|
||||||
|
func GetTenantPackageModules(tid uint64) ([]models.SystemTenantPackageModule, error) {
|
||||||
|
pkg, err := GetTenantPackage(tid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rows []models.SystemTenantPackageModule
|
||||||
|
_, err = models.Orm.QueryTable(new(models.SystemTenantPackageModule)).
|
||||||
|
Filter("package_id", pkg.ID).
|
||||||
|
OrderBy("id").
|
||||||
|
All(&rows)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenantModuleCodes 获取租户已开通的功能模块编码集合(如 erp / oa / crm)
|
||||||
|
func GetTenantModuleCodes(tid uint64) map[string]bool {
|
||||||
|
codes := map[string]bool{}
|
||||||
|
rows, err := GetTenantPackageModules(tid)
|
||||||
|
if err != nil {
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
code := strings.TrimSpace(r.ModuleCode)
|
||||||
|
if code != "" {
|
||||||
|
codes[code] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantQuotaCheck 用户数配额校验结果
|
||||||
|
type TenantQuotaCheck struct {
|
||||||
|
Quota int `json:"quota"`
|
||||||
|
Used int `json:"used"`
|
||||||
|
Allowed bool `json:"allowed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckTenantUserQuota 校验租户是否还能新增用户。
|
||||||
|
// uid > 0 且该用户已绑定到本租户时视为「编辑/复用已有绑定」,不占用新增名额。
|
||||||
|
func CheckTenantUserQuota(tid, uid uint64) (*TenantQuotaCheck, error) {
|
||||||
|
result := &TenantQuotaCheck{Quota: DefaultTenantUserQuota, Allowed: true}
|
||||||
|
if tid == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
quota := DefaultTenantUserQuota
|
||||||
|
var tenant models.SystemTenant
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err == nil && tenant.UserQuota > 0 {
|
||||||
|
quota = tenant.UserQuota
|
||||||
|
}
|
||||||
|
result.Quota = quota
|
||||||
|
|
||||||
|
if uid > 0 {
|
||||||
|
exist, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("uid", uid).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Count()
|
||||||
|
if err == nil && exist > 0 {
|
||||||
|
result.Allowed = true
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
used, err := CountTenantUsers(tid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Used = used
|
||||||
|
result.Allowed = used < quota
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureTenantUserQuota 用户数已满时返回 ErrTenantQuotaExceeded
|
||||||
|
func EnsureTenantUserQuota(tid, uid uint64) error {
|
||||||
|
check, err := CheckTenantUserQuota(tid, uid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !check.Allowed {
|
||||||
|
return ErrTenantQuotaExceeded
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenantQuotaInfo 租户用户数使用情况
|
||||||
|
type TenantQuotaInfo struct {
|
||||||
|
Tid uint64 `json:"tid"`
|
||||||
|
PackageID uint64 `json:"package_id"`
|
||||||
|
PackageName string `json:"package_name"`
|
||||||
|
Quota int `json:"quota"`
|
||||||
|
Used int `json:"used"`
|
||||||
|
Remaining int `json:"remaining"`
|
||||||
|
ExtraUserPrice float64 `json:"extra_user_price"`
|
||||||
|
EffectivePackageID uint64 `json:"effective_package_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenantQuotaInfo 查询租户用户数使用情况(含生效套餐信息)
|
||||||
|
func GetTenantQuotaInfo(tid uint64) (*TenantQuotaInfo, error) {
|
||||||
|
info := &TenantQuotaInfo{Tid: tid, Quota: DefaultTenantUserQuota, ExtraUserPrice: DefaultTenantExtraUserPrice}
|
||||||
|
var tenant models.SystemTenant
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info.PackageID = tenant.PackageID
|
||||||
|
if tenant.UserQuota > 0 {
|
||||||
|
info.Quota = tenant.UserQuota
|
||||||
|
}
|
||||||
|
used, err := CountTenantUsers(tid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info.Used = used
|
||||||
|
info.Remaining = info.Quota - used
|
||||||
|
if info.Remaining < 0 {
|
||||||
|
info.Remaining = 0
|
||||||
|
}
|
||||||
|
if pkg, err := GetTenantPackage(tid); err == nil && pkg != nil {
|
||||||
|
info.EffectivePackageID = pkg.ID
|
||||||
|
info.PackageName = pkg.Name
|
||||||
|
if pkg.ExtraUserPrice > 0 {
|
||||||
|
info.ExtraUserPrice = pkg.ExtraUserPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncreaseTenantQuota 增加租户用户数上限,返回(变更前上限、变更后上限)
|
||||||
|
func IncreaseTenantQuota(tid uint64, count int) (int, int, error) {
|
||||||
|
if tid == 0 || count <= 0 {
|
||||||
|
return 0, 0, errors.New("参数错误")
|
||||||
|
}
|
||||||
|
var tenant models.SystemTenant
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
|
||||||
|
return 0, 0, errors.New("租户不存在")
|
||||||
|
}
|
||||||
|
before := tenant.UserQuota
|
||||||
|
if before <= 0 {
|
||||||
|
before = DefaultTenantUserQuota
|
||||||
|
}
|
||||||
|
after := before + count
|
||||||
|
_, err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||||
|
Filter("id", tid).
|
||||||
|
Update(map[string]interface{}{"user_quota": after})
|
||||||
|
if err != nil {
|
||||||
|
return before, after, err
|
||||||
|
}
|
||||||
|
return before, after, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyPackageToTenant 为租户绑定套餐:同步套餐包含的用户数(不低于当前已开用户数,且不降低已有上限)
|
||||||
|
func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
|
||||||
|
if tid == 0 || packageID == 0 {
|
||||||
|
return 0, errors.New("参数错误")
|
||||||
|
}
|
||||||
|
var pkg models.SystemTenantPackage
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("id", packageID).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&pkg); err != nil {
|
||||||
|
return 0, errors.New("套餐不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
update := map[string]interface{}{"package_id": packageID}
|
||||||
|
newQuota := 0
|
||||||
|
if syncQuota {
|
||||||
|
var tenant models.SystemTenant
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
|
||||||
|
return 0, errors.New("租户不存在")
|
||||||
|
}
|
||||||
|
current := tenant.UserQuota
|
||||||
|
if current <= 0 {
|
||||||
|
current = DefaultTenantUserQuota
|
||||||
|
}
|
||||||
|
newQuota = pkg.UserQuota
|
||||||
|
if newQuota <= 0 {
|
||||||
|
newQuota = DefaultTenantUserQuota
|
||||||
|
}
|
||||||
|
// 不低于当前上限:换套餐不回收已购用户数
|
||||||
|
if newQuota < current {
|
||||||
|
newQuota = current
|
||||||
|
}
|
||||||
|
update["user_quota"] = newQuota
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||||
|
Filter("id", tid).
|
||||||
|
Update(update); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return newQuota, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortModulesByPathDesc 模块按 path 长度倒序:长路径优先匹配,避免 /apps 误吞 /apps/erp
|
||||||
|
func sortModulesByPathDesc(modules []models.SystemModules) []models.SystemModules {
|
||||||
|
list := make([]models.SystemModules, 0, len(modules))
|
||||||
|
for _, m := range modules {
|
||||||
|
if strings.TrimSpace(m.Path) != "" {
|
||||||
|
list = append(list, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(list, func(i, j int) bool {
|
||||||
|
return len(list[i].Path) > len(list[j].Path)
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerModuleCode 返回菜单路径归属的模块编码(按模块 path 前缀匹配)。
|
||||||
|
// 不属于任何模块时 ok=false —— 视为公共菜单,不参与套餐过滤。
|
||||||
|
func ownerModuleCode(sortedModules []models.SystemModules, path string) (string, bool) {
|
||||||
|
p := strings.TrimSuffix(strings.TrimSpace(path), "/")
|
||||||
|
if p == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, m := range sortedModules {
|
||||||
|
mp := strings.TrimSuffix(strings.TrimSpace(m.Path), "/")
|
||||||
|
if mp == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p == mp || strings.HasPrefix(p, mp+"/") {
|
||||||
|
return m.Code, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterMenusByTenantModules 按租户套餐包含的功能模块过滤菜单:
|
||||||
|
// - 菜单不属于任何模块(公共/基础菜单)→ 保留;
|
||||||
|
// - 菜单归属某模块 → 仅当该模块已被租户套餐开通时保留。
|
||||||
|
func FilterMenusByTenantModules(menus []models.SystemMenu, allModules []models.SystemModules, allowedCodes map[string]bool) []models.SystemMenu {
|
||||||
|
if len(menus) == 0 || len(allModules) == 0 {
|
||||||
|
return menus
|
||||||
|
}
|
||||||
|
sorted := sortModulesByPathDesc(allModules)
|
||||||
|
out := make([]models.SystemMenu, 0, len(menus))
|
||||||
|
for _, m := range menus {
|
||||||
|
path := ""
|
||||||
|
if m.Path != nil {
|
||||||
|
path = *m.Path
|
||||||
|
}
|
||||||
|
code, ok := ownerModuleCode(sorted, path)
|
||||||
|
if !ok {
|
||||||
|
out = append(out, m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if allowedCodes[code] {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEnabledModules 获取全部启用且未删除的模块(用于菜单归属判定)
|
||||||
|
func ListEnabledModules() ([]models.SystemModules, error) {
|
||||||
|
var rows []models.SystemModules
|
||||||
|
_, err := models.Orm.QueryTable(new(models.SystemModules)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Filter("status", 1).
|
||||||
|
OrderBy("sort", "id").
|
||||||
|
All(&rows)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
Vendored
+5
@@ -24,6 +24,8 @@ declare module 'vue' {
|
|||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
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']
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
@@ -49,6 +51,7 @@ declare module 'vue' {
|
|||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
@@ -65,6 +68,8 @@ declare module 'vue' {
|
|||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
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']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElTree: typeof import('element-plus/es')['ElTree']
|
ElTree: typeof import('element-plus/es')['ElTree']
|
||||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
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>
|
<template>
|
||||||
<div class="tenant-users-tab">
|
<div class="tenant-users-tab">
|
||||||
<div class="section-header">
|
<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-button type="primary" size="small" :disabled="!tid" @click="handleAddUser">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon><Plus /></el-icon>
|
||||||
添加用户
|
添加用户
|
||||||
@@ -121,6 +128,7 @@ import { ref, reactive, watch } from "vue";
|
|||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Plus } from "@element-plus/icons-vue";
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
import { getTenantUserList, editTenantUser, deleteTenantUser } from "@/api/tenantUser";
|
import { getTenantUserList, editTenantUser, deleteTenantUser } from "@/api/tenantUser";
|
||||||
|
import { getTenantQuotaInfo } from "@/api/tenantPackage";
|
||||||
import { getOrganizationList } from "@/api/erp";
|
import { getOrganizationList } from "@/api/erp";
|
||||||
import AddUser from "./adduser.vue";
|
import AddUser from "./adduser.vue";
|
||||||
|
|
||||||
@@ -134,6 +142,25 @@ const usersLoading = ref(false);
|
|||||||
const userSearchKeyword = ref("");
|
const userSearchKeyword = ref("");
|
||||||
const addUserRef = ref<{ open: (tenantId: number) => void } | null>(null);
|
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 orgTree = ref<any[]>([]);
|
||||||
const orgNameMap = ref<Record<number, string>>({});
|
const orgNameMap = ref<Record<number, string>>({});
|
||||||
|
|
||||||
@@ -234,6 +261,8 @@ const refreshTenantUsers = async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
usersLoading.value = false;
|
usersLoading.value = false;
|
||||||
}
|
}
|
||||||
|
// 用户数统计随增删变化,同步刷新配额展示
|
||||||
|
loadQuotaInfo();
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUsersForTid = async (tid: number) => {
|
const loadUsersForTid = async (tid: number) => {
|
||||||
@@ -259,9 +288,11 @@ watch(
|
|||||||
loadOrgs();
|
loadOrgs();
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
tenantUsers.value = [];
|
tenantUsers.value = [];
|
||||||
|
quotaInfo.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadUsersForTid(id);
|
loadUsersForTid(id);
|
||||||
|
loadQuotaInfo();
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
@@ -276,9 +307,17 @@ const resetUserSearch = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddUser = () => {
|
const handleAddUser = () => {
|
||||||
if (props.tid != null) {
|
if (props.tid == null) return;
|
||||||
addUserRef.value?.open(props.tid);
|
// 用户数上限校验:达到上限时提示到「套餐与用户数」增购
|
||||||
|
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) => {
|
const openPasswordDialog = (row: any) => {
|
||||||
@@ -419,6 +458,22 @@ defineExpose({
|
|||||||
color: #303133;
|
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 {
|
.user-search-form {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
|||||||
@@ -38,6 +38,16 @@
|
|||||||
<el-descriptions-item label="创建时间">
|
<el-descriptions-item label="创建时间">
|
||||||
{{ detailData.create_time ? formatDateTime(detailData.create_time) : '-' }}
|
{{ detailData.create_time ? formatDateTime(detailData.create_time) : '-' }}
|
||||||
</el-descriptions-item>
|
</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-descriptions>
|
||||||
|
|
||||||
<el-divider />
|
<el-divider />
|
||||||
@@ -53,6 +63,11 @@
|
|||||||
<TenantUsersTab :tid="detailTenantId" />
|
<TenantUsersTab :tid="detailTenantId" />
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</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-tab-pane label="..." name="...">...</el-tab-pane> -->
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,6 +83,7 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { getTenantDetail } from "@/api/tenant";
|
import { getTenantDetail } from "@/api/tenant";
|
||||||
import TenantUsersTab from "./TenantUsersTab.vue";
|
import TenantUsersTab from "./TenantUsersTab.vue";
|
||||||
|
import TenantPackageTab from "./TenantPackageTab.vue";
|
||||||
import { formatDateTime } from "@/utils/datetime";
|
import { formatDateTime } from "@/utils/datetime";
|
||||||
|
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
|
|||||||
@@ -24,6 +24,21 @@
|
|||||||
<el-form-item label="租户地址" prop="address">
|
<el-form-item label="租户地址" prop="address">
|
||||||
<el-input v-model="formData.address" type="textarea" placeholder="请输入地址" />
|
<el-input v-model="formData.address" type="textarea" placeholder="请输入地址" />
|
||||||
</el-form-item>
|
</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-form-item label="状态" prop="status">
|
||||||
<el-radio-group v-model="formData.status">
|
<el-radio-group v-model="formData.status">
|
||||||
<el-radio :label="1">启用</el-radio>
|
<el-radio :label="1">启用</el-radio>
|
||||||
@@ -40,9 +55,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue';
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { createTenant, editTenant, getTenantDetail, checkTenantCode } from '@/api/tenant';
|
import { createTenant, editTenant, getTenantDetail, checkTenantCode } from '@/api/tenant';
|
||||||
|
import { getTenantPackageSelectList } from '@/api/tenantPackage';
|
||||||
|
|
||||||
const emit = defineEmits(['success']);
|
const emit = defineEmits(['success']);
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
@@ -50,6 +66,39 @@ const loading = ref(false);
|
|||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const formRef = ref();
|
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 = {
|
const initialData = {
|
||||||
id: null,
|
id: null,
|
||||||
tenant_name: '',
|
tenant_name: '',
|
||||||
@@ -58,7 +107,9 @@ const initialData = {
|
|||||||
contact_phone: '',
|
contact_phone: '',
|
||||||
contact_email: '',
|
contact_email: '',
|
||||||
address: '',
|
address: '',
|
||||||
status: 1
|
status: 1,
|
||||||
|
package_id: undefined as number | undefined,
|
||||||
|
user_quota: 20
|
||||||
};
|
};
|
||||||
|
|
||||||
const formData = reactive({ ...initialData });
|
const formData = reactive({ ...initialData });
|
||||||
@@ -192,3 +243,19 @@ const handleClosed = () => {
|
|||||||
|
|
||||||
defineExpose({ open });
|
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 :data="tenants" style="width: 100%" v-loading="loading">
|
||||||
<el-table-column prop="id" label="ID" align="center" fixed="left" />
|
<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
|
<el-table-column
|
||||||
prop="tenant_name"
|
prop="tenant_name"
|
||||||
label="租户名称"
|
label="租户名称"
|
||||||
@@ -104,6 +111,19 @@
|
|||||||
min-width="300"
|
min-width="300"
|
||||||
align="center"
|
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">
|
<el-table-column prop="status" label="租户状态" width="80" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">{{
|
<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,自动随主题切换
|
// 用 Element Plus 变量,亮色下 #f5f7fa、暗色下 #0a0a0a,自动随主题切换
|
||||||
.search-form {
|
.search-form {
|
||||||
background: var(--el-bg-color-page, #f5f7fa);
|
background: var(--el-bg-color-page, #f5f7fa);
|
||||||
|
|||||||
@@ -94,7 +94,12 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="启用状态">
|
<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>
|
<span class="field-tip inline">停用后收银台不再展示支付宝。</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -114,8 +119,12 @@ import { reactive, ref, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import StatusTag from '../components/StatusTag.vue'
|
import StatusTag from '../components/StatusTag.vue'
|
||||||
// TODO: 接口联调时打开
|
import {
|
||||||
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
|
getChannelConfig,
|
||||||
|
saveChannelConfig,
|
||||||
|
testChannelConnection,
|
||||||
|
toggleChannel
|
||||||
|
} from '@/api/payment'
|
||||||
|
|
||||||
const CHANNEL = 'alipay'
|
const CHANNEL = 'alipay'
|
||||||
|
|
||||||
@@ -167,17 +176,20 @@ function toggleSecret(key: 'app_private_key') {
|
|||||||
/* ---------------- 加载 / 保存 / 测试 ---------------- */
|
/* ---------------- 加载 / 保存 / 测试 ---------------- */
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
// TODO: 接口联通后替换为真实请求
|
try {
|
||||||
// const res = await getChannelConfig(CHANNEL)
|
const res: any = await getChannelConfig(CHANNEL)
|
||||||
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
|
if (res?.code === 200 && res.data) {
|
||||||
Object.assign(form, {
|
const cfg = res.data.config || {}
|
||||||
appid: '2021003123456789',
|
form.appid = cfg.appid || ''
|
||||||
app_private_key: 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9f2c7b1a4d6e8f0a',
|
// 敏感字段后端只回掩码,原样放进表单表示「不修改」
|
||||||
alipay_public_key:
|
form.app_private_key = cfg.app_private_key || ''
|
||||||
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt9f2c7b1a4d6e8f0a3c5b7d9e1f4a6c8',
|
form.alipay_public_key = cfg.alipay_public_key || ''
|
||||||
sign_type: 'RSA2',
|
form.sign_type = cfg.sign_type || 'RSA2'
|
||||||
enabled: true
|
form.enabled = !!res.data.enabled
|
||||||
})
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.response?.data?.msg || error?.message || '加载渠道配置失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -189,12 +201,22 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 接口联通后替换为真实请求(app_private_key 为掩码时表示不修改)
|
// app_private_key 为掩码 / 空串时后端保持原值不变
|
||||||
// await saveChannelConfig(CHANNEL, { ...form })
|
await saveChannelConfig(CHANNEL, {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
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('支付宝配置已保存')
|
ElMessage.success('支付宝配置已保存')
|
||||||
|
await loadConfig() // 重新拉取,刷新敏感字段掩码回显
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '保存失败')
|
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -203,12 +225,16 @@ async function handleSave() {
|
|||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
testing.value = true
|
testing.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 接口联通后替换为真实请求
|
const res: any = await testChannelConnection(CHANNEL, {
|
||||||
// const res = await testChannelConnection(CHANNEL, { ...form })
|
config: {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
appid: form.appid,
|
||||||
ElMessage.success('连接成功:APPID 与 RSA2 签名校验通过')
|
app_private_key: form.app_private_key,
|
||||||
|
alipay_public_key: form.alipay_public_key
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ElMessage.success(res?.data?.message || '连接成功:APPID 与 RSA2 签名校验通过')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '连接失败,请检查 APPID、应用私钥与支付宝公钥')
|
ElMessage.error(error?.response?.data?.msg || error?.message || '连接失败,请检查 APPID、应用私钥与支付宝公钥')
|
||||||
} finally {
|
} finally {
|
||||||
testing.value = false
|
testing.value = false
|
||||||
}
|
}
|
||||||
@@ -224,9 +250,17 @@ async function handleToggle(enable: boolean) {
|
|||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
|
try {
|
||||||
|
const res: any = await toggleChannel(CHANNEL, enable)
|
||||||
|
if (res?.code === 200) {
|
||||||
form.enabled = enable
|
form.enabled = enable
|
||||||
ElMessage.success(`支付宝已${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) {
|
async function copyText(text: string) {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="继承收单机构">
|
<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>
|
||||||
|
|
||||||
<el-form-item label="在收银台展示云闪付标识">
|
<el-form-item label="在收银台展示云闪付标识">
|
||||||
@@ -72,7 +72,12 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="启用状态">
|
<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>
|
<span class="field-tip inline">需先启用「银联」渠道,云闪付才会在收银台生效。</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -88,12 +93,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, onMounted } from 'vue'
|
import { computed, reactive, ref, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import StatusTag from '../components/StatusTag.vue'
|
import StatusTag from '../components/StatusTag.vue'
|
||||||
// TODO: 接口联调时打开
|
import {
|
||||||
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
|
getChannelConfig,
|
||||||
|
saveChannelConfig,
|
||||||
|
testChannelConnection,
|
||||||
|
toggleChannel
|
||||||
|
} from '@/api/payment'
|
||||||
|
|
||||||
const CHANNEL = 'cloudpay'
|
const CHANNEL = 'cloudpay'
|
||||||
|
|
||||||
@@ -120,20 +129,33 @@ const rules: FormRules = {
|
|||||||
|
|
||||||
/* ---------------- 加载 / 保存 / 测试 ---------------- */
|
/* ---------------- 加载 / 保存 / 测试 ---------------- */
|
||||||
|
|
||||||
async function loadConfig() {
|
/** 继承收单机构展示为「代码 - 名称」 */
|
||||||
// TODO: 接口联通后替换为真实请求(商户号/收单机构建议由后端从银联配置继承返回)
|
const acqDisplay = computed(() => {
|
||||||
// const res = await getChannelConfig(CHANNEL)
|
const opt = acqOptions.find((o) => o.code === form.acq_ins_code)
|
||||||
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
|
return opt ? `${opt.code} - ${opt.name}` : form.acq_ins_code
|
||||||
// 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
|
|
||||||
|
async function loadConfig() {
|
||||||
|
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() {
|
async function handleSave() {
|
||||||
@@ -145,12 +167,16 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 接口联通后替换为真实请求
|
// 继承参数写入自身 config,保证启用校验(参数非空)可通过;展示开关存于扩展参数
|
||||||
// await saveChannelConfig(CHANNEL, { ...form })
|
await saveChannelConfig(CHANNEL, {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
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('云闪付配置已保存')
|
ElMessage.success('云闪付配置已保存')
|
||||||
|
await loadConfig() // 重新拉取,刷新继承状态
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '保存失败')
|
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -159,12 +185,12 @@ async function handleSave() {
|
|||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
testing.value = true
|
testing.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 接口联通后替换为真实请求(后端会校验银联参数是否可用)
|
const res: any = await testChannelConnection(CHANNEL, {
|
||||||
// const res = await testChannelConnection(CHANNEL, { ...form })
|
config: { mer_id: form.mer_id, acq_ins_code: form.acq_ins_code }
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
})
|
||||||
ElMessage.success('连接成功:云闪付通道可用(继承银联参数)')
|
ElMessage.success(res?.data?.message || '连接成功:云闪付通道可用(继承银联参数)')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '连接失败,请先完成银联配置')
|
ElMessage.error(error?.response?.data?.msg || error?.message || '连接失败,请先完成银联配置')
|
||||||
} finally {
|
} finally {
|
||||||
testing.value = false
|
testing.value = false
|
||||||
}
|
}
|
||||||
@@ -184,9 +210,17 @@ async function handleToggle(enable: boolean) {
|
|||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
|
try {
|
||||||
|
const res: any = await toggleChannel(CHANNEL, enable)
|
||||||
|
if (res?.code === 200) {
|
||||||
form.enabled = enable
|
form.enabled = enable
|
||||||
ElMessage.success(`云闪付已${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) {
|
async function copyText(text: string) {
|
||||||
|
|||||||
@@ -88,7 +88,12 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="启用状态">
|
<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>
|
<span class="field-tip inline">仅当有海外租户时才建议启用。</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -108,8 +113,12 @@ import { reactive, ref, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import StatusTag from '../components/StatusTag.vue'
|
import StatusTag from '../components/StatusTag.vue'
|
||||||
// TODO: 接口联调时打开
|
import {
|
||||||
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
|
getChannelConfig,
|
||||||
|
saveChannelConfig,
|
||||||
|
testChannelConnection,
|
||||||
|
toggleChannel
|
||||||
|
} from '@/api/payment'
|
||||||
|
|
||||||
const CHANNEL = 'paypal'
|
const CHANNEL = 'paypal'
|
||||||
|
|
||||||
@@ -161,16 +170,21 @@ function toggleSecret(key: 'client_secret') {
|
|||||||
/* ---------------- 加载 / 保存 / 测试 ---------------- */
|
/* ---------------- 加载 / 保存 / 测试 ---------------- */
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
// TODO: 接口联通后替换为真实请求
|
try {
|
||||||
// const res = await getChannelConfig(CHANNEL)
|
const res: any = await getChannelConfig(CHANNEL)
|
||||||
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
|
if (res?.code === 200 && res.data) {
|
||||||
Object.assign(form, {
|
const cfg = res.data.config || {}
|
||||||
client_id: '',
|
form.client_id = cfg.client_id || ''
|
||||||
client_secret: '',
|
// 敏感字段后端只回掩码,原样放进表单表示「不修改」
|
||||||
webhook_id: '',
|
form.client_secret = cfg.client_secret || ''
|
||||||
env: 'sandbox',
|
form.webhook_id = cfg.webhook_id || ''
|
||||||
enabled: false
|
// 运行环境存于扩展参数 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() {
|
async function handleSave() {
|
||||||
@@ -182,12 +196,20 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 接口联通后替换为真实请求(client_secret 为掩码时表示不修改)
|
// client_secret 为掩码 / 空串时后端保持原值不变;env 存于扩展参数
|
||||||
// await saveChannelConfig(CHANNEL, { ...form })
|
await saveChannelConfig(CHANNEL, {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
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 配置已保存')
|
ElMessage.success('PayPal 配置已保存')
|
||||||
|
await loadConfig() // 重新拉取,刷新敏感字段掩码回显
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '保存失败')
|
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -196,12 +218,17 @@ async function handleSave() {
|
|||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
testing.value = true
|
testing.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 接口联通后替换为真实请求
|
const res: any = await testChannelConnection(CHANNEL, {
|
||||||
// const res = await testChannelConnection(CHANNEL, { ...form })
|
config: {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
client_id: form.client_id,
|
||||||
ElMessage.success('连接成功:OAuth2 凭证校验通过')
|
client_secret: form.client_secret,
|
||||||
|
webhook_id: form.webhook_id
|
||||||
|
},
|
||||||
|
extra: { env: form.env }
|
||||||
|
})
|
||||||
|
ElMessage.success(res?.data?.message || '连接成功:OAuth2 凭证校验通过')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '连接失败,请检查 Client ID / Secret 与运行环境')
|
ElMessage.error(error?.response?.data?.msg || error?.message || '连接失败,请检查 Client ID / Secret 与运行环境')
|
||||||
} finally {
|
} finally {
|
||||||
testing.value = false
|
testing.value = false
|
||||||
}
|
}
|
||||||
@@ -217,9 +244,17 @@ async function handleToggle(enable: boolean) {
|
|||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
|
try {
|
||||||
|
const res: any = await toggleChannel(CHANNEL, enable)
|
||||||
|
if (res?.code === 200) {
|
||||||
form.enabled = enable
|
form.enabled = enable
|
||||||
ElMessage.success(`PayPal 已${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) {
|
async function copyText(text: string) {
|
||||||
|
|||||||
@@ -88,7 +88,12 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="启用状态">
|
<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>
|
<span class="field-tip inline">停用后收银台不再展示银联。</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -108,8 +113,13 @@ import { reactive, ref, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import StatusTag from '../components/StatusTag.vue'
|
import StatusTag from '../components/StatusTag.vue'
|
||||||
// TODO: 接口联调时打开
|
import {
|
||||||
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel, uploadChannelCert } from '@/api/payment'
|
getChannelConfig,
|
||||||
|
saveChannelConfig,
|
||||||
|
testChannelConnection,
|
||||||
|
toggleChannel,
|
||||||
|
uploadChannelCert
|
||||||
|
} from '@/api/payment'
|
||||||
|
|
||||||
const CHANNEL = 'unionpay'
|
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() {
|
async function loadConfig() {
|
||||||
// TODO: 接口联通后替换为真实请求
|
try {
|
||||||
// const res = await getChannelConfig(CHANNEL)
|
const res: any = await getChannelConfig(CHANNEL)
|
||||||
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
|
if (res?.code === 200 && res.data) {
|
||||||
Object.assign(form, {
|
const cfg = res.data.config || {}
|
||||||
mer_id: '',
|
form.mer_id = cfg.mer_id || ''
|
||||||
acq_ins_code: '',
|
form.acq_ins_code = cfg.acq_ins_code || ''
|
||||||
cert_file: '',
|
form.cert_file = cfg.cert_file || ''
|
||||||
enabled: false
|
form.enabled = !!res.data.enabled
|
||||||
})
|
certFile.value = certFileName(res.data.cert_files?.cert_path)
|
||||||
certFile.value = ''
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.response?.data?.msg || error?.message || '加载渠道配置失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -167,12 +187,21 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 接口联通后替换为真实请求
|
// 证书密码(cert_password)为掩码 / 空串时后端保持原值不变
|
||||||
// await saveChannelConfig(CHANNEL, { ...form })
|
await saveChannelConfig(CHANNEL, {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
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('银联配置已保存')
|
ElMessage.success('银联配置已保存')
|
||||||
|
await loadConfig() // 重新拉取,刷新掩码与证书回显
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '保存失败')
|
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -181,12 +210,15 @@ async function handleSave() {
|
|||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
testing.value = true
|
testing.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 接口联通后替换为真实请求
|
const res: any = await testChannelConnection(CHANNEL, {
|
||||||
// const res = await testChannelConnection(CHANNEL, { ...form })
|
config: {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
mer_id: form.mer_id,
|
||||||
ElMessage.success('连接成功:商户号、收单机构与证书校验通过')
|
acq_ins_code: form.acq_ins_code
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ElMessage.success(res?.data?.message || '连接成功:商户号、收单机构与证书校验通过')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '连接失败,请检查商户号、收单机构与证书')
|
ElMessage.error(error?.response?.data?.msg || error?.message || '连接失败,请检查商户号、收单机构与证书')
|
||||||
} finally {
|
} finally {
|
||||||
testing.value = false
|
testing.value = false
|
||||||
}
|
}
|
||||||
@@ -202,16 +234,34 @@ async function handleToggle(enable: boolean) {
|
|||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
|
try {
|
||||||
|
const res: any = await toggleChannel(CHANNEL, enable)
|
||||||
|
if (res?.code === 200) {
|
||||||
form.enabled = enable
|
form.enabled = enable
|
||||||
ElMessage.success(`银联已${enable ? '启用' : '停用'}`)
|
ElMessage.success(`银联已${enable ? '启用' : '停用'}`)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.msg || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.response?.data?.msg || error?.message || '操作失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCertChange(file: any) {
|
async function handleCertChange(file: any) {
|
||||||
// TODO: 接口联通后替换为 uploadChannelCert(CHANNEL, file.raw, 'cert')
|
const raw = file?.raw
|
||||||
certFile.value = file?.name || ''
|
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
|
form.cert_file = certFile.value
|
||||||
ElMessage.success(`已选择证书:${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) {
|
async function copyText(text: string) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<b>去哪拿参数:</b>登录微信商户平台
|
<b>去哪拿参数:</b>登录微信商户平台
|
||||||
<a href="https://pay.weixin.qq.com" target="_blank" rel="noopener">pay.weixin.qq.com</a>
|
<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)。
|
「账户中心 → API 安全」申请<b>APIv3 密钥</b>并下载<b>API 证书</b>(apiclient_cert.pem / apiclient_key.pem)。
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<span class="card-title">参数配置</span>
|
<span class="card-title">参数配置</span>
|
||||||
</template>
|
</template>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" class="config-form">
|
<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-input v-model="form.mch_id" placeholder="请输入微信支付商户号,如 1620888999" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
@@ -71,6 +71,32 @@
|
|||||||
<div class="field-tip">回显为掩码表示已保存;点击「修改」后留空保存,表示保持原密钥不变。</div>
|
<div class="field-tip">回显为掩码表示已保存;点击「修改」后留空保存,表示保持原密钥不变。</div>
|
||||||
</el-form-item>
|
</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="商户证书">
|
<el-form-item label="商户证书">
|
||||||
<div class="upload-col">
|
<div class="upload-col">
|
||||||
<div class="upload-row">
|
<div class="upload-row">
|
||||||
@@ -108,7 +134,12 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="启用状态">
|
<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>
|
<span class="field-tip inline">停用后收银台不再展示微信支付。</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -128,8 +159,13 @@ import { reactive, ref, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import StatusTag from '../components/StatusTag.vue'
|
import StatusTag from '../components/StatusTag.vue'
|
||||||
// TODO: 接口联调时打开
|
import {
|
||||||
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel, uploadChannelCert } from '@/api/payment'
|
getChannelConfig,
|
||||||
|
saveChannelConfig,
|
||||||
|
testChannelConnection,
|
||||||
|
toggleChannel,
|
||||||
|
uploadChannelCert
|
||||||
|
} from '@/api/payment'
|
||||||
|
|
||||||
const CHANNEL = 'wechat'
|
const CHANNEL = 'wechat'
|
||||||
|
|
||||||
@@ -143,7 +179,10 @@ const testing = ref(false)
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
mch_id: '',
|
mch_id: '',
|
||||||
appid: '',
|
appid: '',
|
||||||
|
cert_serial_no: '',
|
||||||
api_v3_key: '',
|
api_v3_key: '',
|
||||||
|
pub_key_id: '',
|
||||||
|
pub_key: '',
|
||||||
enabled: true
|
enabled: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -152,6 +191,7 @@ const certFiles = reactive<{ cert: string; key: string }>({ cert: '', key: '' })
|
|||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
mch_id: [{ required: true, message: '请输入商户号', trigger: 'blur' }],
|
mch_id: [{ required: true, message: '请输入商户号', trigger: 'blur' }],
|
||||||
appid: [{ required: true, message: '请输入 APPID', 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' }]
|
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() {
|
async function loadConfig() {
|
||||||
// TODO: 接口联通后替换为真实请求
|
try {
|
||||||
// const res = await getChannelConfig(CHANNEL)
|
const res: any = await getChannelConfig(CHANNEL)
|
||||||
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
|
if (res?.code === 200 && res.data) {
|
||||||
Object.assign(form, {
|
const cfg = res.data.config || {}
|
||||||
mch_id: '1620888999',
|
form.mch_id = cfg.mch_id || ''
|
||||||
appid: 'wx9a2c7b1a4d6e8f0a',
|
form.appid = cfg.appid || ''
|
||||||
api_v3_key: 'WXv3Key9f2c7b1a4d6e8f0a3c5b7d9e',
|
form.cert_serial_no = cfg.cert_serial_no || ''
|
||||||
enabled: true
|
form.pub_key_id = cfg.pub_key_id || ''
|
||||||
})
|
form.pub_key = cfg.pub_key || ''
|
||||||
certFiles.cert = 'apiclient_cert.pem'
|
// 敏感字段后端只回掩码,原样放进表单表示「不修改」
|
||||||
certFiles.key = 'apiclient_key.pem'
|
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() {
|
async function handleSave() {
|
||||||
@@ -204,12 +258,22 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 接口联通后替换为真实请求(api_v3_key 为掩码时表示不修改)
|
// api_v3_key 为掩码 / 空串时后端保持原值不变
|
||||||
// await saveChannelConfig(CHANNEL, { ...form, cert_files: certFiles })
|
await saveChannelConfig(CHANNEL, {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
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('微信支付配置已保存')
|
ElMessage.success('微信支付配置已保存')
|
||||||
|
await loadConfig() // 重新拉取,刷新敏感字段掩码回显
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '保存失败')
|
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -218,12 +282,20 @@ async function handleSave() {
|
|||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
testing.value = true
|
testing.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 接口联通后替换为真实请求
|
// 支持携带未保存参数测试,掩码 / 空串由后端还原为已保存原值
|
||||||
// const res = await testChannelConnection(CHANNEL, { ...form })
|
const res: any = await testChannelConnection(CHANNEL, {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
config: {
|
||||||
ElMessage.success('连接成功:商户证书与 APIv3 密钥校验通过')
|
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) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '连接失败,请检查商户号、密钥与证书')
|
ElMessage.error(error?.response?.data?.msg || error?.message || '连接失败,请检查商户号、密钥与证书')
|
||||||
} finally {
|
} finally {
|
||||||
testing.value = false
|
testing.value = false
|
||||||
}
|
}
|
||||||
@@ -239,15 +311,33 @@ async function handleToggle(enable: boolean) {
|
|||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
|
try {
|
||||||
|
const res: any = await toggleChannel(CHANNEL, enable)
|
||||||
|
if (res?.code === 200) {
|
||||||
form.enabled = enable
|
form.enabled = enable
|
||||||
ElMessage.success(`微信支付已${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') {
|
async function handleCertChange(file: any, certType: 'cert' | 'key') {
|
||||||
// TODO: 接口联通后替换为 uploadChannelCert(CHANNEL, file.raw, certType)
|
const raw = file?.raw
|
||||||
certFiles[certType] = file?.name || ''
|
if (!raw) return
|
||||||
ElMessage.success(`已选择证书:${file?.name || ''}`)
|
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) {
|
async function copyText(text: string) {
|
||||||
|
|||||||
@@ -119,14 +119,14 @@ const channels: ChannelDoc[] = [
|
|||||||
name: '微信支付',
|
name: '微信支付',
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
where: [
|
where: [
|
||||||
'登录微信商户平台,进入「账户中心 → 商户信息」,记录商户号 mch_id。',
|
'登录微信商户平台,进入「账户中心 → 商户信息」,记录微信支付商户号。',
|
||||||
'进入「产品中心 → 开发配置」,绑定需要收款的 APPID(公众号 / 小程序 / APP)。',
|
'进入「产品中心 → 开发配置」,绑定需要收款的 APPID(公众号 / 小程序 / APP)。',
|
||||||
'进入「账户中心 → API 安全」,申请 APIv3 密钥(32 位,只在申请时展示)。',
|
'进入「账户中心 → API 安全」,申请 APIv3 密钥(32 位,只在申请时展示)。',
|
||||||
'同页「API 证书」下载证书文件,取 apiclient_cert.pem 与 apiclient_key.pem。'
|
'同页「API 证书」下载证书文件,取 apiclient_cert.pem 与 apiclient_key.pem。'
|
||||||
],
|
],
|
||||||
links: [{ text: '微信商户平台', url: 'https://pay.weixin.qq.com' }],
|
links: [{ text: '微信商户平台', url: 'https://pay.weixin.qq.com' }],
|
||||||
fields: [
|
fields: [
|
||||||
{ label: '商户号 mch_id', required: true, tip: '微信支付商户号,纯数字,注意不要填成 APPID' },
|
{ label: '微信支付商户号', required: true, tip: '微信支付商户号,纯数字,注意不要填成 APPID' },
|
||||||
{ label: 'APPID', required: true, tip: '必须与该商户号完成绑定,否则拉起支付会报商户号与 AppID 不匹配' },
|
{ label: 'APPID', required: true, tip: '必须与该商户号完成绑定,否则拉起支付会报商户号与 AppID 不匹配' },
|
||||||
{ label: 'APIv3 密钥', required: true, secret: true, tip: '32 位字符串,用于解密回调与接口签名,丢失只能重新申请' },
|
{ label: 'APIv3 密钥', required: true, secret: true, tip: '32 位字符串,用于解密回调与接口签名,丢失只能重新申请' },
|
||||||
{ label: '商户证书', required: true, secret: true, tip: 'apiclient_cert.pem + apiclient_key.pem,上传后由后端加密保存' },
|
{ label: '商户证书', required: true, secret: true, tip: 'apiclient_cert.pem + apiclient_key.pem,上传后由后端加密保存' },
|
||||||
|
|||||||
Reference in New Issue
Block a user