From b2b56f43bbaffb46a4c13aeb2f9c2e5042f7fd18 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Mon, 3 Aug 2026 14:32:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E6=9D=83=E7=9B=8A?= =?UTF-8?q?=E5=88=B0=E6=9C=9F=E4=B8=8E=E7=BB=AD=E6=9C=9F=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/admin-web/src/App.vue | 127 ++++++++++++- .../components/UserEntitlementRenewDialog.vue | 60 ++++++ .../apps/admin-web/src/services/api.ts | 7 +- .../apps/admin-web/src/styles.css | 42 +++++ .../apps/admin-web/src/types/api.ts | 11 ++ .../versions/0026_entitlement_renewal.py | 52 ++++++ .../backend/app/api/admin_entitlements.py | 80 +++++++- .../apps/backend/app/api/admin_users.py | 90 ++++++++- .../apps/backend/app/models/entitlement.py | 4 +- .../apps/backend/app/schemas/admin.py | 10 + .../app/services/entitlement_service.py | 174 +++++++++++++++++- .../app/services/maintenance_service.py | 3 + .../backend/tests/test_entitlements_topics.py | 112 ++++++++++- .../src/components/PersonalCenterDialog.vue | 12 ++ .../src/components/SessionQuota.vue | 11 ++ .../apps/user-client/src/types/api.ts | 4 + .../docs/qianwen_product_todo.md | 1 + .../docs/一期全链路验收记录.md | 12 +- 18 files changed, 792 insertions(+), 20 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/admin-web/src/components/UserEntitlementRenewDialog.vue create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0026_entitlement_renewal.py diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index bfcaa92..10d60d7 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -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(null); const userKeyword = ref(""); +const userEntitlementFilters = reactive({ planId: undefined as number | undefined, status: "all" }); +const selectedUsers = ref([]); +const renewDialogOpen = ref(false); +const renewTargets = ref([]); +const renewSubmitting = ref(false); const entitlementPlans = ref([]); const models = ref([]); const configs = ref([]); @@ -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") {