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