刘光辉
9 小时以前 0dfe84494048ce27ba8449831782128412d3eb13
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
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);
    },
  };
}