Files
yunzerwebsiteallinone/platform/src/views/basicSettings/tenants/components/TenantPackageTab.vue
T

543 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>