Add V2 admin console
This commit is contained in:
@@ -40,6 +40,7 @@ docker compose -f docker-compose.dev.yml up --build
|
||||
默认访问地址:
|
||||
|
||||
- 用户端 H5:`http://127.0.0.1:5173/`
|
||||
- 管理后台:`http://127.0.0.1:5174/`
|
||||
- 后端 API:`http://127.0.0.1:8100/api/health`
|
||||
- MySQL:`127.0.0.1:3307`
|
||||
|
||||
|
||||
5
ai_knowledge_base_v2/apps/admin-web/.dockerignore
Normal file
5
ai_knowledge_base_v2/apps/admin-web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
1
ai_knowledge_base_v2/apps/admin-web/.env.example
Normal file
1
ai_knowledge_base_v2/apps/admin-web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8100/api
|
||||
5
ai_knowledge_base_v2/apps/admin-web/.gitignore
vendored
Normal file
5
ai_knowledge_base_v2/apps/admin-web/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile
Normal file
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile
Normal 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"]
|
||||
33
ai_knowledge_base_v2/apps/admin-web/README.md
Normal file
33
ai_knowledge_base_v2/apps/admin-web/README.md
Normal 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
|
||||
```
|
||||
|
||||
生产环境必须通过环境变量修改默认密码。
|
||||
12
ai_knowledge_base_v2/apps/admin-web/index.html
Normal file
12
ai_knowledge_base_v2/apps/admin-web/index.html
Normal 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>
|
||||
1572
ai_knowledge_base_v2/apps/admin-web/package-lock.json
generated
Normal file
1572
ai_knowledge_base_v2/apps/admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
ai_knowledge_base_v2/apps/admin-web/package.json
Normal file
21
ai_knowledge_base_v2/apps/admin-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
300
ai_knowledge_base_v2/apps/admin-web/src/App.vue
Normal file
300
ai_knowledge_base_v2/apps/admin-web/src/App.vue
Normal 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>
|
||||
8
ai_knowledge_base_v2/apps/admin-web/src/main.ts
Normal file
8
ai_knowledge_base_v2/apps/admin-web/src/main.ts
Normal 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");
|
||||
62
ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
Normal file
62
ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
Normal 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"),
|
||||
};
|
||||
190
ai_knowledge_base_v2/apps/admin-web/src/styles.css
Normal file
190
ai_knowledge_base_v2/apps/admin-web/src/styles.css
Normal 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;
|
||||
}
|
||||
52
ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
Normal file
52
ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
Normal 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;
|
||||
}
|
||||
1
ai_knowledge_base_v2/apps/admin-web/src/vite-env.d.ts
vendored
Normal file
1
ai_knowledge_base_v2/apps/admin-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
17
ai_knowledge_base_v2/apps/admin-web/tsconfig.json
Normal file
17
ai_knowledge_base_v2/apps/admin-web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
10
ai_knowledge_base_v2/apps/admin-web/vite.config.ts
Normal file
10
ai_knowledge_base_v2/apps/admin-web/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -22,3 +22,7 @@ FEISHU_RETRY_COUNT=2
|
||||
|
||||
DEFAULT_DAILY_CHAT_LIMIT=100
|
||||
DEFAULT_USER_NAME_PREFIX=用户
|
||||
|
||||
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
BOOTSTRAP_ADMIN_PASSWORD=admin123456
|
||||
BOOTSTRAP_ADMIN_NAME=系统管理员
|
||||
|
||||
24
ai_knowledge_base_v2/apps/backend/app/api/admin_auth.py
Normal file
24
ai_knowledge_base_v2/apps/backend/app/api/admin_auth.py
Normal 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())
|
||||
22
ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py
Normal file
22
ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py
Normal 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())
|
||||
95
ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py
Normal file
95
ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py
Normal 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,
|
||||
}
|
||||
118
ai_knowledge_base_v2/apps/backend/app/api/admin_records.py
Normal file
118
ai_knowledge_base_v2/apps/backend/app/api/admin_records.py
Normal 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
|
||||
]
|
||||
)
|
||||
159
ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py
Normal file
159
ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py
Normal 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,
|
||||
}
|
||||
98
ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
Normal file
98
ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
Normal 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,
|
||||
}
|
||||
@@ -2,10 +2,27 @@ from __future__ import annotations
|
||||
|
||||
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.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(user.router, prefix="/user", tags=["user"])
|
||||
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"])
|
||||
|
||||
@@ -41,6 +41,9 @@ class Settings(BaseSettings):
|
||||
|
||||
default_daily_chat_limit: int = 100
|
||||
default_user_name_prefix: str = "用户"
|
||||
bootstrap_admin_username: str = "admin"
|
||||
bootstrap_admin_password: str = "admin123456"
|
||||
bootstrap_admin_name: str = "系统管理员"
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.admin import Admin
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
@@ -40,3 +41,19 @@ def get_current_user(
|
||||
if user.expired_at and user.expired_at < now:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
|
||||
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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
from uuid import uuid4
|
||||
|
||||
import jwt
|
||||
@@ -31,3 +34,20 @@ def decode_access_token(token: str) -> dict:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") from exc
|
||||
except jwt.InvalidTokenError as 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)
|
||||
|
||||
71
ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
Normal file
71
ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -33,6 +33,8 @@
|
||||
| D-010 | 用户端停止生成先使用浏览器 `AbortController` 中断 SSE 读取,后端真实中断等接入真实模型时实现。 | 默认采用 | 当前后端仍是 mock 模型,前端先保证用户操作反馈真实有效。 |
|
||||
| D-011 | 阶段四外部服务默认保持 mock 开启,通过配置关闭 mock 后再走真实 provider。 | 默认采用 | 当前缺少真实飞书检索地址和模型凭证,默认 mock 可以保证开发、演示和测试连续。 |
|
||||
| D-012 | 大模型真实调用先按 OpenAI 兼容 `chat/completions` 非流式响应接入。 | 默认采用 | 先打通配置化真实模型调用和失败日志,后续再升级为模型原生流式透传。 |
|
||||
| D-013 | 管理后台开发阶段支持首次登录自动创建默认管理员。 | 默认采用 | 降低本地初始化成本;生产环境必须修改默认账号密码。 |
|
||||
| D-014 | 管理后台 Element Plus 先整体引入,后续再做按需导入优化。 | 默认采用 | 阶段五优先保证后台功能闭环和交付速度,体积优化后续处理。 |
|
||||
|
||||
## 待确认决策
|
||||
|
||||
|
||||
@@ -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. 关键管理动作会写操作日志。
|
||||
@@ -31,3 +31,4 @@
|
||||
| `2026-07-06-phase2-rag-skeleton.md` | 阶段二 RAG 问答链路骨架记录。 |
|
||||
| `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 |
|
||||
| `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 |
|
||||
| `2026-07-06-phase5-admin-console.md` | 阶段五管理后台记录。 |
|
||||
|
||||
@@ -53,5 +53,16 @@ services:
|
||||
depends_on:
|
||||
- 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:
|
||||
ai_kb_v2_mysql_data:
|
||||
|
||||
Reference in New Issue
Block a user