Complete admin operations management

This commit is contained in:
2026-07-07 13:55:10 +08:00
parent da262f4596
commit 7be9847d65
10 changed files with 510 additions and 27 deletions

View File

@@ -15,6 +15,15 @@ import type {
ModelItem, ModelItem,
} from "./types/api"; } from "./types/api";
type UserKnowledgePermissionRow = {
knowledgeId: number;
name: string;
status: number;
assigned: boolean;
effectiveAt: string;
expiredAt: string;
};
const admin = ref<AdminProfile | null>(null); const admin = ref<AdminProfile | null>(null);
const activeMenu = ref("dashboard"); const activeMenu = ref("dashboard");
const loading = ref(false); const loading = ref(false);
@@ -34,6 +43,11 @@ const chatDetail = ref<ChatDetail | null>(null);
const chatDetailOpen = ref(false); const chatDetailOpen = ref(false);
const recordTab = ref("chats"); const recordTab = ref("chats");
const editingModelId = ref<number | null>(null); const editingModelId = ref<number | null>(null);
const editingKnowledgeId = ref<number | null>(null);
const userPermissionOpen = ref(false);
const userPermissionLoading = ref(false);
const permissionUser = ref<AdminUser | null>(null);
const userKnowledgePermissionRows = ref<UserKnowledgePermissionRow[]>([]);
const agentDebugging = ref(false); const agentDebugging = ref(false);
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([ const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" }, { role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
@@ -245,13 +259,103 @@ async function saveUser(row: AdminUser) {
await loadCurrentMenu(); await loadCurrentMenu();
} }
async function saveKnowledge() { async function deleteUser(row: AdminUser) {
await api.createKnowledge({ ...knowledgeForm }); if (!window.confirm(`确认删除用户 ${row.phone} 吗?删除后前台将无法继续使用该账号。`)) return;
ElMessage.success("知识库已新增"); await api.deleteUser(row.id);
Object.assign(knowledgeForm, { name: "", feishuSpaceId: "", feishuNodeId: "", status: 1, remark: "" }); ElMessage.success("用户已删除");
await loadCurrentMenu(); await loadCurrentMenu();
} }
async function openUserPermissions(row: AdminUser) {
permissionUser.value = row;
userPermissionOpen.value = true;
userPermissionLoading.value = true;
try {
const [knowledgeRows, detail] = await Promise.all([api.knowledge(), api.userKnowledgePermissions(row.id)]);
knowledge.value = knowledgeRows;
const permissionMap = new Map(detail.permissions.map((item) => [item.knowledgeId, item]));
userKnowledgePermissionRows.value = knowledgeRows.map((item) => {
const permission = permissionMap.get(item.id);
return {
knowledgeId: item.id,
name: item.name,
status: item.status,
assigned: Boolean(permission),
effectiveAt: permission?.effectiveAt ?? "",
expiredAt: permission?.expiredAt ?? "",
};
});
} finally {
userPermissionLoading.value = false;
}
}
async function saveUserPermissions() {
if (!permissionUser.value) return;
await api.saveUserKnowledgePermissions(permissionUser.value.id, {
permissions: userKnowledgePermissionRows.value
.filter((item) => item.assigned)
.map((item) => ({
knowledgeId: item.knowledgeId,
effectiveAt: item.effectiveAt || null,
expiredAt: item.expiredAt || null,
})),
});
ElMessage.success("知识库权限已保存");
userPermissionOpen.value = false;
}
async function saveKnowledge() {
if (!knowledgeForm.name || !knowledgeForm.feishuSpaceId || !knowledgeForm.feishuNodeId) {
ElMessage.error("请填写知识库名称、SpaceID 和 NodeID");
return;
}
if (editingKnowledgeId.value) {
await api.updateKnowledge(editingKnowledgeId.value, { ...knowledgeForm });
ElMessage.success("知识库已更新");
} else {
await api.createKnowledge({ ...knowledgeForm });
ElMessage.success("知识库已新增");
}
resetKnowledgeForm();
await loadCurrentMenu();
}
async function saveKnowledgeRow(row: KnowledgeItem) {
await api.updateKnowledge(row.id, {
name: row.name,
feishuSpaceId: row.feishuSpaceId,
feishuNodeId: row.feishuNodeId,
status: row.status,
remark: row.remark ?? "",
});
ElMessage.success("知识库状态已更新");
}
function editKnowledge(row: KnowledgeItem) {
editingKnowledgeId.value = row.id;
Object.assign(knowledgeForm, {
name: row.name,
feishuSpaceId: row.feishuSpaceId,
feishuNodeId: row.feishuNodeId,
status: row.status,
remark: row.remark ?? "",
});
}
async function deleteKnowledge(row: KnowledgeItem) {
if (!window.confirm(`确认删除知识库 ${row.name} 吗?已分配给用户的授权也会失效。`)) return;
await api.deleteKnowledge(row.id);
ElMessage.success("知识库已删除");
if (editingKnowledgeId.value === row.id) resetKnowledgeForm();
await loadCurrentMenu();
}
function resetKnowledgeForm() {
editingKnowledgeId.value = null;
Object.assign(knowledgeForm, { name: "", feishuSpaceId: "", feishuNodeId: "", status: 1, remark: "" });
}
async function savePrompt() { async function savePrompt() {
const result = await api.savePrompt(promptContent.value); const result = await api.savePrompt(promptContent.value);
promptContent.value = result.promptContent; promptContent.value = result.promptContent;
@@ -473,14 +577,16 @@ async function debugAgent() {
ElMessage.error("请输入调试问题"); ElMessage.error("请输入调试问题");
return; return;
} }
agentPreviewMessages.value.push({ role: "user", content: agentForm.question.trim() }); const question = agentForm.question.trim();
agentPreviewMessages.value.push({ role: "user", content: question });
agentForm.question = "";
agentDebugging.value = true; agentDebugging.value = true;
try { try {
const result: AgentDebugResult = await api.debugAgent({ const result: AgentDebugResult = await api.debugAgent({
promptContent: promptContent.value, promptContent: promptContent.value,
modelId: agentForm.modelId, modelId: agentForm.modelId,
knowledgeIds: agentForm.knowledgeIds, knowledgeIds: agentForm.knowledgeIds,
question: agentForm.question.trim(), question,
temperature: agentForm.temperature, temperature: agentForm.temperature,
topP: agentForm.topP, topP: agentForm.topP,
topK: agentForm.topK, topK: agentForm.topK,
@@ -599,8 +705,10 @@ function buildChatQuery() {
<el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @change="loadCurrentMenu" /> <el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @change="loadCurrentMenu" />
</div> </div>
<el-table :data="users" stripe> <el-table :data="users" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" width="150" /> <el-table-column prop="phone" label="手机号" width="150" />
<el-table-column prop="name" label="姓名" width="140" /> <el-table-column prop="name" label="姓名" width="140" />
<el-table-column prop="nickname" label="昵称" width="140" />
<el-table-column label="状态" width="130"> <el-table-column label="状态" width="130">
<template #default="{ row }"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" /></template> <template #default="{ row }"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" /></template>
</el-table-column> </el-table-column>
@@ -608,25 +716,55 @@ function buildChatQuery() {
<template #default="{ row }"><el-input-number v-model="row.dailyChatLimit" :min="0" size="small" /></template> <template #default="{ row }"><el-input-number v-model="row.dailyChatLimit" :min="0" size="small" /></template>
</el-table-column> </el-table-column>
<el-table-column prop="dailyChatUsed" label="已用" width="90" /> <el-table-column prop="dailyChatUsed" label="已用" width="90" />
<el-table-column label="操作" width="110"> <el-table-column prop="lastLoginAt" label="最近登录" width="180" />
<template #default="{ row }"><el-button size="small" @click="saveUser(row)">保存</el-button></template> <el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="saveUser(row)">保存</el-button>
<el-button size="small" type="primary" @click="openUserPermissions(row)">知识库权限</el-button>
<el-button size="small" type="danger" @click="deleteUser(row)">删除</el-button>
</template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</template> </template>
<template v-if="activeMenu === 'knowledge'"> <template v-if="activeMenu === 'knowledge'">
<div class="page-head"><h2>知识库管理</h2><p>维护飞书知识库 SpaceID NodeID</p></div> <div class="page-head"><h2>知识库管理</h2><p>维护飞书知识库 SpaceID NodeID</p></div>
<el-form class="inline-form" :model="knowledgeForm"> <el-form class="knowledge-form" :model="knowledgeForm">
<el-input v-model="knowledgeForm.name" placeholder="知识库名称" /> <el-input v-model="knowledgeForm.name" placeholder="知识库名称" />
<el-input v-model="knowledgeForm.feishuSpaceId" placeholder="SpaceID" /> <el-input v-model="knowledgeForm.feishuSpaceId" placeholder="SpaceID" />
<el-input v-model="knowledgeForm.feishuNodeId" placeholder="NodeID" /> <el-input v-model="knowledgeForm.feishuNodeId" placeholder="NodeID" />
<el-button type="primary" @click="saveKnowledge">新增</el-button> <el-input v-model="knowledgeForm.remark" placeholder="备注" />
<div class="form-switch-row">
<span>启用</span>
<el-switch v-model="knowledgeForm.status" :active-value="1" :inactive-value="0" />
</div>
<div class="form-actions-inline">
<el-button type="primary" @click="saveKnowledge">{{ editingKnowledgeId ? "保存" : "新增" }}</el-button>
<el-button @click="resetKnowledgeForm">清空</el-button>
</div>
</el-form> </el-form>
<el-table :data="knowledge" stripe> <el-table :data="knowledge" stripe>
<el-table-column prop="name" label="名称" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="feishuSpaceId" label="SpaceID" /> <el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="feishuNodeId" label="NodeID" /> <el-table-column prop="feishuSpaceId" label="SpaceID" min-width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" /> <el-table-column prop="feishuNodeId" label="NodeID" min-width="180" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="saveKnowledgeRow(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editKnowledge(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteKnowledge(row)">删除</el-button>
</template>
</el-table-column>
</el-table> </el-table>
</template> </template>
@@ -676,17 +814,8 @@ function buildChatQuery() {
<el-input-number v-model="agentForm.frequencyPenalty" :min="-2" :max="2" :step="0.1" /> <el-input-number v-model="agentForm.frequencyPenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item> </el-form-item>
</div> </div>
<el-form-item label="调试问题">
<el-input
v-model="agentForm.question"
type="textarea"
:rows="3"
placeholder="输入一个真实用户可能会问的问题"
/>
</el-form-item>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="savePrompt">保存 Agent 提示词</el-button> <el-button type="primary" @click="savePrompt">保存 Agent 提示词</el-button>
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送调试</el-button>
<el-button @click="clearAgentPreview">清空预览</el-button> <el-button @click="clearAgentPreview">清空预览</el-button>
</div> </div>
</el-form> </el-form>
@@ -707,6 +836,17 @@ function buildChatQuery() {
<pre>{{ message.content }}</pre> <pre>{{ message.content }}</pre>
</article> </article>
</div> </div>
<div class="agent-preview-composer">
<el-input
v-model="agentForm.question"
type="textarea"
:rows="2"
resize="none"
placeholder="输入测试问题"
@keydown.enter.exact.prevent="debugAgent"
/>
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button>
</div>
</aside> </aside>
</section> </section>
</template> </template>
@@ -907,6 +1047,62 @@ function buildChatQuery() {
</section> </section>
</section> </section>
<el-drawer v-model="userPermissionOpen" size="760px" title="知识库权限分配">
<template v-if="permissionUser">
<section class="permission-summary">
<div>
<span>用户</span>
<strong>{{ permissionUser.name || '-' }}</strong>
</div>
<div>
<span>手机号</span>
<strong>{{ permissionUser.phone }}</strong>
</div>
<div>
<span>每日额度</span>
<strong>{{ permissionUser.dailyChatLimit }}</strong>
</div>
</section>
<el-table v-loading="userPermissionLoading" :data="userKnowledgePermissionRows" stripe>
<el-table-column label="授权" width="90">
<template #default="{ row }">
<el-switch v-model="row.assigned" />
</template>
</el-table-column>
<el-table-column prop="name" label="知识库" min-width="170" />
<el-table-column label="状态" width="90">
<template #default="{ row }">{{ row.status === 1 ? "启用" : "禁用" }}</template>
</el-table-column>
<el-table-column label="生效时间" width="220">
<template #default="{ row }">
<el-date-picker
v-model="row.effectiveAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="不限制"
:disabled="!row.assigned"
/>
</template>
</el-table-column>
<el-table-column label="失效时间" width="220">
<template #default="{ row }">
<el-date-picker
v-model="row.expiredAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="不限制"
:disabled="!row.assigned"
/>
</template>
</el-table-column>
</el-table>
<div class="drawer-actions">
<el-button type="primary" @click="saveUserPermissions">保存权限</el-button>
<el-button @click="userPermissionOpen = false">取消</el-button>
</div>
</template>
</el-drawer>
<el-drawer v-model="chatDetailOpen" size="760px" title="聊天详情"> <el-drawer v-model="chatDetailOpen" size="760px" title="聊天详情">
<template v-if="chatDetail"> <template v-if="chatDetail">
<section class="chat-summary"> <section class="chat-summary">

View File

@@ -10,6 +10,7 @@ import type {
DashboardStats, DashboardStats,
KnowledgeItem, KnowledgeItem,
ModelItem, ModelItem,
UserKnowledgePermissionDetail,
} from "../types/api"; } from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api";
@@ -75,11 +76,20 @@ export const api = {
users: (keyword = "") => request<AdminUser[]>(`/admin/user/list?keyword=${encodeURIComponent(keyword)}`), users: (keyword = "") => request<AdminUser[]>(`/admin/user/list?keyword=${encodeURIComponent(keyword)}`),
updateUser: (id: number, payload: Record<string, unknown>) => updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }), request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
userKnowledgePermissions: (id: number) =>
request<UserKnowledgePermissionDetail>(`/admin/user/${id}/knowledge-permissions`),
saveUserKnowledgePermissions: (id: number, payload: Record<string, unknown>) =>
request<UserKnowledgePermissionDetail>(`/admin/user/${id}/knowledge-permissions`, {
method: "PUT",
body: JSON.stringify(payload),
}),
knowledge: () => request<KnowledgeItem[]>("/admin/knowledge/list"), knowledge: () => request<KnowledgeItem[]>("/admin/knowledge/list"),
createKnowledge: (payload: Record<string, unknown>) => createKnowledge: (payload: Record<string, unknown>) =>
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }), request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
updateKnowledge: (id: number, payload: Record<string, unknown>) => updateKnowledge: (id: number, payload: Record<string, unknown>) =>
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }), request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
prompt: () => request<{ promptContent: string }>("/admin/prompt"), prompt: () => request<{ promptContent: string }>("/admin/prompt"),
savePrompt: (promptContent: string) => savePrompt: (promptContent: string) =>
request<{ promptContent: string }>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }), request<{ promptContent: string }>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),

View File

@@ -185,6 +185,31 @@ textarea {
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
} }
.knowledge-form {
display: grid;
grid-template-columns: minmax(160px, 1.1fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(180px, 1.2fr) 100px 150px;
gap: 10px;
align-items: center;
margin-bottom: 16px;
}
.form-switch-row,
.form-actions-inline {
display: flex;
align-items: center;
gap: 10px;
}
.form-switch-row {
height: 32px;
color: #40524b;
font-size: 13px;
}
.form-actions-inline {
justify-content: flex-end;
}
.model-config-panel { .model-config-panel {
margin-bottom: 18px; margin-bottom: 18px;
padding: 18px; padding: 18px;
@@ -256,6 +281,12 @@ textarea {
background: #ffffff; background: #ffffff;
} }
.agent-preview-panel {
height: clamp(520px, calc(100vh - 170px), 680px);
display: flex;
flex-direction: column;
}
.agent-config-grid { .agent-config-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -290,7 +321,8 @@ textarea {
} }
.agent-preview-chat { .agent-preview-chat {
height: 560px; min-height: 0;
flex: 1;
overflow: auto; overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -327,10 +359,64 @@ textarea {
line-height: 1.6; line-height: 1.6;
} }
.agent-preview-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) 76px;
gap: 10px;
align-items: end;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e4ece9;
}
.agent-preview-composer .el-button {
height: 54px;
}
.actions { .actions {
margin-top: 12px; margin-top: 12px;
} }
.permission-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.permission-summary div {
padding: 12px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #f8fbfa;
}
.permission-summary span {
display: block;
color: #6b7d77;
font-size: 12px;
}
.permission-summary strong {
display: block;
margin-top: 6px;
overflow: hidden;
color: #1f2d2a;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.el-drawer .el-date-editor.el-input {
width: 100%;
}
.record-filter { .record-filter {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -23,11 +23,13 @@ export interface AdminUser {
id: number; id: number;
phone: string; phone: string;
name: string; name: string;
nickname?: string | null;
status: number; status: number;
dailyChatLimit: number; dailyChatLimit: number;
dailyChatUsed: number; dailyChatUsed: number;
expiredAt?: string | null; expiredAt?: string | null;
lastLoginAt?: string | null; lastLoginAt?: string | null;
createdAt?: string | null;
} }
export interface KnowledgeItem { export interface KnowledgeItem {
@@ -37,6 +39,19 @@ export interface KnowledgeItem {
feishuNodeId: string; feishuNodeId: string;
status: number; status: number;
remark?: string | null; remark?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface UserKnowledgePermissionItem {
knowledgeId: number;
effectiveAt?: string | null;
expiredAt?: string | null;
}
export interface UserKnowledgePermissionDetail {
user: AdminUser;
permissions: UserKnowledgePermissionItem[];
} }
export interface ModelItem { export interface ModelItem {

View File

@@ -8,7 +8,7 @@ from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.models.knowledge import Knowledge from app.models.knowledge import Knowledge, UserKnowledgePermission
from app.schemas.admin import KnowledgeSaveRequest from app.schemas.admin import KnowledgeSaveRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
@@ -69,6 +69,11 @@ def delete_knowledge(
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
knowledge = _get_knowledge(db, knowledge_id) knowledge = _get_knowledge(db, knowledge_id)
permissions = db.scalars(
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
).all()
for permission in permissions:
db.delete(permission)
db.delete(knowledge) db.delete(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id)
db.commit() db.commit()

View File

@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, Query from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -8,8 +10,9 @@ from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.models.knowledge import Knowledge, UserKnowledgePermission
from app.models.user import User from app.models.user import User
from app.schemas.admin import AdminUserUpdateRequest from app.schemas.admin import AdminUserKnowledgePermissionSaveRequest, AdminUserUpdateRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
router = APIRouter() router = APIRouter()
@@ -60,6 +63,72 @@ def update_user(
return api_success(_user_dict(user)) return api_success(_user_dict(user))
@router.get("/user/{user_id}/knowledge-permissions")
def user_knowledge_permissions(
user_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
permissions = db.scalars(
select(UserKnowledgePermission)
.where(UserKnowledgePermission.user_id == user.id)
.order_by(UserKnowledgePermission.knowledge_id.asc())
).all()
return api_success(
{
"user": _user_dict(user),
"permissions": [_permission_dict(permission) for permission in permissions],
}
)
@router.put("/user/{user_id}/knowledge-permissions")
def save_user_knowledge_permissions(
user_id: int,
payload: AdminUserKnowledgePermissionSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
normalized = {item.knowledgeId: item for item in payload.permissions}
if normalized:
existing_ids = set(db.scalars(select(Knowledge.id).where(Knowledge.id.in_(normalized.keys()))).all())
missing_ids = sorted(set(normalized.keys()) - existing_ids)
if missing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"知识库不存在: {','.join(str(item) for item in missing_ids)}",
)
old_permissions = db.scalars(
select(UserKnowledgePermission).where(UserKnowledgePermission.user_id == user.id)
).all()
for permission in old_permissions:
db.delete(permission)
db.flush()
for item in normalized.values():
db.add(
UserKnowledgePermission(
user_id=user.id,
knowledge_id=item.knowledgeId,
effective_at=_without_timezone(item.effectiveAt),
expired_at=_without_timezone(item.expiredAt),
created_by=current_admin.id,
)
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="user",
action="knowledge_permission",
target_id=user.id,
)
db.commit()
return user_knowledge_permissions(user.id, db, current_admin)
@router.delete("/user/{user_id}") @router.delete("/user/{user_id}")
def delete_user( def delete_user(
user_id: int, user_id: int,
@@ -96,3 +165,17 @@ def _user_dict(user: User) -> dict:
"lastLoginAt": user.last_login_at, "lastLoginAt": user.last_login_at,
"createdAt": user.created_at, "createdAt": user.created_at,
} }
def _permission_dict(permission: UserKnowledgePermission) -> dict:
return {
"knowledgeId": permission.knowledge_id,
"effectiveAt": permission.effective_at,
"expiredAt": permission.expired_at,
}
def _without_timezone(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=None)

View File

@@ -39,6 +39,16 @@ class AdminUserUpdateRequest(BaseModel):
expiredAt: datetime | None = None expiredAt: datetime | None = None
class AdminUserKnowledgePermissionItem(BaseModel):
knowledgeId: int = Field(gt=0)
effectiveAt: datetime | None = None
expiredAt: datetime | None = None
class AdminUserKnowledgePermissionSaveRequest(BaseModel):
permissions: list[AdminUserKnowledgePermissionItem] = Field(default_factory=list)
class KnowledgeSaveRequest(BaseModel): class KnowledgeSaveRequest(BaseModel):
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
feishuSpaceId: str = Field(min_length=1, max_length=100) feishuSpaceId: str = Field(min_length=1, max_length=100)

View File

@@ -42,6 +42,8 @@
| D-019 | 管理后台 Element Plus 改为按需注册,并拆分表格和基础 vendor chunk。 | 默认采用 | 解决后台构建单 chunk 过大的提示,降低首屏主入口体积,同时不引入额外构建插件。 | | D-019 | 管理后台 Element Plus 改为按需注册,并拆分表格和基础 vendor chunk。 | 默认采用 | 解决后台构建单 chunk 过大的提示,降低首屏主入口体积,同时不引入额外构建插件。 |
| D-020 | MiniMax 快速添加默认使用 Anthropic Messages 兼容协议。 | 默认采用 | MiniMax 文本生成指南推荐 Anthropic-compatible 路径;后端按 `auth_type` 兼容 Bearer 和 x-api-key 两种鉴权。 | | D-020 | MiniMax 快速添加默认使用 Anthropic Messages 兼容协议。 | 默认采用 | MiniMax 文本生成指南推荐 Anthropic-compatible 路径;后端按 `auth_type` 兼容 Bearer 和 x-api-key 两种鉴权。 |
| D-021 | 模型管理只负责 LLM 接入Agent 管理负责提示词、知识库、模型选择和推理参数调试。 | 默认采用 | 避免模型连接配置与 Agent 运行策略混在一起,方便后续做 Agent 版本、调试和发布流程。 | | D-021 | 模型管理只负责 LLM 接入Agent 管理负责提示词、知识库、模型选择和推理参数调试。 | 默认采用 | 避免模型连接配置与 Agent 运行策略混在一起,方便后续做 Agent 版本、调试和发布流程。 |
| D-022 | 用户-知识库授权先放在用户管理中维护。 | 默认采用 | 一期运营动作更直接:找到用户后分配知识库;后续用户规模变大时再增加角色、用户组或批量授权。 |
| D-023 | Agent 调试预览采用接近用户端聊天页的交互形态。 | 默认采用 | 测试问题从配置区移到预览窗口底部,减少调试和真实用户问答体验之间的差异。 |
## 待确认决策 ## 待确认决策

View File

@@ -0,0 +1,75 @@
# 管理后台运营能力补齐记录
日期2026-07-07
## 背景
用户反馈当前后台仍有三块没有做到产品可用状态:
1. 知识库管理只做到新增和列表,缺少编辑、删除、启停、备注维护。
2. 用户管理只做到查询、启停和额度调整,缺少删除用户和知识库权限分配。
3. Agent 调试预览不符合用户端聊天体验,测试问题在左侧配置区,预览窗口在右侧,使用时割裂。
本次调整目标是把后台从“可演示”推进到“一期运营人员可使用”的状态。
## 本次完成内容
### 知识库管理
- 新增知识库编辑能力。
- 新增知识库删除能力。
- 支持在列表中直接启用或禁用知识库。
- 表单补充备注字段。
- 删除知识库时同步清理用户-知识库授权关系,避免外键约束导致删除失败。
### 用户管理
- 用户列表补充 ID、昵称、最近登录时间。
- 支持删除用户。
- 支持打开“知识库权限分配”抽屉。
- 可为用户按知识库开启或关闭权限。
- 可为每个授权设置生效时间和失效时间。
- 后端新增用户知识库权限读取和保存接口。
### Agent 管理预览
- 移除左侧配置区中的“调试问题”输入。
- 右侧预览窗口改为接近用户端聊天的交互:
- 上方展示对话消息。
- 下方固定输入框。
- 右侧发送按钮。
- 发送后输入框自动清空,消息进入预览对话流。
## 后端接口变化
新增接口:
- `GET /api/admin/user/{user_id}/knowledge-permissions`
- 读取指定用户已分配的知识库权限。
- `PUT /api/admin/user/{user_id}/knowledge-permissions`
- 覆盖保存指定用户的知识库权限列表。
复用接口:
- `GET /api/admin/knowledge/list`
- `POST /api/admin/knowledge`
- `PUT /api/admin/knowledge/{knowledge_id}`
- `DELETE /api/admin/knowledge/{knowledge_id}`
- `GET /api/admin/user/list`
- `PUT /api/admin/user/{user_id}`
- `DELETE /api/admin/user/{user_id}`
- `POST /api/admin/agent/debug`
## 当前边界
- 用户知识库权限已经可以维护,但用户端问答链路后续还需要根据登录态和用户权限过滤可用知识库。
- 当前用户删除仍是软删除,聊天记录和审计记录不会被物理删除。
- Agent 管理目前仍是调试态,尚未做正式的 Agent 版本发布、回滚和灰度。
- 权限分配按用户维护;后续如果用户规模变大,应增加角色、用户组或批量授权能力。
## 后续建议
1. 在用户端接入真实登录后,把当前用户 ID 传入后端问答链路,按 `sys_user_kb` 过滤知识库检索范围。
2. Agent 管理增加“保存草稿 / 发布版本 / 回滚版本”,把提示词、模型、知识库和参数落成版本。
3. 用户管理增加批量启停、批量授权和导入导出。
4. 知识库管理增加同步状态、最后同步时间、文档数量和检索测试入口。

View File

@@ -36,4 +36,5 @@
| `2026-07-07-model-management-completion.md` | 管理后台模型管理补全记录。 | | `2026-07-07-model-management-completion.md` | 管理后台模型管理补全记录。 |
| `2026-07-07-admin-web-build-optimization.md` | 管理后台构建体积优化记录。 | | `2026-07-07-admin-web-build-optimization.md` | 管理后台构建体积优化记录。 |
| `2026-07-07-agent-management-adjustment.md` | Agent 管理与模型接入边界调整记录。 | | `2026-07-07-agent-management-adjustment.md` | Agent 管理与模型接入边界调整记录。 |
| `2026-07-07-admin-operations-completion.md` | 管理后台知识库、用户权限和 Agent 预览补齐记录。 |
| `../test_records/2026-07-06-phase6-verification.md` | 阶段六验证记录。 | | `../test_records/2026-07-06-phase6-verification.md` | 阶段六验证记录。 |