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

1615 lines
61 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, onMounted, reactive, ref } from "vue";
import { api, clearToken, getToken, saveToken } from "./services/api";
import type {
AdminProfile,
AdminUser,
AgentDebugResult,
AiLogRecord,
ChatDetail,
ChatRecord,
DashboardStats,
KnowledgeItem,
ModelItem,
SystemConfigItem,
UserImportResult,
} from "./types/api";
const admin = ref<AdminProfile | null>(null);
const activeMenu = ref("dashboard");
const loading = ref(false);
const loginForm = reactive({ username: "admin", password: "admin123456" });
const stats = ref<DashboardStats | null>(null);
const users = ref<AdminUser[]>([]);
const userKeyword = ref("");
const knowledge = ref<KnowledgeItem[]>([]);
const models = ref<ModelItem[]>([]);
const configs = ref<SystemConfigItem[]>([]);
const chats = ref<ChatRecord[]>([]);
const aiLogs = ref<AiLogRecord[]>([]);
const operationLogs = ref<Record<string, unknown>[]>([]);
const promptContent = ref("");
const chatDetail = ref<ChatDetail | null>(null);
const chatDetailOpen = ref(false);
const selectedAiLog = ref<AiLogRecord | null>(null);
const aiLogDetailOpen = ref(false);
const recordTab = ref("chats");
const editingModelId = ref<number | null>(null);
const editingKnowledgeId = ref<number | null>(null);
const batchNodeToken = ref("");
const batchImporting = ref(false);
const agentDebugging = ref(false);
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
]);
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 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: null as number | null,
contextWindow: null as number | null,
streamEnabled: 1,
responseFormat: "",
extraParams: "",
remark: "",
timeoutSecond: 30,
});
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",
] as const;
type SystemSettingValue = string | number | boolean;
type SystemSettingType = "number" | "switch" | "text" | "password";
interface SystemSettingDefinition {
key: string;
label: string;
type: SystemSettingType;
defaultValue: SystemSettingValue;
min?: number;
max?: number;
placeholder?: string;
description: string;
}
interface SystemSettingSection {
title: string;
description: string;
settings: SystemSettingDefinition[];
}
const systemSettingSections: SystemSettingSection[] = [
{
title: "用户与登录",
description: "控制学员准入、默认资料、验证码和登录态有效时间。",
settings: [
{
key: "daily_chat_limit",
label: "默认每日聊天额度",
type: "number",
defaultValue: 100,
min: 0,
max: 100000,
description: "新建或导入学员未单独填写额度时使用。",
},
{
key: "access_token_expire_minutes",
label: "登录 Token 有效期(分钟)",
type: "number",
defaultValue: 43200,
min: 5,
max: 43200,
description: "用户端和管理端登录后的 Token 有效期。",
},
{
key: "sms_code_expire_minutes",
label: "验证码有效期(分钟)",
type: "number",
defaultValue: 5,
min: 1,
max: 60,
description: "短信验证码生成后的可使用时间。",
},
{
key: "mock_sms_enabled",
label: "使用 mock 短信",
type: "switch",
defaultValue: true,
description: "开发和演示环境可开启,生产环境应关闭并接入真实短信。",
},
{
key: "mock_sms_code",
label: "mock 短信验证码",
type: "text",
defaultValue: "123456",
placeholder: "例如 123456",
description: "mock 短信开启时使用的固定验证码。",
},
{
key: "default_user_name_prefix",
label: "默认用户名前缀",
type: "text",
defaultValue: "用户",
placeholder: "例如 用户",
description: "系统需要生成默认名称时使用的前缀。",
},
],
},
{
title: "AI 问答",
description: "控制 Agent 调用、上下文和用户端回答展示策略。",
settings: [
{
key: "ai_timeout_seconds",
label: "AI 请求超时(秒)",
type: "number",
defaultValue: 60,
min: 5,
max: 300,
description: "真实模型请求超过该时间应判定为超时。",
},
{
key: "mock_model_enabled",
label: "使用 mock 大模型",
type: "switch",
defaultValue: true,
description: "开发和演示环境可开启,真实模型验收前应关闭。",
},
{
key: "chat_context_message_count",
label: "上下文消息数",
type: "number",
defaultValue: 10,
min: 0,
max: 100,
description: "组装 Prompt 时带入的历史消息数量。",
},
{
key: "show_reference_sources",
label: "用户端展示引用来源",
type: "switch",
defaultValue: true,
description: "后台始终记录引用;此项控制用户端是否展示。",
},
{
key: "chat_max_active_requests",
label: "问答最大并发数",
type: "number",
defaultValue: 2,
min: 1,
max: 1000,
description: "同一后端进程内同时进入飞书和模型生成链路的最大请求数。",
},
{
key: "chat_max_queue_size",
label: "问答最大排队数",
type: "number",
defaultValue: 20,
min: 0,
max: 10000,
description: "超过最大并发后允许等待的请求数量,超过后直接提示稍后再试。",
},
{
key: "chat_queue_timeout_seconds",
label: "问答排队超时(秒)",
type: "number",
defaultValue: 60,
min: 1,
max: 3600,
description: "请求在排队中超过该时间后自动结束并提示用户稍后再试。",
},
{
key: "chat_active_lease_seconds",
label: "问答执行租约(秒)",
type: "number",
defaultValue: 900,
min: 60,
max: 86400,
description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。",
},
],
},
{
title: "外部服务",
description: "控制飞书搜索接口、飞书直读超时和重试策略。",
settings: [
{
key: "feishu_search_url",
label: "飞书检索服务地址",
type: "text",
defaultValue: "",
placeholder: "例如 https://example.com/search",
description: "如使用独立飞书检索适配服务,在这里填写调用地址。",
},
{
key: "feishu_app_id",
label: "飞书应用 AppID",
type: "text",
defaultValue: "",
placeholder: "填写飞书开放平台应用 AppID",
description: "用于后端直读飞书知识库;系统设置优先于环境变量。",
},
{
key: "feishu_app_secret",
label: "飞书应用 AppSecret",
type: "password",
defaultValue: "",
placeholder: "填写飞书开放平台应用 AppSecret",
description: "用于后端获取飞书 tenant_access_token保存后立即生效。",
},
{
key: "feishu_timeout_seconds",
label: "飞书请求超时(秒)",
type: "number",
defaultValue: 20,
min: 1,
max: 120,
description: "飞书相关接口调用超过该时间应判定为超时。",
},
{
key: "feishu_retry_count",
label: "飞书检索重试次数",
type: "number",
defaultValue: 2,
min: 0,
max: 10,
description: "飞书检索失败时的最大重试次数。",
},
],
},
{
title: "系统开关",
description: "控制系统级展示和运行策略,不包含密钥类配置。",
settings: [
{
key: "show_operation_log_detail",
label: "展示操作日志详情",
type: "switch",
defaultValue: true,
description: "开启后后台审计页可展示更完整的操作上下文。",
},
{
key: "chat_export_enabled",
label: "允许导出聊天记录",
type: "switch",
defaultValue: true,
description: "关闭后后台不应提供聊天记录导出能力。",
},
{
key: "maintenance_mode",
label: "维护模式",
type: "switch",
defaultValue: false,
description: "预留给后续停机维护提示和访问控制使用。",
},
],
},
];
const systemSettingDefinitions = systemSettingSections.flatMap((section) => section.settings);
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 agentForm = reactive({
modelId: undefined as number | undefined,
knowledgeIds: [] as number[],
temperature: 0.2 as number | null,
topP: null as number | null,
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 1024 as number | null,
question: "",
});
onMounted(async () => {
if (!getToken()) return;
try {
admin.value = await api.profile();
await loadCurrentMenu();
} catch {
clearToken();
}
});
async function login() {
loading.value = true;
try {
const result = await api.login(loginForm.username, loginForm.password);
saveToken(result.token);
admin.value = result.admin;
await loadCurrentMenu();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "登录失败");
} finally {
loading.value = false;
}
}
function logout() {
clearToken();
admin.value = null;
}
async function switchMenu(menu: string) {
activeMenu.value = menu;
await loadCurrentMenu();
}
async function loadCurrentMenu() {
loading.value = true;
try {
if (activeMenu.value === "dashboard") stats.value = await api.dashboard();
if (activeMenu.value === "users") users.value = await api.users(userKeyword.value);
if (activeMenu.value === "knowledge") knowledge.value = await api.knowledge();
if (activeMenu.value === "prompt") {
const [prompt, modelRows, knowledgeRows] = await Promise.all([api.prompt(), api.models(), api.knowledge()]);
promptContent.value = prompt.promptContent;
models.value = modelRows;
knowledge.value = knowledgeRows;
if (!agentForm.modelId && modelRows.length) {
agentForm.modelId = modelRows.find((model) => model.enabled === 1)?.id ?? modelRows[0].id;
}
}
if (activeMenu.value === "models") models.value = await api.models();
if (activeMenu.value === "configs") {
configs.value = await api.configs();
hydrateSystemSettings();
}
if (activeMenu.value === "records") {
chats.value = await api.chats(buildChatQuery());
aiLogs.value = await api.aiLogs();
operationLogs.value = await api.operationLogs();
}
} finally {
loading.value = false;
}
}
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: studentForm.expiredAt || null,
});
ElMessage.success("学员已添加");
resetStudentForm();
await loadCurrentMenu();
}
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 savePrompt() {
const result = await api.savePrompt(promptContent.value);
promptContent.value = result.promptContent;
ElMessage.success("Prompt 已保存");
}
async function resetPrompt() {
const result = await api.resetPrompt();
promptContent.value = result.promptContent;
ElMessage.success("Prompt 已恢复默认");
}
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;
}
});
Object.assign(payload, {
temperature: null,
topP: null,
topK: null,
presencePenalty: null,
frequencyPenalty: null,
maxToken: null,
contextWindow: null,
streamEnabled: 1,
responseFormat: null,
extraParams: 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: null,
contextWindow: null,
streamEnabled: 1,
responseFormat: null,
extraParams: null,
remark: "快速添加",
timeoutSecond: 30,
});
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 enableModel(id: number) {
await api.enableModel(id);
ElMessage.success("模型已启用");
await loadCurrentMenu();
}
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: null,
topP: null,
topK: null,
presencePenalty: null,
frequencyPenalty: null,
maxToken: null,
contextWindow: null,
streamEnabled: row.streamEnabled,
responseFormat: row.responseFormat ?? "",
extraParams: row.extraParams ?? "",
remark: row.remark ?? "",
timeoutSecond: row.timeoutSecond,
});
}
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: null,
contextWindow: null,
streamEnabled: 1,
responseFormat: "",
extraParams: "",
remark: "",
timeoutSecond: 30,
});
}
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);
}
function agentMessageParts(content: string) {
const matched = content.match(/<think>([\s\S]*?)<\/think>/i);
if (!matched) return { reasoning: "", answer: content };
const answer = content.replace(matched[0], "").trim();
return {
reasoning: matched[1].trim(),
answer: answer || "模型未返回正式回答。",
};
}
async function debugAgent() {
if (!agentForm.modelId) {
ElMessage.error("请选择调试模型");
return;
}
if (!agentForm.question.trim()) {
ElMessage.error("请输入调试问题");
return;
}
const question = agentForm.question.trim();
agentPreviewMessages.value.push({ role: "user", content: question });
agentForm.question = "";
agentDebugging.value = true;
try {
const result: AgentDebugResult = await api.debugAgent({
promptContent: promptContent.value,
modelId: agentForm.modelId,
knowledgeIds: agentForm.knowledgeIds,
question,
temperature: agentForm.temperature,
topP: agentForm.topP,
topK: agentForm.topK,
presencePenalty: agentForm.presencePenalty,
frequencyPenalty: agentForm.frequencyPenalty,
maxToken: agentForm.maxToken,
});
if (!result.ok) {
ElMessage.error(result.message);
agentPreviewMessages.value.push({ role: "assistant", content: result.message });
return;
}
agentPreviewMessages.value.push({ role: "assistant", content: result.answer });
ElMessage.success(result.message);
} catch (error) {
const message = error instanceof Error ? error.message : "Agent 调试失败";
ElMessage.error(message);
agentPreviewMessages.value.push({ role: "assistant", content: message });
} finally {
agentDebugging.value = false;
}
}
function clearAgentPreview() {
agentPreviewMessages.value = [
{ role: "assistant", content: "预览已清空,可以继续发起新的 Agent 调试。" },
];
}
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() {
chats.value = await api.chats(buildChatQuery());
}
async function resetChatFilters() {
Object.assign(chatFilters, { keyword: "", userId: undefined, status: "", dateFrom: "", dateTo: "" });
await searchChats();
}
async function openChatDetail(row: ChatRecord) {
chatDetail.value = await api.chatDetail(row.id);
chatDetailOpen.value = true;
}
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: chatFilters.dateFrom,
dateTo: chatFilters.dateTo,
};
}
</script>
<template>
<main v-if="!admin" class="login-page">
<section class="login-box">
<div class="brand">AI</div>
<h1>AI 知识库后台</h1>
<p>用于维护用户知识库Agent模型和审计记录</p>
<el-form label-position="top" @submit.prevent="login">
<el-form-item label="管理员账号">
<el-input v-model="loginForm.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="loginForm.password" type="password" show-password />
</el-form-item>
<el-button type="primary" :loading="loading" class="full-btn" @click="login">登录</el-button>
</el-form>
</section>
</main>
<main v-else class="admin-shell">
<aside class="sidebar">
<div class="sidebar-title">AI 知识库</div>
<button :class="{ active: activeMenu === 'dashboard' }" @click="switchMenu('dashboard')">Dashboard</button>
<button :class="{ active: activeMenu === 'users' }" @click="switchMenu('users')">用户管理</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>
</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">
<h2>Dashboard</h2>
<p>核心业务数据概览</p>
</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>
</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="loadCurrentMenu" />
</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" />
<el-date-picker
v-model="studentForm.expiredAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="有效期(可选)"
/>
<div class="form-switch-row">
<span>启用</span>
<el-switch v-model="studentForm.status" :active-value="1" :inactive-value="0" />
</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 prop="lastLoginAt" label="最近登录" width="180" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="saveUser(row)">保存</el-button>
<el-button size="small" type="danger" @click="deleteUser(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="activeMenu === 'knowledge'">
<div class="page-head inline">
<div><h2>知识库管理</h2><p>维护飞书知识库 SpaceID NodeID</p></div>
<el-button type="warning" @click="clearFeishuCache">刷新飞书缓存</el-button>
</div>
<el-form class="knowledge-form" :model="knowledgeForm">
<el-input v-model="knowledgeForm.name" placeholder="知识库名称" />
<el-input v-model="knowledgeForm.feishuSpaceId" placeholder="SpaceID" />
<el-input v-model="knowledgeForm.feishuNodeId" placeholder="NodeID" />
<el-input v-model="knowledgeForm.remark" placeholder="备注" />
<div class="form-switch-row">
<span>启用</span>
<el-switch v-model="knowledgeForm.status" :active-value="1" :inactive-value="0" />
</div>
<div class="form-actions-inline">
<el-button type="primary" @click="saveKnowledge">{{ editingKnowledgeId ? "保存" : "新增" }}</el-button>
<el-button @click="resetKnowledgeForm">清空</el-button>
</div>
</el-form>
<el-divider />
<div class="batch-import-row">
<el-input v-model="batchNodeToken" placeholder="输入 NodeToken如 wikcnXXX自动获取标题与 SpaceID" style="max-width: 400px" />
<el-button type="primary" @click="batchImportKnowledge" :loading="batchImporting">导入</el-button>
</div>
<el-table :data="knowledge" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="feishuSpaceId" label="SpaceID" min-width="180" show-overflow-tooltip />
<el-table-column prop="feishuNodeId" label="NodeID" min-width="180" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="saveKnowledgeRow(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editKnowledge(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteKnowledge(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="activeMenu === 'prompt'">
<div class="page-head inline">
<div><h2>Agent 管理</h2><p>配置提示词模型知识库和调试参数并在右侧实时预览对话效果</p></div>
<el-button @click="resetPrompt">恢复默认提示词</el-button>
</div>
<section class="agent-workbench">
<div class="agent-config-panel">
<el-form label-position="top">
<el-form-item label="Agent 提示词">
<el-input v-model="promptContent" type="textarea" :rows="10" />
</el-form-item>
<div class="agent-config-grid">
<el-form-item label="调试模型">
<el-select v-model="agentForm.modelId" placeholder="选择已接入模型">
<el-option
v-for="model in models"
:key="model.id"
:label="model.displayName || model.modelName"
:value="model.id"
/>
</el-select>
</el-form-item>
<el-form-item label="调试知识库">
<el-select v-model="agentForm.knowledgeIds" multiple collapse-tags placeholder="默认使用全部启用知识库">
<el-option v-for="item in knowledge" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="Temperature">
<el-input-number v-model="agentForm.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="Top P">
<el-input-number v-model="agentForm.topP" :min="0" :max="1" :step="0.05" />
</el-form-item>
<el-form-item label="Top K">
<el-input-number v-model="agentForm.topK" :min="1" :max="1000" />
</el-form-item>
<el-form-item label="Max Tokens">
<el-input-number v-model="agentForm.maxToken" :min="1" :max="100000" />
</el-form-item>
<el-form-item label="Presence Penalty">
<el-input-number v-model="agentForm.presencePenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="Frequency Penalty">
<el-input-number v-model="agentForm.frequencyPenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
</div>
<div class="actions">
<el-button type="primary" @click="savePrompt">保存 Agent 提示词</el-button>
<el-button @click="clearAgentPreview">清空预览</el-button>
</div>
</el-form>
</div>
<aside class="agent-preview-panel">
<div class="agent-preview-head">
<h3>调试预览</h3>
<span>{{ models.find((model) => model.id === agentForm.modelId)?.modelName || "未选择模型" }}</span>
</div>
<div class="agent-preview-chat">
<article
v-for="(message, index) in agentPreviewMessages"
:key="`${message.role}-${index}`"
class="agent-preview-message-row"
:class="message.role"
>
<div class="agent-preview-avatar">{{ message.role === "user" ? "你" : "AI" }}</div>
<div class="agent-preview-bubble">
<strong>{{ message.role === "user" ? "你" : "Agent" }}</strong>
<template v-if="message.role === 'assistant'">
<details v-if="agentMessageParts(message.content).reasoning" class="agent-preview-thinking">
<summary>思考过程</summary>
<pre>{{ agentMessageParts(message.content).reasoning }}</pre>
</details>
<pre>{{ agentMessageParts(message.content).answer }}</pre>
</template>
<pre v-else>{{ message.content }}</pre>
</div>
</article>
<article v-if="agentDebugging" class="agent-preview-message-row assistant thinking">
<div class="agent-preview-avatar">AI</div>
<div class="agent-preview-bubble">
<strong>Agent</strong>
<pre>正在思考中...</pre>
</div>
</article>
</div>
<div class="agent-preview-composer">
<el-input
v-model="agentForm.question"
type="textarea"
:rows="2"
resize="none"
placeholder="向 Agent 发送测试问题"
@keydown.enter.exact.prevent="debugAgent"
/>
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button>
</div>
</aside>
</section>
</template>
<template v-if="activeMenu === 'models'">
<div class="page-head inline">
<div><h2>模型管理</h2><p>DeepSeekMiniMax 可快速添加其他供应商按 OpenAI 兼容或 Anthropic Messages 协议添加</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>
</div>
<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 prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
<el-table-column prop="authType" label="鉴权" width="110" />
<el-table-column prop="enabled" label="启用" width="90" />
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editModel(row)">编辑</el-button>
<el-button size="small" @click="testModel(row.id)">测试</el-button>
<el-button size="small" type="primary" @click="enableModel(row.id)">启用</el-button>
<el-button size="small" type="danger" @click="deleteModel(row.id)">删除</el-button>
</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 === '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>
<el-date-picker
v-model="chatFilters.dateFrom"
class="record-date-item"
type="datetime"
placeholder="开始时间"
value-format="YYYY-MM-DD HH:mm:ss"
/>
<span class="record-date-sep"></span>
<el-date-picker
v-model="chatFilters.dateTo"
class="record-date-item"
type="datetime"
placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
/>
<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 :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>
</el-tab-pane>
<el-tab-pane label="AI 请求" name="aiLogs">
<el-table :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 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>
</el-tab-pane>
<el-tab-pane label="操作日志" name="operationLogs">
<el-table :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>
</el-tab-pane>
</el-tabs>
</template>
</section>
</section>
<el-drawer v-model="chatDetailOpen" size="760px" title="聊天详情">
<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>
<section 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' ? '用户' : 'AI 助手' }}</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>
<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-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>耗时{{ 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>