Add V2 admin console

This commit is contained in:
2026-07-06 18:13:08 +08:00
parent 301661e7b4
commit 62eeb3186c
34 changed files with 3132 additions and 1 deletions

View File

@@ -0,0 +1,300 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api, clearToken, getToken, saveToken } from "./services/api";
import type { AdminProfile, AdminUser, DashboardStats, KnowledgeItem, ModelItem } from "./types/api";
const admin = ref<AdminProfile | null>(null);
const activeMenu = ref("dashboard");
const loading = ref(false);
const loginForm = reactive({ username: "admin", password: "admin123456" });
const stats = ref<DashboardStats | null>(null);
const users = ref<AdminUser[]>([]);
const userKeyword = ref("");
const knowledge = ref<KnowledgeItem[]>([]);
const models = ref<ModelItem[]>([]);
const configs = ref<Record<string, unknown>[]>([]);
const chats = ref<Record<string, unknown>[]>([]);
const aiLogs = ref<Record<string, unknown>[]>([]);
const operationLogs = ref<Record<string, unknown>[]>([]);
const promptContent = ref("");
const knowledgeForm = reactive({
name: "",
feishuSpaceId: "",
feishuNodeId: "",
status: 1,
remark: "",
});
const modelForm = reactive({
provider: "openai-compatible",
modelName: "",
apiUrl: "",
apiKey: "",
temperature: 0.2,
maxToken: 1024,
timeoutSecond: 30,
});
const configForm = reactive({
configKey: "daily_chat_limit",
configValue: "100",
description: "默认每日聊天次数",
});
onMounted(async () => {
if (!getToken()) return;
try {
admin.value = await api.profile();
await loadCurrentMenu();
} catch {
clearToken();
}
});
async function login() {
loading.value = true;
try {
const result = await api.login(loginForm.username, loginForm.password);
saveToken(result.token);
admin.value = result.admin;
await loadCurrentMenu();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "登录失败");
} finally {
loading.value = false;
}
}
function logout() {
clearToken();
admin.value = null;
}
async function switchMenu(menu: string) {
activeMenu.value = menu;
await loadCurrentMenu();
}
async function loadCurrentMenu() {
loading.value = true;
try {
if (activeMenu.value === "dashboard") stats.value = await api.dashboard();
if (activeMenu.value === "users") users.value = await api.users(userKeyword.value);
if (activeMenu.value === "knowledge") knowledge.value = await api.knowledge();
if (activeMenu.value === "prompt") promptContent.value = (await api.prompt()).promptContent;
if (activeMenu.value === "models") models.value = await api.models();
if (activeMenu.value === "configs") configs.value = await api.configs();
if (activeMenu.value === "records") {
chats.value = await api.chats();
aiLogs.value = await api.aiLogs();
operationLogs.value = await api.operationLogs();
}
} finally {
loading.value = false;
}
}
async function saveUser(row: AdminUser) {
await api.updateUser(row.id, { status: row.status, dailyChatLimit: row.dailyChatLimit });
ElMessage.success("用户已更新");
await loadCurrentMenu();
}
async function saveKnowledge() {
await api.createKnowledge({ ...knowledgeForm });
ElMessage.success("知识库已新增");
Object.assign(knowledgeForm, { name: "", feishuSpaceId: "", feishuNodeId: "", status: 1, remark: "" });
await loadCurrentMenu();
}
async function savePrompt() {
const result = await api.savePrompt(promptContent.value);
promptContent.value = result.promptContent;
ElMessage.success("Prompt 已保存");
}
async function resetPrompt() {
const result = await api.resetPrompt();
promptContent.value = result.promptContent;
ElMessage.success("Prompt 已恢复默认");
}
async function saveModel() {
await api.createModel({ ...modelForm });
ElMessage.success("模型已新增");
Object.assign(modelForm, {
provider: "openai-compatible",
modelName: "",
apiUrl: "",
apiKey: "",
temperature: 0.2,
maxToken: 1024,
timeoutSecond: 30,
});
await loadCurrentMenu();
}
async function enableModel(id: number) {
await api.enableModel(id);
ElMessage.success("模型已启用");
await loadCurrentMenu();
}
async function saveConfig() {
await api.saveConfig({ ...configForm });
ElMessage.success("配置已保存");
await loadCurrentMenu();
}
</script>
<template>
<main v-if="!admin" class="login-page">
<section class="login-box">
<div class="brand">AI</div>
<h1>AI 知识库后台</h1>
<p>用于维护用户知识库Prompt模型和审计记录</p>
<el-form label-position="top" @submit.prevent="login">
<el-form-item label="管理员账号">
<el-input v-model="loginForm.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="loginForm.password" type="password" show-password />
</el-form-item>
<el-button type="primary" :loading="loading" class="full-btn" @click="login">登录</el-button>
</el-form>
</section>
</main>
<main v-else class="admin-shell">
<aside class="sidebar">
<div class="sidebar-title">AI 知识库</div>
<button :class="{ active: activeMenu === 'dashboard' }" @click="switchMenu('dashboard')">Dashboard</button>
<button :class="{ active: activeMenu === 'users' }" @click="switchMenu('users')">用户管理</button>
<button :class="{ active: activeMenu === 'knowledge' }" @click="switchMenu('knowledge')">知识库管理</button>
<button :class="{ active: activeMenu === 'prompt' }" @click="switchMenu('prompt')">Prompt 管理</button>
<button :class="{ active: activeMenu === 'models' }" @click="switchMenu('models')">模型管理</button>
<button :class="{ active: activeMenu === 'configs' }" @click="switchMenu('configs')">系统配置</button>
<button :class="{ active: activeMenu === 'records' }" @click="switchMenu('records')">记录审计</button>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<strong>{{ admin.name }}</strong>
<span>管理员</span>
</div>
<el-button @click="logout">退出</el-button>
</header>
<section v-loading="loading" class="content">
<template v-if="activeMenu === 'dashboard'">
<div class="page-head">
<h2>Dashboard</h2>
<p>核心业务数据概览</p>
</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>
</template>
<template v-if="activeMenu === 'users'">
<div class="page-head inline">
<div><h2>用户管理</h2><p>查询用户启用禁用和调整额度</p></div>
<el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @change="loadCurrentMenu" />
</div>
<el-table :data="users" stripe>
<el-table-column prop="phone" label="手机号" width="150" />
<el-table-column prop="name" label="姓名" width="140" />
<el-table-column label="状态" width="130">
<template #default="{ row }"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" /></template>
</el-table-column>
<el-table-column label="每日额度" width="150">
<template #default="{ row }"><el-input-number v-model="row.dailyChatLimit" :min="0" size="small" /></template>
</el-table-column>
<el-table-column prop="dailyChatUsed" label="已用" width="90" />
<el-table-column label="操作" width="110">
<template #default="{ row }"><el-button size="small" @click="saveUser(row)">保存</el-button></template>
</el-table-column>
</el-table>
</template>
<template v-if="activeMenu === 'knowledge'">
<div class="page-head"><h2>知识库管理</h2><p>维护飞书知识库 SpaceID NodeID</p></div>
<el-form class="inline-form" :model="knowledgeForm">
<el-input v-model="knowledgeForm.name" placeholder="知识库名称" />
<el-input v-model="knowledgeForm.feishuSpaceId" placeholder="SpaceID" />
<el-input v-model="knowledgeForm.feishuNodeId" placeholder="NodeID" />
<el-button type="primary" @click="saveKnowledge">新增</el-button>
</el-form>
<el-table :data="knowledge" stripe>
<el-table-column prop="name" label="名称" />
<el-table-column prop="feishuSpaceId" label="SpaceID" />
<el-table-column prop="feishuNodeId" label="NodeID" />
<el-table-column prop="status" label="状态" width="90" />
</el-table>
</template>
<template v-if="activeMenu === 'prompt'">
<div class="page-head inline">
<div><h2>Prompt 管理</h2><p>保存后会影响后续问答 Prompt 组装</p></div>
<el-button @click="resetPrompt">恢复默认</el-button>
</div>
<el-input v-model="promptContent" type="textarea" :rows="16" />
<div class="actions"><el-button type="primary" @click="savePrompt">保存 Prompt</el-button></div>
</template>
<template v-if="activeMenu === 'models'">
<div class="page-head"><h2>模型管理</h2><p>同一时间只启用一个模型</p></div>
<el-form class="model-form" label-position="top" :model="modelForm">
<el-input v-model="modelForm.provider" placeholder="Provider" />
<el-input v-model="modelForm.modelName" placeholder="模型名" />
<el-input v-model="modelForm.apiUrl" placeholder="API URL" />
<el-input v-model="modelForm.apiKey" placeholder="API Key" show-password />
<el-button type="primary" @click="saveModel">新增模型</el-button>
</el-form>
<el-table :data="models" stripe>
<el-table-column prop="provider" label="Provider" />
<el-table-column prop="modelName" label="模型" />
<el-table-column prop="apiUrl" label="API URL" />
<el-table-column prop="enabled" label="启用" width="90" />
<el-table-column label="操作" width="110">
<template #default="{ row }"><el-button size="small" @click="enableModel(row.id)">启用</el-button></template>
</el-table-column>
</el-table>
</template>
<template v-if="activeMenu === 'configs'">
<div class="page-head"><h2>系统配置</h2><p>维护运行时配置项</p></div>
<el-form class="inline-form" :model="configForm">
<el-input v-model="configForm.configKey" placeholder="配置 Key" />
<el-input v-model="configForm.configValue" placeholder="配置值" />
<el-input v-model="configForm.description" placeholder="说明" />
<el-button type="primary" @click="saveConfig">保存</el-button>
</el-form>
<el-table :data="configs" stripe>
<el-table-column prop="configKey" label="Key" />
<el-table-column prop="configValue" label="Value" />
<el-table-column prop="description" label="说明" />
</el-table>
</template>
<template v-if="activeMenu === 'records'">
<div class="page-head"><h2>记录审计</h2><p>聊天AI 请求和后台操作记录</p></div>
<el-tabs>
<el-tab-pane label="聊天会话"><el-table :data="chats" stripe><el-table-column prop="title" label="标题" /><el-table-column prop="messageCount" label="消息数" width="100" /><el-table-column prop="updatedAt" label="更新时间" /></el-table></el-tab-pane>
<el-tab-pane label="AI 请求"><el-table :data="aiLogs" stripe><el-table-column prop="modelName" label="模型" /><el-table-column prop="retrieveCount" label="命中" width="90" /><el-table-column prop="status" label="状态" width="100" /><el-table-column prop="errorMessage" label="错误" /></el-table></el-tab-pane>
<el-tab-pane label="操作日志"><el-table :data="operationLogs" stripe><el-table-column prop="module" label="模块" /><el-table-column prop="action" label="动作" /><el-table-column prop="result" label="结果" /><el-table-column prop="createdAt" label="时间" /></el-table></el-tab-pane>
</el-tabs>
</template>
</section>
</section>
</main>
</template>

View File

@@ -0,0 +1,8 @@
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";
createApp(App).use(ElementPlus).mount("#app");

View File

@@ -0,0 +1,62 @@
import type { AdminProfile, ApiResponse, DashboardStats, KnowledgeItem, ModelItem, AdminUser } from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api";
const TOKEN_KEY = "ai-kb-admin-token";
export function getToken() {
return window.localStorage.getItem(TOKEN_KEY);
}
export function saveToken(token: string) {
window.localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
window.localStorage.removeItem(TOKEN_KEY);
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json");
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
const body = (await response.json()) as ApiResponse<T>;
if (!response.ok || body.code !== 0) {
throw new Error(body.message || `${response.status} ${response.statusText}`);
}
return body.data;
}
export const api = {
login: (username: string, password: string) =>
request<{ token: string; admin: AdminProfile }>("/admin/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
profile: () => request<AdminProfile>("/admin/profile"),
dashboard: () => request<DashboardStats>("/admin/dashboard"),
users: (keyword = "") => request<AdminUser[]>(`/admin/user/list?keyword=${encodeURIComponent(keyword)}`),
updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
knowledge: () => request<KnowledgeItem[]>("/admin/knowledge/list"),
createKnowledge: (payload: Record<string, unknown>) =>
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
prompt: () => request<{ promptContent: string }>("/admin/prompt"),
savePrompt: (promptContent: string) =>
request<{ promptContent: string }>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),
resetPrompt: () => request<{ promptContent: string }>("/admin/prompt/reset", { method: "POST", body: "{}" }),
models: () => request<ModelItem[]>("/admin/model/list"),
createModel: (payload: Record<string, unknown>) =>
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),
enableModel: (modelId: number) =>
request<null>("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId }) }),
configs: () => request<Record<string, unknown>[]>("/admin/config"),
saveConfig: (payload: Record<string, unknown>) =>
request<Record<string, unknown>>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
chats: () => request<Record<string, unknown>[]>("/admin/chat/list"),
aiLogs: () => request<Record<string, unknown>[]>("/admin/ai-log/list"),
operationLogs: () => request<Record<string, unknown>[]>("/admin/log/list"),
};

View File

@@ -0,0 +1,190 @@
:root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #1f2d2a;
background: #f2f5f4;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 1180px;
}
button,
input,
textarea {
font: inherit;
}
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
background: #eef4f2;
}
.login-box {
width: 380px;
padding: 32px;
border: 1px solid #dce6e2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 44px rgba(35, 54, 49, 0.08);
}
.brand {
width: 44px;
height: 44px;
display: grid;
place-items: center;
margin-bottom: 18px;
border-radius: 8px;
background: #0f8b6f;
color: #ffffff;
font-weight: 800;
}
.login-box h1,
.page-head h2 {
margin: 0;
letter-spacing: 0;
}
.login-box p,
.page-head p {
margin: 8px 0 0;
color: #667a73;
}
.full-btn {
width: 100%;
}
.admin-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
}
.sidebar {
padding: 20px 14px;
border-right: 1px solid #dfe8e5;
background: #ffffff;
}
.sidebar-title {
padding: 0 10px 18px;
color: #0f735d;
font-weight: 800;
}
.sidebar button {
width: 100%;
height: 40px;
margin-bottom: 4px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: #40524b;
text-align: left;
cursor: pointer;
}
.sidebar button.active {
background: #e9f5f1;
color: #0f735d;
font-weight: 700;
}
.workspace {
min-width: 0;
display: grid;
grid-template-rows: 58px minmax(0, 1fr);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid #dfe8e5;
background: #ffffff;
}
.topbar div {
display: grid;
gap: 2px;
}
.topbar span {
color: #70837c;
font-size: 12px;
}
.content {
min-width: 0;
padding: 24px;
overflow: auto;
}
.page-head {
margin-bottom: 18px;
}
.page-head.inline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-head.inline .el-input {
width: 260px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.stat {
padding: 18px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #ffffff;
}
.stat span {
display: block;
color: #667a73;
font-size: 13px;
}
.stat strong {
display: block;
margin-top: 10px;
font-size: 28px;
}
.inline-form,
.model-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.model-form {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.actions {
margin-top: 12px;
}

View File

@@ -0,0 +1,52 @@
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
export interface AdminProfile {
id: number;
username: string;
name: string;
status: number;
}
export interface DashboardStats {
userCount: number;
sessionCount: number;
messageCount: number;
aiRequestCount: number;
knowledgeCount: number;
}
export interface AdminUser {
id: number;
phone: string;
name: string;
status: number;
dailyChatLimit: number;
dailyChatUsed: number;
expiredAt?: string | null;
lastLoginAt?: string | null;
}
export interface KnowledgeItem {
id: number;
name: string;
feishuSpaceId: string;
feishuNodeId: string;
status: number;
remark?: string | null;
}
export interface ModelItem {
id: number;
provider: string;
modelName: string;
apiUrl: string;
apiKeyMasked: string;
temperature?: number | null;
maxToken?: number | null;
timeoutSecond: number;
enabled: number;
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />