feat: add admin permissions voice input analytics and feedback
This commit is contained in:
@@ -49,10 +49,26 @@ const SsoIntegrationView = defineAsyncComponent(
|
||||
const SystemConfigView = defineAsyncComponent(
|
||||
() => import("./components/SystemConfigView.vue"),
|
||||
);
|
||||
const AdminManagementView = defineAsyncComponent(
|
||||
() => import("./components/AdminManagementView.vue"),
|
||||
);
|
||||
const FeedbackManagementView = defineAsyncComponent(() => import("./components/FeedbackManagementView.vue"));
|
||||
|
||||
const admin = ref<AdminProfile | null>(null);
|
||||
const activeMenu = ref("dashboard");
|
||||
const loading = ref(false);
|
||||
const passwordDialogVisible = ref(false);
|
||||
const passwordSaving = ref(false);
|
||||
const passwordForm = reactive({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||
|
||||
const can = (permission: string) => Boolean(admin.value?.isSuperAdmin || admin.value?.permissions.includes(permission));
|
||||
const menuPermissions: Record<string, string> = {
|
||||
dashboard: "dashboard.view", users: "users.view", entitlements: "entitlements.view",
|
||||
knowledge: "knowledge.view", prompt: "prompt.view", models: "models.view",
|
||||
"content-generation": "content-generation.view", configs: "configs.view", sso: "sso.view",
|
||||
records: "records.view", retrievals: "retrievals.view", attention: "attention.view", admins: "admins.view",
|
||||
feedback: "feedback.view",
|
||||
};
|
||||
|
||||
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
||||
const models = ref<ModelItem[]>([]);
|
||||
@@ -190,7 +206,8 @@ onMounted(async () => {
|
||||
if (!getToken()) return;
|
||||
try {
|
||||
admin.value = await api.profile();
|
||||
await loadCurrentMenu();
|
||||
passwordDialogVisible.value = admin.value.mustChangePassword;
|
||||
if (!admin.value.mustChangePassword) await enterFirstAvailableMenu();
|
||||
} catch {
|
||||
clearToken();
|
||||
}
|
||||
@@ -202,7 +219,8 @@ async function login(credentials: { username: string; password: string }) {
|
||||
const result = await api.login(credentials.username, credentials.password);
|
||||
saveToken(result.token);
|
||||
admin.value = result.admin;
|
||||
await loadCurrentMenu();
|
||||
passwordDialogVisible.value = result.admin.mustChangePassword;
|
||||
if (!result.admin.mustChangePassword) await enterFirstAvailableMenu();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "登录失败");
|
||||
} finally {
|
||||
@@ -220,10 +238,32 @@ async function logout() {
|
||||
}
|
||||
|
||||
async function switchMenu(menu: string) {
|
||||
if (!can(menuPermissions[menu])) return;
|
||||
activeMenu.value = menu;
|
||||
await loadCurrentMenu();
|
||||
}
|
||||
|
||||
async function enterFirstAvailableMenu() {
|
||||
const first = Object.keys(menuPermissions).find((menu) => can(menuPermissions[menu]));
|
||||
activeMenu.value = first ?? "dashboard";
|
||||
if (first) await loadCurrentMenu();
|
||||
}
|
||||
|
||||
async function changeInitialPassword() {
|
||||
if (passwordForm.newPassword.length < 8) return ElMessage.warning("新密码至少 8 位");
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) return ElMessage.warning("两次输入的新密码不一致");
|
||||
passwordSaving.value = true;
|
||||
try {
|
||||
await api.changeAdminPassword({ currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword });
|
||||
if (admin.value) admin.value.mustChangePassword = false;
|
||||
passwordDialogVisible.value = false;
|
||||
Object.assign(passwordForm, { currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||
ElMessage.success("密码已修改,请使用新密码登录");
|
||||
await logout();
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "密码修改失败"); }
|
||||
finally { passwordSaving.value = false; }
|
||||
}
|
||||
|
||||
async function previewKnowledge(knowledgeId: number) {
|
||||
previewKnowledgeId.value = knowledgeId;
|
||||
await switchMenu("prompt");
|
||||
@@ -596,77 +636,99 @@ async function clearFeishuCache() {
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-title">大本营千问千答</div>
|
||||
<button
|
||||
v-if="can('dashboard.view')"
|
||||
:class="{ active: activeMenu === 'dashboard' }"
|
||||
@click="switchMenu('dashboard')"
|
||||
>
|
||||
数据看板
|
||||
</button>
|
||||
<button
|
||||
v-if="can('users.view')"
|
||||
:class="{ active: activeMenu === 'users' }"
|
||||
@click="switchMenu('users')"
|
||||
>
|
||||
用户管理
|
||||
</button>
|
||||
<button
|
||||
v-if="can('entitlements.view')"
|
||||
:class="{ active: activeMenu === 'entitlements' }"
|
||||
@click="switchMenu('entitlements')"
|
||||
>
|
||||
权益管理
|
||||
</button>
|
||||
<button
|
||||
v-if="can('knowledge.view')"
|
||||
:class="{ active: activeMenu === 'knowledge' }"
|
||||
@click="switchMenu('knowledge')"
|
||||
>
|
||||
知识库管理
|
||||
</button>
|
||||
<button
|
||||
v-if="can('prompt.view')"
|
||||
:class="{ active: activeMenu === 'prompt' }"
|
||||
@click="switchMenu('prompt')"
|
||||
>
|
||||
Agent 管理
|
||||
</button>
|
||||
<button
|
||||
v-if="can('models.view')"
|
||||
:class="{ active: activeMenu === 'models' }"
|
||||
@click="switchMenu('models')"
|
||||
>
|
||||
模型管理
|
||||
</button>
|
||||
<button
|
||||
v-if="can('content-generation.view')"
|
||||
:class="{ active: activeMenu === 'content-generation' }"
|
||||
@click="switchMenu('content-generation')"
|
||||
>
|
||||
内容生成
|
||||
</button>
|
||||
<button
|
||||
v-if="can('configs.view')"
|
||||
:class="{ active: activeMenu === 'configs' }"
|
||||
@click="switchMenu('configs')"
|
||||
>
|
||||
系统配置
|
||||
</button>
|
||||
<button
|
||||
v-if="can('sso.view')"
|
||||
:class="{ active: activeMenu === 'sso' }"
|
||||
@click="switchMenu('sso')"
|
||||
>
|
||||
应用接入
|
||||
</button>
|
||||
<button
|
||||
v-if="can('records.view')"
|
||||
:class="{ active: activeMenu === 'records' }"
|
||||
@click="switchMenu('records')"
|
||||
>
|
||||
记录审计
|
||||
</button>
|
||||
<button
|
||||
v-if="can('retrievals.view')"
|
||||
:class="{ active: activeMenu === 'retrievals' }"
|
||||
@click="switchMenu('retrievals')"
|
||||
>
|
||||
检索日志
|
||||
</button>
|
||||
<button
|
||||
v-if="can('feedback.view')"
|
||||
:class="{ active: activeMenu === 'feedback' }"
|
||||
@click="switchMenu('feedback')"
|
||||
>反馈管理</button>
|
||||
<button
|
||||
v-if="can('attention.view')"
|
||||
:class="{ active: activeMenu === 'attention' }"
|
||||
@click="switchMenu('attention')"
|
||||
>
|
||||
人工关注
|
||||
</button>
|
||||
<button
|
||||
v-if="admin.isSuperAdmin"
|
||||
:class="{ active: activeMenu === 'admins' }"
|
||||
@click="switchMenu('admins')"
|
||||
>管理员与权限</button>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
@@ -1057,8 +1119,20 @@ async function clearFeishuCache() {
|
||||
|
||||
<RetrievalLogView v-if="activeMenu === 'retrievals'" />
|
||||
<AttentionManagementView v-if="activeMenu === 'attention'" />
|
||||
<FeedbackManagementView v-if="activeMenu === 'feedback'" :can-delete="can('feedback.delete')" />
|
||||
<RecordAuditView v-if="activeMenu === 'records'" />
|
||||
<AdminManagementView v-if="activeMenu === 'admins'" />
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<el-dialog v-model="passwordDialogVisible" title="首次登录,请修改初始密码" width="460px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||||
<el-alert title="为保障账号安全,修改密码后需要重新登录。" type="warning" :closable="false" show-icon />
|
||||
<el-form label-position="top" class="password-form">
|
||||
<el-form-item label="当前初始密码"><el-input v-model="passwordForm.currentPassword" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="新密码"><el-input v-model="passwordForm.newPassword" type="password" show-password placeholder="至少 8 位" /></el-form-item>
|
||||
<el-form-item label="确认新密码"><el-input v-model="passwordForm.confirmPassword" type="password" show-password /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="logout">退出登录</el-button><el-button type="primary" :loading="passwordSaving" @click="changeInitialPassword">确认修改</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox, type ElTree } from "element-plus";
|
||||
import { nextTick, onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { ManagedAdmin, PermissionNode } from "../types/api";
|
||||
|
||||
const admins = ref<ManagedAdmin[]>([]);
|
||||
const tree = ref<PermissionNode[]>([]);
|
||||
const permissionTree = ref<InstanceType<typeof ElTree>>();
|
||||
const dialogVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = reactive({ username: "", name: "", initialPassword: "", resetPassword: "", status: 1 });
|
||||
|
||||
async function load() {
|
||||
[admins.value, tree.value] = await Promise.all([api.administrators(), api.permissionTree()]);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null;
|
||||
Object.assign(form, { username: "", name: "", initialPassword: "", resetPassword: "", status: 1 });
|
||||
dialogVisible.value = true;
|
||||
nextTick(() => permissionTree.value?.setCheckedKeys([]));
|
||||
}
|
||||
|
||||
function openEdit(item: ManagedAdmin) {
|
||||
editingId.value = item.id;
|
||||
Object.assign(form, { username: item.username, name: item.name, initialPassword: "", resetPassword: "", status: item.status });
|
||||
dialogVisible.value = true;
|
||||
nextTick(() => permissionTree.value?.setCheckedKeys(item.permissions));
|
||||
}
|
||||
|
||||
function selectedPermissions() {
|
||||
return (permissionTree.value?.getCheckedKeys(false) ?? []).map(String).filter((code) => code.includes("."));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim() || (!editingId.value && (!form.username.trim() || form.initialPassword.length < 8))) {
|
||||
ElMessage.warning("请完整填写账号、姓名和至少 8 位的初始密码");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const permissions = selectedPermissions();
|
||||
if (editingId.value) {
|
||||
await api.updateAdministrator(editingId.value, { name: form.name, status: form.status, permissions, resetPassword: form.resetPassword || null });
|
||||
} else {
|
||||
await api.createAdministrator({ username: form.username, name: form.name, initialPassword: form.initialPassword, status: form.status, permissions });
|
||||
}
|
||||
ElMessage.success(editingId.value ? "管理员已更新" : "管理员已创建");
|
||||
dialogVisible.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally { saving.value = false; }
|
||||
}
|
||||
|
||||
async function remove(item: ManagedAdmin) {
|
||||
await ElMessageBox.confirm(`确定删除管理员“${item.name}”?删除后将无法登录。`, "删除管理员", { type: "warning", confirmButtonText: "确定删除" });
|
||||
await api.deleteAdministrator(item.id);
|
||||
ElMessage.success("管理员已删除");
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-management">
|
||||
<div class="page-head inline">
|
||||
<div><h2>管理员与权限</h2><p>按页面和功能分配权限;未授权的页面不会出现,后端也会拒绝越权请求。</p></div>
|
||||
<el-button type="primary" @click="openCreate">+新增管理员</el-button>
|
||||
</div>
|
||||
<el-card shadow="never">
|
||||
<el-table :data="admins" stripe>
|
||||
<el-table-column prop="name" label="管理员" min-width="150" />
|
||||
<el-table-column prop="username" label="登录账号" min-width="160" />
|
||||
<el-table-column label="类型" width="130"><template #default="{ row }"><el-tag :type="row.isSuperAdmin ? 'danger' : 'info'">{{ row.isSuperAdmin ? '超级管理员' : '普通管理员' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="权限" min-width="150"><template #default="{ row }">{{ row.isSuperAdmin ? '全部权限' : `${row.permissions.length} 项功能` }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '正常' : '已停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="密码状态" width="150"><template #default="{ row }">{{ row.isSuperAdmin ? '环境变量托管' : (row.mustChangePassword ? '待首次修改' : '已修改') }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><template v-if="!row.isSuperAdmin"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template><span v-else class="locked">系统托管</span></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" class="admin-editor-dialog" :title="editingId ? '编辑管理员' : '新增管理员'" width="820px" destroy-on-close>
|
||||
<el-form class="admin-editor-form" label-position="top">
|
||||
<section class="form-section">
|
||||
<div class="section-title"><strong>基本信息</strong><span>用于登录和识别管理员</span></div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="登录账号"><el-input v-model="form.username" :disabled="!!editingId" placeholder="英文、数字、点、下划线或短横线" /></el-form-item>
|
||||
<el-form-item label="管理员姓名"><el-input v-model="form.name" placeholder="请输入真实姓名或职位名称" /></el-form-item>
|
||||
<el-form-item :label="editingId ? '重置密码(可选)' : '初始密码'"><el-input v-if="editingId" v-model="form.resetPassword" show-password placeholder="留空表示不重置" /><el-input v-else v-model="form.initialPassword" show-password placeholder="至少 8 位,首次登录后强制修改" /></el-form-item>
|
||||
<el-form-item label="账号状态"><div class="status-control"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /><span>{{ form.status === 1 ? '正常,可以登录' : '已停用,无法登录' }}</span></div></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="form-section permission-section">
|
||||
<div class="section-title"><strong>功能权限</strong><span>勾选一级菜单可快速全选,也可逐项精细分配</span></div>
|
||||
<div class="permission-box"><el-tree ref="permissionTree" :data="tree.filter(n => !n.superOnly)" node-key="code" show-checkbox default-expand-all :props="{ label: 'name', children: 'children' }" /></div>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存管理员</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-editor-form{display:grid;gap:22px}.form-section{padding:18px 20px;border:1px solid #e3e9e6;border-radius:12px;background:#fff}.section-title{display:flex;align-items:baseline;gap:10px;margin-bottom:18px}.section-title strong{font-size:16px}.section-title span{color:#7c8d87;font-size:13px}.form-grid{display:grid;grid-template-columns:1fr 1fr;column-gap:24px;row-gap:18px}.admin-editor-form :deep(.el-form-item){margin-bottom:0}.admin-editor-form :deep(.el-form-item__label){height:auto;padding:0 0 8px;color:#465650;font-weight:600;line-height:1.4}.status-control{display:flex;align-items:center;gap:10px;height:40px}.status-control span{color:#687a74;font-size:13px}.permission-section{background:#f8faf9}.permission-box{width:100%;max-height:390px;overflow:auto;padding:12px 14px;border:1px solid #dfe6e3;border-radius:9px;background:#fff}.locked{color:#909399}.admin-management :deep(.el-tree-node__content){height:34px;border-radius:6px}.admin-management :deep(.el-tree-node__content:hover){background:#eef6f3}.admin-management :deep(.el-dialog__body){max-height:calc(100vh - 190px);padding:20px 24px;overflow:auto}.admin-management :deep(.el-dialog__header){padding:22px 24px 16px;border-bottom:1px solid #edf1ef}.admin-management :deep(.el-dialog__footer){padding:16px 24px 20px;border-top:1px solid #edf1ef}@media(max-width:760px){.form-grid{grid-template-columns:1fr}.section-title{align-items:flex-start;flex-direction:column;gap:4px}}
|
||||
</style>
|
||||
@@ -1,58 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { LineChart } from "echarts/charts";
|
||||
import { DataZoomComponent, GridComponent, LegendComponent, MarkPointComponent, TooltipComponent } from "echarts/components";
|
||||
import { init, use, type ECharts } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { DashboardStats } from "../types/api";
|
||||
import type { DashboardStats, PeakTrafficResult, TrafficGrain } from "../types/api";
|
||||
|
||||
use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkPointComponent, CanvasRenderer]);
|
||||
|
||||
const stats = ref<DashboardStats | null>(null);
|
||||
const traffic = ref<PeakTrafficResult | null>(null);
|
||||
const trafficChart = ref<HTMLElement | null>(null);
|
||||
let trafficChartInstance: ECharts | null = null;
|
||||
const trafficGrain = ref<TrafficGrain>("hour");
|
||||
const storageStats = ref<Record<string, any> | null>(null);
|
||||
const storageLoading = ref(false);
|
||||
const trafficLoading = ref(false);
|
||||
const loading = ref(false);
|
||||
const filters = reactive({ start: "", end: "" });
|
||||
|
||||
onMounted(loadDashboard);
|
||||
const trafficOptions: Array<{ label: string; value: TrafficGrain }> = [
|
||||
{ label: "分钟", value: "minute" },
|
||||
{ label: "小时", value: "hour" },
|
||||
{ label: "天", value: "day" },
|
||||
{ label: "周", value: "week" },
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadDashboard(), loadTraffic()]);
|
||||
window.addEventListener("resize", resizeTrafficChart);
|
||||
});
|
||||
onBeforeUnmount(() => { window.removeEventListener("resize", resizeTrafficChart); trafficChartInstance?.dispose(); });
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true;
|
||||
try {
|
||||
stats.value = await api.dashboard(filters.start, filters.end);
|
||||
storageLoading.value = true;
|
||||
api
|
||||
.storageStats()
|
||||
.then((value) => {
|
||||
storageStats.value = value;
|
||||
})
|
||||
.catch(() => {
|
||||
storageStats.value = null;
|
||||
})
|
||||
.finally(() => {
|
||||
storageLoading.value = false;
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
api.storageStats().then((value) => { storageStats.value = value; }).catch(() => { storageStats.value = null; }).finally(() => { storageLoading.value = false; });
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function loadTraffic(grain: TrafficGrain = trafficGrain.value) {
|
||||
trafficGrain.value = grain;
|
||||
trafficLoading.value = true;
|
||||
try { traffic.value = await api.peakTraffic(grain); await nextTick(); renderTrafficChart(); }
|
||||
catch (error) { ElMessage.error(error instanceof Error ? error.message : "峰值流量加载失败"); }
|
||||
finally { trafficLoading.value = false; }
|
||||
}
|
||||
|
||||
function resizeTrafficChart() { trafficChartInstance?.resize(); }
|
||||
|
||||
function renderTrafficChart() {
|
||||
if (!trafficChart.value) return;
|
||||
trafficChartInstance ??= init(trafficChart.value);
|
||||
const rows = traffic.value?.rows ?? [];
|
||||
const zoomStart = trafficGrain.value === "minute" ? 98 : trafficGrain.value === "hour" ? 70 : 0;
|
||||
trafficChartInstance.setOption({
|
||||
animationDuration: 350,
|
||||
color: ["#176b55", "#d66a57"],
|
||||
grid: { left: 52, right: 28, top: 36, bottom: rows.length > 30 ? 72 : 48 },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
formatter(params: Array<{ dataIndex: number; marker: string; seriesName: string; value: number }>) {
|
||||
const index = params[0]?.dataIndex ?? 0;
|
||||
const row = rows[index];
|
||||
if (!row) return "";
|
||||
return `<strong>${row.periodLabel}</strong><br/>${params.map((item) => `${item.marker}${item.seriesName}:${item.value}`).join("<br/>")}<br/><span style="color:#7b8b85">平均响应:${row.avgResponseMs} ms</span>`;
|
||||
},
|
||||
},
|
||||
legend: { top: 0, right: 0, data: ["请求数", "5xx 错误"] },
|
||||
xAxis: { type: "category", boundaryGap: false, data: rows.map((row) => row.periodLabel), axisLabel: { color: "#71827c", hideOverlap: true }, axisLine: { lineStyle: { color: "#dce6e2" } } },
|
||||
yAxis: { type: "value", minInterval: 1, name: "请求数", nameTextStyle: { color: "#71827c" }, splitLine: { lineStyle: { color: "#edf2f0" } } },
|
||||
dataZoom: rows.length > 30 ? [{ type: "inside", start: zoomStart, end: 100 }, { type: "slider", start: zoomStart, end: 100, height: 18, bottom: 8, borderColor: "#dce6e2", fillerColor: "rgba(23,107,85,.12)" }] : [],
|
||||
series: [
|
||||
{ name: "请求数", type: "line", smooth: true, showSymbol: rows.length < 80, symbolSize: 7, lineStyle: { width: 3 }, areaStyle: { color: "rgba(23,107,85,.10)" }, data: rows.map((row) => row.requestCount), markPoint: { symbolSize: 48, data: [{ type: "max", name: "峰值" }] } },
|
||||
{ name: "5xx 错误", type: "line", smooth: true, showSymbol: false, lineStyle: { width: 2, type: "dashed" }, data: rows.map((row) => row.errorCount) },
|
||||
],
|
||||
}, true);
|
||||
}
|
||||
|
||||
async function refreshStorage() {
|
||||
storageLoading.value = true;
|
||||
try {
|
||||
storageStats.value = await api.storageStats(true);
|
||||
ElMessage.success("存储统计已刷新");
|
||||
} finally {
|
||||
storageLoading.value = false;
|
||||
}
|
||||
try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); }
|
||||
finally { storageLoading.value = false; }
|
||||
}
|
||||
|
||||
function formatBytes(value: unknown) {
|
||||
if (typeof value !== "number") return "无法统计";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = value;
|
||||
let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
let size = value; let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; }
|
||||
return `${size.toFixed(index ? 2 : 0)} ${units[index]}`;
|
||||
}
|
||||
|
||||
@@ -63,159 +104,67 @@ function formatMoney(value?: number | null, currency = "CNY") {
|
||||
}
|
||||
|
||||
function modelSceneLabel(scene: string) {
|
||||
return (
|
||||
(
|
||||
{
|
||||
background_report: "周期报告",
|
||||
background_summary: "摘要沉淀",
|
||||
fixed_info: "固定信息问答",
|
||||
simple_knowledge: "简单知识问答",
|
||||
knowledge_grounded: "知识问答",
|
||||
general_chat: "通用对话",
|
||||
} as Record<string, string>
|
||||
)[scene] ||
|
||||
scene ||
|
||||
"未分类"
|
||||
);
|
||||
return ({ background_report: "周期报告", background_summary: "摘要沉淀", fixed_info: "固定信息问答", simple_knowledge: "简单知识问答", knowledge_grounded: "知识问答", general_chat: "通用对话" } as Record<string, string>)[scene] || scene || "未分类";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="feature-page">
|
||||
<div class="page-head inline">
|
||||
<section v-loading="loading" class="feature-page dashboard-page">
|
||||
<header class="dashboard-hero">
|
||||
<div>
|
||||
<span class="dashboard-eyebrow">OPERATIONS OVERVIEW</span>
|
||||
<h2>数据看板</h2>
|
||||
<p>核心业务数据概览。</p>
|
||||
<p>从业务规模、请求峰值到模型成本,快速判断系统运行状态。</p>
|
||||
</div>
|
||||
<div class="dashboard-date-range" aria-label="看板日期范围">
|
||||
<label class="record-date-field dashboard-date-field">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
v-model="filters.start"
|
||||
type="date"
|
||||
aria-label="开始日期"
|
||||
@change="loadDashboard"
|
||||
/>
|
||||
</label>
|
||||
<label class="dashboard-date-field"><span>开始日期</span><input v-model="filters.start" type="date" aria-label="开始日期" @change="loadDashboard" /></label>
|
||||
<span class="record-date-sep">至</span>
|
||||
<label class="record-date-field dashboard-date-field">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
v-model="filters.end"
|
||||
type="date"
|
||||
aria-label="结束日期"
|
||||
@change="loadDashboard"
|
||||
/>
|
||||
</label>
|
||||
<label class="dashboard-date-field"><span>结束日期</span><input v-model="filters.end" type="date" aria-label="结束日期" @change="loadDashboard" /></label>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { FeedbackDetail, FeedbackItem } from "../types/api";
|
||||
|
||||
const props = defineProps<{ canDelete: boolean }>();
|
||||
const loading = ref(false);
|
||||
const items = ref<FeedbackItem[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const readStatus = ref("all");
|
||||
const detail = ref<FeedbackDetail | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.feedbackList({ readStatus: readStatus.value, page: page.value, pageSize: 20 });
|
||||
items.value = result.items;
|
||||
total.value = result.total;
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "反馈列表加载失败"); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function open(item: FeedbackItem) {
|
||||
detail.value = await api.feedbackDetail(item.id);
|
||||
item.isRead = true;
|
||||
}
|
||||
|
||||
async function remove(item: FeedbackItem) {
|
||||
await ElMessageBox.confirm("确认删除这条用户反馈?删除后不可恢复。", "删除反馈", { type: "warning" });
|
||||
await api.deleteFeedback(item.id);
|
||||
if (detail.value?.id === item.id) detail.value = null;
|
||||
ElMessage.success("反馈已删除");
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feedback-page">
|
||||
<header><div><h2>反馈管理</h2><p>查看用户针对 AI 回答提交的问题与当时对话。</p></div><el-segmented v-model="readStatus" :options="[{ label: '全部', value: 'all' }, { label: '未读', value: 'unread' }, { label: '已读', value: 'read' }]" @change="page=1; load()" /></header>
|
||||
<el-table v-loading="loading" :data="items" stripe>
|
||||
<el-table-column label="状态" width="88"><template #default="{ row }"><el-tag :type="row.isRead ? 'info' : 'danger'">{{ row.isRead ? '已读' : '未读' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="userName" label="用户" width="130" />
|
||||
<el-table-column prop="content" label="反馈内容" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="messageContent" label="反馈的 AI 回答" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="提交时间" width="180" />
|
||||
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="open(row)">查看</el-button><el-button v-if="canDelete" link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-if="total > 20" v-model:current-page="page" :page-size="20" :total="total" layout="prev, pager, next, total" @current-change="load" />
|
||||
<el-drawer :model-value="Boolean(detail)" title="反馈详情" size="620px" @close="detail = null">
|
||||
<template v-if="detail">
|
||||
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ detail.createdAt }}</small></section>
|
||||
<h3>当时对话记录</h3>
|
||||
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]">
|
||||
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag></div><p>{{ message.content }}</p>
|
||||
</article></div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feedback-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; margin-bottom:22px; }
|
||||
.feedback-page h2 { margin:0 0 6px; }.feedback-page header p { margin:0; color:#7b8494; }
|
||||
.feedback-summary { display:grid; gap:9px; padding:16px; border-radius:12px; background:#f7f9fc; }.feedback-summary span,.feedback-summary small { color:#87909f; }
|
||||
.feedback-history { display:grid; gap:12px; }.feedback-history article { padding:14px 16px; border:1px solid #e6eaf0; border-radius:12px; }.feedback-history article.user { margin-left:42px; background:#f5f8ff; }.feedback-history article.assistant { margin-right:42px; }.feedback-history article.target { border:2px solid #f0a020; background:#fffaf0; }.feedback-history article div { display:flex; align-items:center; justify-content:space-between; }.feedback-history p { white-space:pre-wrap; line-height:1.65; margin:8px 0 0; }
|
||||
</style>
|
||||
@@ -81,6 +81,11 @@ const groupBlueprints: Record<string, SettingGroupBlueprint[]> = {
|
||||
"chat_active_lease_seconds",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "语音输入",
|
||||
description: "控制用户端麦克风入口和单次录音上限。",
|
||||
keys: ["voice_input_enabled", "voice_max_duration_seconds"],
|
||||
},
|
||||
],
|
||||
外部服务: [
|
||||
{
|
||||
@@ -175,6 +180,13 @@ const statusItems = computed(() => [
|
||||
value: `${props.values.chat_max_active_requests || 0} 并发`,
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
label: "语音输入",
|
||||
value: props.values.voice_input_enabled
|
||||
? `已开启 · ${props.values.voice_max_duration_seconds || 60} 秒`
|
||||
: "已关闭",
|
||||
tone: props.values.voice_input_enabled ? "success" : "neutral",
|
||||
},
|
||||
]);
|
||||
|
||||
function updateSetting(key: string, value: unknown) {
|
||||
|
||||
@@ -215,6 +215,22 @@ export const systemSettingSections: SystemSettingSection[] = [
|
||||
max: 86400,
|
||||
description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。",
|
||||
},
|
||||
{
|
||||
key: "voice_input_enabled",
|
||||
label: "启用语音输入",
|
||||
type: "switch",
|
||||
defaultValue: false,
|
||||
description: "关闭后用户端不展示麦克风入口,后端也会拒绝语音转写请求。",
|
||||
},
|
||||
{
|
||||
key: "voice_max_duration_seconds",
|
||||
label: "单条语音最大时长(秒)",
|
||||
type: "number",
|
||||
defaultValue: 60,
|
||||
min: 5,
|
||||
max: 60,
|
||||
description: "达到设定时长后自动停止并转成文字,阿里云一句话识别上限为 60 秒。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElCheckbox,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
@@ -28,11 +29,13 @@ import {
|
||||
ElTabs,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
ElTree,
|
||||
vLoading,
|
||||
} from "element-plus";
|
||||
import "element-plus/theme-chalk/base.css";
|
||||
import "element-plus/theme-chalk/el-alert.css";
|
||||
import "element-plus/theme-chalk/el-button.css";
|
||||
import "element-plus/theme-chalk/el-card.css";
|
||||
import "element-plus/theme-chalk/el-checkbox.css";
|
||||
import "element-plus/theme-chalk/el-collapse.css";
|
||||
import "element-plus/theme-chalk/el-date-picker.css";
|
||||
@@ -60,6 +63,7 @@ import "element-plus/theme-chalk/el-table.css";
|
||||
import "element-plus/theme-chalk/el-tabs.css";
|
||||
import "element-plus/theme-chalk/el-tag.css";
|
||||
import "element-plus/theme-chalk/el-tooltip.css";
|
||||
import "element-plus/theme-chalk/el-tree.css";
|
||||
import { createApp } from "vue";
|
||||
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
||||
|
||||
@@ -72,6 +76,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
|
||||
[
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElCheckbox,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
@@ -99,6 +104,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
|
||||
ElTabs,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
ElTree,
|
||||
].forEach((component) => {
|
||||
app.use(component);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
AdminProfile,
|
||||
ManagedAdmin,
|
||||
PermissionNode,
|
||||
AdminUser,
|
||||
AdminUserDetail,
|
||||
AgentDebugResult,
|
||||
@@ -16,6 +18,8 @@ import type {
|
||||
ContentGenerationHistoryItem,
|
||||
ContentGenerationType,
|
||||
DashboardStats,
|
||||
PeakTrafficResult,
|
||||
TrafficGrain,
|
||||
EntitlementPlan,
|
||||
EntitlementBatchRenewResult,
|
||||
KnowledgeItem,
|
||||
@@ -129,6 +133,12 @@ export const api = {
|
||||
}),
|
||||
logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }),
|
||||
profile: () => request<AdminProfile>("/admin/profile"),
|
||||
permissionTree: () => request<PermissionNode[]>("/admin/permission-tree"),
|
||||
administrators: () => request<ManagedAdmin[]>("/admin/administrator/list"),
|
||||
createAdministrator: (payload: Record<string, unknown>) => request<ManagedAdmin>("/admin/administrator", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateAdministrator: (id: number, payload: Record<string, unknown>) => request<ManagedAdmin>(`/admin/administrator/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||
deleteAdministrator: (id: number) => request<null>(`/admin/administrator/${id}`, { method: "DELETE" }),
|
||||
changeAdminPassword: (payload: { currentPassword: string; newPassword: string }) => request<null>("/admin/password", { method: "POST", body: JSON.stringify(payload) }),
|
||||
dashboard: (start?: string, end?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (start) params.set("start", start);
|
||||
@@ -136,6 +146,8 @@ export const api = {
|
||||
const qs = params.toString();
|
||||
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
|
||||
},
|
||||
peakTraffic: (grain: TrafficGrain) =>
|
||||
request<PeakTrafficResult>(`/admin/dashboard/traffic${queryString({ grain })}`),
|
||||
users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
|
||||
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
|
||||
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
@@ -316,6 +328,9 @@ export const api = {
|
||||
deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }),
|
||||
clearFeishuCache: () =>
|
||||
request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }),
|
||||
feedbackList: (query: { readStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<import("../types/api").FeedbackItem>>(`/feedback/admin/list${queryString(query)}`),
|
||||
feedbackDetail: (id: number) => request<import("../types/api").FeedbackDetail>(`/feedback/admin/${id}`),
|
||||
deleteFeedback: (id: number) => request<null>(`/feedback/admin/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
export async function streamDebugAgent(
|
||||
|
||||
@@ -16,6 +16,28 @@ export interface AdminProfile {
|
||||
username: string;
|
||||
name: string;
|
||||
status: number;
|
||||
isSuperAdmin: boolean;
|
||||
mustChangePassword: boolean;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface ManagedAdmin {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
status: number;
|
||||
isSuperAdmin: boolean;
|
||||
mustChangePassword: boolean;
|
||||
permissions: string[];
|
||||
lastLoginAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PermissionNode {
|
||||
code: string;
|
||||
name: string;
|
||||
superOnly?: boolean;
|
||||
children?: PermissionNode[];
|
||||
}
|
||||
|
||||
export interface SsoClientItem {
|
||||
@@ -84,6 +106,25 @@ export interface DashboardStats {
|
||||
}>;
|
||||
}
|
||||
|
||||
export type TrafficGrain = "minute" | "hour" | "day" | "week";
|
||||
|
||||
export interface PeakTrafficRow {
|
||||
period: string;
|
||||
periodLabel: string;
|
||||
requestCount: number;
|
||||
errorCount: number;
|
||||
errorRate: number;
|
||||
avgResponseMs: number;
|
||||
}
|
||||
|
||||
export interface PeakTrafficResult {
|
||||
grain: TrafficGrain;
|
||||
peakPeriod?: string | null;
|
||||
peakRequests: number;
|
||||
totalRequests: number;
|
||||
rows: PeakTrafficRow[];
|
||||
}
|
||||
|
||||
export interface PromptDetail {
|
||||
id: number | null;
|
||||
promptContent: string;
|
||||
@@ -730,3 +771,20 @@ export interface ChatRecordQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface FeedbackItem {
|
||||
id: number;
|
||||
userId: number;
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
messageId: number;
|
||||
messageContent: string;
|
||||
content: string;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FeedbackDetail extends FeedbackItem {
|
||||
sessionTitle: string;
|
||||
messages: Array<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user