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