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>
|
||||
<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" />
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.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.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.models.ai_config import SystemConfig
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度", "状态", "有效期"]
|
||||
|
||||
|
||||
@router.get("/user/list")
|
||||
def list_users(
|
||||
@@ -49,7 +58,7 @@ def create_user(
|
||||
else:
|
||||
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.flush()
|
||||
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:
|
||||
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
|
||||
updated = 0
|
||||
failed: list[dict] = []
|
||||
failed: list[dict] = list(initial_failures or [])
|
||||
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)
|
||||
name = item.name.strip()
|
||||
try:
|
||||
@@ -90,10 +191,10 @@ def import_users(
|
||||
else:
|
||||
user.is_deleted = 0
|
||||
updated += 1
|
||||
_apply_user_payload(user, item)
|
||||
_apply_user_payload(user, item, default_daily_limit)
|
||||
db.add(user)
|
||||
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:
|
||||
db.flush()
|
||||
@@ -107,7 +208,7 @@ def import_users(
|
||||
)
|
||||
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}")
|
||||
@@ -179,21 +280,99 @@ def _user_dict(user: User) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _apply_user_payload(user: User, payload: AdminUserCreateRequest) -> None:
|
||||
settings_limit = 100
|
||||
try:
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings_limit = get_settings().default_daily_chat_limit
|
||||
except Exception:
|
||||
pass
|
||||
def _apply_user_payload(
|
||||
user: User,
|
||||
payload: AdminUserCreateRequest | AdminUserImportItem,
|
||||
default_daily_limit: int,
|
||||
) -> None:
|
||||
user.name = payload.name.strip()
|
||||
user.nickname = payload.nickname.strip() if payload.nickname else None
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
return re.sub(r"\D", "", phone or "")
|
||||
|
||||
|
||||
@@ -9,3 +9,5 @@ PyJWT>=2.10.0
|
||||
PyMySQL>=1.1.1
|
||||
cryptography>=45.0.0
|
||||
httpx>=0.28.0
|
||||
openpyxl>=3.1.5
|
||||
python-multipart>=0.0.20
|
||||
|
||||
Reference in New Issue
Block a user