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

- 重构运营总览、侧栏和内容运营工作台\n- 增加大喇叭、成果审核与运营统计链路\n- 补齐租户、知识空间、分类和知识维护闭环\n- 加固标签页租户上下文、停用租户写保护与迁移预检
This commit is contained in:
2026-07-23 01:24:22 +08:00
parent 333c365c6f
commit 163b08ecfe
94 changed files with 8610 additions and 1114 deletions
+46 -1
View File
@@ -11,18 +11,54 @@ import { getLanguage } from '@/lang';
import { encryptBase64, encryptWithAes, generateAesKey, decryptWithAes, decryptBase64 } from '@/utils/crypto';
import { encrypt, decrypt } from '@/utils/jsencrypt';
import router from '@/router';
import {
currentExpectedTenantId,
currentTenantRequestContextId,
dynamicTenantContextHeader,
expectedTenantHeader,
markTenantContextUnverified
} from './tenant-context-state';
const encryptHeader = 'encrypt-key';
let downloadLoadingInstance: LoadingInstance;
// 是否显示重新登录
export const isRelogin = { show: false };
const tenantContextHeaders = () => {
const expectedTenantId = currentExpectedTenantId();
return {
[dynamicTenantContextHeader]: currentTenantRequestContextId(),
...(expectedTenantId ? { [expectedTenantHeader]: expectedTenantId } : {})
};
};
export const globalHeaders = () => {
return {
Authorization: 'Bearer ' + getToken(),
clientid: import.meta.env.VITE_APP_CLIENT_ID
clientid: import.meta.env.VITE_APP_CLIENT_ID,
...tenantContextHeaders()
};
};
/** Lets native upload components fail closed as well as Axios requests. */
export function reconcileTenantContextResponse(response: unknown) {
if (!response || typeof response !== 'object') return false;
const { code, msg } = response as { code?: unknown; msg?: unknown };
if (code === 409 && msg === '租户上下文已变更,请刷新后重试') {
markTenantContextUnverified();
return true;
}
return false;
}
/** Parses blob error bodies returned by native download/audio requests. */
export async function reconcileTenantContextBlobResponse(response: unknown) {
if (typeof Blob === 'undefined' || !(response instanceof Blob)) return false;
try {
return reconcileTenantContextResponse(JSON.parse(await response.text()));
} catch {
return false;
}
}
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
axios.defaults.headers['clientid'] = import.meta.env.VITE_APP_CLIENT_ID;
// 创建 axios 实例
@@ -40,6 +76,13 @@ service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 对应国际化资源文件后缀
config.headers['Content-Language'] = getLanguage();
config.headers[dynamicTenantContextHeader] = currentTenantRequestContextId();
const expectedTenantId = currentExpectedTenantId();
if (expectedTenantId) {
config.headers[expectedTenantHeader] = expectedTenantId;
} else {
delete config.headers[expectedTenantHeader];
}
const isToken = config.headers?.isToken === false;
// 是否需要防止数据重复提交
@@ -124,6 +167,7 @@ service.interceptors.response.use(
const code = res.data.code || HttpStatus.SUCCESS;
// 获取错误信息
const msg = errorCode[code] || res.data.msg || errorCode['default'];
reconcileTenantContextResponse(res.data);
// 二进制数据则直接返回
if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
return res.data;
@@ -198,6 +242,7 @@ export function download(url: string, params: any, fileName: string) {
const blob = new Blob([resp]);
const resText = await blob.text();
const rspObj = JSON.parse(resText);
reconcileTenantContextResponse(rspObj);
const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default'];
ElMessage.error(errMsg);
}
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
beginTenantContextOperation,
clearDynamicTenantContextState,
currentExpectedTenantId,
currentTenantRequestContextId,
dynamicTenantContextVersion,
dynamicTenantId,
dynamicTenantName,
finishTenantContextOperation,
restoreDynamicTenantContext,
tenantContextVerified,
tenantContextSwitching
} from './tenant-context-state';
describe('tenant context state', () => {
beforeEach(() => {
clearDynamicTenantContextState();
});
it('clears the previous identity context and advances its version', () => {
const version = dynamicTenantContextVersion.value;
const previousRequestContextId = currentTenantRequestContextId();
restoreDynamicTenantContext({ tenantId: 'tenant-a', companyName: 'A 项目' });
expect(dynamicTenantId.value).toBe('tenant-a');
expect(dynamicTenantName.value).toBe('A 项目');
expect(tenantContextVerified.value).toBe(true);
expect(currentExpectedTenantId()).toBe('tenant-a');
expect(currentTenantRequestContextId()).toMatch(/^tab_[A-Za-z0-9_-]{16,}$/);
expect(dynamicTenantContextVersion.value).toBe(version + 1);
tenantContextSwitching.value = true;
clearDynamicTenantContextState();
expect(dynamicTenantId.value).toBeUndefined();
expect(dynamicTenantName.value).toBe('');
expect(tenantContextVerified.value).toBe(false);
expect(currentExpectedTenantId()).toBeUndefined();
expect(currentTenantRequestContextId()).not.toBe(previousRequestContextId);
expect(tenantContextSwitching.value).toBe(false);
expect(dynamicTenantContextVersion.value).toBe(version + 2);
});
it('does not let a stale request unlock a newer tenant operation', () => {
const firstOperation = beginTenantContextOperation();
clearDynamicTenantContextState();
const secondOperation = beginTenantContextOperation();
finishTenantContextOperation(firstOperation);
expect(tenantContextSwitching.value).toBe(true);
finishTenantContextOperation(secondOperation);
expect(tenantContextSwitching.value).toBe(false);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { computed, ref } from 'vue';
export type DynamicTenant = {
tenantId: string | number;
companyName?: string | null;
};
export const dynamicTenantContextHeader = 'X-AIHR-Tenant-Context';
export const expectedTenantHeader = 'X-AIHR-Expected-Tenant';
const requestContextStorageKey = 'aihr.tenant.page-context';
const requestContextPattern = /^tab_[A-Za-z0-9_-]{16,}$/;
const createRequestContextId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `tab_${crypto.randomUUID().replace(/-/g, '')}`;
}
return `tab_${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
};
function requestContextStorage() {
try {
return typeof window === 'undefined' ? undefined : window.sessionStorage;
} catch {
return undefined;
}
}
function restoreOrCreateRequestContextId() {
const storage = requestContextStorage();
const storedContextId = storage?.getItem(requestContextStorageKey);
if (storedContextId && requestContextPattern.test(storedContextId)) return storedContextId;
const requestContextId = createRequestContextId();
try {
storage?.setItem(requestContextStorageKey, requestContextId);
} catch {
// Keep the in-memory identifier when browser storage is unavailable.
}
return requestContextId;
}
// sessionStorage is scoped to one browser tab and survives a refresh. It keeps
// a tab's selected tenant stable without allowing another tab sharing the
// same login token to overwrite it.
let requestContextId = restoreOrCreateRequestContextId();
let activeOperationId = 0;
/**
* Shared client-side mirror of the tenant selected by the server for this
* browser page. It is dependency-free so authentication can reset it without
* creating a store/router import cycle.
*/
export const dynamicTenantId = ref<string | number>();
export const dynamicTenantName = ref('');
export const tenantContextSwitching = ref(false);
export const tenantContextVerified = ref(false);
export const dynamicTenantContextVersion = ref(0);
export const hasDynamicTenant = computed(() => dynamicTenantId.value !== undefined && dynamicTenantId.value !== null && dynamicTenantId.value !== '');
export function currentTenantRequestContextId() {
return requestContextId;
}
function resetTenantRequestContextId() {
requestContextId = createRequestContextId();
try {
requestContextStorage()?.setItem(requestContextStorageKey, requestContextId);
} catch {
// The new in-memory identifier still separates the next identity.
}
}
function guardAgainstClonedTabContext(): Promise<void> {
if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') return Promise.resolve();
const pageInstanceId = createRequestContextId();
let channel: BroadcastChannel;
try {
channel = new BroadcastChannel('aihr-tenant-page-context');
} catch {
resetTenantRequestContextId();
return Promise.resolve();
}
return new Promise((resolve) => {
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
window.clearTimeout(timeoutId);
resolve();
};
channel.onmessage = (event: MessageEvent<{ type?: string; contextId?: string; pageInstanceId?: string }>) => {
const message = event.data;
if (message.type === 'probe' && message.contextId === requestContextId && message.pageInstanceId !== pageInstanceId) {
channel.postMessage({ type: 'claim', contextId: requestContextId, pageInstanceId: message.pageInstanceId });
return;
}
if (message.type === 'claim' && message.contextId === requestContextId && message.pageInstanceId === pageInstanceId) {
resetTenantRequestContextId();
markTenantContextUnverified();
settle();
}
};
const timeoutId = window.setTimeout(settle, 250);
const probeContextOwner = () => channel.postMessage({ type: 'probe', contextId: requestContextId, pageInstanceId });
probeContextOwner();
const closeChannel = (event: PageTransitionEvent) => {
if (event.persisted) return;
channel.close();
window.removeEventListener('pagehide', closeChannel);
window.removeEventListener('pageshow', probeOnPageShow);
};
const probeOnPageShow = (event: PageTransitionEvent) => {
if (event.persisted) probeContextOwner();
};
window.addEventListener('pagehide', closeChannel);
window.addEventListener('pageshow', probeOnPageShow);
});
}
export function currentExpectedTenantId() {
if (!tenantContextVerified.value || !hasDynamicTenant.value) return undefined;
return String(dynamicTenantId.value);
}
export function restoreDynamicTenantContext(tenant?: DynamicTenant | null) {
dynamicTenantId.value = tenant?.tenantId;
dynamicTenantName.value = tenant?.companyName?.trim() || '';
tenantContextVerified.value = true;
dynamicTenantContextVersion.value += 1;
}
/** Starts the only active switch/clear request for this browser page. */
export function beginTenantContextOperation() {
activeOperationId += 1;
dynamicTenantContextVersion.value += 1;
tenantContextSwitching.value = true;
return activeOperationId;
}
export function isTenantContextOperationCurrent(operationId: number) {
return operationId === activeOperationId;
}
export function finishTenantContextOperation(operationId: number) {
if (isTenantContextOperationCurrent(operationId)) {
tenantContextSwitching.value = false;
}
}
export function isTenantContextVersionCurrent(version: number) {
return dynamicTenantContextVersion.value === version && !tenantContextSwitching.value;
}
/**
* Fail closed when the browser cannot prove that its display matches the
* server-side context. Requests then omit the expected-tenant header and the
* server rejects any still-active dynamic context.
*/
export function markTenantContextUnverified() {
activeOperationId += 1;
dynamicTenantId.value = undefined;
dynamicTenantName.value = '';
tenantContextVerified.value = false;
dynamicTenantContextVersion.value += 1;
tenantContextSwitching.value = false;
}
/** Clears the UI mirror immediately when the authenticated identity changes. */
export function clearDynamicTenantContextState() {
markTenantContextUnverified();
resetTenantRequestContextId();
}
// Some browsers clone sessionStorage when duplicating a tab. A live owner
// claims its context over BroadcastChannel, so the clone fails closed and gets
// a fresh page context instead of sharing the owner's dynamic tenant.
const tenantContextOwnershipReady = guardAgainstClonedTabContext();
export async function waitForTenantRequestContextOwnership() {
await tenantContextOwnershipReady;
}
+84
View File
@@ -0,0 +1,84 @@
import router from '@/router';
import { dynamicClear, dynamicTenant } from '@/api/system/tenant';
import tab from '@/plugins/tab';
import {
beginTenantContextOperation,
finishTenantContextOperation,
isTenantContextOperationCurrent,
markTenantContextUnverified,
restoreDynamicTenantContext,
tenantContextSwitching,
type DynamicTenant
} from './tenant-context-state';
export {
currentExpectedTenantId,
currentTenantRequestContextId,
dynamicTenantContextHeader,
clearDynamicTenantContextState,
dynamicTenantContextVersion,
dynamicTenantId,
dynamicTenantName,
expectedTenantHeader,
hasDynamicTenant,
isTenantContextVersionCurrent,
markTenantContextUnverified,
restoreDynamicTenantContext,
tenantContextVerified,
tenantContextSwitching,
waitForTenantRequestContextOwnership,
type DynamicTenant
} from './tenant-context-state';
export async function switchDynamicTenantContext(tenant: DynamicTenant, destination = '/') {
if (tenant.tenantId === undefined || tenant.tenantId === null || String(tenant.tenantId).trim() === '') {
throw new Error('租户编号不能为空');
}
if (tenantContextSwitching.value) {
throw new Error('租户切换正在进行,请稍候');
}
const operationId = beginTenantContextOperation();
let serverContextConfirmed = false;
try {
await dynamicTenant(tenant.tenantId);
if (!isTenantContextOperationCurrent(operationId)) return;
restoreDynamicTenantContext(tenant);
serverContextConfirmed = true;
await router.push(destination);
await tab.closeAllPage();
await tab.refreshPage();
} catch (error) {
if (!serverContextConfirmed && isTenantContextOperationCurrent(operationId)) {
markTenantContextUnverified();
}
throw error;
} finally {
finishTenantContextOperation(operationId);
}
}
export async function clearDynamicTenantContext(destination = '/') {
if (tenantContextSwitching.value) {
throw new Error('租户切换正在进行,请稍候');
}
const operationId = beginTenantContextOperation();
let serverContextConfirmed = false;
try {
await dynamicClear();
if (!isTenantContextOperationCurrent(operationId)) return;
restoreDynamicTenantContext();
serverContextConfirmed = true;
await router.push(destination);
await tab.closeAllPage();
await tab.refreshPage();
} catch (error) {
if (!serverContextConfirmed && isTenantContextOperationCurrent(operationId)) {
markTenantContextUnverified();
}
throw error;
} finally {
finishTenantContextOperation(operationId);
}
}