增加租户套餐

This commit is contained in:
2026-09-15 17:28:08 +08:00
parent a82b300b1a
commit 71a14c46f9
37 changed files with 3855 additions and 178 deletions
@@ -0,0 +1,389 @@
<template>
<div class="tenant-package-tab" v-loading="loading">
<el-descriptions :column="2" border class="quota-desc">
<el-descriptions-item label="当前套餐">
<el-select
v-model="selectedPackageId"
placeholder="请选择套餐"
size="small"
style="width: 220px"
@change="handleSetPackage"
>
<el-option
v-for="p in packageOptions"
:key="p.id"
:label="p.name"
:value="p.id"
/>
</el-select>
</el-descriptions-item>
<el-descriptions-item label="开通功能">
<template v-if="info.module_names && info.module_names.length">
<el-tag v-for="(m, i) in info.module_names" :key="i" size="small" class="module-tag">
{{ m }}
</el-tag>
</template>
<span v-else class="muted">未配置功能</span>
</el-descriptions-item>
<el-descriptions-item label="用户数使用">
<span class="quota-text">
<b :class="{ danger: info.remaining <= 0 }">{{ info.used }}</b> / {{ info.quota }} 人
</span>
<el-progress
:percentage="usedPercent"
:status="info.remaining <= 0 ? 'exception' : undefined"
:stroke-width="8"
class="quota-progress"
/>
<span class="muted">剩余 {{ info.remaining }} 个名额</span>
</el-descriptions-item>
<el-descriptions-item label="增购单价">
<span>{{ formatMoney(info.extra_user_price) }}/人</span>
<el-button type="primary" link class="ml8" @click="openRecharge">增购用户数</el-button>
</el-descriptions-item>
</el-descriptions>
<el-alert
v-if="info.remaining <= 0"
type="warning"
show-icon
:closable="false"
class="quota-alert"
title="该租户用户数已达上限,新增账号前请先增购用户数"
/>
<div class="section-title">增购记录</div>
<el-table :data="orders" style="width: 100%" size="small" v-loading="ordersLoading">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="增购方式" width="110" align="center">
<template #default="{ row }">
<el-tag size="small" :type="Number(row.type) === 2 ? 'success' : 'info'" effect="plain">
{{ Number(row.type) === 2 ? "套餐增购" : "单个增购" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="package_name" label="规格" min-width="120" align="center" />
<el-table-column label="用户数" width="90" align="center">
<template #default="{ row }">+{{ row.user_count }}</template>
</el-table-column>
<el-table-column label="单价" width="110" align="center">
<template #default="{ row }">{{ formatMoney(row.unit_price) }}</template>
</el-table-column>
<el-table-column label="金额" width="110" align="center">
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="上限变化" width="130" align="center">
<template #default="{ row }">
{{ row.quota_before }} → <b>{{ row.quota_after }}</b>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="120" align="center" />
<el-table-column label="时间" min-width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
</el-table>
<!-- 增购用户数 -->
<el-dialog v-model="rechargeVisible" title="增购用户数" width="520px" destroy-on-close>
<el-form :model="rechargeForm" label-width="100px">
<el-form-item label="增购方式">
<el-radio-group v-model="rechargeForm.type">
<el-radio :label="1">单个增购</el-radio>
<el-radio :label="2">按套餐增购</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="rechargeForm.type === 1">
<el-form-item label="增购数量">
<el-input-number
v-model="rechargeForm.user_count"
:min="1"
:max="9999"
controls-position="right"
/>
<span class="field-tip">个用户</span>
</el-form-item>
<el-form-item label="单价">
<el-input-number
v-model="rechargeForm.unit_price"
:min="0"
:max="999999"
:precision="2"
controls-position="right"
/>
<span class="field-tip">元/人(默认为套餐设置的单用户增购价)</span>
</el-form-item>
</template>
<template v-else>
<el-form-item label="加购套餐">
<el-select v-model="rechargeForm.quota_package_id" placeholder="请选择" style="width: 100%">
<el-option
v-for="q in info.quota_packages || []"
:key="q.id"
:label="`${q.name}(${q.user_count}人 / ${formatMoney(q.price)})`"
:value="q.id"
/>
</el-select>
</el-form-item>
</template>
<el-form-item label="应付金额">
<span class="amount-text">{{ formatMoney(rechargeAmount) }}</span>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="rechargeForm.remark" maxlength="200" placeholder="选填,如:客户已付款" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rechargeVisible = false">取消</el-button>
<el-button type="primary" :loading="recharging" @click="submitRecharge">确认增购</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { formatDateTime } from "@/utils/datetime";
import {
getTenantQuotaInfo,
getTenantQuotaOrders,
getTenantPackageSelectList,
setTenantPackage,
rechargeTenantQuota,
} from "@/api/tenantPackage";
const props = defineProps<{
/** 当前租户 ID,为空时不请求 */
tid: number | null;
}>();
const loading = ref(false);
const ordersLoading = ref(false);
const recharging = ref(false);
const info = ref<any>({});
const orders = ref<any[]>([]);
const packageOptions = ref<any[]>([]);
const selectedPackageId = ref<number | undefined>(undefined);
const rechargeVisible = ref(false);
const rechargeForm = ref({
type: 1,
user_count: 1,
unit_price: 0,
quota_package_id: undefined as number | undefined,
remark: "",
});
const formatMoney = (v: any) => `¥${Number(v || 0).toFixed(2)}`;
const usedPercent = computed(() => {
const quota = Number(info.value.quota || 0);
const used = Number(info.value.used || 0);
if (quota <= 0) return 0;
return Math.min(100, Math.round((used / quota) * 100));
});
const rechargeAmount = computed(() => {
if (Number(rechargeForm.value.type) === 2) {
const pkg = (info.value.quota_packages || []).find(
(q: any) => Number(q.id) === Number(rechargeForm.value.quota_package_id)
);
return Number(pkg?.price || 0);
}
return Number(rechargeForm.value.user_count || 0) * Number(rechargeForm.value.unit_price || 0);
});
const loadInfo = async () => {
const tid = props.tid;
if (tid == null) return;
loading.value = true;
try {
const res = await getTenantQuotaInfo(tid);
if (res.code === 200) {
info.value = res.data || {};
selectedPackageId.value = Number(info.value.package_id || 0) || undefined;
} else {
ElMessage.error(res.msg || "获取租户套餐信息失败");
}
} catch (e: any) {
ElMessage.error(e?.message || "获取租户套餐信息失败");
} finally {
loading.value = false;
}
};
const loadOrders = async () => {
const tid = props.tid;
if (tid == null) return;
ordersLoading.value = true;
try {
const res = await getTenantQuotaOrders(tid);
if (res.code === 200) {
orders.value = res.data?.list || [];
}
} catch {
orders.value = [];
} finally {
ordersLoading.value = false;
}
};
const loadPackageOptions = async () => {
try {
const res = await getTenantPackageSelectList();
if (res.code === 200) {
packageOptions.value = res.data || [];
}
} catch {
packageOptions.value = [];
}
};
const refresh = () => {
loadInfo();
loadOrders();
};
watch(
() => props.tid,
(val) => {
if (val != null) {
refresh();
if (packageOptions.value.length === 0) loadPackageOptions();
}
},
{ immediate: true }
);
const handleSetPackage = async (id: number) => {
const tid = props.tid;
if (tid == null || !id) return;
try {
await ElMessageBox.confirm(
"调整套餐会改变该租户可用的功能模块(用户数上限只增不减),确定继续吗?",
"提示",
{ type: "warning" }
);
} catch {
// 取消时回滚选择
selectedPackageId.value = Number(info.value.package_id || 0) || undefined;
return;
}
try {
const res = await setTenantPackage({ tid, package_id: id, sync_quota: true });
if (res.code === 200) {
ElMessage.success("套餐设置成功");
refresh();
} else {
ElMessage.error(res.msg || "设置失败");
selectedPackageId.value = Number(info.value.package_id || 0) || undefined;
}
} catch (e: any) {
ElMessage.error(e?.message || "设置失败");
}
};
const openRecharge = () => {
rechargeForm.value = {
type: 1,
user_count: 1,
unit_price: Number(info.value.extra_user_price || 0),
quota_package_id: (info.value.quota_packages || [])[0]?.id,
remark: "",
};
rechargeVisible.value = true;
};
const submitRecharge = async () => {
const tid = props.tid;
if (tid == null) return;
const payload: any = {
tid,
type: rechargeForm.value.type,
remark: rechargeForm.value.remark,
};
if (Number(rechargeForm.value.type) === 2) {
if (!rechargeForm.value.quota_package_id) {
ElMessage.warning("请选择加购套餐");
return;
}
payload.quota_package_id = rechargeForm.value.quota_package_id;
} else {
if (Number(rechargeForm.value.user_count) <= 0) {
ElMessage.warning("请输入增购数量");
return;
}
payload.user_count = rechargeForm.value.user_count;
payload.unit_price = rechargeForm.value.unit_price;
}
recharging.value = true;
try {
const res = await rechargeTenantQuota(payload);
if (res.code === 200) {
ElMessage.success(`增购成功,本次增加 ${res.data?.add || 0} 个用户数`);
rechargeVisible.value = false;
refresh();
} else {
ElMessage.error(res.msg || "增购失败");
}
} catch (e: any) {
ElMessage.error(e?.message || "增购失败");
} finally {
recharging.value = false;
}
};
defineExpose({ refresh });
</script>
<style scoped>
.quota-desc {
margin-bottom: 12px;
}
.module-tag {
margin: 0 6px 6px 0;
}
.quota-text b {
font-size: 16px;
}
.quota-text b.danger {
color: var(--el-color-danger);
}
.quota-progress {
margin: 6px 0 2px;
max-width: 220px;
}
.muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.ml8 {
margin-left: 8px;
}
.quota-alert {
margin-bottom: 12px;
}
.section-title {
margin: 16px 0 8px;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.amount-text {
font-size: 16px;
font-weight: 600;
color: var(--el-color-danger);
}
.field-tip {
margin-left: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -1,7 +1,14 @@
<template>
<div class="tenant-users-tab">
<div class="section-header">
<div class="section-title">用户列表</div>
<div class="section-title">
用户列表
<span v-if="quotaInfo" class="quota-badge">
用户数
<b :class="{ danger: Number(quotaInfo.remaining) <= 0 }">{{ quotaInfo.used }}</b>
/ {{ quotaInfo.quota }}
</span>
</div>
<el-button type="primary" size="small" :disabled="!tid" @click="handleAddUser">
<el-icon><Plus /></el-icon>
添加用户
@@ -121,6 +128,7 @@ import { ref, reactive, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { getTenantUserList, editTenantUser, deleteTenantUser } from "@/api/tenantUser";
import { getTenantQuotaInfo } from "@/api/tenantPackage";
import { getOrganizationList } from "@/api/erp";
import AddUser from "./adduser.vue";
@@ -134,6 +142,25 @@ const usersLoading = ref(false);
const userSearchKeyword = ref("");
const addUserRef = ref<{ open: (tenantId: number) => void } | null>(null);
// 租户用户数配额(已用/上限):用于展示与新增前的拦截
const quotaInfo = ref<any>(null);
const loadQuotaInfo = async () => {
const id = props.tid;
if (id == null) {
quotaInfo.value = null;
return;
}
try {
const res = await getTenantQuotaInfo(id);
if (res.code === 200) {
quotaInfo.value = res.data || null;
}
} catch {
quotaInfo.value = null;
}
};
const orgTree = ref<any[]>([]);
const orgNameMap = ref<Record<number, string>>({});
@@ -234,6 +261,8 @@ const refreshTenantUsers = async () => {
} finally {
usersLoading.value = false;
}
// 用户数统计随增删变化,同步刷新配额展示
loadQuotaInfo();
};
const loadUsersForTid = async (tid: number) => {
@@ -259,9 +288,11 @@ watch(
loadOrgs();
if (id == null) {
tenantUsers.value = [];
quotaInfo.value = null;
return;
}
loadUsersForTid(id);
loadQuotaInfo();
},
{ immediate: true }
);
@@ -276,9 +307,17 @@ const resetUserSearch = () => {
};
const handleAddUser = () => {
if (props.tid != null) {
addUserRef.value?.open(props.tid);
if (props.tid == null) return;
// 用户数上限校验:达到上限时提示到「套餐与用户数」增购
if (quotaInfo.value && Number(quotaInfo.value.quota) > 0 && Number(quotaInfo.value.used) >= Number(quotaInfo.value.quota)) {
ElMessageBox.alert(
`该租户用户数已达上限(${quotaInfo.value.used}/${quotaInfo.value.quota}),请先在「套餐与用户数」中增购用户数。`,
"用户数已达上限",
{ type: "warning", confirmButtonText: "我知道了" }
).catch(() => {});
return;
}
addUserRef.value?.open(props.tid);
};
const openPasswordDialog = (row: any) => {
@@ -419,6 +458,22 @@ defineExpose({
color: #303133;
}
.quota-badge {
margin-left: 12px;
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
b {
color: var(--el-color-primary);
font-size: 13px;
}
b.danger {
color: var(--el-color-danger);
}
}
.user-search-form {
margin-bottom: 8px;
padding: 8px 12px;
@@ -38,6 +38,16 @@
<el-descriptions-item label="创建时间">
{{ detailData.create_time ? formatDateTime(detailData.create_time) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="租户套餐">
<el-tag size="small" type="warning" effect="plain">
{{ detailData.package_name || '未绑定' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用户数">
<span>
{{ detailData.user_used ?? 0 }} / {{ detailData.user_quota ?? 0 }} 人
</span>
</el-descriptions-item>
</el-descriptions>
<el-divider />
@@ -53,6 +63,11 @@
<TenantUsersTab :tid="detailTenantId" />
</div>
</el-tab-pane>
<el-tab-pane label="套餐与用户数" name="package">
<div class="tab-pane-inner">
<TenantPackageTab :tid="detailTenantId" />
</div>
</el-tab-pane>
<!-- 后续功能:在此继续增加 <el-tab-pane label="..." name="...">...</el-tab-pane> -->
</el-tabs>
</div>
@@ -68,6 +83,7 @@
import { ref } from "vue";
import { getTenantDetail } from "@/api/tenant";
import TenantUsersTab from "./TenantUsersTab.vue";
import TenantPackageTab from "./TenantPackageTab.vue";
import { formatDateTime } from "@/utils/datetime";
const visible = ref(false);
@@ -24,6 +24,21 @@
<el-form-item label="租户地址" prop="address">
<el-input v-model="formData.address" type="textarea" placeholder="请输入地址" />
</el-form-item>
<el-form-item label="租户套餐" prop="package_id">
<el-select v-model="formData.package_id" placeholder="请选择套餐" clearable
style="width: 100%" @change="handlePackageChange">
<el-option v-for="p in packageOptions" :key="p.id" :label="p.name" :value="p.id">
<span>{{ p.name }}</span>
<span class="option-tip">{{ packageSummary(p) }}</span>
</el-option>
</el-select>
<div class="form-tip">决定租户端可用的功能模块(套餐在「基础设置 → 租户套餐」维护)</div>
</el-form-item>
<el-form-item label="用户数上限" prop="user_quota">
<el-input-number v-model="formData.user_quota" :min="1" :max="99999"
controls-position="right" />
<div class="form-tip">默认 20 人;达到上限后需增购用户数才能继续开账号</div>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
@@ -40,9 +55,10 @@
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { createTenant, editTenant, getTenantDetail, checkTenantCode } from '@/api/tenant';
import { getTenantPackageSelectList } from '@/api/tenantPackage';
const emit = defineEmits(['success']);
const visible = ref(false);
@@ -50,6 +66,39 @@ const loading = ref(false);
const submitting = ref(false);
const formRef = ref();
// 套餐下拉:套餐决定租户端开通的功能模块与初始用户数
const packageOptions = ref<any[]>([]);
const loadPackages = async () => {
try {
const res = await getTenantPackageSelectList();
if (res.code === 200) {
packageOptions.value = res.data || [];
}
} catch {
packageOptions.value = [];
}
};
onMounted(loadPackages);
const packageSummary = (p: any) => {
const names = (p.modules || []).map((m: any) => m.module_name || m.module_code).join('、');
return `${names || '未配置功能'} · ${p.user_quota || 0}人`;
};
// 选择套餐时联动用户数上限(取套餐包含人数与当前值的较大者,避免回收已购用户数)
const handlePackageChange = (id: number) => {
const pkg = packageOptions.value.find((p) => Number(p.id) === Number(id));
if (!pkg) return;
const quota = Number(pkg.user_quota || 20);
if (!formData.id) {
formData.user_quota = quota;
} else if (quota > Number(formData.user_quota || 0)) {
formData.user_quota = quota;
}
};
const initialData = {
id: null,
tenant_name: '',
@@ -58,7 +107,9 @@ const initialData = {
contact_phone: '',
contact_email: '',
address: '',
status: 1
status: 1,
package_id: undefined as number | undefined,
user_quota: 20
};
const formData = reactive({ ...initialData });
@@ -191,4 +242,20 @@ const handleClosed = () => {
};
defineExpose({ open });
</script>
</script>
<style scoped>
.option-tip {
float: right;
margin-left: 16px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
margin-top: 4px;
}
</style>