1685 lines
63 KiB
Vue
1685 lines
63 KiB
Vue
<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: "aliyun_sms_access_key_id",
|
||
label: "阿里云 AccessKey ID",
|
||
type: "password",
|
||
defaultValue: "",
|
||
placeholder: "请输入 RAM 用户 AccessKey ID",
|
||
description: "关闭 mock 短信后用于调用阿里云短信服务。",
|
||
},
|
||
{
|
||
key: "aliyun_sms_access_key_secret",
|
||
label: "阿里云 AccessKey Secret",
|
||
type: "password",
|
||
defaultValue: "",
|
||
placeholder: "请输入 RAM 用户 AccessKey Secret",
|
||
description: "敏感配置,建议使用最小权限 RAM 用户。",
|
||
},
|
||
{
|
||
key: "aliyun_sms_sign_name",
|
||
label: "阿里云短信签名",
|
||
type: "text",
|
||
defaultValue: "",
|
||
placeholder: "例如 某某课堂",
|
||
description: "需与阿里云短信控制台审核通过的签名一致。",
|
||
},
|
||
{
|
||
key: "aliyun_sms_template_code",
|
||
label: "阿里云模板 Code",
|
||
type: "text",
|
||
defaultValue: "",
|
||
placeholder: "例如 SMS_123456789",
|
||
description: "验证码短信模板 Code。",
|
||
},
|
||
{
|
||
key: "aliyun_sms_template_param_key",
|
||
label: "验证码变量名",
|
||
type: "text",
|
||
defaultValue: "code",
|
||
placeholder: "默认 code",
|
||
description: "模板中验证码变量名,例如模板变量为 ${code} 时填写 code。",
|
||
},
|
||
{
|
||
key: "aliyun_sms_endpoint",
|
||
label: "阿里云短信 Endpoint",
|
||
type: "text",
|
||
defaultValue: "dysmsapi.aliyuncs.com",
|
||
placeholder: "dysmsapi.aliyuncs.com",
|
||
description: "一般保持默认值即可。",
|
||
},
|
||
{
|
||
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-hero">
|
||
<div class="login-hero-main">
|
||
<div class="login-brand">
|
||
<span>AI</span>
|
||
<strong>知识库管理后台</strong>
|
||
</div>
|
||
<h1>让知识库、Agent 和用户运营保持在同一个控制台里。</h1>
|
||
<p>统一维护学员、知识库、模型配置、问答记录和系统运行参数,方便排查与交接。</p>
|
||
<div class="login-highlights">
|
||
<span>知识库运营</span>
|
||
<span>Agent 调试</span>
|
||
<span>记录审计</span>
|
||
</div>
|
||
</div>
|
||
<section class="login-card">
|
||
<div class="login-card-head">
|
||
<h2>管理员登录</h2>
|
||
<p>请输入后台管理员账号和密码。</p>
|
||
</div>
|
||
<el-form label-position="top" @submit.prevent="login">
|
||
<el-form-item label="管理员账号">
|
||
<el-input v-model="loginForm.username" size="large" placeholder="请输入管理员账号" />
|
||
</el-form-item>
|
||
<el-form-item label="密码">
|
||
<el-input
|
||
v-model="loginForm.password"
|
||
size="large"
|
||
type="password"
|
||
placeholder="请输入密码"
|
||
show-password
|
||
/>
|
||
</el-form-item>
|
||
<el-button type="primary" :loading="loading" class="full-btn login-submit" @click="login">登录后台</el-button>
|
||
</el-form>
|
||
</section>
|
||
</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>DeepSeek、MiniMax 可快速添加;其他供应商按 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>
|