feat: 完成权益到期与续期闭环

This commit is contained in:
2026-08-03 14:32:38 +08:00
parent 20ed4875b8
commit b2b56f43bb
18 changed files with 792 additions and 20 deletions

View File

@@ -8,6 +8,7 @@ import AgentManagementView from "./components/AgentManagementView.vue";
import EntitlementManagementView from "./components/EntitlementManagementView.vue";
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
import TableRowActions from "./components/TableRowActions.vue";
import UserEntitlementRenewDialog from "./components/UserEntitlementRenewDialog.vue";
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
import { api, clearToken, getToken, saveToken } from "./services/api";
import type {
@@ -49,6 +50,11 @@ const userDetailLoading = ref(false);
const userReportGenerating = ref("");
const userSettlementRetrying = ref<number | null>(null);
const userKeyword = ref("");
const userEntitlementFilters = reactive({ planId: undefined as number | undefined, status: "all" });
const selectedUsers = ref<AdminUser[]>([]);
const renewDialogOpen = ref(false);
const renewTargets = ref<AdminUser[]>([]);
const renewSubmitting = ref(false);
const entitlementPlans = ref<EntitlementPlan[]>([]);
const models = ref<ModelItem[]>([]);
const configs = ref<SystemConfigItem[]>([]);
@@ -326,11 +332,91 @@ async function loadCurrentMenu() {
}
async function loadUsers(page = 1, pageSize = pagers.users.pageSize) {
const result = await api.users({ keyword: userKeyword.value, page, pageSize });
const result = await api.users({
keyword: userKeyword.value,
planId: userEntitlementFilters.planId,
entitlementStatus: userEntitlementFilters.status,
page,
pageSize,
});
users.value = result.items;
selectedUsers.value = [];
Object.assign(pagers.users, { page: result.page, pageSize: result.pageSize, total: result.total });
}
function resetUserFilters() {
userKeyword.value = "";
userEntitlementFilters.planId = undefined;
userEntitlementFilters.status = "all";
void loadUsers(1);
}
function handleUserSelectionChange(rows: AdminUser[]) {
selectedUsers.value = rows;
}
function openRenewDialog(targets: AdminUser[]) {
if (!targets.length) return ElMessage.warning("请先选择需要续期的用户");
renewTargets.value = targets;
renewDialogOpen.value = true;
}
async function submitEntitlementRenew(payload: { extensionDays: number; remark: string }) {
if (!renewTargets.value.length || renewSubmitting.value) return;
renewSubmitting.value = true;
const idempotencyKey = typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `renew-${Date.now()}-${Math.random().toString(36).slice(2)}`;
try {
if (renewTargets.value.length === 1) {
await api.renewUserEntitlement(renewTargets.value[0].id, { ...payload, idempotencyKey });
ElMessage.success("权益续期成功");
} else {
const result = await api.batchRenewUserEntitlement({
userIds: renewTargets.value.map((user) => user.id),
...payload,
idempotencyKey,
});
if (result.failureCount) {
const firstFailure = result.failed[0];
ElMessage.warning(`成功 ${result.successCount} 人,失败 ${result.failureCount}${firstFailure ? `${firstFailure.name || firstFailure.userId}${firstFailure.reason}` : ""}`);
} else {
ElMessage.success(`已为 ${result.successCount} 位用户续期`);
}
}
renewDialogOpen.value = false;
await loadUsers(pagers.users.page, pagers.users.pageSize);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "权益续期失败");
} finally {
renewSubmitting.value = false;
}
}
function entitlementStatusLabel(row: AdminUser) {
const status = row.entitlement?.lifecycleStatus;
if (status === "expired_fallback") return "已到期,已降级";
if (status === "expiring_7") return "7 天内到期";
if (status === "expiring_30") return "30 天内到期";
if (status === "long_term") return "长期有效";
if (status === "default") return "默认权益";
return "生效中";
}
function entitlementStatusType(row: AdminUser) {
const status = row.entitlement?.lifecycleStatus;
if (status === "expired_fallback") return "danger";
if (status === "expiring_7" || status === "expiring_30") return "warning";
if (status === "active" || status === "long_term") return "success";
return "info";
}
function formatEntitlementDate(value?: string | null) {
if (!value) return "长期";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString("zh-CN");
}
async function loadEntitlementPlans() {
entitlementPlans.value = await api.entitlementPlans(true);
}
@@ -1098,7 +1184,6 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<template v-if="activeMenu === 'users'">
<div class="page-head inline">
<div><h2>用户管理</h2><p>维护学员名单只有名单内启用学员可以登录用户端</p></div>
<el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @change="loadUsers(1)" />
</div>
<section class="student-tools">
<div class="student-tool-panel">
@@ -1154,7 +1239,27 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</div>
</div>
</section>
<el-table :data="users" stripe>
<section class="user-list-toolbar">
<el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @keyup.enter="loadUsers(1)" />
<el-select v-model="userEntitlementFilters.planId" placeholder="全部权益版本" clearable>
<el-option v-for="plan in entitlementPlans.filter((item) => item.status === 1)" :key="plan.id" :label="plan.name" :value="plan.id" />
</el-select>
<el-select v-model="userEntitlementFilters.status" aria-label="权益状态">
<el-option label="全部权益状态" value="all" />
<el-option label="生效中" value="active" />
<el-option label="7 天内到期" value="expiring7" />
<el-option label="8-30 天内到期" value="expiring30" />
<el-option label="已到期" value="expired" />
<el-option label="使用默认权益" value="default" />
</el-select>
<el-button type="primary" @click="loadUsers(1)">筛选</el-button>
<el-button @click="resetUserFilters">重置</el-button>
<el-button class="batch-renew-button" :disabled="!selectedUsers.length" @click="openRenewDialog(selectedUsers)">
批量续期{{ selectedUsers.length ? `${selectedUsers.length}` : '' }}
</el-button>
</section>
<el-table :data="users" stripe @selection-change="handleUserSelectionChange">
<el-table-column type="selection" width="48" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" width="150" />
<el-table-column prop="name" label="姓名" width="140" />
@@ -1166,7 +1271,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<template #default="{ row }"><el-input-number v-model="row.dailyChatLimit" :min="0" size="small" /></template>
</el-table-column>
<el-table-column prop="dailyChatUsed" label="已用" width="90" />
<el-table-column label="权益版本" min-width="260">
<el-table-column label="权益版本" min-width="300">
<template #default="{ row }">
<div class="user-entitlement-cell">
<el-select
@@ -1184,9 +1289,12 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</el-select>
<small>
{{ row.entitlement?.name || '默认基础版' }}
· 本月主题
{{ row.entitlement?.monthlyTopicUsed ?? 0 }}/{{ row.entitlement?.monthlyTopicLimit ?? '不限' }}
· {{ row.entitlement?.lifecycleStatus === 'expired_fallback' ? `${row.entitlement?.previousPlanName || '权益'} ${formatEntitlementDate(row.entitlement?.previousExpiredAt)} 到期` : `有效至 ${formatEntitlementDate(row.entitlement?.expiredAt)}` }}
</small>
<div class="entitlement-meta-row">
<el-tag size="small" :type="entitlementStatusType(row)">{{ entitlementStatusLabel(row) }}</el-tag>
<span>本月主题 {{ row.entitlement?.monthlyTopicUsed ?? 0 }}/{{ row.entitlement?.monthlyTopicLimit ?? '不限' }}</span>
</div>
</div>
</template>
</el-table-column>
@@ -1200,6 +1308,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-button size="small">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="openRenewDialog([row])">权益续期</el-dropdown-item>
<el-dropdown-item @click="deleteUser(row)">删除用户</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -1209,6 +1318,12 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</el-table-column>
</el-table>
<AdminPagination :page="pagers.users.page" :page-size="pagers.users.pageSize" :total="pagers.users.total" @change="loadUsers" />
<UserEntitlementRenewDialog
v-model="renewDialogOpen"
:users="renewTargets"
:submitting="renewSubmitting"
@submit="submitEntitlementRenew"
/>
</template>
<template v-if="activeMenu === 'entitlements'">