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

@@ -40,6 +40,7 @@ docker compose -f docker-compose.dev.yml up --build
默认访问地址: 默认访问地址:
- 用户端 H5`http://127.0.0.1:5173/` - 用户端 H5`http://127.0.0.1:5173/`
- 管理后台:`http://127.0.0.1:5174/`
- 后端 API`http://127.0.0.1:8100/api/health` - 后端 API`http://127.0.0.1:8100/api/health`
- MySQL`127.0.0.1:3307` - MySQL`127.0.0.1:3307`

View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.local
*.tsbuildinfo

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://127.0.0.1:8100/api

View File

@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local

View File

@@ -0,0 +1,12 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5174
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5174"]

View File

@@ -0,0 +1,33 @@
# AI 知识库系统 V2 管理后台
这是 V2 的管理后台,使用 Vue 3、Vite、TypeScript 和 Element Plus。
## 本地启动
```bash
npm install
npm run dev -- --port 5174
```
默认访问地址:
```text
http://127.0.0.1:5174/
```
默认后端地址:
```text
http://127.0.0.1:8100/api
```
## 默认管理员
开发阶段后端会在首次管理员登录时自动创建默认管理员:
```text
账号admin
密码admin123456
```
生产环境必须通过环境变量修改默认密码。

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 知识库后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "ai-kb-admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"element-plus": "^2.11.0",
"vite": "^7.1.0",
"vue": "^3.5.0"
},
"devDependencies": {
"typescript": "^5.9.0",
"vue-tsc": "^3.1.0"
}
}

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" />

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,10 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
server: {
host: "127.0.0.1",
port: 5174,
},
});

View File

@@ -22,3 +22,7 @@ FEISHU_RETRY_COUNT=2
DEFAULT_DAILY_CHAT_LIMIT=100 DEFAULT_DAILY_CHAT_LIMIT=100
DEFAULT_USER_NAME_PREFIX=用户 DEFAULT_USER_NAME_PREFIX=用户
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=admin123456
BOOTSTRAP_ADMIN_NAME=系统管理员

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead
from app.services.admin_service import AdminAuthService
router = APIRouter()
@router.post("/login")
def login(payload: AdminLoginRequest, db: Session = Depends(get_db)) -> dict:
result = AdminAuthService.login(db, payload.username, payload.password)
return api_success(AdminLoginResponse.model_validate(result).model_dump(mode="json"))
@router.get("/profile")
def profile(current_admin: Admin = Depends(get_current_admin)) -> dict:
return api_success(AdminRead.model_validate(current_admin).model_dump())

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.schemas.admin import DashboardStats
from app.services.admin_service import AdminDashboardService
router = APIRouter()
@router.get("/dashboard")
def dashboard(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
stats = AdminDashboardService.stats(db)
return api_success(DashboardStats.model_validate(stats).model_dump())

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.knowledge import Knowledge
from app.schemas.admin import KnowledgeSaveRequest
from app.services.admin_service import OperationLogService
router = APIRouter()
@router.get("/knowledge/list")
def list_knowledge(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
items = db.scalars(select(Knowledge).order_by(Knowledge.id.desc())).all()
return api_success([_knowledge_dict(item) for item in items])
@router.post("/knowledge")
def create_knowledge(
payload: KnowledgeSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
knowledge = Knowledge(
name=payload.name,
feishu_space_id=payload.feishuSpaceId,
feishu_node_id=payload.feishuNodeId,
status=payload.status,
remark=payload.remark,
)
db.add(knowledge)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="create", target_id=knowledge.id)
db.commit()
db.refresh(knowledge)
return api_success(_knowledge_dict(knowledge))
@router.put("/knowledge/{knowledge_id}")
def update_knowledge(
knowledge_id: int,
payload: KnowledgeSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
knowledge = _get_knowledge(db, knowledge_id)
knowledge.name = payload.name
knowledge.feishu_space_id = payload.feishuSpaceId
knowledge.feishu_node_id = payload.feishuNodeId
knowledge.status = payload.status
knowledge.remark = payload.remark
db.add(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
db.commit()
db.refresh(knowledge)
return api_success(_knowledge_dict(knowledge))
@router.delete("/knowledge/{knowledge_id}")
def delete_knowledge(
knowledge_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
knowledge = _get_knowledge(db, knowledge_id)
db.delete(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id)
db.commit()
return api_success()
def _get_knowledge(db: Session, knowledge_id: int) -> Knowledge:
knowledge = db.get(Knowledge, knowledge_id)
if knowledge is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在")
return knowledge
def _knowledge_dict(knowledge: Knowledge) -> dict:
return {
"id": knowledge.id,
"name": knowledge.name,
"feishuSpaceId": knowledge.feishu_space_id,
"feishuNodeId": knowledge.feishu_node_id,
"status": knowledge.status,
"remark": knowledge.remark,
"createdAt": knowledge.created_at,
"updatedAt": knowledge.updated_at,
}

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.logs import AiRequestLog, OperationLog
router = APIRouter()
@router.get("/chat/list")
def chat_list(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
sessions = db.scalars(
select(ChatSession)
.where(ChatSession.is_deleted == 0)
.order_by(ChatSession.updated_at.desc())
.limit(200)
).all()
return api_success(
[
{
"id": item.id,
"userId": item.user_id,
"title": item.title,
"messageCount": item.message_count,
"lastMessageAt": item.last_message_at,
"updatedAt": item.updated_at,
}
for item in sessions
]
)
@router.get("/chat/{session_id}")
def chat_detail(
session_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
messages = db.scalars(
select(ChatMessage)
.where(ChatMessage.session_id == session_id)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
).all()
return api_success(
[
{
"id": item.id,
"role": item.role,
"content": item.content,
"messageStatus": item.message_status,
"tokenInput": item.token_input,
"tokenOutput": item.token_output,
"responseTimeMs": item.response_time_ms,
"createdAt": item.created_at,
}
for item in messages
]
)
@router.get("/log/list")
def operation_logs(
module: str = Query(default=""),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(OperationLog).order_by(OperationLog.created_at.desc()).limit(200)
if module:
query = query.where(OperationLog.module == module)
logs = db.scalars(query).all()
return api_success(
[
{
"id": item.id,
"adminId": item.admin_id,
"module": item.module,
"action": item.action,
"targetId": item.target_id,
"result": item.result,
"createdAt": item.created_at,
}
for item in logs
]
)
@router.get("/ai-log/list")
def ai_logs(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
logs = db.scalars(select(AiRequestLog).order_by(AiRequestLog.created_at.desc()).limit(200)).all()
return api_success(
[
{
"id": item.id,
"sessionId": item.session_id,
"messageId": item.message_id,
"userId": item.user_id,
"modelName": item.model_name,
"knowledgeIds": item.knowledge_ids,
"retrieveCount": item.retrieve_count,
"totalToken": item.total_token,
"costMs": item.cost_ms,
"status": item.status,
"errorMessage": item.error_message,
"createdAt": item.created_at,
}
for item in logs
]
)

View File

@@ -0,0 +1,159 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.schemas.admin import EnableModelRequest, ModelSaveRequest, PromptSaveRequest, SystemConfigSaveRequest
from app.services.admin_service import OperationLogService
router = APIRouter()
DEFAULT_PROMPT = "你是企业飞书知识库 AI 助手。你只能基于提供的知识片段回答,不能编造。"
@router.get("/prompt")
def get_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
prompt = db.scalar(select(Prompt).order_by(Prompt.updated_at.desc(), Prompt.id.desc()).limit(1))
return api_success({"promptContent": prompt.prompt_content if prompt else DEFAULT_PROMPT})
@router.put("/prompt")
def save_prompt(
payload: PromptSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
prompt = Prompt(prompt_content=payload.promptContent, updated_by=current_admin.id)
db.add(prompt)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="save", target_id=prompt.id)
db.commit()
return api_success({"promptContent": prompt.prompt_content})
@router.post("/prompt/reset")
def reset_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
prompt = Prompt(prompt_content=DEFAULT_PROMPT, updated_by=current_admin.id)
db.add(prompt)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="reset", target_id=prompt.id)
db.commit()
return api_success({"promptContent": prompt.prompt_content})
@router.get("/model/list")
def list_models(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
models = db.scalars(select(ModelConfig).order_by(ModelConfig.id.desc())).all()
return api_success([_model_dict(model) for model in models])
@router.post("/model")
def create_model(
payload: ModelSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = ModelConfig(
provider=payload.provider,
model_name=payload.modelName,
api_url=payload.apiUrl,
api_key=payload.apiKey,
temperature=payload.temperature,
max_token=payload.maxToken,
timeout_second=payload.timeoutSecond,
enabled=0,
)
db.add(model)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="create", target_id=model.id)
db.commit()
db.refresh(model)
return api_success(_model_dict(model))
@router.post("/model/enable")
def enable_model(
payload: EnableModelRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
target = db.get(ModelConfig, payload.modelId)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
for model in db.scalars(select(ModelConfig)).all():
model.enabled = 1 if model.id == target.id else 0
db.add(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="enable", target_id=target.id)
db.commit()
return api_success()
@router.delete("/model/{model_id}")
def delete_model(
model_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = db.get(ModelConfig, model_id)
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
db.delete(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="delete", target_id=model.id)
db.commit()
return api_success()
@router.get("/config")
def list_config(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
configs = db.scalars(select(SystemConfig).order_by(SystemConfig.config_key.asc())).all()
return api_success([_config_dict(config) for config in configs])
@router.put("/config")
def save_config(
payload: SystemConfigSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == payload.configKey))
if config is None:
config = SystemConfig(config_key=payload.configKey, config_value=payload.configValue)
config.config_value = payload.configValue
config.description = payload.description
config.updated_by = current_admin.id
db.add(config)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="config", action="save", target_id=config.id)
db.commit()
db.refresh(config)
return api_success(_config_dict(config))
def _model_dict(model: ModelConfig) -> dict:
return {
"id": model.id,
"provider": model.provider,
"modelName": model.model_name,
"apiUrl": model.api_url,
"apiKeyMasked": "******" if model.api_key else "",
"temperature": float(model.temperature) if model.temperature is not None else None,
"maxToken": model.max_token,
"timeoutSecond": model.timeout_second,
"enabled": model.enabled,
}
def _config_dict(config: SystemConfig) -> dict:
return {
"id": config.id,
"configKey": config.config_key,
"configValue": config.config_value,
"description": config.description,
"updatedAt": config.updated_at,
}

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.user import User
from app.schemas.admin import AdminUserUpdateRequest
from app.services.admin_service import OperationLogService
router = APIRouter()
@router.get("/user/list")
def list_users(
keyword: str = Query(default=""),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(User).where(User.is_deleted == 0).order_by(User.id.desc())
if keyword:
like = f"%{keyword}%"
query = query.where((User.phone.like(like)) | (User.name.like(like)))
users = db.scalars(query.limit(200)).all()
return api_success([_user_dict(user) for user in users])
@router.get("/user/{user_id}")
def user_detail(
user_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
return api_success(_user_dict(user))
@router.put("/user/{user_id}")
def update_user(
user_id: int,
payload: AdminUserUpdateRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
if payload.status is not None:
user.status = payload.status
if payload.dailyChatLimit is not None:
user.daily_chat_limit = payload.dailyChatLimit
if payload.expiredAt is not None:
user.expired_at = payload.expiredAt.replace(tzinfo=None)
db.add(user)
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="update", target_id=user.id)
db.commit()
db.refresh(user)
return api_success(_user_dict(user))
@router.delete("/user/{user_id}")
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
user.is_deleted = 1
db.add(user)
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="delete", target_id=user.id)
db.commit()
return api_success()
def _get_user(db: Session, user_id: int) -> User:
user = db.get(User, user_id)
if user is None or user.is_deleted:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return user
def _user_dict(user: User) -> dict:
return {
"id": user.id,
"phone": user.phone,
"name": user.name,
"nickname": user.nickname,
"status": user.status,
"dailyChatLimit": user.daily_chat_limit,
"dailyChatUsed": user.daily_chat_used,
"expiredAt": user.expired_at,
"lastLoginAt": user.last_login_at,
"createdAt": user.created_at,
}

View File

@@ -2,10 +2,27 @@ from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter
from app.api import auth, chat, health, user from app.api import (
admin_auth,
admin_dashboard,
admin_knowledge,
admin_records,
admin_settings,
admin_users,
auth,
chat,
health,
user,
)
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(health.router, tags=["health"]) api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(user.router, prefix="/user", tags=["user"]) api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])

View File

@@ -41,6 +41,9 @@ class Settings(BaseSettings):
default_daily_chat_limit: int = 100 default_daily_chat_limit: int = 100
default_user_name_prefix: str = "用户" default_user_name_prefix: str = "用户"
bootstrap_admin_username: str = "admin"
bootstrap_admin_password: str = "admin123456"
bootstrap_admin_name: str = "系统管理员"
@lru_cache @lru_cache

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.security import decode_access_token from app.core.security import decode_access_token
from app.models.admin import Admin
from app.models.user import User from app.models.user import User
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
@@ -40,3 +41,19 @@ def get_current_user(
if user.expired_at and user.expired_at < now: if user.expired_at and user.expired_at < now:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
return user return user
def get_current_admin(
payload: dict = Depends(get_current_token_payload),
db: Session = Depends(get_db),
) -> Admin:
if payload.get("type") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是管理员登录态")
admin_id = payload.get("sub")
admin = db.get(Admin, int(admin_id)) if admin_id else None
if admin is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员不存在")
if admin.status != 1:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
return admin

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import hashlib
import hmac
import os
from uuid import uuid4 from uuid import uuid4
import jwt import jwt
@@ -31,3 +34,20 @@ def decode_access_token(token: str) -> dict:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") from exc raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") from exc
except jwt.InvalidTokenError as exc: except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效登录凭证") from exc raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效登录凭证") from exc
def hash_password(password: str) -> str:
salt = os.urandom(16).hex()
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
return f"pbkdf2_sha256${salt}${digest.hex()}"
def verify_password(password: str, password_hash: str) -> bool:
try:
algorithm, salt, expected = password_hash.split("$", 2)
except ValueError:
return False
if algorithm != "pbkdf2_sha256":
return False
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
return hmac.compare_digest(digest.hex(), expected)

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.common import ORMModel
class AdminLoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=50)
password: str = Field(min_length=1, max_length=100)
class AdminLoginResponse(BaseModel):
token: str
expiredAt: datetime
admin: dict
class AdminRead(ORMModel):
id: int
username: str
name: str
status: int
class DashboardStats(BaseModel):
userCount: int
sessionCount: int
messageCount: int
aiRequestCount: int
knowledgeCount: int
class AdminUserUpdateRequest(BaseModel):
status: int | None = Field(default=None, ge=0, le=1)
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
expiredAt: datetime | None = None
class KnowledgeSaveRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
feishuSpaceId: str = Field(min_length=1, max_length=100)
feishuNodeId: str = Field(min_length=1, max_length=100)
status: int = Field(default=1, ge=0, le=1)
remark: str | None = Field(default=None, max_length=255)
class PromptSaveRequest(BaseModel):
promptContent: str = Field(min_length=1)
class ModelSaveRequest(BaseModel):
provider: str = Field(min_length=1, max_length=50)
modelName: str = Field(min_length=1, max_length=100)
apiUrl: str = Field(min_length=1, max_length=255)
apiKey: str = Field(min_length=1)
temperature: float | None = Field(default=0.2, ge=0, le=2)
maxToken: int | None = Field(default=1024, ge=1, le=100000)
timeoutSecond: int = Field(default=30, ge=1, le=300)
class EnableModelRequest(BaseModel):
modelId: int
class SystemConfigSaveRequest(BaseModel):
configKey: str = Field(min_length=1, max_length=100)
configValue: str
description: str | None = Field(default=None, max_length=255)

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import create_access_token, hash_password, verify_password
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.knowledge import Knowledge
from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User
class AdminAuthService:
@classmethod
def login(cls, db: Session, username: str, password: str) -> dict:
cls.ensure_bootstrap_admin(db)
admin = db.scalar(select(Admin).where(Admin.username == username))
if admin is None or not verify_password(password, admin.password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员账号或密码错误")
if admin.status != 1:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
admin.last_login_at = datetime.now(UTC).replace(tzinfo=None)
db.add(admin)
db.commit()
db.refresh(admin)
token, expired_at = create_access_token(str(admin.id), "admin")
return {"token": token, "expiredAt": expired_at, "admin": admin}
@staticmethod
def ensure_bootstrap_admin(db: Session) -> None:
has_admin = db.scalar(select(func.count(Admin.id))) or 0
if has_admin:
return
settings = get_settings()
admin = Admin(
username=settings.bootstrap_admin_username,
password=hash_password(settings.bootstrap_admin_password),
name=settings.bootstrap_admin_name,
status=1,
)
db.add(admin)
db.commit()
class AdminDashboardService:
@staticmethod
def stats(db: Session) -> dict:
return {
"userCount": db.scalar(select(func.count(User.id)).where(User.is_deleted == 0)) or 0,
"sessionCount": db.scalar(select(func.count(ChatSession.id)).where(ChatSession.is_deleted == 0)) or 0,
"messageCount": db.scalar(select(func.count(ChatMessage.id))) or 0,
"aiRequestCount": db.scalar(select(func.count(AiRequestLog.id))) or 0,
"knowledgeCount": db.scalar(select(func.count(Knowledge.id))) or 0,
}
class OperationLogService:
@staticmethod
def write(
db: Session,
*,
admin_id: int | None,
module: str,
action: str,
target_id: int | None = None,
result: str = "SUCCESS",
) -> None:
db.add(
OperationLog(
admin_id=admin_id,
module=module,
action=action,
target_id=target_id,
result=result,
)
)

View File

@@ -33,6 +33,8 @@
| D-010 | 用户端停止生成先使用浏览器 `AbortController` 中断 SSE 读取,后端真实中断等接入真实模型时实现。 | 默认采用 | 当前后端仍是 mock 模型,前端先保证用户操作反馈真实有效。 | | D-010 | 用户端停止生成先使用浏览器 `AbortController` 中断 SSE 读取,后端真实中断等接入真实模型时实现。 | 默认采用 | 当前后端仍是 mock 模型,前端先保证用户操作反馈真实有效。 |
| D-011 | 阶段四外部服务默认保持 mock 开启,通过配置关闭 mock 后再走真实 provider。 | 默认采用 | 当前缺少真实飞书检索地址和模型凭证,默认 mock 可以保证开发、演示和测试连续。 | | D-011 | 阶段四外部服务默认保持 mock 开启,通过配置关闭 mock 后再走真实 provider。 | 默认采用 | 当前缺少真实飞书检索地址和模型凭证,默认 mock 可以保证开发、演示和测试连续。 |
| D-012 | 大模型真实调用先按 OpenAI 兼容 `chat/completions` 非流式响应接入。 | 默认采用 | 先打通配置化真实模型调用和失败日志,后续再升级为模型原生流式透传。 | | D-012 | 大模型真实调用先按 OpenAI 兼容 `chat/completions` 非流式响应接入。 | 默认采用 | 先打通配置化真实模型调用和失败日志,后续再升级为模型原生流式透传。 |
| D-013 | 管理后台开发阶段支持首次登录自动创建默认管理员。 | 默认采用 | 降低本地初始化成本;生产环境必须修改默认账号密码。 |
| D-014 | 管理后台 Element Plus 先整体引入,后续再做按需导入优化。 | 默认采用 | 阶段五优先保证后台功能闭环和交付速度,体积优化后续处理。 |
## 待确认决策 ## 待确认决策

View File

@@ -0,0 +1,84 @@
# 阶段五记录:管理后台
日期2026-07-06
## 本次目标
补齐一期后台管理能力的第一版,让系统具备基础运营、配置和审计入口。
## 已完成后端能力
- 管理员登录:
- 新增 `/api/admin/login`
- 新增 `/api/admin/profile`
- 开发阶段支持首次登录自动创建默认管理员。
- Dashboard
- 用户数、会话数、消息数、AI 请求数、知识库数。
- 用户管理:
- 用户列表
- 用户详情
- 修改状态、每日额度、过期时间
- 逻辑删除
- 知识库管理:
- 列表
- 新增
- 编辑
- 删除
- Prompt 管理:
- 查看当前 Prompt
- 保存 Prompt
- 恢复默认
- 模型管理:
- 列表
- 新增模型
- 启用模型
- 删除模型
- 系统配置:
- 列表
- 新增或更新配置项
- 记录审计:
- 聊天会话列表
- 聊天详情
- AI 请求日志
- 操作日志
## 已完成前端能力
- 新增 `apps/admin-web`
- 技术栈Vue 3 + Vite + TypeScript + Element Plus。
- 页面包括:
- 登录页
- Dashboard
- 用户管理
- 知识库管理
- Prompt 管理
- 模型管理
- 系统配置
- 记录审计
- 已接入阶段五后端管理接口。
## 默认管理员
```text
账号admin
密码admin123456
```
该账号只用于开发阶段,生产环境必须通过环境变量修改。
## 当前边界
- 管理后台是第一版可用骨架,表单校验、批量操作、导出文件和更细权限还需要后续增强。
- 当前管理员权限尚未按角色矩阵细分,默认登录管理员拥有全部后台能力。
- 模型 API Key 当前新增时写入数据库,页面列表只展示脱敏值;生产环境还需要进一步做加密或密钥托管。
- Element Plus 当前整体引入,构建有体积提示;阶段五先保证功能闭环,后续可做按需导入优化。
## 阶段五判断
阶段五已经具备管理后台主链路:
1. 管理员可以登录后台。
2. 后台可以查看核心统计。
3. 后台可以维护用户、知识库、Prompt、模型和系统配置。
4. 后台可以查看聊天、AI 请求和操作日志。
5. 关键管理动作会写操作日志。

View File

@@ -31,3 +31,4 @@
| `2026-07-06-phase2-rag-skeleton.md` | 阶段二 RAG 问答链路骨架记录。 | | `2026-07-06-phase2-rag-skeleton.md` | 阶段二 RAG 问答链路骨架记录。 |
| `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 | | `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 |
| `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 | | `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 |
| `2026-07-06-phase5-admin-console.md` | 阶段五管理后台记录。 |

View File

@@ -53,5 +53,16 @@ services:
depends_on: depends_on:
- backend - backend
admin-web:
build:
context: ./apps/admin-web
container_name: ai_kb_v2_admin_web
environment:
VITE_API_BASE_URL: http://127.0.0.1:8100/api
ports:
- "5174:5174"
depends_on:
- backend
volumes: volumes:
ai_kb_v2_mysql_data: ai_kb_v2_mysql_data: