feat: add entitlement plans and topic sessions

This commit is contained in:
2026-07-31 15:22:11 +08:00
parent ad2161497f
commit 884dc765ad
27 changed files with 2389 additions and 15 deletions

View File

@@ -16,6 +16,7 @@ import type {
ChatDetail,
ChatRecord,
DashboardStats,
EntitlementPlan,
KnowledgeItem,
RetrievalLogItem,
AttentionItem,
@@ -41,6 +42,8 @@ const dashboardFilters = reactive({
});
const users = ref<AdminUser[]>([]);
const userKeyword = ref("");
const entitlementPlans = ref<EntitlementPlan[]>([]);
const editingEntitlementPlanId = ref<number | null>(null);
const models = ref<ModelItem[]>([]);
const configs = ref<SystemConfigItem[]>([]);
const chats = ref<ChatRecord[]>([]);
@@ -88,6 +91,21 @@ const studentImportFile = ref<File | null>(null);
const studentImportInput = ref<HTMLInputElement | null>(null);
const studentImportResult = ref<UserImportResult | null>(null);
const entitlementPlanForm = reactive({
name: "",
planType: "basic" as "basic" | "deep" | "addon" | "teacher",
description: "",
validityDays: null as number | null,
monthlyTopicLimit: 30 as number | null,
enableGrowthProfile: 0,
enablePeriodicReports: 0,
allowHelpCard: 1,
allowShareDraft: 1,
deductQuota: 1,
status: 1,
sortOrder: 10,
});
const knowledgeForm = reactive({
name: "",
feishuSpaceId: "",
@@ -278,7 +296,8 @@ async function loadCurrentMenu() {
loading.value = true;
try {
if (activeMenu.value === "dashboard") await loadDashboard();
if (activeMenu.value === "users") await loadUsers(pagers.users.page, pagers.users.pageSize);
if (activeMenu.value === "users") { await loadEntitlementPlans(); await loadUsers(pagers.users.page, pagers.users.pageSize); }
if (activeMenu.value === "entitlements") await loadEntitlementPlans();
if (activeMenu.value === "models") models.value = await api.models();
if (activeMenu.value === "configs") {
configs.value = await api.configs();
@@ -298,6 +317,78 @@ async function loadUsers(page = 1, pageSize = pagers.users.pageSize) {
Object.assign(pagers.users, { page: result.page, pageSize: result.pageSize, total: result.total });
}
async function loadEntitlementPlans() {
entitlementPlans.value = await api.entitlementPlans(true);
}
function planTypeLabel(type: string) {
return {
basic: "基础版",
deep: "深度陪伴版",
addon: "高频加购包",
teacher: "老师工作版",
}[type] || type;
}
async function saveEntitlementPlan() {
if (!entitlementPlanForm.name.trim()) {
ElMessage.warning("请填写权益版本名称");
return;
}
const payload = { ...entitlementPlanForm };
if (editingEntitlementPlanId.value) {
await api.updateEntitlementPlan(editingEntitlementPlanId.value, payload);
ElMessage.success("权益版本已更新");
} else {
await api.createEntitlementPlan(payload);
ElMessage.success("权益版本已新增");
}
resetEntitlementPlanForm();
await loadEntitlementPlans();
}
function editEntitlementPlan(row: EntitlementPlan) {
editingEntitlementPlanId.value = row.id;
Object.assign(entitlementPlanForm, {
name: row.name,
planType: row.planType,
description: row.description ?? "",
validityDays: row.validityDays ?? null,
monthlyTopicLimit: row.monthlyTopicLimit ?? null,
enableGrowthProfile: row.enableGrowthProfile ? 1 : 0,
enablePeriodicReports: row.enablePeriodicReports ? 1 : 0,
allowHelpCard: row.allowHelpCard ? 1 : 0,
allowShareDraft: row.allowShareDraft ? 1 : 0,
deductQuota: row.deductQuota ? 1 : 0,
status: row.status,
sortOrder: row.sortOrder,
});
}
function resetEntitlementPlanForm() {
editingEntitlementPlanId.value = null;
Object.assign(entitlementPlanForm, {
name: "",
planType: "basic",
description: "",
validityDays: null,
monthlyTopicLimit: 30,
enableGrowthProfile: 0,
enablePeriodicReports: 0,
allowHelpCard: 1,
allowShareDraft: 1,
deductQuota: 1,
status: 1,
sortOrder: 10,
});
}
async function assignUserEntitlement(row: AdminUser, planId: number) {
const entitlement = await api.assignUserEntitlement(row.id, { planId });
row.entitlement = entitlement;
ElMessage.success("用户权益已更新");
}
async function loadRecordTab(tab = recordTab.value) {
loading.value = true;
try {
@@ -866,6 +957,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div class="sidebar-title">大本营千问千答</div>
<button :class="{ active: activeMenu === 'dashboard' }" @click="switchMenu('dashboard')">数据看板</button>
<button :class="{ active: activeMenu === 'users' }" @click="switchMenu('users')">用户管理</button>
<button :class="{ active: activeMenu === 'entitlements' }" @click="switchMenu('entitlements')">权益管理</button>
<button :class="{ active: activeMenu === 'knowledge' }" @click="switchMenu('knowledge')">知识库管理</button>
<button :class="{ active: activeMenu === 'prompt' }" @click="switchMenu('prompt')">Agent 管理</button>
<button :class="{ active: activeMenu === 'models' }" @click="switchMenu('models')">模型管理</button>
@@ -984,6 +1076,30 @@ 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">
<template #default="{ row }">
<div class="user-entitlement-cell">
<el-select
:model-value="row.entitlement?.planId"
placeholder="默认基础版"
size="small"
@change="(planId: number) => assignUserEntitlement(row, planId)"
>
<el-option
v-for="plan in entitlementPlans.filter((item) => item.status === 1)"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</el-select>
<small>
{{ row.entitlement?.name || '默认基础版' }}
· 本月主题
{{ row.entitlement?.monthlyTopicUsed ?? 0 }}/{{ row.entitlement?.monthlyTopicLimit ?? '不限' }}
</small>
</div>
</template>
</el-table-column>
<el-table-column prop="lastLoginAt" label="最近登录" width="180" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
@@ -995,6 +1111,92 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<AdminPagination :page="pagers.users.page" :page-size="pagers.users.pageSize" :total="pagers.users.total" @change="loadUsers" />
</template>
<template v-if="activeMenu === 'entitlements'">
<div class="page-head">
<h2>权益管理</h2>
<p>配置不同服务版本的本月主题额度和可用能力学员可在用户管理里直接分配</p>
</div>
<section class="entitlement-editor">
<div class="entitlement-editor-head">
<div>
<h3>{{ editingEntitlementPlanId ? '编辑权益版本' : '新增权益版本' }}</h3>
<p>一期先接入主题额度成长档案/周期报告开关和转人工卡片/分享草稿能力位</p>
</div>
<el-button @click="resetEntitlementPlanForm">清空</el-button>
</div>
<el-form label-position="top" :model="entitlementPlanForm">
<div class="entitlement-form-grid">
<el-form-item label="版本名称">
<el-input v-model="entitlementPlanForm.name" placeholder="例如:五个月深度陪伴版" />
</el-form-item>
<el-form-item label="版本类型">
<el-select v-model="entitlementPlanForm.planType">
<el-option label="基础版" value="basic" />
<el-option label="深度陪伴版" value="deep" />
<el-option label="高频加购包" value="addon" />
<el-option label="老师工作版" value="teacher" />
</el-select>
</el-form-item>
<el-form-item label="有效天数">
<el-input-number v-model="entitlementPlanForm.validityDays" :min="1" :max="3650" placeholder="留空长期有效" />
</el-form-item>
<el-form-item label="本月主题额度">
<el-input-number v-model="entitlementPlanForm.monthlyTopicLimit" :min="0" :max="99999" placeholder="留空不限" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="entitlementPlanForm.sortOrder" :min="0" :max="9999" />
</el-form-item>
</div>
<el-form-item label="说明">
<el-input v-model="entitlementPlanForm.description" type="textarea" :rows="2" placeholder="给后台管理员看的说明,不展示给学员" />
</el-form-item>
<div class="entitlement-switch-grid">
<label><span>成长档案</span><el-switch v-model="entitlementPlanForm.enableGrowthProfile" :active-value="1" :inactive-value="0" /></label>
<label><span>周期报告</span><el-switch v-model="entitlementPlanForm.enablePeriodicReports" :active-value="1" :inactive-value="0" /></label>
<label><span>允许求助卡片</span><el-switch v-model="entitlementPlanForm.allowHelpCard" :active-value="1" :inactive-value="0" /></label>
<label><span>允许分享草稿</span><el-switch v-model="entitlementPlanForm.allowShareDraft" :active-value="1" :inactive-value="0" /></label>
<label><span>占用主题额度</span><el-switch v-model="entitlementPlanForm.deductQuota" :active-value="1" :inactive-value="0" /></label>
<label><span>启用版本</span><el-switch v-model="entitlementPlanForm.status" :active-value="1" :inactive-value="0" /></label>
</div>
<div class="actions">
<el-button type="primary" @click="saveEntitlementPlan">{{ editingEntitlementPlanId ? '保存权益版本' : '新增权益版本' }}</el-button>
<el-button @click="resetEntitlementPlanForm">取消编辑</el-button>
</div>
</el-form>
</section>
<el-table :data="entitlementPlans" stripe>
<el-table-column prop="name" label="版本名称" min-width="180" />
<el-table-column label="类型" width="130">
<template #default="{ row }">{{ planTypeLabel(row.planType) }}</template>
</el-table-column>
<el-table-column label="本月主题额度" width="130">
<template #default="{ row }">{{ row.monthlyTopicLimit ?? '不限' }}</template>
</el-table-column>
<el-table-column label="有效期" width="110">
<template #default="{ row }">{{ row.validityDays ? `${row.validityDays}` : '长期' }}</template>
</el-table-column>
<el-table-column label="能力" min-width="260">
<template #default="{ row }">
<div class="entitlement-capabilities">
<el-tag v-if="row.enableGrowthProfile" type="success" effect="plain">成长档案</el-tag>
<el-tag v-if="row.enablePeriodicReports" type="success" effect="plain">周期报告</el-tag>
<el-tag v-if="row.allowHelpCard" effect="plain">求助卡片</el-tag>
<el-tag v-if="row.allowShareDraft" effect="plain">分享草稿</el-tag>
<el-tag :type="row.deductQuota ? 'warning' : 'info'" effect="plain">{{ row.deductQuota ? '计入额度' : '不计额度' }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '启用' : '停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }"><el-button size="small" @click="editEntitlementPlan(row)">编辑</el-button></template>
</el-table-column>
</el-table>
</template>
<template v-if="activeMenu === 'knowledge'">
<KnowledgeManagementView @preview="previewKnowledge" />
</template>

View File

@@ -12,6 +12,7 @@ import type {
ChatRecord,
ChatRecordQuery,
DashboardStats,
EntitlementPlan,
KnowledgeItem,
KnowledgeContentSearchItem,
KnowledgeDetail,
@@ -25,6 +26,7 @@ import type {
ModelItem,
SystemConfigItem,
UserImportResult,
UserEntitlementSummary,
PageResult,
PromptDetail,
PromptHistoryItem,
@@ -136,6 +138,14 @@ export const api = {
updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
entitlementPlans: (includeDisabled = true) =>
request<EntitlementPlan[]>(`/admin/entitlement/plan/list${queryString({ includeDisabled })}`),
createEntitlementPlan: (payload: Record<string, unknown>) =>
request<EntitlementPlan>("/admin/entitlement/plan", { method: "POST", body: JSON.stringify(payload) }),
updateEntitlementPlan: (id: number, payload: Record<string, unknown>) =>
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) }),
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

@@ -635,6 +635,97 @@ textarea {
flex: 0 0 auto;
}
.user-entitlement-cell {
display: grid;
gap: 6px;
min-width: 0;
}
.user-entitlement-cell .el-select {
width: 100%;
}
.user-entitlement-cell small {
overflow: hidden;
color: #71817b;
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.entitlement-editor {
margin-bottom: 16px;
padding: 18px;
border: 1px solid #dfe8e5;
border-radius: 12px;
background: #ffffff;
}
.entitlement-editor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.entitlement-editor-head h3,
.entitlement-editor-head p {
margin: 0;
}
.entitlement-editor-head p {
margin-top: 6px;
color: #71817b;
font-size: 13px;
line-height: 1.6;
}
.entitlement-form-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.entitlement-form-grid .el-input-number,
.entitlement-form-grid .el-select {
width: 100%;
}
.entitlement-switch-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
}
.entitlement-switch-grid label {
min-width: 0;
min-height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 1px solid #dfe8e5;
border-radius: 10px;
background: #f8fbfa;
color: #40524b;
font-size: 13px;
}
.entitlement-switch-grid span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entitlement-capabilities {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.migration-overview {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));

View File

@@ -61,6 +61,42 @@ export interface AdminUser {
expiredAt?: string | null;
lastLoginAt?: string | null;
createdAt?: string | null;
entitlement?: UserEntitlementSummary | null;
}
export interface EntitlementPlan {
id: number;
name: string;
planType: "basic" | "deep" | "addon" | "teacher";
description?: string | null;
validityDays?: number | null;
monthlyTopicLimit?: number | null;
enableGrowthProfile: boolean;
enablePeriodicReports: boolean;
allowHelpCard: boolean;
allowShareDraft: boolean;
deductQuota: boolean;
status: number;
sortOrder: number;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface UserEntitlementSummary {
planId?: number | null;
name: string;
planType: string;
monthlyTopicLimit?: number | null;
monthlyTopicUsed: number;
monthlyTopicRemaining?: number | null;
enableGrowthProfile: boolean;
enablePeriodicReports: boolean;
allowHelpCard: boolean;
allowShareDraft: boolean;
deductQuota: boolean;
effectiveAt?: string | null;
expiredAt?: string | null;
source: string;
}
export interface UserImportFailure {
@@ -315,6 +351,7 @@ export interface ChatRecord {
export interface ChatMessageRecord {
id: number;
sessionId: number;
topicSessionId?: number | null;
userId: number;
role: "user" | "assistant";
content: string;