@svadmin/core 0.53.0 → 0.55.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.53.0",
3
+ "version": "0.55.0",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,19 +16,45 @@
16
16
  "@testing-library/user-event": "^14.6.6",
17
17
  "@types/bun": "^1.4.2",
18
18
  "@types/jsdom": "^30.0.0",
19
+ "exceljs": "4.4.0",
19
20
  "happy-dom": "^20.11.12",
20
21
  "jsdom": "^30.0.1",
21
22
  "vitest": "^4.1.11"
22
23
  },
23
24
  "peerDependencies": {
25
+ "exceljs": "^4.4.0",
24
26
  "svelte": "^5.56.10",
25
27
  "@tanstack/svelte-query": "^6.1.48"
26
28
  },
27
29
  "exports": {
30
+ "./spreadsheet-xlsx": {
31
+ "types": "./src/spreadsheet-xlsx.ts",
32
+ "default": "./src/spreadsheet-xlsx.ts"
33
+ },
34
+ "./filter-values": {
35
+ "types": "./src/filter-values.ts",
36
+ "default": "./src/filter-values.ts"
37
+ },
38
+ "./date-time": {
39
+ "types": "./src/date-time.ts",
40
+ "default": "./src/date-time.ts"
41
+ },
42
+ "./pivot": {
43
+ "types": "./src/pivot.ts",
44
+ "default": "./src/pivot.ts"
45
+ },
46
+ "./gantt": {
47
+ "types": "./src/gantt.ts",
48
+ "default": "./src/gantt.ts"
49
+ },
28
50
  "./schema": {
29
51
  "types": "./src/schema.ts",
30
52
  "default": "./src/schema.ts"
31
53
  },
54
+ "./schema-form": {
55
+ "types": "./src/schema-form.ts",
56
+ "default": "./src/schema-form.ts"
57
+ },
32
58
  "./options": {
33
59
  "types": "./src/defined-options.ts",
34
60
  "default": "./src/defined-options.ts"
@@ -107,10 +133,14 @@
107
133
  "type": "module",
108
134
  "types": "src/index.ts",
109
135
  "dependencies": {
136
+ "@internationalized/date": "^3.12.3",
110
137
  "@sinclair/typebox": "^0.34.52",
111
138
  "papaparse": "^5.5.4"
112
139
  },
113
140
  "peerDependenciesMeta": {
141
+ "exceljs": {
142
+ "optional": true
143
+ },
114
144
  "svelte": {
115
145
  "optional": true
116
146
  },
@@ -0,0 +1,131 @@
1
+ import { Type, type Static } from '@sinclair/typebox';
2
+ import { checkExact } from './schema-validation';
3
+ import { snapshotPlainData } from './plain-data';
4
+ import type { EnterpriseProviderRequestContext } from './enterprise';
5
+
6
+ const identifier = Type.String({ minLength: 1, maxLength: 200, pattern: '^\\S(?:[\\s\\S]*\\S)?$' });
7
+ const version = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
8
+ const comment = Type.String({ maxLength: 10000 });
9
+ const action = Type.Object({
10
+ id: identifier,
11
+ label: Type.String({ minLength: 1, maxLength: 200 }),
12
+ commentRequired: Type.Boolean(),
13
+ targetRequired: Type.Boolean(),
14
+ }, { additionalProperties: false });
15
+ const attachment = Type.Object({
16
+ id: identifier,
17
+ name: Type.String({ minLength: 1, maxLength: 500 }),
18
+ }, { additionalProperties: false });
19
+ const history = Type.Object({
20
+ id: identifier,
21
+ action: identifier,
22
+ actor: Type.String({ minLength: 1, maxLength: 200 }),
23
+ at: Type.String({ pattern: '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z$' }),
24
+ comment,
25
+ }, { additionalProperties: false });
26
+ const recordSchema = Type.Object({
27
+ id: identifier,
28
+ version,
29
+ title: Type.String({ minLength: 1, maxLength: 500 }),
30
+ status: identifier,
31
+ applicant: Type.String({ minLength: 1, maxLength: 200 }),
32
+ allowedActions: Type.Array(action, { maxItems: 30 }),
33
+ attachments: Type.Array(attachment, { maxItems: 100 }),
34
+ history: Type.Array(history, { maxItems: 1000 }),
35
+ }, { additionalProperties: false });
36
+ const transitionSchema = Type.Object({
37
+ id: identifier,
38
+ expectedVersion: version,
39
+ action: identifier,
40
+ comment,
41
+ targetId: Type.Optional(identifier),
42
+ idempotencyKey: identifier,
43
+ }, { additionalProperties: false });
44
+ const listSchema = Type.Object({
45
+ data: Type.Array(recordSchema, { maxItems: 200 }),
46
+ total: Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
47
+ }, { additionalProperties: false });
48
+
49
+ export type ApprovalRecord = Static<typeof recordSchema>;
50
+ export type ApprovalTransition = Static<typeof transitionSchema>;
51
+ export type ApprovalList = Static<typeof listSchema>;
52
+ export type ApprovalReceipt =
53
+ | { ok: true; record: ApprovalRecord }
54
+ | { ok: false; code: 'VERSION_CONFLICT'; current: ApprovalRecord };
55
+
56
+ /** 宿主执行授权与状态机;附件仅返回引用,不接受任意下载 URL。 */
57
+ export interface ApprovalProvider {
58
+ list(context: EnterpriseProviderRequestContext, query: {
59
+ view: 'pending' | 'history'; page: number; pageSize: number; search: string;
60
+ }): Promise<unknown>;
61
+ get(context: EnterpriseProviderRequestContext, id: string): Promise<unknown>;
62
+ transition?(context: EnterpriseProviderRequestContext, input: ApprovalTransition): Promise<unknown>;
63
+ }
64
+
65
+ export class ApprovalContractError extends Error {
66
+ constructor(readonly code: 'INVALID_APPROVAL_INPUT' | 'INVALID_APPROVAL_RESPONSE') {
67
+ super(code === 'INVALID_APPROVAL_INPUT' ? 'Invalid approval input.' : 'Invalid approval response.');
68
+ this.name = 'ApprovalContractError';
69
+ }
70
+ }
71
+
72
+ function unique(items: readonly { id: string }[]): boolean {
73
+ return new Set(items.map(item => item.id)).size === items.length;
74
+ }
75
+
76
+ export function decodeApprovalRecord(value: unknown, expectedId?: string): ApprovalRecord {
77
+ try {
78
+ const record = snapshotPlainData(value);
79
+ if (checkExact(recordSchema, record)
80
+ && (expectedId === undefined || record.id === expectedId)
81
+ && unique(record.allowedActions) && unique(record.attachments) && unique(record.history)
82
+ && record.history.every(event => {
83
+ const parsed = new Date(event.at);
84
+ return Number.isFinite(parsed.getTime())
85
+ && parsed.toISOString().replace('.000Z', 'Z') ===
86
+ event.at.replace(/(?:\.0{1,3})Z$/, 'Z').replace(/\.(\d{1,2})Z$/, (_, digits: string) => `.${digits.padEnd(3, '0')}Z`);
87
+ })) return record;
88
+ } catch { /* 不回显 Provider 载荷或执行访问器。 */ }
89
+ throw new ApprovalContractError('INVALID_APPROVAL_RESPONSE');
90
+ }
91
+
92
+ export function decodeApprovalList(value: unknown): ApprovalList {
93
+ try {
94
+ const result = snapshotPlainData(value);
95
+ if (checkExact(listSchema, result) && result.total >= result.data.length && unique(result.data)) {
96
+ return { data: result.data.map(record => decodeApprovalRecord(record)), total: result.total };
97
+ }
98
+ } catch { /* 统一失败,不返回部分合法记录。 */ }
99
+ throw new ApprovalContractError('INVALID_APPROVAL_RESPONSE');
100
+ }
101
+
102
+ export function decodeApprovalTransition(value: unknown, current: ApprovalRecord): ApprovalTransition {
103
+ try {
104
+ const input = snapshotPlainData(value);
105
+ const record = decodeApprovalRecord(current);
106
+ if (checkExact(transitionSchema, input) && input.id === record.id && input.expectedVersion === record.version) {
107
+ const permitted = record.allowedActions.find(candidate => candidate.id === input.action);
108
+ if (permitted && (!permitted.commentRequired || !!input.comment.trim())
109
+ && (permitted.targetRequired ? input.targetId !== undefined : input.targetId === undefined)) return input;
110
+ }
111
+ } catch { /* 不将本地允许动作视为服务端授权。 */ }
112
+ throw new ApprovalContractError('INVALID_APPROVAL_INPUT');
113
+ }
114
+
115
+ export function decodeApprovalReceipt(value: unknown, input: ApprovalTransition): ApprovalReceipt {
116
+ try {
117
+ const request = snapshotPlainData(input);
118
+ const receipt = snapshotPlainData(value);
119
+ if (!checkExact(transitionSchema, request) || typeof receipt !== 'object' || receipt === null || Array.isArray(receipt)) throw new Error();
120
+ if (receipt['ok'] === true && Object.keys(receipt).every(key => ['ok', 'record'].includes(key))) {
121
+ const record = decodeApprovalRecord(receipt['record'], request.id);
122
+ if (record.version > request.expectedVersion) return { ok: true, record };
123
+ }
124
+ if (receipt['ok'] === false && receipt['code'] === 'VERSION_CONFLICT'
125
+ && Object.keys(receipt).every(key => ['ok', 'code', 'current'].includes(key))) {
126
+ const current = decodeApprovalRecord(receipt['current'], request.id);
127
+ if (current.version >= request.expectedVersion) return { ok: false, code: 'VERSION_CONFLICT', current };
128
+ }
129
+ } catch { /* 错配和未知回执不作为成功。 */ }
130
+ throw new ApprovalContractError('INVALID_APPROVAL_RESPONSE');
131
+ }
@@ -0,0 +1,72 @@
1
+ import { Type, type Static } from '@sinclair/typebox';
2
+ import { checkExact } from './schema-validation';
3
+ import { snapshotPlainData } from './plain-data';
4
+ import type { EnterpriseProviderRequestContext } from './enterprise';
5
+
6
+ const id = Type.String({ minLength: 1, maxLength: 200, pattern: '^\\S(?:[\\s\\S]*\\S)?$' });
7
+ const scalar = Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]);
8
+ const metric = Type.Object({
9
+ id,
10
+ label: Type.String({ minLength: 1, maxLength: 200 }),
11
+ value: scalar,
12
+ tone: Type.Optional(Type.Union([
13
+ Type.Literal('default'), Type.Literal('primary'), Type.Literal('success'),
14
+ Type.Literal('warning'), Type.Literal('danger'), Type.Literal('info'),
15
+ ])),
16
+ href: Type.Optional(Type.String({ minLength: 1, maxLength: 2000, pattern: '^(?:/|#)' })),
17
+ }, { additionalProperties: false });
18
+ const widget = Type.Object({
19
+ id,
20
+ title: Type.String({ minLength: 1, maxLength: 300 }),
21
+ kind: Type.Union([Type.Literal('metric'), Type.Literal('text')]),
22
+ metrics: Type.Array(metric, { maxItems: 100 }),
23
+ text: Type.Optional(Type.String({ maxLength: 10000 })),
24
+ }, { additionalProperties: false });
25
+ const snapshot = Type.Object({
26
+ version: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
27
+ widgets: Type.Array(widget, { maxItems: 100 }),
28
+ }, { additionalProperties: false });
29
+ const query = Type.Object({
30
+ dashboardId: id,
31
+ filters: Type.Optional(Type.Array(Type.Object({
32
+ field: id,
33
+ value: scalar,
34
+ }, { additionalProperties: false }), { maxItems: 50 })),
35
+ }, { additionalProperties: false });
36
+
37
+ export type DashboardMetric = Static<typeof metric>;
38
+ export type DashboardWidget = Static<typeof widget>;
39
+ export type DashboardSnapshot = Static<typeof snapshot>;
40
+ export type DashboardQuery = Static<typeof query>;
41
+
42
+ export interface DashboardProvider {
43
+ get(context: EnterpriseProviderRequestContext, query: DashboardQuery): Promise<unknown>;
44
+ }
45
+
46
+ export class DashboardContractError extends Error {
47
+ constructor() {
48
+ super('Invalid dashboard response.');
49
+ this.name = 'DashboardContractError';
50
+ }
51
+ }
52
+
53
+ export function decodeDashboardSnapshot(value: unknown): DashboardSnapshot {
54
+ try {
55
+ const result = snapshotPlainData(value);
56
+ if (checkExact(snapshot, result)
57
+ && new Set(result.widgets.map(item => item.id)).size === result.widgets.length
58
+ && result.widgets.every(item => new Set(item.metrics.map(metricItem => metricItem.id)).size === item.metrics.length)
59
+ && result.widgets.every(item => item.kind === 'text' ? item.text !== undefined : item.metrics.length > 0)) {
60
+ return result;
61
+ }
62
+ } catch { /* Provider payloads fail closed. */ }
63
+ throw new DashboardContractError();
64
+ }
65
+
66
+ export function decodeDashboardQuery(value: unknown): DashboardQuery {
67
+ try {
68
+ const result = snapshotPlainData(value);
69
+ if (checkExact(query, result)) return result;
70
+ } catch { /* Query payloads fail closed. */ }
71
+ throw new DashboardContractError();
72
+ }
@@ -0,0 +1,55 @@
1
+ import { fromDate, parseAbsolute, parseDateTime, toCalendarDateTime, toZoned } from '@internationalized/date';
2
+
3
+ export type DateTimeValueMode = 'civil' | 'instant';
4
+ export type DateTimeDisambiguation = 'reject' | 'earlier' | 'later';
5
+
6
+ const civilPattern = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?$/;
7
+ const absolutePattern = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
8
+
9
+ function validTimeZone(timeZone: string): boolean {
10
+ if (typeof timeZone !== 'string' || !timeZone.trim() || timeZone.length > 200) return false;
11
+ try {
12
+ new Intl.DateTimeFormat('en', { timeZone });
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ /** 默认拒绝夏令时重复或不存在的本地时间;消歧必须由调用方明确选择。 */
20
+ export function civilDateTimeToInstant(
21
+ value: string, timeZone: string, disambiguation: DateTimeDisambiguation = 'reject',
22
+ ): number | undefined {
23
+ if (!civilPattern.test(value) || !validTimeZone(timeZone)
24
+ || !['reject', 'earlier', 'later'].includes(disambiguation)) return;
25
+ try {
26
+ const civil = parseDateTime(value);
27
+ const zoned = toZoned(civil, timeZone, disambiguation);
28
+ // 不允许消歧策略把不存在的时间自动挪到夏令时跳跃前后。
29
+ if (toCalendarDateTime(zoned).compare(civil) !== 0) return;
30
+ return zoned.toDate().getTime();
31
+ } catch {
32
+ return;
33
+ }
34
+ }
35
+
36
+ export function parseDateTimeInstant(value: string): number | undefined {
37
+ if (!absolutePattern.test(value)) return;
38
+ try {
39
+ return parseAbsolute(value, 'UTC').toDate().getTime();
40
+ } catch {
41
+ return;
42
+ }
43
+ }
44
+
45
+ export function formatCivilDateTime(instant: number, timeZone: string): string | undefined {
46
+ if (!Number.isSafeInteger(instant) || !validTimeZone(timeZone)) return;
47
+ try {
48
+ const date = fromDate(new Date(instant), timeZone);
49
+ if (date.era !== 'AD' || date.year < 1 || date.year > 9999) return;
50
+ const civil = toCalendarDateTime(date).toString();
51
+ return date.second === 0 && date.millisecond === 0 ? civil.slice(0, 16) : civil;
52
+ } catch {
53
+ return;
54
+ }
55
+ }
@@ -0,0 +1,54 @@
1
+ export type EnterpriseMaturityTier = 'P0' | 'P1' | 'P2';
2
+ export type EnterpriseMaturityStatus = 'stable' | 'verified' | 'experimental' | 'planned';
3
+
4
+ export interface EnterpriseMaturityEntry {
5
+ readonly id: string;
6
+ readonly tier: EnterpriseMaturityTier;
7
+ readonly status: EnterpriseMaturityStatus;
8
+ readonly surfaces: readonly string[];
9
+ readonly acceptance: readonly string[];
10
+ }
11
+
12
+ export const ENTERPRISE_MATURITY: readonly EnterpriseMaturityEntry[] = [
13
+ {
14
+ id: 'platform-foundation',
15
+ tier: 'P0',
16
+ status: 'stable',
17
+ surfaces: ['AdminApp', 'ProviderBundle', 'SSR', 'Lite'],
18
+ acceptance: ['公共契约可类型检查', '失败状态不伪造成功', '关键路径有 SSR 和 Lite 证据'],
19
+ },
20
+ {
21
+ id: 'resource-operations',
22
+ tier: 'P0',
23
+ status: 'stable',
24
+ surfaces: ['CRUD', 'AutoTable', 'AutoForm', 'Import', 'Export'],
25
+ acceptance: ['资源读写、表格、表单和批量数据操作有直接行为测试'],
26
+ },
27
+ {
28
+ id: 'governance-and-audit',
29
+ tier: 'P0',
30
+ status: 'stable',
31
+ surfaces: ['Permissions', 'Tenant', 'Audit', 'Session'],
32
+ acceptance: ['操作按租户和权限边界执行,并保留可追踪回执'],
33
+ },
34
+ {
35
+ id: 'competitive-workflows',
36
+ tier: 'P1',
37
+ status: 'verified',
38
+ surfaces: ['AdvancedTable', 'SavedViews', 'ImportWizard', 'TaskQueue', 'Approval', 'VersionDiff', 'Dashboard', 'MultiTab'],
39
+ acceptance: ['跨 UI/Core/Lite 聚焦测试覆盖异步回执和错误边界'],
40
+ },
41
+ {
42
+ id: 'extension-modules',
43
+ tier: 'P2',
44
+ status: 'experimental',
45
+ surfaces: ['Kanban', 'Gantt', 'Pivot', 'Spreadsheet', 'DecisionTable', 'PDF', 'Print', 'Signature', 'Annotation', 'Media', 'Collaboration', 'OfflineSync'],
46
+ acceptance: ['按场景逐项完成 API、SSR/Lite、集成和线上验收后再升级状态'],
47
+ },
48
+ ] as const;
49
+
50
+ export function enterpriseMaturityFor(
51
+ tier: EnterpriseMaturityTier,
52
+ ): readonly EnterpriseMaturityEntry[] {
53
+ return ENTERPRISE_MATURITY.filter((entry) => entry.tier === tier);
54
+ }
@@ -1,8 +1,52 @@
1
1
  // Pure export formatting helpers with no Svelte rune or browser API dependencies except downloadData.
2
2
  // These helpers can be tested directly with bun:test.
3
3
 
4
+ import { Type, type Static } from '@sinclair/typebox';
5
+ import { checkExact } from './schema-validation';
6
+ import { snapshotPlainData } from './plain-data';
7
+
4
8
  /** Supported export and import formats. */
5
9
  export type ExportFormat = 'csv' | 'json' | 'xlsx';
10
+ const exportTaskResultSchema = Type.Object({
11
+ downloadUrl: Type.String({ minLength: 1, maxLength: 4096 }),
12
+ format: Type.Union([Type.Literal('csv'), Type.Literal('json'), Type.Literal('xlsx')]),
13
+ fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
14
+ }, { additionalProperties: false });
15
+ export type ExportTaskResult = Static<typeof exportTaskResultSchema>;
16
+
17
+ export function snapshotExportTaskResult(value: unknown): ExportTaskResult | undefined {
18
+ try {
19
+ const snapshot = snapshotPlainData(value);
20
+ if (!checkExact(exportTaskResultSchema, snapshot)) return undefined;
21
+ if (snapshot.fileName && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(snapshot.fileName)) return undefined;
22
+ return snapshot;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ export function safeArtifactUrl(value: string): string | undefined {
29
+ try {
30
+ const url = new URL(value, document.baseURI);
31
+ if (url.origin !== location.origin || !['http:', 'https:'].includes(url.protocol) ||
32
+ url.username || url.password) return undefined;
33
+ return url.href;
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ export function downloadExportArtifact(result: ExportTaskResult, resource: string): void {
40
+ if (typeof document === 'undefined' || typeof location === 'undefined') throw new Error('Browser required');
41
+ const checked = snapshotExportTaskResult(result);
42
+ const href = checked && safeArtifactUrl(checked.downloadUrl);
43
+ if (!href) throw new Error('Invalid export artifact URL');
44
+ const anchor = document.createElement('a');
45
+ anchor.href = href;
46
+ anchor.download = checked.fileName ?? `${resource.replace(/[^A-Za-z0-9_-]/g, '_')}_export.${checked.format}`;
47
+ anchor.rel = 'noopener';
48
+ anchor.click();
49
+ }
6
50
 
7
51
  /** Escapes a CSV field and prevents formula injection. */
8
52
  export function escapeCsvField(fieldValue: string): string {