feat(aihr): 重构运营后台与租户知识治理

- 重构运营总览、侧栏和内容运营工作台\n- 增加大喇叭、成果审核与运营统计链路\n- 补齐租户、知识空间、分类和知识维护闭环\n- 加固标签页租户上下文、停用租户写保护与迁移预检
This commit is contained in:
2026-07-23 01:24:22 +08:00
parent 333c365c6f
commit 163b08ecfe
94 changed files with 8610 additions and 1114 deletions
+24 -3
View File
@@ -7,18 +7,39 @@ const src = (...parts: string[]) => readFileSync(resolve(frontendRoot, 'src', ..
describe('knowledge space administration contract', () => {
it('registers the controlled route and sidebar entry', () => {
expect(src('router', 'index.ts')).toContain("path: '/knowledge/spaces'");
const sidebar = src('layout', 'components', 'Sidebar', 'index.vue');
expect(src('router', 'index.ts')).toContain("path: '/knowledge'");
expect(src('router', 'index.ts')).toContain("path: 'spaces'");
expect(src('router', 'index.ts')).toContain("title: '知识空间'");
expect(src('layout', 'components', 'Sidebar', 'index.vue')).toContain("'知识空间'");
expect(sidebar).toContain('sidebarGroups');
expect(sidebar).toContain('visibleChildren(parent).map');
expect(sidebar).toContain("import { constantRoutes } from '@/router';");
expect(sidebar).toContain('operationsConsoleRoles');
expect(sidebar).toContain('<router-link');
expect(sidebar).not.toContain('<el-menu-item');
});
it('contains spaces grants and applications tabs without exposing token hash', () => {
it('contains spaces, category maintenance, grants and applications without exposing token hash', () => {
const page = src('views', 'knowledge', 'spaces.vue');
expect(page).toContain('label="知识空间"');
expect(page).toContain('label="分类与知识"');
expect(page).toContain('label="空间授权"');
expect(page).toContain('label="调用应用"');
expect(page).toContain('分类只用于当前租户、当前知识空间内的内容维护');
expect(page).toContain('assignDocumentCategory(row)');
expect(page).toContain('仅显示一次');
expect(page).toContain('租户上下文尚未确认');
expect(page).toContain('requiresTenantContextConfirmation');
expect(page).toContain('ensureTenantContextConfirmed');
expect(page).toContain(':disabled="requiresTenantContextConfirmation"');
expect(page).toContain('if (!ensureTenantContextConfirmed()) return;');
expect(page).toContain('if (!ensureTenantContextConfirmed()) {\n await loadCategoryWorkspace();');
expect(src('api', 'aihr', 'space.ts')).not.toContain('tokenHash');
expect(src('api', 'aihr', 'space.ts')).toContain('listKnowledgeCategories');
expect(src('api', 'aihr', 'space.ts')).toContain('assignSpaceDocumentCategory');
expect(page).toContain('const spaceId = categorySpaceId.value');
expect(page).toContain('if (categorySpaceId.value !== spaceId) return');
expect(page).toContain("error !== 'cancel' && error !== 'close'");
});
it('unbinds only the selected document-space membership', () => {
+556 -65
View File
@@ -5,12 +5,27 @@
<h1>知识空间</h1>
<p>按租户管理知识边界、访问主体和调用应用;空间编码创建后不可修改。</p>
</div>
<el-button :loading="loading" @click="loadAll">刷新</el-button>
<div class="page-head__actions">
<el-tag v-if="dynamicTenantId" type="warning" effect="plain"
>已切换租户:{{ dynamicTenantName || dynamicTenantId }}({{ dynamicTenantId }})</el-tag
>
<el-button :loading="loading" @click="loadAll">刷新</el-button>
</div>
</section>
<el-alert
v-if="requiresTenantContextConfirmation"
class="context-warning"
type="warning"
:closable="false"
title="租户上下文尚未确认"
description="为防止知识写入错误租户,请刷新页面或从租户管理重新选择租户后再操作。"
show-icon
/>
<el-tabs v-model="activeTab" class="space-tabs">
<el-tab-pane label="知识空间" name="spaces">
<div class="toolbar"><el-button type="danger" @click="openSpace()">新建空间</el-button></div>
<div class="toolbar"><el-button type="danger" :disabled="requiresTenantContextConfirmation" @click="openSpace()">新建空间</el-button></div>
<el-table :data="spaces" v-loading="loading">
<el-table-column prop="name" label="空间名称" min-width="170" />
<el-table-column prop="code" label="稳定编码" min-width="180" />
@@ -19,7 +34,12 @@
<el-table-column prop="documentCount" label="文档" width="80" />
<el-table-column prop="fragmentCount" label="片段" width="80" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="openSpace(row)">编辑</el-button><el-button link type="primary" @click="openDocuments(row)">文档成员</el-button></template></el-table-column>
<el-table-column label="操作" width="180"
><template #default="{ row }"
><el-button link type="primary" :disabled="requiresTenantContextConfirmation" @click="openSpace(row)">编辑</el-button
><el-button link type="primary" @click="openDocuments(row)">文档成员</el-button></template
></el-table-column
>
</el-table>
</el-tab-pane>
@@ -28,55 +48,223 @@
<el-select v-model="grantSpaceId" placeholder="选择知识空间" style="width: 280px" @change="loadGrants">
<el-option v-for="space in spaces" :key="space.id" :label="`${space.name} (${space.code})`" :value="space.id" />
</el-select>
<el-button :disabled="!grantSpaceId" @click="addGrant">新增授权</el-button>
<el-button type="danger" :disabled="!grantSpaceId" :loading="saving" @click="saveGrants">保存全部授权</el-button>
<el-button :disabled="!grantSpaceId || requiresTenantContextConfirmation" @click="addGrant">新增授权</el-button>
<el-button type="danger" :disabled="!grantSpaceId || requiresTenantContextConfirmation" :loading="saving" @click="saveGrants"
>保存全部授权</el-button
>
</div>
<el-table :data="grants">
<el-table-column label="主体类型" width="150"><template #default="{ row }"><el-select v-model="row.principalType"><el-option label="角色" value="ROLE" /><el-option label="用户" value="USER" /></el-select></template></el-table-column>
<el-table-column label="角色编码 / 用户ID" min-width="220"><template #default="{ row }"><el-input v-model="row.principalValue" placeholder="如 employee / 10001" /></template></el-table-column>
<el-table-column label="权限" width="150"><template #default="{ row }"><el-select v-model="row.permission"><el-option label="读取" value="READ" /><el-option label="管理" value="MANAGE" /></el-select></template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{ row }"><el-select v-model="row.status"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></template></el-table-column>
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button link type="danger" @click="grants.splice($index, 1)">删除</el-button></template></el-table-column>
<el-table-column label="主体类型" width="150"
><template #default="{ row }"
><el-select v-model="row.principalType" :disabled="requiresTenantContextConfirmation"
><el-option label="角色" value="ROLE" /><el-option label="用户" value="USER" /></el-select></template
></el-table-column>
<el-table-column label="角色编码 / 用户ID" min-width="220"
><template #default="{ row }"
><el-input v-model="row.principalValue" :disabled="requiresTenantContextConfirmation" placeholder="如 employee / 10001" /></template
></el-table-column>
<el-table-column label="权限" width="150"
><template #default="{ row }"
><el-select v-model="row.permission" :disabled="requiresTenantContextConfirmation"
><el-option label="读取" value="READ" /><el-option label="管理" value="MANAGE" /></el-select></template
></el-table-column>
<el-table-column label="状态" width="150"
><template #default="{ row }"
><el-select v-model="row.status" :disabled="requiresTenantContextConfirmation"
><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></template
></el-table-column>
<el-table-column label="操作" width="80"
><template #default="{ $index }"
><el-button link type="danger" :disabled="requiresTenantContextConfirmation" @click="removeGrant($index)">删除</el-button></template
></el-table-column
>
</el-table>
</el-tab-pane>
<el-tab-pane label="分类与知识" name="categories">
<el-alert
class="category-hint"
type="info"
:closable="false"
title="分类只用于当前租户、当前知识空间内的内容维护,不改变空间授权和调用应用可检索范围。"
/>
<div class="toolbar">
<el-select v-model="categorySpaceId" placeholder="选择知识空间" style="width: 300px" @change="loadCategoryWorkspace">
<el-option v-for="space in spaces" :key="space.id" :label="`${space.name} (${space.code})`" :value="space.id" />
</el-select>
<el-button type="danger" :disabled="!categorySpaceId || requiresTenantContextConfirmation" @click="openCategory()">新建分类</el-button>
</div>
<el-row :gutter="16" class="category-layout">
<el-col :xs="24" :lg="10">
<section class="category-panel">
<h2>空间分类</h2>
<el-table :data="categories" v-loading="categoryLoading" max-height="460">
<el-table-column prop="name" label="分类名称" min-width="140" />
<el-table-column prop="code" label="稳定编码" min-width="145" />
<el-table-column prop="documentCount" label="知识数" width="86" />
<el-table-column label="状态" width="92"
><template #default="{ row }"
><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" size="small">{{
row.status === 'ACTIVE' ? '启用' : '停用'
}}</el-tag></template
></el-table-column
>
<el-table-column label="操作" width="130"
><template #default="{ row }"
><el-button link type="primary" :disabled="requiresTenantContextConfirmation" @click="openCategory(row)">编辑</el-button
><el-button link type="danger" :disabled="row.documentCount > 0 || requiresTenantContextConfirmation" @click="deleteCategory(row)"
>删除</el-button
></template
></el-table-column
>
</el-table>
</section>
</el-col>
<el-col :xs="24" :lg="14">
<section class="category-panel">
<h2>知识归类</h2>
<el-table :data="categoryDocuments" v-loading="categoryLoading" max-height="460">
<el-table-column prop="name" label="文件名" min-width="190" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="92" />
<el-table-column prop="fragmentCount" label="片段" width="76" />
<el-table-column label="所属分类" min-width="170">
<template #default="{ row }">
<el-select
v-model="row.categoryId"
clearable
placeholder="未分类"
:disabled="!categorySpaceId || requiresTenantContextConfirmation"
@change="assignDocumentCategory(row)"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
:disabled="category.status !== 'ACTIVE' && row.categoryId !== category.id"
/>
</el-select>
</template>
</el-table-column>
</el-table>
</section>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="调用应用" name="apps">
<div class="toolbar"><el-button type="danger" @click="openApp()">新建应用</el-button></div>
<div class="toolbar"><el-button type="danger" :disabled="requiresTenantContextConfirmation" @click="openApp()">新建应用</el-button></div>
<el-table :data="apps" v-loading="loading">
<el-table-column prop="appName" label="应用名称" min-width="160" />
<el-table-column prop="appCode" label="应用编码" min-width="160" />
<el-table-column prop="authType" label="认证方式" width="130" />
<el-table-column label="授权空间" min-width="230"><template #default="{ row }">{{ row.spaceCodes.join('、') || '未绑定' }}</template></el-table-column>
<el-table-column label="授权空间" min-width="230"
><template #default="{ row }">{{ row.spaceCodes.join('、') || '未绑定' }}</template></el-table-column
>
<el-table-column prop="rateLimitPerMinute" label="每分钟限流" width="110" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column label="操作" width="190"><template #default="{ row }"><el-button link type="primary" @click="openApp(row)">编辑</el-button><el-button v-if="row.authType === 'API_TOKEN'" link type="danger" @click="rotateToken(row)">轮换令牌</el-button></template></el-table-column>
<el-table-column label="操作" width="190"
><template #default="{ row }"
><el-button link type="primary" :disabled="requiresTenantContextConfirmation" @click="openApp(row)">编辑</el-button
><el-button
v-if="row.authType === 'API_TOKEN'"
link
type="danger"
:disabled="requiresTenantContextConfirmation"
@click="rotateToken(row)"
>轮换令牌</el-button
></template
></el-table-column
>
</el-table>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="spaceDialog" :title="spaceEditingId ? '编辑知识空间' : '新建知识空间'" width="560px">
<el-form label-width="100px">
<el-form-item label="稳定编码"><el-input v-model="spaceForm.code" :disabled="Boolean(spaceEditingId)" placeholder="如 yc_property_sop" /></el-form-item>
<el-form-item label="空间名称"><el-input v-model="spaceForm.name" /></el-form-item>
<el-form-item label="说明"><el-input v-model="spaceForm.description" type="textarea" /></el-form-item>
<el-form-item label="类型"><el-select v-model="spaceForm.spaceType"><el-option v-for="value in spaceTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="密级"><el-select v-model="spaceForm.sensitivityLevel"><el-option v-for="value in sensitivities" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="状态"><el-select v-model="spaceForm.status"><el-option label="草稿" value="DRAFT" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
<el-form-item label="稳定编码"
><el-input
v-model="spaceForm.code"
:disabled="Boolean(spaceEditingId) || requiresTenantContextConfirmation"
placeholder="如 yc_property_sop"
/></el-form-item>
<el-form-item label="空间名称"><el-input v-model="spaceForm.name" :disabled="requiresTenantContextConfirmation" /></el-form-item>
<el-form-item label="说明"
><el-input v-model="spaceForm.description" :disabled="requiresTenantContextConfirmation" type="textarea"
/></el-form-item>
<el-form-item label="类型"
><el-select v-model="spaceForm.spaceType" :disabled="requiresTenantContextConfirmation"
><el-option v-for="value in spaceTypes" :key="value" :label="value" :value="value" /></el-select
></el-form-item>
<el-form-item label="密级"
><el-select v-model="spaceForm.sensitivityLevel" :disabled="requiresTenantContextConfirmation"
><el-option v-for="value in sensitivities" :key="value" :label="value" :value="value" /></el-select
></el-form-item>
<el-form-item label="状态"
><el-select v-model="spaceForm.status" :disabled="requiresTenantContextConfirmation"
><el-option label="草稿" value="DRAFT" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select
></el-form-item>
</el-form>
<template #footer><el-button @click="spaceDialog = false">取消</el-button><el-button type="danger" :loading="saving" @click="saveSpace">保存</el-button></template>
<template #footer
><el-button @click="spaceDialog = false">取消</el-button
><el-button type="danger" :disabled="requiresTenantContextConfirmation" :loading="saving" @click="saveSpace">保存</el-button></template
>
</el-dialog>
<el-dialog v-model="categoryDialog" :title="categoryEditingId ? '编辑分类' : '新建分类'" width="520px">
<el-form label-width="96px">
<el-form-item label="稳定编码"
><el-input
v-model="categoryForm.code"
:disabled="Boolean(categoryEditingId) || requiresTenantContextConfirmation"
placeholder="如 resident_service"
/></el-form-item>
<el-form-item label="分类名称"
><el-input v-model="categoryForm.name" :disabled="requiresTenantContextConfirmation" placeholder="如 业主服务"
/></el-form-item>
<el-form-item label="排序"
><el-input-number v-model="categoryForm.sortOrder" :disabled="requiresTenantContextConfirmation" :min="0" :max="9999"
/></el-form-item>
<el-form-item label="状态"
><el-select v-model="categoryForm.status" :disabled="requiresTenantContextConfirmation"
><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select
></el-form-item>
</el-form>
<template #footer
><el-button @click="categoryDialog = false">取消</el-button
><el-button type="danger" :disabled="requiresTenantContextConfirmation" :loading="saving" @click="saveCategory">保存</el-button></template
>
</el-dialog>
<el-dialog v-model="appDialog" :title="appEditingId ? '编辑调用应用' : '新建调用应用'" width="620px">
<el-form label-width="110px">
<el-form-item label="应用编码"><el-input v-model="appForm.appCode" :disabled="Boolean(appEditingId)" /></el-form-item>
<el-form-item label="应用名称"><el-input v-model="appForm.appName" /></el-form-item>
<el-form-item label="认证方式"><el-select v-model="appForm.authType" :disabled="Boolean(appEditingId)"><el-option label="内部登录态" value="SESSION" /><el-option label="外部令牌" value="API_TOKEN" /></el-select></el-form-item>
<el-form-item v-if="appForm.authType === 'SESSION'" label="客户端标识"><el-input v-model="appForm.internalClientKey" placeholder="pc / app" /></el-form-item>
<el-form-item label="授权空间"><el-select v-model="appForm.spaceCodes" multiple style="width: 100%"><el-option v-for="space in activeSpaces" :key="space.code" :label="space.name" :value="space.code" /></el-select></el-form-item>
<el-form-item label="每分钟限流"><el-input-number v-model="appForm.rateLimitPerMinute" :min="1" :max="10000" /></el-form-item>
<el-form-item label="状态"><el-select v-model="appForm.status"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
<el-form-item label="应用编码"
><el-input v-model="appForm.appCode" :disabled="Boolean(appEditingId) || requiresTenantContextConfirmation"
/></el-form-item>
<el-form-item label="应用名称"><el-input v-model="appForm.appName" :disabled="requiresTenantContextConfirmation" /></el-form-item>
<el-form-item label="认证方式"
><el-select v-model="appForm.authType" :disabled="Boolean(appEditingId) || requiresTenantContextConfirmation"
><el-option label="内部登录态" value="SESSION" /><el-option label="外部令牌" value="API_TOKEN" /></el-select
></el-form-item>
<el-form-item v-if="appForm.authType === 'SESSION'" label="客户端标识"
><el-input v-model="appForm.internalClientKey" :disabled="requiresTenantContextConfirmation" placeholder="pc / app"
/></el-form-item>
<el-form-item label="授权空间"
><el-select v-model="appForm.spaceCodes" :disabled="requiresTenantContextConfirmation" multiple style="width: 100%"
><el-option v-for="space in activeSpaces" :key="space.code" :label="space.name" :value="space.code" /></el-select
></el-form-item>
<el-form-item label="每分钟限流"
><el-input-number v-model="appForm.rateLimitPerMinute" :disabled="requiresTenantContextConfirmation" :min="1" :max="10000"
/></el-form-item>
<el-form-item label="状态"
><el-select v-model="appForm.status" :disabled="requiresTenantContextConfirmation"
><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select
></el-form-item>
</el-form>
<template #footer><el-button @click="closeAppDialog">取消</el-button><el-button type="danger" :loading="saving" @click="saveApp">保存</el-button></template>
<template #footer
><el-button @click="closeAppDialog">取消</el-button
><el-button type="danger" :disabled="requiresTenantContextConfirmation" :loading="saving" @click="saveApp">保存</el-button></template
>
</el-dialog>
<el-dialog v-model="tokenDialog" title="访问令牌(仅显示一次)" width="620px" :close-on-click-modal="false" @closed="issuedToken = ''">
@@ -86,13 +274,23 @@
</el-dialog>
<el-dialog v-model="documentDialog" :title="`${documentSpace?.name || ''} · 文档成员`" width="840px">
<el-alert type="info" :closable="false" title="解绑只移除当前空间的文档成员和检索片段;其他空间仍可使用同一原始文件。仅当没有任何空间再引用时,系统才删除 OSS 文件。" />
<el-alert
type="info"
:closable="false"
title="解绑只移除当前空间的文档成员和检索片段;其他空间仍可使用同一原始文件。仅当没有任何空间再引用时,系统才删除 OSS 文件。"
/>
<el-table :data="documents" v-loading="documentLoading" class="document-table">
<el-table-column prop="name" label="文件名" min-width="250" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="100" />
<el-table-column prop="status" label="状态" width="110" />
<el-table-column prop="fragmentCount" label="片段" width="80" />
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="danger" @click="unbindDocument(row)">解除当前空间绑定</el-button></template></el-table-column>
<el-table-column label="操作" width="150"
><template #default="{ row }"
><el-button link type="danger" :disabled="requiresTenantContextConfirmation" @click="unbindDocument(row)"
>解除当前空间绑定</el-button
></template
></el-table-column
>
</el-table>
<template #footer><el-button @click="documentDialog = false">关闭</el-button></template>
</el-dialog>
@@ -103,11 +301,36 @@
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import {
createKnowledgeApp, createKnowledgeSpace, listKnowledgeApps, listKnowledgeSpaces, listSpaceDocuments, listSpaceGrants,
replaceKnowledgeAppSpaces, replaceSpaceGrants, rotateKnowledgeAppToken, unbindSpaceDocument, updateKnowledgeApp, updateKnowledgeSpace,
type AppForm, type KnowledgeApp, type KnowledgeDocument, type KnowledgeSpace, type SpaceForm, type SpaceGrant
assignSpaceDocumentCategory,
createKnowledgeApp,
createKnowledgeCategory,
createKnowledgeSpace,
deleteKnowledgeCategory,
listKnowledgeApps,
listKnowledgeCategories,
listKnowledgeSpaces,
listSpaceDocuments,
listSpaceGrants,
replaceKnowledgeAppSpaces,
replaceSpaceGrants,
rotateKnowledgeAppToken,
unbindSpaceDocument,
updateKnowledgeApp,
updateKnowledgeCategory,
updateKnowledgeSpace,
type AppForm,
type CategoryForm,
type KnowledgeApp,
type KnowledgeCategory,
type KnowledgeDocument,
type KnowledgeSpace,
type SpaceForm,
type SpaceGrant
} from '@/api/aihr/space';
import { useUserStore } from '@/store/modules/user';
import { dynamicTenantId, dynamicTenantName, tenantContextVerified } from '@/utils/tenant-context';
const userStore = useUserStore();
const activeTab = ref('spaces');
const loading = ref(false);
const saving = ref(false);
@@ -115,22 +338,45 @@ const spaces = ref<KnowledgeSpace[]>([]);
const apps = ref<KnowledgeApp[]>([]);
const grants = ref<SpaceGrant[]>([]);
const grantSpaceId = ref<number>();
const categorySpaceId = ref<number>();
const categories = ref<KnowledgeCategory[]>([]);
const categoryDocuments = ref<KnowledgeDocument[]>([]);
const spaceDialog = ref(false);
const categoryDialog = ref(false);
const appDialog = ref(false);
const tokenDialog = ref(false);
const documentDialog = ref(false);
const documentLoading = ref(false);
const categoryLoading = ref(false);
const issuedToken = ref('');
const documents = ref<KnowledgeDocument[]>([]);
const documentSpace = ref<KnowledgeSpace>();
const spaceEditingId = ref<number>();
const categoryEditingId = ref<number>();
const appEditingId = ref<number>();
const spaceTypes = ['PUBLIC', 'BUSINESS', 'MANAGEMENT', 'EXTERNAL'] as const;
const sensitivities = ['PUBLIC', 'INTERNAL', 'CONFIDENTIAL'] as const;
const activeSpaces = computed(() => spaces.value.filter((item) => item.status === 'ACTIVE'));
const requiresTenantContextConfirmation = computed(() => userStore.roles.includes('superadmin') && !tenantContextVerified.value);
function ensureTenantContextConfirmed() {
if (!requiresTenantContextConfirmation.value) return true;
ElMessage.warning('租户上下文尚未确认,暂不能进行知识维护操作');
return false;
}
const blankSpace = (): SpaceForm => ({ code: '', name: '', description: '', spaceType: 'BUSINESS', sensitivityLevel: 'INTERNAL', status: 'DRAFT' });
const blankApp = (): AppForm => ({ appCode: '', appName: '', authType: 'SESSION', internalClientKey: 'pc', status: 'ACTIVE', rateLimitPerMinute: 60, expiresTime: null, spaceCodes: [] });
const blankCategory = (): CategoryForm => ({ code: '', name: '', status: 'ACTIVE', sortOrder: 0 });
const blankApp = (): AppForm => ({
appCode: '',
appName: '',
authType: 'SESSION',
internalClientKey: 'pc',
status: 'ACTIVE',
rateLimitPerMinute: 60,
expiresTime: null,
spaceCodes: []
});
const spaceForm = reactive<SpaceForm>(blankSpace());
const categoryForm = reactive<CategoryForm>(blankCategory());
const appForm = reactive<AppForm>(blankApp());
async function loadAll() {
@@ -139,22 +385,51 @@ async function loadAll() {
const [spaceResult, appResult] = await Promise.all([listKnowledgeSpaces(), listKnowledgeApps()]);
spaces.value = spaceResult.data || [];
apps.value = appResult.data || [];
} catch (error) { ElMessage.error(errorMessage(error)); } finally { loading.value = false; }
if (categorySpaceId.value && !spaces.value.some((space) => space.id === categorySpaceId.value)) {
categorySpaceId.value = undefined;
categories.value = [];
categoryDocuments.value = [];
}
} catch (error) {
ElMessage.error(errorMessage(error));
} finally {
loading.value = false;
}
}
function openSpace(row?: KnowledgeSpace) {
if (!ensureTenantContextConfirmed()) return;
spaceEditingId.value = row?.id;
Object.assign(spaceForm, row ? { code: row.code, name: row.name, description: row.description, spaceType: row.spaceType, sensitivityLevel: row.sensitivityLevel, status: row.status } : blankSpace());
Object.assign(
spaceForm,
row
? {
code: row.code,
name: row.name,
description: row.description,
spaceType: row.spaceType,
sensitivityLevel: row.sensitivityLevel,
status: row.status
}
: blankSpace()
);
spaceDialog.value = true;
}
async function saveSpace() {
if (!ensureTenantContextConfirmed()) return;
saving.value = true;
try {
if (spaceEditingId.value) await updateKnowledgeSpace(spaceEditingId.value, spaceForm);
else await createKnowledgeSpace(spaceForm);
spaceDialog.value = false; ElMessage.success('知识空间已保存'); await loadAll();
} catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
spaceDialog.value = false;
ElMessage.success('知识空间已保存');
await loadAll();
} catch (error) {
ElMessage.error(errorMessage(error));
} finally {
saving.value = false;
}
}
async function openDocuments(space: KnowledgeSpace) {
@@ -166,46 +441,183 @@ async function openDocuments(space: KnowledgeSpace) {
async function loadDocuments() {
if (!documentSpace.value) return;
documentLoading.value = true;
try { documents.value = (await listSpaceDocuments(documentSpace.value.id)).data || []; }
catch (error) { ElMessage.error(errorMessage(error)); }
finally { documentLoading.value = false; }
try {
documents.value = (await listSpaceDocuments(documentSpace.value.id)).data || [];
} catch (error) {
ElMessage.error(errorMessage(error));
} finally {
documentLoading.value = false;
}
}
async function unbindDocument(document: KnowledgeDocument) {
if (!ensureTenantContextConfirmed()) return;
if (!documentSpace.value) return;
await ElMessageBox.confirm(
`确认将“${document.name}”从“${documentSpace.value.name}”解绑?其他空间的同一文件不会删除。`,
'解除当前空间绑定',
{ type: 'warning', confirmButtonText: '确认解绑' }
);
await ElMessageBox.confirm(`确认将“${document.name}”从“${documentSpace.value.name}”解绑?其他空间的同一文件不会删除。`, '解除当前空间绑定', {
type: 'warning',
confirmButtonText: '确认解绑'
});
if (!ensureTenantContextConfirmed()) return;
try {
await unbindSpaceDocument(documentSpace.value.id, document.attachId);
ElMessage.success('已解除当前空间绑定');
await Promise.all([loadDocuments(), loadAll()]);
} catch (error) { ElMessage.error(errorMessage(error)); }
} catch (error) {
ElMessage.error(errorMessage(error));
}
}
async function loadCategoryWorkspace() {
const spaceId = categorySpaceId.value;
if (!spaceId) {
categories.value = [];
categoryDocuments.value = [];
categoryLoading.value = false;
return;
}
categoryLoading.value = true;
try {
const [categoryResult, documentResult] = await Promise.all([listKnowledgeCategories(spaceId), listSpaceDocuments(spaceId)]);
if (categorySpaceId.value !== spaceId) return;
categories.value = categoryResult.data || [];
categoryDocuments.value = documentResult.data || [];
} catch (error) {
if (categorySpaceId.value === spaceId) ElMessage.error(errorMessage(error));
} finally {
if (categorySpaceId.value === spaceId) categoryLoading.value = false;
}
}
function openCategory(row?: KnowledgeCategory) {
if (!ensureTenantContextConfirmed()) return;
if (!categorySpaceId.value) {
ElMessage.warning('请先选择知识空间');
return;
}
categoryEditingId.value = row?.id;
Object.assign(
categoryForm,
row
? {
code: row.code,
name: row.name,
status: row.status,
sortOrder: row.sortOrder
}
: blankCategory()
);
categoryDialog.value = true;
}
async function saveCategory() {
if (!ensureTenantContextConfirmed()) return;
if (!categorySpaceId.value) return;
saving.value = true;
try {
if (categoryEditingId.value) await updateKnowledgeCategory(categorySpaceId.value, categoryEditingId.value, categoryForm);
else await createKnowledgeCategory(categorySpaceId.value, categoryForm);
categoryDialog.value = false;
ElMessage.success('分类已保存');
await loadCategoryWorkspace();
} catch (error) {
ElMessage.error(errorMessage(error));
} finally {
saving.value = false;
}
}
async function deleteCategory(category: KnowledgeCategory) {
if (!ensureTenantContextConfirmed()) return;
if (!categorySpaceId.value) return;
try {
await ElMessageBox.confirm(`确认删除分类“${category.name}”?分类下的知识需要先转移或设为未分类。`, '删除分类', {
type: 'warning',
confirmButtonText: '确认删除'
});
if (!ensureTenantContextConfirmed()) return;
await deleteKnowledgeCategory(categorySpaceId.value, category.id);
ElMessage.success('分类已删除');
await loadCategoryWorkspace();
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(errorMessage(error));
}
}
async function assignDocumentCategory(document: KnowledgeDocument) {
if (!ensureTenantContextConfirmed()) {
await loadCategoryWorkspace();
return;
}
if (!categorySpaceId.value) return;
try {
await assignSpaceDocumentCategory(categorySpaceId.value, document.attachId, document.categoryId);
document.categoryName = categories.value.find((category) => category.id === document.categoryId)?.name || null;
ElMessage.success('知识分类已更新');
await loadCategoryWorkspace();
} catch (error) {
ElMessage.error(errorMessage(error));
await loadCategoryWorkspace();
}
}
async function loadGrants() {
if (!grantSpaceId.value) return;
try { grants.value = (await listSpaceGrants(grantSpaceId.value)).data || []; } catch (error) { ElMessage.error(errorMessage(error)); }
try {
grants.value = (await listSpaceGrants(grantSpaceId.value)).data || [];
} catch (error) {
ElMessage.error(errorMessage(error));
}
}
function addGrant() { grants.value.push({ principalType: 'ROLE', principalValue: '', permission: 'READ', status: 'ACTIVE' }); }
function addGrant() {
if (!ensureTenantContextConfirmed()) return;
grants.value.push({ principalType: 'ROLE', principalValue: '', permission: 'READ', status: 'ACTIVE' });
}
function removeGrant(index: number) {
if (!ensureTenantContextConfirmed()) return;
grants.value.splice(index, 1);
}
async function saveGrants() {
if (!ensureTenantContextConfirmed()) return;
if (!grantSpaceId.value) return;
saving.value = true;
try { grants.value = (await replaceSpaceGrants(grantSpaceId.value, grants.value)).data || []; ElMessage.success('空间授权已保存'); }
catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
try {
grants.value = (await replaceSpaceGrants(grantSpaceId.value, grants.value)).data || [];
ElMessage.success('空间授权已保存');
} catch (error) {
ElMessage.error(errorMessage(error));
} finally {
saving.value = false;
}
}
function openApp(row?: KnowledgeApp) {
if (!ensureTenantContextConfirmed()) return;
appEditingId.value = row?.id;
Object.assign(appForm, row ? { appCode: row.appCode, appName: row.appName, authType: row.authType, internalClientKey: row.internalClientKey, status: row.status, rateLimitPerMinute: row.rateLimitPerMinute, expiresTime: row.expiresTime, spaceCodes: [...row.spaceCodes] } : blankApp());
Object.assign(
appForm,
row
? {
appCode: row.appCode,
appName: row.appName,
authType: row.authType,
internalClientKey: row.internalClientKey,
status: row.status,
rateLimitPerMinute: row.rateLimitPerMinute,
expiresTime: row.expiresTime,
spaceCodes: [...row.spaceCodes]
}
: blankApp()
);
appDialog.value = true;
}
function closeAppDialog() { appDialog.value = false; Object.assign(appForm, blankApp()); }
function closeAppDialog() {
appDialog.value = false;
Object.assign(appForm, blankApp());
}
async function saveApp() {
if (!ensureTenantContextConfirmed()) return;
saving.value = true;
try {
if (appEditingId.value) {
@@ -215,26 +627,105 @@ async function saveApp() {
const result = await createKnowledgeApp(appForm);
if (result.data.token) showIssuedToken(result.data.token);
}
closeAppDialog(); ElMessage.success('调用应用已保存'); await loadAll();
} catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
closeAppDialog();
ElMessage.success('调用应用已保存');
await loadAll();
} catch (error) {
ElMessage.error(errorMessage(error));
} finally {
saving.value = false;
}
}
async function rotateToken(row: KnowledgeApp) {
if (!ensureTenantContextConfirmed()) return;
await ElMessageBox.confirm('轮换后旧令牌立即失效,是否继续?', '轮换访问令牌', { type: 'warning' });
try { showIssuedToken((await rotateKnowledgeAppToken(row.id, row.expiresTime)).data.token); }
catch (error) { ElMessage.error(errorMessage(error)); }
if (!ensureTenantContextConfirmed()) return;
try {
showIssuedToken((await rotateKnowledgeAppToken(row.id, row.expiresTime)).data.token);
} catch (error) {
ElMessage.error(errorMessage(error));
}
}
function showIssuedToken(token: string) { issuedToken.value = token; tokenDialog.value = true; }
function errorMessage(error: unknown) { return error instanceof Error && error.message ? error.message : '操作失败,请检查权限或参数'; }
function showIssuedToken(token: string) {
issuedToken.value = token;
tokenDialog.value = true;
}
function errorMessage(error: unknown) {
return error instanceof Error && error.message ? error.message : '操作失败,请检查权限或参数';
}
onMounted(loadAll);
</script>
<style scoped lang="scss">
.space-page { min-height: calc(100vh - 84px); padding: 22px; background: #f5f7fb; color: #162033; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 16px; h1 { margin: 0 0 8px; font-size: 24px; } p { margin: 0; color: #657184; } }
.space-tabs { padding: 16px 18px; border: 1px solid #e4e9f2; border-radius: 10px; background: #fff; }
.toolbar { display: flex; gap: 10px; margin-bottom: 14px; }
.token-box { margin-top: 16px; }
.document-table { margin-top: 14px; }
.space-page {
min-height: calc(100vh - 84px);
padding: 22px;
background: #f5f7fb;
color: #162033;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
h1 {
margin: 0 0 8px;
font-size: 24px;
}
p {
margin: 0;
color: #657184;
}
}
.page-head__actions {
display: flex;
align-items: center;
gap: 10px;
}
.context-warning {
margin-bottom: 16px;
}
.space-tabs {
padding: 16px 18px;
border: 1px solid #e4e9f2;
border-radius: 10px;
background: #fff;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
}
.token-box {
margin-top: 16px;
}
.document-table {
margin-top: 14px;
}
.category-hint {
margin-bottom: 14px;
}
.category-layout {
margin-top: 2px;
}
.category-panel {
min-height: 290px;
padding: 14px;
border: 1px solid #e8edf5;
border-radius: 8px;
background: #fbfcfe;
}
.category-panel h2 {
margin: 0 0 12px;
color: #27364a;
font-size: 16px;
}
@media (max-width: 1199px) {
.category-panel {
margin-bottom: 14px;
}
}
</style>