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, DashboardStats,
KnowledgeItem, KnowledgeItem,
ModelItem, ModelItem,
SystemConfigItem,
UserImportResult, UserImportResult,
} from "./types/api"; } from "./types/api";
@@ -26,7 +27,7 @@ const users = ref<AdminUser[]>([]);
const userKeyword = ref(""); const userKeyword = ref("");
const knowledge = ref<KnowledgeItem[]>([]); const knowledge = ref<KnowledgeItem[]>([]);
const models = ref<ModelItem[]>([]); const models = ref<ModelItem[]>([]);
const configs = ref<Record<string, unknown>[]>([]); const configs = ref<SystemConfigItem[]>([]);
const chats = ref<ChatRecord[]>([]); const chats = ref<ChatRecord[]>([]);
const aiLogs = ref<AiLogRecord[]>([]); const aiLogs = ref<AiLogRecord[]>([]);
const operationLogs = ref<Record<string, unknown>[]>([]); const operationLogs = ref<Record<string, unknown>[]>([]);
@@ -49,7 +50,8 @@ const studentForm = reactive({
dailyChatLimit: 100, dailyChatLimit: 100,
expiredAt: "", 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 studentImportResult = ref<UserImportResult | null>(null);
const knowledgeForm = reactive({ const knowledgeForm = reactive({
@@ -166,6 +168,118 @@ const configForm = reactive({
description: "默认每日聊天次数", 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({ const chatFilters = reactive({
keyword: "", keyword: "",
userId: undefined as number | undefined, userId: undefined as number | undefined,
@@ -236,7 +350,10 @@ async function loadCurrentMenu() {
} }
} }
if (activeMenu.value === "models") models.value = await api.models(); 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") { if (activeMenu.value === "records") {
chats.value = await api.chats(buildChatQuery()); chats.value = await api.chats(buildChatQuery());
aiLogs.value = await api.aiLogs(); aiLogs.value = await api.aiLogs();
@@ -271,37 +388,29 @@ async function createStudent() {
await loadCurrentMenu(); 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() { async function importStudents() {
const students = parseStudentImportText(studentImportText.value); if (!studentImportFile.value) {
if (!students.length) { ElMessage.error("请先选择 Excel 文件");
ElMessage.error("请粘贴学员数据");
return; return;
} }
studentImportResult.value = await api.importUsers(students); studentImportResult.value = await api.importUsersExcel(studentImportFile.value);
ElMessage.success( ElMessage.success(
`导入完成:新增 ${studentImportResult.value.created},更新 ${studentImportResult.value.updated},失败 ${studentImportResult.value.failed}`, `导入完成:新增 ${studentImportResult.value.created},更新 ${studentImportResult.value.updated},失败 ${studentImportResult.value.failed}`,
); );
studentImportFile.value = null;
if (studentImportInput.value) studentImportInput.value.value = "";
await loadCurrentMenu(); 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() { function resetStudentForm() {
Object.assign(studentForm, { phone: "", name: "", nickname: "", status: 1, dailyChatLimit: 100, expiredAt: "" }); Object.assign(studentForm, { phone: "", name: "", nickname: "", status: 1, dailyChatLimit: 100, expiredAt: "" });
} }
@@ -591,6 +700,57 @@ async function saveConfig() {
await loadCurrentMenu(); 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() { async function debugAgent() {
if (!agentForm.modelId) { if (!agentForm.modelId) {
ElMessage.error("请选择调试模型"); ElMessage.error("请选择调试模型");
@@ -753,15 +913,20 @@ function buildChatQuery() {
</div> </div>
<div class="student-tool-panel"> <div class="student-tool-panel">
<h3>批量导入学员</h3> <h3>批量导入学员</h3>
<el-input <p class="tool-help">下载模板后填写学员信息再上传 .xlsx 文件导入</p>
v-model="studentImportText" <div class="student-file-import">
type="textarea" <input
:rows="5" ref="studentImportInput"
resize="none" class="file-input"
placeholder="每行一个学员:手机号,姓名,昵称" type="file"
/> accept=".xlsx"
@change="handleStudentImportFile"
/>
<span class="file-name">{{ studentImportFile?.name || "未选择文件" }}</span>
</div>
<div class="actions compact"> <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"> <span v-if="studentImportResult" class="import-result">
新增 {{ studentImportResult.created }}更新 {{ studentImportResult.updated }}失败 {{ studentImportResult.failed }} 新增 {{ studentImportResult.created }}更新 {{ studentImportResult.updated }}失败 {{ studentImportResult.failed }}
</span> </span>
@@ -897,11 +1062,28 @@ function buildChatQuery() {
<article <article
v-for="(message, index) in agentPreviewMessages" v-for="(message, index) in agentPreviewMessages"
:key="`${message.role}-${index}`" :key="`${message.role}-${index}`"
class="agent-preview-message" class="agent-preview-message-row"
:class="message.role" :class="message.role"
> >
<strong>{{ message.role === "user" ? "用户" : "Agent" }}</strong> <div class="agent-preview-avatar">{{ message.role === "user" ? "" : "AI" }}</div>
<pre>{{ message.content }}</pre> <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> </article>
</div> </div>
<div class="agent-preview-composer"> <div class="agent-preview-composer">
@@ -910,7 +1092,7 @@ function buildChatQuery() {
type="textarea" type="textarea"
:rows="2" :rows="2"
resize="none" resize="none"
placeholder="输入测试问题" placeholder="向 Agent 发送测试问题"
@keydown.enter.exact.prevent="debugAgent" @keydown.enter.exact.prevent="debugAgent"
/> />
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button> <el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button>
@@ -1035,13 +1217,43 @@ function buildChatQuery() {
</template> </template>
<template v-if="activeMenu === 'configs'"> <template v-if="activeMenu === 'configs'">
<div class="page-head"><h2>系统配置</h2><p>维护运行时配置项</p></div> <div class="page-head inline">
<el-form class="inline-form" :model="configForm"> <div><h2>系统配置</h2><p>按业务分组维护运行时配置默认每日额度已接入学员新增和导入</p></div>
<el-input v-model="configForm.configKey" placeholder="配置 Key" /> <el-button type="primary" @click="saveSystemSettings">保存系统配置</el-button>
<el-input v-model="configForm.configValue" placeholder="配置值" /> </div>
<el-input v-model="configForm.description" placeholder="说明" /> <section class="settings-grid">
<el-button type="primary" @click="saveConfig">保存</el-button> <div v-for="section in systemSettingSections" :key="section.title" class="settings-section">
</el-form> <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 :data="configs" stripe>
<el-table-column prop="configKey" label="Key" /> <el-table-column prop="configKey" label="Key" />
<el-table-column prop="configValue" label="Value" /> <el-table-column prop="configValue" label="Value" />

View File

@@ -10,6 +10,7 @@ import type {
DashboardStats, DashboardStats,
KnowledgeItem, KnowledgeItem,
ModelItem, ModelItem,
SystemConfigItem,
UserImportResult, UserImportResult,
} from "../types/api"; } from "../types/api";
@@ -56,6 +57,18 @@ async function download(path: string, filename: string) {
window.URL.revokeObjectURL(url); 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) { function queryString(query: object) {
const params = new URLSearchParams(); const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => { Object.entries(query).forEach(([key, value]) => {
@@ -78,6 +91,12 @@ export const api = {
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }), request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
importUsers: (students: Record<string, unknown>[]) => importUsers: (students: Record<string, unknown>[]) =>
request<UserImportResult>("/admin/user/import", { method: "POST", body: JSON.stringify({ students }) }), 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>) => updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }), request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }), deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
@@ -107,9 +126,9 @@ export const api = {
method: "POST", method: "POST",
body: "{}", body: "{}",
}), }),
configs: () => request<Record<string, unknown>[]>("/admin/config"), configs: () => request<SystemConfigItem[]>("/admin/config"),
saveConfig: (payload: Record<string, unknown>) => 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)}`), chats: (query: ChatRecordQuery = {}) => request<ChatRecord[]>(`/admin/chat/list${queryString(query)}`),
chatDetail: (sessionId: number) => request<ChatDetail>(`/admin/chat/${sessionId}`), chatDetail: (sessionId: number) => request<ChatDetail>(`/admin/chat/${sessionId}`),
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"), exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),

View File

@@ -204,6 +204,13 @@ textarea {
font-size: 16px; font-size: 16px;
} }
.tool-help {
margin: -4px 0 12px;
color: #667a73;
font-size: 13px;
line-height: 1.6;
}
.student-form-grid { .student-form-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -224,6 +231,30 @@ textarea {
align-items: center; 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 { .import-result {
color: #40524b; color: #40524b;
font-size: 13px; font-size: 13px;
@@ -334,9 +365,11 @@ textarea {
} }
.agent-preview-panel { .agent-preview-panel {
height: clamp(520px, calc(100vh - 170px), 680px); height: clamp(560px, calc(100vh - 170px), 760px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0;
overflow: hidden;
} }
.agent-config-grid { .agent-config-grid {
@@ -356,7 +389,9 @@ textarea {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
margin-bottom: 12px; padding: 16px 18px;
border-bottom: 1px solid #e4ece9;
background: #ffffff;
} }
.agent-preview-head h3 { .agent-preview-head h3 {
@@ -378,37 +413,95 @@ textarea {
overflow: auto; overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 18px;
padding-right: 4px; padding: 18px;
background: #f6f8f7;
} }
.agent-preview-message { .agent-preview-message-row {
max-width: 92%; display: grid;
padding: 10px 12px; 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: 1px solid #dfe8e5;
border-radius: 8px; border-radius: 8px;
background: #f8fbfa; background: #ffffff;
box-shadow: 0 8px 24px rgba(21, 43, 36, 0.05);
} }
.agent-preview-message.user { .agent-preview-message-row.user .agent-preview-bubble {
align-self: flex-end; grid-column: 1;
grid-row: 1;
border-color: #cde7df; border-color: #cde7df;
background: #e9f5f1; background: #e9f5f1;
} }
.agent-preview-message strong { .agent-preview-message-row.thinking .agent-preview-bubble {
color: #667a73;
}
.agent-preview-bubble strong {
display: block; display: block;
margin-bottom: 6px; margin-bottom: 6px;
color: #0f735d; color: #0f735d;
font-size: 12px; font-size: 12px;
} }
.agent-preview-message pre { .agent-preview-bubble pre {
margin: 0; margin: 0;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
font-family: inherit; 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 { .agent-preview-composer {
@@ -416,9 +509,9 @@ textarea {
grid-template-columns: minmax(0, 1fr) 76px; grid-template-columns: minmax(0, 1fr) 76px;
gap: 10px; gap: 10px;
align-items: end; align-items: end;
margin-top: 12px; padding: 14px 18px 16px;
padding-top: 12px;
border-top: 1px solid #e4ece9; border-top: 1px solid #e4ece9;
background: #ffffff;
} }
.agent-preview-composer .el-button { .agent-preview-composer .el-button {
@@ -429,6 +522,68 @@ textarea {
margin-top: 12px; 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 { .el-drawer .el-date-editor.el-input {
width: 100%; width: 100%;
} }
@@ -563,3 +718,96 @@ textarea {
.error-text { .error-text {
color: #b42318; 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[]; failures: UserImportFailure[];
} }
export interface SystemConfigItem {
id: number;
configKey: string;
configValue: string;
description?: string | null;
updatedAt?: string | null;
}
export interface KnowledgeItem { export interface KnowledgeItem {
id: number; id: number;
name: string; name: string;

View File

@@ -1,7 +1,13 @@
from __future__ import annotations from __future__ import annotations
import re import re
from fastapi import APIRouter, Depends, HTTPException, Query, status from datetime import date, datetime
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from pydantic import ValidationError
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -9,12 +15,15 @@ from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.models.ai_config import SystemConfig
from app.models.user import User from app.models.user import User
from app.schemas.admin import AdminUserCreateRequest, AdminUserImportRequest, AdminUserUpdateRequest from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
router = APIRouter() router = APIRouter()
STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度", "状态", "有效期"]
@router.get("/user/list") @router.get("/user/list")
def list_users( def list_users(
@@ -49,7 +58,7 @@ def create_user(
else: else:
user = User(phone=phone, daily_chat_used=0) user = User(phone=phone, daily_chat_used=0)
_apply_user_payload(user, payload) _apply_user_payload(user, payload, _default_daily_chat_limit(db))
db.add(user) db.add(user)
db.flush() db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id) OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id)
@@ -67,12 +76,104 @@ def import_users(
if not payload.students: if not payload.students:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请提供学员数据") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请提供学员数据")
return api_success(_import_user_items(list(enumerate(payload.students, start=1)), db, current_admin))
@router.get("/user/import/template")
def download_user_import_template(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
workbook = Workbook()
sheet = workbook.active
sheet.title = "学员导入模板"
sheet.append(STUDENT_TEMPLATE_HEADERS)
sheet.append(["13800000001", "张三", "三三", _default_daily_chat_limit(db), "启用", "2026-12-31 23:59:59"])
sheet.append(["13800000002", "李四", "", "", "启用", ""])
widths = [18, 14, 14, 16, 12, 22]
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[chr(64 + index)].width = width
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="student_import_template.xlsx"'},
)
@router.post("/user/import/excel")
def import_users_excel(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
filename = (file.filename or "").lower()
if not filename.endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式的 Excel 文件")
content = file.file.read()
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件为空")
try:
workbook = load_workbook(BytesIO(content), data_only=True)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件解析失败") from exc
sheet = workbook.active
header_map = _excel_header_map(next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), ()))
for required in ("手机号", "姓名"):
if required not in header_map:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"模板缺少必填列:{required}")
students: list[tuple[int, AdminUserImportItem]] = []
parse_failures: list[dict] = []
for row_number, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
if not any(_cell_text(cell) for cell in row):
continue
try:
item = AdminUserImportItem(
phone=_cell_text(_excel_value(row, header_map, "手机号")),
name=_cell_text(_excel_value(row, header_map, "姓名")),
nickname=_cell_text(_excel_value(row, header_map, "昵称")) or None,
dailyChatLimit=_parse_optional_int(_excel_value(row, header_map, "每日聊天额度")),
status=_parse_status(_excel_value(row, header_map, "状态")),
expiredAt=_parse_optional_datetime(_excel_value(row, header_map, "有效期")),
)
students.append((row_number, item))
except (ValueError, ValidationError) as exc:
parse_failures.append(
{
"row": row_number,
"phone": _cell_text(_excel_value(row, header_map, "手机号")),
"reason": _row_error_message(exc),
}
)
if not students and not parse_failures:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 中没有可导入的学员数据")
result = _import_user_items(students, db, current_admin, parse_failures)
return api_success(result)
def _import_user_items(
students: list[tuple[int, AdminUserImportItem]],
db: Session,
current_admin: Admin,
initial_failures: list[dict] | None = None,
) -> dict:
created = 0 created = 0
updated = 0 updated = 0
failed: list[dict] = [] failed: list[dict] = list(initial_failures or [])
seen: set[str] = set() seen: set[str] = set()
default_daily_limit = _default_daily_chat_limit(db)
for index, item in enumerate(payload.students, start=1): for row_number, item in students:
phone = _normalize_phone(item.phone) phone = _normalize_phone(item.phone)
name = item.name.strip() name = item.name.strip()
try: try:
@@ -90,10 +191,10 @@ def import_users(
else: else:
user.is_deleted = 0 user.is_deleted = 0
updated += 1 updated += 1
_apply_user_payload(user, item) _apply_user_payload(user, item, default_daily_limit)
db.add(user) db.add(user)
except ValueError as exc: except ValueError as exc:
failed.append({"row": index, "phone": item.phone, "reason": str(exc)}) failed.append({"row": row_number, "phone": item.phone, "reason": str(exc)})
if created or updated: if created or updated:
db.flush() db.flush()
@@ -107,7 +208,7 @@ def import_users(
) )
db.commit() db.commit()
return api_success({"created": created, "updated": updated, "failed": len(failed), "failures": failed}) return {"created": created, "updated": updated, "failed": len(failed), "failures": failed}
@router.get("/user/{user_id}") @router.get("/user/{user_id}")
@@ -179,21 +280,99 @@ def _user_dict(user: User) -> dict:
} }
def _apply_user_payload(user: User, payload: AdminUserCreateRequest) -> None: def _apply_user_payload(
settings_limit = 100 user: User,
try: payload: AdminUserCreateRequest | AdminUserImportItem,
from app.core.config import get_settings default_daily_limit: int,
) -> None:
settings_limit = get_settings().default_daily_chat_limit
except Exception:
pass
user.name = payload.name.strip() user.name = payload.name.strip()
user.nickname = payload.nickname.strip() if payload.nickname else None user.nickname = payload.nickname.strip() if payload.nickname else None
user.status = payload.status user.status = payload.status
user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else settings_limit user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else default_daily_limit
user.expired_at = payload.expiredAt.replace(tzinfo=None) if payload.expiredAt is not None else None user.expired_at = payload.expiredAt.replace(tzinfo=None) if payload.expiredAt is not None else None
def _default_daily_chat_limit(db: Session) -> int:
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == "daily_chat_limit"))
if config is not None and config.config_value.strip():
try:
return max(0, min(100000, int(float(config.config_value.strip()))))
except ValueError:
pass
try:
from app.core.config import get_settings
return get_settings().default_daily_chat_limit
except Exception:
return 100
def _excel_header_map(headers: tuple) -> dict[str, int]:
return {_cell_text(header): index for index, header in enumerate(headers) if _cell_text(header)}
def _excel_value(row: tuple, header_map: dict[str, int], header: str) -> object | None:
index = header_map.get(header)
if index is None or index >= len(row):
return None
return row[index]
def _cell_text(value: object | None) -> str:
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def _parse_optional_int(value: object | None) -> int | None:
text = _cell_text(value)
if not text:
return None
try:
parsed = int(float(text))
except ValueError as exc:
raise ValueError("每日聊天额度必须是数字") from exc
if parsed < 0 or parsed > 100000:
raise ValueError("每日聊天额度必须在 0 到 100000 之间")
return parsed
def _parse_status(value: object | None) -> int:
text = _cell_text(value).lower()
if not text:
return 1
if text in {"1", "启用", "正常", "", "true", "enabled"}:
return 1
if text in {"0", "禁用", "停用", "", "false", "disabled"}:
return 0
raise ValueError("状态只能填写启用/禁用或 1/0")
def _parse_optional_datetime(value: object | None) -> datetime | None:
if value is None or _cell_text(value) == "":
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
text = _cell_text(value)
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
raise ValueError("有效期格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss")
def _row_error_message(exc: ValueError | ValidationError) -> str:
if isinstance(exc, ValidationError):
return "字段格式不正确"
return str(exc)
def _normalize_phone(phone: str) -> str: def _normalize_phone(phone: str) -> str:
return re.sub(r"\D", "", phone or "") return re.sub(r"\D", "", phone or "")

View File

@@ -9,3 +9,5 @@ PyJWT>=2.10.0
PyMySQL>=1.1.1 PyMySQL>=1.1.1
cryptography>=45.0.0 cryptography>=45.0.0
httpx>=0.28.0 httpx>=0.28.0
openpyxl>=3.1.5
python-multipart>=0.0.20