feat: 完成权益到期与续期闭环
This commit is contained in:
@@ -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'">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }) }),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user