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
|
||||
# 自定义域名,逗号分隔,如: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",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.11.0",
|
||||
"markdown-it": "^14.3.0",
|
||||
"vite": "^7.1.0",
|
||||
@@ -1155,6 +1156,16 @@
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"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": {
|
||||
"version": "2.14.2",
|
||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz",
|
||||
@@ -1535,6 +1546,12 @@
|
||||
"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": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1679,6 +1696,15 @@
|
||||
"peerDependencies": {
|
||||
"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": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.11.0",
|
||||
"markdown-it": "^14.3.0",
|
||||
"vite": "^7.1.0",
|
||||
|
||||
@@ -49,10 +49,26 @@ const SsoIntegrationView = defineAsyncComponent(
|
||||
const SystemConfigView = defineAsyncComponent(
|
||||
() => 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 activeMenu = ref("dashboard");
|
||||
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 models = ref<ModelItem[]>([]);
|
||||
@@ -190,7 +206,8 @@ onMounted(async () => {
|
||||
if (!getToken()) return;
|
||||
try {
|
||||
admin.value = await api.profile();
|
||||
await loadCurrentMenu();
|
||||
passwordDialogVisible.value = admin.value.mustChangePassword;
|
||||
if (!admin.value.mustChangePassword) await enterFirstAvailableMenu();
|
||||
} catch {
|
||||
clearToken();
|
||||
}
|
||||
@@ -202,7 +219,8 @@ async function login(credentials: { username: string; password: string }) {
|
||||
const result = await api.login(credentials.username, credentials.password);
|
||||
saveToken(result.token);
|
||||
admin.value = result.admin;
|
||||
await loadCurrentMenu();
|
||||
passwordDialogVisible.value = result.admin.mustChangePassword;
|
||||
if (!result.admin.mustChangePassword) await enterFirstAvailableMenu();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "登录失败");
|
||||
} finally {
|
||||
@@ -220,10 +238,32 @@ async function logout() {
|
||||
}
|
||||
|
||||
async function switchMenu(menu: string) {
|
||||
if (!can(menuPermissions[menu])) return;
|
||||
activeMenu.value = menu;
|
||||
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) {
|
||||
previewKnowledgeId.value = knowledgeId;
|
||||
await switchMenu("prompt");
|
||||
@@ -596,77 +636,99 @@ async function clearFeishuCache() {
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-title">大本营千问千答</div>
|
||||
<button
|
||||
v-if="can('dashboard.view')"
|
||||
:class="{ active: activeMenu === 'dashboard' }"
|
||||
@click="switchMenu('dashboard')"
|
||||
>
|
||||
数据看板
|
||||
</button>
|
||||
<button
|
||||
v-if="can('users.view')"
|
||||
:class="{ active: activeMenu === 'users' }"
|
||||
@click="switchMenu('users')"
|
||||
>
|
||||
用户管理
|
||||
</button>
|
||||
<button
|
||||
v-if="can('entitlements.view')"
|
||||
:class="{ active: activeMenu === 'entitlements' }"
|
||||
@click="switchMenu('entitlements')"
|
||||
>
|
||||
权益管理
|
||||
</button>
|
||||
<button
|
||||
v-if="can('knowledge.view')"
|
||||
:class="{ active: activeMenu === 'knowledge' }"
|
||||
@click="switchMenu('knowledge')"
|
||||
>
|
||||
知识库管理
|
||||
</button>
|
||||
<button
|
||||
v-if="can('prompt.view')"
|
||||
:class="{ active: activeMenu === 'prompt' }"
|
||||
@click="switchMenu('prompt')"
|
||||
>
|
||||
Agent 管理
|
||||
</button>
|
||||
<button
|
||||
v-if="can('models.view')"
|
||||
:class="{ active: activeMenu === 'models' }"
|
||||
@click="switchMenu('models')"
|
||||
>
|
||||
模型管理
|
||||
</button>
|
||||
<button
|
||||
v-if="can('content-generation.view')"
|
||||
:class="{ active: activeMenu === 'content-generation' }"
|
||||
@click="switchMenu('content-generation')"
|
||||
>
|
||||
内容生成
|
||||
</button>
|
||||
<button
|
||||
v-if="can('configs.view')"
|
||||
:class="{ active: activeMenu === 'configs' }"
|
||||
@click="switchMenu('configs')"
|
||||
>
|
||||
系统配置
|
||||
</button>
|
||||
<button
|
||||
v-if="can('sso.view')"
|
||||
:class="{ active: activeMenu === 'sso' }"
|
||||
@click="switchMenu('sso')"
|
||||
>
|
||||
应用接入
|
||||
</button>
|
||||
<button
|
||||
v-if="can('records.view')"
|
||||
:class="{ active: activeMenu === 'records' }"
|
||||
@click="switchMenu('records')"
|
||||
>
|
||||
记录审计
|
||||
</button>
|
||||
<button
|
||||
v-if="can('retrievals.view')"
|
||||
:class="{ active: activeMenu === 'retrievals' }"
|
||||
@click="switchMenu('retrievals')"
|
||||
>
|
||||
检索日志
|
||||
</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' }"
|
||||
@click="switchMenu('attention')"
|
||||
>
|
||||
人工关注
|
||||
</button>
|
||||
<button
|
||||
v-if="admin.isSuperAdmin"
|
||||
:class="{ active: activeMenu === 'admins' }"
|
||||
@click="switchMenu('admins')"
|
||||
>管理员与权限</button>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
@@ -1057,8 +1119,20 @@ async function clearFeishuCache() {
|
||||
|
||||
<RetrievalLogView v-if="activeMenu === 'retrievals'" />
|
||||
<AttentionManagementView v-if="activeMenu === 'attention'" />
|
||||
<FeedbackManagementView v-if="activeMenu === 'feedback'" :can-delete="can('feedback.delete')" />
|
||||
<RecordAuditView v-if="activeMenu === 'records'" />
|
||||
<AdminManagementView v-if="activeMenu === 'admins'" />
|
||||
</section>
|
||||
</section>
|
||||
</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>
|
||||
|
||||
@@ -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">
|
||||
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 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 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 storageLoading = ref(false);
|
||||
const trafficLoading = ref(false);
|
||||
const loading = ref(false);
|
||||
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() {
|
||||
loading.value = true;
|
||||
try {
|
||||
stats.value = await api.dashboard(filters.start, filters.end);
|
||||
storageLoading.value = true;
|
||||
api
|
||||
.storageStats()
|
||||
.then((value) => {
|
||||
storageStats.value = value;
|
||||
})
|
||||
.catch(() => {
|
||||
storageStats.value = null;
|
||||
})
|
||||
.finally(() => {
|
||||
storageLoading.value = false;
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
api.storageStats().then((value) => { storageStats.value = value; }).catch(() => { storageStats.value = null; }).finally(() => { storageLoading.value = false; });
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function loadTraffic(grain: TrafficGrain = trafficGrain.value) {
|
||||
trafficGrain.value = grain;
|
||||
trafficLoading.value = true;
|
||||
try { traffic.value = await api.peakTraffic(grain); await nextTick(); renderTrafficChart(); }
|
||||
catch (error) { ElMessage.error(error instanceof Error ? error.message : "峰值流量加载失败"); }
|
||||
finally { trafficLoading.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() {
|
||||
storageLoading.value = true;
|
||||
try {
|
||||
storageStats.value = await api.storageStats(true);
|
||||
ElMessage.success("存储统计已刷新");
|
||||
} finally {
|
||||
storageLoading.value = false;
|
||||
}
|
||||
try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); }
|
||||
finally { storageLoading.value = false; }
|
||||
}
|
||||
|
||||
function formatBytes(value: unknown) {
|
||||
if (typeof value !== "number") return "无法统计";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = value;
|
||||
let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
let size = value; let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; }
|
||||
return `${size.toFixed(index ? 2 : 0)} ${units[index]}`;
|
||||
}
|
||||
|
||||
@@ -63,159 +104,67 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
}
|
||||
|
||||
function modelSceneLabel(scene: string) {
|
||||
return (
|
||||
(
|
||||
{
|
||||
background_report: "周期报告",
|
||||
background_summary: "摘要沉淀",
|
||||
fixed_info: "固定信息问答",
|
||||
simple_knowledge: "简单知识问答",
|
||||
knowledge_grounded: "知识问答",
|
||||
general_chat: "通用对话",
|
||||
} as Record<string, string>
|
||||
)[scene] ||
|
||||
scene ||
|
||||
"未分类"
|
||||
);
|
||||
return ({ background_report: "周期报告", background_summary: "摘要沉淀", fixed_info: "固定信息问答", simple_knowledge: "简单知识问答", knowledge_grounded: "知识问答", general_chat: "通用对话" } as Record<string, string>)[scene] || scene || "未分类";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="feature-page">
|
||||
<div class="page-head inline">
|
||||
<section v-loading="loading" class="feature-page dashboard-page">
|
||||
<header class="dashboard-hero">
|
||||
<div>
|
||||
<span class="dashboard-eyebrow">OPERATIONS OVERVIEW</span>
|
||||
<h2>数据看板</h2>
|
||||
<p>核心业务数据概览。</p>
|
||||
<p>从业务规模、请求峰值到模型成本,快速判断系统运行状态。</p>
|
||||
</div>
|
||||
<div class="dashboard-date-range" aria-label="看板日期范围">
|
||||
<label class="record-date-field dashboard-date-field">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
v-model="filters.start"
|
||||
type="date"
|
||||
aria-label="开始日期"
|
||||
@change="loadDashboard"
|
||||
/>
|
||||
</label>
|
||||
<label class="dashboard-date-field"><span>开始日期</span><input v-model="filters.start" type="date" aria-label="开始日期" @change="loadDashboard" /></label>
|
||||
<span class="record-date-sep">至</span>
|
||||
<label class="record-date-field dashboard-date-field">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
v-model="filters.end"
|
||||
type="date"
|
||||
aria-label="结束日期"
|
||||
@change="loadDashboard"
|
||||
/>
|
||||
</label>
|
||||
<label class="dashboard-date-field"><span>结束日期</span><input v-model="filters.end" type="date" aria-label="结束日期" @change="loadDashboard" /></label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="overview-grid" aria-label="核心业务指标">
|
||||
<article class="overview-card primary"><span>用户总数</span><strong>{{ stats?.userCount ?? 0 }}</strong><small>当前已沉淀用户</small></article>
|
||||
<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>
|
||||
<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 class="stats-grid">
|
||||
<div class="stat">
|
||||
<span>用户数</span><strong>{{ stats?.userCount ?? 0 }}</strong>
|
||||
<div class="traffic-summary">
|
||||
<div><span>峰值时段</span><strong>{{ traffic?.peakPeriod || "暂无数据" }}</strong></div>
|
||||
<div><span>峰值请求数</span><strong>{{ traffic?.peakRequests ?? 0 }}</strong></div>
|
||||
<div><span>近 7 天总请求</span><strong>{{ traffic?.totalRequests ?? 0 }}</strong></div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>会话数</span><strong>{{ stats?.sessionCount ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>消息数</span><strong>{{ stats?.messageCount ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>AI 请求</span><strong>{{ stats?.aiRequestCount ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>知识库</span><strong>{{ stats?.knowledgeCount ?? 0 }}</strong>
|
||||
</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
|
||||
>
|
||||
<div v-if="traffic?.rows.length" ref="trafficChart" class="traffic-chart" role="img" aria-label="最近七天峰值流量折线图"></div>
|
||||
<el-empty v-else description="暂无流量数据,新请求产生后会自动统计" :image-size="72" />
|
||||
</section>
|
||||
|
||||
<section class="dashboard-panel">
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
<section class="storage-panel" v-loading="storageLoading">
|
||||
<div class="storage-panel-head">
|
||||
<div>
|
||||
<h3>项目存储占用</h3>
|
||||
<p>数据库、Redis 与附件分别统计;无权限或不支持时显示“无法统计”。</p>
|
||||
</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 class="dashboard-panel" v-loading="storageLoading">
|
||||
<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>
|
||||
<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 class="storage-updated">最后统计:{{ storageStats?.createdAt || "尚未统计" }}</small>
|
||||
</section>
|
||||
</section>
|
||||
</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",
|
||||
],
|
||||
},
|
||||
{
|
||||
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} 并发`,
|
||||
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) {
|
||||
|
||||
@@ -215,6 +215,22 @@ export const systemSettingSections: SystemSettingSection[] = [
|
||||
max: 86400,
|
||||
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 {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElCheckbox,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
@@ -28,11 +29,13 @@ import {
|
||||
ElTabs,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
ElTree,
|
||||
vLoading,
|
||||
} from "element-plus";
|
||||
import "element-plus/theme-chalk/base.css";
|
||||
import "element-plus/theme-chalk/el-alert.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-collapse.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-tag.css";
|
||||
import "element-plus/theme-chalk/el-tooltip.css";
|
||||
import "element-plus/theme-chalk/el-tree.css";
|
||||
import { createApp } from "vue";
|
||||
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
||||
|
||||
@@ -72,6 +76,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
|
||||
[
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElCheckbox,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
@@ -99,6 +104,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
|
||||
ElTabs,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
ElTree,
|
||||
].forEach((component) => {
|
||||
app.use(component);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
AdminProfile,
|
||||
ManagedAdmin,
|
||||
PermissionNode,
|
||||
AdminUser,
|
||||
AdminUserDetail,
|
||||
AgentDebugResult,
|
||||
@@ -16,6 +18,8 @@ import type {
|
||||
ContentGenerationHistoryItem,
|
||||
ContentGenerationType,
|
||||
DashboardStats,
|
||||
PeakTrafficResult,
|
||||
TrafficGrain,
|
||||
EntitlementPlan,
|
||||
EntitlementBatchRenewResult,
|
||||
KnowledgeItem,
|
||||
@@ -129,6 +133,12 @@ export const api = {
|
||||
}),
|
||||
logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }),
|
||||
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) => {
|
||||
const params = new URLSearchParams();
|
||||
if (start) params.set("start", start);
|
||||
@@ -136,6 +146,8 @@ export const api = {
|
||||
const qs = params.toString();
|
||||
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)}`),
|
||||
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
|
||||
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" }),
|
||||
clearFeishuCache: () =>
|
||||
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(
|
||||
|
||||
@@ -16,6 +16,28 @@ export interface AdminProfile {
|
||||
username: string;
|
||||
name: string;
|
||||
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 {
|
||||
@@ -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 {
|
||||
id: number | null;
|
||||
promptContent: string;
|
||||
@@ -730,3 +771,20 @@ export interface ChatRecordQuery {
|
||||
page?: 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_PASSWORD=admin123456
|
||||
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.responses import api_success
|
||||
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.auth_service import AuthService
|
||||
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")
|
||||
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")
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -16,6 +17,7 @@ from app.schemas.admin import DashboardStats
|
||||
from app.services.admin_service import AdminDashboardService
|
||||
from app.models.logs import StorageSnapshot
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
from app.services.request_traffic_service import RequestTrafficService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -37,6 +39,14 @@ def dashboard(
|
||||
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")
|
||||
def storage_stats(
|
||||
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 fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.core.dependencies import enforce_admin_access
|
||||
|
||||
from app.api import (
|
||||
admin_auth,
|
||||
@@ -10,15 +11,18 @@ from app.api import (
|
||||
admin_entitlements,
|
||||
admin_knowledge,
|
||||
admin_knowledge_lifecycle,
|
||||
admin_management,
|
||||
admin_records,
|
||||
admin_settings,
|
||||
admin_sso,
|
||||
admin_users,
|
||||
auth,
|
||||
feedback,
|
||||
chat,
|
||||
health,
|
||||
integration_sso,
|
||||
user,
|
||||
voice,
|
||||
)
|
||||
|
||||
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(integration_sso.router, prefix="/integration/sso", tags=["integration-sso"])
|
||||
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(feedback.router, prefix="/feedback", tags=["feedback"])
|
||||
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_agent_records.router, prefix="/admin", tags=["admin-agent-records"])
|
||||
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"])
|
||||
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])
|
||||
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"])
|
||||
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_management.router, prefix="/admin", tags=["admin-management"])
|
||||
guard = [Depends(enforce_admin_access)]
|
||||
api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"], dependencies=guard)
|
||||
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"], dependencies=guard)
|
||||
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"], dependencies=guard)
|
||||
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"], dependencies=guard)
|
||||
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard)
|
||||
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"], dependencies=guard)
|
||||
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"], dependencies=guard)
|
||||
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_param_key: str = "code"
|
||||
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_model_enabled: bool = True
|
||||
feishu_mock_enabled: bool = False
|
||||
@@ -63,6 +65,8 @@ class Settings(BaseSettings):
|
||||
chat_max_queue_size: int = 90
|
||||
chat_queue_timeout_seconds: int = 90
|
||||
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_poll_seconds: int = 5
|
||||
periodic_report_stale_minutes: int = 30
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
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 sqlalchemy.orm import Session
|
||||
|
||||
@@ -77,3 +77,44 @@ def get_current_admin(
|
||||
if admin.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
|
||||
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:
|
||||
if response_finished:
|
||||
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)
|
||||
|
||||
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.chat import ChatMessage, ChatSession, TopicSession
|
||||
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.insight import QuestionInsightCleanedQuestion
|
||||
from app.models.knowledge import (
|
||||
@@ -50,6 +51,7 @@ __all__ = [
|
||||
"HumanAttentionHistory",
|
||||
"HumanAttentionRecord",
|
||||
"ModelConfig",
|
||||
"MessageFeedback",
|
||||
"OperationLog",
|
||||
"PeriodicReport",
|
||||
"LogRetentionPolicy",
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
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 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)
|
||||
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
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")
|
||||
|
||||
@@ -29,5 +30,7 @@ class Admin(Base, TimestampMixin):
|
||||
role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True)
|
||||
status: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||
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")
|
||||
|
||||
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
|
||||
name: str
|
||||
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):
|
||||
|
||||
@@ -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.user import User
|
||||
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"}
|
||||
@@ -66,7 +67,14 @@ class AdminAuthService:
|
||||
)
|
||||
cls.ensure_bootstrap_admin(db)
|
||||
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(
|
||||
failure_key,
|
||||
limit=5,
|
||||
@@ -92,6 +100,9 @@ class AdminAuthService:
|
||||
"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)),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -106,6 +117,8 @@ class AdminAuthService:
|
||||
password=hash_password(password),
|
||||
name=name,
|
||||
status=1,
|
||||
must_change_password=0,
|
||||
is_super_admin=1,
|
||||
)
|
||||
db.add(admin)
|
||||
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
|
||||
python-multipart==0.0.32
|
||||
alibabacloud-dysmsapi20170525==4.6.0
|
||||
aliyun-python-sdk-core==2.16.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 followingOutput = ref(true);
|
||||
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);
|
||||
let toastTimer: number | null = null;
|
||||
let settlementPollVersion = 0;
|
||||
@@ -234,6 +238,13 @@ async function runGeneration(sessionId: number, message: string, assistantIndex:
|
||||
currentAssistant().streaming = false;
|
||||
currentAssistant().retryQuestion = undefined;
|
||||
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 refreshProfile();
|
||||
} 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) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多,AI 服务暂时繁忙。";
|
||||
@@ -607,6 +641,7 @@ async function copyText(text: string) {
|
||||
:loading-session="loadingSession"
|
||||
@follow-change="followingOutput = $event"
|
||||
@retry="retryMessage"
|
||||
@feedback="openFeedback"
|
||||
/>
|
||||
<ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" />
|
||||
<SessionDrawer
|
||||
@@ -626,6 +661,14 @@ async function copyText(text: string) {
|
||||
|
||||
<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
|
||||
v-if="personalCenterOpen && user"
|
||||
:user="user"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Send, Square } from "@lucide/vue";
|
||||
import { nextTick, ref } from "vue";
|
||||
import { Mic, Send, Square, X } from "@lucide/vue";
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
|
||||
import { useVoiceRecorder } from "../composables/useVoiceRecorder";
|
||||
import { api, transcribeVoice } from "../services/api";
|
||||
|
||||
defineProps<{
|
||||
loading: boolean;
|
||||
@@ -14,6 +17,35 @@ const emit = defineEmits<{
|
||||
|
||||
const input = ref("");
|
||||
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() {
|
||||
const element = textarea.value;
|
||||
@@ -49,11 +81,48 @@ function onKeydown(event: KeyboardEvent) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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
|
||||
v-else
|
||||
ref="textarea"
|
||||
v-model="input"
|
||||
rows="1"
|
||||
@@ -63,13 +132,25 @@ function onKeydown(event: KeyboardEvent) {
|
||||
@input="resize"
|
||||
@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')">
|
||||
<Square :size="17" fill="currentColor" aria-hidden="true" />
|
||||
<span>停止</span>
|
||||
</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" />
|
||||
<span>发送</span>
|
||||
</button>
|
||||
<p v-if="voiceError" class="voice-input-error" role="alert">{{ voiceError }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Bot, RotateCcw } from "@lucide/vue";
|
||||
import { Bot, MessageSquareWarning, RotateCcw } from "@lucide/vue";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { computed } from "vue";
|
||||
|
||||
@@ -17,6 +17,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: [];
|
||||
feedback: [];
|
||||
}>();
|
||||
|
||||
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
|
||||
@@ -111,6 +112,10 @@ const displayTime = computed(() => {
|
||||
</template>
|
||||
<div v-else class="message-content">{{ renderedContent }}</div>
|
||||
<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>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -23,6 +23,7 @@ defineProps<{
|
||||
const emit = defineEmits<{
|
||||
followChange: [following: boolean];
|
||||
retry: [messageId: string];
|
||||
feedback: [messageId: string];
|
||||
}>();
|
||||
|
||||
const scroller = ref<HTMLElement | null>(null);
|
||||
@@ -69,6 +70,7 @@ defineExpose({ scrollToBottom, scrollToMessage });
|
||||
:error-message="message.errorMessage"
|
||||
:can-retry="Boolean(message.retryQuestion)"
|
||||
@retry="emit('retry', message.id)"
|
||||
@feedback="emit('feedback', message.id)"
|
||||
/>
|
||||
</template>
|
||||
</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,
|
||||
TeacherHelpCard,
|
||||
UserProfile,
|
||||
VoiceInputConfig,
|
||||
VoiceTranscriptionResult,
|
||||
} from "../types/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"),
|
||||
periodicReports: (limit = 10) => request<PeriodicReport[]>(`/user/periodic-report/list?limit=${limit}`),
|
||||
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(
|
||||
sessionId: number,
|
||||
message: string,
|
||||
|
||||
@@ -1506,7 +1506,7 @@ textarea:focus-visible {
|
||||
position: relative;
|
||||
z-index: 6;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
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); }
|
||||
|
||||
.composer-send,
|
||||
.composer-stop {
|
||||
.composer-stop,
|
||||
.composer-voice {
|
||||
min-width: 78px;
|
||||
min-height: 50px;
|
||||
display: inline-flex;
|
||||
@@ -1554,6 +1555,36 @@ textarea:focus-visible {
|
||||
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-stop { background: var(--chat-danger); }
|
||||
|
||||
@@ -1563,6 +1594,14 @@ textarea:focus-visible {
|
||||
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 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -128,6 +128,16 @@ export interface CaptchaResult {
|
||||
expiresInSeconds: number;
|
||||
}
|
||||
|
||||
export interface VoiceInputConfig {
|
||||
enabled: boolean;
|
||||
maxDurationSeconds: number;
|
||||
}
|
||||
|
||||
export interface VoiceTranscriptionResult {
|
||||
text: string;
|
||||
durationSeconds: number;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
@@ -60,6 +60,7 @@ services:
|
||||
JWT_SECRET_KEY: local-dev-secret-change-before-production
|
||||
MOCK_SMS_ENABLED: "true"
|
||||
MOCK_SMS_CODE: "123456"
|
||||
ALIYUN_NLS_APP_KEY: "${ALIYUN_NLS_APP_KEY:-FBFMOHMH4JFv7UNn}"
|
||||
MOCK_RAG_ENABLED: "false"
|
||||
MOCK_MODEL_ENABLED: "true"
|
||||
FEISHU_MOCK_ENABLED: "false"
|
||||
|
||||
Reference in New Issue
Block a user