feat: add admin permissions voice input analytics and feedback
This commit is contained in:
@@ -47,3 +47,5 @@ FRP_LOCAL_IP=gateway
|
|||||||
FRP_LOCAL_PORT=80
|
FRP_LOCAL_PORT=80
|
||||||
# 自定义域名,逗号分隔,如:qa.huiyushuyuan.cn
|
# 自定义域名,逗号分隔,如:qa.huiyushuyuan.cn
|
||||||
FRP_CUSTOM_DOMAINS=qa.huiyushuyuan.cn
|
FRP_CUSTOM_DOMAINS=qa.huiyushuyuan.cn
|
||||||
|
ALIYUN_NLS_APP_KEY=
|
||||||
|
ALIYUN_NLS_ENDPOINT=https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.11.0",
|
"element-plus": "^2.11.0",
|
||||||
"markdown-it": "^14.3.0",
|
"markdown-it": "^14.3.0",
|
||||||
"vite": "^7.1.0",
|
"vite": "^7.1.0",
|
||||||
@@ -1155,6 +1156,16 @@
|
|||||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/echarts": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/element-plus": {
|
"node_modules/element-plus": {
|
||||||
"version": "2.14.2",
|
"version": "2.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz",
|
||||||
@@ -1535,6 +1546,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -1679,6 +1696,15 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zrender": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.11.0",
|
"element-plus": "^2.11.0",
|
||||||
"markdown-it": "^14.3.0",
|
"markdown-it": "^14.3.0",
|
||||||
"vite": "^7.1.0",
|
"vite": "^7.1.0",
|
||||||
|
|||||||
@@ -49,10 +49,26 @@ const SsoIntegrationView = defineAsyncComponent(
|
|||||||
const SystemConfigView = defineAsyncComponent(
|
const SystemConfigView = defineAsyncComponent(
|
||||||
() => import("./components/SystemConfigView.vue"),
|
() => import("./components/SystemConfigView.vue"),
|
||||||
);
|
);
|
||||||
|
const AdminManagementView = defineAsyncComponent(
|
||||||
|
() => import("./components/AdminManagementView.vue"),
|
||||||
|
);
|
||||||
|
const FeedbackManagementView = defineAsyncComponent(() => import("./components/FeedbackManagementView.vue"));
|
||||||
|
|
||||||
const admin = ref<AdminProfile | null>(null);
|
const admin = ref<AdminProfile | null>(null);
|
||||||
const activeMenu = ref("dashboard");
|
const activeMenu = ref("dashboard");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const passwordDialogVisible = ref(false);
|
||||||
|
const passwordSaving = ref(false);
|
||||||
|
const passwordForm = reactive({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||||
|
|
||||||
|
const can = (permission: string) => Boolean(admin.value?.isSuperAdmin || admin.value?.permissions.includes(permission));
|
||||||
|
const menuPermissions: Record<string, string> = {
|
||||||
|
dashboard: "dashboard.view", users: "users.view", entitlements: "entitlements.view",
|
||||||
|
knowledge: "knowledge.view", prompt: "prompt.view", models: "models.view",
|
||||||
|
"content-generation": "content-generation.view", configs: "configs.view", sso: "sso.view",
|
||||||
|
records: "records.view", retrievals: "retrievals.view", attention: "attention.view", admins: "admins.view",
|
||||||
|
feedback: "feedback.view",
|
||||||
|
};
|
||||||
|
|
||||||
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
||||||
const models = ref<ModelItem[]>([]);
|
const models = ref<ModelItem[]>([]);
|
||||||
@@ -190,7 +206,8 @@ onMounted(async () => {
|
|||||||
if (!getToken()) return;
|
if (!getToken()) return;
|
||||||
try {
|
try {
|
||||||
admin.value = await api.profile();
|
admin.value = await api.profile();
|
||||||
await loadCurrentMenu();
|
passwordDialogVisible.value = admin.value.mustChangePassword;
|
||||||
|
if (!admin.value.mustChangePassword) await enterFirstAvailableMenu();
|
||||||
} catch {
|
} catch {
|
||||||
clearToken();
|
clearToken();
|
||||||
}
|
}
|
||||||
@@ -202,7 +219,8 @@ async function login(credentials: { username: string; password: string }) {
|
|||||||
const result = await api.login(credentials.username, credentials.password);
|
const result = await api.login(credentials.username, credentials.password);
|
||||||
saveToken(result.token);
|
saveToken(result.token);
|
||||||
admin.value = result.admin;
|
admin.value = result.admin;
|
||||||
await loadCurrentMenu();
|
passwordDialogVisible.value = result.admin.mustChangePassword;
|
||||||
|
if (!result.admin.mustChangePassword) await enterFirstAvailableMenu();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : "登录失败");
|
ElMessage.error(error instanceof Error ? error.message : "登录失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -220,10 +238,32 @@ async function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function switchMenu(menu: string) {
|
async function switchMenu(menu: string) {
|
||||||
|
if (!can(menuPermissions[menu])) return;
|
||||||
activeMenu.value = menu;
|
activeMenu.value = menu;
|
||||||
await loadCurrentMenu();
|
await loadCurrentMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enterFirstAvailableMenu() {
|
||||||
|
const first = Object.keys(menuPermissions).find((menu) => can(menuPermissions[menu]));
|
||||||
|
activeMenu.value = first ?? "dashboard";
|
||||||
|
if (first) await loadCurrentMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeInitialPassword() {
|
||||||
|
if (passwordForm.newPassword.length < 8) return ElMessage.warning("新密码至少 8 位");
|
||||||
|
if (passwordForm.newPassword !== passwordForm.confirmPassword) return ElMessage.warning("两次输入的新密码不一致");
|
||||||
|
passwordSaving.value = true;
|
||||||
|
try {
|
||||||
|
await api.changeAdminPassword({ currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword });
|
||||||
|
if (admin.value) admin.value.mustChangePassword = false;
|
||||||
|
passwordDialogVisible.value = false;
|
||||||
|
Object.assign(passwordForm, { currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||||
|
ElMessage.success("密码已修改,请使用新密码登录");
|
||||||
|
await logout();
|
||||||
|
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "密码修改失败"); }
|
||||||
|
finally { passwordSaving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
async function previewKnowledge(knowledgeId: number) {
|
async function previewKnowledge(knowledgeId: number) {
|
||||||
previewKnowledgeId.value = knowledgeId;
|
previewKnowledgeId.value = knowledgeId;
|
||||||
await switchMenu("prompt");
|
await switchMenu("prompt");
|
||||||
@@ -596,77 +636,99 @@ async function clearFeishuCache() {
|
|||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-title">大本营千问千答</div>
|
<div class="sidebar-title">大本营千问千答</div>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('dashboard.view')"
|
||||||
:class="{ active: activeMenu === 'dashboard' }"
|
:class="{ active: activeMenu === 'dashboard' }"
|
||||||
@click="switchMenu('dashboard')"
|
@click="switchMenu('dashboard')"
|
||||||
>
|
>
|
||||||
数据看板
|
数据看板
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('users.view')"
|
||||||
:class="{ active: activeMenu === 'users' }"
|
:class="{ active: activeMenu === 'users' }"
|
||||||
@click="switchMenu('users')"
|
@click="switchMenu('users')"
|
||||||
>
|
>
|
||||||
用户管理
|
用户管理
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('entitlements.view')"
|
||||||
:class="{ active: activeMenu === 'entitlements' }"
|
:class="{ active: activeMenu === 'entitlements' }"
|
||||||
@click="switchMenu('entitlements')"
|
@click="switchMenu('entitlements')"
|
||||||
>
|
>
|
||||||
权益管理
|
权益管理
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('knowledge.view')"
|
||||||
:class="{ active: activeMenu === 'knowledge' }"
|
:class="{ active: activeMenu === 'knowledge' }"
|
||||||
@click="switchMenu('knowledge')"
|
@click="switchMenu('knowledge')"
|
||||||
>
|
>
|
||||||
知识库管理
|
知识库管理
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('prompt.view')"
|
||||||
:class="{ active: activeMenu === 'prompt' }"
|
:class="{ active: activeMenu === 'prompt' }"
|
||||||
@click="switchMenu('prompt')"
|
@click="switchMenu('prompt')"
|
||||||
>
|
>
|
||||||
Agent 管理
|
Agent 管理
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('models.view')"
|
||||||
:class="{ active: activeMenu === 'models' }"
|
:class="{ active: activeMenu === 'models' }"
|
||||||
@click="switchMenu('models')"
|
@click="switchMenu('models')"
|
||||||
>
|
>
|
||||||
模型管理
|
模型管理
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('content-generation.view')"
|
||||||
:class="{ active: activeMenu === 'content-generation' }"
|
:class="{ active: activeMenu === 'content-generation' }"
|
||||||
@click="switchMenu('content-generation')"
|
@click="switchMenu('content-generation')"
|
||||||
>
|
>
|
||||||
内容生成
|
内容生成
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('configs.view')"
|
||||||
:class="{ active: activeMenu === 'configs' }"
|
:class="{ active: activeMenu === 'configs' }"
|
||||||
@click="switchMenu('configs')"
|
@click="switchMenu('configs')"
|
||||||
>
|
>
|
||||||
系统配置
|
系统配置
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('sso.view')"
|
||||||
:class="{ active: activeMenu === 'sso' }"
|
:class="{ active: activeMenu === 'sso' }"
|
||||||
@click="switchMenu('sso')"
|
@click="switchMenu('sso')"
|
||||||
>
|
>
|
||||||
应用接入
|
应用接入
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('records.view')"
|
||||||
:class="{ active: activeMenu === 'records' }"
|
:class="{ active: activeMenu === 'records' }"
|
||||||
@click="switchMenu('records')"
|
@click="switchMenu('records')"
|
||||||
>
|
>
|
||||||
记录审计
|
记录审计
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('retrievals.view')"
|
||||||
:class="{ active: activeMenu === 'retrievals' }"
|
:class="{ active: activeMenu === 'retrievals' }"
|
||||||
@click="switchMenu('retrievals')"
|
@click="switchMenu('retrievals')"
|
||||||
>
|
>
|
||||||
检索日志
|
检索日志
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="can('feedback.view')"
|
||||||
|
:class="{ active: activeMenu === 'feedback' }"
|
||||||
|
@click="switchMenu('feedback')"
|
||||||
|
>反馈管理</button>
|
||||||
|
<button
|
||||||
|
v-if="can('attention.view')"
|
||||||
:class="{ active: activeMenu === 'attention' }"
|
:class="{ active: activeMenu === 'attention' }"
|
||||||
@click="switchMenu('attention')"
|
@click="switchMenu('attention')"
|
||||||
>
|
>
|
||||||
人工关注
|
人工关注
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="admin.isSuperAdmin"
|
||||||
|
:class="{ active: activeMenu === 'admins' }"
|
||||||
|
@click="switchMenu('admins')"
|
||||||
|
>管理员与权限</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
@@ -1057,8 +1119,20 @@ async function clearFeishuCache() {
|
|||||||
|
|
||||||
<RetrievalLogView v-if="activeMenu === 'retrievals'" />
|
<RetrievalLogView v-if="activeMenu === 'retrievals'" />
|
||||||
<AttentionManagementView v-if="activeMenu === 'attention'" />
|
<AttentionManagementView v-if="activeMenu === 'attention'" />
|
||||||
|
<FeedbackManagementView v-if="activeMenu === 'feedback'" :can-delete="can('feedback.delete')" />
|
||||||
<RecordAuditView v-if="activeMenu === 'records'" />
|
<RecordAuditView v-if="activeMenu === 'records'" />
|
||||||
|
<AdminManagementView v-if="activeMenu === 'admins'" />
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<el-dialog v-model="passwordDialogVisible" title="首次登录,请修改初始密码" width="460px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||||||
|
<el-alert title="为保障账号安全,修改密码后需要重新登录。" type="warning" :closable="false" show-icon />
|
||||||
|
<el-form label-position="top" class="password-form">
|
||||||
|
<el-form-item label="当前初始密码"><el-input v-model="passwordForm.currentPassword" type="password" show-password /></el-form-item>
|
||||||
|
<el-form-item label="新密码"><el-input v-model="passwordForm.newPassword" type="password" show-password placeholder="至少 8 位" /></el-form-item>
|
||||||
|
<el-form-item label="确认新密码"><el-input v-model="passwordForm.confirmPassword" type="password" show-password /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="logout">退出登录</el-button><el-button type="primary" :loading="passwordSaving" @click="changeInitialPassword">确认修改</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox, type ElTree } from "element-plus";
|
||||||
|
import { nextTick, onMounted, reactive, ref } from "vue";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import type { ManagedAdmin, PermissionNode } from "../types/api";
|
||||||
|
|
||||||
|
const admins = ref<ManagedAdmin[]>([]);
|
||||||
|
const tree = ref<PermissionNode[]>([]);
|
||||||
|
const permissionTree = ref<InstanceType<typeof ElTree>>();
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const editingId = ref<number | null>(null);
|
||||||
|
const form = reactive({ username: "", name: "", initialPassword: "", resetPassword: "", status: 1 });
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
[admins.value, tree.value] = await Promise.all([api.administrators(), api.permissionTree()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId.value = null;
|
||||||
|
Object.assign(form, { username: "", name: "", initialPassword: "", resetPassword: "", status: 1 });
|
||||||
|
dialogVisible.value = true;
|
||||||
|
nextTick(() => permissionTree.value?.setCheckedKeys([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: ManagedAdmin) {
|
||||||
|
editingId.value = item.id;
|
||||||
|
Object.assign(form, { username: item.username, name: item.name, initialPassword: "", resetPassword: "", status: item.status });
|
||||||
|
dialogVisible.value = true;
|
||||||
|
nextTick(() => permissionTree.value?.setCheckedKeys(item.permissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedPermissions() {
|
||||||
|
return (permissionTree.value?.getCheckedKeys(false) ?? []).map(String).filter((code) => code.includes("."));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.name.trim() || (!editingId.value && (!form.username.trim() || form.initialPassword.length < 8))) {
|
||||||
|
ElMessage.warning("请完整填写账号、姓名和至少 8 位的初始密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const permissions = selectedPermissions();
|
||||||
|
if (editingId.value) {
|
||||||
|
await api.updateAdministrator(editingId.value, { name: form.name, status: form.status, permissions, resetPassword: form.resetPassword || null });
|
||||||
|
} else {
|
||||||
|
await api.createAdministrator({ username: form.username, name: form.name, initialPassword: form.initialPassword, status: form.status, permissions });
|
||||||
|
}
|
||||||
|
ElMessage.success(editingId.value ? "管理员已更新" : "管理员已创建");
|
||||||
|
dialogVisible.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : "保存失败");
|
||||||
|
} finally { saving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(item: ManagedAdmin) {
|
||||||
|
await ElMessageBox.confirm(`确定删除管理员“${item.name}”?删除后将无法登录。`, "删除管理员", { type: "warning", confirmButtonText: "确定删除" });
|
||||||
|
await api.deleteAdministrator(item.id);
|
||||||
|
ElMessage.success("管理员已删除");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-management">
|
||||||
|
<div class="page-head inline">
|
||||||
|
<div><h2>管理员与权限</h2><p>按页面和功能分配权限;未授权的页面不会出现,后端也会拒绝越权请求。</p></div>
|
||||||
|
<el-button type="primary" @click="openCreate">+新增管理员</el-button>
|
||||||
|
</div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<el-table :data="admins" stripe>
|
||||||
|
<el-table-column prop="name" label="管理员" min-width="150" />
|
||||||
|
<el-table-column prop="username" label="登录账号" min-width="160" />
|
||||||
|
<el-table-column label="类型" width="130"><template #default="{ row }"><el-tag :type="row.isSuperAdmin ? 'danger' : 'info'">{{ row.isSuperAdmin ? '超级管理员' : '普通管理员' }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="权限" min-width="150"><template #default="{ row }">{{ row.isSuperAdmin ? '全部权限' : `${row.permissions.length} 项功能` }}</template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '正常' : '已停用' }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="密码状态" width="150"><template #default="{ row }">{{ row.isSuperAdmin ? '环境变量托管' : (row.mustChangePassword ? '待首次修改' : '已修改') }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><template v-if="!row.isSuperAdmin"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template><span v-else class="locked">系统托管</span></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" class="admin-editor-dialog" :title="editingId ? '编辑管理员' : '新增管理员'" width="820px" destroy-on-close>
|
||||||
|
<el-form class="admin-editor-form" label-position="top">
|
||||||
|
<section class="form-section">
|
||||||
|
<div class="section-title"><strong>基本信息</strong><span>用于登录和识别管理员</span></div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="登录账号"><el-input v-model="form.username" :disabled="!!editingId" placeholder="英文、数字、点、下划线或短横线" /></el-form-item>
|
||||||
|
<el-form-item label="管理员姓名"><el-input v-model="form.name" placeholder="请输入真实姓名或职位名称" /></el-form-item>
|
||||||
|
<el-form-item :label="editingId ? '重置密码(可选)' : '初始密码'"><el-input v-if="editingId" v-model="form.resetPassword" show-password placeholder="留空表示不重置" /><el-input v-else v-model="form.initialPassword" show-password placeholder="至少 8 位,首次登录后强制修改" /></el-form-item>
|
||||||
|
<el-form-item label="账号状态"><div class="status-control"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /><span>{{ form.status === 1 ? '正常,可以登录' : '已停用,无法登录' }}</span></div></el-form-item>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="form-section permission-section">
|
||||||
|
<div class="section-title"><strong>功能权限</strong><span>勾选一级菜单可快速全选,也可逐项精细分配</span></div>
|
||||||
|
<div class="permission-box"><el-tree ref="permissionTree" :data="tree.filter(n => !n.superOnly)" node-key="code" show-checkbox default-expand-all :props="{ label: 'name', children: 'children' }" /></div>
|
||||||
|
</section>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存管理员</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-editor-form{display:grid;gap:22px}.form-section{padding:18px 20px;border:1px solid #e3e9e6;border-radius:12px;background:#fff}.section-title{display:flex;align-items:baseline;gap:10px;margin-bottom:18px}.section-title strong{font-size:16px}.section-title span{color:#7c8d87;font-size:13px}.form-grid{display:grid;grid-template-columns:1fr 1fr;column-gap:24px;row-gap:18px}.admin-editor-form :deep(.el-form-item){margin-bottom:0}.admin-editor-form :deep(.el-form-item__label){height:auto;padding:0 0 8px;color:#465650;font-weight:600;line-height:1.4}.status-control{display:flex;align-items:center;gap:10px;height:40px}.status-control span{color:#687a74;font-size:13px}.permission-section{background:#f8faf9}.permission-box{width:100%;max-height:390px;overflow:auto;padding:12px 14px;border:1px solid #dfe6e3;border-radius:9px;background:#fff}.locked{color:#909399}.admin-management :deep(.el-tree-node__content){height:34px;border-radius:6px}.admin-management :deep(.el-tree-node__content:hover){background:#eef6f3}.admin-management :deep(.el-dialog__body){max-height:calc(100vh - 190px);padding:20px 24px;overflow:auto}.admin-management :deep(.el-dialog__header){padding:22px 24px 16px;border-bottom:1px solid #edf1ef}.admin-management :deep(.el-dialog__footer){padding:16px 24px 20px;border-top:1px solid #edf1ef}@media(max-width:760px){.form-grid{grid-template-columns:1fr}.section-title{align-items:flex-start;flex-direction:column;gap:4px}}
|
||||||
|
</style>
|
||||||
@@ -1,58 +1,99 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { LineChart } from "echarts/charts";
|
||||||
|
import { DataZoomComponent, GridComponent, LegendComponent, MarkPointComponent, TooltipComponent } from "echarts/components";
|
||||||
|
import { init, use, type ECharts } from "echarts/core";
|
||||||
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import type { DashboardStats } from "../types/api";
|
import type { DashboardStats, PeakTrafficResult, TrafficGrain } from "../types/api";
|
||||||
|
|
||||||
|
use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkPointComponent, CanvasRenderer]);
|
||||||
|
|
||||||
const stats = ref<DashboardStats | null>(null);
|
const stats = ref<DashboardStats | null>(null);
|
||||||
|
const traffic = ref<PeakTrafficResult | null>(null);
|
||||||
|
const trafficChart = ref<HTMLElement | null>(null);
|
||||||
|
let trafficChartInstance: ECharts | null = null;
|
||||||
|
const trafficGrain = ref<TrafficGrain>("hour");
|
||||||
const storageStats = ref<Record<string, any> | null>(null);
|
const storageStats = ref<Record<string, any> | null>(null);
|
||||||
const storageLoading = ref(false);
|
const storageLoading = ref(false);
|
||||||
|
const trafficLoading = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const filters = reactive({ start: "", end: "" });
|
const filters = reactive({ start: "", end: "" });
|
||||||
|
|
||||||
onMounted(loadDashboard);
|
const trafficOptions: Array<{ label: string; value: TrafficGrain }> = [
|
||||||
|
{ label: "分钟", value: "minute" },
|
||||||
|
{ label: "小时", value: "hour" },
|
||||||
|
{ label: "天", value: "day" },
|
||||||
|
{ label: "周", value: "week" },
|
||||||
|
];
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadDashboard(), loadTraffic()]);
|
||||||
|
window.addEventListener("resize", resizeTrafficChart);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => { window.removeEventListener("resize", resizeTrafficChart); trafficChartInstance?.dispose(); });
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
stats.value = await api.dashboard(filters.start, filters.end);
|
stats.value = await api.dashboard(filters.start, filters.end);
|
||||||
storageLoading.value = true;
|
storageLoading.value = true;
|
||||||
api
|
api.storageStats().then((value) => { storageStats.value = value; }).catch(() => { storageStats.value = null; }).finally(() => { storageLoading.value = false; });
|
||||||
.storageStats()
|
} finally { loading.value = false; }
|
||||||
.then((value) => {
|
}
|
||||||
storageStats.value = value;
|
|
||||||
})
|
async function loadTraffic(grain: TrafficGrain = trafficGrain.value) {
|
||||||
.catch(() => {
|
trafficGrain.value = grain;
|
||||||
storageStats.value = null;
|
trafficLoading.value = true;
|
||||||
})
|
try { traffic.value = await api.peakTraffic(grain); await nextTick(); renderTrafficChart(); }
|
||||||
.finally(() => {
|
catch (error) { ElMessage.error(error instanceof Error ? error.message : "峰值流量加载失败"); }
|
||||||
storageLoading.value = false;
|
finally { trafficLoading.value = false; }
|
||||||
});
|
}
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
function resizeTrafficChart() { trafficChartInstance?.resize(); }
|
||||||
}
|
|
||||||
|
function renderTrafficChart() {
|
||||||
|
if (!trafficChart.value) return;
|
||||||
|
trafficChartInstance ??= init(trafficChart.value);
|
||||||
|
const rows = traffic.value?.rows ?? [];
|
||||||
|
const zoomStart = trafficGrain.value === "minute" ? 98 : trafficGrain.value === "hour" ? 70 : 0;
|
||||||
|
trafficChartInstance.setOption({
|
||||||
|
animationDuration: 350,
|
||||||
|
color: ["#176b55", "#d66a57"],
|
||||||
|
grid: { left: 52, right: 28, top: 36, bottom: rows.length > 30 ? 72 : 48 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
formatter(params: Array<{ dataIndex: number; marker: string; seriesName: string; value: number }>) {
|
||||||
|
const index = params[0]?.dataIndex ?? 0;
|
||||||
|
const row = rows[index];
|
||||||
|
if (!row) return "";
|
||||||
|
return `<strong>${row.periodLabel}</strong><br/>${params.map((item) => `${item.marker}${item.seriesName}:${item.value}`).join("<br/>")}<br/><span style="color:#7b8b85">平均响应:${row.avgResponseMs} ms</span>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: { top: 0, right: 0, data: ["请求数", "5xx 错误"] },
|
||||||
|
xAxis: { type: "category", boundaryGap: false, data: rows.map((row) => row.periodLabel), axisLabel: { color: "#71827c", hideOverlap: true }, axisLine: { lineStyle: { color: "#dce6e2" } } },
|
||||||
|
yAxis: { type: "value", minInterval: 1, name: "请求数", nameTextStyle: { color: "#71827c" }, splitLine: { lineStyle: { color: "#edf2f0" } } },
|
||||||
|
dataZoom: rows.length > 30 ? [{ type: "inside", start: zoomStart, end: 100 }, { type: "slider", start: zoomStart, end: 100, height: 18, bottom: 8, borderColor: "#dce6e2", fillerColor: "rgba(23,107,85,.12)" }] : [],
|
||||||
|
series: [
|
||||||
|
{ name: "请求数", type: "line", smooth: true, showSymbol: rows.length < 80, symbolSize: 7, lineStyle: { width: 3 }, areaStyle: { color: "rgba(23,107,85,.10)" }, data: rows.map((row) => row.requestCount), markPoint: { symbolSize: 48, data: [{ type: "max", name: "峰值" }] } },
|
||||||
|
{ name: "5xx 错误", type: "line", smooth: true, showSymbol: false, lineStyle: { width: 2, type: "dashed" }, data: rows.map((row) => row.errorCount) },
|
||||||
|
],
|
||||||
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStorage() {
|
async function refreshStorage() {
|
||||||
storageLoading.value = true;
|
storageLoading.value = true;
|
||||||
try {
|
try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); }
|
||||||
storageStats.value = await api.storageStats(true);
|
finally { storageLoading.value = false; }
|
||||||
ElMessage.success("存储统计已刷新");
|
|
||||||
} finally {
|
|
||||||
storageLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(value: unknown) {
|
function formatBytes(value: unknown) {
|
||||||
if (typeof value !== "number") return "无法统计";
|
if (typeof value !== "number") return "无法统计";
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
let size = value;
|
let size = value; let index = 0;
|
||||||
let index = 0;
|
while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; }
|
||||||
while (size >= 1024 && index < units.length - 1) {
|
|
||||||
size /= 1024;
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
return `${size.toFixed(index ? 2 : 0)} ${units[index]}`;
|
return `${size.toFixed(index ? 2 : 0)} ${units[index]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,159 +104,67 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function modelSceneLabel(scene: string) {
|
function modelSceneLabel(scene: string) {
|
||||||
return (
|
return ({ background_report: "周期报告", background_summary: "摘要沉淀", fixed_info: "固定信息问答", simple_knowledge: "简单知识问答", knowledge_grounded: "知识问答", general_chat: "通用对话" } as Record<string, string>)[scene] || scene || "未分类";
|
||||||
(
|
|
||||||
{
|
|
||||||
background_report: "周期报告",
|
|
||||||
background_summary: "摘要沉淀",
|
|
||||||
fixed_info: "固定信息问答",
|
|
||||||
simple_knowledge: "简单知识问答",
|
|
||||||
knowledge_grounded: "知识问答",
|
|
||||||
general_chat: "通用对话",
|
|
||||||
} as Record<string, string>
|
|
||||||
)[scene] ||
|
|
||||||
scene ||
|
|
||||||
"未分类"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-loading="loading" class="feature-page">
|
<section v-loading="loading" class="feature-page dashboard-page">
|
||||||
<div class="page-head inline">
|
<header class="dashboard-hero">
|
||||||
<div>
|
<div>
|
||||||
|
<span class="dashboard-eyebrow">OPERATIONS OVERVIEW</span>
|
||||||
<h2>数据看板</h2>
|
<h2>数据看板</h2>
|
||||||
<p>核心业务数据概览。</p>
|
<p>从业务规模、请求峰值到模型成本,快速判断系统运行状态。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-date-range" aria-label="看板日期范围">
|
<div class="dashboard-date-range" aria-label="看板日期范围">
|
||||||
<label class="record-date-field dashboard-date-field">
|
<label class="dashboard-date-field"><span>开始日期</span><input v-model="filters.start" type="date" aria-label="开始日期" @change="loadDashboard" /></label>
|
||||||
<span>开始日期</span>
|
|
||||||
<input
|
|
||||||
v-model="filters.start"
|
|
||||||
type="date"
|
|
||||||
aria-label="开始日期"
|
|
||||||
@change="loadDashboard"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<span class="record-date-sep">至</span>
|
<span class="record-date-sep">至</span>
|
||||||
<label class="record-date-field dashboard-date-field">
|
<label class="dashboard-date-field"><span>结束日期</span><input v-model="filters.end" type="date" aria-label="结束日期" @change="loadDashboard" /></label>
|
||||||
<span>结束日期</span>
|
</div>
|
||||||
<input
|
</header>
|
||||||
v-model="filters.end"
|
|
||||||
type="date"
|
<section class="overview-grid" aria-label="核心业务指标">
|
||||||
aria-label="结束日期"
|
<article class="overview-card primary"><span>用户总数</span><strong>{{ stats?.userCount ?? 0 }}</strong><small>当前已沉淀用户</small></article>
|
||||||
@change="loadDashboard"
|
<article class="overview-card"><span>会话数</span><strong>{{ stats?.sessionCount ?? 0 }}</strong><small>选定日期范围</small></article>
|
||||||
/>
|
<article class="overview-card"><span>消息数</span><strong>{{ stats?.messageCount ?? 0 }}</strong><small>用户与 Agent 对话</small></article>
|
||||||
</label>
|
<article class="overview-card"><span>AI 请求</span><strong>{{ stats?.aiRequestCount ?? 0 }}</strong><small>模型实际调用次数</small></article>
|
||||||
|
<article class="overview-card"><span>估算成本</span><strong class="money">{{ formatMoney(stats?.estimatedCost, stats?.costCurrency || "CNY") }}</strong><small>按已配置模型单价</small></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-panel traffic-panel" v-loading="trafficLoading">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div><span class="section-kicker">流量监测</span><h3>峰值流量</h3><p>折线图展示最近 7 天变化趋势,可切换时间粒度查看。</p></div>
|
||||||
|
<div class="grain-switch" role="group" aria-label="流量统计粒度">
|
||||||
|
<button v-for="option in trafficOptions" :key="option.value" type="button" :class="{ active: trafficGrain === option.value }" @click="loadTraffic(option.value)">{{ option.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-grid">
|
<div class="traffic-summary">
|
||||||
<div class="stat">
|
<div><span>峰值时段</span><strong>{{ traffic?.peakPeriod || "暂无数据" }}</strong></div>
|
||||||
<span>用户数</span><strong>{{ stats?.userCount ?? 0 }}</strong>
|
<div><span>峰值请求数</span><strong>{{ traffic?.peakRequests ?? 0 }}</strong></div>
|
||||||
|
<div><span>近 7 天总请求</span><strong>{{ traffic?.totalRequests ?? 0 }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div v-if="traffic?.rows.length" ref="trafficChart" class="traffic-chart" role="img" aria-label="最近七天峰值流量折线图"></div>
|
||||||
<span>会话数</span><strong>{{ stats?.sessionCount ?? 0 }}</strong>
|
<el-empty v-else description="暂无流量数据,新请求产生后会自动统计" :image-size="72" />
|
||||||
</div>
|
</section>
|
||||||
<div class="stat">
|
|
||||||
<span>消息数</span><strong>{{ stats?.messageCount ?? 0 }}</strong>
|
<section class="dashboard-panel">
|
||||||
</div>
|
<div class="panel-heading"><div><span class="section-kicker">模型资源</span><h3>模型使用与成本</h3><p>核对模型分流、Token 消耗和费用。</p></div><div class="token-summary"><span>总 Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong><small>输入 {{ stats?.inputToken ?? 0 }} · 输出 {{ stats?.outputToken ?? 0 }}</small></div></div>
|
||||||
<div class="stat">
|
<el-table :data="stats?.costBreakdown || []" empty-text="当前日期范围内暂无模型调用">
|
||||||
<span>AI 请求</span><strong>{{ stats?.aiRequestCount ?? 0 }}</strong>
|
<el-table-column label="调用场景" min-width="130"><template #default="{ row }">{{ modelSceneLabel(row.scene) }}</template></el-table-column>
|
||||||
</div>
|
<el-table-column prop="modelName" label="实际模型" min-width="180" show-overflow-tooltip />
|
||||||
<div class="stat">
|
<el-table-column prop="requestCount" label="请求数" width="100" /><el-table-column prop="inputToken" label="输入 Token" width="130" /><el-table-column prop="outputToken" label="输出 Token" width="130" />
|
||||||
<span>知识库</span><strong>{{ stats?.knowledgeCount ?? 0 }}</strong>
|
<el-table-column label="估算成本" width="170"><template #default="{ row }">{{ formatMoney(row.estimatedCost, row.currency || "CNY") }}</template></el-table-column>
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span>输入 Token</span><strong>{{ stats?.inputToken ?? 0 }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span>输出 Token</span><strong>{{ stats?.outputToken ?? 0 }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span>总 Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span>估算成本</span
|
|
||||||
><strong>{{
|
|
||||||
formatMoney(stats?.estimatedCost, stats?.costCurrency || "CNY")
|
|
||||||
}}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section class="cost-breakdown-panel">
|
|
||||||
<div class="storage-panel-head">
|
|
||||||
<div>
|
|
||||||
<h3>模型使用与成本</h3>
|
|
||||||
<p>按实际调用场景和模型统计;报告、摘要分流是否生效可在这里核对。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-table
|
|
||||||
:data="stats?.costBreakdown || []"
|
|
||||||
empty-text="当前日期范围内暂无模型调用"
|
|
||||||
>
|
|
||||||
<el-table-column label="调用场景" min-width="130"
|
|
||||||
><template #default="{ row }">{{
|
|
||||||
modelSceneLabel(row.scene)
|
|
||||||
}}</template></el-table-column
|
|
||||||
>
|
|
||||||
<el-table-column
|
|
||||||
prop="modelName"
|
|
||||||
label="实际模型"
|
|
||||||
min-width="180"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column prop="requestCount" label="请求数" width="100" />
|
|
||||||
<el-table-column prop="inputToken" label="输入 Token" width="130" />
|
|
||||||
<el-table-column prop="outputToken" label="输出 Token" width="130" />
|
|
||||||
<el-table-column label="估算成本" width="170"
|
|
||||||
><template #default="{ row }">{{
|
|
||||||
formatMoney(row.estimatedCost, row.currency || "CNY")
|
|
||||||
}}</template></el-table-column
|
|
||||||
>
|
|
||||||
</el-table>
|
</el-table>
|
||||||
</section>
|
</section>
|
||||||
<section class="storage-panel" v-loading="storageLoading">
|
|
||||||
<div class="storage-panel-head">
|
<section class="dashboard-panel" v-loading="storageLoading">
|
||||||
<div>
|
<div class="panel-heading"><div><span class="section-kicker">基础设施</span><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计,无法取得时明确标记。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div>
|
||||||
<h3>项目存储占用</h3>
|
<div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div>
|
||||||
<p>数据库、Redis 与附件分别统计;无权限或不支持时显示“无法统计”。</p>
|
<small class="storage-updated">最后统计:{{ storageStats?.createdAt || "尚未统计" }}</small>
|
||||||
</div>
|
|
||||||
<el-button :loading="storageLoading" @click="refreshStorage"
|
|
||||||
>手动刷新</el-button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="storage-grid">
|
|
||||||
<div>
|
|
||||||
<span>项目总量</span
|
|
||||||
><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>数据库</span
|
|
||||||
><strong>{{
|
|
||||||
formatBytes(storageStats?.detail?.database?.bytes)
|
|
||||||
}}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Redis</span
|
|
||||||
><strong>{{
|
|
||||||
formatBytes(storageStats?.detail?.redis?.bytes)
|
|
||||||
}}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>附件/文件</span
|
|
||||||
><strong>{{
|
|
||||||
formatBytes(storageStats?.detail?.files?.bytes)
|
|
||||||
}}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>近7天增长</span
|
|
||||||
><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>近30天增长</span
|
|
||||||
><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<small>最后统计:{{ storageStats?.createdAt || "尚未统计" }}</small>
|
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-page{--dash-border:#dce6e2;--dash-muted:#687b74;--dash-green:#176b55;display:grid;gap:20px}.dashboard-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;padding:24px 26px;border:1px solid var(--dash-border);border-radius:14px;background:linear-gradient(135deg,#f5fbf8 0%,#fff 64%)}.dashboard-eyebrow,.section-kicker{display:block;margin-bottom:6px;color:#2a8069;font-size:11px;font-weight:700;letter-spacing:.12em}.dashboard-hero h2,.panel-heading h3{margin:0}.dashboard-hero h2{font-size:28px}.dashboard-hero p,.panel-heading p{margin:7px 0 0;color:var(--dash-muted);line-height:1.5}.dashboard-date-range{padding:8px;border:1px solid var(--dash-border);border-radius:10px;background:#fff}.dashboard-date-field{display:flex;align-items:center;gap:8px}.dashboard-date-field span{color:var(--dash-muted);font-size:12px;white-space:nowrap}.dashboard-date-field input{height:34px;border:0;outline:0;color:#283631;background:transparent}.overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.overview-card{min-height:126px;padding:18px;border:1px solid var(--dash-border);border-radius:12px;background:#fff}.overview-card.primary{border-color:#b9d9ce;background:#f0f8f5}.overview-card span,.traffic-summary span,.token-summary span{color:var(--dash-muted);font-size:13px}.overview-card strong{display:block;margin:12px 0 7px;color:#1d2925;font-size:28px;line-height:1}.overview-card strong.money{font-size:20px;line-height:1.3}.overview-card small,.token-summary small{color:#8a9a94}.dashboard-panel{padding:22px;border:1px solid var(--dash-border);border-radius:14px;background:#fff}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.panel-heading h3{font-size:20px}.grain-switch{display:flex;padding:4px;border:1px solid var(--dash-border);border-radius:10px;background:#f5f8f7}.grain-switch button{min-width:56px;height:34px;padding:0 13px;border:0;border-radius:7px;color:#586b64;background:transparent;cursor:pointer}.grain-switch button:hover{color:var(--dash-green)}.grain-switch button.active{color:#fff;background:var(--dash-green);box-shadow:0 2px 6px rgba(23,107,85,.18)}.traffic-summary{display:grid;grid-template-columns:2fr 1fr 1fr;margin-bottom:16px;border:1px solid #e6ece9;border-radius:10px;background:#f8faf9}.traffic-summary>div{padding:15px 18px;border-right:1px solid #e6ece9}.traffic-summary>div:last-child{border-right:0}.traffic-summary strong{display:block;margin-top:6px;font-size:19px}.traffic-chart{width:100%;height:360px}.token-summary{min-width:220px;padding:12px 16px;border-radius:10px;background:#f5f8f7}.token-summary strong{display:block;margin:3px 0;font-size:22px}.storage-grid{margin:0}.storage-updated{display:block;margin-top:14px;color:#7d8e88}@media(max-width:900px){.dashboard-hero,.panel-heading{align-items:stretch;flex-direction:column}.dashboard-date-range{align-self:stretch;overflow:auto}.overview-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.traffic-summary{grid-template-columns:1fr}.traffic-summary>div{border-right:0;border-bottom:1px solid #e6ece9}.traffic-summary>div:last-child{border-bottom:0}.grain-switch{overflow:auto}.grain-switch button{flex:1}.traffic-chart{height:320px}}@media(max-width:560px){.overview-grid{grid-template-columns:1fr}.dashboard-panel,.dashboard-hero{padding:16px}}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import type { FeedbackDetail, FeedbackItem } from "../types/api";
|
||||||
|
|
||||||
|
const props = defineProps<{ canDelete: boolean }>();
|
||||||
|
const loading = ref(false);
|
||||||
|
const items = ref<FeedbackItem[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const readStatus = ref("all");
|
||||||
|
const detail = ref<FeedbackDetail | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.feedbackList({ readStatus: readStatus.value, page: page.value, pageSize: 20 });
|
||||||
|
items.value = result.items;
|
||||||
|
total.value = result.total;
|
||||||
|
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "反馈列表加载失败"); }
|
||||||
|
finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(item: FeedbackItem) {
|
||||||
|
detail.value = await api.feedbackDetail(item.id);
|
||||||
|
item.isRead = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(item: FeedbackItem) {
|
||||||
|
await ElMessageBox.confirm("确认删除这条用户反馈?删除后不可恢复。", "删除反馈", { type: "warning" });
|
||||||
|
await api.deleteFeedback(item.id);
|
||||||
|
if (detail.value?.id === item.id) detail.value = null;
|
||||||
|
ElMessage.success("反馈已删除");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="feedback-page">
|
||||||
|
<header><div><h2>反馈管理</h2><p>查看用户针对 AI 回答提交的问题与当时对话。</p></div><el-segmented v-model="readStatus" :options="[{ label: '全部', value: 'all' }, { label: '未读', value: 'unread' }, { label: '已读', value: 'read' }]" @change="page=1; load()" /></header>
|
||||||
|
<el-table v-loading="loading" :data="items" stripe>
|
||||||
|
<el-table-column label="状态" width="88"><template #default="{ row }"><el-tag :type="row.isRead ? 'info' : 'danger'">{{ row.isRead ? '已读' : '未读' }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column prop="userName" label="用户" width="130" />
|
||||||
|
<el-table-column prop="content" label="反馈内容" min-width="240" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="messageContent" label="反馈的 AI 回答" min-width="280" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="createdAt" label="提交时间" width="180" />
|
||||||
|
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="open(row)">查看</el-button><el-button v-if="canDelete" link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination v-if="total > 20" v-model:current-page="page" :page-size="20" :total="total" layout="prev, pager, next, total" @current-change="load" />
|
||||||
|
<el-drawer :model-value="Boolean(detail)" title="反馈详情" size="620px" @close="detail = null">
|
||||||
|
<template v-if="detail">
|
||||||
|
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ detail.createdAt }}</small></section>
|
||||||
|
<h3>当时对话记录</h3>
|
||||||
|
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]">
|
||||||
|
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag></div><p>{{ message.content }}</p>
|
||||||
|
</article></div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feedback-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; margin-bottom:22px; }
|
||||||
|
.feedback-page h2 { margin:0 0 6px; }.feedback-page header p { margin:0; color:#7b8494; }
|
||||||
|
.feedback-summary { display:grid; gap:9px; padding:16px; border-radius:12px; background:#f7f9fc; }.feedback-summary span,.feedback-summary small { color:#87909f; }
|
||||||
|
.feedback-history { display:grid; gap:12px; }.feedback-history article { padding:14px 16px; border:1px solid #e6eaf0; border-radius:12px; }.feedback-history article.user { margin-left:42px; background:#f5f8ff; }.feedback-history article.assistant { margin-right:42px; }.feedback-history article.target { border:2px solid #f0a020; background:#fffaf0; }.feedback-history article div { display:flex; align-items:center; justify-content:space-between; }.feedback-history p { white-space:pre-wrap; line-height:1.65; margin:8px 0 0; }
|
||||||
|
</style>
|
||||||
@@ -81,6 +81,11 @@ const groupBlueprints: Record<string, SettingGroupBlueprint[]> = {
|
|||||||
"chat_active_lease_seconds",
|
"chat_active_lease_seconds",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "语音输入",
|
||||||
|
description: "控制用户端麦克风入口和单次录音上限。",
|
||||||
|
keys: ["voice_input_enabled", "voice_max_duration_seconds"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
外部服务: [
|
外部服务: [
|
||||||
{
|
{
|
||||||
@@ -175,6 +180,13 @@ const statusItems = computed(() => [
|
|||||||
value: `${props.values.chat_max_active_requests || 0} 并发`,
|
value: `${props.values.chat_max_active_requests || 0} 并发`,
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "语音输入",
|
||||||
|
value: props.values.voice_input_enabled
|
||||||
|
? `已开启 · ${props.values.voice_max_duration_seconds || 60} 秒`
|
||||||
|
: "已关闭",
|
||||||
|
tone: props.values.voice_input_enabled ? "success" : "neutral",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function updateSetting(key: string, value: unknown) {
|
function updateSetting(key: string, value: unknown) {
|
||||||
|
|||||||
@@ -215,6 +215,22 @@ export const systemSettingSections: SystemSettingSection[] = [
|
|||||||
max: 86400,
|
max: 86400,
|
||||||
description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。",
|
description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "voice_input_enabled",
|
||||||
|
label: "启用语音输入",
|
||||||
|
type: "switch",
|
||||||
|
defaultValue: false,
|
||||||
|
description: "关闭后用户端不展示麦克风入口,后端也会拒绝语音转写请求。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "voice_max_duration_seconds",
|
||||||
|
label: "单条语音最大时长(秒)",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 60,
|
||||||
|
min: 5,
|
||||||
|
max: 60,
|
||||||
|
description: "达到设定时长后自动停止并转成文字,阿里云一句话识别上限为 60 秒。",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ElAlert,
|
ElAlert,
|
||||||
ElButton,
|
ElButton,
|
||||||
|
ElCard,
|
||||||
ElCheckbox,
|
ElCheckbox,
|
||||||
ElCollapse,
|
ElCollapse,
|
||||||
ElCollapseItem,
|
ElCollapseItem,
|
||||||
@@ -28,11 +29,13 @@ import {
|
|||||||
ElTabs,
|
ElTabs,
|
||||||
ElTag,
|
ElTag,
|
||||||
ElTooltip,
|
ElTooltip,
|
||||||
|
ElTree,
|
||||||
vLoading,
|
vLoading,
|
||||||
} from "element-plus";
|
} from "element-plus";
|
||||||
import "element-plus/theme-chalk/base.css";
|
import "element-plus/theme-chalk/base.css";
|
||||||
import "element-plus/theme-chalk/el-alert.css";
|
import "element-plus/theme-chalk/el-alert.css";
|
||||||
import "element-plus/theme-chalk/el-button.css";
|
import "element-plus/theme-chalk/el-button.css";
|
||||||
|
import "element-plus/theme-chalk/el-card.css";
|
||||||
import "element-plus/theme-chalk/el-checkbox.css";
|
import "element-plus/theme-chalk/el-checkbox.css";
|
||||||
import "element-plus/theme-chalk/el-collapse.css";
|
import "element-plus/theme-chalk/el-collapse.css";
|
||||||
import "element-plus/theme-chalk/el-date-picker.css";
|
import "element-plus/theme-chalk/el-date-picker.css";
|
||||||
@@ -60,6 +63,7 @@ import "element-plus/theme-chalk/el-table.css";
|
|||||||
import "element-plus/theme-chalk/el-tabs.css";
|
import "element-plus/theme-chalk/el-tabs.css";
|
||||||
import "element-plus/theme-chalk/el-tag.css";
|
import "element-plus/theme-chalk/el-tag.css";
|
||||||
import "element-plus/theme-chalk/el-tooltip.css";
|
import "element-plus/theme-chalk/el-tooltip.css";
|
||||||
|
import "element-plus/theme-chalk/el-tree.css";
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
||||||
|
|
||||||
@@ -72,6 +76,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
|
|||||||
[
|
[
|
||||||
ElAlert,
|
ElAlert,
|
||||||
ElButton,
|
ElButton,
|
||||||
|
ElCard,
|
||||||
ElCheckbox,
|
ElCheckbox,
|
||||||
ElCollapse,
|
ElCollapse,
|
||||||
ElCollapseItem,
|
ElCollapseItem,
|
||||||
@@ -99,6 +104,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
|
|||||||
ElTabs,
|
ElTabs,
|
||||||
ElTag,
|
ElTag,
|
||||||
ElTooltip,
|
ElTooltip,
|
||||||
|
ElTree,
|
||||||
].forEach((component) => {
|
].forEach((component) => {
|
||||||
app.use(component);
|
app.use(component);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminProfile,
|
AdminProfile,
|
||||||
|
ManagedAdmin,
|
||||||
|
PermissionNode,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
AdminUserDetail,
|
AdminUserDetail,
|
||||||
AgentDebugResult,
|
AgentDebugResult,
|
||||||
@@ -16,6 +18,8 @@ import type {
|
|||||||
ContentGenerationHistoryItem,
|
ContentGenerationHistoryItem,
|
||||||
ContentGenerationType,
|
ContentGenerationType,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
|
PeakTrafficResult,
|
||||||
|
TrafficGrain,
|
||||||
EntitlementPlan,
|
EntitlementPlan,
|
||||||
EntitlementBatchRenewResult,
|
EntitlementBatchRenewResult,
|
||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
@@ -129,6 +133,12 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }),
|
logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }),
|
||||||
profile: () => request<AdminProfile>("/admin/profile"),
|
profile: () => request<AdminProfile>("/admin/profile"),
|
||||||
|
permissionTree: () => request<PermissionNode[]>("/admin/permission-tree"),
|
||||||
|
administrators: () => request<ManagedAdmin[]>("/admin/administrator/list"),
|
||||||
|
createAdministrator: (payload: Record<string, unknown>) => request<ManagedAdmin>("/admin/administrator", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
updateAdministrator: (id: number, payload: Record<string, unknown>) => request<ManagedAdmin>(`/admin/administrator/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
deleteAdministrator: (id: number) => request<null>(`/admin/administrator/${id}`, { method: "DELETE" }),
|
||||||
|
changeAdminPassword: (payload: { currentPassword: string; newPassword: string }) => request<null>("/admin/password", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
dashboard: (start?: string, end?: string) => {
|
dashboard: (start?: string, end?: string) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (start) params.set("start", start);
|
if (start) params.set("start", start);
|
||||||
@@ -136,6 +146,8 @@ export const api = {
|
|||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
|
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
|
||||||
},
|
},
|
||||||
|
peakTraffic: (grain: TrafficGrain) =>
|
||||||
|
request<PeakTrafficResult>(`/admin/dashboard/traffic${queryString({ grain })}`),
|
||||||
users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
|
users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
|
||||||
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
|
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
|
||||||
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
@@ -316,6 +328,9 @@ export const api = {
|
|||||||
deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }),
|
deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }),
|
||||||
clearFeishuCache: () =>
|
clearFeishuCache: () =>
|
||||||
request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }),
|
request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }),
|
||||||
|
feedbackList: (query: { readStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<import("../types/api").FeedbackItem>>(`/feedback/admin/list${queryString(query)}`),
|
||||||
|
feedbackDetail: (id: number) => request<import("../types/api").FeedbackDetail>(`/feedback/admin/${id}`),
|
||||||
|
deleteFeedback: (id: number) => request<null>(`/feedback/admin/${id}`, { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function streamDebugAgent(
|
export async function streamDebugAgent(
|
||||||
|
|||||||
@@ -16,6 +16,28 @@ export interface AdminProfile {
|
|||||||
username: string;
|
username: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: number;
|
status: number;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedAdmin {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
permissions: string[];
|
||||||
|
lastLoginAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionNode {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
superOnly?: boolean;
|
||||||
|
children?: PermissionNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SsoClientItem {
|
export interface SsoClientItem {
|
||||||
@@ -84,6 +106,25 @@ export interface DashboardStats {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TrafficGrain = "minute" | "hour" | "day" | "week";
|
||||||
|
|
||||||
|
export interface PeakTrafficRow {
|
||||||
|
period: string;
|
||||||
|
periodLabel: string;
|
||||||
|
requestCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
errorRate: number;
|
||||||
|
avgResponseMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeakTrafficResult {
|
||||||
|
grain: TrafficGrain;
|
||||||
|
peakPeriod?: string | null;
|
||||||
|
peakRequests: number;
|
||||||
|
totalRequests: number;
|
||||||
|
rows: PeakTrafficRow[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PromptDetail {
|
export interface PromptDetail {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
promptContent: string;
|
promptContent: string;
|
||||||
@@ -730,3 +771,20 @@ export interface ChatRecordQuery {
|
|||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FeedbackItem {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
userName: string;
|
||||||
|
userPhone: string;
|
||||||
|
messageId: number;
|
||||||
|
messageContent: string;
|
||||||
|
content: string;
|
||||||
|
isRead: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackDetail extends FeedbackItem {
|
||||||
|
sessionTitle: string;
|
||||||
|
messages: Array<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,3 +58,5 @@ TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
|
|||||||
BOOTSTRAP_ADMIN_USERNAME=admin
|
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=admin123456
|
BOOTSTRAP_ADMIN_PASSWORD=admin123456
|
||||||
BOOTSTRAP_ADMIN_NAME=系统管理员
|
BOOTSTRAP_ADMIN_NAME=系统管理员
|
||||||
|
ALIYUN_NLS_APP_KEY=
|
||||||
|
ALIYUN_NLS_ENDPOINT=https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add administrator permission management
|
||||||
|
|
||||||
|
Revision ID: 0031_admin_permissions
|
||||||
|
Revises: 0030_sso_chat_source
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0031_admin_permissions"
|
||||||
|
down_revision = "0030_sso_chat_source"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# MySQL does not permit a DEFAULT on TEXT on all supported versions.
|
||||||
|
op.add_column("sys_role", sa.Column("permissions", sa.Text(), nullable=True))
|
||||||
|
op.execute("UPDATE sys_role SET permissions = '[]' WHERE permissions IS NULL")
|
||||||
|
op.alter_column("sys_role", "permissions", existing_type=sa.Text(), nullable=False)
|
||||||
|
op.add_column("sys_admin", sa.Column("must_change_password", sa.Integer(), nullable=False, server_default="1"))
|
||||||
|
op.add_column("sys_admin", sa.Column("is_super_admin", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
# Existing installations have one bootstrap administrator. Preserve it as the immutable super administrator.
|
||||||
|
op.execute("UPDATE sys_admin SET is_super_admin = 1, must_change_password = 0 WHERE id = (SELECT id FROM (SELECT MIN(id) AS id FROM sys_admin) AS first_admin)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("sys_admin", "is_super_admin")
|
||||||
|
op.drop_column("sys_admin", "must_change_password")
|
||||||
|
op.drop_column("sys_role", "permissions")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add message feedback management
|
||||||
|
|
||||||
|
Revision ID: 0032_message_feedback
|
||||||
|
Revises: 0031_admin_permissions
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0032_message_feedback"
|
||||||
|
down_revision = "0031_admin_permissions"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"sys_message_feedback",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||||
|
sa.Column("session_id", sa.BigInteger(), sa.ForeignKey("sys_chat_session.id"), nullable=False),
|
||||||
|
sa.Column("message_id", sa.BigInteger(), sa.ForeignKey("sys_chat_message.id"), nullable=False),
|
||||||
|
sa.Column("content", sa.String(200), nullable=False),
|
||||||
|
sa.Column("is_read", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("read_by", sa.BigInteger(), sa.ForeignKey("sys_admin.id"), nullable=True),
|
||||||
|
sa.Column("read_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("user_id", "message_id", name="uq_message_feedback_user_message"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_message_feedback_user_id", "sys_message_feedback", ["user_id"])
|
||||||
|
op.create_index("ix_message_feedback_session_id", "sys_message_feedback", ["session_id"])
|
||||||
|
op.create_index("ix_message_feedback_message_id", "sys_message_feedback", ["message_id"])
|
||||||
|
op.create_index("ix_message_feedback_read_created", "sys_message_feedback", ["is_read", "created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("sys_message_feedback")
|
||||||
@@ -7,7 +7,8 @@ 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.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead
|
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse
|
||||||
|
from app.services.admin_permission_service import permissions_for
|
||||||
from app.services.admin_service import AdminAuthService
|
from app.services.admin_service import AdminAuthService
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
from app.services.security_state_service import client_ip
|
from app.services.security_state_service import client_ip
|
||||||
@@ -24,7 +25,12 @@ def login(payload: AdminLoginRequest, request: Request, db: Session = Depends(ge
|
|||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
def profile(current_admin: Admin = Depends(get_current_admin)) -> dict:
|
def profile(current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
return api_success(AdminRead.model_validate(current_admin).model_dump())
|
return api_success({
|
||||||
|
"id": current_admin.id, "username": current_admin.username, "name": current_admin.name,
|
||||||
|
"status": current_admin.status, "isSuperAdmin": bool(current_admin.is_super_admin),
|
||||||
|
"mustChangePassword": bool(current_admin.must_change_password),
|
||||||
|
"permissions": sorted(permissions_for(current_admin)),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ from app.schemas.admin import DashboardStats
|
|||||||
from app.services.admin_service import AdminDashboardService
|
from app.services.admin_service import AdminDashboardService
|
||||||
from app.models.logs import StorageSnapshot
|
from app.models.logs import StorageSnapshot
|
||||||
from app.services.redis_client import get_sync_redis_client
|
from app.services.redis_client import get_sync_redis_client
|
||||||
|
from app.services.request_traffic_service import RequestTrafficService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -37,6 +39,14 @@ def dashboard(
|
|||||||
return api_success(DashboardStats.model_validate(stats).model_dump())
|
return api_success(DashboardStats.model_validate(stats).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/traffic")
|
||||||
|
async def peak_traffic(
|
||||||
|
grain: Literal["minute", "hour", "day", "week"] = Query(default="hour"),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
return api_success(await RequestTrafficService.peak_traffic(grain))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/storage")
|
@router.get("/dashboard/storage")
|
||||||
def storage_stats(
|
def storage_stats(
|
||||||
refresh: bool = Query(default=False),
|
refresh: bool = Query(default=False),
|
||||||
|
|||||||
120
ai_knowledge_base_v2/apps/backend/app/api/admin_management.py
Normal file
120
ai_knowledge_base_v2/apps/backend/app/api/admin_management.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_admin
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
from app.models.admin import Admin, Role
|
||||||
|
from app.schemas.admin import AdminPasswordChangeRequest, ManagedAdminCreateRequest, ManagedAdminUpdateRequest
|
||||||
|
from app.services.admin_permission_service import ALL_PERMISSION_CODES, PERMISSION_TREE, permissions_for, require_permission
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(admin: Admin) -> dict:
|
||||||
|
return {
|
||||||
|
"id": admin.id, "username": admin.username, "name": admin.name, "status": admin.status,
|
||||||
|
"isSuperAdmin": bool(admin.is_super_admin), "mustChangePassword": bool(admin.must_change_password),
|
||||||
|
"permissions": sorted(permissions_for(admin)), "lastLoginAt": admin.last_login_at,
|
||||||
|
"createdAt": admin.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_permissions(values: list[str]) -> list[str]:
|
||||||
|
unknown = set(values) - ALL_PERMISSION_CODES
|
||||||
|
if unknown:
|
||||||
|
raise HTTPException(status_code=422, detail=f"包含未知权限:{', '.join(sorted(unknown))}")
|
||||||
|
return sorted(set(values))
|
||||||
|
|
||||||
|
|
||||||
|
def _require_super(admin: Admin) -> None:
|
||||||
|
if not admin.is_super_admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可管理管理员账号")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permission-tree")
|
||||||
|
def permission_tree(current: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
_require_super(current)
|
||||||
|
return api_success(PERMISSION_TREE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/administrator/list")
|
||||||
|
def list_administrators(db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
_require_super(current)
|
||||||
|
rows = db.scalars(select(Admin).options(selectinload(Admin.role)).order_by(Admin.is_super_admin.desc(), Admin.id)).all()
|
||||||
|
return api_success([_serialize(item) for item in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/administrator")
|
||||||
|
def create_administrator(payload: ManagedAdminCreateRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
_require_super(current)
|
||||||
|
if db.scalar(select(Admin.id).where(Admin.username == payload.username.strip())):
|
||||||
|
raise HTTPException(status_code=409, detail="管理员账号已存在")
|
||||||
|
permissions = _validated_permissions(payload.permissions)
|
||||||
|
role = Role(code=f"admin_{payload.username.strip().lower()}", name=f"{payload.name.strip()}的权限", permissions=json.dumps(permissions))
|
||||||
|
db.add(role)
|
||||||
|
db.flush()
|
||||||
|
item = Admin(username=payload.username.strip(), name=payload.name.strip(), password=hash_password(payload.initialPassword), role_id=role.id, status=payload.status, must_change_password=1, is_super_admin=0)
|
||||||
|
db.add(item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
item.role = role
|
||||||
|
return api_success(_serialize(item))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/administrator/{admin_id}")
|
||||||
|
def update_administrator(admin_id: int, payload: ManagedAdminUpdateRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
_require_super(current)
|
||||||
|
item = db.scalar(select(Admin).options(selectinload(Admin.role)).where(Admin.id == admin_id))
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="管理员不存在")
|
||||||
|
if item.is_super_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="超级管理员账号由环境变量管理,不可修改")
|
||||||
|
item.name, item.status = payload.name.strip(), payload.status
|
||||||
|
if item.role is None:
|
||||||
|
item.role = Role(code=f"admin_{item.username.lower()}", name=f"{item.name}的权限")
|
||||||
|
item.role.name = f"{item.name}的权限"
|
||||||
|
item.role.permissions = json.dumps(_validated_permissions(payload.permissions))
|
||||||
|
if payload.resetPassword:
|
||||||
|
item.password = hash_password(payload.resetPassword)
|
||||||
|
item.must_change_password = 1
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return api_success(_serialize(item))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/administrator/{admin_id}")
|
||||||
|
def delete_administrator(admin_id: int, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
_require_super(current)
|
||||||
|
item = db.scalar(select(Admin).options(selectinload(Admin.role)).where(Admin.id == admin_id))
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="管理员不存在")
|
||||||
|
if item.is_super_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="超级管理员不可删除")
|
||||||
|
role = item.role
|
||||||
|
db.delete(item)
|
||||||
|
db.flush()
|
||||||
|
if role is not None:
|
||||||
|
db.delete(role)
|
||||||
|
db.commit()
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password")
|
||||||
|
def change_password(payload: AdminPasswordChangeRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
if current.is_super_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="超级管理员密码由环境变量管理,不可在后台修改")
|
||||||
|
if not verify_password(payload.currentPassword, current.password):
|
||||||
|
raise HTTPException(status_code=400, detail="当前密码不正确")
|
||||||
|
if payload.currentPassword == payload.newPassword:
|
||||||
|
raise HTTPException(status_code=400, detail="新密码不能与当前密码相同")
|
||||||
|
current.password = hash_password(payload.newPassword)
|
||||||
|
current.must_change_password = 0
|
||||||
|
db.commit()
|
||||||
|
return api_success()
|
||||||
92
ai_knowledge_base_v2/apps/backend/app/api/feedback.py
Normal file
92
ai_knowledge_base_v2/apps/backend/app/api/feedback.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.pagination import page_result
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_admin, get_current_user
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.models.chat import ChatMessage, ChatSession
|
||||||
|
from app.models.feedback import MessageFeedback
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.admin_service import OperationLogService
|
||||||
|
from app.services.admin_permission_service import require_permission
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCreate(BaseModel):
|
||||||
|
messageId: int = Field(gt=0)
|
||||||
|
content: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_feedback(payload: FeedbackCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
|
message = db.scalar(select(ChatMessage).where(ChatMessage.id == payload.messageId, ChatMessage.user_id == user.id))
|
||||||
|
if message is None or message.role != "assistant" or message.message_status != "FINISHED":
|
||||||
|
raise HTTPException(status_code=404, detail="反馈的回答不存在")
|
||||||
|
content = payload.content.strip()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="请填写反馈内容")
|
||||||
|
existing = db.scalar(select(MessageFeedback).where(MessageFeedback.user_id == user.id, MessageFeedback.message_id == message.id))
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="这条回答已经反馈过了")
|
||||||
|
item = MessageFeedback(user_id=user.id, session_id=message.session_id, message_id=message.id, content=content)
|
||||||
|
db.add(item)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="这条回答已经反馈过了") from exc
|
||||||
|
db.refresh(item)
|
||||||
|
return api_success({"id": item.id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/list")
|
||||||
|
def feedback_list(readStatus: str = Query(default="all", pattern="^(all|read|unread)$"), page: int = Query(default=1, ge=1), pageSize: int = Query(default=20, ge=10, le=100), db: Session = Depends(get_db), _admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
require_permission(_admin, "feedback.view")
|
||||||
|
query = select(MessageFeedback, User, ChatMessage).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id)
|
||||||
|
if readStatus != "all":
|
||||||
|
query = query.where(MessageFeedback.is_read == (1 if readStatus == "read" else 0))
|
||||||
|
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||||
|
rows = db.execute(query.order_by(MessageFeedback.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||||
|
return api_success(page_result([_summary(*row) for row in rows], total=total, page=page, page_size=pageSize))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/{feedback_id}")
|
||||||
|
def feedback_detail(feedback_id: int, db: Session = Depends(get_db), admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
row = db.execute(select(MessageFeedback, User, ChatMessage, ChatSession).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id).join(ChatSession, ChatSession.id == MessageFeedback.session_id).where(MessageFeedback.id == feedback_id)).first()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||||
|
require_permission(admin, "feedback.view")
|
||||||
|
feedback, user, target, session = row
|
||||||
|
if not feedback.is_read:
|
||||||
|
feedback.is_read = 1
|
||||||
|
feedback.read_by = admin.id
|
||||||
|
feedback.read_at = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
db.commit()
|
||||||
|
messages = db.scalars(select(ChatMessage).where(ChatMessage.session_id == feedback.session_id, ChatMessage.id <= target.id).order_by(ChatMessage.id.asc()).limit(200)).all()
|
||||||
|
return api_success({**_summary(feedback, user, target), "sessionTitle": session.title, "messages": [{"id": m.id, "role": m.role, "content": m.content, "createdAt": m.created_at, "isTarget": m.id == target.id} for m in messages]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/{feedback_id}")
|
||||||
|
def delete_feedback(feedback_id: int, db: Session = Depends(get_db), admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
require_permission(admin, "feedback.delete")
|
||||||
|
item = db.get(MessageFeedback, feedback_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||||
|
db.delete(item)
|
||||||
|
OperationLogService.write(db, admin_id=admin.id, module="feedback", action="delete", target_id=feedback_id)
|
||||||
|
db.commit()
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(item: MessageFeedback, user: User, message: ChatMessage) -> dict:
|
||||||
|
return {"id": item.id, "userId": user.id, "userName": user.name, "userPhone": user.phone, "messageId": message.id, "messageContent": message.content, "content": item.content, "isRead": bool(item.is_read), "createdAt": item.created_at}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
|
from app.core.dependencies import enforce_admin_access
|
||||||
|
|
||||||
from app.api import (
|
from app.api import (
|
||||||
admin_auth,
|
admin_auth,
|
||||||
@@ -10,15 +11,18 @@ from app.api import (
|
|||||||
admin_entitlements,
|
admin_entitlements,
|
||||||
admin_knowledge,
|
admin_knowledge,
|
||||||
admin_knowledge_lifecycle,
|
admin_knowledge_lifecycle,
|
||||||
|
admin_management,
|
||||||
admin_records,
|
admin_records,
|
||||||
admin_settings,
|
admin_settings,
|
||||||
admin_sso,
|
admin_sso,
|
||||||
admin_users,
|
admin_users,
|
||||||
auth,
|
auth,
|
||||||
|
feedback,
|
||||||
chat,
|
chat,
|
||||||
health,
|
health,
|
||||||
integration_sso,
|
integration_sso,
|
||||||
user,
|
user,
|
||||||
|
voice,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -26,15 +30,19 @@ api_router.include_router(health.router, tags=["health"])
|
|||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
api_router.include_router(integration_sso.router, prefix="/integration/sso", tags=["integration-sso"])
|
api_router.include_router(integration_sso.router, prefix="/integration/sso", tags=["integration-sso"])
|
||||||
api_router.include_router(user.router, prefix="/user", tags=["user"])
|
api_router.include_router(user.router, prefix="/user", tags=["user"])
|
||||||
|
api_router.include_router(voice.router, prefix="/voice", tags=["voice"])
|
||||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||||
|
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
||||||
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
|
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
|
||||||
api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"])
|
api_router.include_router(admin_management.router, prefix="/admin", tags=["admin-management"])
|
||||||
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"])
|
guard = [Depends(enforce_admin_access)]
|
||||||
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"])
|
api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"], dependencies=guard)
|
||||||
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"])
|
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"], dependencies=guard)
|
||||||
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
|
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"], dependencies=guard)
|
||||||
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
|
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"], dependencies=guard)
|
||||||
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])
|
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard)
|
||||||
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"])
|
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"], dependencies=guard)
|
||||||
api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"])
|
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"], dependencies=guard)
|
||||||
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])
|
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"], dependencies=guard)
|
||||||
|
api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"], dependencies=guard)
|
||||||
|
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"], dependencies=guard)
|
||||||
|
|||||||
27
ai_knowledge_base_v2/apps/backend/app/api/voice.py
Normal file
27
ai_knowledge_base_v2/apps/backend/app/api/voice.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, UploadFile
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.voice_input_service import MAX_UPLOAD_BYTES, VoiceInputService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
def voice_config(db: Session = Depends(get_db), _user: User = Depends(get_current_user)) -> dict:
|
||||||
|
return api_success(VoiceInputService.public_config(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transcribe")
|
||||||
|
def transcribe(
|
||||||
|
audio: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
content = audio.file.read(MAX_UPLOAD_BYTES + 1)
|
||||||
|
return api_success(VoiceInputService.transcribe(db, user.id, content, audio.content_type))
|
||||||
@@ -48,6 +48,8 @@ class Settings(BaseSettings):
|
|||||||
aliyun_sms_template_code: str = ""
|
aliyun_sms_template_code: str = ""
|
||||||
aliyun_sms_template_param_key: str = "code"
|
aliyun_sms_template_param_key: str = "code"
|
||||||
aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com"
|
aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com"
|
||||||
|
aliyun_nls_app_key: str = ""
|
||||||
|
aliyun_nls_endpoint: str = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr"
|
||||||
mock_rag_enabled: bool = False
|
mock_rag_enabled: bool = False
|
||||||
mock_model_enabled: bool = True
|
mock_model_enabled: bool = True
|
||||||
feishu_mock_enabled: bool = False
|
feishu_mock_enabled: bool = False
|
||||||
@@ -63,6 +65,8 @@ class Settings(BaseSettings):
|
|||||||
chat_max_queue_size: int = 90
|
chat_max_queue_size: int = 90
|
||||||
chat_queue_timeout_seconds: int = 90
|
chat_queue_timeout_seconds: int = 90
|
||||||
chat_active_lease_seconds: int = 900
|
chat_active_lease_seconds: int = 900
|
||||||
|
voice_input_enabled: bool = False
|
||||||
|
voice_max_duration_seconds: int = 60
|
||||||
periodic_report_worker_enabled: bool = True
|
periodic_report_worker_enabled: bool = True
|
||||||
periodic_report_poll_seconds: int = 5
|
periodic_report_poll_seconds: int = 5
|
||||||
periodic_report_stale_minutes: int = 30
|
periodic_report_stale_minutes: int = 30
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -77,3 +77,44 @@ def get_current_admin(
|
|||||||
if admin.status != 1:
|
if admin.status != 1:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
|
||||||
return admin
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_admin_access(
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> Admin:
|
||||||
|
"""Block first-login accounts and enforce permissions on every existing admin API."""
|
||||||
|
from app.services.admin_permission_service import require_permission
|
||||||
|
|
||||||
|
if admin.must_change_password:
|
||||||
|
raise HTTPException(status_code=status.HTTP_428_PRECONDITION_REQUIRED, detail="首次登录请先修改初始密码")
|
||||||
|
path = request.url.path.split("/admin/", 1)[-1].strip("/")
|
||||||
|
method = request.method.upper()
|
||||||
|
if path.startswith("dashboard"):
|
||||||
|
permission = "dashboard.view"
|
||||||
|
elif path.startswith("user/") and ("/entitlement" in path or path.startswith("user/entitlement")):
|
||||||
|
permission = "entitlements.edit"
|
||||||
|
elif path.startswith("entitlement/"):
|
||||||
|
permission = "entitlements.view" if method == "GET" else "entitlements.edit"
|
||||||
|
elif path.startswith("user/"):
|
||||||
|
permission = "users.delete" if method == "DELETE" else ("users.view" if method == "GET" else ("users.create" if method == "POST" and (path in {"user", "user/import", "user/import/excel"}) else "users.edit"))
|
||||||
|
elif path.startswith("knowledge"):
|
||||||
|
permission = "knowledge.delete" if method == "DELETE" else ("knowledge.view" if method == "GET" else ("knowledge.publish" if path.endswith("open-status") or path.endswith("lifecycle") else "knowledge.edit"))
|
||||||
|
elif path.startswith("prompt") or path.startswith("agent/"):
|
||||||
|
permission = "prompt.view" if method == "GET" else "prompt.edit"
|
||||||
|
elif path.startswith("model"):
|
||||||
|
permission = "models.delete" if method == "DELETE" else ("models.view" if method == "GET" else "models.edit")
|
||||||
|
elif path.startswith("content-generation"):
|
||||||
|
permission = "content-generation.view" if method == "GET" else "content-generation.edit"
|
||||||
|
elif path.startswith("config") or path.startswith("feishu/cache"):
|
||||||
|
permission = "configs.view" if method == "GET" else "configs.edit"
|
||||||
|
elif path.startswith("sso/"):
|
||||||
|
permission = "sso.view" if method == "GET" else "sso.edit"
|
||||||
|
elif path.startswith("retrieval-log"):
|
||||||
|
permission = "retrievals.view" if method == "GET" else "configs.edit"
|
||||||
|
elif path.startswith("attention"):
|
||||||
|
permission = "attention.view" if method == "GET" else "attention.edit"
|
||||||
|
else:
|
||||||
|
permission = "records.view"
|
||||||
|
require_permission(admin, permission)
|
||||||
|
return admin
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ class RequestObservabilityMiddleware:
|
|||||||
finally:
|
finally:
|
||||||
if response_finished:
|
if response_finished:
|
||||||
self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed")
|
self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed")
|
||||||
|
from app.services.request_traffic_service import RequestTrafficService
|
||||||
|
|
||||||
|
await RequestTrafficService.record(
|
||||||
|
scope.get("path", ""),
|
||||||
|
status_code,
|
||||||
|
(perf_counter() - started_at) * 1000,
|
||||||
|
)
|
||||||
request_id_context.reset(token)
|
request_id_context.reset(token)
|
||||||
|
|
||||||
def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None:
|
def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from app.models.ai_config import ContentGenerationConfig, ModelConfig, Prompt, S
|
|||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||||
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
||||||
|
from app.models.feedback import MessageFeedback
|
||||||
from app.models.growth import GrowthProfileRevision, PeriodicReport, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile
|
from app.models.growth import GrowthProfileRevision, PeriodicReport, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile
|
||||||
from app.models.insight import QuestionInsightCleanedQuestion
|
from app.models.insight import QuestionInsightCleanedQuestion
|
||||||
from app.models.knowledge import (
|
from app.models.knowledge import (
|
||||||
@@ -50,6 +51,7 @@ __all__ = [
|
|||||||
"HumanAttentionHistory",
|
"HumanAttentionHistory",
|
||||||
"HumanAttentionRecord",
|
"HumanAttentionRecord",
|
||||||
"ModelConfig",
|
"ModelConfig",
|
||||||
|
"MessageFeedback",
|
||||||
"OperationLog",
|
"OperationLog",
|
||||||
"PeriodicReport",
|
"PeriodicReport",
|
||||||
"LogRetentionPolicy",
|
"LogRetentionPolicy",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, String
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
@@ -15,6 +15,7 @@ class Role(Base, TimestampMixin):
|
|||||||
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
permissions: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||||
|
|
||||||
admins: Mapped[list["Admin"]] = relationship("Admin", back_populates="role")
|
admins: Mapped[list["Admin"]] = relationship("Admin", back_populates="role")
|
||||||
|
|
||||||
@@ -29,5 +30,7 @@ class Admin(Base, TimestampMixin):
|
|||||||
role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True)
|
role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True)
|
||||||
status: Mapped[int] = mapped_column(default=1, nullable=False)
|
status: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
must_change_password: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
is_super_admin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
role: Mapped[Role | None] = relationship("Role", back_populates="admins")
|
role: Mapped[Role | None] = relationship("Role", back_populates="admins")
|
||||||
|
|||||||
26
ai_knowledge_base_v2/apps/backend/app/models/feedback.py
Normal file
26
ai_knowledge_base_v2/apps/backend/app/models/feedback.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFeedback(Base):
|
||||||
|
__tablename__ = "sys_message_feedback"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "message_id", name="uq_message_feedback_user_message"),
|
||||||
|
Index("ix_message_feedback_read_created", "is_read", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), nullable=False, index=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), nullable=False, index=True)
|
||||||
|
message_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_message.id"), nullable=False, index=True)
|
||||||
|
content: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
is_read: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
read_by: Mapped[int | None] = mapped_column(ForeignKey("sys_admin.id"), nullable=True)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
@@ -24,6 +24,29 @@ class AdminRead(ORMModel):
|
|||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
status: int
|
status: int
|
||||||
|
isSuperAdmin: bool = False
|
||||||
|
mustChangePassword: bool = False
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedAdminCreateRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||||
|
name: str = Field(min_length=1, max_length=50)
|
||||||
|
initialPassword: str = Field(min_length=8, max_length=100)
|
||||||
|
status: int = Field(default=1, ge=0, le=1)
|
||||||
|
permissions: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedAdminUpdateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=50)
|
||||||
|
status: int = Field(default=1, ge=0, le=1)
|
||||||
|
permissions: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
resetPassword: str | None = Field(default=None, min_length=8, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPasswordChangeRequest(BaseModel):
|
||||||
|
currentPassword: str = Field(min_length=1, max_length=100)
|
||||||
|
newPassword: str = Field(min_length=8, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class CostBreakdownItem(BaseModel):
|
class CostBreakdownItem(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from app.models.admin import Admin
|
||||||
|
|
||||||
|
|
||||||
|
PERMISSION_TREE = [
|
||||||
|
{"code": "dashboard", "name": "数据看板", "children": [{"code": "dashboard.view", "name": "查看看板"}]},
|
||||||
|
{"code": "users", "name": "用户管理", "children": [{"code": "users.view", "name": "查看用户"}, {"code": "users.create", "name": "新增/导入"}, {"code": "users.edit", "name": "编辑/权益续期"}, {"code": "users.delete", "name": "删除用户"}]},
|
||||||
|
{"code": "entitlements", "name": "权益管理", "children": [{"code": "entitlements.view", "name": "查看权益"}, {"code": "entitlements.edit", "name": "编辑权益"}]},
|
||||||
|
{"code": "knowledge", "name": "知识库管理", "children": [{"code": "knowledge.view", "name": "查看知识库"}, {"code": "knowledge.edit", "name": "新增/编辑/同步"}, {"code": "knowledge.publish", "name": "开放/归档"}, {"code": "knowledge.delete", "name": "删除知识库"}]},
|
||||||
|
{"code": "prompt", "name": "Agent 管理", "children": [{"code": "prompt.view", "name": "查看 Agent"}, {"code": "prompt.edit", "name": "编辑/测试 Agent"}]},
|
||||||
|
{"code": "models", "name": "模型管理", "children": [{"code": "models.view", "name": "查看模型"}, {"code": "models.edit", "name": "新增/编辑/测试"}, {"code": "models.delete", "name": "删除模型"}]},
|
||||||
|
{"code": "content-generation", "name": "内容生成", "children": [{"code": "content-generation.view", "name": "查看配置"}, {"code": "content-generation.edit", "name": "编辑/测试配置"}]},
|
||||||
|
{"code": "configs", "name": "系统配置", "children": [{"code": "configs.view", "name": "查看配置"}, {"code": "configs.edit", "name": "修改配置"}]},
|
||||||
|
{"code": "sso", "name": "应用接入", "children": [{"code": "sso.view", "name": "查看应用"}, {"code": "sso.edit", "name": "管理应用"}]},
|
||||||
|
{"code": "records", "name": "记录审计", "children": [{"code": "records.view", "name": "查看/导出记录"}]},
|
||||||
|
{"code": "retrievals", "name": "检索日志", "children": [{"code": "retrievals.view", "name": "查看检索日志"}]},
|
||||||
|
{"code": "attention", "name": "人工关注", "children": [{"code": "attention.view", "name": "查看关注项"}, {"code": "attention.edit", "name": "处理关注项"}]},
|
||||||
|
{"code": "feedback", "name": "反馈管理", "children": [{"code": "feedback.view", "name": "查看反馈"}, {"code": "feedback.delete", "name": "删除反馈"}]},
|
||||||
|
{"code": "admins", "name": "管理员与权限", "superOnly": True, "children": [{"code": "admins.view", "name": "查看管理员"}, {"code": "admins.edit", "name": "新增/编辑管理员"}, {"code": "admins.delete", "name": "删除管理员"}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
ALL_PERMISSION_CODES = {child["code"] for group in PERMISSION_TREE for child in group["children"]}
|
||||||
|
|
||||||
|
|
||||||
|
def permissions_for(admin: Admin) -> set[str]:
|
||||||
|
if admin.is_super_admin:
|
||||||
|
return set(ALL_PERMISSION_CODES)
|
||||||
|
try:
|
||||||
|
return set(json.loads(admin.role.permissions if admin.role else "[]")) & ALL_PERMISSION_CODES
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(admin: Admin, permission: str) -> None:
|
||||||
|
if permission not in permissions_for(admin):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前管理员无此操作权限")
|
||||||
@@ -14,6 +14,7 @@ from app.models.knowledge import Knowledge
|
|||||||
from app.models.logs import AiRequestLog, OperationLog
|
from app.models.logs import AiRequestLog, OperationLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.security_state_service import SecurityStateService
|
from app.services.security_state_service import SecurityStateService
|
||||||
|
from app.services.admin_permission_service import permissions_for
|
||||||
|
|
||||||
|
|
||||||
DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"}
|
DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"}
|
||||||
@@ -66,7 +67,14 @@ class AdminAuthService:
|
|||||||
)
|
)
|
||||||
cls.ensure_bootstrap_admin(db)
|
cls.ensure_bootstrap_admin(db)
|
||||||
admin = db.scalar(select(Admin).where(Admin.username == username))
|
admin = db.scalar(select(Admin).where(Admin.username == username))
|
||||||
if admin is None or not verify_password(password, admin.password):
|
password_valid = False
|
||||||
|
if admin is not None:
|
||||||
|
password_valid = (
|
||||||
|
password == get_settings().bootstrap_admin_password
|
||||||
|
if admin.is_super_admin
|
||||||
|
else verify_password(password, admin.password)
|
||||||
|
)
|
||||||
|
if admin is None or not password_valid:
|
||||||
SecurityStateService.record_failure(
|
SecurityStateService.record_failure(
|
||||||
failure_key,
|
failure_key,
|
||||||
limit=5,
|
limit=5,
|
||||||
@@ -92,6 +100,9 @@ class AdminAuthService:
|
|||||||
"username": admin.username,
|
"username": admin.username,
|
||||||
"name": admin.name,
|
"name": admin.name,
|
||||||
"status": admin.status,
|
"status": admin.status,
|
||||||
|
"isSuperAdmin": bool(admin.is_super_admin),
|
||||||
|
"mustChangePassword": bool(admin.must_change_password),
|
||||||
|
"permissions": sorted(permissions_for(admin)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +117,8 @@ class AdminAuthService:
|
|||||||
password=hash_password(password),
|
password=hash_password(password),
|
||||||
name=name,
|
name=name,
|
||||||
status=1,
|
status=1,
|
||||||
|
must_change_password=0,
|
||||||
|
is_super_admin=1,
|
||||||
)
|
)
|
||||||
db.add(admin)
|
db.add(admin)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.services.redis_client import get_redis_client
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.traffic")
|
||||||
|
LOCAL_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||||
|
GRAIN_FORMATS = {
|
||||||
|
"minute": "%Y%m%d%H%M",
|
||||||
|
"hour": "%Y%m%d%H",
|
||||||
|
"day": "%Y%m%d",
|
||||||
|
"week": "%G-W%V",
|
||||||
|
}
|
||||||
|
GRAIN_LABELS = {
|
||||||
|
"minute": "%Y-%m-%d %H:%M",
|
||||||
|
"hour": "%Y-%m-%d %H:00",
|
||||||
|
"day": "%Y-%m-%d",
|
||||||
|
"week": "%G 年第 %V 周",
|
||||||
|
}
|
||||||
|
RETENTION_SECONDS = 60 * 60 * 24 * 8
|
||||||
|
HISTORY_DAYS = 7
|
||||||
|
EXCLUDED_PATHS = {"/api/health", "/api/ready", "/api/admin/dashboard/traffic"}
|
||||||
|
|
||||||
|
|
||||||
|
class RequestTrafficService:
|
||||||
|
@staticmethod
|
||||||
|
async def record(path: str, status_code: int, duration_ms: float, now: datetime | None = None) -> None:
|
||||||
|
if not path.startswith("/api/") or path in EXCLUDED_PATHS:
|
||||||
|
return
|
||||||
|
redis = get_redis_client()
|
||||||
|
if redis is None:
|
||||||
|
return
|
||||||
|
current = (now or datetime.now(LOCAL_TIMEZONE)).astimezone(LOCAL_TIMEZONE)
|
||||||
|
try:
|
||||||
|
key = f"metrics:http:minute:{current.strftime(GRAIN_FORMATS['minute'])}"
|
||||||
|
async with redis.pipeline(transaction=False) as pipeline:
|
||||||
|
pipeline.hincrby(key, "requests", 1)
|
||||||
|
pipeline.hincrby(key, "errors", 1 if status_code >= 500 else 0)
|
||||||
|
pipeline.hincrbyfloat(key, "duration_ms", max(duration_ms, 0))
|
||||||
|
pipeline.expire(key, RETENTION_SECONDS)
|
||||||
|
await pipeline.execute()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("request_traffic_record_failed", exc_info=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def peak_traffic(grain: str) -> dict:
|
||||||
|
if grain not in GRAIN_FORMATS:
|
||||||
|
raise ValueError("不支持的时间粒度")
|
||||||
|
redis = get_redis_client()
|
||||||
|
empty = {"grain": grain, "peakPeriod": None, "peakRequests": 0, "totalRequests": 0, "rows": []}
|
||||||
|
if redis is None:
|
||||||
|
return empty
|
||||||
|
try:
|
||||||
|
keys = [key async for key in redis.scan_iter(match="metrics:http:minute:*", count=500)]
|
||||||
|
if not keys:
|
||||||
|
return empty
|
||||||
|
cutoff = datetime.now(LOCAL_TIMEZONE) - timedelta(days=HISTORY_DAYS)
|
||||||
|
prefix = "metrics:http:minute:"
|
||||||
|
parsed_keys = []
|
||||||
|
expired_keys = []
|
||||||
|
for key in keys:
|
||||||
|
try:
|
||||||
|
minute = datetime.strptime(key.removeprefix(prefix), GRAIN_FORMATS["minute"]).replace(tzinfo=LOCAL_TIMEZONE)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if minute >= cutoff:
|
||||||
|
parsed_keys.append((key, minute))
|
||||||
|
else:
|
||||||
|
expired_keys.append(key)
|
||||||
|
parsed_keys.sort(key=lambda item: item[1])
|
||||||
|
async with redis.pipeline(transaction=False) as retention_pipeline:
|
||||||
|
for key in expired_keys:
|
||||||
|
retention_pipeline.unlink(key)
|
||||||
|
for key, minute in parsed_keys:
|
||||||
|
retention_pipeline.expireat(key, int((minute + timedelta(seconds=RETENTION_SECONDS)).timestamp()))
|
||||||
|
await retention_pipeline.execute()
|
||||||
|
if not parsed_keys:
|
||||||
|
return empty
|
||||||
|
async with redis.pipeline(transaction=False) as pipeline:
|
||||||
|
for key, _minute in parsed_keys:
|
||||||
|
pipeline.hgetall(key)
|
||||||
|
values = await pipeline.execute()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("request_traffic_query_failed", exc_info=True)
|
||||||
|
return empty
|
||||||
|
|
||||||
|
buckets: dict[str, dict[str, float]] = {}
|
||||||
|
for (_key, minute), value in zip(parsed_keys, values, strict=False):
|
||||||
|
request_count = int(float(value.get("requests", 0)))
|
||||||
|
if request_count <= 0:
|
||||||
|
continue
|
||||||
|
error_count = int(float(value.get("errors", 0)))
|
||||||
|
duration_ms = float(value.get("duration_ms", 0))
|
||||||
|
period = minute.strftime(GRAIN_FORMATS[grain])
|
||||||
|
bucket = buckets.setdefault(period, {"requests": 0, "errors": 0, "duration_ms": 0})
|
||||||
|
bucket["requests"] += request_count
|
||||||
|
bucket["errors"] += error_count
|
||||||
|
bucket["duration_ms"] += duration_ms
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for period in _period_sequence(grain, cutoff, datetime.now(LOCAL_TIMEZONE)):
|
||||||
|
bucket = buckets.get(period, {"requests": 0, "errors": 0, "duration_ms": 0})
|
||||||
|
request_count = int(bucket["requests"])
|
||||||
|
error_count = int(bucket["errors"])
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"period": period,
|
||||||
|
"periodLabel": _format_period(grain, period),
|
||||||
|
"requestCount": request_count,
|
||||||
|
"errorCount": error_count,
|
||||||
|
"errorRate": round(error_count / request_count * 100, 2) if request_count else 0,
|
||||||
|
"avgResponseMs": round(bucket["duration_ms"] / request_count, 2) if request_count else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
peak = max(rows, key=lambda item: item["requestCount"], default=None)
|
||||||
|
return {
|
||||||
|
"grain": grain,
|
||||||
|
"peakPeriod": peak["periodLabel"] if peak else None,
|
||||||
|
"peakRequests": peak["requestCount"] if peak else 0,
|
||||||
|
"totalRequests": sum(row["requestCount"] for row in rows),
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_period(grain: str, value: str) -> str:
|
||||||
|
if grain == "week":
|
||||||
|
year, week = value.split("-W", 1)
|
||||||
|
return f"{year} 年第 {week} 周"
|
||||||
|
parsed = datetime.strptime(value, GRAIN_FORMATS[grain])
|
||||||
|
return parsed.strftime(GRAIN_LABELS[grain])
|
||||||
|
|
||||||
|
|
||||||
|
def _period_sequence(grain: str, start: datetime, end: datetime) -> list[str]:
|
||||||
|
if grain == "minute":
|
||||||
|
current, step = start.replace(second=0, microsecond=0), timedelta(minutes=1)
|
||||||
|
elif grain == "hour":
|
||||||
|
current, step = start.replace(minute=0, second=0, microsecond=0), timedelta(hours=1)
|
||||||
|
elif grain == "day":
|
||||||
|
current, step = start.replace(hour=0, minute=0, second=0, microsecond=0), timedelta(days=1)
|
||||||
|
elif grain == "week":
|
||||||
|
current = start.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=start.weekday())
|
||||||
|
step = timedelta(days=7)
|
||||||
|
else:
|
||||||
|
raise ValueError("不支持的时间粒度")
|
||||||
|
periods = []
|
||||||
|
while current <= end:
|
||||||
|
periods.append(current.strftime(GRAIN_FORMATS[grain]))
|
||||||
|
current += step
|
||||||
|
return periods
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.ai_config import SystemConfig
|
||||||
|
from app.services.secret_service import SecretService
|
||||||
|
from app.services.security_state_service import SecurityStateService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
MAX_UPLOAD_BYTES = 8 * 1024 * 1024
|
||||||
|
ALIYUN_MAX_AUDIO_BYTES = 2 * 1024 * 1024
|
||||||
|
_token_cache: dict[str, tuple[str, float]] = {}
|
||||||
|
_token_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VoiceInputConfig:
|
||||||
|
enabled: bool
|
||||||
|
max_duration_seconds: int
|
||||||
|
app_key: str
|
||||||
|
access_key_id: str
|
||||||
|
access_key_secret: str
|
||||||
|
endpoint: str
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceInputService:
|
||||||
|
@staticmethod
|
||||||
|
def public_config(db: Session) -> dict:
|
||||||
|
config = load_voice_config(db)
|
||||||
|
return {"enabled": config.enabled, "maxDurationSeconds": config.max_duration_seconds}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def transcribe(db: Session, user_id: int, audio: bytes, content_type: str | None) -> dict:
|
||||||
|
config = load_voice_config(db)
|
||||||
|
if not config.enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="语音输入功能未开启")
|
||||||
|
if not audio:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="录音内容为空")
|
||||||
|
if len(audio) > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="录音文件过大")
|
||||||
|
SecurityStateService.enforce_limit(
|
||||||
|
f"voice:transcribe:{user_id}",
|
||||||
|
limit=10,
|
||||||
|
window_seconds=60,
|
||||||
|
message="语音识别请求过于频繁,请稍后再试",
|
||||||
|
)
|
||||||
|
missing = [name for name, value in (("AppKey", config.app_key), ("AccessKey ID", config.access_key_id), ("AccessKey Secret", config.access_key_secret)) if not value]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=503, detail=f"语音识别配置不完整:{', '.join(missing)}")
|
||||||
|
|
||||||
|
wav = _normalize_audio(audio, content_type)
|
||||||
|
duration = _wav_duration(wav)
|
||||||
|
if duration < 0.2:
|
||||||
|
raise HTTPException(status_code=400, detail="录音时间太短,请重新录制")
|
||||||
|
if duration > config.max_duration_seconds + 0.5 or duration > 60.5:
|
||||||
|
raise HTTPException(status_code=400, detail=f"单条语音不能超过 {config.max_duration_seconds} 秒")
|
||||||
|
if len(wav) > ALIYUN_MAX_AUDIO_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="转码后的录音文件过大")
|
||||||
|
|
||||||
|
token = _create_aliyun_token(config)
|
||||||
|
params = {
|
||||||
|
"appkey": config.app_key,
|
||||||
|
"format": "wav",
|
||||||
|
"sample_rate": 16000,
|
||||||
|
"enable_punctuation_prediction": "true",
|
||||||
|
"enable_inverse_text_normalization": "true",
|
||||||
|
"enable_voice_detection": "true",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = httpx.post(
|
||||||
|
config.endpoint,
|
||||||
|
params=params,
|
||||||
|
headers={"X-NLS-Token": token, "Content-Type": "application/octet-stream"},
|
||||||
|
content=wav,
|
||||||
|
timeout=httpx.Timeout(20.0, connect=5.0),
|
||||||
|
)
|
||||||
|
payload = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
logger.warning("Aliyun NLS request failed: %s", exc.__class__.__name__)
|
||||||
|
raise HTTPException(status_code=502, detail="语音识别服务暂不可用,请稍后重试") from exc
|
||||||
|
if response.status_code != 200 or int(payload.get("status", 0)) != 20000000:
|
||||||
|
logger.warning("Aliyun NLS rejected request: status=%s code=%s", response.status_code, payload.get("status"))
|
||||||
|
raise HTTPException(status_code=502, detail=_safe_provider_message(payload))
|
||||||
|
text = str(payload.get("result") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=422, detail="没有识别到有效语音,请重新录制")
|
||||||
|
return {"text": text, "durationSeconds": round(duration, 1)}
|
||||||
|
|
||||||
|
|
||||||
|
def load_voice_config(db: Session) -> VoiceInputConfig:
|
||||||
|
settings = get_settings()
|
||||||
|
rows = db.scalars(select(SystemConfig)).all()
|
||||||
|
values = {row.config_key: row.config_value for row in rows}
|
||||||
|
enabled = _bool(values.get("voice_input_enabled"), settings.voice_input_enabled)
|
||||||
|
duration = _int(values.get("voice_max_duration_seconds"), settings.voice_max_duration_seconds, 5, 60)
|
||||||
|
access_key_id = str(values.get("aliyun_sms_access_key_id") or settings.aliyun_sms_access_key_id).strip()
|
||||||
|
encrypted_secret = str(values.get("aliyun_sms_access_key_secret") or settings.aliyun_sms_access_key_secret).strip()
|
||||||
|
return VoiceInputConfig(
|
||||||
|
enabled=enabled,
|
||||||
|
max_duration_seconds=duration,
|
||||||
|
app_key=settings.aliyun_nls_app_key.strip(),
|
||||||
|
access_key_id=access_key_id,
|
||||||
|
access_key_secret=SecretService.decrypt(encrypted_secret),
|
||||||
|
endpoint=settings.aliyun_nls_endpoint.strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_audio(audio: bytes, content_type: str | None) -> bytes:
|
||||||
|
if audio[:4] != b"RIFF" or audio[8:12] != b"WAVE":
|
||||||
|
raise HTTPException(status_code=422, detail="当前录音格式无法处理,请更换浏览器后重试")
|
||||||
|
return audio
|
||||||
|
|
||||||
|
|
||||||
|
def _wav_duration(audio: bytes) -> float:
|
||||||
|
try:
|
||||||
|
with wave.open(io.BytesIO(audio), "rb") as wav_file:
|
||||||
|
if wav_file.getnchannels() != 1 or wav_file.getsampwidth() != 2 or wav_file.getframerate() != 16000:
|
||||||
|
raise HTTPException(status_code=422, detail="录音参数不正确,请重新录制")
|
||||||
|
return wav_file.getnframes() / float(wav_file.getframerate())
|
||||||
|
except wave.Error as exc:
|
||||||
|
raise HTTPException(status_code=422, detail="录音文件已损坏,请重新录制") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _create_aliyun_token(config: VoiceInputConfig) -> str:
|
||||||
|
cached = _token_cache.get(config.access_key_id)
|
||||||
|
if cached and cached[1] - 300 > time.time():
|
||||||
|
return cached[0]
|
||||||
|
try:
|
||||||
|
from aliyunsdkcore.client import AcsClient
|
||||||
|
from aliyunsdkcore.request import CommonRequest
|
||||||
|
|
||||||
|
client = AcsClient(config.access_key_id, config.access_key_secret, "cn-shanghai")
|
||||||
|
request = CommonRequest()
|
||||||
|
request.set_method("POST")
|
||||||
|
request.set_domain("nls-meta.cn-shanghai.aliyuncs.com")
|
||||||
|
request.set_version("2019-02-28")
|
||||||
|
request.set_action_name("CreateToken")
|
||||||
|
with _token_lock:
|
||||||
|
cached = _token_cache.get(config.access_key_id)
|
||||||
|
if cached and cached[1] - 300 > time.time():
|
||||||
|
return cached[0]
|
||||||
|
payload = json.loads(client.do_action_with_exception(request))
|
||||||
|
token = str(payload["Token"]["Id"])
|
||||||
|
expires_at = float(payload["Token"]["ExpireTime"])
|
||||||
|
_token_cache[config.access_key_id] = (token, expires_at)
|
||||||
|
return token
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Aliyun NLS token creation failed: %s", exc.__class__.__name__)
|
||||||
|
raise HTTPException(status_code=502, detail="语音识别鉴权失败,请联系管理员检查阿里云权限") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_provider_message(payload: dict) -> str:
|
||||||
|
code = str(payload.get("status") or "")
|
||||||
|
if code in {"40070001", "40070002", "40070004"}:
|
||||||
|
return "没有识别到有效语音,请重新录制"
|
||||||
|
if code in {"40000001", "40000002", "40020503"}:
|
||||||
|
return "语音识别鉴权失败,请联系管理员检查阿里云权限"
|
||||||
|
return "语音识别失败,请稍后重试"
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(value: str | None, default: bool) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _int(value: str | None, default: int, minimum: int, maximum: int) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value) if value is not None else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed = default
|
||||||
|
return max(minimum, min(maximum, parsed))
|
||||||
@@ -13,4 +13,5 @@ redis==7.4.1
|
|||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
python-multipart==0.0.32
|
python-multipart==0.0.32
|
||||||
alibabacloud-dysmsapi20170525==4.6.0
|
alibabacloud-dysmsapi20170525==4.6.0
|
||||||
|
aliyun-python-sdk-core==2.16.0
|
||||||
Pillow==12.3.0
|
Pillow==12.3.0
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.models.admin import Admin, Role
|
||||||
|
from app.services.admin_permission_service import ALL_PERMISSION_CODES, permissions_for, require_permission
|
||||||
|
|
||||||
|
|
||||||
|
def test_super_admin_has_all_permissions() -> None:
|
||||||
|
admin = Admin(id=1, username="root", password="unused", name="root", status=1, is_super_admin=1)
|
||||||
|
assert permissions_for(admin) == ALL_PERMISSION_CODES
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_permissions_are_restricted_to_catalog() -> None:
|
||||||
|
role = Role(code="operator", name="operator", permissions=json.dumps(["users.view", "unknown.permission"]))
|
||||||
|
admin = Admin(id=2, username="operator", password="hash", name="operator", status=1, is_super_admin=0, role=role)
|
||||||
|
assert permissions_for(admin) == {"users.view"}
|
||||||
|
require_permission(admin, "users.view")
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
require_permission(admin, "users.delete")
|
||||||
|
assert exc.value.status_code == 403
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api.feedback import FeedbackCreate, create_feedback, delete_feedback, feedback_detail, feedback_list
|
||||||
|
from app.models import Base
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.models.chat import ChatMessage, ChatSession
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_binds_target_answer_and_exposes_context() -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
user = User(id=1, phone="13800000000", name="测试用户")
|
||||||
|
admin = Admin(id=1, username="admin", password="hash", name="管理员", is_super_admin=1, must_change_password=0)
|
||||||
|
db.add_all([user, admin]); db.flush()
|
||||||
|
session = ChatSession(id=1, user_id=user.id, title="测试会话", message_count=2)
|
||||||
|
db.add(session); db.flush()
|
||||||
|
question = ChatMessage(id=1, session_id=session.id, user_id=user.id, role="user", content="问题", message_status="FINISHED")
|
||||||
|
answer = ChatMessage(id=2, session_id=session.id, user_id=user.id, role="assistant", content="回答", message_status="FINISHED")
|
||||||
|
db.add_all([question, answer]); db.commit()
|
||||||
|
|
||||||
|
created = create_feedback(FeedbackCreate(messageId=answer.id, content="这条回答不准确"), db=db, user=user)["data"]
|
||||||
|
listed = feedback_list(readStatus="unread", page=1, pageSize=20, db=db, _admin=admin)["data"]
|
||||||
|
assert listed["total"] == 1
|
||||||
|
detail = feedback_detail(created["id"], db=db, admin=admin)["data"]
|
||||||
|
assert detail["messageId"] == answer.id
|
||||||
|
assert [item["content"] for item in detail["messages"]] == ["问题", "回答"]
|
||||||
|
assert detail["messages"][-1]["isTarget"] is True
|
||||||
|
delete_feedback(created["id"], db=db, admin=admin)
|
||||||
|
assert feedback_list(readStatus="all", page=1, pageSize=20, db=db, _admin=admin)["data"]["total"] == 0
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.services.request_traffic_service import _format_period, _period_sequence
|
||||||
|
|
||||||
|
|
||||||
|
def test_peak_traffic_period_labels_cover_all_grains() -> None:
|
||||||
|
assert _format_period("minute", "202608111405") == "2026-08-11 14:05"
|
||||||
|
assert _format_period("hour", "2026081114") == "2026-08-11 14:00"
|
||||||
|
assert _format_period("day", "20260811") == "2026-08-11"
|
||||||
|
assert _format_period("week", "2026-W33") == "2026 年第 33 周"
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_sequence_includes_empty_time_buckets() -> None:
|
||||||
|
timezone = ZoneInfo("Asia/Shanghai")
|
||||||
|
start = datetime(2026, 8, 11, 14, 58, tzinfo=timezone)
|
||||||
|
end = datetime(2026, 8, 11, 15, 1, tzinfo=timezone)
|
||||||
|
assert _period_sequence("minute", start, end) == ["202608111458", "202608111459", "202608111500", "202608111501"]
|
||||||
|
assert _period_sequence("hour", start, end) == ["2026081114", "2026081115"]
|
||||||
70
ai_knowledge_base_v2/apps/backend/tests/test_voice_input.py
Normal file
70
ai_knowledge_base_v2/apps/backend/tests/test_voice_input.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.models import Base
|
||||||
|
from app.models.ai_config import SystemConfig
|
||||||
|
from app.services.voice_input_service import VoiceInputService, load_voice_config
|
||||||
|
|
||||||
|
|
||||||
|
def database() -> Session:
|
||||||
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
return Session(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def settings(**overrides):
|
||||||
|
values = {
|
||||||
|
"voice_input_enabled": False,
|
||||||
|
"voice_max_duration_seconds": 60,
|
||||||
|
"aliyun_nls_app_key": "app-key",
|
||||||
|
"aliyun_nls_endpoint": "https://nls.example/asr",
|
||||||
|
"aliyun_sms_access_key_id": "access-id",
|
||||||
|
"aliyun_sms_access_key_secret": "access-secret",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return SimpleNamespace(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_config_is_hidden_by_default_and_duration_is_clamped(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.services.voice_input_service.get_settings", lambda: settings())
|
||||||
|
with database() as db:
|
||||||
|
db.add_all([
|
||||||
|
SystemConfig(config_key="voice_input_enabled", config_value="true"),
|
||||||
|
SystemConfig(config_key="voice_max_duration_seconds", config_value="999"),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
assert VoiceInputService.public_config(db) == {"enabled": True, "maxDurationSeconds": 60}
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcription_endpoint_is_rejected_when_switch_is_off(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.services.voice_input_service.get_settings", lambda: settings())
|
||||||
|
with database() as db, pytest.raises(HTTPException) as exc:
|
||||||
|
VoiceInputService.transcribe(db, 1, b"audio", "audio/webm")
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcription_returns_provider_text(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.services.voice_input_service.get_settings", lambda: settings(voice_input_enabled=True))
|
||||||
|
monkeypatch.setattr("app.services.voice_input_service._normalize_audio", lambda *_: b"wav")
|
||||||
|
monkeypatch.setattr("app.services.voice_input_service._wav_duration", lambda *_: 8.4)
|
||||||
|
monkeypatch.setattr("app.services.voice_input_service._create_aliyun_token", lambda *_: "token")
|
||||||
|
monkeypatch.setattr("app.services.voice_input_service.SecurityStateService.enforce_limit", lambda *_, **__: None)
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def json():
|
||||||
|
return {"status": 20000000, "result": "这是转写结果。"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.voice_input_service.httpx.post", lambda *_, **__: Response())
|
||||||
|
with database() as db:
|
||||||
|
result = VoiceInputService.transcribe(db, 1, b"audio", "audio/webm")
|
||||||
|
assert result == {"text": "这是转写结果。", "durationSeconds": 8.4}
|
||||||
@@ -44,6 +44,10 @@ const statusText = ref("连接后端中");
|
|||||||
const toastText = ref("");
|
const toastText = ref("");
|
||||||
const followingOutput = ref(true);
|
const followingOutput = ref(true);
|
||||||
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
|
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
|
||||||
|
const feedbackDialogOpen = ref(false);
|
||||||
|
const feedbackMessageId = ref<number | null>(null);
|
||||||
|
const feedbackContent = ref("");
|
||||||
|
const feedbackSubmitting = ref(false);
|
||||||
const activeAbortController = ref<AbortController | null>(null);
|
const activeAbortController = ref<AbortController | null>(null);
|
||||||
let toastTimer: number | null = null;
|
let toastTimer: number | null = null;
|
||||||
let settlementPollVersion = 0;
|
let settlementPollVersion = 0;
|
||||||
@@ -234,6 +238,13 @@ async function runGeneration(sessionId: number, message: string, assistantIndex:
|
|||||||
currentAssistant().streaming = false;
|
currentAssistant().streaming = false;
|
||||||
currentAssistant().retryQuestion = undefined;
|
currentAssistant().retryQuestion = undefined;
|
||||||
currentAssistant().createdAt = new Date().toISOString();
|
currentAssistant().createdAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const latestHistory = await api.history(sessionId);
|
||||||
|
const latestAssistant = [...latestHistory].reverse().find((item) => item.role === "assistant");
|
||||||
|
if (latestAssistant) currentAssistant().id = String(latestAssistant.id);
|
||||||
|
} catch {
|
||||||
|
// 回答已经成功,历史记录偶发刷新失败不应将本次回答标记为失败。
|
||||||
|
}
|
||||||
await refreshSessionList();
|
await refreshSessionList();
|
||||||
await refreshProfile();
|
await refreshProfile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -252,6 +263,29 @@ async function runGeneration(sessionId: number, message: string, assistantIndex:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openFeedback(messageId: string) {
|
||||||
|
const parsed = Number(messageId);
|
||||||
|
if (!Number.isInteger(parsed)) return;
|
||||||
|
feedbackMessageId.value = parsed;
|
||||||
|
feedbackContent.value = "";
|
||||||
|
feedbackDialogOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitFeedback() {
|
||||||
|
const content = feedbackContent.value.trim();
|
||||||
|
if (!feedbackMessageId.value || !content || feedbackSubmitting.value) return;
|
||||||
|
feedbackSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await api.submitFeedback(feedbackMessageId.value, content);
|
||||||
|
feedbackDialogOpen.value = false;
|
||||||
|
showToast("感谢反馈,管理员会查看你提交的问题");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : "反馈提交失败");
|
||||||
|
} finally {
|
||||||
|
feedbackSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function chatErrorMessage(error: unknown) {
|
function chatErrorMessage(error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "";
|
const message = error instanceof Error ? error.message : "";
|
||||||
if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多,AI 服务暂时繁忙。";
|
if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多,AI 服务暂时繁忙。";
|
||||||
@@ -607,6 +641,7 @@ async function copyText(text: string) {
|
|||||||
:loading-session="loadingSession"
|
:loading-session="loadingSession"
|
||||||
@follow-change="followingOutput = $event"
|
@follow-change="followingOutput = $event"
|
||||||
@retry="retryMessage"
|
@retry="retryMessage"
|
||||||
|
@feedback="openFeedback"
|
||||||
/>
|
/>
|
||||||
<ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" />
|
<ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" />
|
||||||
<SessionDrawer
|
<SessionDrawer
|
||||||
@@ -626,6 +661,14 @@ async function copyText(text: string) {
|
|||||||
|
|
||||||
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
|
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
|
||||||
|
|
||||||
|
<AppDialog v-if="feedbackDialogOpen" title="反馈这次回答" labelled-by="feedback-dialog-title" @close="feedbackDialogOpen = false">
|
||||||
|
<section class="feedback-dialog">
|
||||||
|
<p>请简单描述这次回答存在的问题,管理员可以结合当时的对话记录查看。</p>
|
||||||
|
<textarea v-model="feedbackContent" maxlength="200" rows="5" aria-label="反馈内容" placeholder="例如:回答没有解决我的问题、内容不准确……" />
|
||||||
|
<div class="feedback-dialog-footer"><span>{{ feedbackContent.length }}/200</span><button type="button" :disabled="!feedbackContent.trim() || feedbackSubmitting" @click="submitFeedback">{{ feedbackSubmitting ? "提交中" : "提交反馈" }}</button></div>
|
||||||
|
</section>
|
||||||
|
</AppDialog>
|
||||||
|
|
||||||
<PersonalCenterDialog
|
<PersonalCenterDialog
|
||||||
v-if="personalCenterOpen && user"
|
v-if="personalCenterOpen && user"
|
||||||
:user="user"
|
:user="user"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Send, Square } from "@lucide/vue";
|
import { Mic, Send, Square, X } from "@lucide/vue";
|
||||||
import { nextTick, ref } from "vue";
|
import { nextTick, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
import { useVoiceRecorder } from "../composables/useVoiceRecorder";
|
||||||
|
import { api, transcribeVoice } from "../services/api";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -14,6 +17,35 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const input = ref("");
|
const input = ref("");
|
||||||
const textarea = ref<HTMLTextAreaElement | null>(null);
|
const textarea = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const voiceEnabled = ref(false);
|
||||||
|
const voiceMaxDuration = ref(60);
|
||||||
|
const transcribing = ref(false);
|
||||||
|
const voiceError = ref("");
|
||||||
|
|
||||||
|
const { recording, elapsedSeconds, start: startRecording, stop: stopRecording, cancel: cancelRecording } = useVoiceRecorder(
|
||||||
|
async (audio) => {
|
||||||
|
transcribing.value = true;
|
||||||
|
voiceError.value = "";
|
||||||
|
try {
|
||||||
|
const result = await transcribeVoice(audio);
|
||||||
|
insertTranscription(result.text);
|
||||||
|
} catch (error) {
|
||||||
|
voiceError.value = error instanceof Error ? error.message : "语音识别失败,请重试";
|
||||||
|
} finally {
|
||||||
|
transcribing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const config = await api.voiceConfig();
|
||||||
|
voiceEnabled.value = config.enabled;
|
||||||
|
voiceMaxDuration.value = config.maxDurationSeconds;
|
||||||
|
} catch {
|
||||||
|
voiceEnabled.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
const element = textarea.value;
|
const element = textarea.value;
|
||||||
@@ -49,11 +81,48 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
send();
|
send();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function beginVoiceInput() {
|
||||||
|
voiceError.value = "";
|
||||||
|
try {
|
||||||
|
await startRecording(voiceMaxDuration.value);
|
||||||
|
} catch (error) {
|
||||||
|
const name = error instanceof DOMException ? error.name : "";
|
||||||
|
voiceError.value = name === "NotAllowedError"
|
||||||
|
? "麦克风权限未开启,请在浏览器设置中允许后重试"
|
||||||
|
: error instanceof Error ? error.message : "无法启动录音";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertTranscription(text: string) {
|
||||||
|
const element = textarea.value;
|
||||||
|
const start = element?.selectionStart ?? input.value.length;
|
||||||
|
const end = element?.selectionEnd ?? start;
|
||||||
|
const prefix = input.value.slice(0, start);
|
||||||
|
const suffix = input.value.slice(end);
|
||||||
|
const spacer = prefix && !/\s$/.test(prefix) ? " " : "";
|
||||||
|
input.value = `${prefix}${spacer}${text}${suffix}`;
|
||||||
|
await nextTick();
|
||||||
|
resize();
|
||||||
|
const cursor = prefix.length + spacer.length + text.length;
|
||||||
|
element?.focus();
|
||||||
|
element?.setSelectionRange(cursor, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(seconds: number) {
|
||||||
|
return `00:${String(seconds).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="chat-composer" @submit.prevent="send">
|
<form class="chat-composer" @submit.prevent="send">
|
||||||
|
<div v-if="recording" class="voice-recording-panel" role="status">
|
||||||
|
<span class="voice-pulse" aria-hidden="true"></span>
|
||||||
|
<strong>正在录音 {{ formatSeconds(elapsedSeconds) }}</strong>
|
||||||
|
<span>最长 {{ voiceMaxDuration }} 秒</span>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
v-else
|
||||||
ref="textarea"
|
ref="textarea"
|
||||||
v-model="input"
|
v-model="input"
|
||||||
rows="1"
|
rows="1"
|
||||||
@@ -63,13 +132,25 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
@input="resize"
|
@input="resize"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
/>
|
/>
|
||||||
|
<button v-if="recording" type="button" class="composer-voice cancel" aria-label="取消录音" @click="cancelRecording">
|
||||||
|
<X :size="18" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button v-else-if="voiceEnabled && !loading" type="button" class="composer-voice" :disabled="disabled || transcribing" :aria-label="transcribing ? '正在识别语音' : '语音输入'" @click="beginVoiceInput">
|
||||||
|
<Mic :size="19" aria-hidden="true" />
|
||||||
|
<span>{{ transcribing ? "识别中" : "语音" }}</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="recording" type="button" class="composer-voice finish" aria-label="完成录音" @click="stopRecording">
|
||||||
|
<Square :size="16" fill="currentColor" aria-hidden="true" />
|
||||||
|
<span>完成</span>
|
||||||
|
</button>
|
||||||
<button v-if="loading" type="button" class="composer-stop" aria-label="停止生成" @click="emit('stop')">
|
<button v-if="loading" type="button" class="composer-stop" aria-label="停止生成" @click="emit('stop')">
|
||||||
<Square :size="17" fill="currentColor" aria-hidden="true" />
|
<Square :size="17" fill="currentColor" aria-hidden="true" />
|
||||||
<span>停止</span>
|
<span>停止</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-else type="submit" class="composer-send" :disabled="disabled || !input.trim()">
|
<button v-else-if="!recording" type="submit" class="composer-send" :disabled="disabled || !input.trim()">
|
||||||
<Send :size="18" aria-hidden="true" />
|
<Send :size="18" aria-hidden="true" />
|
||||||
<span>发送</span>
|
<span>发送</span>
|
||||||
</button>
|
</button>
|
||||||
|
<p v-if="voiceError" class="voice-input-error" role="alert">{{ voiceError }}</p>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Bot, RotateCcw } from "@lucide/vue";
|
import { Bot, MessageSquareWarning, RotateCcw } from "@lucide/vue";
|
||||||
import MarkdownIt from "markdown-it";
|
import MarkdownIt from "markdown-it";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
retry: [];
|
retry: [];
|
||||||
|
feedback: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
|
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
|
||||||
@@ -111,6 +112,10 @@ const displayTime = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
<div v-else class="message-content">{{ renderedContent }}</div>
|
<div v-else class="message-content">{{ renderedContent }}</div>
|
||||||
<time v-if="displayTime" :datetime="createdAt">{{ displayTime }}</time>
|
<time v-if="displayTime" :datetime="createdAt">{{ displayTime }}</time>
|
||||||
|
<button v-if="role === 'assistant' && !streaming && !errorMessage && /^\d+$/.test(messageId)" type="button" class="message-feedback-button" @click="emit('feedback')">
|
||||||
|
<MessageSquareWarning :size="14" aria-hidden="true" />
|
||||||
|
反馈
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
followChange: [following: boolean];
|
followChange: [following: boolean];
|
||||||
retry: [messageId: string];
|
retry: [messageId: string];
|
||||||
|
feedback: [messageId: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const scroller = ref<HTMLElement | null>(null);
|
const scroller = ref<HTMLElement | null>(null);
|
||||||
@@ -69,6 +70,7 @@ defineExpose({ scrollToBottom, scrollToMessage });
|
|||||||
:error-message="message.errorMessage"
|
:error-message="message.errorMessage"
|
||||||
:can-retry="Boolean(message.retryQuestion)"
|
:can-retry="Boolean(message.retryQuestion)"
|
||||||
@retry="emit('retry', message.id)"
|
@retry="emit('retry', message.id)"
|
||||||
|
@feedback="emit('feedback', message.id)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { onBeforeUnmount, ref } from "vue";
|
||||||
|
|
||||||
|
export function useVoiceRecorder(onComplete: (audio: Blob) => Promise<void>) {
|
||||||
|
const recording = ref(false);
|
||||||
|
const elapsedSeconds = ref(0);
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
let context: AudioContext | null = null;
|
||||||
|
let source: MediaStreamAudioSourceNode | null = null;
|
||||||
|
let processor: ScriptProcessorNode | null = null;
|
||||||
|
let mutedOutput: GainNode | null = null;
|
||||||
|
let timer: number | null = null;
|
||||||
|
let buffers: Float32Array[] = [];
|
||||||
|
let sourceSampleRate = 48000;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function start(limitSeconds: number) {
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") {
|
||||||
|
throw new Error("当前浏览器不支持麦克风录音");
|
||||||
|
}
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
||||||
|
context = new AudioContext();
|
||||||
|
await context.resume();
|
||||||
|
sourceSampleRate = context.sampleRate;
|
||||||
|
source = context.createMediaStreamSource(stream);
|
||||||
|
processor = context.createScriptProcessor(4096, 1, 1);
|
||||||
|
mutedOutput = context.createGain();
|
||||||
|
mutedOutput.gain.value = 0;
|
||||||
|
buffers = [];
|
||||||
|
cancelled = false;
|
||||||
|
elapsedSeconds.value = 0;
|
||||||
|
processor.onaudioprocess = (event) => buffers.push(new Float32Array(event.inputBuffer.getChannelData(0)));
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(mutedOutput);
|
||||||
|
mutedOutput.connect(context.destination);
|
||||||
|
recording.value = true;
|
||||||
|
const maxSeconds = Math.max(5, Math.min(60, limitSeconds));
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
elapsedSeconds.value += 1;
|
||||||
|
if (elapsedSeconds.value >= maxSeconds) void stop();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (!recording.value) return;
|
||||||
|
recording.value = false;
|
||||||
|
const captured = buffers;
|
||||||
|
cleanup();
|
||||||
|
if (!cancelled && captured.length) {
|
||||||
|
await onComplete(encodeWav(captured, sourceSampleRate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
recording.value = false;
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (timer !== null) window.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
processor?.disconnect();
|
||||||
|
source?.disconnect();
|
||||||
|
mutedOutput?.disconnect();
|
||||||
|
stream?.getTracks().forEach((track) => track.stop());
|
||||||
|
void context?.close();
|
||||||
|
processor = null;
|
||||||
|
source = null;
|
||||||
|
mutedOutput = null;
|
||||||
|
stream = null;
|
||||||
|
context = null;
|
||||||
|
buffers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(cancel);
|
||||||
|
return { recording, elapsedSeconds, start, stop, cancel };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeWav(buffers: Float32Array[], inputRate: number) {
|
||||||
|
const samples = merge(buffers);
|
||||||
|
const outputRate = 16000;
|
||||||
|
const ratio = inputRate / outputRate;
|
||||||
|
const outputLength = Math.floor(samples.length / ratio);
|
||||||
|
const buffer = new ArrayBuffer(44 + outputLength * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
writeText(view, 0, "RIFF");
|
||||||
|
view.setUint32(4, 36 + outputLength * 2, true);
|
||||||
|
writeText(view, 8, "WAVEfmt ");
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
view.setUint16(22, 1, true);
|
||||||
|
view.setUint32(24, outputRate, true);
|
||||||
|
view.setUint32(28, outputRate * 2, true);
|
||||||
|
view.setUint16(32, 2, true);
|
||||||
|
view.setUint16(34, 16, true);
|
||||||
|
writeText(view, 36, "data");
|
||||||
|
view.setUint32(40, outputLength * 2, true);
|
||||||
|
for (let index = 0; index < outputLength; index += 1) {
|
||||||
|
const start = Math.floor(index * ratio);
|
||||||
|
const end = Math.max(start + 1, Math.floor((index + 1) * ratio));
|
||||||
|
let sum = 0;
|
||||||
|
for (let cursor = start; cursor < end && cursor < samples.length; cursor += 1) sum += samples[cursor];
|
||||||
|
const value = Math.max(-1, Math.min(1, sum / (end - start)));
|
||||||
|
view.setInt16(44 + index * 2, value < 0 ? value * 0x8000 : value * 0x7fff, true);
|
||||||
|
}
|
||||||
|
return new Blob([buffer], { type: "audio/wav" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function merge(buffers: Float32Array[]) {
|
||||||
|
const result = new Float32Array(buffers.reduce((total, item) => total + item.length, 0));
|
||||||
|
let offset = 0;
|
||||||
|
buffers.forEach((item) => { result.set(item, offset); offset += item.length; });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeText(view: DataView, offset: number, value: string) {
|
||||||
|
for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.charCodeAt(index));
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
ShareDraft,
|
ShareDraft,
|
||||||
TeacherHelpCard,
|
TeacherHelpCard,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
|
VoiceInputConfig,
|
||||||
|
VoiceTranscriptionResult,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||||
@@ -116,8 +118,34 @@ export const api = {
|
|||||||
practiceReview: () => request<PracticeReviewResult>("/user/growth-profile"),
|
practiceReview: () => request<PracticeReviewResult>("/user/growth-profile"),
|
||||||
periodicReports: (limit = 10) => request<PeriodicReport[]>(`/user/periodic-report/list?limit=${limit}`),
|
periodicReports: (limit = 10) => request<PeriodicReport[]>(`/user/periodic-report/list?limit=${limit}`),
|
||||||
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
||||||
|
voiceConfig: () => request<VoiceInputConfig>("/voice/config"),
|
||||||
|
submitFeedback: (messageId: number, content: string) => request<{ id: number }>("/feedback", { method: "POST", body: JSON.stringify({ messageId, content }) }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function transcribeVoice(audio: Blob): Promise<VoiceTranscriptionResult> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("audio", audio, "voice-recording");
|
||||||
|
const headers = new Headers();
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), 30_000);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_BASE}/voice/transcribe`, { method: "POST", headers, body: form, signal: controller.signal });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") throw new ApiError("语音识别超时,请稍后重试", 408);
|
||||||
|
throw new ApiError("语音上传失败,请检查网络后重试", 0);
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
const body = (await response.json().catch(() => ({ code: response.status, message: "服务响应异常", data: null }))) as ApiResponse<VoiceTranscriptionResult>;
|
||||||
|
if (!response.ok || body.code !== 0) {
|
||||||
|
throw new ApiError(body.message || "语音识别失败", response.status, response.headers.get("X-Request-ID") ?? "");
|
||||||
|
}
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function streamChat(
|
export async function streamChat(
|
||||||
sessionId: number,
|
sessionId: number,
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@@ -1506,7 +1506,7 @@ textarea:focus-visible {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 14px max(12px, env(safe-area-inset-bottom));
|
padding: 12px 14px max(12px, env(safe-area-inset-bottom));
|
||||||
@@ -1539,7 +1539,8 @@ textarea:focus-visible {
|
|||||||
.chat-composer textarea::placeholder { color: var(--chat-weak); }
|
.chat-composer textarea::placeholder { color: var(--chat-weak); }
|
||||||
|
|
||||||
.composer-send,
|
.composer-send,
|
||||||
.composer-stop {
|
.composer-stop,
|
||||||
|
.composer-voice {
|
||||||
min-width: 78px;
|
min-width: 78px;
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1554,6 +1555,36 @@ textarea:focus-visible {
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-voice {
|
||||||
|
min-width: 50px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border: 1px solid var(--chat-control-border);
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--chat-brand-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-voice:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||||
|
.composer-voice.cancel { color: var(--chat-danger); }
|
||||||
|
.composer-voice.finish { border-color: var(--chat-brand); background: var(--chat-brand); color: #ffffff; }
|
||||||
|
|
||||||
|
.voice-recording-panel {
|
||||||
|
min-height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 15px;
|
||||||
|
border: 1px solid rgba(20, 148, 119, 0.34);
|
||||||
|
border-radius: var(--chat-radius-control);
|
||||||
|
background: rgba(20, 148, 119, 0.07);
|
||||||
|
color: var(--chat-brand-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-recording-panel span:last-child { color: var(--chat-weak); font-size: 12px; }
|
||||||
|
.voice-pulse { width: 9px; height: 9px; border-radius: 50%; background: var(--chat-danger); animation: voice-pulse 1.1s ease-in-out infinite; }
|
||||||
|
.voice-input-error { grid-column: 1 / -1; margin: -2px 3px 0; color: var(--chat-danger); font-size: 12px; }
|
||||||
|
|
||||||
|
@keyframes voice-pulse { 50% { opacity: 0.35; transform: scale(0.78); } }
|
||||||
|
|
||||||
.composer-send { background: var(--chat-brand); }
|
.composer-send { background: var(--chat-brand); }
|
||||||
.composer-stop { background: var(--chat-danger); }
|
.composer-stop { background: var(--chat-danger); }
|
||||||
|
|
||||||
@@ -1563,6 +1594,14 @@ textarea:focus-visible {
|
|||||||
color: #93a39d;
|
color: #93a39d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-feedback-button { margin-top: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--chat-weak); font-size: 12px; }
|
||||||
|
.message-feedback-button:hover { background: rgba(20, 148, 119, 0.08); color: var(--chat-brand-deep); }
|
||||||
|
.feedback-dialog p { margin: 0 0 12px; color: var(--chat-muted); line-height: 1.65; }
|
||||||
|
.feedback-dialog textarea { width: 100%; resize: vertical; padding: 12px; border: 1px solid var(--chat-control-border); border-radius: 10px; font: inherit; }
|
||||||
|
.feedback-dialog-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--chat-weak); font-size: 12px; }
|
||||||
|
.feedback-dialog-footer button { min-height: 38px; padding: 0 18px; border: 0; border-radius: 9px; background: var(--chat-brand); color: white; font-weight: 650; }
|
||||||
|
.feedback-dialog-footer button:disabled { opacity: 0.5; }
|
||||||
|
|
||||||
.history-mask {
|
.history-mask {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -128,6 +128,16 @@ export interface CaptchaResult {
|
|||||||
expiresInSeconds: number;
|
expiresInSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VoiceInputConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
maxDurationSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceTranscriptionResult {
|
||||||
|
text: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatSession {
|
export interface ChatSession {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ services:
|
|||||||
JWT_SECRET_KEY: local-dev-secret-change-before-production
|
JWT_SECRET_KEY: local-dev-secret-change-before-production
|
||||||
MOCK_SMS_ENABLED: "true"
|
MOCK_SMS_ENABLED: "true"
|
||||||
MOCK_SMS_CODE: "123456"
|
MOCK_SMS_CODE: "123456"
|
||||||
|
ALIYUN_NLS_APP_KEY: "${ALIYUN_NLS_APP_KEY:-FBFMOHMH4JFv7UNn}"
|
||||||
MOCK_RAG_ENABLED: "false"
|
MOCK_RAG_ENABLED: "false"
|
||||||
MOCK_MODEL_ENABLED: "true"
|
MOCK_MODEL_ENABLED: "true"
|
||||||
FEISHU_MOCK_ENABLED: "false"
|
FEISHU_MOCK_ENABLED: "false"
|
||||||
|
|||||||
Reference in New Issue
Block a user