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
+597
View File
@@ -0,0 +1,597 @@
<template>
<div class="broadcast-page">
<section class="page-head">
<div>
<h1>银城大喇叭</h1>
<p>向当前租户全员公开发布公司通知。发布后立即可见;撤回会停止员工端展示,但保留发布记录。</p>
</div>
<el-button type="danger" icon="Plus" :disabled="publishing || publishPending || !scopeTenantId || !storageAvailable" @click="openPublish">发布消息</el-button>
</section>
<el-alert
v-if="loadError"
class="data-alert"
type="warning"
:closable="false"
show-icon
title="消息管理暂不可读取。发布与管理仅限 superadmin 或 hr_operator,请确认当前账号权限后重试。"
/>
<section class="table-card">
<div class="section-title">
<div>
<h2>消息管理</h2>
<p>当前支持立即发布、查看版本号和撤回;发布重试不会重复推送,撤回会保留初次审计原因。</p>
</div>
<div class="table-actions">
<el-select v-model="status" class="status-select" placeholder="全部状态" :disabled="publishing" @change="handleStatusChange">
<el-option label="全部状态" value="" />
<el-option label="已发布" value="PUBLISHED" />
<el-option label="已撤回" value="WITHDRAWN" />
</el-select>
<el-button :icon="Refresh" :loading="loading" :disabled="publishing" @click="() => void loadMessages()">刷新</el-button>
</div>
</div>
<el-table v-loading="loading" :data="messages" size="small" empty-text="暂无公司消息">
<el-table-column prop="title" label="标题" min-width="170" show-overflow-tooltip />
<el-table-column label="内容摘要" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.content }}</template>
</el-table-column>
<el-table-column label="版本" width="64" align="center">
<template #default="{ row }">v{{ row.currentVersion }}</template>
</el-table-column>
<el-table-column label="状态" width="76" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'PUBLISHED' ? 'success' : 'info'" size="small">
{{ row.status === 'PUBLISHED' ? '已发布' : '已撤回' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发布信息" width="140">
<template #default="{ row }">
<div class="audit-info">
<span>管理员 #{{ row.publishedBy }}</span>
<small class="audit-time">{{ displayTime(row.publishedAt) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="撤回信息" width="180">
<template #default="{ row }">
<div v-if="row.withdrawnAt" class="withdraw-info">
<span>管理员 #{{ row.withdrawnBy }}</span>
<small class="audit-time">{{ displayTime(row.withdrawnAt) }}</small>
<small v-if="row.withdrawReason">原因:{{ row.withdrawReason }}</small>
</div>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="76" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'PUBLISHED'" link type="danger" :loading="withdrawingId === row.id" @click="handleWithdraw(row)">
撤回
</el-button>
<span v-else class="muted">已撤回</span>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:page="pageNum" v-model:limit="pageSize" :total="total" @pagination="() => void loadMessages()" />
</section>
<el-dialog
v-model="publishVisible"
title="发布银城大喇叭消息"
width="680px"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="!publishing"
:before-close="beforeClosePublish"
>
<el-alert
class="publish-alert"
type="warning"
:closable="false"
show-icon
title="消息会立即向当前租户员工端公开,请确认内容不含敏感人员、薪酬或审批材料。"
/>
<el-alert
v-if="publishPending"
class="publish-alert"
type="error"
:closable="false"
show-icon
title="上一次发布结果尚未确认。系统已保留原请求和内容;请重试发布,或明确放弃后再新建消息。"
/>
<el-form ref="publishFormRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="标题" prop="title">
<el-input
v-model="form.title"
:disabled="publishing || publishPending"
maxlength="200"
show-word-limit
placeholder="例如:夏季服务标准通知"
/>
</el-form-item>
<el-form-item label="正文" prop="content">
<el-input
v-model="form.content"
:disabled="publishing || publishPending"
type="textarea"
:rows="10"
maxlength="10000"
show-word-limit
placeholder="请输入完整通知正文"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="publishing" @click="requestClosePublish">{{ publishPending ? '放弃并关闭' : '取消' }}</el-button>
<el-button type="danger" :loading="publishing" @click="submitPublish">{{ publishPending ? '重试发布' : '确认发布' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="ContentBroadcast" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { FormInstance } from 'element-plus';
import { onMounted, reactive, ref } from 'vue';
import {
listBroadcastAdminMessages,
publishBroadcast,
withdrawBroadcast,
type BroadcastAdminMessage,
type BroadcastStatus
} from '@/api/aihr/broadcast';
import { useUserStore } from '@/store/modules/user';
const messages = ref<BroadcastAdminMessage[]>([]);
const total = ref(0);
const pageNum = ref(1);
const pageSize = ref(20);
const status = ref<BroadcastStatus | ''>('');
const loading = ref(false);
const loadError = ref(false);
const publishVisible = ref(false);
const publishing = ref(false);
const publishPending = ref(false);
const withdrawingId = ref<number>();
const publishFormRef = ref<FormInstance>();
const form = reactive({ requestId: '', title: '', content: '' });
const userStore = useUserStore();
const scopeTenantId = ref('');
const draftTenantId = ref('');
const pendingTenantId = ref('');
const storageAvailable = ref(true);
const rules = {
title: [{ required: true, whitespace: true, message: '请输入消息标题', trigger: 'blur' }],
content: [{ required: true, whitespace: true, message: '请输入消息正文', trigger: 'blur' }]
};
const displayTime = (value?: string) => (value ? value.replace('T', ' ').slice(0, 16) : '—');
const createRequestId = () => globalThis.crypto?.randomUUID?.() ?? `broadcast-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
const pendingPublishStoragePrefix = 'aihr.broadcast.pending.v2';
type PendingPublish = { tenantId: string; requestId: string; title: string; content: string };
const isValidPendingPublish = (candidate: Partial<PendingPublish>): candidate is PendingPublish =>
typeof candidate.tenantId === 'string' && candidate.tenantId === scopeTenantId.value &&
candidate.tenantId === candidate.tenantId.trim() && candidate.tenantId.length <= 64 &&
typeof candidate.requestId === 'string' && candidate.requestId === candidate.requestId.trim() &&
/^[A-Za-z0-9._:-]{1,100}$/.test(candidate.requestId) &&
typeof candidate.title === 'string' && !!candidate.title.trim() && candidate.title.length <= 200 &&
typeof candidate.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000;
const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) =>
tenantId ? `${pendingPublishStoragePrefix}:${tenantId}:${userStore.userId || 'unknown'}` : '';
const safelyReadStorage = (storageKey: string) => {
try {
return storageKey ? localStorage.getItem(storageKey) : null;
} catch {
storageAvailable.value = false;
ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。');
return null;
}
};
const safelyRemoveStorage = (storageKey: string) => {
try {
if (storageKey) localStorage.removeItem(storageKey);
return true;
} catch {
storageAvailable.value = false;
ElMessage.warning('浏览器暂时无法清理待确认发布记录,请勿在其他窗口重复发布。');
return false;
}
};
const persistPendingPublish = () => {
pendingTenantId.value = draftTenantId.value;
const storageKey = pendingPublishStorageKey();
const pending: PendingPublish = {
tenantId: pendingTenantId.value,
requestId: form.requestId,
title: form.title,
content: form.content
};
try {
if (!storageKey) throw new Error('missing tenant scope');
localStorage.setItem(storageKey, JSON.stringify(pending));
return true;
} catch {
storageAvailable.value = false;
pendingTenantId.value = '';
ElMessage.error('无法保存待确认发布记录,请检查浏览器存储权限后重试。');
return false;
}
};
const clearPendingPublish = () => {
const cleared = safelyRemoveStorage(pendingPublishStorageKey());
if (cleared) pendingTenantId.value = '';
return cleared;
};
const readPendingPublish = (): PendingPublish | undefined => {
const storageKey = pendingPublishStorageKey(scopeTenantId.value);
const stored = safelyReadStorage(storageKey);
if (!stored) return undefined;
try {
const candidate = JSON.parse(stored) as Partial<PendingPublish>;
if (!isValidPendingPublish(candidate)) {
throw new Error('invalid pending publish');
}
return {
tenantId: candidate.tenantId,
requestId: candidate.requestId,
title: candidate.title,
content: candidate.content
};
} catch {
safelyRemoveStorage(storageKey);
return undefined;
}
};
const applyPendingPublish = (candidate: PendingPublish) => {
draftTenantId.value = candidate.tenantId;
pendingTenantId.value = candidate.tenantId;
form.requestId = candidate.requestId;
form.title = candidate.title;
form.content = candidate.content;
publishPending.value = true;
publishVisible.value = true;
};
const restorePendingPublish = () => {
if (publishPending.value && pendingTenantId.value === scopeTenantId.value) return false;
const candidate = readPendingPublish();
if (!candidate) return false;
applyPendingPublish(candidate);
return true;
};
const loadMessages = async (allowWhilePublishing = false) => {
if (publishing.value && !allowWhilePublishing) return;
loading.value = true;
loadError.value = false;
try {
const response = await listBroadcastAdminMessages({
pageNum: pageNum.value,
pageSize: pageSize.value,
status: status.value || undefined
});
const resolvedTenantId = String(response.data?.tenantId ?? '').trim();
if (!resolvedTenantId) throw new Error('missing broadcast tenant scope');
if (scopeTenantId.value && scopeTenantId.value !== resolvedTenantId && publishVisible.value) {
// A tenant switch invalidates both an unresolved request and an open draft. Keep an
// unresolved request only in its original v2 storage key; never retarget its content.
publishPending.value = false;
publishVisible.value = false;
draftTenantId.value = '';
pendingTenantId.value = '';
form.requestId = '';
form.title = '';
form.content = '';
}
scopeTenantId.value = resolvedTenantId;
restorePendingPublish();
messages.value = response.data?.rows ?? [];
total.value = response.data?.total ?? 0;
} catch {
messages.value = [];
total.value = 0;
loadError.value = true;
} finally {
loading.value = false;
}
};
const handleStatusChange = () => {
if (publishing.value) return;
pageNum.value = 1;
void loadMessages();
};
const openPublish = () => {
if (publishing.value || publishPending.value) return;
if (!storageAvailable.value) {
ElMessage.error('浏览器存储不可用,无法安全发起公司消息发布。');
return;
}
if (!scopeTenantId.value) {
ElMessage.warning('正在确认当前租户,请稍候。');
return;
}
if (restorePendingPublish()) {
ElMessage.warning('检测到同一租户存在结果未确认的发布,请先重试或明确放弃。');
return;
}
if (!storageAvailable.value) return;
form.requestId = createRequestId();
draftTenantId.value = scopeTenantId.value;
form.title = '';
form.content = '';
publishVisible.value = true;
};
const resetPublishForm = () => {
if (publishPending.value && !clearPendingPublish()) return false;
publishPending.value = false;
draftTenantId.value = '';
form.requestId = '';
form.title = '';
form.content = '';
publishFormRef.value?.clearValidate();
return true;
};
const confirmDiscardPendingPublish = async () => {
if (!publishPending.value) return true;
try {
await ElMessageBox.confirm(
'上一次发布结果尚未确认。继续重试会复用同一请求;放弃后若该请求其实已成功,重新发布可能产生另一条消息。',
'确认放弃未确认发布?',
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续重试', type: 'warning' }
);
return true;
} catch {
return false;
}
};
const requestClosePublish = async () => {
if (publishing.value || !(await confirmDiscardPendingPublish())) return;
if (!resetPublishForm()) return;
publishVisible.value = false;
};
const beforeClosePublish = (done: () => void) => {
void (async () => {
if (publishing.value || !(await confirmDiscardPendingPublish())) return;
if (!resetPublishForm()) return;
done();
})();
};
const submitPublish = async () => {
if (!publishFormRef.value) return;
const valid = await publishFormRef.value
.validate()
.then(() => true)
.catch(() => false);
if (!valid) return;
if (!scopeTenantId.value) {
ElMessage.warning('当前租户尚未确认,请刷新页面后重试。');
return;
}
if (!draftTenantId.value || draftTenantId.value !== scopeTenantId.value) {
ElMessage.warning('当前租户已变化,已取消这条未发布草稿;请刷新后重新填写。');
publishVisible.value = false;
form.requestId = '';
form.title = '';
form.content = '';
draftTenantId.value = '';
return;
}
const otherPending = readPendingPublish();
if (!storageAvailable.value) return;
if (otherPending && otherPending.requestId !== form.requestId) {
applyPendingPublish(otherPending);
ElMessage.warning('检测到另一窗口存在结果未确认的发布,请先重试或明确放弃。');
return;
}
if (!form.requestId) form.requestId = createRequestId();
form.title = form.title.trim();
form.content = form.content.trim();
publishPending.value = true;
if (!persistPendingPublish()) {
publishPending.value = false;
return;
}
publishing.value = true;
try {
await publishBroadcast({
requestId: form.requestId,
title: form.title,
content: form.content,
expectedTenantId: pendingTenantId.value
});
if (!resetPublishForm()) {
ElMessage.warning('消息已发布,但浏览器无法清理重试记录;请不要新建消息,先恢复浏览器存储后重试原请求。');
return;
}
publishVisible.value = false;
ElMessage.success('消息已发布');
pageNum.value = 1;
await loadMessages(true);
} catch {
ElMessage.error('发布结果暂未确认,请使用“重试发布”复用原请求,或确认放弃。');
} finally {
publishing.value = false;
}
};
const handleWithdraw = async (message: BroadcastAdminMessage) => {
let reason = '';
try {
const result = await ElMessageBox.prompt(`撤回“${message.title}”后,员工端将不再展示该消息。请填写撤回原因。`, '确认撤回', {
confirmButtonText: '确认撤回',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '例如:内容需要修订后重新发布',
inputValidator: (value) => {
const normalized = String(value ?? '').trim();
if (!normalized) return '请填写撤回原因';
if (normalized.length > 500) return '撤回原因不能超过 500 个字符';
return true;
},
type: 'warning'
});
reason = String(result.value ?? '').trim();
} catch {
return;
}
withdrawingId.value = message.id;
try {
if (!scopeTenantId.value) {
ElMessage.warning('当前租户尚未确认,请刷新页面后重试。');
return;
}
await withdrawBroadcast(message.id, { reason, expectedTenantId: scopeTenantId.value });
ElMessage.success('消息已撤回');
await loadMessages();
} finally {
withdrawingId.value = undefined;
}
};
onMounted(() => {
void loadMessages();
});
</script>
<style scoped lang="scss">
.broadcast-page {
min-height: calc(100vh - 84px);
padding: 24px 28px 40px;
background: #f4f6fa;
color: #172033;
}
.page-head,
.section-title,
.table-actions {
display: flex;
align-items: center;
}
.page-head,
.section-title {
justify-content: space-between;
}
.page-head {
align-items: flex-start;
gap: 18px;
margin-bottom: 18px;
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 24px;
}
p {
margin-top: 6px;
color: #858991;
font-size: 13px;
}
}
.data-alert,
.publish-alert {
margin-bottom: 12px;
}
.table-card {
padding: 18px;
border-radius: 8px;
background: #fff;
.section-title {
gap: 16px;
margin-bottom: 16px;
}
h2 {
margin: 0;
font-size: 18px;
}
p,
.muted {
color: #858991;
font-size: 13px;
}
.audit-info,
.withdraw-info {
display: grid;
gap: 3px;
small {
color: #858991;
font-size: 12px;
}
.audit-time {
white-space: nowrap;
}
}
p {
margin: 6px 0 0;
}
:deep(.el-table th.el-table__cell) {
color: #667085;
font-weight: 500;
background: #fafbfc;
}
}
.table-actions {
flex: none;
gap: 10px;
}
.status-select {
width: 126px;
}
@media (max-width: 900px) {
.page-head,
.section-title {
align-items: stretch;
flex-direction: column;
}
.table-actions {
justify-content: space-between;
}
}
@media (max-width: 760px) {
.broadcast-page {
padding: 16px;
}
}
</style>
File diff suppressed because it is too large Load Diff
+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>
@@ -0,0 +1,301 @@
<template>
<div class="work-report-page">
<section class="page-head">
<div>
<h1>成果投稿审核</h1>
<p>审核员工提交的案例、视频、SOP 和知识素材;通过后仍需在对应业务库完成最终入库。</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadReports">刷新</el-button>
</section>
<el-alert v-if="loadError" class="data-alert" type="warning" :closable="false" show-icon title="待审核投稿暂不可读取,页面未展示本地演示数据。" />
<section class="table-card">
<div class="section-title">
<div>
<h2>待审核投稿</h2>
<p>当前仅加载状态为 PENDING 的投稿。</p>
</div>
<el-tag type="warning">{{ reports.length }} 条</el-tag>
</div>
<el-table v-loading="loading" :data="reports" size="small" empty-text="暂无待审核投稿">
<el-table-column prop="typeLabel" label="类型" width="120">
<template #default="{ row }">{{ row.typeLabel || row.type }}</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="220" show-overflow-tooltip />
<el-table-column prop="submitterName" label="投稿人" width="120" />
<el-table-column prop="attachmentName" label="附件" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.attachmentName || '无附件' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="提交时间" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="openReview(row)">审核</el-button>
</template>
</el-table-column>
</el-table>
</section>
<el-drawer v-model="drawerVisible" :title="selectedReport?.title || '成果投稿审核'" size="560px">
<template v-if="selectedReport">
<div class="report-meta">
<el-tag>{{ selectedReport.typeLabel || selectedReport.type }}</el-tag>
<span>{{ selectedReport.submitterName }} · {{ selectedReport.createdAt }}</span>
</div>
<h2>{{ selectedReport.title }}</h2>
<p class="report-content">{{ selectedReport.content }}</p>
<div v-if="selectedReport.attachmentName" class="attachment-name">
<span>附件:{{ selectedReport.attachmentName }}</span>
<el-button
v-if="selectedReport.attachmentAvailable && selectedReport.attachmentUrl"
link
type="primary"
:loading="attachmentLoading"
@click="viewAttachment"
>
受控查看
</el-button>
<span v-else class="attachment-unavailable">附件暂不可读取</span>
</div>
<el-alert
v-if="requiresAttachmentReview && !attachmentViewed"
class="attachment-alert"
type="warning"
:closable="false"
show-icon
title="请先通过受控查看读取投稿附件,再审核通过。"
/>
<el-form label-position="top" class="review-form">
<el-form-item label="审核意见">
<el-input v-model="reviewNote" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="通过可选填;驳回请说明原因" />
</el-form-item>
</el-form>
<div class="review-actions">
<el-button type="danger" plain :loading="reviewing" @click="submitReview('REJECT')">驳回</el-button>
<el-button type="danger" :loading="reviewing" :disabled="requiresAttachmentReview && !attachmentViewed" @click="submitReview('APPROVE')">
审核通过
</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup name="OperationsWorkReportReview" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import { getWorkReportAttachment, listWorkReports, reviewWorkReport, type WorkReport } from '@/api/aihr/work-report';
const reports = ref<WorkReport[]>([]);
const selectedReport = ref<WorkReport>();
const loading = ref(false);
const reviewing = ref(false);
const loadError = ref(false);
const drawerVisible = ref(false);
const reviewNote = ref('');
const attachmentLoading = ref(false);
const attachmentViewed = ref(false);
const requiresAttachmentReview = computed(() => Boolean(selectedReport.value?.attachmentName) || selectedReport.value?.type === 'VIDEO');
const loadReports = async () => {
loading.value = true;
loadError.value = false;
try {
const response = await listWorkReports('PENDING');
reports.value = response.data ?? [];
} catch {
reports.value = [];
loadError.value = true;
} finally {
loading.value = false;
}
};
const openReview = (report: WorkReport) => {
selectedReport.value = report;
reviewNote.value = '';
attachmentViewed.value = false;
drawerVisible.value = true;
};
const viewAttachment = async () => {
const report = selectedReport.value;
if (!report?.attachmentAvailable || !report.attachmentUrl) {
ElMessage.warning('该附件暂不可读取');
return;
}
attachmentLoading.value = true;
try {
const blob = await getWorkReportAttachment(report.attachmentUrl);
if (!blob.size) throw new Error('empty attachment');
attachmentViewed.value = true;
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
} catch {
ElMessage.error('附件读取失败,请稍后重试');
} finally {
attachmentLoading.value = false;
}
};
const submitReview = async (decision: 'APPROVE' | 'REJECT') => {
if (!selectedReport.value) return;
const note = reviewNote.value.trim();
if (decision === 'REJECT' && !note) {
ElMessage.warning('驳回时请填写审核意见');
return;
}
reviewing.value = true;
try {
await reviewWorkReport(selectedReport.value.id, { decision, note: note || undefined });
drawerVisible.value = false;
ElMessage.success(decision === 'APPROVE' ? '投稿已审核通过' : '投稿已驳回');
await loadReports();
} catch {
ElMessage.error('审核提交失败,请重试');
} finally {
reviewing.value = false;
}
};
onMounted(loadReports);
</script>
<style scoped lang="scss">
.work-report-page {
min-height: calc(100vh - 84px);
padding: 24px 28px 40px;
background: #f4f6fa;
color: #172033;
}
.page-head,
.section-title,
.report-meta,
.review-actions {
display: flex;
align-items: center;
}
.page-head,
.section-title {
justify-content: space-between;
}
.page-head {
align-items: flex-start;
margin-bottom: 18px;
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 24px;
}
p {
margin-top: 6px;
color: #858991;
font-size: 13px;
}
}
.section-title p {
margin-top: 6px;
color: #858991;
font-size: 13px;
}
.data-alert {
margin-bottom: 12px;
}
.table-card {
padding: 18px;
border-radius: 8px;
background: #fff;
.section-title {
margin-bottom: 16px;
}
h2 {
font-size: 18px;
}
:deep(.el-table th.el-table__cell) {
color: #667085;
font-weight: 500;
background: #fafbfc;
}
}
.report-meta,
.review-actions {
gap: 10px;
}
.report-meta {
margin-bottom: 20px;
color: #667085;
font-size: 13px;
}
.report-content {
min-height: 120px;
color: #344054;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.attachment-name {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
background: #f7f8fa;
color: #667085;
}
.attachment-unavailable {
color: #c0781a;
font-size: 13px;
}
.attachment-alert {
margin-top: 14px;
}
.review-form {
margin-top: 24px;
}
.review-actions {
justify-content: flex-end;
margin-top: 22px;
}
@media (max-width: 760px) {
.work-report-page {
padding: 16px;
}
.page-head {
gap: 12px;
flex-direction: column;
}
}
</style>
@@ -0,0 +1,422 @@
<template>
<div class="work-results-page">
<section class="page-head">
<div>
<h1>项目工作成果</h1>
<p>按项目和自然日汇总员工已确认的工作助手记录,主管范围由服务端按当前身份复核。</p>
</div>
<el-button :icon="Refresh" :loading="projectsLoading || resultLoading" @click="refresh">刷新</el-button>
</section>
<el-alert
v-if="projectsError"
class="data-alert"
type="warning"
:closable="false"
show-icon
title="项目列表暂未返回,无法查询团队工作成果。请完成项目权限配置后刷新。"
/>
<el-alert
v-else-if="resultError"
class="data-alert"
type="warning"
:closable="false"
show-icon
title="所选项目的工作成果暂不可读取,请刷新后重试。"
/>
<section class="filter-card">
<el-select
v-model="projectCode"
class="project-select"
filterable
placeholder="请选择项目"
:loading="projectsLoading"
:disabled="!projects.length"
@change="resetAndLoad"
>
<el-option v-for="project in projects" :key="project.projectCode" :label="project.projectName" :value="project.projectCode" />
</el-select>
<el-date-picker v-model="workDate" type="date" value-format="YYYY-MM-DD" placeholder="工作日期" :disabled-date="disableFutureDate" />
<el-button type="danger" :loading="resultLoading" :disabled="!projectCode" @click="resetAndLoad">查询</el-button>
</section>
<el-empty v-if="!projectsLoading && !projects.length" class="empty-state" description="暂无可用项目;页面不会使用默认或猜测的项目编码。" />
<template v-else-if="projectResult">
<section class="metric-grid">
<div class="metric-card">
<span>贡献员工</span>
<strong>{{ projectResult.contributorCount }}</strong>
</div>
<div class="metric-card">
<span>已汇总成果</span>
<strong>{{ projectResult.resultCount }}</strong>
</div>
<div class="metric-card warning">
<span>待跟进</span>
<strong>{{ projectResult.pendingCount }}</strong>
</div>
<div class="metric-card danger">
<span>高优先级</span>
<strong>{{ projectResult.highPriorityCount }}</strong>
</div>
</section>
<section class="table-card">
<div class="section-title">
<div>
<h2>{{ projectResult.projectName }}</h2>
<p>
{{ projectResult.workDate }} · 第 {{ projectResult.pageNum }} 页展示 {{ projectResult.members.length }} /
{{ projectResult.contributorCount }} 位员工的已确认记录
</p>
</div>
<el-tag type="info">{{ projectResult.projectCode }}</el-tag>
</div>
<el-table :data="projectResult.members" size="small" empty-text="该日期暂无已确认工作成果">
<el-table-column type="expand" width="48">
<template #default="{ row }">
<div class="member-details">
<div v-for="section in resultSections(row.sections)" :key="section.key" class="result-section">
<strong>{{ sectionLabel(section.key) }}({{ section.items.length }})</strong>
<div v-if="section.items.length" class="result-list">
<div v-for="item in section.items" :key="item.captureId" class="result-item">
<span>{{ item.summary }}</span>
<small>{{ item.occurredAt }} · {{ item.category || item.suggestedType }}</small>
</div>
</div>
<span v-else class="muted">暂无记录</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="ownerName" label="员工" min-width="130" />
<el-table-column prop="total" label="成果数" width="100" />
<el-table-column label="待跟进" width="110">
<template #default="{ row }">
<el-tag :type="row.pending ? 'warning' : 'success'" size="small">{{ row.pending }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="88" />
<el-table-column prop="generatedAt" label="汇总时间" min-width="180" />
</el-table>
<el-pagination
v-if="projectResult.contributorCount > projectResult.pageSize"
class="member-pagination"
background
layout="prev, pager, next"
:current-page="projectResult.pageNum"
:page-size="projectResult.pageSize"
:total="projectResult.contributorCount"
@current-change="changePage"
/>
</section>
</template>
<el-empty v-else-if="!projectsLoading && projectCode && !resultLoading" class="empty-state" description="请选择日期后查询项目工作成果。" />
</div>
</template>
<script setup name="OperationsWorkResults" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { onMounted, ref, watch } from 'vue';
import {
getProjectWorkResult,
listWorkResultProjects,
type ProjectWorkResult,
type WorkResultItem,
type WorkResultProject
} from '@/api/aihr/work-results';
const localDate = () => {
const date = new Date();
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
const projects = ref<WorkResultProject[]>([]);
const projectCode = ref('');
const workDate = ref(localDate());
const projectResult = ref<ProjectWorkResult>();
const pageNum = ref(1);
const pageSize = 20;
const projectsLoading = ref(false);
const resultLoading = ref(false);
const projectsError = ref(false);
const resultError = ref(false);
const disableFutureDate = (date: Date) => date.getTime() > Date.now();
const sectionLabels: Record<string, string> = {
completed: '已完成',
inspection: '巡检',
services: '服务记录',
leads: '服务线索',
attendance: '考勤',
pending: '待跟进'
};
const sectionLabel = (section: string) => sectionLabels[section] || section;
const resultSections = (sections: Record<string, WorkResultItem[]>) => Object.entries(sections).map(([key, items]) => ({ key, items }));
const loadProjects = async () => {
projectsLoading.value = true;
projectsError.value = false;
try {
const response = await listWorkResultProjects();
projects.value = response.data ?? [];
if (!projects.value.some((project) => project.projectCode === projectCode.value)) {
projectCode.value = projects.value[0]?.projectCode || '';
}
} catch {
projects.value = [];
projectCode.value = '';
projectResult.value = undefined;
projectsError.value = true;
} finally {
projectsLoading.value = false;
}
};
const loadProjectResult = async () => {
if (!projectCode.value) return;
resultLoading.value = true;
resultError.value = false;
try {
const response = await getProjectWorkResult({
projectCode: projectCode.value,
workDate: workDate.value,
pageNum: pageNum.value,
pageSize
});
projectResult.value = response.data;
} catch {
projectResult.value = undefined;
resultError.value = true;
} finally {
resultLoading.value = false;
}
};
const resetAndLoad = () => {
pageNum.value = 1;
return loadProjectResult();
};
const changePage = (page: number) => {
pageNum.value = page;
return loadProjectResult();
};
const refresh = async () => {
await loadProjects();
if (projectCode.value) await loadProjectResult();
};
watch(workDate, () => {
projectResult.value = undefined;
pageNum.value = 1;
});
onMounted(async () => {
await loadProjects();
if (projectCode.value) await loadProjectResult();
});
</script>
<style scoped lang="scss">
.work-results-page {
min-height: calc(100vh - 84px);
padding: 24px 28px 40px;
background: #f4f6fa;
color: #172033;
}
.page-head,
.filter-card,
.section-title {
display: flex;
align-items: center;
}
.page-head,
.section-title {
justify-content: space-between;
}
.page-head {
align-items: flex-start;
margin-bottom: 18px;
h1,
p,
h2 {
margin: 0;
}
h1 {
font-size: 24px;
}
p {
margin-top: 6px;
color: #858991;
font-size: 13px;
}
}
.section-title p,
.muted {
margin-top: 6px;
color: #858991;
font-size: 13px;
}
.data-alert {
margin-bottom: 12px;
}
.filter-card,
.table-card {
padding: 18px;
border-radius: 8px;
background: #fff;
}
.filter-card {
gap: 12px;
margin-bottom: 16px;
}
.project-select {
min-width: 280px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.member-pagination {
justify-content: flex-end;
margin-top: 16px;
}
.metric-card {
padding: 16px 18px;
border-radius: 8px;
background: #fff;
span,
strong {
display: block;
}
span {
color: #858991;
font-size: 13px;
}
strong {
margin-top: 8px;
color: #253044;
font-size: 26px;
}
&.warning strong {
color: #d97706;
}
&.danger strong {
color: #d92d20;
}
}
.table-card {
.section-title {
margin-bottom: 16px;
}
h2 {
font-size: 18px;
}
:deep(.el-table th.el-table__cell) {
color: #667085;
font-weight: 500;
background: #fafbfc;
}
}
.member-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 4px 16px 12px;
}
.result-section {
min-width: 0;
padding: 12px;
border-radius: 6px;
background: #fafbfc;
}
.result-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.result-item {
span,
small {
display: block;
}
span {
overflow-wrap: anywhere;
line-height: 1.5;
}
small {
margin-top: 3px;
color: #858991;
}
}
.empty-state {
padding: 44px 0;
border-radius: 8px;
background: #fff;
}
@media (max-width: 960px) {
.work-results-page {
padding: 16px;
}
.filter-card,
.page-head {
align-items: stretch;
flex-direction: column;
}
.project-select {
min-width: 0;
}
.metric-grid,
.member-details {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.metric-grid,
.member-details {
grid-template-columns: 1fr;
}
}
</style>
+75 -2
View File
@@ -1,5 +1,12 @@
<template>
<div class="p-2">
<div class="tenant-governance p-2">
<section class="tenant-governance__head">
<div>
<h1>租户管理</h1>
<p>维护租户主体,并按“租户 → 知识空间 → 分类 → 知识”进入内容治理。</p>
</div>
<div class="tenant-governance__hint">知识空间、分类和知识仍按当前切换租户隔离管理。</div>
</section>
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
<div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover">
@@ -72,8 +79,18 @@
<el-switch v-model="scope.row.status" active-value="0" inactive-value="1" @change="handleStatusChange(scope.row)"></el-switch>
</template>
</el-table-column>
<el-table-column width="150" label="操作" align="center" fixed="right" class-name="small-padding fixed-width">
<el-table-column width="230" label="操作" align="center" fixed="right" class-name="small-padding fixed-width">
<template #default="scope">
<el-button
v-hasPermi="['system:tenant:edit']"
link
type="primary"
:loading="tenantContextSwitching"
:disabled="tenantContextSwitching || scope.row.status !== '0'"
:title="scope.row.status === '0' ? '进入此租户的知识维护' : '停用租户不能维护知识'"
@click="manageKnowledge(scope.row)"
>知识维护</el-button
>
<el-tooltip content="修改" placement="top">
<el-button v-hasPermi="['system:tenant:edit']" link type="primary" icon="Edit" @click="handleUpdate(scope.row)"></el-button>
</el-tooltip>
@@ -160,6 +177,7 @@ import {
} from '@/api/system/tenant';
import { selectTenantPackage } from '@/api/system/tenantPackage';
import { useUserStore } from '@/store/modules/user';
import { switchDynamicTenantContext, tenantContextSwitching } from '@/utils/tenant-context';
import { TenantForm, TenantQuery, TenantVO } from '@/api/system/tenant/types';
import { TenantPkgVO } from '@/api/system/tenantPackage/types';
@@ -232,6 +250,21 @@ const data = reactive<PageData<TenantForm, TenantQuery>>({
const { queryParams, form, rules } = toRefs(data);
/** 切换到目标租户后再进入空间、分类和文档维护。后端以动态租户上下文为准。 */
const manageKnowledge = async (row: TenantVO) => {
if (!row.tenantId || tenantContextSwitching.value) return;
if (row.status !== '0') {
proxy?.$modal.msgError('停用租户不能维护知识');
return;
}
try {
await switchDynamicTenantContext({ tenantId: row.tenantId, companyName: row.companyName }, '/knowledge/spaces');
proxy?.$modal.msgSuccess(`已切换至“${row.companyName}”,可继续维护知识空间、分类和知识`);
} catch (error) {
proxy?.$modal.msgError(error instanceof Error && error.message ? error.message : '租户切换失败,请刷新后重试');
}
};
/** 查询所有租户套餐 */
const getTenantPackage = async () => {
const res = await selectTenantPackage();
@@ -380,3 +413,43 @@ onMounted(() => {
getList();
});
</script>
<style scoped lang="scss">
.tenant-governance__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 8px 2px 16px;
h1 {
margin: 0 0 6px;
color: #162033;
font-size: 24px;
line-height: 1.25;
}
p {
margin: 0;
color: #657184;
}
}
.tenant-governance__hint {
max-width: 420px;
padding: 8px 12px;
border: 1px solid #dbe7fb;
border-radius: 8px;
color: #39699f;
background: #f4f8ff;
font-size: 13px;
line-height: 1.5;
}
@media (max-width: 760px) {
.tenant-governance__head {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+7 -5
View File
@@ -294,7 +294,7 @@ import { UserForm, UserQuery, UserVO } from '@/api/system/user/types';
import { DeptTreeVO, DeptVO } from '@/api/system/dept/types';
import { RoleVO } from '@/api/system/role/types';
import { PostVO } from '@/api/system/post/types';
import { globalHeaders } from '@/utils/request';
import { globalHeaders, reconcileTenantContextResponse } from '@/utils/request';
import { to } from 'await-to-js';
import { optionselect } from '@/api/system/post';
import { checkPermi } from '@/utils/permission';
@@ -332,6 +332,9 @@ const upload = reactive<ImportOption>({
// 上传的地址
url: import.meta.env.VITE_APP_BASE_API + '/system/user/importData'
});
watchEffect(() => {
upload.headers = globalHeaders();
});
// 列显隐信息
const columns = ref<FieldOption[]>([
{ key: 0, label: `用户编号`, visible: false, children: [] },
@@ -570,13 +573,14 @@ const handleFileUploadProgress = () => {
};
/** 文件上传成功处理 */
const handleFileSuccess = (response: any, file: UploadFile) => {
const tenantContextChanged = reconcileTenantContextResponse(response);
upload.open = false;
upload.isUploading = false;
uploadRef.value?.handleRemove(file);
ElMessageBox.alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + '</div>', '导入结果', {
dangerouslyUseHTMLString: true
});
getList();
if (!tenantContextChanged) getList();
};
/** 提交上传文件 */
@@ -615,9 +619,7 @@ const handleUpdate = async (row?: UserForm) => {
dialog.title = '修改用户';
Object.assign(form.value, data.user);
postOptions.value = data.posts;
roleOptions.value = Array.from(
new Map([...data.roles, ...data.user.roles].map(role => [role.roleId, role])).values()
);
roleOptions.value = Array.from(new Map([...data.roles, ...data.user.roles].map((role) => [role.roleId, role])).values());
form.value.postIds = data.postIds;
form.value.roleIds = data.roleIds;
form.value.password = '';