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

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

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

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

View File

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

View File

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

View File

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

View File

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