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'">

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
import type { AdminUser } from "../types/api";
const props = defineProps<{
modelValue: boolean;
users: AdminUser[];
submitting: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
submit: [payload: { extensionDays: number; remark: string }];
}>();
const form = reactive({ extensionDays: 30, remark: "" });
const title = computed(() => props.users.length > 1 ? `批量续期(${props.users.length} 人)` : "用户权益续期");
const userNames = computed(() => props.users.map((user) => user.name || user.phone).join("、"));
watch(() => props.modelValue, (open) => {
if (open) Object.assign(form, { extensionDays: 30, remark: "" });
});
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="min(520px, calc(100vw - 32px))"
destroy-on-close
@update:model-value="$emit('update:modelValue', $event)"
>
<div class="renew-summary">
<span>续期对象</span>
<strong>{{ userNames }}</strong>
<small>未到期权益从原到期日顺延已到期权益从当前时间重新生效长期有效或未分配专属权益的用户不会被误续期</small>
</div>
<el-form label-position="top" @submit.prevent>
<el-form-item label="延长天数">
<el-input-number v-model="form.extensionDays" :min="1" :max="3650" controls-position="right" />
</el-form-item>
<el-form-item label="续期备注(可选)">
<el-input v-model="form.remark" maxlength="255" show-word-limit placeholder="例如:线下续费确认、活动补偿" />
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="submitting" @click="$emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="emit('submit', form)">确认续期</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.renew-summary { display: grid; gap: 6px; margin-bottom: 18px; padding: 14px; border-radius: 10px; background: #f4f8f6; }
.renew-summary span, .renew-summary small { color: #6d7e78; font-size: 12px; }
.renew-summary strong { color: #25483e; line-height: 1.55; }
.renew-summary small { line-height: 1.6; }
.el-input-number { width: 100%; }
</style>

View File

@@ -14,6 +14,7 @@ import type {
ChatRecordQuery,
DashboardStats,
EntitlementPlan,
EntitlementBatchRenewResult,
KnowledgeItem,
KnowledgeContentSearchItem,
KnowledgeDetail,
@@ -129,7 +130,7 @@ export const api = {
const qs = params.toString();
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
},
users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<TopicSessionRecord>>(`/admin/user/${id}/topics${queryString(query)}`),
@@ -159,6 +160,10 @@ export const api = {
request<EntitlementPlan>(`/admin/entitlement/plan/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
assignUserEntitlement: (userId: number, payload: { planId: number; effectiveAt?: string | null; expiredAt?: string | null; remark?: string | null }) =>
request<UserEntitlementSummary>(`/admin/user/${userId}/entitlement`, { method: "POST", body: JSON.stringify(payload) }),
renewUserEntitlement: (userId: number, payload: { extensionDays: number; idempotencyKey: string; remark?: string | null }) =>
request<UserEntitlementSummary>(`/admin/user/${userId}/entitlement/renew`, { method: "POST", body: JSON.stringify(payload) }),
batchRenewUserEntitlement: (payload: { userIds: number[]; extensionDays: number; idempotencyKey: string; remark?: string | null }) =>
request<EntitlementBatchRenewResult>("/admin/user/entitlement/batch-renew", { method: "POST", body: JSON.stringify(payload) }),
knowledge: (query: Record<string, unknown> = {}) => request<PageResult<KnowledgeItem>>(`/admin/knowledge/list${queryString(query)}`),
knowledgeOptions: () => request<KnowledgeItem[]>("/admin/knowledge/options"),
resolveKnowledgeNode: (nodeId: string) => request<{ nodeId: string; spaceId: string; sourceTitle: string; name: string; remark: string }>("/admin/knowledge/resolve-node", { method: "POST", body: JSON.stringify({ nodeId }) }),

View File

@@ -658,6 +658,42 @@ textarea {
white-space: nowrap;
}
.user-list-toolbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) 220px 190px auto auto auto;
gap: 10px;
align-items: center;
margin-bottom: 14px;
padding: 14px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #ffffff;
}
.user-list-toolbar .el-input,
.user-list-toolbar .el-select {
width: 100%;
}
.batch-renew-button {
margin-left: auto;
}
.entitlement-meta-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.entitlement-meta-row span {
overflow: hidden;
color: #71817b;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.migration-overview {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -2298,11 +2334,17 @@ textarea {
@media (max-width: 1120px) {
.student-tools,
.user-list-toolbar,
.agent-workbench,
.settings-grid {
grid-template-columns: 1fr;
}
.batch-renew-button {
width: 100%;
margin-left: 0;
}
.audit-detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

View File

@@ -138,6 +138,17 @@ export interface UserEntitlementSummary {
effectiveAt?: string | null;
expiredAt?: string | null;
source: string;
lifecycleStatus: "active" | "long_term" | "expiring_7" | "expiring_30" | "expired_fallback" | "default" | "legacy" | string;
daysUntilExpiry?: number | null;
previousPlanName?: string | null;
previousExpiredAt?: string | null;
}
export interface EntitlementBatchRenewResult {
succeeded: Array<{ userId: number; name: string }>;
failed: Array<{ userId: number; name?: string; reason: string }>;
successCount: number;
failureCount: number;
}
export interface UserImportFailure {