@svadmin/core 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -1
- package/src/approval-contract.ts +131 -0
- package/src/dashboard-contract.ts +72 -0
- package/src/date-time.ts +55 -0
- package/src/enterprise-maturity.ts +54 -0
- package/src/export-format.ts +44 -0
- package/src/export-hooks.svelte.ts +169 -8
- package/src/filter-values.ts +42 -0
- package/src/gantt.ts +84 -0
- package/src/i18n.svelte.ts +306 -2
- package/src/import-contract.ts +94 -6
- package/src/import-hooks.svelte.ts +13 -5
- package/src/index.ts +36 -1
- package/src/pivot.ts +367 -0
- package/src/query-session.svelte.ts +6 -1
- package/src/schema-form.ts +204 -0
- package/src/schema.ts +7 -0
- package/src/spreadsheet-clipboard.ts +45 -0
- package/src/spreadsheet-workbook.ts +64 -0
- package/src/spreadsheet-xlsx.ts +181 -0
- package/src/task-hooks.svelte.ts +115 -46
- package/src/types.ts +18 -0
package/src/schema.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export { createExactSchemaValidator, checkExact } from './schema-validation';
|
|
2
|
+
export {
|
|
3
|
+
readSchemaFormPath, matchesSchemaFormCondition, isSchemaFormNodeVisible, validateSchemaFormValue,
|
|
4
|
+
assertSchemaFormSchema, prepareSchemaFormValue,
|
|
5
|
+
} from './schema-form';
|
|
6
|
+
export type {
|
|
7
|
+
SchemaFormScalar, SchemaFormCondition, SchemaFormSchema, SchemaFormValidationError,
|
|
8
|
+
} from './schema-form';
|
|
2
9
|
export { snapshotPlainData } from './plain-data';
|
|
3
10
|
export type { JsonValue } from './plain-data';
|
|
4
11
|
export { decodeAuditEntry, decodeAuditCreate, decodeAuditQuery, decodeAuditEntries } from './audit-contract';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import Papa from 'papaparse';
|
|
2
|
+
|
|
3
|
+
/** 复制计算值而非可执行公式;文本中和与字段转义分别处理。 */
|
|
4
|
+
export function serializeSpreadsheetClipboard(rows: (string | number)[][], format: 'tsv' | 'csv'): string | undefined {
|
|
5
|
+
const width = rows[0]?.length ?? 0;
|
|
6
|
+
if (!rows.length || rows.length > 1000 || !width || width > 100 || rows.length * width > 10_000) return;
|
|
7
|
+
const safe: string[][] = [];
|
|
8
|
+
for (const row of rows) {
|
|
9
|
+
if (row.length !== width) return;
|
|
10
|
+
const values: string[] = [];
|
|
11
|
+
for (const value of row) {
|
|
12
|
+
if (typeof value !== 'string' && typeof value !== 'number') return;
|
|
13
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return;
|
|
14
|
+
const text = typeof value === 'string' && (/^[\t\r\n]/.test(value) || /^\s*[=+@-]/.test(value))
|
|
15
|
+
? `'${value}` : String(value);
|
|
16
|
+
if (text.length > 10_000) return;
|
|
17
|
+
values.push(text);
|
|
18
|
+
}
|
|
19
|
+
safe.push(values);
|
|
20
|
+
}
|
|
21
|
+
const result = Papa.unparse(safe, { delimiter: format === 'csv' ? ',' : '\t', newline: '\n', quotes: true });
|
|
22
|
+
return result.length <= 1_000_000 ? result : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 剪贴板采用显式分隔符,纯文本中的逗号不自动拆列。 */
|
|
26
|
+
export function parseSpreadsheetClipboard(text: string, format: 'tsv' | 'csv' = 'tsv'): string[][] | undefined {
|
|
27
|
+
if (typeof text !== 'string' || text.length === 0 || text.length > 1_000_000
|
|
28
|
+
|| (format !== 'tsv' && format !== 'csv')) return undefined;
|
|
29
|
+
const parsed = Papa.parse<string[]>(text, {
|
|
30
|
+
delimiter: format === 'csv' ? ',' : '\t',
|
|
31
|
+
dynamicTyping: false,
|
|
32
|
+
skipEmptyLines: false,
|
|
33
|
+
preview: 1002,
|
|
34
|
+
});
|
|
35
|
+
if (parsed.errors.length || parsed.meta.truncated) return undefined;
|
|
36
|
+
const rows = parsed.data;
|
|
37
|
+
// 只去掉行终止符生成的末尾空记录,保留中间空行和空单元格。
|
|
38
|
+
if (/[\r\n]$/.test(text) && rows.at(-1)?.length === 1 && rows.at(-1)?.[0] === '') rows.pop();
|
|
39
|
+
const columns = rows[0]?.length ?? 0;
|
|
40
|
+
if (!rows.length || rows.length > 1000 || columns < 1 || columns > 100 || rows.length * columns > 10_000
|
|
41
|
+
|| rows.some(row => row.length !== columns || row.some(value => typeof value !== 'string' || value.length > 10_000))) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Type, type Static } from '@sinclair/typebox';
|
|
2
|
+
import { snapshotPlainData } from './plain-data';
|
|
3
|
+
import { checkExact } from './schema-validation';
|
|
4
|
+
|
|
5
|
+
export const SPREADSHEET_WORKBOOK_LIMITS = Object.freeze({
|
|
6
|
+
maxSheets: 32, maxRows: 1000, maxCols: 100, maxCells: 10_000,
|
|
7
|
+
maxCellLength: 10_000, maxTotalText: 1_000_000, maxJsonLength: 10_000_000,
|
|
8
|
+
});
|
|
9
|
+
const formatSchema = Type.Object({
|
|
10
|
+
numberFormat: Type.Optional(Type.Union([Type.Literal('general'), Type.Literal('number'), Type.Literal('percent')])),
|
|
11
|
+
decimalPlaces: Type.Optional(Type.Integer({ minimum: 0, maximum: 20 })),
|
|
12
|
+
bold: Type.Optional(Type.Boolean()), italic: Type.Optional(Type.Boolean()),
|
|
13
|
+
align: Type.Optional(Type.Union([Type.Literal('left'), Type.Literal('center'), Type.Literal('right')])),
|
|
14
|
+
}, { additionalProperties: false });
|
|
15
|
+
export type SpreadsheetCellFormat = Static<typeof formatSchema>;
|
|
16
|
+
const sheetSchema = Type.Object({
|
|
17
|
+
id: Type.String({ minLength: 1, maxLength: 200 }), name: Type.String({ minLength: 1, maxLength: 200 }),
|
|
18
|
+
rows: Type.Integer({ minimum: 1, maximum: 1000 }), cols: Type.Integer({ minimum: 1, maximum: 100 }),
|
|
19
|
+
cells: Type.Record(Type.String({ pattern: '^[A-Z]{1,2}[1-9][0-9]{0,3}$' }), Type.String({ maxLength: 10_000 })),
|
|
20
|
+
formats: Type.Optional(Type.Record(Type.String({ pattern: '^[A-Z]{1,2}[1-9][0-9]{0,3}$' }), formatSchema)),
|
|
21
|
+
}, { additionalProperties: false });
|
|
22
|
+
export type SpreadsheetSheet = Static<typeof sheetSchema>;
|
|
23
|
+
const workbookSchema = Type.Object({
|
|
24
|
+
protocolVersion: Type.Literal(1), sheets: Type.Array(sheetSchema, { minItems: 1, maxItems: 32 }),
|
|
25
|
+
activeSheetId: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
|
|
26
|
+
}, { additionalProperties: false });
|
|
27
|
+
export type SpreadsheetWorkbook = Static<typeof workbookSchema>;
|
|
28
|
+
function inside(key: string, sheet: SpreadsheetSheet): boolean {
|
|
29
|
+
const match = /^([A-Z]{1,2})([1-9][0-9]{0,3})$/.exec(key);
|
|
30
|
+
if (!match) return false;
|
|
31
|
+
let column = 0;
|
|
32
|
+
for (const char of match[1] ?? '') column = column * 26 + char.charCodeAt(0) - 64;
|
|
33
|
+
return column <= sheet.cols && Number(match[2]) <= sheet.rows;
|
|
34
|
+
}
|
|
35
|
+
export function snapshotSpreadsheetWorkbook(value: unknown): SpreadsheetWorkbook | undefined {
|
|
36
|
+
try {
|
|
37
|
+
const plain = snapshotPlainData(value);
|
|
38
|
+
if (!checkExact(workbookSchema, plain)) return;
|
|
39
|
+
const ids = new Set<string>();
|
|
40
|
+
let totalText = 0;
|
|
41
|
+
for (const sheet of plain.sheets) {
|
|
42
|
+
if (!sheet.id.trim() || !sheet.name.trim() || ids.has(sheet.id)
|
|
43
|
+
|| sheet.rows * sheet.cols > SPREADSHEET_WORKBOOK_LIMITS.maxCells) return;
|
|
44
|
+
ids.add(sheet.id);
|
|
45
|
+
for (const [key, text] of Object.entries(sheet.cells)) {
|
|
46
|
+
if (!inside(key, sheet)) return;
|
|
47
|
+
totalText += text.length;
|
|
48
|
+
if (totalText > SPREADSHEET_WORKBOOK_LIMITS.maxTotalText) return;
|
|
49
|
+
}
|
|
50
|
+
for (const key of Object.keys(sheet.formats ?? {})) if (!inside(key, sheet)) return;
|
|
51
|
+
}
|
|
52
|
+
if (plain.activeSheetId !== undefined && !ids.has(plain.activeSheetId)) return;
|
|
53
|
+
if (JSON.stringify(plain).length > SPREADSHEET_WORKBOOK_LIMITS.maxJsonLength) return;
|
|
54
|
+
return plain;
|
|
55
|
+
} catch { return; }
|
|
56
|
+
}
|
|
57
|
+
export function serializeSpreadsheetWorkbook(value: unknown): string | undefined {
|
|
58
|
+
const workbook = snapshotSpreadsheetWorkbook(value);
|
|
59
|
+
return workbook ? JSON.stringify(workbook) : undefined;
|
|
60
|
+
}
|
|
61
|
+
export function parseSpreadsheetWorkbook(value: string): SpreadsheetWorkbook | undefined {
|
|
62
|
+
if (typeof value !== 'string' || value.length > SPREADSHEET_WORKBOOK_LIMITS.maxJsonLength) return;
|
|
63
|
+
try { return snapshotSpreadsheetWorkbook(JSON.parse(value)); } catch { return; }
|
|
64
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { Cell, Workbook, Worksheet } from 'exceljs';
|
|
2
|
+
import { snapshotSpreadsheetWorkbook, type SpreadsheetWorkbook, type SpreadsheetCellFormat } from './spreadsheet-workbook';
|
|
3
|
+
|
|
4
|
+
export interface SpreadsheetXlsxOptions {
|
|
5
|
+
exceljs?: { Workbook: new () => Workbook };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SpreadsheetXlsxImportLimits {
|
|
9
|
+
maxBytes?: number;
|
|
10
|
+
maxSheets?: number;
|
|
11
|
+
maxRows?: number;
|
|
12
|
+
maxCols?: number;
|
|
13
|
+
maxCells?: number;
|
|
14
|
+
maxCellLength?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SpreadsheetXlsxImportOptions extends SpreadsheetXlsxImportLimits {
|
|
18
|
+
exceljs?: { Workbook: new () => Workbook };
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_IMPORT_LIMITS: Required<SpreadsheetXlsxImportLimits> = {
|
|
23
|
+
maxBytes: 25 * 1024 * 1024,
|
|
24
|
+
maxSheets: 32,
|
|
25
|
+
maxRows: 1000,
|
|
26
|
+
maxCols: 100,
|
|
27
|
+
maxCells: 10_000,
|
|
28
|
+
maxCellLength: 10_000,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function checkImport(signal: AbortSignal | undefined): void {
|
|
32
|
+
if (signal?.aborted) throw new Error('aborted');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cellText(cell: { value: unknown; formula?: string; result?: unknown }): string {
|
|
36
|
+
if (typeof cell.formula === 'string') return `=${cell.formula}`;
|
|
37
|
+
if (cell.value === null || cell.value === undefined) return '';
|
|
38
|
+
if (typeof cell.value === 'string') return cell.value;
|
|
39
|
+
if (typeof cell.value === 'number' || typeof cell.value === 'boolean') return String(cell.value);
|
|
40
|
+
if (typeof cell.value === 'object' && cell.value !== null && 'text' in cell.value
|
|
41
|
+
&& typeof cell.value.text === 'string') return cell.value.text;
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cellFormat(cell: Cell): SpreadsheetCellFormat | undefined {
|
|
46
|
+
const format: SpreadsheetCellFormat = {};
|
|
47
|
+
if (cell.font?.bold !== undefined) format.bold = cell.font.bold;
|
|
48
|
+
if (cell.font?.italic !== undefined) format.italic = cell.font.italic;
|
|
49
|
+
if (cell.alignment?.horizontal === 'left' || cell.alignment?.horizontal === 'center'
|
|
50
|
+
|| cell.alignment?.horizontal === 'right') format.align = cell.alignment.horizontal;
|
|
51
|
+
if (cell.numFmt?.includes('%')) format.numberFormat = 'percent';
|
|
52
|
+
else if (cell.numFmt?.includes('0') || cell.numFmt?.includes('#')) format.numberFormat = 'number';
|
|
53
|
+
return Object.keys(format).length > 0 ? format : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function worksheetData(
|
|
57
|
+
worksheet: Worksheet,
|
|
58
|
+
limits: Required<SpreadsheetXlsxImportLimits>,
|
|
59
|
+
signal: AbortSignal | undefined,
|
|
60
|
+
): SpreadsheetWorkbook['sheets'][number] {
|
|
61
|
+
const rows = worksheet.rowCount;
|
|
62
|
+
const cols = worksheet.columnCount;
|
|
63
|
+
if (!Number.isSafeInteger(rows) || rows <= 0 || rows > limits.maxRows
|
|
64
|
+
|| !Number.isSafeInteger(cols) || cols <= 0 || cols > limits.maxCols
|
|
65
|
+
|| rows * cols > limits.maxCells) throw new Error('limit');
|
|
66
|
+
const cells: Record<string, string> = {};
|
|
67
|
+
const formats: Record<string, SpreadsheetCellFormat> = {};
|
|
68
|
+
for (let row = 1; row <= rows; row += 1) {
|
|
69
|
+
checkImport(signal);
|
|
70
|
+
for (let col = 1; col <= cols; col += 1) {
|
|
71
|
+
const cell = worksheet.getCell(row, col);
|
|
72
|
+
const text = cellText(cell);
|
|
73
|
+
if (text.length > limits.maxCellLength) throw new Error('limit');
|
|
74
|
+
const format = cellFormat(cell);
|
|
75
|
+
if (text !== '') {
|
|
76
|
+
cells[cell.address] = text;
|
|
77
|
+
}
|
|
78
|
+
if (format) formats[cell.address] = format;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
// XLSX 不保存 SVAdmin 的内部 sheet id;名称是唯一可复现的导入身份。
|
|
83
|
+
id: worksheet.name,
|
|
84
|
+
name: worksheet.name,
|
|
85
|
+
rows,
|
|
86
|
+
cols,
|
|
87
|
+
cells,
|
|
88
|
+
...(Object.keys(formats).length > 0 ? { formats } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 读取有界 XLSX;失败、超限或取消时原子返回 undefined。 */
|
|
93
|
+
export async function parseSpreadsheetXlsx(
|
|
94
|
+
bytes: unknown,
|
|
95
|
+
options: SpreadsheetXlsxImportOptions = {},
|
|
96
|
+
): Promise<SpreadsheetWorkbook | undefined> {
|
|
97
|
+
const limits = {
|
|
98
|
+
...DEFAULT_IMPORT_LIMITS,
|
|
99
|
+
...Object.fromEntries(Object.entries(options).filter(([key]) => key.startsWith('max'))),
|
|
100
|
+
} as Required<SpreadsheetXlsxImportLimits>;
|
|
101
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength > limits.maxBytes) return;
|
|
102
|
+
try {
|
|
103
|
+
checkImport(options.signal);
|
|
104
|
+
const ExcelJS = options.exceljs ?? (await import('exceljs')).default;
|
|
105
|
+
checkImport(options.signal);
|
|
106
|
+
const workbook = new ExcelJS.Workbook();
|
|
107
|
+
type XlsxInput = Parameters<typeof workbook.xlsx.load>[0];
|
|
108
|
+
const xlsxBytes = new Uint8Array(bytes).slice().buffer as ArrayBuffer;
|
|
109
|
+
await workbook.xlsx.load(xlsxBytes as XlsxInput);
|
|
110
|
+
checkImport(options.signal);
|
|
111
|
+
if (workbook.worksheets.length === 0 || workbook.worksheets.length > limits.maxSheets) return;
|
|
112
|
+
const sheets = workbook.worksheets.map((worksheet) => worksheetData(worksheet, limits, options.signal));
|
|
113
|
+
const activeIndex = workbook.views[0]?.activeTab ?? 0;
|
|
114
|
+
const activeSheetId = sheets[activeIndex]?.id ?? sheets[0]?.id;
|
|
115
|
+
if (!activeSheetId) return;
|
|
116
|
+
return snapshotSpreadsheetWorkbook({ protocolVersion: 1, activeSheetId, sheets });
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function numberFormat(format: string | undefined, decimalPlaces: number | undefined): string | undefined {
|
|
123
|
+
const places = decimalPlaces === undefined ? 2 : decimalPlaces;
|
|
124
|
+
const fraction = places ? `.${'0'.repeat(places)}` : '';
|
|
125
|
+
if (format === 'percent') return `0${fraction}%`;
|
|
126
|
+
if (format === 'number') return `#,##0${fraction}`;
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 公式按文本保留,避免将不可信内容升级为 Excel 可执行公式。 */
|
|
131
|
+
export async function serializeSpreadsheetXlsx(
|
|
132
|
+
value: unknown, options: SpreadsheetXlsxOptions = {},
|
|
133
|
+
): Promise<Uint8Array | undefined> {
|
|
134
|
+
const workbook = snapshotSpreadsheetWorkbook(value);
|
|
135
|
+
if (!workbook) return;
|
|
136
|
+
const names = new Set<string>();
|
|
137
|
+
for (const sheet of workbook.sheets) {
|
|
138
|
+
const name = sheet.name.toLowerCase();
|
|
139
|
+
if (sheet.name.length > 31 || /[\\/*?:[\]]/.test(sheet.name)
|
|
140
|
+
|| [...sheet.name].some(character => character.charCodeAt(0) < 32)
|
|
141
|
+
|| sheet.name.startsWith("'") || sheet.name.endsWith("'")
|
|
142
|
+
|| name === 'history' || names.has(name)) return;
|
|
143
|
+
names.add(name);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const ExcelJS = options.exceljs ?? (await import('exceljs')).default;
|
|
147
|
+
const output = new ExcelJS.Workbook();
|
|
148
|
+
output.views = [{
|
|
149
|
+
x: 0, y: 0, width: 1200, height: 800, firstSheet: 0, visibility: 'visible',
|
|
150
|
+
activeTab: Math.max(0, workbook.sheets.findIndex(sheet => sheet.id === workbook.activeSheetId)),
|
|
151
|
+
}];
|
|
152
|
+
for (const sheet of workbook.sheets) {
|
|
153
|
+
const target = output.addWorksheet(sheet.name);
|
|
154
|
+
// 空白尾部也属于网格,避免导出后尺寸缩小到最后一个非空单元格。
|
|
155
|
+
target.getCell(sheet.rows, sheet.cols).value = '';
|
|
156
|
+
const addresses = new Set([...Object.keys(sheet.cells), ...Object.keys(sheet.formats ?? {})]);
|
|
157
|
+
for (const address of addresses) {
|
|
158
|
+
const raw = sheet.cells[address] ?? '';
|
|
159
|
+
const cell = target.getCell(address);
|
|
160
|
+
const format = sheet.formats?.[address];
|
|
161
|
+
const numeric = Number(raw);
|
|
162
|
+
const explicitNumber = format?.numberFormat === 'number' || format?.numberFormat === 'percent';
|
|
163
|
+
cell.value = explicitNumber && raw !== '' && Number.isFinite(numeric)
|
|
164
|
+
&& String(numeric) === raw && Math.abs(numeric) <= Number.MAX_SAFE_INTEGER ? numeric : raw;
|
|
165
|
+
if (format) {
|
|
166
|
+
cell.font = {
|
|
167
|
+
...(format.bold === undefined ? {} : { bold: format.bold }),
|
|
168
|
+
...(format.italic === undefined ? {} : { italic: format.italic }),
|
|
169
|
+
};
|
|
170
|
+
if (format.align !== undefined) cell.alignment = { horizontal: format.align };
|
|
171
|
+
const numFmt = numberFormat(format.numberFormat, format.decimalPlaces);
|
|
172
|
+
if (numFmt !== undefined) cell.numFmt = numFmt;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const result = await output.xlsx.writeBuffer();
|
|
177
|
+
return new Uint8Array(result);
|
|
178
|
+
} catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/task-hooks.svelte.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { definedOptions } from './defined-options';
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
2
3
|
import { createMutation,useQueryClient } from '@tanstack/svelte-query';
|
|
3
4
|
import { createSessionQuery } from './session-query.svelte';
|
|
4
5
|
import { supersededQuerySession } from './query-session.svelte';
|
|
6
|
+
import { captureAuthLiveScope } from './auth-hooks.svelte';
|
|
5
7
|
import { getAdminOptions } from './options.svelte';
|
|
6
8
|
import { captureAdminContext } from './context.svelte';
|
|
7
9
|
import type { AdminContextAccessor } from './context.svelte';
|
|
@@ -85,29 +87,74 @@ export function useSubmitTask(options: UseSubmitTaskOptions={}) {
|
|
|
85
87
|
const adminOptions=getAdminOptions();
|
|
86
88
|
const queryClient=useQueryClient();
|
|
87
89
|
const overtime=createOvertimeTracker(() => mutation.isPending,options.overtimeOptions??adminOptions.overtime);
|
|
88
|
-
|
|
90
|
+
let disposed = false;
|
|
91
|
+
onDestroy(() => { disposed = true; });
|
|
92
|
+
interface SubmissionScope {
|
|
93
|
+
queryScope: ReturnType<AdminContextAccessor['queryKeyMatcher']>;
|
|
94
|
+
auth: ReturnType<typeof captureAuthLiveScope>;
|
|
95
|
+
notification: AdminContextAccessor['notificationProvider'];
|
|
96
|
+
current: () => boolean;
|
|
97
|
+
}
|
|
98
|
+
const submissionScopes = new WeakMap<TaskHandle, SubmissionScope>();
|
|
99
|
+
const failureScopes = new WeakMap<Error, SubmissionScope>();
|
|
89
100
|
|
|
90
101
|
const mutation=createMutation<TaskHandle,Error,UseSubmitTaskMutateParams>(() => ({
|
|
91
102
|
mutationFn: async (params) => {
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
const authProvider = adminContext.authProvider;
|
|
104
|
+
const auth = captureAuthLiveScope(authProvider);
|
|
105
|
+
const source = params.taskProvider ?? adminContext.taskProvider;
|
|
106
|
+
const tenant = adminContext.tenantCacheKey?.__svadminTenant;
|
|
107
|
+
const router = adminContext.routerProvider;
|
|
108
|
+
const access = adminContext.accessControlProvider;
|
|
109
|
+
const notification = adminContext.notificationProvider;
|
|
110
|
+
const scope: SubmissionScope = {
|
|
111
|
+
queryScope: adminContext.queryKeyMatcher(),
|
|
112
|
+
auth,
|
|
113
|
+
notification,
|
|
114
|
+
current: () => !disposed && auth.isCurrent() && adminContext.authProvider === authProvider
|
|
115
|
+
&& adminContext.tenantCacheKey?.__svadminTenant === tenant
|
|
116
|
+
&& adminContext.routerProvider === router && adminContext.accessControlProvider === access
|
|
117
|
+
&& adminContext.notificationProvider === notification
|
|
118
|
+
&& (params.taskProvider ?? adminContext.taskProvider) === source,
|
|
119
|
+
};
|
|
120
|
+
if (!scope.current()) throw supersededQuerySession();
|
|
121
|
+
try {
|
|
122
|
+
const provider=normalizeTaskProvider(source,adminContext);
|
|
123
|
+
const providerMeta=adminContext.tenant
|
|
124
|
+
? {
|
|
125
|
+
...(params.options?.meta??{}),
|
|
126
|
+
...resolveTenantProviderMeta(adminContext.tenant,adminContext.tenantAdapter),
|
|
127
|
+
}
|
|
128
|
+
:params.options?.meta;
|
|
129
|
+
const taskOptions=providerMeta===params.options?.meta
|
|
130
|
+
? params.options
|
|
131
|
+
:definedOptions({
|
|
132
|
+
...params.options,
|
|
133
|
+
meta: providerMeta,
|
|
134
|
+
});
|
|
135
|
+
const handle = await provider.submit(params.taskName,taskOptions);
|
|
136
|
+
submissionScopes.set(handle, scope);
|
|
137
|
+
return handle;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const failure = error instanceof TaskError ? error : new TaskError('TASK_PROVIDER_FAILED');
|
|
140
|
+
failureScopes.set(failure, scope);
|
|
141
|
+
throw failure;
|
|
142
|
+
}
|
|
109
143
|
},
|
|
110
144
|
onSuccess: (data,params,context) => {
|
|
145
|
+
const submission = submissionScopes.get(data);
|
|
146
|
+
if (submission) submissionScopes.delete(data);
|
|
147
|
+
if (!submission) return;
|
|
148
|
+
if (submission.auth.isCurrent()) {
|
|
149
|
+
void queryClient.invalidateQueries({
|
|
150
|
+
predicate: (query) => queryKeyMatches(query.queryKey,{
|
|
151
|
+
...submission.queryScope,
|
|
152
|
+
kind: 'task',
|
|
153
|
+
action: 'list',
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (!submission.current()) return;
|
|
111
158
|
fireSuccessNotification({
|
|
112
159
|
config: params.successNotification,
|
|
113
160
|
defaultMessage: i18n.t('task.submitSuccess'),
|
|
@@ -115,34 +162,27 @@ export function useSubmitTask(options: UseSubmitTaskOptions={}) {
|
|
|
115
162
|
values: params.options,
|
|
116
163
|
resource: params.taskName,
|
|
117
164
|
...definedOptions({
|
|
118
|
-
provider:
|
|
165
|
+
provider: submission.notification
|
|
119
166
|
})
|
|
120
167
|
});
|
|
121
|
-
|
|
122
|
-
if (scope) {
|
|
123
|
-
submissionScopes.delete(data);
|
|
124
|
-
void queryClient.invalidateQueries({
|
|
125
|
-
predicate: (query) => queryKeyMatches(query.queryKey,{
|
|
126
|
-
...scope,
|
|
127
|
-
kind: 'task',
|
|
128
|
-
action: 'list',
|
|
129
|
-
}),
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
options.mutationOptions?.onSuccess?.(data,params,context);
|
|
168
|
+
if (submission.current()) options.mutationOptions?.onSuccess?.(data,params,context);
|
|
133
169
|
},
|
|
134
170
|
onError: (error,params,context) => {
|
|
171
|
+
const submission = failureScopes.get(error);
|
|
172
|
+
failureScopes.delete(error);
|
|
173
|
+
if (!submission?.current()) return;
|
|
135
174
|
checkError(error,adminContext);
|
|
175
|
+
if (!submission.current()) return;
|
|
136
176
|
fireErrorNotification({
|
|
137
177
|
config: params.errorNotification,
|
|
138
178
|
defaultMessage: i18n.t('task.submitFailed'),
|
|
139
179
|
error,
|
|
140
180
|
resource: params.taskName,
|
|
141
181
|
...definedOptions({
|
|
142
|
-
provider:
|
|
182
|
+
provider: submission.notification
|
|
143
183
|
})
|
|
144
184
|
});
|
|
145
|
-
options.mutationOptions?.onError?.(error,params,context);
|
|
185
|
+
if (submission.current()) options.mutationOptions?.onError?.(error,params,context);
|
|
146
186
|
},
|
|
147
187
|
}));
|
|
148
188
|
|
|
@@ -235,6 +275,18 @@ export function useTaskList(
|
|
|
235
275
|
const activeProvider=options.taskProvider??adminContext.taskProvider;
|
|
236
276
|
const provider=activeProvider? withValidatedTaskProvider(activeProvider):undefined;
|
|
237
277
|
const params=options.params===undefined? undefined:taskQueryParams(options.params);
|
|
278
|
+
const callerMeta = params?.['meta'];
|
|
279
|
+
if (callerMeta !== undefined
|
|
280
|
+
&& (typeof callerMeta !== 'object' || callerMeta === null || Array.isArray(callerMeta))) {
|
|
281
|
+
throw new TaskError('INVALID_TASK_INPUT');
|
|
282
|
+
}
|
|
283
|
+
const tenantMeta = adminContext.tenant
|
|
284
|
+
? resolveTenantProviderMeta(adminContext.tenant, adminContext.tenantAdapter)
|
|
285
|
+
: undefined;
|
|
286
|
+
const providerParams = params === undefined && tenantMeta === undefined ? undefined : {
|
|
287
|
+
...(params ?? {}),
|
|
288
|
+
...(tenantMeta ? { meta: { ...(callerMeta ?? {}), ...tenantMeta } } : {}),
|
|
289
|
+
};
|
|
238
290
|
const dlq=options.dlq??false;
|
|
239
291
|
const queryOptions=options.queryOptions;
|
|
240
292
|
const resource = dlq ? 'taskDlq' : 'tasks';
|
|
@@ -249,16 +301,16 @@ export function useTaskList(
|
|
|
249
301
|
decode: decodeTaskList,
|
|
250
302
|
queryKey: scopedTaskKey(adminContext.queryKeys().task.list(definedOptions({
|
|
251
303
|
list: dlq? 'dlq':'default',
|
|
252
|
-
params,
|
|
304
|
+
params: providerParams,
|
|
253
305
|
})),provider),
|
|
254
306
|
queryFn: async () => {
|
|
255
307
|
if(!provider) throw new Error('TaskProvider not found.');
|
|
256
308
|
if(dlq) {
|
|
257
309
|
if(!provider.listDlq) throw new Error('TaskProvider does not implement listDlq');
|
|
258
|
-
return provider.listDlq(
|
|
310
|
+
return provider.listDlq(providerParams);
|
|
259
311
|
}
|
|
260
312
|
if(!provider.list) throw new Error('TaskProvider does not implement list');
|
|
261
|
-
return provider.list(
|
|
313
|
+
return provider.list(providerParams);
|
|
262
314
|
},
|
|
263
315
|
enabled: queryOptions?.enabled??true,
|
|
264
316
|
...definedOptions({
|
|
@@ -294,19 +346,36 @@ export function useTaskSubscription(
|
|
|
294
346
|
const enabled=options.enabled??true;
|
|
295
347
|
if(!enabled) return;
|
|
296
348
|
|
|
297
|
-
const
|
|
349
|
+
const authProvider = adminContext.authProvider;
|
|
350
|
+
const auth = captureAuthLiveScope(authProvider);
|
|
351
|
+
if (!auth.available) return;
|
|
352
|
+
const source = options.taskProvider ?? adminContext.taskProvider;
|
|
353
|
+
const tenant = adminContext.tenantCacheKey?.__svadminTenant;
|
|
354
|
+
const router = adminContext.routerProvider;
|
|
355
|
+
const access = adminContext.accessControlProvider;
|
|
356
|
+
const taskId = options.taskId;
|
|
357
|
+
const onTask = options.onTask;
|
|
358
|
+
const onError = options.onError;
|
|
359
|
+
let active = true;
|
|
360
|
+
const current = () => active && (options.enabled ?? true) && options.taskId === taskId
|
|
361
|
+
&& (options.taskProvider ?? adminContext.taskProvider) === source
|
|
362
|
+
&& adminContext.authProvider === authProvider && auth.isCurrent()
|
|
363
|
+
&& adminContext.tenantCacheKey?.__svadminTenant === tenant
|
|
364
|
+
&& adminContext.routerProvider === router && adminContext.accessControlProvider === access;
|
|
365
|
+
const provider=normalizeTaskProvider(source,adminContext);
|
|
298
366
|
if(!provider.subscribe) {
|
|
299
367
|
throw new Error('TaskProvider does not implement subscribe');
|
|
300
368
|
}
|
|
301
369
|
|
|
302
|
-
const subscription=provider.subscribe(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
370
|
+
const subscription=provider.subscribe(taskId,
|
|
371
|
+
task => { if (current()) onTask(task); },
|
|
372
|
+
error => { if (current()) onError?.(error); },
|
|
373
|
+
);
|
|
374
|
+
return () => {
|
|
375
|
+
// 先失效,再退订;上游退订时同步发出的最后一条事件也不能进入新会话。
|
|
376
|
+
active = false;
|
|
377
|
+
if (typeof subscription === 'function') subscription();
|
|
378
|
+
else subscription.unsubscribe();
|
|
379
|
+
};
|
|
311
380
|
});
|
|
312
381
|
}
|
package/src/types.ts
CHANGED
|
@@ -405,6 +405,22 @@ export interface MenuItem {
|
|
|
405
405
|
children?: MenuItem[];
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
export interface DateInputOptions {
|
|
409
|
+
min?: string;
|
|
410
|
+
max?: string;
|
|
411
|
+
step?: number;
|
|
412
|
+
/** 仅用于 daterange;其他日期字段由 field.type 决定输入模式。 */
|
|
413
|
+
rangeMode?: 'date' | 'time' | 'datetime';
|
|
414
|
+
disabledDate?: (date: string) => boolean;
|
|
415
|
+
presets?: { label: string; value: { start: string; end: string } }[];
|
|
416
|
+
/** datetime 字段的输出语义;默认保留本地 datetime-local 字符串。 */
|
|
417
|
+
valueMode?: 'civil' | 'instant';
|
|
418
|
+
/** valueMode=instant 时必填的 IANA 时区。 */
|
|
419
|
+
timeZone?: string;
|
|
420
|
+
/** 仅用于 datetime,重复本地时刻默认拒绝,允许明确选择较早或较晚的一次。 */
|
|
421
|
+
disambiguation?: 'reject' | 'earlier' | 'later';
|
|
422
|
+
}
|
|
423
|
+
|
|
408
424
|
export interface FieldDefinition {
|
|
409
425
|
key: string;
|
|
410
426
|
label: string;
|
|
@@ -438,6 +454,8 @@ export interface FieldDefinition {
|
|
|
438
454
|
currency?: string;
|
|
439
455
|
locale?: string;
|
|
440
456
|
scale?: '100' | '1';
|
|
457
|
+
// 日期输入策略与数值上下限分离,不隐式转换时区。
|
|
458
|
+
dateInput?: DateInputOptions;
|
|
441
459
|
// File upload contract
|
|
442
460
|
accept?: string;
|
|
443
461
|
maxSize?: number;
|