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