Implement admin Excel import settings and chat preview

This commit is contained in:
2026-07-07 19:05:52 +08:00
parent d18d9fe9d8
commit cd01e45fbd
6 changed files with 746 additions and 78 deletions

View File

@@ -13,6 +13,7 @@ import type {
DashboardStats,
KnowledgeItem,
ModelItem,
SystemConfigItem,
UserImportResult,
} from "./types/api";
@@ -26,7 +27,7 @@ const users = ref<AdminUser[]>([]);
const userKeyword = ref("");
const knowledge = ref<KnowledgeItem[]>([]);
const models = ref<ModelItem[]>([]);
const configs = ref<Record<string, unknown>[]>([]);
const configs = ref<SystemConfigItem[]>([]);
const chats = ref<ChatRecord[]>([]);
const aiLogs = ref<AiLogRecord[]>([]);
const operationLogs = ref<Record<string, unknown>[]>([]);
@@ -49,7 +50,8 @@ const studentForm = reactive({
dailyChatLimit: 100,
expiredAt: "",
});
const studentImportText = ref("手机号,姓名,昵称\n13800000001,张三,三三\n13800000002,李四,");
const studentImportFile = ref<File | null>(null);
const studentImportInput = ref<HTMLInputElement | null>(null);
const studentImportResult = ref<UserImportResult | null>(null);
const knowledgeForm = reactive({
@@ -166,6 +168,118 @@ const configForm = reactive({
description: "默认每日聊天次数",
});
type SystemSettingValue = string | number | boolean;
type SystemSettingType = "number" | "switch";
interface SystemSettingDefinition {
key: string;
label: string;
type: SystemSettingType;
defaultValue: SystemSettingValue;
min?: number;
max?: number;
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: "login_token_expire_minutes",
label: "登录有效期(分钟)",
type: "number",
defaultValue: 10080,
min: 5,
max: 43200,
description: "用户端和管理端 Token 的建议有效期配置。",
},
],
},
{
title: "AI 问答",
description: "控制 Agent 调用、上下文和用户端回答展示策略。",
settings: [
{
key: "ai_timeout_seconds",
label: "AI 请求超时(秒)",
type: "number",
defaultValue: 60,
min: 5,
max: 300,
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: "后台始终记录引用;此项控制用户端是否展示。",
},
],
},
{
title: "外部服务",
description: "控制飞书检索和模型 mock 开关,方便开发、测试和灰度。",
settings: [
{
key: "feishu_retry_count",
label: "飞书检索重试次数",
type: "number",
defaultValue: 2,
min: 0,
max: 10,
description: "飞书检索失败时的最大重试次数。",
},
{
key: "use_mock_feishu",
label: "使用 mock 飞书检索",
type: "switch",
defaultValue: true,
description: "开发和演示环境可开启,生产环境应关闭。",
},
{
key: "use_mock_model",
label: "使用 mock 大模型",
type: "switch",
defaultValue: true,
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,
@@ -236,7 +350,10 @@ async function loadCurrentMenu() {
}
}
if (activeMenu.value === "models") models.value = await api.models();
if (activeMenu.value === "configs") configs.value = await api.configs();
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();
@@ -271,37 +388,29 @@ async function createStudent() {
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() {
const students = parseStudentImportText(studentImportText.value);
if (!students.length) {
ElMessage.error("请粘贴学员数据");
if (!studentImportFile.value) {
ElMessage.error("请先选择 Excel 文件");
return;
}
studentImportResult.value = await api.importUsers(students);
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 parseStudentImportText(text: string) {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((line, index) => !(index === 0 && line.includes("手机号")))
.map((line) => {
const [phone = "", name = "", nickname = ""] = line.split(/,|\t/).map((item) => item.trim());
return {
phone,
name,
nickname: nickname || null,
status: 1,
dailyChatLimit: 100,
};
});
}
function resetStudentForm() {
Object.assign(studentForm, { phone: "", name: "", nickname: "", status: 1, dailyChatLimit: 100, expiredAt: "" });
}
@@ -591,6 +700,57 @@ async function saveConfig() {
await loadCurrentMenu();
}
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());
}
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("请选择调试模型");
@@ -753,15 +913,20 @@ function buildChatQuery() {
</div>
<div class="student-tool-panel">
<h3>批量导入学员</h3>
<el-input
v-model="studentImportText"
type="textarea"
:rows="5"
resize="none"
placeholder="每行一个学员:手机号,姓名,昵称"
/>
<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 type="primary" @click="importStudents">导入学员</el-button>
<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>
@@ -897,11 +1062,28 @@ function buildChatQuery() {
<article
v-for="(message, index) in agentPreviewMessages"
:key="`${message.role}-${index}`"
class="agent-preview-message"
class="agent-preview-message-row"
:class="message.role"
>
<strong>{{ message.role === "user" ? "用户" : "Agent" }}</strong>
<pre>{{ message.content }}</pre>
<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">
@@ -910,7 +1092,7 @@ function buildChatQuery() {
type="textarea"
:rows="2"
resize="none"
placeholder="输入测试问题"
placeholder="向 Agent 发送测试问题"
@keydown.enter.exact.prevent="debugAgent"
/>
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button>
@@ -1035,13 +1217,43 @@ function buildChatQuery() {
</template>
<template v-if="activeMenu === 'configs'">
<div class="page-head"><h2>系统配置</h2><p>维护运行时配置项</p></div>
<el-form class="inline-form" :model="configForm">
<el-input v-model="configForm.configKey" placeholder="配置 Key" />
<el-input v-model="configForm.configValue" placeholder="配置值" />
<el-input v-model="configForm.description" placeholder="说明" />
<el-button type="primary" @click="saveConfig">保存</el-button>
</el-form>
<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-switch v-else v-model="systemSettingValues[setting.key]" />
<small>{{ setting.description }}</small>
</label>
</div>
</div>
</section>
<div class="advanced-config-panel">
<div class="advanced-config-head">
<h3>高级配置</h3>
<p>用于排查或临时增加未展示在分组表单里的配置项</p>
</div>
<el-form class="inline-form" :model="configForm">
<el-input v-model="configForm.configKey" placeholder="配置 Key" />
<el-input v-model="configForm.configValue" placeholder="配置值" />
<el-input v-model="configForm.description" placeholder="说明" />
<el-button type="primary" @click="saveConfig">保存</el-button>
</el-form>
</div>
<el-table :data="configs" stripe>
<el-table-column prop="configKey" label="Key" />
<el-table-column prop="configValue" label="Value" />