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
+2 -2
View File
@@ -1,6 +1,6 @@
# 页面标题
VITE_APP_TITLE = 物业AI人力资源系统
VITE_APP_LOGO_TITLE = 物业AI人力资源系统
VITE_APP_TITLE = 银城帮道
VITE_APP_LOGO_TITLE = 银城帮道
# 开发环境配置
VITE_APP_ENV = 'development'
+2 -2
View File
@@ -1,6 +1,6 @@
# 页面标题
VITE_APP_TITLE = 物业AI人力资源系统
VITE_APP_LOGO_TITLE = 物业AI人力资源系统
VITE_APP_TITLE = 银城帮道
VITE_APP_LOGO_TITLE = 银城帮道
# 生产环境配置
VITE_APP_ENV = 'production'
+71
View File
@@ -0,0 +1,71 @@
import request from '@/utils/request';
type ApiResult<T> = {
code: number;
msg: string;
data: T;
};
export type BroadcastStatus = 'PUBLISHED' | 'WITHDRAWN';
export type BroadcastAdminMessage = {
id: number;
title: string;
content: string;
status: BroadcastStatus;
currentVersion: number;
publishedBy: number;
publishedAt: string;
withdrawnBy?: number;
withdrawnAt?: string;
withdrawReason?: string;
};
export type BroadcastAdminPage = {
/** Effective request tenant, including an administrator's dynamic-tenant selection. */
tenantId: string;
total: number;
pageNum: number;
pageSize: number;
rows: BroadcastAdminMessage[];
};
export type PublishBroadcastRequest = {
requestId: string;
title: string;
content: string;
expectedTenantId: string;
};
export type WithdrawBroadcastRequest = {
reason: string;
expectedTenantId: string;
};
export function listBroadcastAdminMessages(params: {
pageNum: number;
pageSize: number;
status?: BroadcastStatus;
}): Promise<ApiResult<BroadcastAdminPage>> {
return request({
url: '/api/aihr/broadcast/admin/messages',
method: 'get',
params
});
}
export function publishBroadcast(data: PublishBroadcastRequest): Promise<ApiResult<{ id: number; version: number }>> {
return request({
url: '/api/aihr/broadcast/messages',
method: 'post',
data
});
}
export function withdrawBroadcast(id: number, data: WithdrawBroadcastRequest): Promise<ApiResult<void>> {
return request({
url: `/api/aihr/broadcast/messages/${id}/withdraw`,
method: 'post',
data
});
}
+45 -37
View File
@@ -6,27 +6,44 @@ type ApiResult<T> = {
data: T;
};
export type DashboardHeroEntry = {
export type DashboardState = 'success' | 'warning' | 'danger' | 'info';
export type DashboardRecordState = DashboardState | 'active' | 'done';
export type DashboardTodo = {
key: string;
status: string;
type: string;
note: string;
};
export type DashboardStage = {
step: number;
name: string;
desc: string;
label: string;
state: string;
tagType: string;
count: number;
detail: string;
path: string;
actionLabel: string;
tone: DashboardState;
available?: boolean;
};
export type DashboardRisk = {
name: string;
owner: string;
status: string;
type: string;
export type DashboardPilotProgress = {
key: string;
label: string;
value: string;
detail: string;
state: DashboardState;
available?: boolean;
};
export type DashboardAcceptance = {
key: string;
label: string;
detail: string;
state: DashboardState;
};
export type DashboardKnowledge = {
key: string;
label: string;
value: string;
detail: string;
tone: DashboardState;
path: string;
available?: boolean;
};
export type DashboardTrainingRecord = {
@@ -34,36 +51,27 @@ export type DashboardTrainingRecord = {
scene: string;
score: number;
status: string;
state: string;
state: DashboardRecordState;
time: string;
};
export type DashboardPipelineStep = {
name: string;
count: string;
state: string;
};
export type DashboardMetric = {
export type DashboardHealth = {
key: string;
label: string;
value: string;
target: string;
type: string;
detail: string;
state: DashboardState;
available?: boolean;
};
export type DashboardOverview = {
snapshotTime: string;
heroEntries: DashboardHeroEntry[];
stages: DashboardStage[];
risks: DashboardRisk[];
todos: DashboardTodo[];
pilotProgress: DashboardPilotProgress[];
acceptance: DashboardAcceptance[];
knowledge: DashboardKnowledge[];
records: DashboardTrainingRecord[];
pipeline: DashboardPipelineStep[];
pipelineSummary: {
todayNew: string;
pending: string;
completed: string;
};
metrics: DashboardMetric[];
health: DashboardHealth[];
};
export function getDashboardOverview(): Promise<ApiResult<DashboardOverview>> {
+17 -8
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import request, { globalHeaders } from '@/utils/request';
import request, { globalHeaders, reconcileTenantContextBlobResponse } from '@/utils/request';
type ApiResult<T> = {
code: number;
@@ -210,13 +210,22 @@ export function getPracticeReview(id: number): Promise<ApiResult<ReviewDetail>>
}
export async function getPracticeAudioBlobUrl(ossId: number | string): Promise<string> {
const response = await axios({
method: 'get',
url: `${import.meta.env.VITE_APP_BASE_API}/api/aihr/mobile/oss/${ossId}`,
responseType: 'blob',
headers: globalHeaders()
});
return URL.createObjectURL(response.data);
try {
const response = await axios({
method: 'get',
url: `${import.meta.env.VITE_APP_BASE_API}/api/aihr/mobile/oss/${ossId}`,
responseType: 'blob',
headers: globalHeaders()
});
const contentType = String(response.headers['content-type'] || '');
if (contentType.includes('application/json') && (await reconcileTenantContextBlobResponse(response.data))) {
throw new Error('租户上下文已变更,请刷新后重试');
}
return URL.createObjectURL(response.data);
} catch (error) {
await reconcileTenantContextBlobResponse(axios.isAxiosError(error) ? error.response?.data : undefined);
throw error;
}
}
export function markPracticeReviewed(id: number, data: ReviewAdviceRequest): Promise<ApiResult<null>> {
+30 -4
View File
@@ -29,11 +29,24 @@ export type KnowledgeDocument = {
name: string;
type: string;
status: string;
categoryId?: number | null;
categoryName?: string | null;
ossId?: number | null;
fragmentCount: number;
updateTime?: string | null;
};
export type KnowledgeCategory = {
id: number;
code: string;
name: string;
status: 'ACTIVE' | 'DISABLED';
sortOrder: number;
documentCount: number;
};
export type CategoryForm = Pick<KnowledgeCategory, 'code' | 'name' | 'status' | 'sortOrder'>;
export type UnbindDocumentResult = {
attachId: number;
spaceCode: string;
@@ -68,8 +81,7 @@ export type AppForm = {
export type IssuedToken = { appCode: string; token: string; expiresTime?: string | null };
export type CreatedApp = { app: KnowledgeApp; token?: string | null };
export const listKnowledgeSpaces = (): Promise<ApiResult<KnowledgeSpace[]>> =>
request({ url: '/api/knowledge/admin/spaces', method: 'get' });
export const listKnowledgeSpaces = (): Promise<ApiResult<KnowledgeSpace[]>> => request({ url: '/api/knowledge/admin/spaces', method: 'get' });
export const createKnowledgeSpace = (data: SpaceForm): Promise<ApiResult<KnowledgeSpace>> =>
request({ url: '/api/knowledge/admin/spaces', method: 'post', data });
@@ -80,17 +92,31 @@ export const updateKnowledgeSpace = (id: number, data: SpaceForm): Promise<ApiRe
export const listSpaceDocuments = (id: number): Promise<ApiResult<KnowledgeDocument[]>> =>
request({ url: `/api/knowledge/admin/spaces/${id}/documents`, method: 'get' });
export const assignSpaceDocumentCategory = (id: number, attachId: number, categoryId?: number | null): Promise<ApiResult<KnowledgeDocument>> =>
request({ url: `/api/knowledge/admin/spaces/${id}/documents/${attachId}/category`, method: 'put', data: { categoryId: categoryId ?? null } });
export const unbindSpaceDocument = (id: number, attachId: number): Promise<ApiResult<UnbindDocumentResult>> =>
request({ url: `/api/knowledge/admin/spaces/${id}/documents/${attachId}`, method: 'delete' });
export const listKnowledgeCategories = (id: number): Promise<ApiResult<KnowledgeCategory[]>> =>
request({ url: `/api/knowledge/admin/spaces/${id}/categories`, method: 'get' });
export const createKnowledgeCategory = (id: number, data: CategoryForm): Promise<ApiResult<KnowledgeCategory>> =>
request({ url: `/api/knowledge/admin/spaces/${id}/categories`, method: 'post', data });
export const updateKnowledgeCategory = (id: number, categoryId: number, data: CategoryForm): Promise<ApiResult<KnowledgeCategory>> =>
request({ url: `/api/knowledge/admin/spaces/${id}/categories/${categoryId}`, method: 'put', data });
export const deleteKnowledgeCategory = (id: number, categoryId: number): Promise<ApiResult<void>> =>
request({ url: `/api/knowledge/admin/spaces/${id}/categories/${categoryId}`, method: 'delete' });
export const listSpaceGrants = (id: number): Promise<ApiResult<SpaceGrant[]>> =>
request({ url: `/api/knowledge/admin/spaces/${id}/grants`, method: 'get' });
export const replaceSpaceGrants = (id: number, grants: SpaceGrant[]): Promise<ApiResult<SpaceGrant[]>> =>
request({ url: `/api/knowledge/admin/spaces/${id}/grants`, method: 'put', data: { grants } });
export const listKnowledgeApps = (): Promise<ApiResult<KnowledgeApp[]>> =>
request({ url: '/api/knowledge/admin/apps', method: 'get' });
export const listKnowledgeApps = (): Promise<ApiResult<KnowledgeApp[]>> => request({ url: '/api/knowledge/admin/apps', method: 'get' });
export const createKnowledgeApp = (data: AppForm): Promise<ApiResult<CreatedApp>> =>
request({ url: '/api/knowledge/admin/apps', method: 'post', data });
+53
View File
@@ -0,0 +1,53 @@
import request from '@/utils/request';
type ApiResult<T> = {
code: number;
msg: string;
data: T;
};
export type WorkReport = {
id: number;
type: string;
typeLabel: string;
title: string;
content: string;
attachmentName?: string;
attachmentAvailable?: boolean;
attachmentUrl?: string;
status: string;
statusLabel: string;
submitterName: string;
reviewNote?: string;
createdAt: string;
reviewedAt?: string;
};
export type WorkReportReview = {
decision: 'APPROVE' | 'REJECT';
note?: string;
};
export function listWorkReports(status?: string): Promise<ApiResult<WorkReport[]>> {
return request({
url: '/api/aihr/work-report/reports',
method: 'get',
params: { status }
});
}
export function reviewWorkReport(id: number, data: WorkReportReview): Promise<ApiResult<WorkReport>> {
return request({
url: `/api/aihr/work-report/reports/${id}/review`,
method: 'post',
data
});
}
export function getWorkReportAttachment(url: string): Promise<Blob> {
return request({
url,
method: 'get',
responseType: 'blob'
}) as Promise<Blob>;
}
+70
View File
@@ -0,0 +1,70 @@
import request from '@/utils/request';
type ApiResult<T> = {
code: number;
msg: string;
data: T;
};
export type WorkResultProject = {
projectCode: string;
projectName: string;
};
export type WorkResultItem = {
captureId: number;
suggestedType: string;
category: string;
summary: string;
businessStatus: string;
deliveryStatus: string;
occurredAt: string;
sourceType: string;
sourceUrl: string;
};
export type MemberWorkResult = {
ownerUserId: number;
ownerName: string;
projectCode: string;
projectName: string;
workDate: string;
version: number;
generatedAt: string;
sections: Record<string, WorkResultItem[]>;
total: number;
pending: number;
};
export type ProjectWorkResult = {
projectCode: string;
projectName: string;
workDate: string;
contributorCount: number;
resultCount: number;
pendingCount: number;
highPriorityCount: number;
members: MemberWorkResult[];
pageNum: number;
pageSize: number;
};
export function listWorkResultProjects(): Promise<ApiResult<WorkResultProject[]>> {
return request({
url: '/api/aihr/work-results/projects',
method: 'get'
});
}
export function getProjectWorkResult(params: {
projectCode: string;
workDate: string;
pageNum: number;
pageSize: number;
}): Promise<ApiResult<ProjectWorkResult>> {
return request({
url: '/api/aihr/work-results/project',
method: 'get',
params
});
}
+8
View File
@@ -71,6 +71,14 @@ export function dynamicTenant(tenantId: string | number) {
});
}
// 查询当前超级管理员的动态租户上下文
export function getDynamicTenant(): AxiosPromise<string | null> {
return request({
url: '/system/tenant/dynamic',
method: 'get'
});
}
// 清除动态租户
export function dynamicClear() {
return request({
+1
View File
@@ -51,6 +51,7 @@ export interface TenantVO {
companyName: string;
domain: any;
tenantId: string;
status?: string;
}
export interface TenantInfo {
@@ -347,7 +347,7 @@ $--color-warning: #e6a23c;
$--color-danger: #f56c6c;
$--color-info: #909399;
$base-sidebar-width: 200px;
$base-sidebar-width: 184px;
// the :export directive is the magic sauce for webpack
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
+5 -1
View File
@@ -31,7 +31,7 @@ import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { QuillEditor, Quill } from '@vueup/vue-quill';
import { propTypes } from '@/utils/propTypes';
import { globalHeaders } from '@/utils/request';
import { globalHeaders, reconcileTenantContextResponse } from '@/utils/request';
defineEmits(['update:modelValue']);
@@ -56,6 +56,9 @@ const upload = reactive<UploadOption>({
headers: globalHeaders(),
url: import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload'
});
watchEffect(() => {
upload.headers = globalHeaders();
});
const quillEditorRef = ref();
const uploadRef = ref<HTMLDivElement>();
@@ -118,6 +121,7 @@ watch(
// 图片上传成功返回图片地址
const handleUploadSuccess = (res: any) => {
reconcileTenantContextResponse(res);
// 如果上传成功
if (res.code === 200) {
// 获取富文本实例
+3 -2
View File
@@ -47,7 +47,7 @@
<script setup lang="ts">
import { propTypes } from '@/utils/propTypes';
import { delOss, listByIds } from '@/api/system/oss';
import { globalHeaders } from '@/utils/request';
import { globalHeaders, reconcileTenantContextResponse } from '@/utils/request';
const props = defineProps({
modelValue: {
@@ -73,7 +73,7 @@ const uploadList = ref<any[]>([]);
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadFileUrl = ref(baseUrl + '/resource/oss/upload'); // 上传文件服务器地址
const headers = ref(globalHeaders());
const headers = computed(() => globalHeaders());
const fileList = ref<any[]>([]);
const showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize));
@@ -158,6 +158,7 @@ const handleUploadError = () => {
// 上传成功回调
const handleUploadSuccess = (res: any, file: UploadFile) => {
reconcileTenantContextResponse(res);
if (res.code === 200) {
uploadList.value.push({
name: res.data.fileName,
+35 -3
View File
@@ -1,11 +1,27 @@
<template>
<div style="padding: 0 15px" @click="toggleClick">
<svg :class="{ 'is-active': isActive }" class="hamburger" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="64" height="64">
<button
type="button"
class="hamburger-trigger"
:aria-label="isActive ? '收起侧栏' : '展开侧栏'"
:aria-expanded="isActive"
aria-controls="main-navigation"
@click="toggleClick"
>
<svg
:class="{ 'is-active': isActive }"
class="hamburger"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
aria-hidden="true"
focusable="false"
>
<path
d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z"
/>
</svg>
</div>
</button>
</template>
<script setup lang="ts">
@@ -22,6 +38,22 @@ const toggleClick = () => {
</script>
<style scoped>
.hamburger-trigger {
display: flex;
height: 50px;
padding: 0 15px;
align-items: center;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
&:focus-visible {
outline: 2px solid #236fd7;
outline-offset: -3px;
}
}
.hamburger {
display: inline-block;
vertical-align: middle;
@@ -44,7 +44,7 @@
import { listByIds, delOss } from '@/api/system/oss';
import { OssVO } from '@/api/system/oss/types';
import { propTypes } from '@/utils/propTypes';
import { globalHeaders } from '@/utils/request';
import { globalHeaders, reconcileTenantContextResponse } from '@/utils/request';
import { compressAccurately } from 'image-conversion';
const props = defineProps({
@@ -81,7 +81,7 @@ const dialogVisible = ref(false);
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadImgUrl = ref(baseUrl + '/resource/oss/upload'); // 上传的图片服务器地址
const headers = ref(globalHeaders());
const headers = computed(() => globalHeaders());
const fileList = ref<any[]>([]);
const showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize));
@@ -173,6 +173,7 @@ const handleExceed = () => {
// 上传成功回调
const handleUploadSuccess = (res: any, file: UploadFile) => {
reconcileTenantContextResponse(res);
if (res.code === 200) {
uploadList.value.push({ name: res.data.fileName, url: res.data.url, ossId: res.data.ossId });
uploadedSuccessfully();
+116 -28
View File
@@ -1,6 +1,12 @@
<template>
<div class="navbar" :class="'nav' + navType">
<hamburger id="hamburger-container" :is-active="appStore.sidebar.opened" class="hamburger-container" @toggle-click="toggleSideBar" />
<hamburger
v-if="navType !== NavTypeEnum.TOP"
id="hamburger-container"
:is-active="appStore.sidebar.opened"
class="hamburger-container"
@toggle-click="toggleSideBar"
/>
<breadcrumb v-if="navType == NavTypeEnum.LEFT" id="breadcrumb-container" class="breadcrumb-container" />
<top-nav v-if="navType == NavTypeEnum.MIX" id="topmenu-container" class="topmenu-container" />
@@ -11,12 +17,15 @@
</template>
<div class="right-menu flex align-center">
<template v-if="appStore.device !== 'mobile'">
<el-tag v-if="isSuperAdmin && tenantEnabled && !tenantContextVerified" class="mr-2" type="warning" effect="plain"> 租户上下文未确认 </el-tag>
<el-select
v-if="userId === 1 && tenantEnabled"
v-model="companyName"
v-if="isSuperAdmin && tenantEnabled"
v-model="selectedTenantId"
class="min-w-244px mr-2"
clearable
filterable
:disabled="tenantContextSwitching"
:loading="tenantContextSwitching"
reserve-keyword
:placeholder="proxy.$t('navbar.selectTenant')"
@change="dynamicTenantEvent"
@@ -34,7 +43,7 @@
</el-tooltip>
<!-- 消息 -->
<el-tooltip :content="proxy.$t('navbar.message')" effect="dark" placement="bottom">
<div style="display:flex;align-items:center">
<div style="display: flex; align-items: center">
<el-popover placement="bottom" trigger="click" transition="el-zoom-in-top" :width="300" :persistent="false">
<template #reference>
<el-badge :value="newNotice > 0 ? newNotice : ''" :max="99">
@@ -99,14 +108,28 @@ import { useUserStore } from '@/store/modules/user';
import { useSettingsStore } from '@/store/modules/settings';
import { useNoticeStore } from '@/store/modules/notice';
import { getTenantList } from '@/api/login';
import { dynamicClear, dynamicTenant } from '@/api/system/tenant';
import { getDynamicTenant } from '@/api/system/tenant';
import { TenantVO } from '@/api/types';
import notice from './notice/index.vue';
import router from '@/router';
import {
clearDynamicTenantContext,
clearDynamicTenantContextState,
dynamicTenantContextVersion,
dynamicTenantId,
hasDynamicTenant,
isTenantContextVersionCurrent,
markTenantContextUnverified,
restoreDynamicTenantContext,
switchDynamicTenantContext,
tenantContextVerified,
tenantContextSwitching,
waitForTenantRequestContextOwnership
} from '@/utils/tenant-context';
import { ElMessageBoxOptions } from 'element-plus/es/components/message-box/src/message-box.type';
import { NavTypeEnum } from '@/enums/NavTypeEnum';
import Logo from "@/layout/components/Sidebar/Logo.vue";
import TopBar from './TopBar'
import Logo from '@/layout/components/Sidebar/Logo.vue';
import TopBar from './TopBar';
const appStore = useAppStore();
const userStore = useUserStore();
@@ -116,14 +139,14 @@ const newNotice = ref(<number>0);
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const userId = ref(userStore.userId);
const navType = computed(() => settingsStore.navType);
const showLogo = computed(() => settingsStore.sidebarLogo);
const isSuperAdmin = computed(() => userStore.roles.includes('superadmin'));
const companyName = ref(undefined);
const selectedTenantId = ref<string | number>();
const tenantList = ref<TenantVO[]>([]);
// 是否切换了租户
const dynamic = ref(false);
const dynamic = hasDynamicTenant;
// 租户开关
const tenantEnabled = ref(true);
// 搜索菜单
@@ -134,33 +157,97 @@ const openSearchMenu = () => {
};
// 动态切换
const dynamicTenantEvent = async (tenantId: string) => {
if (companyName.value != null && companyName.value !== '') {
await dynamicTenant(tenantId);
dynamic.value = true;
await proxy?.$router.push('/');
await proxy?.$tab.closeAllPage();
await proxy?.$tab.refreshPage();
const dynamicTenantEvent = async (tenantId?: string | number) => {
if (tenantId === undefined || tenantId === null || tenantId === '' || tenantContextSwitching.value) return;
const tenant = tenantList.value.find((item) => String(item.tenantId) === String(tenantId));
if (!tenant) {
selectedTenantId.value = dynamicTenantId.value;
return;
}
try {
await switchDynamicTenantContext({ tenantId, companyName: tenant.companyName }, '/');
} catch {
selectedTenantId.value = dynamicTenantId.value;
}
};
const dynamicClearEvent = async () => {
await dynamicClear();
dynamic.value = false;
await proxy?.$router.push('/');
await proxy?.$tab.closeAllPage();
await proxy?.$tab.refreshPage();
if (tenantContextSwitching.value) return;
try {
await clearDynamicTenantContext('/');
} catch {
selectedTenantId.value = dynamicTenantId.value;
}
};
/** 租户列表 */
const initTenantList = async () => {
const { data } = await getTenantList(true);
tenantEnabled.value = data.tenantEnabled === undefined ? true : data.tenantEnabled;
if (tenantEnabled.value) {
tenantList.value = data.voList;
await waitForTenantRequestContextOwnership();
let contextVersion = dynamicTenantContextVersion.value;
const isCurrentInit = () => isTenantContextVersionCurrent(contextVersion);
let currentTenantId: string | number | null | undefined;
let dynamicContextLoaded = false;
// Read the page-scoped server context first. Without this echo, a restored
// dynamic tenant deliberately rejects the tenant list request as stale.
if (isSuperAdmin.value) {
try {
currentTenantId = (await getDynamicTenant()).data;
if (!isCurrentInit()) return;
restoreDynamicTenantContext(
currentTenantId !== undefined && currentTenantId !== null && currentTenantId !== '' ? { tenantId: currentTenantId } : undefined
);
contextVersion = dynamicTenantContextVersion.value;
dynamicContextLoaded = true;
} catch {
if (!isCurrentInit()) return;
}
}
try {
const { data } = await getTenantList(true);
if (!isCurrentInit()) return;
tenantEnabled.value = data.tenantEnabled === undefined ? true : data.tenantEnabled;
if (!tenantEnabled.value || !isSuperAdmin.value) {
tenantList.value = [];
clearDynamicTenantContextState();
return;
}
tenantList.value = data.voList.filter((tenant) => tenant.status === undefined || tenant.status === '0');
} catch {
if (!isCurrentInit()) return;
tenantList.value = [];
markTenantContextUnverified();
return;
}
if (!dynamicContextLoaded) {
markTenantContextUnverified();
return;
}
const tenant = tenantList.value.find((item) => String(item.tenantId) === String(currentTenantId));
if (currentTenantId !== undefined && currentTenantId !== null && currentTenantId !== '' && !tenant) {
markTenantContextUnverified();
return;
}
restoreDynamicTenantContext(
currentTenantId !== undefined && currentTenantId !== null && currentTenantId !== ''
? { tenantId: currentTenantId, companyName: tenant?.companyName }
: undefined
);
};
watch(
dynamicTenantId,
(tenantId) => {
selectedTenantId.value = tenantId;
},
{ immediate: true }
);
defineExpose({
initTenantList
});
@@ -282,7 +369,6 @@ watch(
margin-left: 8px;
}
.errLog-container {
display: inline-block;
vertical-align: top;
@@ -310,7 +396,9 @@ watch(
&.hover-effect {
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
transition:
background 0.2s ease,
color 0.2s ease;
&:hover {
background: var(--el-fill-color-light);
+24 -17
View File
@@ -1,17 +1,14 @@
<template>
<div
class="sidebar-logo-container"
:class="{ collapse: collapse }"
>
<div class="sidebar-logo-container" :class="{ collapse: collapse }">
<transition :enter-active-class="proxy?.animate.logoAnimate.enter" mode="out-in">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" />
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" :aria-label="title" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" alt="" />
<h1 v-else class="sidebar-title">
{{ title }}
</h1>
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" />
<router-link v-else key="expand" class="sidebar-logo-link" :aria-label="title" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" alt="" />
<h1 class="sidebar-title">
{{ title }}
</h1>
@@ -41,24 +38,24 @@ const sideTheme = computed(() => settingsStore.sideTheme);
// 获取Logo背景色
const getLogoBackground = computed(() => {
if (settingsStore.isDark) {
return 'var(--sidebar-bg)'
return 'var(--sidebar-bg)';
}
if (settingsStore.navType == NavTypeEnum.TOP) {
return variables.menuLightBackground
return variables.menuLightBackground;
}
return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBackground
})
return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBackground;
});
// 获取Logo文字颜色
const getLogoTextColor = computed(() => {
if (settingsStore.isDark) {
return 'var(--sidebar-text)'
return 'var(--sidebar-text)';
}
if (settingsStore.navType == NavTypeEnum.TOP) {
return variables.logoLightTitleColor
return variables.logoLightTitleColor;
}
return sideTheme.value === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor
})
return sideTheme.value === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor;
});
</script>
<style lang="scss" scoped>
@@ -83,6 +80,11 @@ const getLogoTextColor = computed(() => {
height: 100%;
width: 100%;
&:focus-visible {
outline: 2px solid #236fd7;
outline-offset: -3px;
}
& .sidebar-logo {
width: 32px;
height: 32px;
@@ -98,7 +100,12 @@ const getLogoTextColor = computed(() => {
font-weight: 600;
line-height: 50px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
font-family:
Avenir,
Helvetica Neue,
Arial,
Helvetica,
sans-serif;
vertical-align: middle;
}
}
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { canAccessMatchedRoutes, normalizeOperationPath, operationsConsoleRoles, tenantGovernanceRoles } from '@/router/access';
const frontendRoot = existsSync(resolve(process.cwd(), 'src')) ? process.cwd() : resolve(process.cwd(), 'frontend');
const src = (...parts: string[]) => readFileSync(resolve(frontendRoot, 'src', ...parts), 'utf8');
describe('operations sidebar navigation contract', () => {
it('uses native links while retaining the operations role boundary', () => {
const sidebar = src('layout', 'components', 'Sidebar', 'index.vue');
const router = src('router', 'index.ts');
const guard = src('permission.ts');
expect(sidebar).toContain('<router-link');
expect(sidebar).toContain('canAccessOperations');
expect(sidebar).toContain('operationsConsoleRoles');
expect(router).toContain("export { operationsConsoleRoles, tenantGovernanceRoles } from './access';");
expect(router).toContain('roles: operationsConsoleRoles');
expect(guard).toContain('canAccessRoute(to)');
expect(sidebar).toContain('aria-labelledby');
expect(sidebar).not.toContain('<el-menu-item');
expect(sidebar).not.toContain('<transition');
});
it('keeps the shell control and brand link keyboard visible', () => {
const hamburger = src('components', 'Hamburger', 'index.vue');
const logo = src('layout', 'components', 'Sidebar', 'Logo.vue');
expect(hamburger).toContain('<button');
expect(hamburger).toContain('aria-expanded');
expect(hamburger).toContain('aria-controls="main-navigation"');
expect(logo).toContain('&:focus-visible');
expect(logo).toContain(':aria-label="title"');
});
it('enforces the operations roles and canonicalizes the legacy admin route', () => {
const protectedRoute = [{ meta: { roles: operationsConsoleRoles } }];
expect(canAccessMatchedRoutes(['superadmin'], protectedRoute)).toBe(true);
expect(canAccessMatchedRoutes(['admin'], protectedRoute)).toBe(false);
expect(canAccessMatchedRoutes(['employee'], protectedRoute)).toBe(false);
expect(canAccessMatchedRoutes(['employee'], [{ meta: {} }])).toBe(true);
expect(canAccessMatchedRoutes(['employee'], [{ meta: { roles: [] } }])).toBe(false);
expect(normalizeOperationPath('/admin/interview')).toBe('/recruit/interview');
});
it('keeps tenant management out of the HR operator navigation boundary', () => {
const router = src('router', 'index.ts');
const sidebar = src('layout', 'components', 'Sidebar', 'index.vue');
const tenantPage = src('views', 'system', 'tenant', 'index.vue');
expect(router).toContain("path: 'tenant'");
expect(router).toContain("title: '租户管理'");
expect(router).toContain('roles: tenantGovernanceRoles');
expect(tenantGovernanceRoles).toEqual(['superadmin']);
expect(sidebar).toContain('hasRequiredRouteRole(userStore.roles, child.meta?.roles)');
expect(tenantPage).toContain('租户 → 知识空间 → 分类 → 知识');
expect(tenantPage).toContain('switchDynamicTenantContext');
expect(tenantPage).toContain("'/knowledge/spaces'");
expect(tenantPage).toContain('tenantContextSwitching');
const navbar = src('layout', 'components', 'Navbar.vue');
expect(navbar).toContain('switchDynamicTenantContext');
expect(navbar).toContain('getDynamicTenant');
expect(navbar.indexOf('await waitForTenantRequestContextOwnership()')).toBeLessThan(
navbar.indexOf('currentTenantId = (await getDynamicTenant()).data')
);
expect(navbar.indexOf('currentTenantId = (await getDynamicTenant()).data')).toBeLessThan(navbar.indexOf('await getTenantList(true)'));
expect(navbar).toContain("tenant.status === undefined || tenant.status === '0'");
expect(navbar).toContain("currentTenantId !== undefined && currentTenantId !== null && currentTenantId !== '' && !tenant");
const context = src('utils', 'tenant-context.ts');
const contextState = src('utils', 'tenant-context-state.ts');
expect(context).toContain('await tab.closeAllPage()');
expect(context).toContain('await tab.refreshPage()');
expect(context).toContain('dynamicTenantContextVersion');
expect(context).toContain('beginTenantContextOperation');
expect(context).toContain('isTenantContextOperationCurrent');
expect(contextState).toContain('clearDynamicTenantContextState');
expect(contextState).toContain('currentTenantRequestContextId');
expect(contextState).toContain('tenantContextVerified');
expect(contextState).toContain('window.sessionStorage');
expect(contextState).toContain('resetTenantRequestContextId');
expect(contextState).toContain('guardAgainstClonedTabContext');
expect(contextState).toContain("new BroadcastChannel('aihr-tenant-page-context')");
expect(contextState).toContain('tenantContextOwnershipReady');
expect(contextState).toContain('event.persisted');
expect(contextState).toContain("window.addEventListener('pageshow', probeOnPageShow)");
expect(src('utils', 'request.ts')).toContain('dynamicTenantContextHeader');
expect(src('utils', 'request.ts')).toContain('expectedTenantHeader');
expect(src('utils', 'request.ts')).toContain('租户上下文已变更,请刷新后重试');
expect(src('utils', 'request.ts')).toContain('reconcileTenantContextResponse');
expect(src('components', 'FileUpload', 'index.vue')).toContain('reconcileTenantContextResponse(res)');
expect(src('components', 'ImageUpload', 'index.vue')).toContain('reconcileTenantContextResponse(res)');
expect(src('store', 'modules', 'user.ts')).toContain('clearDynamicTenantContextState()');
expect(canAccessMatchedRoutes(['hr_operator'], [{ meta: { roles: operationsConsoleRoles } }, { meta: { roles: tenantGovernanceRoles } }])).toBe(
false
);
});
});
+177 -46
View File
@@ -2,59 +2,98 @@
<div :class="{ 'has-logo': showLogo }" :style="{ backgroundColor: bgColor }">
<logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar :class="sideTheme" wrap-class="scrollbar-wrapper">
<transition :enter-active-class="proxy?.animate.menuSearchAnimate.enter" mode="out-in">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="bgColor"
:text-color="textColor"
:unique-opened="true"
:active-text-color="theme"
:collapse-transition="false"
:popper-offset="12"
mode="vertical"
>
<sidebar-item v-for="(r, index) in sidebarRouters" :key="r.path + index" :item="r" :base-path="r.path" />
</el-menu>
</transition>
<nav
id="main-navigation"
class="operations-menu"
:class="{
'operations-menu--dark': sideTheme === 'theme-dark',
'operations-menu--collapse': isCollapse
}"
aria-label="主导航"
>
<ul class="operations-menu-list">
<li v-if="overview">
<router-link
class="operations-menu-link"
:class="{ 'is-active': isActive(overview.path) }"
:to="overview.path"
:aria-label="overview.title"
:title="isCollapse || overview.title.length > 5 ? overview.title : undefined"
:aria-current="isActive(overview.path) ? 'page' : undefined"
>
<svg-icon :icon-class="overview.icon" />
<span v-if="!isCollapse" class="menu-title">{{ overview.title }}</span>
</router-link>
</li>
<li v-for="group in sidebarGroups" :key="group.title" class="operations-menu-group">
<p v-if="!isCollapse" :id="groupId(group.title)" class="operations-menu-group__title">{{ group.title }}</p>
<ul :aria-labelledby="isCollapse ? undefined : groupId(group.title)">
<li v-for="item in group.items" :key="item.path">
<router-link
class="operations-menu-link"
:class="{ 'is-active': isActive(item.path) }"
:to="item.path"
:aria-label="item.title"
:title="isCollapse || item.title.length > 5 ? item.title : undefined"
:aria-current="isActive(item.path) ? 'page' : undefined"
>
<svg-icon :icon-class="item.icon" />
<span v-if="!isCollapse" class="menu-title">{{ item.title }}</span>
</router-link>
</li>
</ul>
</li>
</ul>
</nav>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import Logo from './Logo.vue';
import SidebarItem from './SidebarItem.vue';
import variables from '@/assets/styles/variables.module.scss';
import { useAppStore } from '@/store/modules/app';
import { useSettingsStore } from '@/store/modules/settings';
import { usePermissionStore } from '@/store/modules/permission';
import { RouteRecordRaw } from 'vue-router';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
import { useUserStore } from '@/store/modules/user';
import { constantRoutes } from '@/router';
import { hasRequiredRouteRole, normalizeOperationPath, operationsConsoleRoles } from '@/router/access';
import { getNormalPath } from '@/utils/ruoyi';
import type { RouteRecordRaw } from 'vue-router';
const route = useRoute();
const appStore = useAppStore();
const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore();
const demoMenuTitles = new Set([
'首页',
'AI面试',
'训练运营',
'三角色对练',
'复盘校准',
'案例沉淀',
'知识空间',
'SOP知识库',
'资料处理',
'模型配置',
'全网AI配置',
'组织与权限',
'成长激励',
'问题榜运营'
]);
const getRouteMenuTitle = (route: RouteRecordRaw) => route.children?.find((child) => !child.hidden)?.meta?.title || route.meta?.title;
const sidebarRouters = computed<RouteRecordRaw[]>(() =>
permissionStore.getSidebarRoutes().filter((route) => demoMenuTitles.has(String(getRouteMenuTitle(route))))
const userStore = useUserStore();
type SidebarLeaf = { title: string; path: string; icon: string };
type SidebarGroup = { title: string; items: SidebarLeaf[] };
const groupTitles = ['训练运营', '工作成果', '知识运营', '内容运营', '共创运营', '招聘与组织', '平台设置'];
const routeTitle = (route: RouteRecordRaw) => String(route.meta?.title || '');
const normalizeRoutePath = (path: string) => {
if (/^https?:\/\//.test(path)) return path;
return normalizeOperationPath(getNormalPath(path));
};
const toLeaf = (parent: RouteRecordRaw, child: RouteRecordRaw): SidebarLeaf => ({
title: routeTitle(child),
path: normalizeRoutePath(child.path.startsWith('/') ? child.path : `${parent.path}/${child.path}`),
icon: String(child.meta?.icon || parent.meta?.icon || 'list')
});
const visibleChildren = (route: RouteRecordRaw) =>
(route.children || []).filter((child) => !child.hidden && routeTitle(child) && hasRequiredRouteRole(userStore.roles, child.meta?.roles));
const canAccessOperations = computed(() => userStore.roles.some((role) => operationsConsoleRoles.includes(role)));
const groupId = (title: string) => `sidebar-group-${groupTitles.indexOf(title)}`;
const sidebarRoutes = computed(() => (canAccessOperations.value ? constantRoutes : []));
const overview = computed<SidebarLeaf | undefined>(() => {
const parent = sidebarRoutes.value.find((route) => visibleChildren(route).some((child) => routeTitle(child) === '运营总览'));
const child = parent && visibleChildren(parent).find((item) => routeTitle(item) === '运营总览');
return parent && child ? toLeaf(parent, child) : undefined;
});
const sidebarGroups = computed<SidebarGroup[]>(() =>
groupTitles.flatMap((title) => {
const parent = sidebarRoutes.value.find((route) => routeTitle(route) === title);
const items = parent ? visibleChildren(parent).map((child) => toLeaf(parent, child)) : [];
return items.length ? [{ title, items }] : [];
})
);
const showLogo = computed(() => settingsStore.sidebarLogo);
const sideTheme = computed(() => settingsStore.sideTheme);
@@ -62,14 +101,106 @@ const theme = computed(() => settingsStore.theme);
const isCollapse = computed(() => !appStore.sidebar.opened);
const activeMenu = computed(() => {
const { meta, path } = route;
const { meta, path, matched } = route;
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu;
}
return path;
const matchedPath = matched[matched.length - 1]?.path;
return normalizeRoutePath(typeof meta.activeMenu === 'string' ? meta.activeMenu : matchedPath || path);
});
const isActive = (path: string) => activeMenu.value === normalizeRoutePath(path);
const bgColor = computed(() => (sideTheme.value === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground));
const textColor = computed(() => (sideTheme.value === 'theme-dark' ? variables.menuColor : variables.menuLightColor));
</script>
<style scoped>
.operations-menu {
min-height: 100%;
background: v-bind(bgColor);
}
.operations-menu-list,
.operations-menu-group ul {
padding: 0;
margin: 0;
list-style: none;
}
#app .sidebar-container .operations-menu-link {
display: flex;
align-items: center;
width: calc(100% - 10px);
height: 45px;
padding: 0 18px;
margin: 1px 5px;
overflow: hidden;
border-radius: var(--app-radius-md);
color: rgba(0, 0, 0, 0.7);
font-size: 14px;
line-height: 45px;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
transition:
background-color 0.18s ease,
color 0.18s ease;
&:hover {
background: rgba(0, 0, 0, 0.05);
}
&.is-active {
background: v-bind(theme);
color: #fff;
}
&:focus-visible {
outline: 2px solid #fff;
outline-offset: -3px;
box-shadow: inset 0 0 0 4px #1f2d3d;
}
}
#app .sidebar-container .operations-menu-link .svg-icon {
flex: 0 0 auto;
margin-right: 10px;
}
#app .sidebar-container .operations-menu--dark .operations-menu-link {
color: var(--menuColor);
&:hover {
background: var(--menuHover);
}
&.is-active {
color: #fff;
}
}
.operations-menu-group__title {
padding: 18px 20px 7px;
margin: 0;
color: #536579;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
}
.operations-menu--dark .operations-menu-group__title {
color: #aeb9cb;
}
#app .sidebar-container .operations-menu--collapse .operations-menu-link {
justify-content: center;
width: 44px;
padding: 0;
}
#app .sidebar-container .operations-menu--collapse .operations-menu-link .svg-icon {
margin-right: 0;
}
:deep(.scrollbar-wrapper:focus-visible) {
outline: 2px solid #236fd7;
outline-offset: -2px;
}
</style>
+3 -2
View File
@@ -31,10 +31,11 @@ import { initWebSocket } from '@/utils/websocket';
import { initSSE } from '@/utils/sse';
const settingsStore = useSettingsStore();
const route = useRoute();
const theme = computed(() => settingsStore.theme);
const sidebar = computed(() => useAppStore().sidebar);
const device = computed(() => useAppStore().device);
const needTagsView = computed(() => settingsStore.tagsView);
const needTagsView = computed(() => settingsStore.tagsView && !route.meta.hideTagsView);
const fixedHeader = computed(() => settingsStore.fixedHeader);
const layout = computed(() => settingsStore.navType);
@@ -127,7 +128,7 @@ const setLayout = () => {
width: calc(100% - #{$base-sidebar-width});
transition: width 0.28s;
background: $fixed-header-bg;
box-shadow: 0 2px 8px rgba(0, 21, 41, 0.10);
box-shadow: 0 2px 8px rgba(0, 21, 41, 0.1);
}
.hideSidebar .fixed-header {
+10 -1
View File
@@ -1,5 +1,7 @@
import { to as tos } from 'await-to-js';
import router from './router';
import type { RouteLocationNormalized } from 'vue-router';
import { canAccessMatchedRoutes } from './router/access';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { getToken } from '@/utils/auth';
@@ -17,6 +19,8 @@ const isWhiteList = (path: string) => {
return whiteList.some((pattern) => isPathMatch(pattern, path));
};
const canAccessRoute = (to: RouteLocationNormalized): boolean => canAccessMatchedRoutes(useUserStore().roles, to.matched);
router.beforeEach(async (to, from, next) => {
NProgress.start();
if (getToken()) {
@@ -49,7 +53,12 @@ router.beforeEach(async (to, from, next) => {
next({ path: to.path, replace: true, params: to.params, query: to.query, hash: to.hash, name: to.name as string }); // hack方法 确保addRoutes已完成
}
} else {
next();
if (canAccessRoute(to)) {
next();
} else {
next({ path: '/401', replace: true });
NProgress.done();
}
}
}
} else {
+4 -1
View File
@@ -3,7 +3,7 @@ import FileSaver from 'file-saver';
import errorCode from '@/utils/errorCode';
import { blobValidate } from '@/utils/ruoyi';
import { LoadingInstance } from 'element-plus/es/components/loading/src/loading';
import { globalHeaders } from '@/utils/request';
import { globalHeaders, reconcileTenantContextBlobResponse, reconcileTenantContextResponse } from '@/utils/request';
const baseURL = import.meta.env.VITE_APP_BASE_API;
let downloadLoadingInstance: LoadingInstance;
@@ -27,6 +27,7 @@ export default {
}
downloadLoadingInstance.close();
} catch (r) {
await reconcileTenantContextBlobResponse(axios.isAxiosError(r) ? r.response?.data : undefined);
console.error(r);
ElMessage.error('下载文件出现错误,请联系管理员!');
downloadLoadingInstance.close();
@@ -51,6 +52,7 @@ export default {
}
downloadLoadingInstance.close();
} catch (r) {
await reconcileTenantContextBlobResponse(axios.isAxiosError(r) ? r.response?.data : undefined);
console.error(r);
ElMessage.error('下载文件出现错误,请联系管理员!');
downloadLoadingInstance.close();
@@ -59,6 +61,7 @@ export default {
async printErrMsg(data: any) {
const resText = await data.text();
const rspObj = JSON.parse(resText);
reconcileTenantContextResponse(rspObj);
const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default'];
ElMessage.error(errMsg);
}
+15
View File
@@ -0,0 +1,15 @@
export const operationsConsoleRoles = ['superadmin', 'hr_operator'];
export const tenantGovernanceRoles = ['superadmin'];
type MatchedRoute = { meta?: { roles?: readonly string[] } };
export const hasRequiredRouteRole = (userRoles: readonly string[], requiredRoles: unknown): boolean => {
if (requiredRoles === undefined) return true;
if (!Array.isArray(requiredRoles) || requiredRoles.length === 0) return false;
return userRoles.some((role) => requiredRoles.includes(role));
};
export const canAccessMatchedRoutes = (userRoles: readonly string[], matched: readonly MatchedRoute[]): boolean =>
matched.every((record) => hasRequiredRouteRole(userRoles, record.meta?.roles));
export const normalizeOperationPath = (path: string): string => path.replace(/^\/admin(?=\/|$)/, '/recruit');
+98 -63
View File
@@ -1,6 +1,9 @@
import { createWebHistory, createRouter, RouteRecordRaw } from 'vue-router';
/* Layout */
import Layout from '@/layout/index.vue';
import { operationsConsoleRoles, tenantGovernanceRoles } from './access';
export { operationsConsoleRoles, tenantGovernanceRoles } from './access';
/**
* Note: 路由配置项
@@ -66,25 +69,35 @@ export const constantRoutes: RouteRecordRaw[] = [
path: '',
component: Layout,
redirect: '/index',
meta: { roles: operationsConsoleRoles },
children: [
{
path: '/index',
component: () => import('@/views/index.vue'),
name: 'Index',
meta: { title: '首页', icon: 'dashboard', affix: true }
meta: { title: '运营总览', icon: 'dashboard', affix: true, hideTagsView: true }
}
]
},
{
path: '/recruit',
alias: '/admin',
component: Layout,
redirect: '/recruit/interview',
redirect: (to) => (['/admin', '/admin/'].includes(to.path) ? '/admin/org-permission' : '/recruit/interview'),
alwaysShow: true,
meta: { title: '招聘与组织', icon: 'user', roles: operationsConsoleRoles },
children: [
{
path: 'interview',
component: () => import('@/views/recruit/interview.vue'),
name: 'RecruitInterview',
meta: { title: 'AI面试', icon: 'user' }
},
{
path: 'org-permission',
component: () => import('@/views/admin/org-permission.vue'),
name: 'OrgPermission',
meta: { title: '组织与权限', icon: 'tree', activeMenu: '/recruit/org-permission' }
}
]
},
@@ -93,25 +106,26 @@ export const constantRoutes: RouteRecordRaw[] = [
component: Layout,
redirect: '/train/practice',
alwaysShow: true,
meta: { title: '训练运营', icon: 'peoples' },
meta: { title: '训练运营', icon: 'peoples', roles: operationsConsoleRoles },
children: [
{
path: 'practice',
component: () => import('@/views/train/practice.vue'),
name: 'TrainPractice',
meta: { title: '三角色对练', icon: 'peoples' }
meta: { title: '训练看板', icon: 'peoples' }
},
{
path: 'scenarios',
component: () => import('@/views/train/scenarios.vue'),
name: 'TrainScenarios',
meta: { title: '场景库', icon: 'list' }
meta: { title: '场景与评分', icon: 'list' }
},
{
path: 'rubrics',
component: () => import('@/views/train/rubrics.vue'),
name: 'TrainRubrics',
meta: { title: 'Rubric 规则', icon: 'list' }
hidden: true,
meta: { title: '评分规则', icon: 'list', activeMenu: '/train/scenarios' }
},
{
path: 'reviews',
@@ -123,67 +137,92 @@ export const constantRoutes: RouteRecordRaw[] = [
path: 'difficulties',
component: () => import('@/views/train/difficulties.vue'),
name: 'TrainDifficulties',
meta: { title: '常见难题', icon: 'chart' }
hidden: true,
meta: { title: '常见难题', icon: 'chart', activeMenu: '/train/scenarios' }
}
]
},
{
path: '/community/questions',
path: '/operations',
component: Layout,
redirect: '/operations/work-results',
alwaysShow: true,
meta: { title: '工作成果', icon: 'list', roles: operationsConsoleRoles },
children: [
{
path: '',
component: () => import('@/views/community/questions.vue'),
name: 'CommunityQuestions',
meta: { title: '问题榜运营', icon: 'message' }
path: 'work-results',
component: () => import('@/views/operations/work-results.vue'),
name: 'OperationsWorkResults',
meta: { title: '项目工作成果', icon: 'list' }
},
{
path: 'work-report-review',
component: () => import('@/views/operations/work-report-review.vue'),
name: 'OperationsWorkReportReview',
meta: { title: '成果投稿审核', icon: 'finish' }
}
]
},
{
path: '/knowledge/cases',
path: '/knowledge',
component: Layout,
redirect: '/knowledge/processing',
alwaysShow: true,
meta: { title: '知识运营', icon: 'documentation', roles: operationsConsoleRoles },
children: [
{
path: '',
component: () => import('@/views/knowledge/cases.vue'),
name: 'KnowledgeCases',
meta: { title: '案例沉淀', icon: 'list' }
}
]
},
{
path: '/knowledge/spaces',
component: Layout,
children: [
{
path: '',
component: () => import('@/views/knowledge/spaces.vue'),
name: 'KnowledgeSpaces',
meta: { title: '知识空间', icon: 'tree-table' }
}
]
},
{
path: '/knowledge/sop',
component: Layout,
children: [
{
path: '',
component: () => import('@/views/knowledge/sop.vue'),
name: 'KnowledgeSop',
meta: { title: 'SOP知识库', icon: 'documentation' }
}
]
},
{
path: '/knowledge/processing',
component: Layout,
children: [
{
path: '',
path: 'processing',
component: () => import('@/views/knowledge/processing.vue'),
name: 'KnowledgeProcessing',
meta: { title: '资料处理', icon: 'upload' }
},
{
path: 'sop',
component: () => import('@/views/knowledge/sop.vue'),
name: 'KnowledgeSop',
meta: { title: 'SOP知识库', icon: 'documentation' }
},
{
path: 'spaces',
component: () => import('@/views/knowledge/spaces.vue'),
name: 'KnowledgeSpaces',
meta: { title: '知识空间', icon: 'tree-table' }
},
{
path: 'cases',
component: () => import('@/views/knowledge/cases.vue'),
name: 'KnowledgeCases',
meta: { title: '成果投稿', icon: 'list' }
}
]
},
{
path: '/content',
component: Layout,
redirect: '/content/broadcast',
alwaysShow: true,
meta: { title: '内容运营', icon: 'message', roles: operationsConsoleRoles },
children: [
{
path: 'broadcast',
component: () => import('@/views/content/broadcast.vue'),
name: 'ContentBroadcast',
meta: { title: '银城大喇叭', icon: 'message' }
}
]
},
{
path: '/community',
component: Layout,
redirect: '/community/questions',
alwaysShow: true,
meta: { title: '共创运营', icon: 'message', roles: operationsConsoleRoles },
children: [
{
path: 'questions',
component: () => import('@/views/community/questions.vue'),
name: 'CommunityQuestions',
meta: { title: '问题榜', icon: 'message' }
}
]
},
@@ -192,7 +231,7 @@ export const constantRoutes: RouteRecordRaw[] = [
component: Layout,
redirect: '/system/model',
alwaysShow: true,
meta: { title: '系统设置', icon: 'system' },
meta: { title: '平台设置', icon: 'system', roles: operationsConsoleRoles },
children: [
{
path: 'model',
@@ -204,6 +243,7 @@ export const constantRoutes: RouteRecordRaw[] = [
path: 'prompts',
component: () => import('@/views/system/prompts.vue'),
name: 'SystemPromptTemplates',
hidden: true,
meta: { title: '提示词模板', icon: 'edit' }
},
{
@@ -211,26 +251,21 @@ export const constantRoutes: RouteRecordRaw[] = [
component: () => import('@/views/system/web-search.vue'),
name: 'SystemWebSearch',
meta: { title: '全网AI配置', icon: 'search' }
}
]
},
{
path: '/admin',
component: Layout,
redirect: '/admin/org-permission',
children: [
},
{
path: 'org-permission',
component: () => import('@/views/admin/org-permission.vue'),
name: 'OrgPermission',
meta: { title: '组织与权限', icon: 'tree' }
path: 'tenant',
component: () => import('@/views/system/tenant/index.vue'),
name: 'SystemTenantGovernance',
meta: { title: '租户管理', icon: 'peoples', roles: tenantGovernanceRoles }
}
]
},
{
path: '/competency',
component: Layout,
hidden: true,
redirect: '/competency/growth',
meta: { roles: operationsConsoleRoles },
children: [
{
path: 'growth',
+11 -5
View File
@@ -3,6 +3,7 @@ import { getToken, removeToken, setToken } from '@/utils/auth';
import { login as loginApi, logout as logoutApi, getInfo as getUserInfo } from '@/api/login';
import { LoginData } from '@/api/types';
import defAva from '@/assets/images/profile.jpg';
import { clearDynamicTenantContextState } from '@/utils/tenant-context-state';
import { defineStore } from 'pinia';
import { ref } from 'vue';
@@ -25,6 +26,7 @@ export const useUserStore = defineStore('user', () => {
const [err, res] = await to(loginApi(userInfo));
if (res) {
const data = res.data;
clearDynamicTenantContextState();
setToken(data.access_token);
token.value = data.access_token;
return Promise.resolve();
@@ -59,11 +61,15 @@ export const useUserStore = defineStore('user', () => {
// 注销
const logout = async (): Promise<void> => {
await logoutApi();
token.value = '';
roles.value = [];
permissions.value = [];
removeToken();
try {
await logoutApi();
} finally {
token.value = '';
roles.value = [];
permissions.value = [];
removeToken();
clearDynamicTenantContextState();
}
};
const setAvatar = (value: string) => {
+2
View File
@@ -8,6 +8,8 @@ declare module 'vue-router' {
activeMenu?: string;
icon?: string;
breadcrumb?: boolean;
hideTagsView?: boolean;
roles?: readonly string[];
}
interface _RouteRecordBase {
+46 -1
View File
@@ -11,18 +11,54 @@ import { getLanguage } from '@/lang';
import { encryptBase64, encryptWithAes, generateAesKey, decryptWithAes, decryptBase64 } from '@/utils/crypto';
import { encrypt, decrypt } from '@/utils/jsencrypt';
import router from '@/router';
import {
currentExpectedTenantId,
currentTenantRequestContextId,
dynamicTenantContextHeader,
expectedTenantHeader,
markTenantContextUnverified
} from './tenant-context-state';
const encryptHeader = 'encrypt-key';
let downloadLoadingInstance: LoadingInstance;
// 是否显示重新登录
export const isRelogin = { show: false };
const tenantContextHeaders = () => {
const expectedTenantId = currentExpectedTenantId();
return {
[dynamicTenantContextHeader]: currentTenantRequestContextId(),
...(expectedTenantId ? { [expectedTenantHeader]: expectedTenantId } : {})
};
};
export const globalHeaders = () => {
return {
Authorization: 'Bearer ' + getToken(),
clientid: import.meta.env.VITE_APP_CLIENT_ID
clientid: import.meta.env.VITE_APP_CLIENT_ID,
...tenantContextHeaders()
};
};
/** Lets native upload components fail closed as well as Axios requests. */
export function reconcileTenantContextResponse(response: unknown) {
if (!response || typeof response !== 'object') return false;
const { code, msg } = response as { code?: unknown; msg?: unknown };
if (code === 409 && msg === '租户上下文已变更,请刷新后重试') {
markTenantContextUnverified();
return true;
}
return false;
}
/** Parses blob error bodies returned by native download/audio requests. */
export async function reconcileTenantContextBlobResponse(response: unknown) {
if (typeof Blob === 'undefined' || !(response instanceof Blob)) return false;
try {
return reconcileTenantContextResponse(JSON.parse(await response.text()));
} catch {
return false;
}
}
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
axios.defaults.headers['clientid'] = import.meta.env.VITE_APP_CLIENT_ID;
// 创建 axios 实例
@@ -40,6 +76,13 @@ service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 对应国际化资源文件后缀
config.headers['Content-Language'] = getLanguage();
config.headers[dynamicTenantContextHeader] = currentTenantRequestContextId();
const expectedTenantId = currentExpectedTenantId();
if (expectedTenantId) {
config.headers[expectedTenantHeader] = expectedTenantId;
} else {
delete config.headers[expectedTenantHeader];
}
const isToken = config.headers?.isToken === false;
// 是否需要防止数据重复提交
@@ -124,6 +167,7 @@ service.interceptors.response.use(
const code = res.data.code || HttpStatus.SUCCESS;
// 获取错误信息
const msg = errorCode[code] || res.data.msg || errorCode['default'];
reconcileTenantContextResponse(res.data);
// 二进制数据则直接返回
if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
return res.data;
@@ -198,6 +242,7 @@ export function download(url: string, params: any, fileName: string) {
const blob = new Blob([resp]);
const resText = await blob.text();
const rspObj = JSON.parse(resText);
reconcileTenantContextResponse(rspObj);
const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default'];
ElMessage.error(errMsg);
}
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
beginTenantContextOperation,
clearDynamicTenantContextState,
currentExpectedTenantId,
currentTenantRequestContextId,
dynamicTenantContextVersion,
dynamicTenantId,
dynamicTenantName,
finishTenantContextOperation,
restoreDynamicTenantContext,
tenantContextVerified,
tenantContextSwitching
} from './tenant-context-state';
describe('tenant context state', () => {
beforeEach(() => {
clearDynamicTenantContextState();
});
it('clears the previous identity context and advances its version', () => {
const version = dynamicTenantContextVersion.value;
const previousRequestContextId = currentTenantRequestContextId();
restoreDynamicTenantContext({ tenantId: 'tenant-a', companyName: 'A 项目' });
expect(dynamicTenantId.value).toBe('tenant-a');
expect(dynamicTenantName.value).toBe('A 项目');
expect(tenantContextVerified.value).toBe(true);
expect(currentExpectedTenantId()).toBe('tenant-a');
expect(currentTenantRequestContextId()).toMatch(/^tab_[A-Za-z0-9_-]{16,}$/);
expect(dynamicTenantContextVersion.value).toBe(version + 1);
tenantContextSwitching.value = true;
clearDynamicTenantContextState();
expect(dynamicTenantId.value).toBeUndefined();
expect(dynamicTenantName.value).toBe('');
expect(tenantContextVerified.value).toBe(false);
expect(currentExpectedTenantId()).toBeUndefined();
expect(currentTenantRequestContextId()).not.toBe(previousRequestContextId);
expect(tenantContextSwitching.value).toBe(false);
expect(dynamicTenantContextVersion.value).toBe(version + 2);
});
it('does not let a stale request unlock a newer tenant operation', () => {
const firstOperation = beginTenantContextOperation();
clearDynamicTenantContextState();
const secondOperation = beginTenantContextOperation();
finishTenantContextOperation(firstOperation);
expect(tenantContextSwitching.value).toBe(true);
finishTenantContextOperation(secondOperation);
expect(tenantContextSwitching.value).toBe(false);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { computed, ref } from 'vue';
export type DynamicTenant = {
tenantId: string | number;
companyName?: string | null;
};
export const dynamicTenantContextHeader = 'X-AIHR-Tenant-Context';
export const expectedTenantHeader = 'X-AIHR-Expected-Tenant';
const requestContextStorageKey = 'aihr.tenant.page-context';
const requestContextPattern = /^tab_[A-Za-z0-9_-]{16,}$/;
const createRequestContextId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `tab_${crypto.randomUUID().replace(/-/g, '')}`;
}
return `tab_${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
};
function requestContextStorage() {
try {
return typeof window === 'undefined' ? undefined : window.sessionStorage;
} catch {
return undefined;
}
}
function restoreOrCreateRequestContextId() {
const storage = requestContextStorage();
const storedContextId = storage?.getItem(requestContextStorageKey);
if (storedContextId && requestContextPattern.test(storedContextId)) return storedContextId;
const requestContextId = createRequestContextId();
try {
storage?.setItem(requestContextStorageKey, requestContextId);
} catch {
// Keep the in-memory identifier when browser storage is unavailable.
}
return requestContextId;
}
// sessionStorage is scoped to one browser tab and survives a refresh. It keeps
// a tab's selected tenant stable without allowing another tab sharing the
// same login token to overwrite it.
let requestContextId = restoreOrCreateRequestContextId();
let activeOperationId = 0;
/**
* Shared client-side mirror of the tenant selected by the server for this
* browser page. It is dependency-free so authentication can reset it without
* creating a store/router import cycle.
*/
export const dynamicTenantId = ref<string | number>();
export const dynamicTenantName = ref('');
export const tenantContextSwitching = ref(false);
export const tenantContextVerified = ref(false);
export const dynamicTenantContextVersion = ref(0);
export const hasDynamicTenant = computed(() => dynamicTenantId.value !== undefined && dynamicTenantId.value !== null && dynamicTenantId.value !== '');
export function currentTenantRequestContextId() {
return requestContextId;
}
function resetTenantRequestContextId() {
requestContextId = createRequestContextId();
try {
requestContextStorage()?.setItem(requestContextStorageKey, requestContextId);
} catch {
// The new in-memory identifier still separates the next identity.
}
}
function guardAgainstClonedTabContext(): Promise<void> {
if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') return Promise.resolve();
const pageInstanceId = createRequestContextId();
let channel: BroadcastChannel;
try {
channel = new BroadcastChannel('aihr-tenant-page-context');
} catch {
resetTenantRequestContextId();
return Promise.resolve();
}
return new Promise((resolve) => {
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
window.clearTimeout(timeoutId);
resolve();
};
channel.onmessage = (event: MessageEvent<{ type?: string; contextId?: string; pageInstanceId?: string }>) => {
const message = event.data;
if (message.type === 'probe' && message.contextId === requestContextId && message.pageInstanceId !== pageInstanceId) {
channel.postMessage({ type: 'claim', contextId: requestContextId, pageInstanceId: message.pageInstanceId });
return;
}
if (message.type === 'claim' && message.contextId === requestContextId && message.pageInstanceId === pageInstanceId) {
resetTenantRequestContextId();
markTenantContextUnverified();
settle();
}
};
const timeoutId = window.setTimeout(settle, 250);
const probeContextOwner = () => channel.postMessage({ type: 'probe', contextId: requestContextId, pageInstanceId });
probeContextOwner();
const closeChannel = (event: PageTransitionEvent) => {
if (event.persisted) return;
channel.close();
window.removeEventListener('pagehide', closeChannel);
window.removeEventListener('pageshow', probeOnPageShow);
};
const probeOnPageShow = (event: PageTransitionEvent) => {
if (event.persisted) probeContextOwner();
};
window.addEventListener('pagehide', closeChannel);
window.addEventListener('pageshow', probeOnPageShow);
});
}
export function currentExpectedTenantId() {
if (!tenantContextVerified.value || !hasDynamicTenant.value) return undefined;
return String(dynamicTenantId.value);
}
export function restoreDynamicTenantContext(tenant?: DynamicTenant | null) {
dynamicTenantId.value = tenant?.tenantId;
dynamicTenantName.value = tenant?.companyName?.trim() || '';
tenantContextVerified.value = true;
dynamicTenantContextVersion.value += 1;
}
/** Starts the only active switch/clear request for this browser page. */
export function beginTenantContextOperation() {
activeOperationId += 1;
dynamicTenantContextVersion.value += 1;
tenantContextSwitching.value = true;
return activeOperationId;
}
export function isTenantContextOperationCurrent(operationId: number) {
return operationId === activeOperationId;
}
export function finishTenantContextOperation(operationId: number) {
if (isTenantContextOperationCurrent(operationId)) {
tenantContextSwitching.value = false;
}
}
export function isTenantContextVersionCurrent(version: number) {
return dynamicTenantContextVersion.value === version && !tenantContextSwitching.value;
}
/**
* Fail closed when the browser cannot prove that its display matches the
* server-side context. Requests then omit the expected-tenant header and the
* server rejects any still-active dynamic context.
*/
export function markTenantContextUnverified() {
activeOperationId += 1;
dynamicTenantId.value = undefined;
dynamicTenantName.value = '';
tenantContextVerified.value = false;
dynamicTenantContextVersion.value += 1;
tenantContextSwitching.value = false;
}
/** Clears the UI mirror immediately when the authenticated identity changes. */
export function clearDynamicTenantContextState() {
markTenantContextUnverified();
resetTenantRequestContextId();
}
// Some browsers clone sessionStorage when duplicating a tab. A live owner
// claims its context over BroadcastChannel, so the clone fails closed and gets
// a fresh page context instead of sharing the owner's dynamic tenant.
const tenantContextOwnershipReady = guardAgainstClonedTabContext();
export async function waitForTenantRequestContextOwnership() {
await tenantContextOwnershipReady;
}
+84
View File
@@ -0,0 +1,84 @@
import router from '@/router';
import { dynamicClear, dynamicTenant } from '@/api/system/tenant';
import tab from '@/plugins/tab';
import {
beginTenantContextOperation,
finishTenantContextOperation,
isTenantContextOperationCurrent,
markTenantContextUnverified,
restoreDynamicTenantContext,
tenantContextSwitching,
type DynamicTenant
} from './tenant-context-state';
export {
currentExpectedTenantId,
currentTenantRequestContextId,
dynamicTenantContextHeader,
clearDynamicTenantContextState,
dynamicTenantContextVersion,
dynamicTenantId,
dynamicTenantName,
expectedTenantHeader,
hasDynamicTenant,
isTenantContextVersionCurrent,
markTenantContextUnverified,
restoreDynamicTenantContext,
tenantContextVerified,
tenantContextSwitching,
waitForTenantRequestContextOwnership,
type DynamicTenant
} from './tenant-context-state';
export async function switchDynamicTenantContext(tenant: DynamicTenant, destination = '/') {
if (tenant.tenantId === undefined || tenant.tenantId === null || String(tenant.tenantId).trim() === '') {
throw new Error('租户编号不能为空');
}
if (tenantContextSwitching.value) {
throw new Error('租户切换正在进行,请稍候');
}
const operationId = beginTenantContextOperation();
let serverContextConfirmed = false;
try {
await dynamicTenant(tenant.tenantId);
if (!isTenantContextOperationCurrent(operationId)) return;
restoreDynamicTenantContext(tenant);
serverContextConfirmed = true;
await router.push(destination);
await tab.closeAllPage();
await tab.refreshPage();
} catch (error) {
if (!serverContextConfirmed && isTenantContextOperationCurrent(operationId)) {
markTenantContextUnverified();
}
throw error;
} finally {
finishTenantContextOperation(operationId);
}
}
export async function clearDynamicTenantContext(destination = '/') {
if (tenantContextSwitching.value) {
throw new Error('租户切换正在进行,请稍候');
}
const operationId = beginTenantContextOperation();
let serverContextConfirmed = false;
try {
await dynamicClear();
if (!isTenantContextOperationCurrent(operationId)) return;
restoreDynamicTenantContext();
serverContextConfirmed = true;
await router.push(destination);
await tab.closeAllPage();
await tab.refreshPage();
} catch (error) {
if (!serverContextConfirmed && isTenantContextOperationCurrent(operationId)) {
markTenantContextUnverified();
}
throw error;
} finally {
finishTenantContextOperation(operationId);
}
}
+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 = '';