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" />

View File

@@ -10,6 +10,7 @@ import type {
DashboardStats,
KnowledgeItem,
ModelItem,
SystemConfigItem,
UserImportResult,
} from "../types/api";
@@ -56,6 +57,18 @@ async function download(path: string, filename: string) {
window.URL.revokeObjectURL(url);
}
async function upload<T>(path: string, formData: FormData): Promise<T> {
const headers = new Headers();
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE}${path}`, { method: "POST", body: formData, headers });
const body = (await response.json()) as ApiResponse<T> & { detail?: string };
if (!response.ok || body.code !== 0) {
throw new Error(body.message || body.detail || `${response.status} ${response.statusText}`);
}
return body.data;
}
function queryString(query: object) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
@@ -78,6 +91,12 @@ export const api = {
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
importUsers: (students: Record<string, unknown>[]) =>
request<UserImportResult>("/admin/user/import", { method: "POST", body: JSON.stringify({ students }) }),
downloadStudentTemplate: () => download("/admin/user/import/template", "学员导入模板.xlsx"),
importUsersExcel: (file: File) => {
const formData = new FormData();
formData.append("file", file);
return upload<UserImportResult>("/admin/user/import/excel", formData);
},
updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
@@ -107,9 +126,9 @@ export const api = {
method: "POST",
body: "{}",
}),
configs: () => request<Record<string, unknown>[]>("/admin/config"),
configs: () => request<SystemConfigItem[]>("/admin/config"),
saveConfig: (payload: Record<string, unknown>) =>
request<Record<string, unknown>>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
request<SystemConfigItem>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
chats: (query: ChatRecordQuery = {}) => request<ChatRecord[]>(`/admin/chat/list${queryString(query)}`),
chatDetail: (sessionId: number) => request<ChatDetail>(`/admin/chat/${sessionId}`),
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),

View File

@@ -204,6 +204,13 @@ textarea {
font-size: 16px;
}
.tool-help {
margin: -4px 0 12px;
color: #667a73;
font-size: 13px;
line-height: 1.6;
}
.student-form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -224,6 +231,30 @@ textarea {
align-items: center;
}
.student-file-import {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.file-input {
width: 100%;
padding: 10px;
border: 1px dashed #b9c9c4;
border-radius: 8px;
background: #f8fbfa;
color: #40524b;
}
.file-name {
overflow: hidden;
color: #40524b;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.import-result {
color: #40524b;
font-size: 13px;
@@ -334,9 +365,11 @@ textarea {
}
.agent-preview-panel {
height: clamp(520px, calc(100vh - 170px), 680px);
height: clamp(560px, calc(100vh - 170px), 760px);
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.agent-config-grid {
@@ -356,7 +389,9 @@ textarea {
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
padding: 16px 18px;
border-bottom: 1px solid #e4ece9;
background: #ffffff;
}
.agent-preview-head h3 {
@@ -378,37 +413,95 @@ textarea {
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 4px;
gap: 18px;
padding: 18px;
background: #f6f8f7;
}
.agent-preview-message {
max-width: 92%;
padding: 10px 12px;
.agent-preview-message-row {
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
gap: 10px;
align-items: start;
max-width: 96%;
}
.agent-preview-message-row.user {
align-self: flex-end;
grid-template-columns: minmax(0, 1fr) 32px;
}
.agent-preview-message-row.user .agent-preview-avatar {
grid-column: 2;
grid-row: 1;
background: #0f735d;
color: #ffffff;
}
.agent-preview-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
background: #e0ebe7;
color: #0f735d;
font-size: 12px;
font-weight: 700;
}
.agent-preview-bubble {
min-width: 0;
padding: 12px 14px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #f8fbfa;
background: #ffffff;
box-shadow: 0 8px 24px rgba(21, 43, 36, 0.05);
}
.agent-preview-message.user {
align-self: flex-end;
.agent-preview-message-row.user .agent-preview-bubble {
grid-column: 1;
grid-row: 1;
border-color: #cde7df;
background: #e9f5f1;
}
.agent-preview-message strong {
.agent-preview-message-row.thinking .agent-preview-bubble {
color: #667a73;
}
.agent-preview-bubble strong {
display: block;
margin-bottom: 6px;
color: #0f735d;
font-size: 12px;
}
.agent-preview-message pre {
.agent-preview-bubble pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
line-height: 1.6;
line-height: 1.75;
}
.agent-preview-thinking {
margin-bottom: 10px;
padding: 9px 10px;
border-radius: 8px;
background: #f3f6f5;
color: #5f6f69;
}
.agent-preview-thinking summary {
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.agent-preview-thinking pre {
margin-top: 8px;
font-size: 13px;
}
.agent-preview-composer {
@@ -416,9 +509,9 @@ textarea {
grid-template-columns: minmax(0, 1fr) 76px;
gap: 10px;
align-items: end;
margin-top: 12px;
padding-top: 12px;
padding: 14px 18px 16px;
border-top: 1px solid #e4ece9;
background: #ffffff;
}
.agent-preview-composer .el-button {
@@ -429,6 +522,68 @@ textarea {
margin-top: 12px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.settings-section,
.advanced-config-panel {
padding: 18px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #ffffff;
}
.settings-section-head h3,
.advanced-config-head h3 {
margin: 0;
font-size: 17px;
}
.settings-section-head p,
.advanced-config-head p {
margin: 6px 0 0;
color: #667a73;
font-size: 13px;
line-height: 1.6;
}
.settings-fields {
display: grid;
gap: 14px;
margin-top: 16px;
}
.setting-field {
display: grid;
gap: 7px;
}
.setting-field span {
color: #223631;
font-weight: 600;
}
.setting-field small {
color: #667a73;
line-height: 1.5;
}
.setting-field .el-input-number {
width: 100%;
}
.advanced-config-panel {
margin-bottom: 16px;
}
.advanced-config-head {
margin-bottom: 12px;
}
.el-drawer .el-date-editor.el-input {
width: 100%;
}
@@ -563,3 +718,96 @@ textarea {
.error-text {
color: #b42318;
}
@media (max-width: 1120px) {
.student-tools,
.agent-workbench,
.settings-grid {
grid-template-columns: 1fr;
}
.agent-preview-panel {
height: 620px;
}
}
@media (max-width: 860px) {
body {
min-width: 0;
}
.login-box {
width: min(380px, calc(100vw - 32px));
padding: 24px;
}
.admin-shell {
display: block;
}
.sidebar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
gap: 6px;
align-items: center;
overflow-x: auto;
padding: 10px 12px;
border-right: 0;
border-bottom: 1px solid #dfe8e5;
}
.sidebar-title {
flex: 0 0 auto;
padding: 0 8px 0 0;
}
.sidebar button {
flex: 0 0 auto;
width: auto;
margin-bottom: 0;
white-space: nowrap;
}
.workspace {
display: block;
}
.topbar {
padding: 12px 16px;
}
.content {
padding: 16px;
}
.page-head.inline {
flex-direction: column;
align-items: stretch;
}
.page-head.inline .el-input {
width: 100%;
}
.stats-grid,
.chat-summary {
grid-template-columns: 1fr;
}
.student-form-grid,
.knowledge-form,
.model-config-grid,
.quick-model-grid,
.agent-config-grid,
.inline-form,
.model-form {
grid-template-columns: 1fr;
}
.agent-preview-message-row,
.agent-preview-message-row.user {
max-width: 100%;
}
}

View File

@@ -45,6 +45,14 @@ export interface UserImportResult {
failures: UserImportFailure[];
}
export interface SystemConfigItem {
id: number;
configKey: string;
configValue: string;
description?: string | null;
updatedAt?: string | null;
}
export interface KnowledgeItem {
id: number;
name: string;