543 lines
17 KiB
Vue
543 lines
17 KiB
Vue
<template>
|
||
<div class="tenant-package-tab" v-loading="loading">
|
||
<div class="panel-toolbar">
|
||
<el-button size="small" :loading="loading" @click="refresh">
|
||
<el-icon><Refresh /></el-icon>
|
||
刷新数据
|
||
</el-button>
|
||
</div>
|
||
<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}(${formatMoney(p.price)}${priceUnit(p)})`"
|
||
:value="p.id"
|
||
/>
|
||
</el-select>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="套餐到期">
|
||
<template v-if="info.package_expire_time">
|
||
<span>{{ formatDateTime(info.package_expire_time) }}</span>
|
||
<el-tag
|
||
size="small"
|
||
:type="expireTagType"
|
||
effect="plain"
|
||
class="ml8"
|
||
>
|
||
剩余 {{ info.days_remaining }} 天
|
||
</el-tag>
|
||
</template>
|
||
<span v-else class="muted">永久有效</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="开通功能">
|
||
<template v-if="info.modules && info.modules.length">
|
||
<el-tag v-for="(m, i) in info.modules" :key="i" size="small" class="module-tag">
|
||
{{ m.module_name || m.module_code }}
|
||
</el-tag>
|
||
</template>
|
||
<span v-else class="muted">未配置功能</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="单品功能">
|
||
<template v-if="info.product_modules && info.product_modules.length">
|
||
<el-tag
|
||
v-for="(m, i) in info.product_modules"
|
||
:key="i"
|
||
size="small"
|
||
type="success"
|
||
class="module-tag"
|
||
>
|
||
{{ m.module_name || m.module_code }}
|
||
</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="增购单价">
|
||
<template v-if="Number(info.extra_user_price) > 0">
|
||
<span>{{ formatMoney(info.extra_user_price) }}/人</span>
|
||
</template>
|
||
<span v-else class="muted">未设置</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="该租户用户数已达上限,新增账号前请先增购用户数"
|
||
/>
|
||
<el-alert
|
||
v-if="expiringSoon"
|
||
type="warning"
|
||
show-icon
|
||
:closable="false"
|
||
class="quota-alert"
|
||
title="该租户套餐即将到期,到期后将自动切换为基础套餐,请及时续费"
|
||
/>
|
||
|
||
<div class="section-title">已购功能(单品购买)</div>
|
||
<el-table :data="info.product_orders || []" style="width: 100%" size="small">
|
||
<el-table-column prop="product_name" label="产品" min-width="100" show-overflow-tooltip />
|
||
<el-table-column label="功能模块" width="150" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.module_name || row.module_code" size="small" effect="plain">
|
||
{{ row.module_name || row.module_code }}
|
||
</el-tag>
|
||
<span v-else class="muted">未关联</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="order_no" label="订单号" min-width="180" show-overflow-tooltip />
|
||
<el-table-column label="金额" width="100" align="center">
|
||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="开通时间" min-width="120" align="center">
|
||
<template #default="{ row }">
|
||
{{ row.activate_time ? formatDate(row.activate_time) : "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="过期时间" min-width="180" align="center">
|
||
<template #default="{ row }">
|
||
<template v-if="row.expire_time">
|
||
<span>{{ formatDate(row.expire_time) }}</span>
|
||
<el-tag
|
||
size="small"
|
||
:type="row.expired ? 'danger' : 'success'"
|
||
effect="plain"
|
||
class="ml8"
|
||
>
|
||
{{ row.expired ? "已过期" : `剩余 ${remainingDays(row.expire_time)} 天` }}
|
||
</el-tag>
|
||
</template>
|
||
<span v-else class="muted">长期有效</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="90" align="center">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="danger" link @click="handleCloseEntitlement(row)">
|
||
关闭功能
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<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 { Refresh } from "@element-plus/icons-vue";
|
||
import { formatDate, formatDateTime } from "@/utils/datetime";
|
||
import {
|
||
getTenantQuotaInfo,
|
||
getTenantQuotaOrders,
|
||
getTenantPackageSelectList,
|
||
setTenantPackage,
|
||
rechargeTenantQuota,
|
||
} from "@/api/tenantPackage";
|
||
import { closeProductOrder } from "@/api/product";
|
||
|
||
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)}`;
|
||
/** 距到期剩余天数(向上取整,最小 0) */
|
||
const remainingDays = (t: string) => {
|
||
const ms = new Date(t).getTime() - Date.now();
|
||
return Math.max(0, Math.ceil(ms / 86400000));
|
||
};
|
||
/** 套餐价格单位随有效时长变化:365天=元/年,30=元/月,90=元/季,其余=元/N天 */
|
||
const priceUnit = (row: any) => {
|
||
const d = Number(row?.duration_days || 365);
|
||
if (d === 365) return "/年";
|
||
if (d === 30) return "/月";
|
||
if (d === 90) return "/季";
|
||
return `/${d}天`;
|
||
};
|
||
|
||
/** 套餐剩余天数:<=30 天给出预警 */
|
||
const expiringSoon = computed(() => {
|
||
if (!info.value.package_expire_time) return false;
|
||
return Number(info.value.days_remaining || 0) <= 30;
|
||
});
|
||
|
||
const expireTagType = computed(() => {
|
||
const days = Number(info.value.days_remaining || 0);
|
||
if (days <= 7) return "danger";
|
||
if (days <= 30) return "warning";
|
||
return "success";
|
||
});
|
||
|
||
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;
|
||
const isRenewal = Number(id) === Number(info.value.package_id) && !!info.value.package_expire_time;
|
||
const billingTip = isRenewal
|
||
? "同套餐未到期再次绑定视为续费,将在原到期时间上顺延一个有效时长"
|
||
: "绑定套餐将按套餐有效时长计费(默认一年),到期后自动切换为基础套餐";
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`调整套餐会改变该租户可用的功能模块(用户数上限只增不减)。${billingTip},确定继续吗?`,
|
||
"提示",
|
||
{ 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 handleCloseEntitlement = async (row: any) => {
|
||
if (!row?.order_id) return;
|
||
const label = row.module_name || row.product_name || "该";
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`关闭后该租户将立即失去「${label}」功能的使用权限(订单保留,可在「产品定价 - 购买订单」中重新开通),确定继续吗?`,
|
||
"关闭功能",
|
||
{ type: "warning", confirmButtonText: "确认关闭" }
|
||
);
|
||
} catch {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await closeProductOrder(row.order_id);
|
||
if (res.code === 200) {
|
||
ElMessage.success("功能已关闭");
|
||
refresh();
|
||
} else {
|
||
ElMessage.error(res.msg || "关闭失败");
|
||
}
|
||
} 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>
|
||
.panel-toolbar {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-bottom: 8px;
|
||
}
|
||
.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>
|