Files
QuestionProject/ai_knowledge_base_v2/apps/admin-web/src/App.vue

2125 lines
102 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import AdminLoginView from "./components/AdminLoginView.vue";
import AdminPagination from "./components/AdminPagination.vue";
import AgentManagementView from "./components/AgentManagementView.vue";
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
import TableRowActions from "./components/TableRowActions.vue";
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
import { api, clearToken, getToken, saveToken } from "./services/api";
import type {
AdminProfile,
AdminUser,
AdminUserDetail,
AiLogRecord,
ChatDetail,
ChatRecord,
DashboardStats,
EntitlementPlan,
KnowledgeItem,
RetrievalLogItem,
AttentionItem,
ModelItem,
QuestionInsightSummary,
SystemConfigItem,
UserImportResult,
} from "./types/api";
const admin = ref<AdminProfile | null>(null);
const activeMenu = ref("dashboard");
const loading = ref(false);
const stats = ref<DashboardStats | null>(null);
const storageStats = ref<Record<string, any> | null>(null);
const storageLoading = ref(false);
const cleanupBefore = ref("");
const retentionDays = ref<number | null>(90);
const retentionState = ref<{ lastRunAt: string | null; lastResult: string | null } | null>(null);
const dashboardFilters = reactive({
start: "",
end: "",
});
const users = ref<AdminUser[]>([]);
const selectedUserDetail = ref<AdminUserDetail | null>(null);
const userDetailOpen = ref(false);
const userDetailLoading = ref(false);
const userReportGenerating = ref("");
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[]>([]);
const aiLogs = ref<AiLogRecord[]>([]);
const operationLogs = ref<Record<string, unknown>[]>([]);
const questionInsights = ref<QuestionInsightSummary | null>(null);
const retrievalLogs = ref<RetrievalLogItem[]>([]);
const attentionRecords = ref<AttentionItem[]>([]);
const selectedRetrievalLog = ref<Record<string, unknown> | null>(null);
const retrievalDetailOpen = ref(false);
const previewKnowledgeId = ref<number | null>(null);
const chatDetail = ref<ChatDetail | null>(null);
const chatDetailOpen = ref(false);
const chatDetailLoading = ref(false);
const chatMessagesLoading = ref(false);
const chatDetailSessionId = ref<number | null>(null);
const chatDetailBodyRef = ref<HTMLElement | null>(null);
const selectedAiLog = ref<AiLogRecord | null>(null);
const aiLogDetailOpen = ref(false);
const recordTab = ref("chats");
const pagers = reactive({
users: { page: 1, pageSize: 20, total: 0 },
chats: { page: 1, pageSize: 20, total: 0 },
aiLogs: { page: 1, pageSize: 20, total: 0 },
operationLogs: { page: 1, pageSize: 20, total: 0 },
questionInsights: { page: 1, pageSize: 20, total: 0 },
retrievals: { page: 1, pageSize: 20, total: 0 },
attention: { page: 1, pageSize: 20, total: 0 },
});
const chatDetailMessagePager = reactive({ page: 1, pageSize: 20, total: 0 });
const editingModelId = ref<number | null>(null);
const editingKnowledgeId = ref<number | null>(null);
const batchNodeToken = ref("");
const batchImporting = ref(false);
const studentForm = reactive({
phone: "",
name: "",
nickname: "",
status: 1,
dailyChatLimit: 100,
expiredAt: "",
});
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",
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: "",
feishuNodeId: "",
status: 1,
remark: "",
});
const modelForm = reactive({
provider: "openai-compatible",
displayName: "",
apiType: "openai_compatible",
modelName: "",
baseUrl: "https://api.openai.com/v1",
apiUrl: "",
apiKey: "",
authType: "bearer",
apiVersion: "",
temperature: null as number | null,
topP: null as number | null,
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 8192 as number | null,
contextWindow: null as number | null,
streamEnabled: 1,
responseFormat: "",
extraParams: "",
remark: "",
timeoutSecond: 30,
inputPricePer1k: null as number | null,
outputPricePer1k: null as number | null,
currency: "CNY",
usageScenarios: "",
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowDeepChat: 1,
});
const quickModelForm = reactive({
provider: "deepseek",
modelName: "deepseek-v4-pro",
apiKey: "",
});
const quickModelProviders = [
{
label: "DeepSeek",
provider: "deepseek",
apiType: "openai_compatible",
baseUrl: "https://api.deepseek.com",
authType: "bearer",
apiVersion: "",
temperature: null,
topP: null,
maxToken: null,
contextWindow: null,
models: [
{ label: "deepseek-v4-pro", value: "deepseek-v4-pro" },
{ label: "deepseek-v4-flash", value: "deepseek-v4-flash" },
],
},
{
label: "MiniMax",
provider: "minimax",
apiType: "openai_compatible",
baseUrl: "https://api.minimaxi.com/v1",
authType: "bearer",
apiVersion: "",
temperature: null,
topP: null,
maxToken: null,
contextWindow: null,
models: [
{ label: "MiniMax-Text-01", value: "MiniMax-Text-01" },
{ label: "MiniMax-M3", value: "MiniMax-M3" },
],
},
] as const;
const protocolPresets = [
{
label: "OpenAI 兼容协议",
provider: "openai-compatible",
apiType: "openai_compatible",
baseUrl: "https://api.openai.com/v1",
apiUrl: "",
authType: "bearer",
},
{
label: "Anthropic Messages 协议",
provider: "anthropic-compatible",
apiType: "anthropic_messages",
baseUrl: "https://api.anthropic.com",
apiUrl: "",
authType: "api_key",
apiVersion: "2023-06-01",
temperature: null,
topP: null,
topK: null,
},
];
const nullableModelFields = [
"temperature",
"topP",
"topK",
"presencePenalty",
"frequencyPenalty",
"contextWindow",
"responseFormat",
"extraParams",
"remark",
"inputPricePer1k",
"outputPricePer1k",
"usageScenarios",
] as const;
const systemSettingValues = reactive<Record<string, SystemSettingValue>>({});
systemSettingDefinitions.forEach((setting) => {
systemSettingValues[setting.key] = setting.defaultValue;
});
const chatFilters = reactive({
keyword: "",
userId: undefined as number | undefined,
status: "",
dateFrom: "",
dateTo: "",
});
const questionInsightFilters = reactive({
dateFrom: "",
dateTo: "",
minCount: 2,
maxMessages: 5000,
});
watch(recordTab, async (tab) => {
if (activeMenu.value === "records") await loadRecordTab(tab);
});
onMounted(async () => {
if (!getToken()) return;
try {
admin.value = await api.profile();
await loadCurrentMenu();
} catch {
clearToken();
}
});
async function login(credentials: { username: string; password: string }) {
loading.value = true;
try {
const result = await api.login(credentials.username, credentials.password);
saveToken(result.token);
admin.value = result.admin;
await loadCurrentMenu();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "登录失败");
} finally {
loading.value = false;
}
}
async function logout() {
try {
await api.logout();
} finally {
clearToken();
admin.value = null;
}
}
async function switchMenu(menu: string) {
activeMenu.value = menu;
await loadCurrentMenu();
}
async function previewKnowledge(knowledgeId: number) {
previewKnowledgeId.value = knowledgeId;
await switchMenu("prompt");
}
async function loadDashboard() {
stats.value = await api.dashboard(dashboardFilters.start, dashboardFilters.end);
storageLoading.value = true;
api.storageStats().then((value) => { storageStats.value = value; }).catch(() => { storageStats.value = null; }).finally(() => { storageLoading.value = false; });
}
async function refreshStorage() { storageLoading.value = true; try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); } finally { storageLoading.value = false; } }
function formatBytes(value: unknown) { if (typeof value !== "number") return "无法统计"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = value, i = 0; while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1; } return `${size.toFixed(i ? 2 : 0)} ${units[i]}`; }
function formatMoney(value?: number | null, currency = "CNY") { if (typeof value !== "number") return "-"; if (currency === "MIXED") return "多币种,见明细"; return `${currency || "CNY"} ${value.toFixed(6)}`; }
function modelSceneLabel(scene: string) {
return ({
background_report: "周期报告",
background_summary: "摘要沉淀",
fixed_info: "固定信息问答",
knowledge_grounded: "知识问答",
general_chat: "通用对话",
} as Record<string, string>)[scene] || scene || "未分类";
}
async function cleanupRetrievalLogs() { if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期"); const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value); await ElMessageBox.confirm(`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`, "确认清理", { type: "warning" }); const result = await api.cleanupRetrievalLogs(cleanupBefore.value); ElMessage.success(`已清理 ${result.deleted} 条日志`); await loadCurrentMenu(); }
async function saveRetention() { await api.saveRetrievalRetention(retentionDays.value); ElMessage.success(retentionDays.value == null ? "已设为永久保留" : `已设为保留 ${retentionDays.value}`); await loadCurrentMenu(); }
async function loadCurrentMenu() {
loading.value = true;
try {
if (activeMenu.value === "dashboard") await loadDashboard();
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();
hydrateSystemSettings();
}
if (activeMenu.value === "records") await loadRecordTab(recordTab.value);
if (activeMenu.value === "retrievals") { const [logs, retention] = await Promise.all([api.retrievalLogs({ page: pagers.retrievals.page, pageSize: pagers.retrievals.pageSize }), api.retrievalRetention()]); retrievalLogs.value = logs.items; Object.assign(pagers.retrievals, { page: logs.page, pageSize: logs.pageSize, total: logs.total }); retentionDays.value = retention.retentionDays; retentionState.value = retention; }
if (activeMenu.value === "attention") await loadAttention(pagers.attention.page, pagers.attention.pageSize);
} finally {
loading.value = false;
}
}
async function loadUsers(page = 1, pageSize = pagers.users.pageSize) {
const result = await api.users({ keyword: userKeyword.value, page, pageSize });
users.value = result.items;
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: "高频加购包",
}[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 openUserDetail(row: AdminUser) {
userDetailOpen.value = true;
selectedUserDetail.value = null;
userDetailLoading.value = true;
try {
selectedUserDetail.value = await api.userDetail(row.id);
} finally {
userDetailLoading.value = false;
}
}
async function generateUserReport(type: "weekly" | "monthly" | "stage") {
if (!selectedUserDetail.value) return;
userReportGenerating.value = type;
try {
await api.generateUserReport(selectedUserDetail.value.user.id, { reportType: type });
selectedUserDetail.value = await api.userDetail(selectedUserDetail.value.user.id);
ElMessage.success("报告任务已进入后台队列,不会阻塞聊天服务");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "报告生成失败");
} finally {
userReportGenerating.value = "";
}
}
async function refreshSelectedUserDetail() {
if (!selectedUserDetail.value) return;
userDetailLoading.value = true;
try {
selectedUserDetail.value = await api.userDetail(selectedUserDetail.value.user.id);
} finally {
userDetailLoading.value = false;
}
}
function reportStatusLabel(status: string) {
const labels: Record<string, string> = {
pending: "等待生成",
running: "生成中",
success: "已完成",
empty: "暂无沉淀",
failed: "生成失败",
};
return labels[status] || status;
}
async function loadRecordTab(tab = recordTab.value) {
loading.value = true;
try {
if (tab === "chats") {
const result = await api.chats({ ...buildChatQuery(), page: pagers.chats.page, pageSize: pagers.chats.pageSize });
chats.value = result.items; Object.assign(pagers.chats, { page: result.page, pageSize: result.pageSize, total: result.total });
} else if (tab === "aiLogs") {
const result = await api.aiLogs({ page: pagers.aiLogs.page, pageSize: pagers.aiLogs.pageSize });
aiLogs.value = result.items; Object.assign(pagers.aiLogs, { page: result.page, pageSize: result.pageSize, total: result.total });
} else if (tab === "operationLogs") {
const result = await api.operationLogs({ page: pagers.operationLogs.page, pageSize: pagers.operationLogs.pageSize });
operationLogs.value = result.items; Object.assign(pagers.operationLogs, { page: result.page, pageSize: result.pageSize, total: result.total });
} else if (tab === "questionInsights") {
await loadQuestionInsights(pagers.questionInsights.page, pagers.questionInsights.pageSize);
}
} finally { loading.value = false; }
}
async function changeRecordPage(tab: "chats" | "aiLogs" | "operationLogs" | "questionInsights", page: number, pageSize: number) {
Object.assign(pagers[tab], { page, pageSize });
await loadRecordTab(tab);
}
async function loadQuestionInsights(page = 1, pageSize = pagers.questionInsights.pageSize) {
const result = await api.questionInsights({
dateFrom: formatRecordDateTime(questionInsightFilters.dateFrom, "start"),
dateTo: formatRecordDateTime(questionInsightFilters.dateTo, "end"),
minCount: questionInsightFilters.minCount,
maxMessages: questionInsightFilters.maxMessages,
page,
pageSize,
});
questionInsights.value = result;
Object.assign(pagers.questionInsights, { page: result.page, pageSize: result.pageSize, total: result.total });
}
async function loadRetrievals(page = 1, pageSize = pagers.retrievals.pageSize) {
const result = await api.retrievalLogs({ page, pageSize });
retrievalLogs.value = result.items; Object.assign(pagers.retrievals, { page: result.page, pageSize: result.pageSize, total: result.total });
}
async function loadAttention(page = 1, pageSize = pagers.attention.pageSize) {
const result = await api.attentionList({ page, pageSize });
attentionRecords.value = result.items; Object.assign(pagers.attention, { page: result.page, pageSize: result.pageSize, total: result.total });
}
async function saveUser(row: AdminUser) {
await api.updateUser(row.id, { status: row.status, dailyChatLimit: row.dailyChatLimit });
ElMessage.success("用户已更新");
await loadCurrentMenu();
}
async function createStudent() {
if (!studentForm.phone || !studentForm.name) {
ElMessage.error("请填写手机号和姓名");
return;
}
await api.createUser({
phone: studentForm.phone,
name: studentForm.name,
nickname: studentForm.nickname || null,
status: studentForm.status,
dailyChatLimit: studentForm.dailyChatLimit,
expiredAt: toExpiryDateTime(studentForm.expiredAt),
});
ElMessage.success("学员已添加");
resetStudentForm();
await loadCurrentMenu();
}
function toExpiryDateTime(date: string) {
return date ? `${date}T23:59:59` : null;
}
function handleStudentImportFile(event: Event) {
const input = event.target as HTMLInputElement;
studentImportFile.value = input.files?.[0] ?? null;
}
async function downloadStudentTemplate() {
await api.downloadStudentTemplate();
}
async function importStudents() {
if (!studentImportFile.value) {
ElMessage.error("请先选择 Excel 文件");
return;
}
studentImportResult.value = await api.importUsersExcel(studentImportFile.value);
ElMessage.success(
`导入完成:新增 ${studentImportResult.value.created},更新 ${studentImportResult.value.updated},失败 ${studentImportResult.value.failed}`,
);
studentImportFile.value = null;
if (studentImportInput.value) studentImportInput.value.value = "";
await loadCurrentMenu();
}
function resetStudentForm() {
Object.assign(studentForm, { phone: "", name: "", nickname: "", status: 1, dailyChatLimit: 100, expiredAt: "" });
}
async function deleteUser(row: AdminUser) {
if (!window.confirm(`确认删除用户 ${row.phone} 吗?删除后前台将无法继续使用该账号。`)) return;
await api.deleteUser(row.id);
ElMessage.success("用户已删除");
await loadCurrentMenu();
}
async function saveKnowledge() {
if (!knowledgeForm.name || !knowledgeForm.feishuSpaceId || !knowledgeForm.feishuNodeId) {
ElMessage.error("请填写知识库名称、SpaceID 和 NodeID");
return;
}
if (editingKnowledgeId.value) {
await api.updateKnowledge(editingKnowledgeId.value, { ...knowledgeForm });
ElMessage.success("知识库已更新");
} else {
await api.createKnowledge({ ...knowledgeForm });
ElMessage.success("知识库已新增");
}
resetKnowledgeForm();
await loadCurrentMenu();
}
async function saveKnowledgeRow(row: KnowledgeItem) {
await api.updateKnowledge(row.id, {
name: row.name,
feishuSpaceId: row.feishuSpaceId,
feishuNodeId: row.feishuNodeId,
status: row.status,
remark: row.remark ?? "",
});
ElMessage.success("知识库状态已更新");
}
function editKnowledge(row: KnowledgeItem) {
editingKnowledgeId.value = row.id;
Object.assign(knowledgeForm, {
name: row.name,
feishuSpaceId: row.feishuSpaceId,
feishuNodeId: row.feishuNodeId,
status: row.status,
remark: row.remark ?? "",
});
}
async function deleteKnowledge(row: KnowledgeItem) {
if (!window.confirm(`确认删除知识库 ${row.name} 吗?删除后所有用户都无法再检索该知识库。`)) return;
await api.deleteKnowledge(row.id);
ElMessage.success("知识库已删除");
if (editingKnowledgeId.value === row.id) resetKnowledgeForm();
await loadCurrentMenu();
}
function resetKnowledgeForm() {
editingKnowledgeId.value = null;
Object.assign(knowledgeForm, { name: "", feishuSpaceId: "", feishuNodeId: "", status: 1, remark: "" });
}
async function batchImportKnowledge() {
const token = batchNodeToken.value.trim();
if (!token) {
ElMessage.warning("请输入 NodeToken");
return;
}
batchImporting.value = true;
try {
const result = await api.importKnowledgeFromSpace({ node_token: token });
if (result.imported > 0) {
ElMessage.success(`导入成功:${result.name}id=${result.id}`);
} else {
ElMessage.info(`该节点已存在:${result.name}id=${result.id}`);
}
batchNodeToken.value = "";
await loadCurrentMenu();
} catch (e: any) {
ElMessage.error(e.message || "导入失败");
} finally {
batchImporting.value = false;
}
}
async function saveModel() {
if (!validateModelForm()) return;
const payload = buildModelPayload();
if (editingModelId.value) {
await api.updateModel(editingModelId.value, payload);
ElMessage.success("模型已更新");
} else {
await api.createModel(payload);
ElMessage.success("模型已新增");
}
resetModelForm();
await loadCurrentMenu();
}
function buildModelPayload() {
const payload = { ...modelForm } as Record<string, unknown>;
nullableModelFields.forEach((field) => {
if (payload[field] === undefined || payload[field] === "") {
payload[field] = null;
}
});
return payload;
}
const selectedQuickProvider = computed(() => {
return quickModelProviders.find((item) => item.provider === quickModelForm.provider) ?? quickModelProviders[0];
});
async function quickAddModel() {
const provider = selectedQuickProvider.value;
if (!provider) return;
if (!quickModelForm.apiKey) {
ElMessage.error("请填写 API Key");
return;
}
await api.createModel({
provider: provider.provider,
displayName: `${provider.label} ${quickModelForm.modelName}`,
apiType: provider.apiType,
modelName: quickModelForm.modelName,
baseUrl: provider.baseUrl,
apiUrl: "",
apiKey: quickModelForm.apiKey,
authType: provider.authType,
apiVersion: provider.apiVersion,
temperature: null,
topP: null,
topK: null,
presencePenalty: null,
frequencyPenalty: null,
maxToken: 8192,
contextWindow: null,
streamEnabled: 1,
responseFormat: null,
extraParams: null,
remark: "快速添加",
timeoutSecond: 30,
inputPricePer1k: null,
outputPricePer1k: null,
currency: "CNY",
usageScenarios: "正式对话",
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowDeepChat: 1,
});
ElMessage.success(`${provider.label} 模型已新增`);
quickModelForm.apiKey = "";
await loadCurrentMenu();
}
function applyQuickProvider(provider: string) {
const target = quickModelProviders.find((item) => item.provider === provider);
if (!target) return;
quickModelForm.modelName = target.models[0]?.value ?? "";
}
async function setModelAvailability(row: ModelItem, enabled: number) {
await api.setModelAvailability(row.id, enabled);
ElMessage.success(enabled === 1 ? "模型已加入可用池" : "模型已停用");
await loadCurrentMenu();
}
async function setDefaultModel(row: ModelItem) {
await api.setDefaultModel(row.id);
ElMessage.success("默认主模型已更新");
await loadCurrentMenu();
}
async function handleModelCommand(command: string, row: ModelItem) {
if (command === "enable") return setModelAvailability(row, 1);
if (command === "disable") return setModelAvailability(row, 0);
if (command === "default") return setDefaultModel(row);
return deleteModel(row.id);
}
async function deleteModel(id: number) {
try {
await ElMessageBox.confirm("确认删除该模型?删除后不可恢复。", "删除模型", {
confirmButtonText: "确认删除",
cancelButtonText: "取消",
type: "warning",
});
} catch {
return;
}
await api.deleteModel(id);
ElMessage.success("模型已删除");
await loadCurrentMenu();
}
async function testModel(id: number) {
const result = await api.testModel(id);
if (result.ok) {
ElMessage.success(result.message);
} else {
ElMessage.error(result.message);
}
}
function applyModelPreset(label: string) {
const preset = protocolPresets.find((item) => item.label === label);
if (!preset) return;
Object.assign(
modelForm,
{
apiVersion: "",
authType: "bearer",
temperature: null,
topP: null,
topK: null,
presencePenalty: null,
frequencyPenalty: null,
responseFormat: "",
streamEnabled: 1,
},
preset,
);
}
function editModel(row: ModelItem) {
editingModelId.value = row.id;
Object.assign(modelForm, {
provider: row.provider,
displayName: row.displayName ?? "",
apiType: row.apiType,
modelName: row.modelName,
baseUrl: row.baseUrl ?? "",
apiUrl: row.apiUrl,
apiKey: "",
authType: row.authType,
apiVersion: row.apiVersion ?? "",
temperature: row.temperature ?? null,
topP: row.topP ?? null,
topK: row.topK ?? null,
presencePenalty: row.presencePenalty ?? null,
frequencyPenalty: row.frequencyPenalty ?? null,
maxToken: row.maxToken ?? 8192,
contextWindow: row.contextWindow ?? null,
streamEnabled: row.streamEnabled,
responseFormat: row.responseFormat ?? "",
extraParams: row.extraParams ?? "",
remark: row.remark ?? "",
timeoutSecond: row.timeoutSecond,
inputPricePer1k: row.inputPricePer1k ?? null,
outputPricePer1k: row.outputPricePer1k ?? null,
currency: row.currency || "CNY",
usageScenarios: row.usageScenarios ?? "",
allowSummary: row.allowSummary ?? 1,
allowReport: row.allowReport ?? 1,
allowFixedInfo: row.allowFixedInfo ?? 1,
allowDeepChat: row.allowDeepChat ?? 1,
});
}
function resetModelForm() {
editingModelId.value = null;
Object.assign(modelForm, {
provider: "openai-compatible",
displayName: "",
apiType: "openai_compatible",
modelName: "",
baseUrl: "https://api.openai.com/v1",
apiUrl: "",
apiKey: "",
authType: "bearer",
apiVersion: "",
temperature: null,
topP: null,
topK: null,
presencePenalty: null,
frequencyPenalty: null,
maxToken: 8192,
contextWindow: null,
streamEnabled: 1,
responseFormat: "",
extraParams: "",
remark: "",
timeoutSecond: 30,
inputPricePer1k: null,
outputPricePer1k: null,
currency: "CNY",
usageScenarios: "",
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowDeepChat: 1,
});
}
function validateModelForm() {
if (!modelForm.apiUrl && !modelForm.baseUrl) {
ElMessage.error("请填写 Base URL 或 API URL");
return false;
}
if (!editingModelId.value && !modelForm.apiKey) {
ElMessage.error("新增模型必须填写 API Key");
return false;
}
if (modelForm.extraParams.trim()) {
try {
const extraParams = JSON.parse(modelForm.extraParams);
if (!extraParams || Array.isArray(extraParams) || typeof extraParams !== "object") {
throw new Error("高级 JSON 参数必须是对象");
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "高级 JSON 参数不是合法 JSON");
return false;
}
}
return true;
}
async function saveSystemSettings() {
await Promise.all(
systemSettingDefinitions.map((setting) =>
api.saveConfig({
configKey: setting.key,
configValue: serializeSystemSettingValue(systemSettingValues[setting.key]),
description: setting.description,
}),
),
);
ElMessage.success("系统配置已保存");
configs.value = await api.configs();
hydrateSystemSettings();
}
function hydrateSystemSettings() {
const configMap = new Map(configs.value.map((item) => [item.configKey, item.configValue]));
systemSettingDefinitions.forEach((setting) => {
systemSettingValues[setting.key] = normalizeSystemSettingValue(setting, configMap.get(setting.key));
});
}
function normalizeSystemSettingValue(setting: (typeof systemSettingDefinitions)[number], rawValue?: string) {
if (rawValue === undefined || rawValue === "") {
return setting.defaultValue;
}
if (setting.type === "switch") {
return ["1", "true", "yes", "on", "启用"].includes(rawValue.toLowerCase());
}
if (setting.type === "text" || setting.type === "password") {
return rawValue;
}
const parsed = Number(rawValue);
if (Number.isFinite(parsed)) {
return parsed;
}
return setting.defaultValue;
}
function serializeSystemSettingValue(value: SystemSettingValue) {
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
}
async function openRetrievalDetail(row: RetrievalLogItem) {
selectedRetrievalLog.value = await api.retrievalLogDetail(row.id);
retrievalDetailOpen.value = true;
}
async function updateAttentionStatus(row: AttentionItem, status: string) {
await api.updateAttention(row.id, status);
ElMessage.success("人工关注状态已更新");
await loadAttention(pagers.attention.page, pagers.attention.pageSize);
}
async function deleteAttentionRecord(row: AttentionItem) {
try {
await ElMessageBox.confirm("确定删除这条人工关注记录吗?操作将写入审计日志。", "删除人工关注记录", {
confirmButtonText: "确认删除", cancelButtonText: "取消", type: "warning",
});
} catch {
return;
}
await api.deleteAttention(row.id);
ElMessage.success("人工关注记录已删除");
await loadAttention(pagers.attention.page, pagers.attention.pageSize);
}
function attentionPriorityLabel(priority: string) {
return { urgent: "紧急", important: "重要", normal: "普通" }[priority as "urgent" | "important" | "normal"] || priority;
}
async function clearFeishuCache() {
try {
const result = await api.clearFeishuCache();
ElMessage.success(result.message || `已清空 ${result.cleared} 条缓存`);
} catch (e: any) {
ElMessage.error(e.message || "刷新缓存失败");
}
}
async function searchChats() {
pagers.chats.page = 1;
await loadRecordTab("chats");
}
async function resetChatFilters() {
Object.assign(chatFilters, { keyword: "", userId: undefined, status: "", dateFrom: "", dateTo: "" });
await searchChats();
}
async function searchQuestionInsights() {
pagers.questionInsights.page = 1;
loading.value = true;
try {
const refresh = await api.refreshQuestionInsights({
dateFrom: formatRecordDateTime(questionInsightFilters.dateFrom, "start"),
dateTo: formatRecordDateTime(questionInsightFilters.dateTo, "end"),
maxMessages: questionInsightFilters.maxMessages,
});
await loadQuestionInsights(1, pagers.questionInsights.pageSize);
if (refresh.processedMessages > 0) {
ElMessage.success(
`新增清洗 ${refresh.processedMessages} 条消息,得到 ${refresh.acceptedQuestions} 个有效问题${refresh.hasMore ? ";仍有历史消息待清洗" : ""}`,
);
} else {
ElMessage.success("没有新增消息,已直接使用持久化清洗结果统计");
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "问题洞察刷新失败");
} finally {
loading.value = false;
}
}
async function resetQuestionInsightFilters() {
Object.assign(questionInsightFilters, { dateFrom: "", dateTo: "", minCount: 2, maxMessages: 5000 });
await searchQuestionInsights();
}
async function openChatDetail(row: ChatRecord) {
await openChatSession(row.id);
}
async function openChatSession(sessionId: number) {
chatDetailOpen.value = true;
chatDetail.value = null;
chatDetailSessionId.value = sessionId;
Object.assign(chatDetailMessagePager, { page: 1, pageSize: chatDetailMessagePager.pageSize, total: 0 });
await loadChatDetail(sessionId, 1, chatDetailMessagePager.pageSize);
}
async function loadChatDetail(sessionId = chatDetailSessionId.value, page = chatDetailMessagePager.page, pageSize = chatDetailMessagePager.pageSize) {
if (!sessionId) return;
chatDetailLoading.value = true;
try {
const detail = await api.chatDetail(sessionId, { messagePage: page, messagePageSize: pageSize });
chatDetail.value = detail;
const messagePage = detail.messagesPage;
Object.assign(chatDetailMessagePager, {
page: messagePage?.page ?? page,
pageSize: messagePage?.pageSize ?? pageSize,
total: messagePage?.total ?? detail.messages.length,
});
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "聊天详情加载失败");
} finally {
chatDetailLoading.value = false;
}
}
async function changeChatDetailMessagePage(page: number, pageSize: number) {
if (!chatDetailSessionId.value || !chatDetail.value) return;
chatMessagesLoading.value = true;
try {
const result = await api.chatMessages(chatDetailSessionId.value, { page, pageSize });
chatDetail.value = {
...chatDetail.value,
messages: result.items,
messagesPage: result,
};
Object.assign(chatDetailMessagePager, { page: result.page, pageSize: result.pageSize, total: result.total });
await nextTick();
chatDetailBodyRef.value?.scrollTo({ top: 0, behavior: "smooth" });
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "聊天消息加载失败");
} finally {
chatMessagesLoading.value = false;
}
}
async function openAiLogDetail(row: AiLogRecord) {
selectedAiLog.value = await api.aiLogDetail(row.id);
aiLogDetailOpen.value = true;
}
async function exportChats() {
await api.exportChats(buildChatQuery());
}
function buildChatQuery() {
return {
keyword: chatFilters.keyword,
userId: chatFilters.userId,
status: chatFilters.status,
dateFrom: formatRecordDateTime(chatFilters.dateFrom, "start"),
dateTo: formatRecordDateTime(chatFilters.dateTo, "end"),
};
}
function formatRecordDateTime(value: string, boundary: "start" | "end") {
if (!value) return "";
const [date, time = ""] = value.trim().replace("T", " ").split(" ");
if (!date) return "";
const [hour = "00", minute = "00", second] = time.split(":");
const normalizedSecond = second ?? (boundary === "end" ? "59" : "00");
return `${date} ${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:${normalizedSecond.padStart(2, "0")}`;
}
</script>
<template>
<AdminLoginView v-if="!admin" :loading="loading" @submit="login" />
<main v-else class="admin-shell">
<aside class="sidebar">
<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>
<button :class="{ active: activeMenu === 'configs' }" @click="switchMenu('configs')">系统配置</button>
<button :class="{ active: activeMenu === 'records' }" @click="switchMenu('records')">记录审计</button>
<button :class="{ active: activeMenu === 'retrievals' }" @click="switchMenu('retrievals')">检索日志</button>
<button :class="{ active: activeMenu === 'attention' }" @click="switchMenu('attention')">人工关注</button>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<strong>{{ admin.name }}</strong>
<span>管理员</span>
</div>
<el-button @click="logout">退出</el-button>
</header>
<section v-loading="loading" class="content">
<template v-if="activeMenu === 'dashboard'">
<div class="page-head inline">
<div><h2>数据看板</h2><p>核心业务数据概览</p></div>
<div class="dashboard-date-range" aria-label="看板日期范围">
<label class="record-date-field dashboard-date-field">
<span>开始日期</span>
<input v-model="dashboardFilters.start" type="date" aria-label="开始日期" @change="loadDashboard" />
</label>
<span class="record-date-sep"></span>
<label class="record-date-field dashboard-date-field">
<span>结束日期</span>
<input v-model="dashboardFilters.end" type="date" aria-label="结束日期" @change="loadDashboard" />
</label>
</div>
</div>
<div class="stats-grid">
<div class="stat"><span>用户数</span><strong>{{ stats?.userCount ?? 0 }}</strong></div>
<div class="stat"><span>会话数</span><strong>{{ stats?.sessionCount ?? 0 }}</strong></div>
<div class="stat"><span>消息数</span><strong>{{ stats?.messageCount ?? 0 }}</strong></div>
<div class="stat"><span>AI 请求</span><strong>{{ stats?.aiRequestCount ?? 0 }}</strong></div>
<div class="stat"><span>知识库</span><strong>{{ stats?.knowledgeCount ?? 0 }}</strong></div>
<div class="stat"><span>输入 Token</span><strong>{{ stats?.inputToken ?? 0 }}</strong></div>
<div class="stat"><span>输出 Token</span><strong>{{ stats?.outputToken ?? 0 }}</strong></div>
<div class="stat"><span> Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong></div>
<div class="stat"><span>估算成本</span><strong>{{ formatMoney(stats?.estimatedCost, stats?.costCurrency || 'CNY') }}</strong></div>
</div>
<section class="cost-breakdown-panel">
<div class="storage-panel-head">
<div><h3>模型使用与成本</h3><p>按实际调用场景和模型统计报告摘要分流是否生效可在这里核对</p></div>
</div>
<el-table :data="stats?.costBreakdown || []" empty-text="当前日期范围内暂无模型调用">
<el-table-column label="调用场景" min-width="130">
<template #default="{ row }">{{ modelSceneLabel(row.scene) }}</template>
</el-table-column>
<el-table-column prop="modelName" label="实际模型" min-width="180" show-overflow-tooltip />
<el-table-column prop="requestCount" label="请求数" width="100" />
<el-table-column prop="inputToken" label="输入 Token" width="130" />
<el-table-column prop="outputToken" label="输出 Token" width="130" />
<el-table-column label="估算成本" width="170">
<template #default="{ row }">{{ formatMoney(row.estimatedCost, row.currency || 'CNY') }}</template>
</el-table-column>
</el-table>
</section>
<section class="storage-panel" v-loading="storageLoading"><div class="storage-panel-head"><div><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计;无权限或不支持时显示“无法统计”。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div><div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div><small>最后统计{{ storageStats?.createdAt || '尚未统计' }}</small></section>
</template>
<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">
<h3>手动添加学员</h3>
<div class="student-form-grid">
<el-input v-model="studentForm.phone" placeholder="手机号" />
<el-input v-model="studentForm.name" placeholder="姓名" />
<el-input v-model="studentForm.nickname" placeholder="昵称(可选)" />
<el-input-number v-model="studentForm.dailyChatLimit" :min="0" />
<label class="record-date-field student-expiry-field">
<span>有效期</span>
<input v-model="studentForm.expiredAt" type="date" aria-label="学员有效期" title="留空表示长期有效" />
</label>
<div class="student-status-field">
<span>启用</span>
<el-switch
v-model="studentForm.status"
:active-value="1"
:inactive-value="0"
aria-label="启用学员账号"
/>
</div>
</div>
<div class="actions compact">
<el-button type="primary" @click="createStudent">添加学员</el-button>
<el-button @click="resetStudentForm">清空</el-button>
</div>
</div>
<div class="student-tool-panel">
<h3>批量导入学员</h3>
<p class="tool-help">下载模板后填写学员信息再上传 .xlsx 文件导入</p>
<div class="student-file-import">
<input
ref="studentImportInput"
class="file-input"
type="file"
accept=".xlsx"
@change="handleStudentImportFile"
/>
<span class="file-name">{{ studentImportFile?.name || "未选择文件" }}</span>
</div>
<div class="actions compact">
<el-button @click="downloadStudentTemplate">下载模板</el-button>
<el-button type="primary" @click="importStudents">导入 Excel</el-button>
<span v-if="studentImportResult" class="import-result">
新增 {{ studentImportResult.created }}更新 {{ studentImportResult.updated }}失败 {{ studentImportResult.failed }}
</span>
</div>
<div v-if="studentImportResult?.failures.length" class="import-failures">
<span v-for="failure in studentImportResult.failures.slice(0, 5)" :key="`${failure.row}-${failure.phone}`">
{{ failure.row }} {{ failure.phone || '-' }}{{ failure.reason }}
</span>
</div>
</div>
</section>
<el-table :data="users" stripe>
<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" />
<el-table-column prop="nickname" label="昵称" width="140" />
<el-table-column label="状态" width="130">
<template #default="{ row }"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" /></template>
</el-table-column>
<el-table-column label="每日额度" width="150">
<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="210" fixed="right">
<template #default="{ row }">
<TableRowActions>
<el-button size="small" @click="openUserDetail(row)">详情</el-button>
<el-button size="small" type="primary" @click="saveUser(row)">保存</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="deleteUser(row)">删除用户</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</TableRowActions>
</template>
</el-table-column>
</el-table>
<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-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>
<template v-if="activeMenu === 'prompt'">
<AgentManagementView :preview-knowledge-id="previewKnowledgeId" @consumed-preview="previewKnowledgeId = null" />
</template>
<template v-if="activeMenu === 'models'">
<div class="page-head inline">
<div><h2>模型管理</h2><p>可同时启用多个模型但只保留一个默认主模型正式聊天始终使用主模型报告和摘要按能力标签分流并自动回退主模型</p></div>
<el-button @click="resetModelForm">清空表单</el-button>
</div>
<section class="quick-model-panel">
<div class="quick-model-head">
<div>
<h3>快速添加</h3>
<p>适用于 DeepSeek MiniMax只需要填写 API Key 并选择模型</p>
</div>
</div>
<div class="quick-model-grid">
<el-form-item label="厂商">
<el-select v-model="quickModelForm.provider" @change="applyQuickProvider">
<el-option
v-for="provider in quickModelProviders"
:key="provider.provider"
:label="provider.label"
:value="provider.provider"
/>
</el-select>
</el-form-item>
<el-form-item label="模型">
<el-select v-model="quickModelForm.modelName" :key="quickModelForm.provider">
<el-option
v-for="model in selectedQuickProvider.models"
:key="model.value"
:label="model.label"
:value="model.value"
/>
</el-select>
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="quickModelForm.apiKey" placeholder="填写后保存,列表中只展示掩码" show-password />
</el-form-item>
<el-form-item label=" ">
<el-button type="primary" class="full-btn" @click="quickAddModel">快速新增</el-button>
</el-form-item>
</div>
</section>
<el-form class="model-config-panel" label-position="top" :model="modelForm">
<div class="model-config-grid">
<el-form-item label="协议模板">
<el-select placeholder="选择协议模板" clearable @change="applyModelPreset">
<el-option v-for="preset in protocolPresets" :key="preset.label" :label="preset.label" :value="preset.label" />
</el-select>
</el-form-item>
<el-form-item label="供应商标识">
<el-input v-model="modelForm.provider" placeholder="custom-openai / custom-anthropic" />
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="modelForm.displayName" placeholder="例如:自建网关生产模型" />
</el-form-item>
<el-form-item label="协议类型">
<el-select v-model="modelForm.apiType">
<el-option label="OpenAI 兼容" value="openai_compatible" />
<el-option label="Anthropic Messages" value="anthropic_messages" />
<el-option label="MiniMax 官方" value="minimax" />
</el-select>
</el-form-item>
<el-form-item label="模型名">
<el-input v-model="modelForm.modelName" placeholder="gpt-4.1 / deepseek-chat / claude..." />
</el-form-item>
<el-form-item label="Base URL">
<el-input v-model="modelForm.baseUrl" placeholder="https://api.example.com/v1" />
</el-form-item>
<el-form-item label="API URL可覆盖">
<el-input v-model="modelForm.apiUrl" placeholder="留空则按协议自动拼接" />
</el-form-item>
<el-form-item label="鉴权方式">
<el-select v-model="modelForm.authType">
<el-option label="Bearer Token" value="bearer" />
<el-option label="x-api-key" value="api_key" />
</el-select>
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="modelForm.apiKey" placeholder="保存时会覆盖旧 Key" show-password />
</el-form-item>
<el-form-item label="API Version">
<el-input v-model="modelForm.apiVersion" placeholder="Anthropic 默认 2023-06-01" />
</el-form-item>
<el-form-item label="超时秒数">
<el-input-number v-model="modelForm.timeoutSecond" :min="1" :max="300" />
</el-form-item>
<el-form-item label="输入单价/千Token">
<el-input-number v-model="modelForm.inputPricePer1k" :min="0" :precision="6" :step="0.001" placeholder="不填则不估算成本" />
</el-form-item>
<el-form-item label="输出单价/千Token">
<el-input-number v-model="modelForm.outputPricePer1k" :min="0" :precision="6" :step="0.001" placeholder="不填则不估算成本" />
</el-form-item>
<el-form-item label="币种">
<el-input v-model="modelForm.currency" placeholder="CNY / USD" maxlength="10" />
</el-form-item>
</div>
<el-form-item label="适用场景">
<el-input v-model="modelForm.usageScenarios" placeholder="例如:正式对话、摘要、固定信息、报告批处理" />
</el-form-item>
<el-form-item label="可用能力">
<div>
<div class="entitlement-capabilities">
<el-checkbox v-model="modelForm.allowFixedInfo" :true-value="1" :false-value="0">固定信息</el-checkbox>
<el-checkbox v-model="modelForm.allowDeepChat" :true-value="1" :false-value="0">深度对话</el-checkbox>
<el-checkbox v-model="modelForm.allowSummary" :true-value="1" :false-value="0">摘要沉淀</el-checkbox>
<el-checkbox v-model="modelForm.allowReport" :true-value="1" :false-value="0">周期报告</el-checkbox>
</div>
<small class="model-routing-help">默认主模型勾选某项能力时会优先承担该场景如需专用模型接管请取消主模型的对应能力并为专用模型开启该能力</small>
</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="modelForm.remark" placeholder="用途、额度、注意事项等" />
</el-form-item>
<div class="actions">
<el-button type="primary" @click="saveModel">{{ editingModelId ? "保存模型" : "新增模型" }}</el-button>
<el-button @click="resetModelForm">取消编辑</el-button>
</div>
</el-form>
<el-table :data="models" stripe>
<el-table-column prop="displayName" label="显示名称" width="170" />
<el-table-column prop="provider" label="Provider" />
<el-table-column prop="apiType" label="协议" width="170" />
<el-table-column prop="modelName" label="模型" />
<el-table-column label="估算单价" width="180">
<template #default="{ row }">{{ row.currency || 'CNY' }} {{ row.inputPricePer1k ?? '-' }}/{{ row.outputPricePer1k ?? '-' }}</template>
</el-table-column>
<el-table-column label="分流能力" min-width="220">
<template #default="{ row }">
<div class="model-capability-tags">
<el-tag v-if="row.allowDeepChat === 1" size="small">深度对话</el-tag>
<el-tag v-if="row.allowFixedInfo === 1" size="small">固定信息</el-tag>
<el-tag v-if="row.allowSummary === 1" size="small" type="success">摘要</el-tag>
<el-tag v-if="row.allowReport === 1" size="small" type="warning">报告</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
<el-table-column prop="authType" label="鉴权" width="110" />
<el-table-column label="运行状态" width="150">
<template #default="{ row }">
<el-tag :type="row.enabled === 1 ? 'success' : 'info'">{{ row.enabled === 1 ? '可用' : '停用' }}</el-tag>
<el-tag v-if="row.isDefault === 1" class="model-default-tag">主模型</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right" align="center">
<template #default="{ row }">
<TableRowActions><el-button size="small" @click="editModel(row)">编辑</el-button><el-button size="small" @click="testModel(row.id)">测试</el-button><el-dropdown trigger="click" teleported @command="(command: string) => handleModelCommand(command, row)"><el-button size="small">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="row.enabled !== 1" command="enable">加入可用池</el-dropdown-item><el-dropdown-item v-if="row.enabled === 1 && row.isDefault !== 1" command="default">设为主模型</el-dropdown-item><el-dropdown-item v-if="row.enabled === 1 && row.isDefault !== 1" command="disable">停用</el-dropdown-item><el-dropdown-item v-if="row.isDefault !== 1" command="delete" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="activeMenu === 'configs'">
<div class="page-head inline">
<div><h2>系统配置</h2><p>按业务分组维护运行时配置默认每日额度已接入学员新增和导入</p></div>
<el-button type="primary" @click="saveSystemSettings">保存系统配置</el-button>
</div>
<section class="settings-grid">
<div v-for="section in systemSettingSections" :key="section.title" class="settings-section">
<div class="settings-section-head">
<h3>{{ section.title }}</h3>
<p>{{ section.description }}</p>
</div>
<div class="settings-fields">
<label v-for="setting in section.settings" :key="setting.key" class="setting-field">
<span>{{ setting.label }}</span>
<el-input-number
v-if="setting.type === 'number'"
v-model="systemSettingValues[setting.key]"
:min="setting.min"
:max="setting.max"
/>
<el-input
v-else-if="setting.type === 'text'"
v-model="systemSettingValues[setting.key]"
:placeholder="setting.placeholder"
/>
<el-input
v-else-if="setting.type === 'password'"
v-model="systemSettingValues[setting.key]"
:placeholder="setting.placeholder"
type="password"
show-password
/>
<el-switch v-else v-model="systemSettingValues[setting.key]" />
<small>{{ setting.description }}</small>
</label>
</div>
</div>
</section>
</template>
<template v-if="activeMenu === 'retrievals'">
<div class="page-head inline"><div><h2>检索日志</h2><p>查看每条回答使用的知识目录Agent 决策搜索候选重排和章节回读</p></div><div class="cleanup-controls"><el-date-picker v-model="cleanupBefore" value-format="YYYY-MM-DD" type="date" placeholder="清理此日期前" /><el-button type="danger" plain @click="cleanupRetrievalLogs">清理日志</el-button></div></div>
<section class="retention-panel"><div><strong>定期清理</strong><span>每日自动分批删除过期检索日志和关联候选明细</span></div><el-select v-model="retentionDays" placeholder="保留时间"><el-option label="永久保留" :value="null" /><el-option label="30天" :value="30" /><el-option label="90天" :value="90" /><el-option label="180天" :value="180" /></el-select><el-button type="primary" @click="saveRetention">保存规则</el-button><small>最近执行{{ retentionState?.lastRunAt || '尚未执行' }}</small></section>
<el-table :data="retrievalLogs" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="sessionId" label="会话" width="90" />
<el-table-column prop="question" label="用户问题" min-width="280" show-overflow-tooltip />
<el-table-column label="调用知识" width="100"><template #default="{ row }"><el-tag :type="row.knowledgeCalled ? 'success' : 'info'">{{ row.knowledgeCalled ? '已调用' : '未调用' }}</el-tag></template></el-table-column>
<el-table-column prop="selectedKnowledgeIds" label="知识库" width="120" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="toolCostMs" label="工具耗时" width="110" />
<el-table-column prop="totalCostMs" label="总耗时" width="100" />
<el-table-column prop="createdAt" label="时间" width="180" />
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openRetrievalDetail(row)">详情</el-button></template></el-table-column>
</el-table>
<AdminPagination :page="pagers.retrievals.page" :page-size="pagers.retrievals.pageSize" :total="pagers.retrievals.total" @change="loadRetrievals" />
</template>
<template v-if="activeMenu === 'attention'">
<div class="page-head"><h2>人工关注</h2><p>处理安全风险复杂卡住知识缺失和用户主动求助记录</p></div>
<el-table :data="attentionRecords" stripe>
<el-table-column label="优先级" width="100"><template #default="{ row }"><el-tag :type="row.priority === 'urgent' ? 'danger' : row.priority === 'important' ? 'warning' : 'info'">{{ attentionPriorityLabel(row.priority) }}</el-tag></template></el-table-column>
<el-table-column prop="problemSummary" label="问题摘要" min-width="220" />
<el-table-column prop="triggerReason" label="触发原因" min-width="220" />
<el-table-column prop="userId" label="用户" width="80" />
<el-table-column prop="sessionId" label="会话" width="80" />
<el-table-column label="状态" width="150"><template #default="{ row }"><el-select :model-value="row.status" size="small" @change="updateAttentionStatus(row, $event)"><el-option label="待处理" value="pending" /><el-option label="处理中" value="processing" /><el-option label="已处理" value="resolved" /><el-option label="无需处理" value="ignored" /></el-select></template></el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180" />
<el-table-column label="操作" width="130" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openChatSession(row.sessionId)">原会话</el-button><el-button link type="danger" @click="deleteAttentionRecord(row)">删除</el-button></template></el-table-column>
</el-table>
<AdminPagination :page="pagers.attention.page" :page-size="pagers.attention.pageSize" :total="pagers.attention.total" @change="loadAttention" />
</template>
<template v-if="activeMenu === 'records'">
<div class="page-head"><h2>记录审计</h2><p>聊天AI 请求和后台操作记录</p></div>
<el-tabs v-model="recordTab">
<el-tab-pane label="聊天记录" name="chats">
<div class="record-filter">
<el-input v-model="chatFilters.keyword" placeholder="搜索标题、手机号、姓名、聊天内容" clearable />
<el-input-number v-model="chatFilters.userId" :min="1" placeholder="用户ID" />
<el-select v-model="chatFilters.status" placeholder="消息状态" clearable>
<el-option label="全部状态" value="" />
<el-option label="已完成" value="FINISHED" />
<el-option label="生成中" value="GENERATING" />
<el-option label="已停止" value="STOPPED" />
<el-option label="失败" value="FAILED" />
</el-select>
<div class="record-date-range" aria-label="时间范围">
<label class="record-date-field">
<span>开始时间</span>
<input v-model="chatFilters.dateFrom" type="datetime-local" aria-label="开始时间" />
</label>
<span class="record-date-sep"></span>
<label class="record-date-field">
<span>结束时间</span>
<input v-model="chatFilters.dateTo" type="datetime-local" aria-label="结束时间" />
</label>
</div>
<div class="record-filter-actions">
<el-button type="primary" @click="searchChats">搜索</el-button>
<el-button @click="resetChatFilters">重置</el-button>
<el-button @click="exportChats">导出</el-button>
</div>
</div>
<el-table v-loading="loading" :data="chats" stripe>
<el-table-column prop="id" label="会话ID" width="90" />
<el-table-column prop="userPhone" label="手机号" width="140" />
<el-table-column prop="userName" label="用户" width="120" />
<el-table-column prop="title" label="会话标题" min-width="220" show-overflow-tooltip />
<el-table-column prop="messageCount" label="消息数" width="90" />
<el-table-column prop="lastMessageAt" label="最后消息" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openChatDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<AdminPagination :page="pagers.chats.page" :page-size="pagers.chats.pageSize" :total="pagers.chats.total" @change="(page, pageSize) => changeRecordPage('chats', page, pageSize)" />
</el-tab-pane>
<el-tab-pane label="问题洞察" name="questionInsights">
<div class="record-filter question-insight-filter">
<div class="record-date-range" aria-label="问题时间范围">
<label class="record-date-field">
<span>开始时间</span>
<input v-model="questionInsightFilters.dateFrom" type="datetime-local" aria-label="问题开始时间" />
</label>
<span class="record-date-sep"></span>
<label class="record-date-field">
<span>结束时间</span>
<input v-model="questionInsightFilters.dateTo" type="datetime-local" aria-label="问题结束时间" />
</label>
</div>
<label class="insight-number-field">
<span>最低频次</span>
<el-input-number v-model="questionInsightFilters.minCount" :min="1" :max="50" controls-position="right" />
</label>
<label class="insight-number-field">
<span>单次清洗上限</span>
<el-input-number v-model="questionInsightFilters.maxMessages" :min="100" :max="20000" :step="500" controls-position="right" />
</label>
<div class="record-filter-actions">
<el-button type="primary" :loading="loading" @click="searchQuestionInsights">刷新并统计</el-button>
<el-button @click="resetQuestionInsightFilters">重置</el-button>
</div>
</div>
<p class="question-insight-help">清洗结果会持久化保存点击刷新并统计只处理尚未清洗的用户消息再按当前时间范围聚合翻页不会重新扫描聊天原文</p>
<section v-if="questionInsights" class="question-insight-summary">
<div><span>纳入清洗消息</span><strong>{{ questionInsights.summary.scannedMessages }}</strong></div>
<div><span>有效问题</span><strong>{{ questionInsights.summary.cleanedQuestions }}</strong></div>
<div><span>过滤低价值</span><strong>{{ questionInsights.summary.filteredMessages }}</strong></div>
<div><span>高频问题组</span><strong>{{ questionInsights.summary.visibleClusterCount }}</strong></div>
</section>
<section v-loading="loading" class="question-insight-list">
<article v-for="cluster in questionInsights?.items || []" :key="`${cluster.rank}-${cluster.normalized}`" class="question-insight-card">
<header>
<div>
<small>TOP {{ cluster.rank }}</small>
<h3>{{ cluster.title }}</h3>
</div>
<div class="question-insight-metrics">
<span>{{ cluster.count }} </span>
<span>{{ cluster.userCount }} </span>
<span>{{ cluster.sessionCount }} 个会话</span>
</div>
</header>
<div class="question-insight-tags">
<el-tag size="small" effect="plain">{{ cluster.categoryLabel }}</el-tag>
<el-tag v-if="cluster.needsKnowledgeFollowUp" size="small" type="warning" effect="plain">需补知识/查召回</el-tag>
<el-tag v-if="cluster.noHitCount" size="small" type="danger" effect="plain">无命中 {{ cluster.noHitCount }}</el-tag>
<el-tag v-if="cluster.failedCount" size="small" type="danger" effect="dark">失败 {{ cluster.failedCount }}</el-tag>
<el-tag v-for="term in cluster.topTerms" :key="term" size="small" type="success" effect="plain">{{ term }}</el-tag>
</div>
<p class="question-insight-action">{{ cluster.suggestedAction }}</p>
<el-collapse>
<el-collapse-item title="查看相似问法和原始样例" :name="cluster.normalized">
<div class="question-variants">
<span v-for="variant in cluster.variants" :key="variant.text">{{ variant.text }} × {{ variant.count }}</span>
</div>
<div class="question-samples">
<article v-for="sample in cluster.samples" :key="sample.messageId">
<div>
<strong>{{ sample.userName || sample.userPhone || `用户 #${sample.userId}` }}</strong>
<span>{{ sample.createdAt }}</span>
</div>
<p>清洗后{{ sample.cleaned }}</p>
<pre>{{ sample.raw }}</pre>
<el-button link type="primary" @click="openChatSession(sample.sessionId)">查看原会话</el-button>
</article>
</div>
</el-collapse-item>
</el-collapse>
</article>
<el-empty v-if="questionInsights && questionInsights.items.length === 0" description="当前条件下暂无达到频次的问题组" :image-size="72" />
<el-empty v-else-if="!questionInsights && !loading" description="选择时间范围后点击统计问题" :image-size="72" />
</section>
<AdminPagination :page="pagers.questionInsights.page" :page-size="pagers.questionInsights.pageSize" :total="pagers.questionInsights.total" @change="(page, pageSize) => changeRecordPage('questionInsights', page, pageSize)" />
</el-tab-pane>
<el-tab-pane label="AI 请求" name="aiLogs">
<el-table v-loading="loading" :data="aiLogs" stripe>
<el-table-column prop="sessionId" label="会话ID" width="90" />
<el-table-column prop="modelName" label="模型" width="160" />
<el-table-column prop="knowledgeIds" label="知识库" width="120" />
<el-table-column prop="retrieveCount" label="命中" width="80" />
<el-table-column label="片段" width="80">
<template #default="{ row }">
{{ row.retrievedChunks?.length || 0 }}
</template>
</el-table-column>
<el-table-column prop="totalToken" label="Token" width="90" />
<el-table-column label="估算成本" width="130">
<template #default="{ row }">{{ formatMoney(row.estimatedCost, row.currency || 'CNY') }}</template>
</el-table-column>
<el-table-column prop="costMs" label="耗时(ms)" width="100" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="errorMessage" label="错误" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openAiLogDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<AdminPagination :page="pagers.aiLogs.page" :page-size="pagers.aiLogs.pageSize" :total="pagers.aiLogs.total" @change="(page, pageSize) => changeRecordPage('aiLogs', page, pageSize)" />
</el-tab-pane>
<el-tab-pane label="操作日志" name="operationLogs">
<el-table v-loading="loading" :data="operationLogs" stripe>
<el-table-column prop="module" label="模块" />
<el-table-column prop="action" label="动作" />
<el-table-column prop="result" label="结果" />
<el-table-column prop="createdAt" label="时间" />
</el-table>
<AdminPagination :page="pagers.operationLogs.page" :page-size="pagers.operationLogs.pageSize" :total="pagers.operationLogs.total" @change="(page, pageSize) => changeRecordPage('operationLogs', page, pageSize)" />
</el-tab-pane>
</el-tabs>
</template>
</section>
</section>
<el-drawer v-model="userDetailOpen" size="860px" title="用户详情">
<div v-loading="userDetailLoading" class="user-detail-body">
<template v-if="selectedUserDetail">
<section class="user-detail-head">
<div>
<h3>{{ selectedUserDetail.user.name || selectedUserDetail.user.nickname || selectedUserDetail.user.phone }}</h3>
<p>{{ selectedUserDetail.user.phone }} · {{ selectedUserDetail.user.status === 1 ? '已启用' : '已停用' }}</p>
</div>
<div class="user-detail-tags">
<el-tag v-if="selectedUserDetail.metrics.isHighFrequency" type="warning" effect="plain">高频使用</el-tag>
<el-tag v-if="selectedUserDetail.metrics.isInactive" type="info" effect="plain">近30天低活跃</el-tag>
<el-tag type="success" effect="plain">{{ selectedUserDetail.user.entitlement?.name || '默认基础版' }}</el-tag>
</div>
</section>
<section class="chat-summary user-metric-grid">
<div><span>本月主题</span><strong>{{ selectedUserDetail.metrics.monthTopics }}/{{ selectedUserDetail.user.entitlement?.monthlyTopicLimit ?? '不限' }}</strong></div>
<div><span>近30天活跃</span><strong>{{ selectedUserDetail.metrics.recentActiveDays }} </strong></div>
<div><span>总会话</span><strong>{{ selectedUserDetail.metrics.totalSessions }}</strong></div>
<div><span>总消息</span><strong>{{ selectedUserDetail.metrics.totalMessages }}</strong></div>
<div><span>主题总数</span><strong>{{ selectedUserDetail.metrics.totalTopics }}</strong></div>
<div><span>AI 请求</span><strong>{{ selectedUserDetail.metrics.aiRequestCount }}</strong></div>
<div><span> Token</span><strong>{{ selectedUserDetail.metrics.totalToken }}</strong></div>
<div><span>估算成本</span><strong>{{ formatMoney(selectedUserDetail.metrics.estimatedCost, selectedUserDetail.metrics.costCurrency) }}</strong></div>
</section>
<section class="audit-detail-grid">
<div><span>权益有效期</span><strong>{{ selectedUserDetail.user.entitlement?.expiredAt || selectedUserDetail.user.expiredAt || '长期有效' }}</strong></div>
<div><span>最近登录</span><strong>{{ selectedUserDetail.user.lastLoginAt || '-' }}</strong></div>
<div><span>最近发言</span><strong>{{ selectedUserDetail.metrics.lastMessageAt || '-' }}</strong></div>
<div><span>生成记录</span><strong>求助卡 {{ selectedUserDetail.metrics.helpCardCount }} / 分享稿 {{ selectedUserDetail.metrics.shareDraftCount }}</strong></div>
</section>
<h3 class="detail-title">成长档案摘要</h3>
<section v-if="selectedUserDetail.growthProfile" class="topic-summary-card">
<pre>{{ selectedUserDetail.growthProfile.profileText || '暂无成长档案摘要' }}</pre>
<div class="topic-summary-fields">
<p v-if="selectedUserDetail.growthProfile.recurringTopics"><strong>反复议题</strong>{{ selectedUserDetail.growthProfile.recurringTopics }}</p>
<p v-if="selectedUserDetail.growthProfile.commonEmotions"><strong>常见情绪</strong>{{ selectedUserDetail.growthProfile.commonEmotions }}</p>
<p v-if="selectedUserDetail.growthProfile.bodyPatterns"><strong>身体模式</strong>{{ selectedUserDetail.growthProfile.bodyPatterns }}</p>
<p v-if="selectedUserDetail.growthProfile.homeworkDone"><strong>做过功课</strong>{{ selectedUserDetail.growthProfile.homeworkDone }}</p>
<p v-if="selectedUserDetail.growthProfile.effectiveHomework"><strong>有效功课</strong>{{ selectedUserDetail.growthProfile.effectiveHomework }}</p>
<p v-if="selectedUserDetail.growthProfile.recentProgress"><strong>近期变化</strong>{{ selectedUserDetail.growthProfile.recentProgress }}</p>
</div>
</section>
<el-empty v-else description="该用户暂无成长档案沉淀" :image-size="64" />
<section class="user-report-head">
<div>
<h3 class="detail-title">周期报告</h3>
<p>基于已沉淀的主题摘要生成不读取整段原始聊天同一周期重复生成会覆盖旧报告</p>
</div>
<div class="user-report-actions">
<el-button size="small" @click="refreshSelectedUserDetail">刷新状态</el-button>
<el-button size="small" :loading="userReportGenerating === 'weekly'" @click="generateUserReport('weekly')">生成周报</el-button>
<el-button size="small" :loading="userReportGenerating === 'monthly'" @click="generateUserReport('monthly')">生成月报</el-button>
<el-button size="small" :loading="userReportGenerating === 'stage'" @click="generateUserReport('stage')">生成阶段总结</el-button>
</div>
</section>
<el-collapse v-if="selectedUserDetail.recentReports.length" class="topic-summary-collapse">
<el-collapse-item
v-for="report in selectedUserDetail.recentReports"
:key="report.id"
:title="`#${report.id} ${report.title} / ${reportStatusLabel(report.status)}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>状态{{ reportStatusLabel(report.status) }}</span>
<span>类型{{ report.reportTypeLabel }}</span>
<span>周期{{ report.periodStart }} {{ report.periodEnd }}</span>
<span>来源主题{{ report.sourceTopicIds.length }}</span>
<span>来源摘要{{ report.sourceSummaryIds.length }}</span>
<span>模型{{ report.modelName || '-' }}</span>
<span>触发方式{{ report.generatedBy.startsWith('schedule:') ? '系统定时' : '管理员手动' }}</span>
<span>执行次数{{ report.attemptCount }}/{{ report.maxAttempts }}</span>
<span v-if="report.nextRunAt">下次执行{{ report.nextRunAt }}</span>
<span>完成时间{{ report.finishedAt || '-' }}</span>
</div>
<p v-if="report.errorMessage" class="report-error">最近错误{{ report.errorMessage }}</p>
<pre v-if="report.content">{{ report.content }}</pre>
<p v-else-if="report.status === 'pending' || report.status === 'running'" class="report-pending">后台正在生成报告可以先关闭抽屉稍后回来查看</p>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无周期报告" :image-size="64" />
<h3 class="detail-title">最近主题</h3>
<el-collapse v-if="selectedUserDetail.recentTopics.length" class="topic-summary-collapse">
<el-collapse-item
v-for="topic in selectedUserDetail.recentTopics"
:key="topic.id"
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>核心问题{{ topic.coreQuestion }}</span>
<span>消息数{{ topic.messageCount }}</span>
<span>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span>
<span>额度{{ topic.quotaDeducted ? '已计入' : '不计入' }}</span>
<span>开始{{ topic.startedAt }}</span>
<span>更新{{ topic.updatedAt }}</span>
</div>
<template v-if="topic.summary">
<pre>{{ topic.summary.summary }}</pre>
<div class="topic-summary-fields">
<p v-if="topic.summary.emotions"><strong>情绪</strong>{{ topic.summary.emotions }}</p>
<p v-if="topic.summary.bodyFeelings"><strong>身体感受</strong>{{ topic.summary.bodyFeelings }}</p>
<p v-if="topic.summary.recommendedHomework"><strong>推荐功课</strong>{{ topic.summary.recommendedHomework }}</p>
<p v-if="topic.summary.nextObservation"><strong>下一步观察</strong>{{ topic.summary.nextObservation }}</p>
</div>
</template>
<el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" />
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无主题记录" :image-size="64" />
<h3 class="detail-title">最近老师求助卡{{ selectedUserDetail.recentHelpCards.length }}</h3>
<el-collapse v-if="selectedUserDetail.recentHelpCards.length" class="topic-summary-collapse">
<el-collapse-item
v-for="card in selectedUserDetail.recentHelpCards"
:key="card.id"
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ card.createdAt }}</span>
<span>复制{{ card.copiedAt || '-' }}</span>
<span>来源{{ card.source }}</span>
</div>
<pre>{{ card.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无求助卡生成记录" :image-size="64" />
<h3 class="detail-title">最近班级分享稿{{ selectedUserDetail.recentShareDrafts.length }}</h3>
<el-collapse v-if="selectedUserDetail.recentShareDrafts.length" class="topic-summary-collapse">
<el-collapse-item
v-for="draft in selectedUserDetail.recentShareDrafts"
:key="draft.id"
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ draft.createdAt }}</span>
<span>复制{{ draft.copiedAt || '-' }}</span>
<span>来源{{ draft.source }}</span>
</div>
<pre>{{ draft.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无分享稿生成记录" :image-size="64" />
</template>
</div>
</el-drawer>
<el-drawer v-model="retrievalDetailOpen" size="820px" title="检索日志详情">
<template v-if="selectedRetrievalLog">
<dl class="knowledge-meta">
<dt>问题</dt><dd>{{ selectedRetrievalLog.question }}</dd>
<dt>安全规则</dt><dd>{{ selectedRetrievalLog.safetyRuleVersion }}</dd>
<dt>目录哈希</dt><dd>{{ selectedRetrievalLog.catalogHash }}</dd>
<dt>最终章节</dt><dd>{{ selectedRetrievalLog.finalSectionIds || '未读取知识章节' }}</dd>
</dl>
<h3>本轮 KnowledgeList</h3><pre class="trace-json">{{ JSON.stringify(selectedRetrievalLog.catalogSnapshot, null, 2) }}</pre>
<h3>Agent 工具轨迹</h3><pre class="trace-json">{{ JSON.stringify(selectedRetrievalLog.toolTrace, null, 2) }}</pre>
<h3>候选切片与取舍</h3><el-table :data="selectedRetrievalLog.candidates"><el-table-column prop="knowledgeId" label="知识库" width="90" /><el-table-column prop="knowledgeName" label="名称" min-width="150" show-overflow-tooltip /><el-table-column prop="knowledgeTypeLabel" label="类型优先级" width="170" /><el-table-column prop="versionId" label="版本" width="80" /><el-table-column prop="sectionId" label="章节" width="80" /><el-table-column prop="lexicalScore" label="词法分" width="100" /><el-table-column prop="rerankScore" label="重排分" width="100" /><el-table-column label="采用" width="80"><template #default="{ row }">{{ row.selected ? '是' : '否' }}</template></el-table-column><el-table-column prop="discardReason" label="舍弃原因" min-width="180" /></el-table>
<h3>最终回答</h3><pre class="trace-json">{{ selectedRetrievalLog.finalAnswer || '尚未生成回答' }}</pre>
</template>
</el-drawer>
<el-drawer v-model="chatDetailOpen" size="760px" title="聊天详情">
<div ref="chatDetailBodyRef" v-loading="chatDetailLoading" class="chat-detail-body">
<template v-if="chatDetail">
<section class="chat-summary">
<div><span>会话ID</span><strong>{{ chatDetail.session.id }}</strong></div>
<div><span>用户</span><strong>{{ chatDetail.session.userName || '-' }}</strong></div>
<div><span>手机号</span><strong>{{ chatDetail.session.userPhone || '-' }}</strong></div>
<div><span>消息数</span><strong>{{ chatDetail.session.messageCount }}</strong></div>
</section>
<h3 class="detail-title">主题会话</h3>
<el-collapse v-if="chatDetail.topics?.length" class="topic-summary-collapse">
<el-collapse-item
v-for="topic in chatDetail.topics"
:key="topic.id"
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>核心问题{{ topic.coreQuestion }}</span>
<span>消息数{{ topic.messageCount }}</span>
<span>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span>
<span>额度{{ topic.quotaDeducted ? '已计入' : '不计入' }}</span>
<span>开始{{ topic.startedAt }}</span>
<span>结束{{ topic.endedAt || '-' }}</span>
</div>
<template v-if="topic.summary">
<pre>{{ topic.summary.summary }}</pre>
<div class="topic-summary-fields">
<p v-if="topic.summary.mainEvents"><strong>主要事件</strong>{{ topic.summary.mainEvents }}</p>
<p v-if="topic.summary.emotions"><strong>情绪</strong>{{ topic.summary.emotions }}</p>
<p v-if="topic.summary.bodyFeelings"><strong>身体感受</strong>{{ topic.summary.bodyFeelings }}</p>
<p v-if="topic.summary.recommendedHomework"><strong>推荐功课</strong>{{ topic.summary.recommendedHomework }}</p>
<p v-if="topic.summary.nextObservation"><strong>下一步观察</strong>{{ topic.summary.nextObservation }}</p>
</div>
</template>
<el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" />
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该会话暂无主题记录" :image-size="64" />
<h3 class="detail-title">老师求助卡{{ chatDetail.helpCards?.length || 0 }}</h3>
<el-collapse v-if="chatDetail.helpCards?.length" class="topic-summary-collapse">
<el-collapse-item
v-for="card in chatDetail.helpCards"
:key="card.id"
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ card.createdAt }}</span>
<span>复制{{ card.copiedAt || '-' }}</span>
<span>来源{{ card.source }}</span>
</div>
<pre>{{ card.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该会话暂无用户生成的求助卡" :image-size="64" />
<h3 class="detail-title">班级分享稿{{ chatDetail.shareDrafts?.length || 0 }}</h3>
<el-collapse v-if="chatDetail.shareDrafts?.length" class="topic-summary-collapse">
<el-collapse-item
v-for="draft in chatDetail.shareDrafts"
:key="draft.id"
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ draft.createdAt }}</span>
<span>复制{{ draft.copiedAt || '-' }}</span>
<span>来源{{ draft.source }}</span>
</div>
<pre>{{ draft.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该会话暂无用户生成的班级分享稿" :image-size="64" />
<h3 class="detail-title">完整对话</h3>
<AdminPagination
class="chat-detail-pagination top"
:page="chatDetailMessagePager.page"
:page-size="chatDetailMessagePager.pageSize"
:total="chatDetailMessagePager.total"
@change="changeChatDetailMessagePage"
/>
<section v-loading="chatMessagesLoading" class="conversation-list">
<article
v-for="message in chatDetail.messages"
:key="message.id"
class="conversation-message"
:class="message.role"
>
<div class="message-meta">
<strong>{{ message.role === 'user' ? '用户' : '大本营答疑助手' }}</strong>
<span>{{ message.createdAt }}</span>
</div>
<pre>{{ message.content }}</pre>
<div class="message-extra">
<span>状态{{ message.messageStatus }}</span>
<span v-if="message.tokenInput">输入 Token{{ message.tokenInput }}</span>
<span v-if="message.tokenOutput">输出 Token{{ message.tokenOutput }}</span>
<span v-if="message.responseTimeMs">耗时{{ message.responseTimeMs }}ms</span>
</div>
</article>
</section>
<AdminPagination
class="chat-detail-pagination"
:page="chatDetailMessagePager.page"
:page-size="chatDetailMessagePager.pageSize"
:total="chatDetailMessagePager.total"
@change="changeChatDetailMessagePage"
/>
<h3 class="detail-title">关联 AI 请求</h3>
<el-collapse>
<el-collapse-item v-for="log in chatDetail.aiLogs" :key="log.id" :title="`请求 #${log.id} / ${log.status}`">
<div class="ai-log-grid">
<span>模型{{ log.modelName || '-' }}</span>
<span>知识库{{ log.knowledgeIds || '-' }}</span>
<span>命中数{{ log.retrieveCount }}</span>
<span>Token{{ log.totalToken || '-' }}</span>
<span>耗时{{ log.costMs || '-' }}ms</span>
<span>时间{{ log.createdAt }}</span>
</div>
<section v-if="log.retrievedChunks?.length" class="retrieval-chunks">
<article v-for="chunk in log.retrievedChunks" :key="`${log.id}-${chunk.index}`" class="retrieval-chunk">
<div class="chunk-head">
<strong>#{{ chunk.index }} {{ chunk.knowledgeName || '未知知识库' }}</strong>
<span>{{ chunk.title || '未命名片段' }}</span>
</div>
<pre>{{ chunk.content }}</pre>
<a v-if="chunk.sourceUrl" :href="chunk.sourceUrl" target="_blank" rel="noreferrer">查看来源</a>
</article>
</section>
<el-empty v-else description="本条请求未保存召回片段" :image-size="64" />
<pre class="prompt-preview">{{ log.prompt || '无 Prompt 记录' }}</pre>
<p v-if="log.errorMessage" class="error-text">{{ log.errorMessage }}</p>
</el-collapse-item>
</el-collapse>
</template>
<el-empty v-else-if="!chatDetailLoading" description="聊天详情加载失败或暂无数据" :image-size="80" />
</div>
</el-drawer>
<el-drawer v-model="aiLogDetailOpen" size="820px" title="AI 请求详情">
<template v-if="selectedAiLog">
<section class="chat-summary">
<div><span>请求ID</span><strong>{{ selectedAiLog.id }}</strong></div>
<div><span>会话ID</span><strong>{{ selectedAiLog.sessionId || '-' }}</strong></div>
<div><span>消息ID</span><strong>{{ selectedAiLog.messageId || '-' }}</strong></div>
<div><span>用户ID</span><strong>{{ selectedAiLog.userId || '-' }}</strong></div>
<div><span>模型</span><strong>{{ selectedAiLog.modelName || '-' }}</strong></div>
<div><span>状态</span><strong>{{ selectedAiLog.status }}</strong></div>
</section>
<section class="audit-detail-grid">
<span>知识库{{ selectedAiLog.knowledgeIds || '-' }}</span>
<span>命中数{{ selectedAiLog.retrieveCount }}</span>
<span>片段数{{ selectedAiLog.retrievedChunks?.length || 0 }}</span>
<span>输入 Token{{ selectedAiLog.inputToken || '-' }}</span>
<span>输出 Token{{ selectedAiLog.outputToken || '-' }}</span>
<span> Token{{ selectedAiLog.totalToken || '-' }}</span>
<span>估算成本{{ formatMoney(selectedAiLog.estimatedCost, selectedAiLog.currency || 'CNY') }}</span>
<span>问题类型{{ selectedAiLog.questionType || '-' }}</span>
<span>知识命中{{ selectedAiLog.knowledgeHit ? '是' : '否' }}</span>
<span>路由原因{{ selectedAiLog.routeReason || '-' }}</span>
<span>耗时{{ selectedAiLog.costMs || '-' }}ms</span>
<span>时间{{ selectedAiLog.createdAt }}</span>
</section>
<p v-if="selectedAiLog.errorMessage" class="error-text">{{ selectedAiLog.errorMessage }}</p>
<h3 class="detail-title">召回知识片段</h3>
<section v-if="selectedAiLog.retrievedChunks?.length" class="retrieval-chunks">
<article
v-for="chunk in selectedAiLog.retrievedChunks"
:key="`${selectedAiLog.id}-${chunk.index}`"
class="retrieval-chunk"
>
<div class="chunk-head">
<strong>片段 {{ chunk.index }}/{{ selectedAiLog.retrievedChunks.length }}</strong>
<span>{{ chunk.knowledgeName || '未知知识库' }}</span>
<span v-if="chunk.knowledgeId">知识库ID{{ chunk.knowledgeId }}</span>
</div>
<div class="chunk-title">{{ chunk.title || '未命名片段' }}</div>
<pre>{{ chunk.content }}</pre>
<a v-if="chunk.sourceUrl" :href="chunk.sourceUrl" target="_blank" rel="noreferrer">查看来源</a>
</article>
</section>
<el-empty v-else description="本条请求未保存召回片段" :image-size="72" />
<h3 class="detail-title">完整 Prompt</h3>
<pre class="prompt-preview">{{ selectedAiLog.prompt || '无 Prompt 记录' }}</pre>
</template>
</el-drawer>
</main>
</template>