Implement admin Excel import settings and chat preview
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user