feat: add admin permissions voice input analytics and feedback

This commit is contained in:
2026-08-11 17:17:59 +08:00
parent 3b9215cc3d
commit fd2da26d10
46 changed files with 1836 additions and 192 deletions

View File

@@ -47,3 +47,5 @@ FRP_LOCAL_IP=gateway
FRP_LOCAL_PORT=80 FRP_LOCAL_PORT=80
# 自定义域名逗号分隔qa.huiyushuyuan.cn # 自定义域名逗号分隔qa.huiyushuyuan.cn
FRP_CUSTOM_DOMAINS=qa.huiyushuyuan.cn FRP_CUSTOM_DOMAINS=qa.huiyushuyuan.cn
ALIYUN_NLS_APP_KEY=
ALIYUN_NLS_ENDPOINT=https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr

View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"echarts": "^6.1.0",
"element-plus": "^2.11.0", "element-plus": "^2.11.0",
"markdown-it": "^14.3.0", "markdown-it": "^14.3.0",
"vite": "^7.1.0", "vite": "^7.1.0",
@@ -1155,6 +1156,16 @@
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/element-plus": { "node_modules/element-plus": {
"version": "2.14.2", "version": "2.14.2",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz",
@@ -1535,6 +1546,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1679,6 +1696,15 @@
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
} }
} }
} }

View File

@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"echarts": "^6.1.0",
"element-plus": "^2.11.0", "element-plus": "^2.11.0",
"markdown-it": "^14.3.0", "markdown-it": "^14.3.0",
"vite": "^7.1.0", "vite": "^7.1.0",

View File

@@ -49,10 +49,26 @@ const SsoIntegrationView = defineAsyncComponent(
const SystemConfigView = defineAsyncComponent( const SystemConfigView = defineAsyncComponent(
() => import("./components/SystemConfigView.vue"), () => import("./components/SystemConfigView.vue"),
); );
const AdminManagementView = defineAsyncComponent(
() => import("./components/AdminManagementView.vue"),
);
const FeedbackManagementView = defineAsyncComponent(() => import("./components/FeedbackManagementView.vue"));
const admin = ref<AdminProfile | null>(null); const admin = ref<AdminProfile | null>(null);
const activeMenu = ref("dashboard"); const activeMenu = ref("dashboard");
const loading = ref(false); const loading = ref(false);
const passwordDialogVisible = ref(false);
const passwordSaving = ref(false);
const passwordForm = reactive({ currentPassword: "", newPassword: "", confirmPassword: "" });
const can = (permission: string) => Boolean(admin.value?.isSuperAdmin || admin.value?.permissions.includes(permission));
const menuPermissions: Record<string, string> = {
dashboard: "dashboard.view", users: "users.view", entitlements: "entitlements.view",
knowledge: "knowledge.view", prompt: "prompt.view", models: "models.view",
"content-generation": "content-generation.view", configs: "configs.view", sso: "sso.view",
records: "records.view", retrievals: "retrievals.view", attention: "attention.view", admins: "admins.view",
feedback: "feedback.view",
};
const entitlementPlans = ref<EntitlementPlan[]>([]); const entitlementPlans = ref<EntitlementPlan[]>([]);
const models = ref<ModelItem[]>([]); const models = ref<ModelItem[]>([]);
@@ -190,7 +206,8 @@ onMounted(async () => {
if (!getToken()) return; if (!getToken()) return;
try { try {
admin.value = await api.profile(); admin.value = await api.profile();
await loadCurrentMenu(); passwordDialogVisible.value = admin.value.mustChangePassword;
if (!admin.value.mustChangePassword) await enterFirstAvailableMenu();
} catch { } catch {
clearToken(); clearToken();
} }
@@ -202,7 +219,8 @@ async function login(credentials: { username: string; password: string }) {
const result = await api.login(credentials.username, credentials.password); const result = await api.login(credentials.username, credentials.password);
saveToken(result.token); saveToken(result.token);
admin.value = result.admin; admin.value = result.admin;
await loadCurrentMenu(); passwordDialogVisible.value = result.admin.mustChangePassword;
if (!result.admin.mustChangePassword) await enterFirstAvailableMenu();
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : "登录失败"); ElMessage.error(error instanceof Error ? error.message : "登录失败");
} finally { } finally {
@@ -220,10 +238,32 @@ async function logout() {
} }
async function switchMenu(menu: string) { async function switchMenu(menu: string) {
if (!can(menuPermissions[menu])) return;
activeMenu.value = menu; activeMenu.value = menu;
await loadCurrentMenu(); await loadCurrentMenu();
} }
async function enterFirstAvailableMenu() {
const first = Object.keys(menuPermissions).find((menu) => can(menuPermissions[menu]));
activeMenu.value = first ?? "dashboard";
if (first) await loadCurrentMenu();
}
async function changeInitialPassword() {
if (passwordForm.newPassword.length < 8) return ElMessage.warning("新密码至少 8 位");
if (passwordForm.newPassword !== passwordForm.confirmPassword) return ElMessage.warning("两次输入的新密码不一致");
passwordSaving.value = true;
try {
await api.changeAdminPassword({ currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword });
if (admin.value) admin.value.mustChangePassword = false;
passwordDialogVisible.value = false;
Object.assign(passwordForm, { currentPassword: "", newPassword: "", confirmPassword: "" });
ElMessage.success("密码已修改,请使用新密码登录");
await logout();
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "密码修改失败"); }
finally { passwordSaving.value = false; }
}
async function previewKnowledge(knowledgeId: number) { async function previewKnowledge(knowledgeId: number) {
previewKnowledgeId.value = knowledgeId; previewKnowledgeId.value = knowledgeId;
await switchMenu("prompt"); await switchMenu("prompt");
@@ -596,77 +636,99 @@ async function clearFeishuCache() {
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-title">大本营千问千答</div> <div class="sidebar-title">大本营千问千答</div>
<button <button
v-if="can('dashboard.view')"
:class="{ active: activeMenu === 'dashboard' }" :class="{ active: activeMenu === 'dashboard' }"
@click="switchMenu('dashboard')" @click="switchMenu('dashboard')"
> >
数据看板 数据看板
</button> </button>
<button <button
v-if="can('users.view')"
:class="{ active: activeMenu === 'users' }" :class="{ active: activeMenu === 'users' }"
@click="switchMenu('users')" @click="switchMenu('users')"
> >
用户管理 用户管理
</button> </button>
<button <button
v-if="can('entitlements.view')"
:class="{ active: activeMenu === 'entitlements' }" :class="{ active: activeMenu === 'entitlements' }"
@click="switchMenu('entitlements')" @click="switchMenu('entitlements')"
> >
权益管理 权益管理
</button> </button>
<button <button
v-if="can('knowledge.view')"
:class="{ active: activeMenu === 'knowledge' }" :class="{ active: activeMenu === 'knowledge' }"
@click="switchMenu('knowledge')" @click="switchMenu('knowledge')"
> >
知识库管理 知识库管理
</button> </button>
<button <button
v-if="can('prompt.view')"
:class="{ active: activeMenu === 'prompt' }" :class="{ active: activeMenu === 'prompt' }"
@click="switchMenu('prompt')" @click="switchMenu('prompt')"
> >
Agent 管理 Agent 管理
</button> </button>
<button <button
v-if="can('models.view')"
:class="{ active: activeMenu === 'models' }" :class="{ active: activeMenu === 'models' }"
@click="switchMenu('models')" @click="switchMenu('models')"
> >
模型管理 模型管理
</button> </button>
<button <button
v-if="can('content-generation.view')"
:class="{ active: activeMenu === 'content-generation' }" :class="{ active: activeMenu === 'content-generation' }"
@click="switchMenu('content-generation')" @click="switchMenu('content-generation')"
> >
内容生成 内容生成
</button> </button>
<button <button
v-if="can('configs.view')"
:class="{ active: activeMenu === 'configs' }" :class="{ active: activeMenu === 'configs' }"
@click="switchMenu('configs')" @click="switchMenu('configs')"
> >
系统配置 系统配置
</button> </button>
<button <button
v-if="can('sso.view')"
:class="{ active: activeMenu === 'sso' }" :class="{ active: activeMenu === 'sso' }"
@click="switchMenu('sso')" @click="switchMenu('sso')"
> >
应用接入 应用接入
</button> </button>
<button <button
v-if="can('records.view')"
:class="{ active: activeMenu === 'records' }" :class="{ active: activeMenu === 'records' }"
@click="switchMenu('records')" @click="switchMenu('records')"
> >
记录审计 记录审计
</button> </button>
<button <button
v-if="can('retrievals.view')"
:class="{ active: activeMenu === 'retrievals' }" :class="{ active: activeMenu === 'retrievals' }"
@click="switchMenu('retrievals')" @click="switchMenu('retrievals')"
> >
检索日志 检索日志
</button> </button>
<button <button
v-if="can('feedback.view')"
:class="{ active: activeMenu === 'feedback' }"
@click="switchMenu('feedback')"
>反馈管理</button>
<button
v-if="can('attention.view')"
:class="{ active: activeMenu === 'attention' }" :class="{ active: activeMenu === 'attention' }"
@click="switchMenu('attention')" @click="switchMenu('attention')"
> >
人工关注 人工关注
</button> </button>
<button
v-if="admin.isSuperAdmin"
:class="{ active: activeMenu === 'admins' }"
@click="switchMenu('admins')"
>管理员与权限</button>
</aside> </aside>
<section class="workspace"> <section class="workspace">
@@ -1057,8 +1119,20 @@ async function clearFeishuCache() {
<RetrievalLogView v-if="activeMenu === 'retrievals'" /> <RetrievalLogView v-if="activeMenu === 'retrievals'" />
<AttentionManagementView v-if="activeMenu === 'attention'" /> <AttentionManagementView v-if="activeMenu === 'attention'" />
<FeedbackManagementView v-if="activeMenu === 'feedback'" :can-delete="can('feedback.delete')" />
<RecordAuditView v-if="activeMenu === 'records'" /> <RecordAuditView v-if="activeMenu === 'records'" />
<AdminManagementView v-if="activeMenu === 'admins'" />
</section> </section>
</section> </section>
</main> </main>
<el-dialog v-model="passwordDialogVisible" title="首次登录,请修改初始密码" width="460px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<el-alert title="为保障账号安全,修改密码后需要重新登录。" type="warning" :closable="false" show-icon />
<el-form label-position="top" class="password-form">
<el-form-item label="当前初始密码"><el-input v-model="passwordForm.currentPassword" type="password" show-password /></el-form-item>
<el-form-item label="新密码"><el-input v-model="passwordForm.newPassword" type="password" show-password placeholder="至少 8 位" /></el-form-item>
<el-form-item label="确认新密码"><el-input v-model="passwordForm.confirmPassword" type="password" show-password /></el-form-item>
</el-form>
<template #footer><el-button @click="logout">退出登录</el-button><el-button type="primary" :loading="passwordSaving" @click="changeInitialPassword">确认修改</el-button></template>
</el-dialog>
</template> </template>

View File

@@ -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>

View File

@@ -1,58 +1,99 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue"; import { LineChart } from "echarts/charts";
import { DataZoomComponent, GridComponent, LegendComponent, MarkPointComponent, TooltipComponent } from "echarts/components";
import { init, use, type ECharts } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import type { DashboardStats } from "../types/api"; import type { DashboardStats, PeakTrafficResult, TrafficGrain } from "../types/api";
use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkPointComponent, CanvasRenderer]);
const stats = ref<DashboardStats | null>(null); const stats = ref<DashboardStats | null>(null);
const traffic = ref<PeakTrafficResult | null>(null);
const trafficChart = ref<HTMLElement | null>(null);
let trafficChartInstance: ECharts | null = null;
const trafficGrain = ref<TrafficGrain>("hour");
const storageStats = ref<Record<string, any> | null>(null); const storageStats = ref<Record<string, any> | null>(null);
const storageLoading = ref(false); const storageLoading = ref(false);
const trafficLoading = ref(false);
const loading = ref(false); const loading = ref(false);
const filters = reactive({ start: "", end: "" }); const filters = reactive({ start: "", end: "" });
onMounted(loadDashboard); const trafficOptions: Array<{ label: string; value: TrafficGrain }> = [
{ label: "分钟", value: "minute" },
{ label: "小时", value: "hour" },
{ label: "天", value: "day" },
{ label: "周", value: "week" },
];
onMounted(async () => {
await Promise.all([loadDashboard(), loadTraffic()]);
window.addEventListener("resize", resizeTrafficChart);
});
onBeforeUnmount(() => { window.removeEventListener("resize", resizeTrafficChart); trafficChartInstance?.dispose(); });
async function loadDashboard() { async function loadDashboard() {
loading.value = true; loading.value = true;
try { try {
stats.value = await api.dashboard(filters.start, filters.end); stats.value = await api.dashboard(filters.start, filters.end);
storageLoading.value = true; storageLoading.value = true;
api api.storageStats().then((value) => { storageStats.value = value; }).catch(() => { storageStats.value = null; }).finally(() => { storageLoading.value = false; });
.storageStats() } finally { loading.value = false; }
.then((value) => { }
storageStats.value = value;
}) async function loadTraffic(grain: TrafficGrain = trafficGrain.value) {
.catch(() => { trafficGrain.value = grain;
storageStats.value = null; trafficLoading.value = true;
}) try { traffic.value = await api.peakTraffic(grain); await nextTick(); renderTrafficChart(); }
.finally(() => { catch (error) { ElMessage.error(error instanceof Error ? error.message : "峰值流量加载失败"); }
storageLoading.value = false; finally { trafficLoading.value = false; }
}); }
} finally {
loading.value = false; function resizeTrafficChart() { trafficChartInstance?.resize(); }
}
function renderTrafficChart() {
if (!trafficChart.value) return;
trafficChartInstance ??= init(trafficChart.value);
const rows = traffic.value?.rows ?? [];
const zoomStart = trafficGrain.value === "minute" ? 98 : trafficGrain.value === "hour" ? 70 : 0;
trafficChartInstance.setOption({
animationDuration: 350,
color: ["#176b55", "#d66a57"],
grid: { left: 52, right: 28, top: 36, bottom: rows.length > 30 ? 72 : 48 },
tooltip: {
trigger: "axis",
formatter(params: Array<{ dataIndex: number; marker: string; seriesName: string; value: number }>) {
const index = params[0]?.dataIndex ?? 0;
const row = rows[index];
if (!row) return "";
return `<strong>${row.periodLabel}</strong><br/>${params.map((item) => `${item.marker}${item.seriesName}${item.value}`).join("<br/>")}<br/><span style="color:#7b8b85">平均响应:${row.avgResponseMs} ms</span>`;
},
},
legend: { top: 0, right: 0, data: ["请求数", "5xx 错误"] },
xAxis: { type: "category", boundaryGap: false, data: rows.map((row) => row.periodLabel), axisLabel: { color: "#71827c", hideOverlap: true }, axisLine: { lineStyle: { color: "#dce6e2" } } },
yAxis: { type: "value", minInterval: 1, name: "请求数", nameTextStyle: { color: "#71827c" }, splitLine: { lineStyle: { color: "#edf2f0" } } },
dataZoom: rows.length > 30 ? [{ type: "inside", start: zoomStart, end: 100 }, { type: "slider", start: zoomStart, end: 100, height: 18, bottom: 8, borderColor: "#dce6e2", fillerColor: "rgba(23,107,85,.12)" }] : [],
series: [
{ name: "请求数", type: "line", smooth: true, showSymbol: rows.length < 80, symbolSize: 7, lineStyle: { width: 3 }, areaStyle: { color: "rgba(23,107,85,.10)" }, data: rows.map((row) => row.requestCount), markPoint: { symbolSize: 48, data: [{ type: "max", name: "峰值" }] } },
{ name: "5xx 错误", type: "line", smooth: true, showSymbol: false, lineStyle: { width: 2, type: "dashed" }, data: rows.map((row) => row.errorCount) },
],
}, true);
} }
async function refreshStorage() { async function refreshStorage() {
storageLoading.value = true; storageLoading.value = true;
try { try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); }
storageStats.value = await api.storageStats(true); finally { storageLoading.value = false; }
ElMessage.success("存储统计已刷新");
} finally {
storageLoading.value = false;
}
} }
function formatBytes(value: unknown) { function formatBytes(value: unknown) {
if (typeof value !== "number") return "无法统计"; if (typeof value !== "number") return "无法统计";
const units = ["B", "KB", "MB", "GB", "TB"]; const units = ["B", "KB", "MB", "GB", "TB"];
let size = value; let size = value; let index = 0;
let index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; }
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(index ? 2 : 0)} ${units[index]}`; return `${size.toFixed(index ? 2 : 0)} ${units[index]}`;
} }
@@ -63,159 +104,67 @@ function formatMoney(value?: number | null, currency = "CNY") {
} }
function modelSceneLabel(scene: string) { function modelSceneLabel(scene: string) {
return ( return ({ background_report: "周期报告", background_summary: "摘要沉淀", fixed_info: "固定信息问答", simple_knowledge: "简单知识问答", knowledge_grounded: "知识问答", general_chat: "通用对话" } as Record<string, string>)[scene] || scene || "未分类";
(
{
background_report: "周期报告",
background_summary: "摘要沉淀",
fixed_info: "固定信息问答",
simple_knowledge: "简单知识问答",
knowledge_grounded: "知识问答",
general_chat: "通用对话",
} as Record<string, string>
)[scene] ||
scene ||
"未分类"
);
} }
</script> </script>
<template> <template>
<section v-loading="loading" class="feature-page"> <section v-loading="loading" class="feature-page dashboard-page">
<div class="page-head inline"> <header class="dashboard-hero">
<div> <div>
<span class="dashboard-eyebrow">OPERATIONS OVERVIEW</span>
<h2>数据看板</h2> <h2>数据看板</h2>
<p>核心业务数据概览</p> <p>从业务规模请求峰值到模型成本快速判断系统运行状态</p>
</div> </div>
<div class="dashboard-date-range" aria-label="看板日期范围"> <div class="dashboard-date-range" aria-label="看板日期范围">
<label class="record-date-field dashboard-date-field"> <label class="dashboard-date-field"><span>开始日期</span><input v-model="filters.start" type="date" aria-label="开始日期" @change="loadDashboard" /></label>
<span>开始日期</span>
<input
v-model="filters.start"
type="date"
aria-label="开始日期"
@change="loadDashboard"
/>
</label>
<span class="record-date-sep"></span> <span class="record-date-sep"></span>
<label class="record-date-field dashboard-date-field"> <label class="dashboard-date-field"><span>结束日期</span><input v-model="filters.end" type="date" aria-label="结束日期" @change="loadDashboard" /></label>
<span>结束日期</span> </div>
<input </header>
v-model="filters.end"
type="date" <section class="overview-grid" aria-label="核心业务指标">
aria-label="结束日期" <article class="overview-card primary"><span>用户总数</span><strong>{{ stats?.userCount ?? 0 }}</strong><small>当前已沉淀用户</small></article>
@change="loadDashboard" <article class="overview-card"><span>会话数</span><strong>{{ stats?.sessionCount ?? 0 }}</strong><small>选定日期范围</small></article>
/> <article class="overview-card"><span>消息数</span><strong>{{ stats?.messageCount ?? 0 }}</strong><small>用户与 Agent 对话</small></article>
</label> <article class="overview-card"><span>AI 请求</span><strong>{{ stats?.aiRequestCount ?? 0 }}</strong><small>模型实际调用次数</small></article>
<article class="overview-card"><span>估算成本</span><strong class="money">{{ formatMoney(stats?.estimatedCost, stats?.costCurrency || "CNY") }}</strong><small>按已配置模型单价</small></article>
</section>
<section class="dashboard-panel traffic-panel" v-loading="trafficLoading">
<div class="panel-heading">
<div><span class="section-kicker">流量监测</span><h3>峰值流量</h3><p>折线图展示最近 7 天变化趋势可切换时间粒度查看</p></div>
<div class="grain-switch" role="group" aria-label="流量统计粒度">
<button v-for="option in trafficOptions" :key="option.value" type="button" :class="{ active: trafficGrain === option.value }" @click="loadTraffic(option.value)">{{ option.label }}</button>
</div> </div>
</div> </div>
<div class="stats-grid"> <div class="traffic-summary">
<div class="stat"> <div><span>峰值时段</span><strong>{{ traffic?.peakPeriod || "暂无数据" }}</strong></div>
<span>用户</span><strong>{{ stats?.userCount ?? 0 }}</strong> <div><span>峰值请求</span><strong>{{ traffic?.peakRequests ?? 0 }}</strong></div>
<div><span> 7 天总请求</span><strong>{{ traffic?.totalRequests ?? 0 }}</strong></div>
</div> </div>
<div class="stat"> <div v-if="traffic?.rows.length" ref="trafficChart" class="traffic-chart" role="img" aria-label="最近七天峰值流量折线图"></div>
<span>会话数</span><strong>{{ stats?.sessionCount ?? 0 }}</strong> <el-empty v-else description="暂无流量数据,新请求产生后会自动统计" :image-size="72" />
</div> </section>
<div class="stat">
<span>消息数</span><strong>{{ stats?.messageCount ?? 0 }}</strong> <section class="dashboard-panel">
</div> <div class="panel-heading"><div><span class="section-kicker">模型资源</span><h3>模型使用与成本</h3><p>核对模型分流Token 消耗和费用</p></div><div class="token-summary"><span> Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong><small>输入 {{ stats?.inputToken ?? 0 }} · 输出 {{ stats?.outputToken ?? 0 }}</small></div></div>
<div class="stat"> <el-table :data="stats?.costBreakdown || []" empty-text="当前日期范围内暂无模型调用">
<span>AI 请求</span><strong>{{ stats?.aiRequestCount ?? 0 }}</strong> <el-table-column label="调用场景" min-width="130"><template #default="{ row }">{{ modelSceneLabel(row.scene) }}</template></el-table-column>
</div> <el-table-column prop="modelName" label="实际模型" min-width="180" show-overflow-tooltip />
<div class="stat"> <el-table-column prop="requestCount" label="请求数" width="100" /><el-table-column prop="inputToken" label="输入 Token" width="130" /><el-table-column prop="outputToken" label="输出 Token" width="130" />
<span>知识库</span><strong>{{ stats?.knowledgeCount ?? 0 }}</strong> <el-table-column label="估算成本" width="170"><template #default="{ row }">{{ formatMoney(row.estimatedCost, row.currency || "CNY") }}</template></el-table-column>
</div>
<div class="stat">
<span>输入 Token</span><strong>{{ stats?.inputToken ?? 0 }}</strong>
</div>
<div class="stat">
<span>输出 Token</span><strong>{{ stats?.outputToken ?? 0 }}</strong>
</div>
<div class="stat">
<span> Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong>
</div>
<div class="stat">
<span>估算成本</span
><strong>{{
formatMoney(stats?.estimatedCost, stats?.costCurrency || "CNY")
}}</strong>
</div>
</div>
<section class="cost-breakdown-panel">
<div class="storage-panel-head">
<div>
<h3>模型使用与成本</h3>
<p>按实际调用场景和模型统计报告摘要分流是否生效可在这里核对</p>
</div>
</div>
<el-table
:data="stats?.costBreakdown || []"
empty-text="当前日期范围内暂无模型调用"
>
<el-table-column label="调用场景" min-width="130"
><template #default="{ row }">{{
modelSceneLabel(row.scene)
}}</template></el-table-column
>
<el-table-column
prop="modelName"
label="实际模型"
min-width="180"
show-overflow-tooltip
/>
<el-table-column prop="requestCount" label="请求数" width="100" />
<el-table-column prop="inputToken" label="输入 Token" width="130" />
<el-table-column prop="outputToken" label="输出 Token" width="130" />
<el-table-column label="估算成本" width="170"
><template #default="{ row }">{{
formatMoney(row.estimatedCost, row.currency || "CNY")
}}</template></el-table-column
>
</el-table> </el-table>
</section> </section>
<section class="storage-panel" v-loading="storageLoading">
<div class="storage-panel-head"> <section class="dashboard-panel" v-loading="storageLoading">
<div> <div class="panel-heading"><div><span class="section-kicker">基础设施</span><h3>项目存储占用</h3><p>数据库Redis 与附件分别统计无法取得时明确标记</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div>
<h3>项目存储占用</h3> <div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div>
<p>数据库Redis 与附件分别统计无权限或不支持时显示无法统计</p> <small class="storage-updated">最后统计{{ storageStats?.createdAt || "尚未统计" }}</small>
</div>
<el-button :loading="storageLoading" @click="refreshStorage"
>手动刷新</el-button
>
</div>
<div class="storage-grid">
<div>
<span>项目总量</span
><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong>
</div>
<div>
<span>数据库</span
><strong>{{
formatBytes(storageStats?.detail?.database?.bytes)
}}</strong>
</div>
<div>
<span>Redis</span
><strong>{{
formatBytes(storageStats?.detail?.redis?.bytes)
}}</strong>
</div>
<div>
<span>附件/文件</span
><strong>{{
formatBytes(storageStats?.detail?.files?.bytes)
}}</strong>
</div>
<div>
<span>近7天增长</span
><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong>
</div>
<div>
<span>近30天增长</span
><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong>
</div>
</div>
<small>最后统计{{ storageStats?.createdAt || "尚未统计" }}</small>
</section> </section>
</section> </section>
</template> </template>
<style scoped>
.dashboard-page{--dash-border:#dce6e2;--dash-muted:#687b74;--dash-green:#176b55;display:grid;gap:20px}.dashboard-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;padding:24px 26px;border:1px solid var(--dash-border);border-radius:14px;background:linear-gradient(135deg,#f5fbf8 0%,#fff 64%)}.dashboard-eyebrow,.section-kicker{display:block;margin-bottom:6px;color:#2a8069;font-size:11px;font-weight:700;letter-spacing:.12em}.dashboard-hero h2,.panel-heading h3{margin:0}.dashboard-hero h2{font-size:28px}.dashboard-hero p,.panel-heading p{margin:7px 0 0;color:var(--dash-muted);line-height:1.5}.dashboard-date-range{padding:8px;border:1px solid var(--dash-border);border-radius:10px;background:#fff}.dashboard-date-field{display:flex;align-items:center;gap:8px}.dashboard-date-field span{color:var(--dash-muted);font-size:12px;white-space:nowrap}.dashboard-date-field input{height:34px;border:0;outline:0;color:#283631;background:transparent}.overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.overview-card{min-height:126px;padding:18px;border:1px solid var(--dash-border);border-radius:12px;background:#fff}.overview-card.primary{border-color:#b9d9ce;background:#f0f8f5}.overview-card span,.traffic-summary span,.token-summary span{color:var(--dash-muted);font-size:13px}.overview-card strong{display:block;margin:12px 0 7px;color:#1d2925;font-size:28px;line-height:1}.overview-card strong.money{font-size:20px;line-height:1.3}.overview-card small,.token-summary small{color:#8a9a94}.dashboard-panel{padding:22px;border:1px solid var(--dash-border);border-radius:14px;background:#fff}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.panel-heading h3{font-size:20px}.grain-switch{display:flex;padding:4px;border:1px solid var(--dash-border);border-radius:10px;background:#f5f8f7}.grain-switch button{min-width:56px;height:34px;padding:0 13px;border:0;border-radius:7px;color:#586b64;background:transparent;cursor:pointer}.grain-switch button:hover{color:var(--dash-green)}.grain-switch button.active{color:#fff;background:var(--dash-green);box-shadow:0 2px 6px rgba(23,107,85,.18)}.traffic-summary{display:grid;grid-template-columns:2fr 1fr 1fr;margin-bottom:16px;border:1px solid #e6ece9;border-radius:10px;background:#f8faf9}.traffic-summary>div{padding:15px 18px;border-right:1px solid #e6ece9}.traffic-summary>div:last-child{border-right:0}.traffic-summary strong{display:block;margin-top:6px;font-size:19px}.traffic-chart{width:100%;height:360px}.token-summary{min-width:220px;padding:12px 16px;border-radius:10px;background:#f5f8f7}.token-summary strong{display:block;margin:3px 0;font-size:22px}.storage-grid{margin:0}.storage-updated{display:block;margin-top:14px;color:#7d8e88}@media(max-width:900px){.dashboard-hero,.panel-heading{align-items:stretch;flex-direction:column}.dashboard-date-range{align-self:stretch;overflow:auto}.overview-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.traffic-summary{grid-template-columns:1fr}.traffic-summary>div{border-right:0;border-bottom:1px solid #e6ece9}.traffic-summary>div:last-child{border-bottom:0}.grain-switch{overflow:auto}.grain-switch button{flex:1}.traffic-chart{height:320px}}@media(max-width:560px){.overview-grid{grid-template-columns:1fr}.dashboard-panel,.dashboard-hero{padding:16px}}
</style>

View File

@@ -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>

View File

@@ -81,6 +81,11 @@ const groupBlueprints: Record<string, SettingGroupBlueprint[]> = {
"chat_active_lease_seconds", "chat_active_lease_seconds",
], ],
}, },
{
title: "语音输入",
description: "控制用户端麦克风入口和单次录音上限。",
keys: ["voice_input_enabled", "voice_max_duration_seconds"],
},
], ],
外部服务: [ 外部服务: [
{ {
@@ -175,6 +180,13 @@ const statusItems = computed(() => [
value: `${props.values.chat_max_active_requests || 0} 并发`, value: `${props.values.chat_max_active_requests || 0} 并发`,
tone: "neutral", tone: "neutral",
}, },
{
label: "语音输入",
value: props.values.voice_input_enabled
? `已开启 · ${props.values.voice_max_duration_seconds || 60}`
: "已关闭",
tone: props.values.voice_input_enabled ? "success" : "neutral",
},
]); ]);
function updateSetting(key: string, value: unknown) { function updateSetting(key: string, value: unknown) {

View File

@@ -215,6 +215,22 @@ export const systemSettingSections: SystemSettingSection[] = [
max: 86400, max: 86400,
description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。", description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。",
}, },
{
key: "voice_input_enabled",
label: "启用语音输入",
type: "switch",
defaultValue: false,
description: "关闭后用户端不展示麦克风入口,后端也会拒绝语音转写请求。",
},
{
key: "voice_max_duration_seconds",
label: "单条语音最大时长(秒)",
type: "number",
defaultValue: 60,
min: 5,
max: 60,
description: "达到设定时长后自动停止并转成文字,阿里云一句话识别上限为 60 秒。",
},
], ],
}, },
{ {

View File

@@ -1,6 +1,7 @@
import { import {
ElAlert, ElAlert,
ElButton, ElButton,
ElCard,
ElCheckbox, ElCheckbox,
ElCollapse, ElCollapse,
ElCollapseItem, ElCollapseItem,
@@ -28,11 +29,13 @@ import {
ElTabs, ElTabs,
ElTag, ElTag,
ElTooltip, ElTooltip,
ElTree,
vLoading, vLoading,
} from "element-plus"; } from "element-plus";
import "element-plus/theme-chalk/base.css"; import "element-plus/theme-chalk/base.css";
import "element-plus/theme-chalk/el-alert.css"; import "element-plus/theme-chalk/el-alert.css";
import "element-plus/theme-chalk/el-button.css"; import "element-plus/theme-chalk/el-button.css";
import "element-plus/theme-chalk/el-card.css";
import "element-plus/theme-chalk/el-checkbox.css"; import "element-plus/theme-chalk/el-checkbox.css";
import "element-plus/theme-chalk/el-collapse.css"; import "element-plus/theme-chalk/el-collapse.css";
import "element-plus/theme-chalk/el-date-picker.css"; import "element-plus/theme-chalk/el-date-picker.css";
@@ -60,6 +63,7 @@ import "element-plus/theme-chalk/el-table.css";
import "element-plus/theme-chalk/el-tabs.css"; import "element-plus/theme-chalk/el-tabs.css";
import "element-plus/theme-chalk/el-tag.css"; import "element-plus/theme-chalk/el-tag.css";
import "element-plus/theme-chalk/el-tooltip.css"; import "element-plus/theme-chalk/el-tooltip.css";
import "element-plus/theme-chalk/el-tree.css";
import { createApp } from "vue"; import { createApp } from "vue";
import zhCn from "element-plus/es/locale/lang/zh-cn"; import zhCn from "element-plus/es/locale/lang/zh-cn";
@@ -72,6 +76,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
[ [
ElAlert, ElAlert,
ElButton, ElButton,
ElCard,
ElCheckbox, ElCheckbox,
ElCollapse, ElCollapse,
ElCollapseItem, ElCollapseItem,
@@ -99,6 +104,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
ElTabs, ElTabs,
ElTag, ElTag,
ElTooltip, ElTooltip,
ElTree,
].forEach((component) => { ].forEach((component) => {
app.use(component); app.use(component);
}); });

View File

@@ -1,5 +1,7 @@
import type { import type {
AdminProfile, AdminProfile,
ManagedAdmin,
PermissionNode,
AdminUser, AdminUser,
AdminUserDetail, AdminUserDetail,
AgentDebugResult, AgentDebugResult,
@@ -16,6 +18,8 @@ import type {
ContentGenerationHistoryItem, ContentGenerationHistoryItem,
ContentGenerationType, ContentGenerationType,
DashboardStats, DashboardStats,
PeakTrafficResult,
TrafficGrain,
EntitlementPlan, EntitlementPlan,
EntitlementBatchRenewResult, EntitlementBatchRenewResult,
KnowledgeItem, KnowledgeItem,
@@ -129,6 +133,12 @@ export const api = {
}), }),
logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }), logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }),
profile: () => request<AdminProfile>("/admin/profile"), profile: () => request<AdminProfile>("/admin/profile"),
permissionTree: () => request<PermissionNode[]>("/admin/permission-tree"),
administrators: () => request<ManagedAdmin[]>("/admin/administrator/list"),
createAdministrator: (payload: Record<string, unknown>) => request<ManagedAdmin>("/admin/administrator", { method: "POST", body: JSON.stringify(payload) }),
updateAdministrator: (id: number, payload: Record<string, unknown>) => request<ManagedAdmin>(`/admin/administrator/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteAdministrator: (id: number) => request<null>(`/admin/administrator/${id}`, { method: "DELETE" }),
changeAdminPassword: (payload: { currentPassword: string; newPassword: string }) => request<null>("/admin/password", { method: "POST", body: JSON.stringify(payload) }),
dashboard: (start?: string, end?: string) => { dashboard: (start?: string, end?: string) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (start) params.set("start", start); if (start) params.set("start", start);
@@ -136,6 +146,8 @@ export const api = {
const qs = params.toString(); const qs = params.toString();
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`); return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
}, },
peakTraffic: (grain: TrafficGrain) =>
request<PeakTrafficResult>(`/admin/dashboard/traffic${queryString({ grain })}`),
users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`), users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`), userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) => userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
@@ -316,6 +328,9 @@ export const api = {
deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }), deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }),
clearFeishuCache: () => clearFeishuCache: () =>
request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }), request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }),
feedbackList: (query: { readStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<import("../types/api").FeedbackItem>>(`/feedback/admin/list${queryString(query)}`),
feedbackDetail: (id: number) => request<import("../types/api").FeedbackDetail>(`/feedback/admin/${id}`),
deleteFeedback: (id: number) => request<null>(`/feedback/admin/${id}`, { method: "DELETE" }),
}; };
export async function streamDebugAgent( export async function streamDebugAgent(

View File

@@ -16,6 +16,28 @@ export interface AdminProfile {
username: string; username: string;
name: string; name: string;
status: number; status: number;
isSuperAdmin: boolean;
mustChangePassword: boolean;
permissions: string[];
}
export interface ManagedAdmin {
id: number;
username: string;
name: string;
status: number;
isSuperAdmin: boolean;
mustChangePassword: boolean;
permissions: string[];
lastLoginAt?: string | null;
createdAt?: string | null;
}
export interface PermissionNode {
code: string;
name: string;
superOnly?: boolean;
children?: PermissionNode[];
} }
export interface SsoClientItem { export interface SsoClientItem {
@@ -84,6 +106,25 @@ export interface DashboardStats {
}>; }>;
} }
export type TrafficGrain = "minute" | "hour" | "day" | "week";
export interface PeakTrafficRow {
period: string;
periodLabel: string;
requestCount: number;
errorCount: number;
errorRate: number;
avgResponseMs: number;
}
export interface PeakTrafficResult {
grain: TrafficGrain;
peakPeriod?: string | null;
peakRequests: number;
totalRequests: number;
rows: PeakTrafficRow[];
}
export interface PromptDetail { export interface PromptDetail {
id: number | null; id: number | null;
promptContent: string; promptContent: string;
@@ -730,3 +771,20 @@ export interface ChatRecordQuery {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
} }
export interface FeedbackItem {
id: number;
userId: number;
userName: string;
userPhone: string;
messageId: number;
messageContent: string;
content: string;
isRead: boolean;
createdAt: string;
}
export interface FeedbackDetail extends FeedbackItem {
sessionTitle: string;
messages: Array<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
}

View File

@@ -58,3 +58,5 @@ TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
BOOTSTRAP_ADMIN_USERNAME=admin BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=admin123456 BOOTSTRAP_ADMIN_PASSWORD=admin123456
BOOTSTRAP_ADMIN_NAME=系统管理员 BOOTSTRAP_ADMIN_NAME=系统管理员
ALIYUN_NLS_APP_KEY=
ALIYUN_NLS_ENDPOINT=https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr

View File

@@ -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")

View File

@@ -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")

View File

@@ -7,7 +7,8 @@ from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead from app.schemas.admin import AdminLoginRequest, AdminLoginResponse
from app.services.admin_permission_service import permissions_for
from app.services.admin_service import AdminAuthService from app.services.admin_service import AdminAuthService
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.security_state_service import client_ip from app.services.security_state_service import client_ip
@@ -24,7 +25,12 @@ def login(payload: AdminLoginRequest, request: Request, db: Session = Depends(ge
@router.get("/profile") @router.get("/profile")
def profile(current_admin: Admin = Depends(get_current_admin)) -> dict: def profile(current_admin: Admin = Depends(get_current_admin)) -> dict:
return api_success(AdminRead.model_validate(current_admin).model_dump()) return api_success({
"id": current_admin.id, "username": current_admin.username, "name": current_admin.name,
"status": current_admin.status, "isSuperAdmin": bool(current_admin.is_super_admin),
"mustChangePassword": bool(current_admin.must_change_password),
"permissions": sorted(permissions_for(current_admin)),
})
@router.post("/logout") @router.post("/logout")

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
import json import json
from pathlib import Path from pathlib import Path
from typing import Literal
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -16,6 +17,7 @@ from app.schemas.admin import DashboardStats
from app.services.admin_service import AdminDashboardService from app.services.admin_service import AdminDashboardService
from app.models.logs import StorageSnapshot from app.models.logs import StorageSnapshot
from app.services.redis_client import get_sync_redis_client from app.services.redis_client import get_sync_redis_client
from app.services.request_traffic_service import RequestTrafficService
router = APIRouter() router = APIRouter()
@@ -37,6 +39,14 @@ def dashboard(
return api_success(DashboardStats.model_validate(stats).model_dump()) return api_success(DashboardStats.model_validate(stats).model_dump())
@router.get("/dashboard/traffic")
async def peak_traffic(
grain: Literal["minute", "hour", "day", "week"] = Query(default="hour"),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
return api_success(await RequestTrafficService.peak_traffic(grain))
@router.get("/dashboard/storage") @router.get("/dashboard/storage")
def storage_stats( def storage_stats(
refresh: bool = Query(default=False), refresh: bool = Query(default=False),

View 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()

View 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}

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter, Depends
from app.core.dependencies import enforce_admin_access
from app.api import ( from app.api import (
admin_auth, admin_auth,
@@ -10,15 +11,18 @@ from app.api import (
admin_entitlements, admin_entitlements,
admin_knowledge, admin_knowledge,
admin_knowledge_lifecycle, admin_knowledge_lifecycle,
admin_management,
admin_records, admin_records,
admin_settings, admin_settings,
admin_sso, admin_sso,
admin_users, admin_users,
auth, auth,
feedback,
chat, chat,
health, health,
integration_sso, integration_sso,
user, user,
voice,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -26,15 +30,19 @@ api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(integration_sso.router, prefix="/integration/sso", tags=["integration-sso"]) api_router.include_router(integration_sso.router, prefix="/integration/sso", tags=["integration-sso"])
api_router.include_router(user.router, prefix="/user", tags=["user"]) api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(voice.router, prefix="/voice", tags=["voice"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"]) api_router.include_router(admin_management.router, prefix="/admin", tags=["admin-management"])
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"]) guard = [Depends(enforce_admin_access)]
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"], dependencies=guard)
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"]) api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"], dependencies=guard)
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"], dependencies=guard)
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"]) api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"]) api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"], dependencies=guard)
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"], dependencies=guard)
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"], dependencies=guard)

View 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))

View File

@@ -48,6 +48,8 @@ class Settings(BaseSettings):
aliyun_sms_template_code: str = "" aliyun_sms_template_code: str = ""
aliyun_sms_template_param_key: str = "code" aliyun_sms_template_param_key: str = "code"
aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com" aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com"
aliyun_nls_app_key: str = ""
aliyun_nls_endpoint: str = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr"
mock_rag_enabled: bool = False mock_rag_enabled: bool = False
mock_model_enabled: bool = True mock_model_enabled: bool = True
feishu_mock_enabled: bool = False feishu_mock_enabled: bool = False
@@ -63,6 +65,8 @@ class Settings(BaseSettings):
chat_max_queue_size: int = 90 chat_max_queue_size: int = 90
chat_queue_timeout_seconds: int = 90 chat_queue_timeout_seconds: int = 90
chat_active_lease_seconds: int = 900 chat_active_lease_seconds: int = 900
voice_input_enabled: bool = False
voice_max_duration_seconds: int = 60
periodic_report_worker_enabled: bool = True periodic_report_worker_enabled: bool = True
periodic_report_poll_seconds: int = 5 periodic_report_poll_seconds: int = 5
periodic_report_stale_minutes: int = 30 periodic_report_stale_minutes: int = 30

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -77,3 +77,44 @@ def get_current_admin(
if admin.status != 1: if admin.status != 1:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
return admin return admin
def enforce_admin_access(
request: Request,
admin: Admin = Depends(get_current_admin),
) -> Admin:
"""Block first-login accounts and enforce permissions on every existing admin API."""
from app.services.admin_permission_service import require_permission
if admin.must_change_password:
raise HTTPException(status_code=status.HTTP_428_PRECONDITION_REQUIRED, detail="首次登录请先修改初始密码")
path = request.url.path.split("/admin/", 1)[-1].strip("/")
method = request.method.upper()
if path.startswith("dashboard"):
permission = "dashboard.view"
elif path.startswith("user/") and ("/entitlement" in path or path.startswith("user/entitlement")):
permission = "entitlements.edit"
elif path.startswith("entitlement/"):
permission = "entitlements.view" if method == "GET" else "entitlements.edit"
elif path.startswith("user/"):
permission = "users.delete" if method == "DELETE" else ("users.view" if method == "GET" else ("users.create" if method == "POST" and (path in {"user", "user/import", "user/import/excel"}) else "users.edit"))
elif path.startswith("knowledge"):
permission = "knowledge.delete" if method == "DELETE" else ("knowledge.view" if method == "GET" else ("knowledge.publish" if path.endswith("open-status") or path.endswith("lifecycle") else "knowledge.edit"))
elif path.startswith("prompt") or path.startswith("agent/"):
permission = "prompt.view" if method == "GET" else "prompt.edit"
elif path.startswith("model"):
permission = "models.delete" if method == "DELETE" else ("models.view" if method == "GET" else "models.edit")
elif path.startswith("content-generation"):
permission = "content-generation.view" if method == "GET" else "content-generation.edit"
elif path.startswith("config") or path.startswith("feishu/cache"):
permission = "configs.view" if method == "GET" else "configs.edit"
elif path.startswith("sso/"):
permission = "sso.view" if method == "GET" else "sso.edit"
elif path.startswith("retrieval-log"):
permission = "retrievals.view" if method == "GET" else "configs.edit"
elif path.startswith("attention"):
permission = "attention.view" if method == "GET" else "attention.edit"
else:
permission = "records.view"
require_permission(admin, permission)
return admin

View File

@@ -80,6 +80,13 @@ class RequestObservabilityMiddleware:
finally: finally:
if response_finished: if response_finished:
self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed") self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed")
from app.services.request_traffic_service import RequestTrafficService
await RequestTrafficService.record(
scope.get("path", ""),
status_code,
(perf_counter() - started_at) * 1000,
)
request_id_context.reset(token) request_id_context.reset(token)
def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None: def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None:

View File

@@ -3,6 +3,7 @@ from app.models.ai_config import ContentGenerationConfig, ModelConfig, Prompt, S
from app.models.base import Base from app.models.base import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
from app.models.feedback import MessageFeedback
from app.models.growth import GrowthProfileRevision, PeriodicReport, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile from app.models.growth import GrowthProfileRevision, PeriodicReport, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile
from app.models.insight import QuestionInsightCleanedQuestion from app.models.insight import QuestionInsightCleanedQuestion
from app.models.knowledge import ( from app.models.knowledge import (
@@ -50,6 +51,7 @@ __all__ = [
"HumanAttentionHistory", "HumanAttentionHistory",
"HumanAttentionRecord", "HumanAttentionRecord",
"ModelConfig", "ModelConfig",
"MessageFeedback",
"OperationLog", "OperationLog",
"PeriodicReport", "PeriodicReport",
"LogRetentionPolicy", "LogRetentionPolicy",

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, String from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -15,6 +15,7 @@ class Role(Base, TimestampMixin):
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False) code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False) name: Mapped[str] = mapped_column(String(50), nullable=False)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True) remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
permissions: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
admins: Mapped[list["Admin"]] = relationship("Admin", back_populates="role") admins: Mapped[list["Admin"]] = relationship("Admin", back_populates="role")
@@ -29,5 +30,7 @@ class Admin(Base, TimestampMixin):
role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True) role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True)
status: Mapped[int] = mapped_column(default=1, nullable=False) status: Mapped[int] = mapped_column(default=1, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
must_change_password: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
is_super_admin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
role: Mapped[Role | None] = relationship("Role", back_populates="admins") role: Mapped[Role | None] = relationship("Role", back_populates="admins")

View 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)

View File

@@ -24,6 +24,29 @@ class AdminRead(ORMModel):
username: str username: str
name: str name: str
status: int status: int
isSuperAdmin: bool = False
mustChangePassword: bool = False
permissions: list[str] = Field(default_factory=list)
class ManagedAdminCreateRequest(BaseModel):
username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
name: str = Field(min_length=1, max_length=50)
initialPassword: str = Field(min_length=8, max_length=100)
status: int = Field(default=1, ge=0, le=1)
permissions: list[str] = Field(default_factory=list, max_length=100)
class ManagedAdminUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=50)
status: int = Field(default=1, ge=0, le=1)
permissions: list[str] = Field(default_factory=list, max_length=100)
resetPassword: str | None = Field(default=None, min_length=8, max_length=100)
class AdminPasswordChangeRequest(BaseModel):
currentPassword: str = Field(min_length=1, max_length=100)
newPassword: str = Field(min_length=8, max_length=100)
class CostBreakdownItem(BaseModel): class CostBreakdownItem(BaseModel):

View File

@@ -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="当前管理员无此操作权限")

View File

@@ -14,6 +14,7 @@ from app.models.knowledge import Knowledge
from app.models.logs import AiRequestLog, OperationLog from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User from app.models.user import User
from app.services.security_state_service import SecurityStateService from app.services.security_state_service import SecurityStateService
from app.services.admin_permission_service import permissions_for
DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"} DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"}
@@ -66,7 +67,14 @@ class AdminAuthService:
) )
cls.ensure_bootstrap_admin(db) cls.ensure_bootstrap_admin(db)
admin = db.scalar(select(Admin).where(Admin.username == username)) admin = db.scalar(select(Admin).where(Admin.username == username))
if admin is None or not verify_password(password, admin.password): password_valid = False
if admin is not None:
password_valid = (
password == get_settings().bootstrap_admin_password
if admin.is_super_admin
else verify_password(password, admin.password)
)
if admin is None or not password_valid:
SecurityStateService.record_failure( SecurityStateService.record_failure(
failure_key, failure_key,
limit=5, limit=5,
@@ -92,6 +100,9 @@ class AdminAuthService:
"username": admin.username, "username": admin.username,
"name": admin.name, "name": admin.name,
"status": admin.status, "status": admin.status,
"isSuperAdmin": bool(admin.is_super_admin),
"mustChangePassword": bool(admin.must_change_password),
"permissions": sorted(permissions_for(admin)),
}, },
} }
@@ -106,6 +117,8 @@ class AdminAuthService:
password=hash_password(password), password=hash_password(password),
name=name, name=name,
status=1, status=1,
must_change_password=0,
is_super_admin=1,
) )
db.add(admin) db.add(admin)
db.commit() db.commit()

View File

@@ -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

View File

@@ -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))

View File

@@ -13,4 +13,5 @@ redis==7.4.1
openpyxl==3.1.5 openpyxl==3.1.5
python-multipart==0.0.32 python-multipart==0.0.32
alibabacloud-dysmsapi20170525==4.6.0 alibabacloud-dysmsapi20170525==4.6.0
aliyun-python-sdk-core==2.16.0
Pillow==12.3.0 Pillow==12.3.0

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View 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}

View File

@@ -44,6 +44,10 @@ const statusText = ref("连接后端中");
const toastText = ref(""); const toastText = ref("");
const followingOutput = ref(true); const followingOutput = ref(true);
const messageList = ref<InstanceType<typeof MessageList> | null>(null); const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const feedbackDialogOpen = ref(false);
const feedbackMessageId = ref<number | null>(null);
const feedbackContent = ref("");
const feedbackSubmitting = ref(false);
const activeAbortController = ref<AbortController | null>(null); const activeAbortController = ref<AbortController | null>(null);
let toastTimer: number | null = null; let toastTimer: number | null = null;
let settlementPollVersion = 0; let settlementPollVersion = 0;
@@ -234,6 +238,13 @@ async function runGeneration(sessionId: number, message: string, assistantIndex:
currentAssistant().streaming = false; currentAssistant().streaming = false;
currentAssistant().retryQuestion = undefined; currentAssistant().retryQuestion = undefined;
currentAssistant().createdAt = new Date().toISOString(); currentAssistant().createdAt = new Date().toISOString();
try {
const latestHistory = await api.history(sessionId);
const latestAssistant = [...latestHistory].reverse().find((item) => item.role === "assistant");
if (latestAssistant) currentAssistant().id = String(latestAssistant.id);
} catch {
// 回答已经成功,历史记录偶发刷新失败不应将本次回答标记为失败。
}
await refreshSessionList(); await refreshSessionList();
await refreshProfile(); await refreshProfile();
} catch (error) { } catch (error) {
@@ -252,6 +263,29 @@ async function runGeneration(sessionId: number, message: string, assistantIndex:
} }
} }
function openFeedback(messageId: string) {
const parsed = Number(messageId);
if (!Number.isInteger(parsed)) return;
feedbackMessageId.value = parsed;
feedbackContent.value = "";
feedbackDialogOpen.value = true;
}
async function submitFeedback() {
const content = feedbackContent.value.trim();
if (!feedbackMessageId.value || !content || feedbackSubmitting.value) return;
feedbackSubmitting.value = true;
try {
await api.submitFeedback(feedbackMessageId.value, content);
feedbackDialogOpen.value = false;
showToast("感谢反馈,管理员会查看你提交的问题");
} catch (error) {
showToast(error instanceof Error ? error.message : "反馈提交失败");
} finally {
feedbackSubmitting.value = false;
}
}
function chatErrorMessage(error: unknown) { function chatErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : ""; const message = error instanceof Error ? error.message : "";
if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多AI 服务暂时繁忙。"; if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多AI 服务暂时繁忙。";
@@ -607,6 +641,7 @@ async function copyText(text: string) {
:loading-session="loadingSession" :loading-session="loadingSession"
@follow-change="followingOutput = $event" @follow-change="followingOutput = $event"
@retry="retryMessage" @retry="retryMessage"
@feedback="openFeedback"
/> />
<ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" /> <ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" />
<SessionDrawer <SessionDrawer
@@ -626,6 +661,14 @@ async function copyText(text: string) {
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div> <div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
<AppDialog v-if="feedbackDialogOpen" title="反馈这次回答" labelled-by="feedback-dialog-title" @close="feedbackDialogOpen = false">
<section class="feedback-dialog">
<p>请简单描述这次回答存在的问题管理员可以结合当时的对话记录查看</p>
<textarea v-model="feedbackContent" maxlength="200" rows="5" aria-label="反馈内容" placeholder="例如:回答没有解决我的问题、内容不准确……" />
<div class="feedback-dialog-footer"><span>{{ feedbackContent.length }}/200</span><button type="button" :disabled="!feedbackContent.trim() || feedbackSubmitting" @click="submitFeedback">{{ feedbackSubmitting ? "提交中" : "提交反馈" }}</button></div>
</section>
</AppDialog>
<PersonalCenterDialog <PersonalCenterDialog
v-if="personalCenterOpen && user" v-if="personalCenterOpen && user"
:user="user" :user="user"

View File

@@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Send, Square } from "@lucide/vue"; import { Mic, Send, Square, X } from "@lucide/vue";
import { nextTick, ref } from "vue"; import { nextTick, onMounted, ref } from "vue";
import { useVoiceRecorder } from "../composables/useVoiceRecorder";
import { api, transcribeVoice } from "../services/api";
defineProps<{ defineProps<{
loading: boolean; loading: boolean;
@@ -14,6 +17,35 @@ const emit = defineEmits<{
const input = ref(""); const input = ref("");
const textarea = ref<HTMLTextAreaElement | null>(null); const textarea = ref<HTMLTextAreaElement | null>(null);
const voiceEnabled = ref(false);
const voiceMaxDuration = ref(60);
const transcribing = ref(false);
const voiceError = ref("");
const { recording, elapsedSeconds, start: startRecording, stop: stopRecording, cancel: cancelRecording } = useVoiceRecorder(
async (audio) => {
transcribing.value = true;
voiceError.value = "";
try {
const result = await transcribeVoice(audio);
insertTranscription(result.text);
} catch (error) {
voiceError.value = error instanceof Error ? error.message : "语音识别失败,请重试";
} finally {
transcribing.value = false;
}
},
);
onMounted(async () => {
try {
const config = await api.voiceConfig();
voiceEnabled.value = config.enabled;
voiceMaxDuration.value = config.maxDurationSeconds;
} catch {
voiceEnabled.value = false;
}
});
function resize() { function resize() {
const element = textarea.value; const element = textarea.value;
@@ -49,11 +81,48 @@ function onKeydown(event: KeyboardEvent) {
send(); send();
} }
} }
async function beginVoiceInput() {
voiceError.value = "";
try {
await startRecording(voiceMaxDuration.value);
} catch (error) {
const name = error instanceof DOMException ? error.name : "";
voiceError.value = name === "NotAllowedError"
? "麦克风权限未开启,请在浏览器设置中允许后重试"
: error instanceof Error ? error.message : "无法启动录音";
}
}
async function insertTranscription(text: string) {
const element = textarea.value;
const start = element?.selectionStart ?? input.value.length;
const end = element?.selectionEnd ?? start;
const prefix = input.value.slice(0, start);
const suffix = input.value.slice(end);
const spacer = prefix && !/\s$/.test(prefix) ? " " : "";
input.value = `${prefix}${spacer}${text}${suffix}`;
await nextTick();
resize();
const cursor = prefix.length + spacer.length + text.length;
element?.focus();
element?.setSelectionRange(cursor, cursor);
}
function formatSeconds(seconds: number) {
return `00:${String(seconds).padStart(2, "0")}`;
}
</script> </script>
<template> <template>
<form class="chat-composer" @submit.prevent="send"> <form class="chat-composer" @submit.prevent="send">
<div v-if="recording" class="voice-recording-panel" role="status">
<span class="voice-pulse" aria-hidden="true"></span>
<strong>正在录音 {{ formatSeconds(elapsedSeconds) }}</strong>
<span>最长 {{ voiceMaxDuration }} </span>
</div>
<textarea <textarea
v-else
ref="textarea" ref="textarea"
v-model="input" v-model="input"
rows="1" rows="1"
@@ -63,13 +132,25 @@ function onKeydown(event: KeyboardEvent) {
@input="resize" @input="resize"
@keydown="onKeydown" @keydown="onKeydown"
/> />
<button v-if="recording" type="button" class="composer-voice cancel" aria-label="取消录音" @click="cancelRecording">
<X :size="18" aria-hidden="true" />
</button>
<button v-else-if="voiceEnabled && !loading" type="button" class="composer-voice" :disabled="disabled || transcribing" :aria-label="transcribing ? '正在识别语音' : '语音输入'" @click="beginVoiceInput">
<Mic :size="19" aria-hidden="true" />
<span>{{ transcribing ? "识别中" : "语音" }}</span>
</button>
<button v-if="recording" type="button" class="composer-voice finish" aria-label="完成录音" @click="stopRecording">
<Square :size="16" fill="currentColor" aria-hidden="true" />
<span>完成</span>
</button>
<button v-if="loading" type="button" class="composer-stop" aria-label="停止生成" @click="emit('stop')"> <button v-if="loading" type="button" class="composer-stop" aria-label="停止生成" @click="emit('stop')">
<Square :size="17" fill="currentColor" aria-hidden="true" /> <Square :size="17" fill="currentColor" aria-hidden="true" />
<span>停止</span> <span>停止</span>
</button> </button>
<button v-else type="submit" class="composer-send" :disabled="disabled || !input.trim()"> <button v-else-if="!recording" type="submit" class="composer-send" :disabled="disabled || !input.trim()">
<Send :size="18" aria-hidden="true" /> <Send :size="18" aria-hidden="true" />
<span>发送</span> <span>发送</span>
</button> </button>
<p v-if="voiceError" class="voice-input-error" role="alert">{{ voiceError }}</p>
</form> </form>
</template> </template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Bot, RotateCcw } from "@lucide/vue"; import { Bot, MessageSquareWarning, RotateCcw } from "@lucide/vue";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { computed } from "vue"; import { computed } from "vue";
@@ -17,6 +17,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
retry: []; retry: [];
feedback: [];
}>(); }>();
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true }); const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
@@ -111,6 +112,10 @@ const displayTime = computed(() => {
</template> </template>
<div v-else class="message-content">{{ renderedContent }}</div> <div v-else class="message-content">{{ renderedContent }}</div>
<time v-if="displayTime" :datetime="createdAt">{{ displayTime }}</time> <time v-if="displayTime" :datetime="createdAt">{{ displayTime }}</time>
<button v-if="role === 'assistant' && !streaming && !errorMessage && /^\d+$/.test(messageId)" type="button" class="message-feedback-button" @click="emit('feedback')">
<MessageSquareWarning :size="14" aria-hidden="true" />
反馈
</button>
</div> </div>
</article> </article>
</template> </template>

View File

@@ -23,6 +23,7 @@ defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
followChange: [following: boolean]; followChange: [following: boolean];
retry: [messageId: string]; retry: [messageId: string];
feedback: [messageId: string];
}>(); }>();
const scroller = ref<HTMLElement | null>(null); const scroller = ref<HTMLElement | null>(null);
@@ -69,6 +70,7 @@ defineExpose({ scrollToBottom, scrollToMessage });
:error-message="message.errorMessage" :error-message="message.errorMessage"
:can-retry="Boolean(message.retryQuestion)" :can-retry="Boolean(message.retryQuestion)"
@retry="emit('retry', message.id)" @retry="emit('retry', message.id)"
@feedback="emit('feedback', message.id)"
/> />
</template> </template>
</section> </section>

View File

@@ -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));
}

View File

@@ -10,6 +10,8 @@ import type {
ShareDraft, ShareDraft,
TeacherHelpCard, TeacherHelpCard,
UserProfile, UserProfile,
VoiceInputConfig,
VoiceTranscriptionResult,
} from "../types/api"; } from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
@@ -116,8 +118,34 @@ export const api = {
practiceReview: () => request<PracticeReviewResult>("/user/growth-profile"), practiceReview: () => request<PracticeReviewResult>("/user/growth-profile"),
periodicReports: (limit = 10) => request<PeriodicReport[]>(`/user/periodic-report/list?limit=${limit}`), periodicReports: (limit = 10) => request<PeriodicReport[]>(`/user/periodic-report/list?limit=${limit}`),
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }), stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
voiceConfig: () => request<VoiceInputConfig>("/voice/config"),
submitFeedback: (messageId: number, content: string) => request<{ id: number }>("/feedback", { method: "POST", body: JSON.stringify({ messageId, content }) }),
}; };
export async function transcribeVoice(audio: Blob): Promise<VoiceTranscriptionResult> {
const form = new FormData();
form.append("audio", audio, "voice-recording");
const headers = new Headers();
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 30_000);
let response: Response;
try {
response = await fetch(`${API_BASE}/voice/transcribe`, { method: "POST", headers, body: form, signal: controller.signal });
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw new ApiError("语音识别超时,请稍后重试", 408);
throw new ApiError("语音上传失败,请检查网络后重试", 0);
} finally {
window.clearTimeout(timeout);
}
const body = (await response.json().catch(() => ({ code: response.status, message: "服务响应异常", data: null }))) as ApiResponse<VoiceTranscriptionResult>;
if (!response.ok || body.code !== 0) {
throw new ApiError(body.message || "语音识别失败", response.status, response.headers.get("X-Request-ID") ?? "");
}
return body.data;
}
export async function streamChat( export async function streamChat(
sessionId: number, sessionId: number,
message: string, message: string,

View File

@@ -1506,7 +1506,7 @@ textarea:focus-visible {
position: relative; position: relative;
z-index: 6; z-index: 6;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto auto;
align-items: end; align-items: end;
gap: 10px; gap: 10px;
padding: 12px 14px max(12px, env(safe-area-inset-bottom)); padding: 12px 14px max(12px, env(safe-area-inset-bottom));
@@ -1539,7 +1539,8 @@ textarea:focus-visible {
.chat-composer textarea::placeholder { color: var(--chat-weak); } .chat-composer textarea::placeholder { color: var(--chat-weak); }
.composer-send, .composer-send,
.composer-stop { .composer-stop,
.composer-voice {
min-width: 78px; min-width: 78px;
min-height: 50px; min-height: 50px;
display: inline-flex; display: inline-flex;
@@ -1554,6 +1555,36 @@ textarea:focus-visible {
font-weight: 650; font-weight: 650;
} }
.composer-voice {
min-width: 50px;
padding: 0 13px;
border: 1px solid var(--chat-control-border);
background: #ffffff;
color: var(--chat-brand-deep);
}
.composer-voice:disabled { cursor: not-allowed; opacity: 0.55; }
.composer-voice.cancel { color: var(--chat-danger); }
.composer-voice.finish { border-color: var(--chat-brand); background: var(--chat-brand); color: #ffffff; }
.voice-recording-panel {
min-height: 50px;
display: flex;
align-items: center;
gap: 9px;
padding: 0 15px;
border: 1px solid rgba(20, 148, 119, 0.34);
border-radius: var(--chat-radius-control);
background: rgba(20, 148, 119, 0.07);
color: var(--chat-brand-deep);
}
.voice-recording-panel span:last-child { color: var(--chat-weak); font-size: 12px; }
.voice-pulse { width: 9px; height: 9px; border-radius: 50%; background: var(--chat-danger); animation: voice-pulse 1.1s ease-in-out infinite; }
.voice-input-error { grid-column: 1 / -1; margin: -2px 3px 0; color: var(--chat-danger); font-size: 12px; }
@keyframes voice-pulse { 50% { opacity: 0.35; transform: scale(0.78); } }
.composer-send { background: var(--chat-brand); } .composer-send { background: var(--chat-brand); }
.composer-stop { background: var(--chat-danger); } .composer-stop { background: var(--chat-danger); }
@@ -1563,6 +1594,14 @@ textarea:focus-visible {
color: #93a39d; color: #93a39d;
} }
.message-feedback-button { margin-top: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--chat-weak); font-size: 12px; }
.message-feedback-button:hover { background: rgba(20, 148, 119, 0.08); color: var(--chat-brand-deep); }
.feedback-dialog p { margin: 0 0 12px; color: var(--chat-muted); line-height: 1.65; }
.feedback-dialog textarea { width: 100%; resize: vertical; padding: 12px; border: 1px solid var(--chat-control-border); border-radius: 10px; font: inherit; }
.feedback-dialog-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--chat-weak); font-size: 12px; }
.feedback-dialog-footer button { min-height: 38px; padding: 0 18px; border: 0; border-radius: 9px; background: var(--chat-brand); color: white; font-weight: 650; }
.feedback-dialog-footer button:disabled { opacity: 0.5; }
.history-mask { .history-mask {
position: absolute; position: absolute;
inset: 0; inset: 0;

View File

@@ -128,6 +128,16 @@ export interface CaptchaResult {
expiresInSeconds: number; expiresInSeconds: number;
} }
export interface VoiceInputConfig {
enabled: boolean;
maxDurationSeconds: number;
}
export interface VoiceTranscriptionResult {
text: string;
durationSeconds: number;
}
export interface ChatSession { export interface ChatSession {
id: number; id: number;
title: string; title: string;

View File

@@ -60,6 +60,7 @@ services:
JWT_SECRET_KEY: local-dev-secret-change-before-production JWT_SECRET_KEY: local-dev-secret-change-before-production
MOCK_SMS_ENABLED: "true" MOCK_SMS_ENABLED: "true"
MOCK_SMS_CODE: "123456" MOCK_SMS_CODE: "123456"
ALIYUN_NLS_APP_KEY: "${ALIYUN_NLS_APP_KEY:-FBFMOHMH4JFv7UNn}"
MOCK_RAG_ENABLED: "false" MOCK_RAG_ENABLED: "false"
MOCK_MODEL_ENABLED: "true" MOCK_MODEL_ENABLED: "true"
FEISHU_MOCK_ENABLED: "false" FEISHU_MOCK_ENABLED: "false"