feat(aihr): 重构运营后台与租户知识治理
- 重构运营总览、侧栏和内容运营工作台\n- 增加大喇叭、成果审核与运营统计链路\n- 补齐租户、知识空间、分类和知识维护闭环\n- 加固标签页租户上下文、停用租户写保护与迁移预检
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user