import { AUDIT_BIZ_SIGN_HEADER, createAuditCorrelationId, resolveAuditBizSign } from '#/api/onlineDev/auditHeaders';
|
|
const MAX_FIELDS = 50;
|
const MAX_FIELD_NAME_LENGTH = 64;
|
const MAX_VALUE_LENGTH = 400;
|
const MAX_TITLE_LENGTH = 400;
|
const PENDING_CONTEXT_TTL = 5 * 60 * 1000;
|
export const AUDIT_DISPLAY_FIELDS_HEADER = 'X-Audit-Display-Fields';
|
export { AUDIT_BIZ_SIGN_HEADER };
|
|
export interface AuditDisplayField {
|
displayValue: string;
|
field: string;
|
}
|
|
let pendingAuditContext: undefined | { correlationId: string; expiresAt: number; fields: AuditDisplayField[] };
|
|
const REFERENCE_COMPONENTS = new Set([
|
'groupSelect',
|
'organizeSelect',
|
'popupSelect',
|
'popupTableSelect',
|
'posSelect',
|
'relationForm',
|
'roleSelect',
|
'userSelect',
|
'usersSelect',
|
]);
|
|
const EXCLUDED_COMPONENTS = new Set(['password', 'sign', 'signature']);
|
|
function hasValue(value: any) {
|
return value !== undefined && value !== null && value !== '';
|
}
|
|
function flattenOptions(options: any[]): any[] {
|
const result: any[] = [];
|
for (const option of options || []) {
|
result.push(option);
|
const children = option?.children;
|
if (Array.isArray(children)) result.push(...flattenOptions(children));
|
}
|
return result;
|
}
|
|
function displayFromRecord(record: any, field: any): string | undefined {
|
if (!record || typeof record !== 'object') return undefined;
|
const labelKey = field?.relationField || field?.fieldNames?.label || field?.props?.label;
|
const candidates = [labelKey, 'fullName', 'orgNameTree', 'label', 'name', 'text', 'title'];
|
for (const key of candidates) {
|
if (!key || !hasValue(record[key])) continue;
|
return String(record[key]);
|
}
|
return undefined;
|
}
|
|
function displayFromOptions(value: any, field: any): string | undefined {
|
const options = flattenOptions(Array.isArray(field?.options) ? field.options : []);
|
if (!options.length) return undefined;
|
const valueKey = field?.fieldNames?.value || field?.props?.value || 'value';
|
const labelKey = field?.fieldNames?.label || field?.props?.label || 'label';
|
const values = Array.isArray(value) ? value : [value];
|
const labels = values
|
.map((item) => options.find((option) => String(option?.[valueKey]) === String(item))?.[labelKey])
|
.filter((item) => hasValue(item))
|
.map(String);
|
return labels.length ? labels.join('/') : undefined;
|
}
|
|
function sameValue(left: any, right: any) {
|
const normalize = (value: any) => (Array.isArray(value) ? value.map(String).sort() : String(value ?? ''));
|
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
|
}
|
|
function resolveDisplayValue(field: any, rawValue: any, selectedValue: any, relationValue: any): string | undefined {
|
const relationValueKey = field?.propsValue || field?.fieldNames?.value || 'id';
|
const currentRelation = relationValue && sameValue(relationValue[relationValueKey], rawValue) ? relationValue : undefined;
|
const selected = selectedValue && sameValue(selectedValue.value, rawValue) ? selectedValue.option : currentRelation;
|
if (Array.isArray(selected)) {
|
const values = selected
|
.map((item) => displayFromRecord(item, field) ?? (typeof item === 'object' ? undefined : String(item)))
|
.filter((item) => hasValue(item));
|
if (values.length) return values.join('/');
|
} else {
|
const value = displayFromRecord(selected, field);
|
if (value) return value;
|
}
|
|
const optionValue = displayFromOptions(rawValue, field);
|
if (optionValue) return optionValue;
|
|
if (REFERENCE_COMPONENTS.has(field?.__config__?.jnpfKey)) return undefined;
|
if (Array.isArray(rawValue))
|
return rawValue
|
.filter((item) => hasValue(item))
|
.map(String)
|
.join('/');
|
if (typeof rawValue === 'object') return undefined;
|
return hasValue(rawValue) ? String(rawValue) : undefined;
|
}
|
|
/**
|
* 收集主表中启用审计标题的字段显示值。客户端只提供“用户看到的内容”,
|
* 后端仍以已发布表单配置为白名单并负责最终标题组装。
|
*/
|
export function buildAuditDisplayFields(fields: any[], formData: any, selectedValues: Record<string, any> = {}, relationData: Record<string, any> = {}) {
|
const result: AuditDisplayField[] = [];
|
|
function walk(fieldList: any[]) {
|
if (!Array.isArray(fieldList) || result.length >= MAX_FIELDS) return;
|
for (const field of fieldList) {
|
const config = field?.__config__;
|
if (!config) continue;
|
const fieldName = field.__vModel__;
|
if (config.biz_log_enabled === true && !config.isSubTable && fieldName && !EXCLUDED_COMPONENTS.has(config.jnpfKey)) {
|
const displayValue = resolveDisplayValue(field, formData?.[fieldName], selectedValues[fieldName], relationData[fieldName]);
|
if (displayValue) result.push({ field: fieldName, displayValue: displayValue.slice(0, MAX_VALUE_LENGTH) });
|
}
|
if (config.jnpfKey !== 'table' && Array.isArray(config.children)) walk(config.children);
|
if (result.length >= MAX_FIELDS) return;
|
}
|
}
|
|
walk(fields);
|
return result;
|
}
|
|
function resolveSameOriginWritePath(url: string, method: string) {
|
if (!['delete', 'patch', 'post', 'put'].includes(String(method || '').toLowerCase())) return false;
|
if (typeof url !== 'string' || !url.trim() || url.startsWith('//')) return false;
|
try {
|
const browserOrigin = globalThis.location?.origin;
|
const absolute = /^[a-z][a-z\d+.-]*:/i.test(url);
|
if (absolute && !browserOrigin) return false;
|
const resolved = new URL(url, browserOrigin || 'http://audit.local');
|
if (browserOrigin && resolved.origin !== browserOrigin) return false;
|
return resolved.pathname;
|
} catch {
|
return false;
|
}
|
}
|
|
function isLimsWriteRequest(url: string, method: string) {
|
const pathname = resolveSameOriginWritePath(url, method);
|
return !!pathname && (pathname.startsWith('/api/lims/') || pathname.startsWith('/api/extend/'));
|
}
|
|
function isExcludedAuditRequest(url: string) {
|
try {
|
const resolved = new URL(url, globalThis.location?.origin || 'http://audit.local');
|
return /\/sign\/(?:verify|init-all)\/?$/i.test(resolved.pathname);
|
} catch {
|
return false;
|
}
|
}
|
|
function headerValue(headers: Record<string, any>, name: string) {
|
const key = Object.keys(headers || {}).find((item) => item.toLowerCase() === name.toLowerCase());
|
return key ? String(headers[key] || '').trim() : '';
|
}
|
|
function encodeDisplayFields(fields: AuditDisplayField[]) {
|
const limited: AuditDisplayField[] = [];
|
let remaining = MAX_TITLE_LENGTH;
|
for (const item of fields || []) {
|
if (!item?.field || !item?.displayValue || remaining <= 0 || limited.length >= MAX_FIELDS) continue;
|
const displayValue = String(item.displayValue)
|
.replaceAll(/[\r\n]+/g, ' ')
|
.trim()
|
.slice(0, remaining);
|
if (!displayValue) continue;
|
limited.push({ field: String(item.field).slice(0, MAX_FIELD_NAME_LENGTH), displayValue });
|
remaining -= displayValue.length + 1;
|
}
|
if (!limited.length) return '';
|
const bytes = new TextEncoder().encode(JSON.stringify(limited));
|
let binary = '';
|
for (const byte of bytes) binary += String.fromCodePoint(byte);
|
return globalThis.btoa(binary);
|
}
|
|
/**
|
* GlobalFormModal 的自定义 onSubmit 可能继续使用打开弹窗前闭包中的 onlineUtils。
|
* 暂存当前事件的标题字段和关联键,使事件内多个请求共用同一 operationId;后续取得
|
* 真实 biz_sign 时,真实签名会替换随机关联键并供该事件的后续请求继续复用。
|
*/
|
export function registerPendingAuditDisplayFields(fields: AuditDisplayField[]) {
|
const normalized = Array.isArray(fields) ? fields.filter((item) => item?.field && item?.displayValue) : [];
|
const correlationId = createAuditCorrelationId();
|
pendingAuditContext = { correlationId, expiresAt: Date.now() + PENDING_CONTEXT_TTL, fields: normalized };
|
return correlationId;
|
}
|
|
export function buildGlobalAuditRequestHeaders(url: string, method: string, data: any, headers: Record<string, any> = {}) {
|
if (!isLimsWriteRequest(url, method) || isExcludedAuditRequest(url)) return headers;
|
const context = pendingAuditContext && pendingAuditContext.expiresAt >= Date.now() ? pendingAuditContext : undefined;
|
if (!context) pendingAuditContext = undefined;
|
const resolvedBizSign = resolveAuditBizSign(data) || headerValue(headers, AUDIT_BIZ_SIGN_HEADER);
|
if (context && resolvedBizSign) context.correlationId = resolvedBizSign;
|
const bizSign = resolvedBizSign || context?.correlationId;
|
if (!bizSign) return headers;
|
|
const auditHeaders = { ...headers, [AUDIT_BIZ_SIGN_HEADER]: bizSign };
|
if (context && !auditHeaders[AUDIT_DISPLAY_FIELDS_HEADER]) {
|
const encoded = encodeDisplayFields(context.fields);
|
if (encoded) auditHeaders[AUDIT_DISPLAY_FIELDS_HEADER] = encoded;
|
}
|
return auditHeaders;
|
}
|
|
/**
|
* 为当前在线表单的自定义提交脚本创建局部 onlineUtils。
|
* 只对同源 LIMS 写请求附加标题显示值,不影响 GET、平台接口和外部地址。
|
*/
|
export function createAuditRequestOnlineUtils(
|
baseOnlineUtils: any,
|
getAuditDisplayFields: () => AuditDisplayField[],
|
getBizSign?: () => unknown,
|
correlationId = createAuditCorrelationId(),
|
) {
|
if (!baseOnlineUtils || typeof baseOnlineUtils.request !== 'function') return baseOnlineUtils;
|
let activeCorrelationId = correlationId;
|
return {
|
...baseOnlineUtils,
|
request(url: string, method: string, data = {}, headers = {}) {
|
if (!isLimsWriteRequest(url, method) || isExcludedAuditRequest(url)) return baseOnlineUtils.request(url, method, data, headers);
|
const encoded = encodeDisplayFields(getAuditDisplayFields?.() || []);
|
const callbackBizSign = getBizSign?.();
|
const resolvedBizSign =
|
resolveAuditBizSign(callbackBizSign == null ? data : { biz_sign: callbackBizSign }) || headerValue(headers, AUDIT_BIZ_SIGN_HEADER);
|
if (resolvedBizSign) activeCorrelationId = resolvedBizSign;
|
const bizSign = resolvedBizSign || activeCorrelationId;
|
const auditHeaders = { ...headers };
|
if (encoded) auditHeaders[AUDIT_DISPLAY_FIELDS_HEADER] = encoded;
|
if (bizSign) auditHeaders[AUDIT_BIZ_SIGN_HEADER] = bizSign;
|
return baseOnlineUtils.request(url, method, data, auditHeaders);
|
},
|
};
|
}
|
|
/** 为已完成电子签名的自定义按钮请求附加签名头,数据接口等同源写请求同样适用。 */
|
export function createSignedAuditRequestOnlineUtils(baseOnlineUtils: any, getBizSign: () => unknown) {
|
if (!baseOnlineUtils || typeof baseOnlineUtils.request !== 'function') return baseOnlineUtils;
|
return {
|
...baseOnlineUtils,
|
request(url: string, method: string, data = {}, headers = {}) {
|
if (!resolveSameOriginWritePath(url, method) || isExcludedAuditRequest(url)) {
|
return baseOnlineUtils.request(url, method, data, headers);
|
}
|
const bizSign = resolveAuditBizSign({ biz_sign: getBizSign?.() });
|
const auditHeaders = bizSign ? { ...headers, [AUDIT_BIZ_SIGN_HEADER]: bizSign } : headers;
|
return baseOnlineUtils.request(url, method, data, auditHeaders);
|
},
|
};
|
}
|