feat: 优化权益管理并完成一期验收

This commit is contained in:
2026-07-31 18:49:48 +08:00
parent b6aff78aac
commit a3946013f5
14 changed files with 740 additions and 284 deletions

View File

@@ -5,6 +5,7 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import AdminLoginView from "./components/AdminLoginView.vue"; import AdminLoginView from "./components/AdminLoginView.vue";
import AdminPagination from "./components/AdminPagination.vue"; import AdminPagination from "./components/AdminPagination.vue";
import AgentManagementView from "./components/AgentManagementView.vue"; import AgentManagementView from "./components/AgentManagementView.vue";
import EntitlementManagementView from "./components/EntitlementManagementView.vue";
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue"; import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
import TableRowActions from "./components/TableRowActions.vue"; import TableRowActions from "./components/TableRowActions.vue";
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings"; import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
@@ -48,7 +49,6 @@ const userDetailLoading = ref(false);
const userReportGenerating = ref(""); const userReportGenerating = ref("");
const userKeyword = ref(""); const userKeyword = ref("");
const entitlementPlans = ref<EntitlementPlan[]>([]); const entitlementPlans = ref<EntitlementPlan[]>([]);
const editingEntitlementPlanId = ref<number | null>(null);
const models = ref<ModelItem[]>([]); const models = ref<ModelItem[]>([]);
const configs = ref<SystemConfigItem[]>([]); const configs = ref<SystemConfigItem[]>([]);
const chats = ref<ChatRecord[]>([]); const chats = ref<ChatRecord[]>([]);
@@ -96,21 +96,6 @@ const studentImportFile = ref<File | null>(null);
const studentImportInput = ref<HTMLInputElement | null>(null); const studentImportInput = ref<HTMLInputElement | null>(null);
const studentImportResult = ref<UserImportResult | null>(null); const studentImportResult = ref<UserImportResult | null>(null);
const entitlementPlanForm = reactive({
name: "",
planType: "basic" as "basic" | "deep" | "addon",
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({ const knowledgeForm = reactive({
name: "", name: "",
feishuSpaceId: "", feishuSpaceId: "",
@@ -349,67 +334,6 @@ async function loadEntitlementPlans() {
entitlementPlans.value = await api.entitlementPlans(true); entitlementPlans.value = await api.entitlementPlans(true);
} }
function planTypeLabel(type: string) {
return {
basic: "基础版",
deep: "深度陪伴版",
addon: "高频加购包",
}[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) { async function assignUserEntitlement(row: AdminUser, planId: number) {
const entitlement = await api.assignUserEntitlement(row.id, { planId }); const entitlement = await api.assignUserEntitlement(row.id, { planId });
row.entitlement = entitlement; row.entitlement = entitlement;
@@ -1272,88 +1196,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template> </template>
<template v-if="activeMenu === 'entitlements'"> <template v-if="activeMenu === 'entitlements'">
<div class="page-head"> <EntitlementManagementView :plans="entitlementPlans" @refreshed="loadEntitlementPlans" />
<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-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>
<template v-if="activeMenu === 'knowledge'"> <template v-if="activeMenu === 'knowledge'">

View File

@@ -0,0 +1,576 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, nextTick, reactive, ref } from "vue";
import { api } from "../services/api";
import type { EntitlementPlan } from "../types/api";
const props = defineProps<{
plans: EntitlementPlan[];
}>();
const emit = defineEmits<{
refreshed: [];
}>();
const editorRef = ref<HTMLElement | null>(null);
const editingPlanId = ref<number | null>(null);
const saving = ref(false);
const form = reactive({
name: "",
planType: "basic" as "basic" | "deep" | "addon",
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 enabledCount = computed(() => props.plans.filter((plan) => plan.status === 1).length);
const capabilityOptions = [
{
key: "enableGrowthProfile" as const,
title: "成长档案",
description: "持续沉淀学员主题、变化与成长记录",
},
{
key: "enablePeriodicReports" as const,
title: "周期报告",
description: "允许生成周报、月报和阶段报告",
},
{
key: "allowHelpCard" as const,
title: "求助卡片",
description: "允许学员整理内容并生成求助卡片",
},
{
key: "allowShareDraft" as const,
title: "分享草稿",
description: "允许将对话内容整理为可分享草稿",
},
{
key: "deductQuota" as const,
title: "占用主题额度",
description: "新建主题时计入本月可用额度",
},
];
function planTypeLabel(type: string) {
return {
basic: "基础版",
deep: "深度陪伴版",
addon: "高频加购包",
}[type] || type;
}
function resetForm() {
editingPlanId.value = null;
Object.assign(form, {
name: "",
planType: "basic",
description: "",
validityDays: null,
monthlyTopicLimit: 30,
enableGrowthProfile: 0,
enablePeriodicReports: 0,
allowHelpCard: 1,
allowShareDraft: 1,
deductQuota: 1,
status: 1,
sortOrder: 10,
});
}
async function savePlan() {
if (!form.name.trim()) {
ElMessage.warning("请填写权益版本名称");
return;
}
saving.value = true;
try {
const payload = { ...form, name: form.name.trim(), description: form.description.trim() };
if (editingPlanId.value) {
await api.updateEntitlementPlan(editingPlanId.value, payload);
ElMessage.success("权益版本已更新");
} else {
await api.createEntitlementPlan(payload);
ElMessage.success("权益版本已新增");
}
resetForm();
emit("refreshed");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "权益版本保存失败");
} finally {
saving.value = false;
}
}
async function editPlan(plan: EntitlementPlan) {
editingPlanId.value = plan.id;
Object.assign(form, {
name: plan.name,
planType: plan.planType,
description: plan.description ?? "",
validityDays: plan.validityDays ?? null,
monthlyTopicLimit: plan.monthlyTopicLimit ?? null,
enableGrowthProfile: plan.enableGrowthProfile ? 1 : 0,
enablePeriodicReports: plan.enablePeriodicReports ? 1 : 0,
allowHelpCard: plan.allowHelpCard ? 1 : 0,
allowShareDraft: plan.allowShareDraft ? 1 : 0,
deductQuota: plan.deductQuota ? 1 : 0,
status: plan.status,
sortOrder: plan.sortOrder,
});
await nextTick();
editorRef.value?.scrollIntoView({ behavior: "smooth", block: "start" });
}
</script>
<template>
<div class="page-head entitlement-page-head">
<div>
<h2>权益管理</h2>
<p>配置不同服务版本的主题额度与可用能力保存后可在用户管理中直接分配</p>
</div>
<div class="entitlement-summary" aria-label="权益版本统计">
<span><strong>{{ plans.length }}</strong> 个版本</span>
<span><strong>{{ enabledCount }}</strong> 个启用</span>
</div>
</div>
<section ref="editorRef" class="entitlement-editor">
<div class="entitlement-editor-head">
<div>
<div class="entitlement-editor-title-row">
<h3>{{ editingPlanId ? "编辑权益版本" : "新增权益版本" }}</h3>
<span class="editor-mode">{{ editingPlanId ? "编辑中" : "新建" }}</span>
</div>
<p>先设置版本和额度再决定学员可使用的产品能力</p>
</div>
<el-button v-if="editingPlanId" @click="resetForm">退出编辑</el-button>
<el-button v-else @click="resetForm">重置表单</el-button>
</div>
<el-form label-position="top" :model="form" @submit.prevent="savePlan">
<div class="entitlement-section-label">
<strong>基础配置</strong>
<span>名称和说明仅用于后台识别不展示给学员</span>
</div>
<div class="entitlement-basic-grid">
<el-form-item class="field-name" label="版本名称" required>
<el-input v-model="form.name" maxlength="50" placeholder="例如:五个月深度陪伴版" />
</el-form-item>
<el-form-item label="版本类型">
<el-select v-model="form.planType">
<el-option label="基础版" value="basic" />
<el-option label="深度陪伴版" value="deep" />
<el-option label="高频加购包" value="addon" />
</el-select>
</el-form-item>
<el-form-item label="版本状态">
<div class="status-switch-field">
<div>
<strong>{{ form.status === 1 ? "启用" : "停用" }}</strong>
<small>{{ form.status === 1 ? "可分配给学员" : "保留配置,不再分配" }}</small>
</div>
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</div>
</el-form-item>
<el-form-item label="有效天数">
<el-input-number v-model="form.validityDays" :min="1" :max="3650" placeholder="留空即长期" />
<div class="field-help">不填写表示长期有效</div>
</el-form-item>
<el-form-item label="每月主题额度">
<el-input-number v-model="form.monthlyTopicLimit" :min="0" :max="99999" placeholder="留空即不限" />
<div class="field-help">不填写表示不限制主题数</div>
</el-form-item>
<el-form-item label="列表排序">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
<div class="field-help">数字越小在列表中越靠前</div>
</el-form-item>
<el-form-item class="field-description" label="后台说明">
<el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="补充适用人群、售卖规则或内部说明" />
</el-form-item>
</div>
<div class="entitlement-section-label capability-heading">
<strong>能力权限</strong>
<span>关闭后前后端都会按权益规则限制对应能力</span>
</div>
<div class="entitlement-switch-grid">
<label v-for="item in capabilityOptions" :key="item.key" class="capability-switch-card">
<span>
<strong>{{ item.title }}</strong>
<small>{{ item.description }}</small>
</span>
<el-switch v-model="form[item.key]" :active-value="1" :inactive-value="0" />
</label>
</div>
<div class="entitlement-form-actions">
<el-button type="primary" native-type="submit" :loading="saving">
{{ editingPlanId ? "保存修改" : "新增权益版本" }}
</el-button>
<el-button v-if="editingPlanId" @click="resetForm">取消编辑</el-button>
</div>
</el-form>
</section>
<section class="entitlement-list-panel">
<div class="entitlement-list-head">
<div>
<h3>已有权益版本</h3>
<p>停用版本不会再出现在可分配列表中已保存的数据仍会保留</p>
</div>
</div>
<el-table :data="plans" stripe>
<el-table-column label="版本" min-width="210">
<template #default="{ row }">
<div class="plan-name-cell">
<strong>{{ row.name }}</strong>
<small>{{ row.description || "暂无后台说明" }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="130">
<template #default="{ row }">{{ planTypeLabel(row.planType) }}</template>
</el-table-column>
<el-table-column label="主题额度" width="110" align="center">
<template #default="{ row }">{{ row.monthlyTopicLimit ?? "不限" }}</template>
</el-table-column>
<el-table-column label="有效期" width="110" align="center">
<template #default="{ row }">{{ row.validityDays ? `${row.validityDays}` : "长期" }}</template>
</el-table-column>
<el-table-column label="能力" min-width="360">
<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" align="center">
<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="112" fixed="right" align="center">
<template #default="{ row }"><el-button size="small" @click="editPlan(row)">编辑</el-button></template>
</el-table-column>
</el-table>
</section>
</template>
<style scoped>
.entitlement-page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
}
.entitlement-summary {
display: flex;
flex: 0 0 auto;
gap: 8px;
}
.entitlement-summary span {
padding: 8px 12px;
border: 1px solid #dfe8e5;
border-radius: 999px;
background: #fff;
color: #71817b;
font-size: 13px;
}
.entitlement-summary strong {
color: #1f8d68;
}
.entitlement-editor,
.entitlement-list-panel {
border: 1px solid #dfe8e5;
border-radius: 14px;
background: #fff;
box-shadow: 0 8px 26px rgb(31 65 54 / 4%);
}
.entitlement-editor {
scroll-margin-top: 20px;
padding: 22px;
}
.entitlement-editor-head,
.entitlement-editor-title-row,
.entitlement-list-head {
display: flex;
align-items: center;
}
.entitlement-editor-head {
justify-content: space-between;
gap: 18px;
padding-bottom: 18px;
border-bottom: 1px solid #edf2f0;
}
.entitlement-editor-head h3,
.entitlement-editor-head p,
.entitlement-list-head h3,
.entitlement-list-head p {
margin: 0;
}
.entitlement-editor-head h3,
.entitlement-list-head h3 {
color: #263a34;
font-size: 18px;
}
.entitlement-editor-head p,
.entitlement-list-head p {
margin-top: 5px;
color: #71817b;
font-size: 13px;
line-height: 1.55;
}
.entitlement-editor-title-row {
gap: 10px;
}
.editor-mode {
padding: 3px 8px;
border-radius: 999px;
background: #edf7f3;
color: #218361;
font-size: 12px;
}
.entitlement-section-label {
display: flex;
align-items: baseline;
gap: 10px;
margin: 20px 0 12px;
}
.entitlement-section-label strong {
color: #263a34;
font-size: 15px;
}
.entitlement-section-label span {
color: #8a9893;
font-size: 12px;
}
.entitlement-basic-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 16px;
}
.field-name {
grid-column: span 2;
}
.field-description {
grid-column: 1 / -1;
}
.entitlement-basic-grid :deep(.el-select),
.entitlement-basic-grid :deep(.el-input-number) {
width: 100%;
}
.entitlement-basic-grid :deep(.el-form-item) {
margin-bottom: 17px;
}
.status-switch-field {
width: 100%;
min-height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
box-sizing: border-box;
padding: 5px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
}
.status-switch-field div,
.plan-name-cell,
.capability-switch-card > span {
min-width: 0;
}
.status-switch-field strong,
.status-switch-field small,
.plan-name-cell strong,
.plan-name-cell small,
.capability-switch-card strong,
.capability-switch-card small {
display: block;
}
.status-switch-field strong {
color: #40524b;
font-size: 13px;
line-height: 1.2;
}
.status-switch-field small,
.field-help {
color: #98a49f;
font-size: 11px;
}
.field-help {
margin-top: 5px;
line-height: 1.4;
}
.capability-heading {
margin-top: 5px;
}
.entitlement-switch-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.capability-switch-card {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
box-sizing: border-box;
padding: 11px 13px;
border: 1px solid #dfe8e5;
border-radius: 10px;
background: #f8fbfa;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.capability-switch-card:hover {
border-color: #b8d9cd;
background: #f4faf7;
}
.capability-switch-card strong {
color: #40524b;
font-size: 13px;
line-height: 1.3;
}
.capability-switch-card small {
margin-top: 4px;
color: #82918c;
font-size: 11px;
line-height: 1.35;
}
.capability-switch-card :deep(.el-switch) {
flex: 0 0 auto;
}
.entitlement-form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.entitlement-form-actions :deep(.el-button) {
min-width: 112px;
}
.entitlement-list-panel {
margin-top: 16px;
overflow: hidden;
}
.entitlement-list-head {
min-height: 74px;
box-sizing: border-box;
padding: 14px 20px;
border-bottom: 1px solid #edf2f0;
}
.plan-name-cell strong {
overflow: hidden;
color: #344942;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan-name-cell small {
overflow: hidden;
margin-top: 4px;
color: #86948f;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.entitlement-capabilities {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
@media (max-width: 1180px) {
.entitlement-basic-grid,
.entitlement-switch-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field-name {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.entitlement-page-head {
align-items: flex-start;
flex-direction: column;
}
.entitlement-editor {
padding: 16px;
}
.entitlement-editor-head {
align-items: flex-start;
}
.entitlement-basic-grid,
.entitlement-switch-grid {
grid-template-columns: 1fr;
}
.field-name,
.field-description {
grid-column: auto;
}
.entitlement-section-label {
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
}
</style>

View File

@@ -658,78 +658,6 @@ textarea {
white-space: nowrap; 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 { .migration-overview {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
revision = "0008_unique_kb_node" revision = "0008_unique_kb_node"
@@ -10,7 +10,9 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
constraints = {item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")} constraints = set() if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")
}
if "uq_knowledge_feishu_node" not in constraints: if "uq_knowledge_feishu_node" not in constraints:
op.create_unique_constraint( op.create_unique_constraint(
"uq_knowledge_feishu_node", "uq_knowledge_feishu_node",
@@ -20,6 +22,8 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
constraints = {item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")} constraints = {"uq_knowledge_feishu_node"} if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")
}
if "uq_knowledge_feishu_node" in constraints: if "uq_knowledge_feishu_node" in constraints:
op.drop_constraint("uq_knowledge_feishu_node", "sys_knowledge", type_="unique") op.drop_constraint("uq_knowledge_feishu_node", "sys_knowledge", type_="unique")

View File

@@ -3,7 +3,7 @@
Revision ID: 0009_logs_storage Revision ID: 0009_logs_storage
Revises: 0008_unique_kb_node Revises: 0008_unique_kb_node
""" """
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
revision = "0009_logs_storage" revision = "0009_logs_storage"
@@ -13,8 +13,8 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set(inspector.get_table_names()) tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_log_retention_policy" not in tables: if "sys_log_retention_policy" not in tables:
op.create_table( op.create_table(
"sys_log_retention_policy", "sys_log_retention_policy",
@@ -45,7 +45,10 @@ def upgrade() -> None:
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None: def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
if name not in {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)}: indexes = set() if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)
}
if name not in indexes:
op.create_index(name, table, columns) op.create_index(name, table, columns)

View File

@@ -4,7 +4,7 @@ Revision ID: 0013_question_insight_indexes
Revises: 0012_knowledge_latest_only Revises: 0012_knowledge_latest_only
""" """
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
@@ -19,11 +19,16 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
indexes = {item["name"] for item in sa.inspect(op.get_bind()).get_indexes("sys_chat_message")} indexes = {"ix_chat_message_role_created"} if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_indexes("sys_chat_message")
}
if "ix_chat_message_role_created" in indexes: if "ix_chat_message_role_created" in indexes:
op.drop_index("ix_chat_message_role_created", table_name="sys_chat_message") op.drop_index("ix_chat_message_role_created", table_name="sys_chat_message")
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None: def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
if name not in {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)}: indexes = set() if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)
}
if name not in indexes:
op.create_index(name, table, columns) op.create_index(name, table, columns)

View File

@@ -4,7 +4,7 @@ Revision ID: 0014_entitlements_topics
Revises: 0013_question_insight_indexes Revises: 0013_question_insight_indexes
""" """
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
@@ -17,8 +17,8 @@ PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None: def upgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set(inspector.get_table_names()) tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_entitlement_plan" not in tables: if "sys_entitlement_plan" not in tables:
op.create_table( op.create_table(
"sys_entitlement_plan", "sys_entitlement_plan",
@@ -100,19 +100,26 @@ def upgrade() -> None:
op.create_index("ix_sys_topic_session_chat_session_id", "sys_topic_session", ["chat_session_id"]) op.create_index("ix_sys_topic_session_chat_session_id", "sys_topic_session", ["chat_session_id"])
op.create_index("ix_sys_topic_session_status", "sys_topic_session", ["status"]) op.create_index("ix_sys_topic_session_status", "sys_topic_session", ["status"])
columns = {column["name"] for column in inspector.get_columns("sys_chat_message")} columns = set() if inspector is None else {column["name"] for column in inspector.get_columns("sys_chat_message")}
if "topic_session_id" not in columns: if "topic_session_id" not in columns:
op.add_column("sys_chat_message", sa.Column("topic_session_id", sa.BigInteger(), nullable=True)) op.add_column("sys_chat_message", sa.Column("topic_session_id", sa.BigInteger(), nullable=True))
op.create_index("ix_sys_chat_message_topic_session_id", "sys_chat_message", ["topic_session_id"]) op.create_index("ix_sys_chat_message_topic_session_id", "sys_chat_message", ["topic_session_id"])
def downgrade() -> None: def downgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
columns = {column["name"] for column in inspector.get_columns("sys_chat_message")} columns = {"topic_session_id"} if inspector is None else {
column["name"] for column in inspector.get_columns("sys_chat_message")
}
if "topic_session_id" in columns: if "topic_session_id" in columns:
op.drop_index("ix_sys_chat_message_topic_session_id", table_name="sys_chat_message") op.drop_index("ix_sys_chat_message_topic_session_id", table_name="sys_chat_message")
op.drop_column("sys_chat_message", "topic_session_id") op.drop_column("sys_chat_message", "topic_session_id")
tables = set(inspector.get_table_names()) tables = {
"sys_topic_session",
"sys_user_entitlement_log",
"sys_user_entitlement",
"sys_entitlement_plan",
} if inspector is None else set(inspector.get_table_names())
if "sys_topic_session" in tables: if "sys_topic_session" in tables:
op.drop_index("ix_sys_topic_session_status", table_name="sys_topic_session") op.drop_index("ix_sys_topic_session_status", table_name="sys_topic_session")
op.drop_index("ix_sys_topic_session_chat_session_id", table_name="sys_topic_session") op.drop_index("ix_sys_topic_session_chat_session_id", table_name="sys_topic_session")
@@ -136,18 +143,18 @@ def _seed_default_plans() -> None:
op.bulk_insert( op.bulk_insert(
sa.table( sa.table(
"sys_entitlement_plan", "sys_entitlement_plan",
sa.column("name"), sa.column("name", sa.String(80)),
sa.column("plan_type"), sa.column("plan_type", sa.String(30)),
sa.column("description"), sa.column("description", sa.String(255)),
sa.column("validity_days"), sa.column("validity_days", sa.Integer()),
sa.column("monthly_topic_limit"), sa.column("monthly_topic_limit", sa.Integer()),
sa.column("enable_growth_profile"), sa.column("enable_growth_profile", sa.Integer()),
sa.column("enable_periodic_reports"), sa.column("enable_periodic_reports", sa.Integer()),
sa.column("allow_help_card"), sa.column("allow_help_card", sa.Integer()),
sa.column("allow_share_draft"), sa.column("allow_share_draft", sa.Integer()),
sa.column("deduct_quota"), sa.column("deduct_quota", sa.Integer()),
sa.column("status"), sa.column("status", sa.Integer()),
sa.column("sort_order"), sa.column("sort_order", sa.Integer()),
), ),
[ [
{ {

View File

@@ -4,7 +4,7 @@ Revision ID: 0015_growth_profiles
Revises: 0014_entitlements_topics Revises: 0014_entitlements_topics
""" """
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
@@ -17,8 +17,8 @@ PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None: def upgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set(inspector.get_table_names()) tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_topic_summary" not in tables: if "sys_topic_summary" not in tables:
op.create_table( op.create_table(
@@ -85,8 +85,12 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set(inspector.get_table_names()) tables = {
"sys_growth_profile_revision",
"sys_user_growth_profile",
"sys_topic_summary",
} if inspector is None else set(inspector.get_table_names())
if "sys_growth_profile_revision" in tables: if "sys_growth_profile_revision" in tables:
op.drop_index("ix_sys_growth_profile_revision_topic_summary_id", table_name="sys_growth_profile_revision") op.drop_index("ix_sys_growth_profile_revision_topic_summary_id", table_name="sys_growth_profile_revision")
op.drop_index("ix_sys_growth_profile_revision_profile_id", table_name="sys_growth_profile_revision") op.drop_index("ix_sys_growth_profile_revision_profile_id", table_name="sys_growth_profile_revision")

View File

@@ -4,7 +4,7 @@ Revision ID: 0016_teacher_help_cards
Revises: 0015_growth_profiles Revises: 0015_growth_profiles
""" """
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
@@ -17,8 +17,8 @@ PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None: def upgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set(inspector.get_table_names()) tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_teacher_help_card" in tables: if "sys_teacher_help_card" in tables:
return return
op.create_table( op.create_table(
@@ -40,8 +40,8 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set(inspector.get_table_names()) tables = {"sys_teacher_help_card"} if inspector is None else set(inspector.get_table_names())
if "sys_teacher_help_card" not in tables: if "sys_teacher_help_card" not in tables:
return return
op.drop_index("ix_sys_teacher_help_card_topic_created", table_name="sys_teacher_help_card") op.drop_index("ix_sys_teacher_help_card_topic_created", table_name="sys_teacher_help_card")

View File

@@ -4,7 +4,7 @@ Revision ID: 0017_share_drafts
Revises: 0016_teacher_help_cards Revises: 0016_teacher_help_cards
""" """
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
@@ -17,8 +17,8 @@ PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None: def upgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set(inspector.get_table_names()) tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_share_draft" in tables: if "sys_share_draft" in tables:
return return
op.create_table( op.create_table(
@@ -40,8 +40,8 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set(inspector.get_table_names()) tables = {"sys_share_draft"} if inspector is None else set(inspector.get_table_names())
if "sys_share_draft" not in tables: if "sys_share_draft" not in tables:
return return
op.drop_index("ix_sys_share_draft_topic_created", table_name="sys_share_draft") op.drop_index("ix_sys_share_draft_topic_created", table_name="sys_share_draft")

View File

@@ -4,7 +4,7 @@ Revision ID: 0018_ai_cost_tracking
Revises: 0017_share_drafts Revises: 0017_share_drafts
""" """
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
@@ -15,8 +15,8 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
model_columns = {column["name"] for column in inspector.get_columns("sys_model")} model_columns = set() if inspector is None else {column["name"] for column in inspector.get_columns("sys_model")}
_add_column_if_missing(model_columns, "sys_model", sa.Column("input_price_per_1k", sa.Numeric(12, 6), nullable=True)) _add_column_if_missing(model_columns, "sys_model", sa.Column("input_price_per_1k", sa.Numeric(12, 6), nullable=True))
_add_column_if_missing(model_columns, "sys_model", sa.Column("output_price_per_1k", sa.Numeric(12, 6), nullable=True)) _add_column_if_missing(model_columns, "sys_model", sa.Column("output_price_per_1k", sa.Numeric(12, 6), nullable=True))
_add_column_if_missing(model_columns, "sys_model", sa.Column("currency", sa.String(10), nullable=False, server_default="CNY")) _add_column_if_missing(model_columns, "sys_model", sa.Column("currency", sa.String(10), nullable=False, server_default="CNY"))
@@ -26,7 +26,7 @@ def upgrade() -> None:
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_fixed_info", sa.Integer(), nullable=False, server_default="1")) _add_column_if_missing(model_columns, "sys_model", sa.Column("allow_fixed_info", sa.Integer(), nullable=False, server_default="1"))
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_deep_chat", sa.Integer(), nullable=False, server_default="1")) _add_column_if_missing(model_columns, "sys_model", sa.Column("allow_deep_chat", sa.Integer(), nullable=False, server_default="1"))
log_columns = {column["name"] for column in inspector.get_columns("sys_ai_request_log")} log_columns = set() if inspector is None else {column["name"] for column in inspector.get_columns("sys_ai_request_log")}
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("model_id", sa.BigInteger(), nullable=True)) _add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("model_id", sa.BigInteger(), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("estimated_cost", sa.Numeric(18, 6), nullable=True)) _add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("estimated_cost", sa.Numeric(18, 6), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("currency", sa.String(10), nullable=True)) _add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("currency", sa.String(10), nullable=True))
@@ -38,15 +38,20 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
log_columns = {column["name"] for column in inspector.get_columns("sys_ai_request_log")} log_columns = {
"model_id", "estimated_cost", "currency", "route_reason", "question_type", "knowledge_hit"
} if inspector is None else {column["name"] for column in inspector.get_columns("sys_ai_request_log")}
if "model_id" in log_columns: if "model_id" in log_columns:
op.drop_index("ix_sys_ai_request_log_model_id", table_name="sys_ai_request_log") op.drop_index("ix_sys_ai_request_log_model_id", table_name="sys_ai_request_log")
for column in ["knowledge_hit", "question_type", "route_reason", "currency", "estimated_cost", "model_id"]: for column in ["knowledge_hit", "question_type", "route_reason", "currency", "estimated_cost", "model_id"]:
if column in log_columns: if column in log_columns:
op.drop_column("sys_ai_request_log", column) op.drop_column("sys_ai_request_log", column)
model_columns = {column["name"] for column in inspector.get_columns("sys_model")} model_columns = {
"input_price_per_1k", "output_price_per_1k", "currency", "usage_scenarios",
"allow_summary", "allow_report", "allow_fixed_info", "allow_deep_chat",
} if inspector is None else {column["name"] for column in inspector.get_columns("sys_model")}
for column in [ for column in [
"allow_deep_chat", "allow_deep_chat",
"allow_fixed_info", "allow_fixed_info",

View File

@@ -20,6 +20,9 @@
- [ ] 已验证每日问答额度跨自然日自动重置。 - [ ] 已验证每日问答额度跨自然日自动重置。
- [ ] 已验证并发排队、超时、取消和队列满提示。 - [ ] 已验证并发排队、超时、取消和队列满提示。
- [ ] 已验证退出登录后的 Token 在多 worker 下立即失效。 - [ ] 已验证退出登录后的 Token 在多 worker 下立即失效。
- [ ] 已确认权益版本、能力开关和学员当前权益配置正确。
- [ ] 已验证后台 Agent 预览能够按模拟学员和具体主题加载上下文。
- [ ] 已确认“结束主题”在当前网关超时范围内完成;如已异步化,已验证任务状态、幂等和失败重试。
## 发布中 ## 发布中
@@ -40,6 +43,8 @@
- [ ] `域名/login` 进入管理后台登录页。 - [ ] `域名/login` 进入管理后台登录页。
- [ ] 用户端可以创建会话。 - [ ] 用户端可以创建会话。
- [ ] 用户端可以提问并收到回答。 - [ ] 用户端可以提问并收到回答。
- [ ] 用户发送后输入框立即清空,思考状态与流式 Markdown 回答显示正常。
- [ ] 连续提问能够继续使用同一主题的上下文。
- [ ] 无命中问题返回固定兜底。 - [ ] 无命中问题返回固定兜底。
- [ ] 管理后台可以登录。 - [ ] 管理后台可以登录。
- [ ] 管理后台可以查看 Dashboard。 - [ ] 管理后台可以查看 Dashboard。
@@ -47,6 +52,8 @@
- [ ] 管理后台可以新增知识库。 - [ ] 管理后台可以新增知识库。
- [ ] AI 请求日志有记录。 - [ ] AI 请求日志有记录。
- [ ] 操作日志有记录。 - [ ] 操作日志有记录。
- [ ] 后台 Agent 预览能够选择学员和主题,并在追踪中看到上下文与模型路由。
- [ ] 求助卡、分享稿、成长档案和周期报告能够按权益生成与查看。
- [ ] 访问日志为单行 JSON且可用响应头中的 `X-Request-ID` 定位同一次请求。 - [ ] 访问日志为单行 JSON且可用响应头中的 `X-Request-ID` 定位同一次请求。
- [ ] 已确认备份任务生成 `.sql.gz` 和对应 `.sha256` 文件。 - [ ] 已确认备份任务生成 `.sql.gz` 和对应 `.sha256` 文件。
- [ ] 已完成至少一次隔离数据库恢复演练。 - [ ] 已完成至少一次隔离数据库恢复演练。

View File

@@ -356,7 +356,8 @@ Agent 已经可以:
#### 开发进度 #### 开发进度
- 2026-07-31已补齐后台预览的“模拟学员”入口选择学员后预览链路会加载该学员权益、主题额度使用量和长期成长档案并在检索追踪中记录 `load_debug_user_context`。默认不选择学员时,保持原后台调试行为。 - 2026-07-31已补齐后台预览的“模拟学员”入口选择学员后预览链路会加载该学员权益、主题额度使用量和长期成长档案并在检索追踪中记录 `load_debug_user_context`。默认不选择学员时,保持原后台调试行为。
- 待继续:后台预览模拟具体主题上下文、求助卡/分享稿权限注入 - 2026-07-31后台预览已支持继续选择具体主题加载主题摘要、滚动摘要和最近消息正式聊天与后台预览均会按权益注入成长档案、求助卡分享稿能力,并在追踪中记录模型路由与上下文来源
- 2026-07-31一期全链路已完成隔离生产编排、真实模型问答、流式响应、主题上下文、成长档案、求助卡、分享稿、异步报告和记录审计验收。详细结果见《一期全链路验收记录》主题沉淀接口的同步耗时仍需作为上线前性能项处理。
--- ---
@@ -894,6 +895,7 @@ AI 日志增加:
## 6. 当前阶段结论 ## 6. 当前阶段结论
- 千问千答不建设老师端或“老师工作版”,老师仍由学员自行联系; - 千问千答不建设老师端或“老师工作版”,老师仍由学员自行联系;
- 周报、月报的异步与定时生成完成,本计划中的连续陪伴主链路先告一段落 - 周报、月报的异步与定时生成已经完成,本计划中的连续陪伴主链路已完成一期闭环
- 一期主要功能链路已验收通过;主题结束时同步生成摘要和成长档案的耗时仍是上线前必须关注的性能风险;
- 问题洞察的知识库补充建议、人工合并和拆分暂缓; - 问题洞察的知识库补充建议、人工合并和拆分暂缓;
- 下一阶段切换到其他产品板块时,再按对应板块重新确认范围、依赖和验收标准。 - 下一阶段切换到其他产品板块时,再按对应板块重新确认范围、依赖和验收标准。

View File

@@ -0,0 +1,72 @@
# 千问千答一期全链路验收记录
## 1. 验收信息
- 验收日期2026-07-31
- 验收分支:`codex/h5-qa-experience`
- 验收范围:权益体系、用户聊天、后台 Agent 预览、主题上下文、成长档案、求助卡、分享稿、周期报告、记录审计、数据库迁移与生产部署链路
- 验收方式:浏览器真实操作、接口调用、真实模型调用、自动化测试、隔离 MySQL 迁移、隔离生产编排及上线冒烟
## 2. 验收结论
一期功能主链路已打通,权益管理页面已完成布局重构,用户端和后台 Agent 预览均能够按学员、主题和权益运行。自动化测试、前后端构建、数据库迁移和隔离生产环境冒烟均通过。
当前仍有一项上线前性能风险:结束主题时会同步生成主题摘要和成长档案,本次真实模型验收耗时约 42 秒。结果能够正确生成,但等待时间容易让用户误认为操作无响应,也会放大重复点击和网络超时风险。本项不影响数据正确性,但不应按“完全无风险”关闭。
## 3. 功能验收结果
| 验收项 | 结果 | 实际结果 |
| --- | --- | --- |
| 权益管理页面 | 通过 | 编辑区按基础配置、能力权限拆分;桌面端和窄屏自适应;按钮、开关和表格不再拥挤错位 |
| 权益版本编辑 | 通过 | 编辑已有权益、退出编辑、启用状态和能力开关均可正常操作 |
| 用户登录 | 通过 | 通过真实页面完成验证码登录并进入聊天页 |
| 用户端流式问答 | 通过 | 显示“思考中”,随后流式展示 Markdown 回答;发送后输入框立即清空 |
| 用户端主题上下文 | 通过 | 连续消息均绑定同一主题,历史消息与主题上下文可继续使用 |
| 后台 Agent 预览 | 通过 | 可选择模拟学员和具体主题,能够加载权益、主题摘要、最近消息和成长档案 |
| Agent 运行追踪 | 通过 | 能看到 `load_debug_user_context`、模型路由等追踪节点 |
| 主题沉淀 | 功能通过,性能待优化 | 主题摘要与成长档案均生成成功;真实模型调用约 42 秒 |
| 求助卡 | 通过 | 可生成非空内容并记录复制状态 |
| 分享稿 | 通过 | 可生成非空内容并记录复制状态 |
| 周期报告 | 通过 | 入队接口约 7 毫秒返回,后台任务能够完成并保存报告 |
| 用户成长档案 | 通过 | 用户端可读取档案、主题摘要和周期报告 |
| 后台用户详情 | 通过 | 可查看成长档案、求助卡、分享稿和周期报告 |
| 记录审计 | 通过 | 聊天列表与按需加载详情均正常 |
## 4. 工程与部署验收
| 验收项 | 结果 | 实际结果 |
| --- | --- | --- |
| 后端自动化测试 | 通过 | 107 项测试全部通过 |
| OpenAPI 检查 | 通过 | 成功生成并检查 93 个接口路径 |
| 管理后台构建 | 通过 | 生产构建成功 |
| 用户端构建 | 通过 | 生产构建成功 |
| Alembic 离线 SQL | 通过 | 成功生成 1106 行迁移 SQL并覆盖到 `0023_simple_knowledge_route` |
| 全新数据库迁移 | 通过 | 在隔离 MySQL 数据库从零升级至最新版本,共生成 39 张表 |
| 数据库与 Redis 就绪检查 | 通过 | `/api/ready` 返回数据库和 Redis 均已就绪,并返回 `X-Request-ID` |
| 隔离生产编排 | 通过 | 使用生产镜像、独立数据库和 Redis 启动,所有容器健康 |
| 生产冒烟 | 通过 | 用户端、管理后台、静态资源、健康接口和就绪接口均可访问 |
| 生产安全开关 | 通过 | `DEBUG`、短信模拟、模型模拟、飞书模拟均关闭Redis 为强制就绪依赖 |
| 回滚与清理 | 通过 | 隔离生产容器、网络和临时数据卷均已完整移除,开发环境未受影响 |
## 5. 本次主动修复
全链路验证脚本在生成 Alembic 离线 SQL 时,被部分历史迁移中的在线数据库检查阻断。已为相关迁移补充分离的离线模式逻辑,并为默认权益数据补充明确字段类型。在线迁移行为保持不变,且已通过全新 MySQL 数据库实际迁移验证。
## 6. 上线前风险与建议
### P1结束主题接口等待时间过长
- 现象:结束主题后同步执行主题摘要和成长档案两次模型处理,本次耗时约 42 秒。
- 影响:用户容易认为按钮没有生效;弱网或网关超时会使前端收到失败,但后端可能已经成功;重复点击可能产生重复任务。
- 建议:把主题结束本身改为快速提交,摘要和成长档案转为后台任务;增加处理中状态、幂等键和失败重试;前端轮询或通过现有任务状态刷新结果。
- 上线判断:内部小范围试用可继续;正式扩大用户前建议完成该项。
### P2前端构建体积提示
- 现象:管理后台构建仍有单个产物超过 500KB 的提示。
- 影响:不阻断功能,但会增加首次加载耗时。
- 建议:后续按页面拆包和延迟加载处理,不与本次权益页面重构混在一起修改。
## 7. 验收数据说明
验收使用专用测试学员和隔离生产数据库。隔离生产环境及数据卷已清理;测试学员最终恢复为基础版权益。开发数据库中保留的验收主题和聊天数据可用于后续问题追踪,不影响正式用户数据。