@yoyoboot/yoyo-admin-core 10.5.5
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/dist/abp/application-configuration.d.ts +4 -0
- package/dist/abp/application-configuration.js +156 -0
- package/dist/abp/error.d.ts +7 -0
- package/dist/abp/error.js +65 -0
- package/dist/abp/index.d.ts +3 -0
- package/dist/abp/index.js +3 -0
- package/dist/abp/query.d.ts +16 -0
- package/dist/abp/query.js +21 -0
- package/dist/auth/auth.d.ts +13 -0
- package/dist/auth/auth.js +17 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +2 -0
- package/dist/auth/storage-contract.d.mts +10 -0
- package/dist/auth/storage-contract.mjs +25 -0
- package/dist/auth/storage.d.ts +40 -0
- package/dist/auth/storage.js +71 -0
- package/dist/contracts/generated/capability/feature-keys.d.ts +2 -0
- package/dist/contracts/generated/capability/feature-keys.js +11 -0
- package/dist/contracts/generated/capability/index.d.ts +2 -0
- package/dist/contracts/generated/capability/index.js +4 -0
- package/dist/contracts/generated/capability/permission-keys.d.ts +2 -0
- package/dist/contracts/generated/capability/permission-keys.js +106 -0
- package/dist/contracts/index.d.ts +8 -0
- package/dist/contracts/index.js +8 -0
- package/dist/contracts/mappers.d.ts +4 -0
- package/dist/contracts/mappers.js +40 -0
- package/dist/contracts/models.d.ts +68 -0
- package/dist/contracts/models.js +1 -0
- package/dist/contracts/page.d.ts +28 -0
- package/dist/contracts/page.js +44 -0
- package/dist/contracts/runtime/datasource.d.ts +109 -0
- package/dist/contracts/runtime/datasource.js +89 -0
- package/dist/contracts/runtime/error.d.ts +23 -0
- package/dist/contracts/runtime/error.js +54 -0
- package/dist/contracts/runtime/mutation.d.ts +49 -0
- package/dist/contracts/runtime/mutation.js +12 -0
- package/dist/contracts/runtime/policy.d.ts +16 -0
- package/dist/contracts/runtime/policy.js +10 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/temporal/index.d.ts +21 -0
- package/dist/temporal/index.js +78 -0
- package/package.json +30 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function createEmptyApplicationConfiguration() {
|
|
2
|
+
return {
|
|
3
|
+
currentUser: {
|
|
4
|
+
id: "",
|
|
5
|
+
userName: "",
|
|
6
|
+
name: "未登录",
|
|
7
|
+
surname: "",
|
|
8
|
+
email: "",
|
|
9
|
+
title: "未初始化",
|
|
10
|
+
initials: "YA",
|
|
11
|
+
isAuthenticated: false,
|
|
12
|
+
},
|
|
13
|
+
currentTenant: {
|
|
14
|
+
id: "",
|
|
15
|
+
name: "Host Context",
|
|
16
|
+
plan: "Internal Preview",
|
|
17
|
+
environment: "Local Development",
|
|
18
|
+
},
|
|
19
|
+
identitySwitch: {
|
|
20
|
+
mode: "normal",
|
|
21
|
+
canReturn: false,
|
|
22
|
+
},
|
|
23
|
+
timing: {
|
|
24
|
+
timeZoneSettingId: "UTC",
|
|
25
|
+
timeZoneIana: "Etc/UTC",
|
|
26
|
+
},
|
|
27
|
+
grantedPolicies: {},
|
|
28
|
+
settings: {},
|
|
29
|
+
features: {},
|
|
30
|
+
localization: {},
|
|
31
|
+
modules: [],
|
|
32
|
+
themePreference: "system",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function hasPermission(grantedPolicies, permission) {
|
|
36
|
+
return grantedPolicies[permission] === true;
|
|
37
|
+
}
|
|
38
|
+
export function listGrantedPolicies(grantedPolicies) {
|
|
39
|
+
return Object.keys(grantedPolicies).filter((permission) => grantedPolicies[permission] === true);
|
|
40
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type ThemePreference = "light" | "dark" | "system";
|
|
2
|
+
export interface CurrentUser {
|
|
3
|
+
id: string;
|
|
4
|
+
userName: string;
|
|
5
|
+
name: string;
|
|
6
|
+
surname: string;
|
|
7
|
+
email: string;
|
|
8
|
+
title: string;
|
|
9
|
+
initials: string;
|
|
10
|
+
isAuthenticated: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface CurrentTenant {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
tenancyName?: string;
|
|
16
|
+
plan: string;
|
|
17
|
+
environment: string;
|
|
18
|
+
}
|
|
19
|
+
export interface IdentitySwitchState {
|
|
20
|
+
mode: "normal" | "impersonated" | "delegated" | "linked";
|
|
21
|
+
sourceUserId?: string;
|
|
22
|
+
sourceTenantId?: string;
|
|
23
|
+
canReturn: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface ApplicationNavigationMetadata {
|
|
26
|
+
routeName: string;
|
|
27
|
+
pageKey: string;
|
|
28
|
+
menuKey: string;
|
|
29
|
+
title: string;
|
|
30
|
+
group: string;
|
|
31
|
+
permission?: string;
|
|
32
|
+
feature?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ApplicationModuleMetadata {
|
|
35
|
+
moduleKey: string;
|
|
36
|
+
permissions: string[];
|
|
37
|
+
features: string[];
|
|
38
|
+
navigation: ApplicationNavigationMetadata[];
|
|
39
|
+
}
|
|
40
|
+
export interface ManagedMenuConfiguration {
|
|
41
|
+
menuKey: string;
|
|
42
|
+
routeName: string;
|
|
43
|
+
pageKey: string;
|
|
44
|
+
parentMenuKey?: string;
|
|
45
|
+
title?: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
group?: string;
|
|
48
|
+
subGroup?: string;
|
|
49
|
+
order?: number;
|
|
50
|
+
isEnabled: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface YoyoTemporalContext {
|
|
53
|
+
timeZoneSettingId: string;
|
|
54
|
+
timeZoneIana: string;
|
|
55
|
+
}
|
|
56
|
+
export interface ApplicationConfigurationState {
|
|
57
|
+
currentUser: CurrentUser;
|
|
58
|
+
currentTenant: CurrentTenant;
|
|
59
|
+
identitySwitch: IdentitySwitchState;
|
|
60
|
+
timing: YoyoTemporalContext;
|
|
61
|
+
grantedPolicies: Record<string, boolean>;
|
|
62
|
+
settings: Record<string, string>;
|
|
63
|
+
features: Record<string, unknown>;
|
|
64
|
+
localization: Record<string, unknown>;
|
|
65
|
+
modules?: ApplicationModuleMetadata[];
|
|
66
|
+
managedMenus?: ManagedMenuConfiguration[];
|
|
67
|
+
themePreference: ThemePreference;
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface PageQueryInput {
|
|
2
|
+
page?: number;
|
|
3
|
+
pageSize?: number;
|
|
4
|
+
keyword?: string;
|
|
5
|
+
sorting?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PageQueryState {
|
|
8
|
+
page: number;
|
|
9
|
+
pageSize: number;
|
|
10
|
+
maxResultCount: number;
|
|
11
|
+
skipCount: number;
|
|
12
|
+
keyword?: string;
|
|
13
|
+
sorting?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface PagedRequest {
|
|
16
|
+
skipCount: number;
|
|
17
|
+
maxResultCount: number;
|
|
18
|
+
sorting?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface PageResult<TItem> extends PageQueryState {
|
|
21
|
+
items: TItem[];
|
|
22
|
+
totalCount: number;
|
|
23
|
+
pageCount: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function createPageQueryState(input?: PageQueryInput): PageQueryState;
|
|
26
|
+
export declare function createPagedRequest(input?: PageQueryInput): PagedRequest;
|
|
27
|
+
export declare function createPageResult<TItem>(items: TItem[], input?: PageQueryInput): PageResult<TItem>;
|
|
28
|
+
export declare function resolvePageCount(totalCount: number, pageSize: number): number;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const DEFAULT_PAGE = 1;
|
|
2
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
3
|
+
export function createPageQueryState(input = {}) {
|
|
4
|
+
const page = normalizePositiveInteger(input.page, DEFAULT_PAGE);
|
|
5
|
+
const pageSize = normalizePositiveInteger(input.pageSize, DEFAULT_PAGE_SIZE);
|
|
6
|
+
const keyword = input.keyword?.trim() || undefined;
|
|
7
|
+
const sorting = input.sorting?.trim() || undefined;
|
|
8
|
+
return {
|
|
9
|
+
page,
|
|
10
|
+
pageSize,
|
|
11
|
+
maxResultCount: pageSize,
|
|
12
|
+
skipCount: (page - 1) * pageSize,
|
|
13
|
+
...(keyword === undefined ? {} : { keyword }),
|
|
14
|
+
...(sorting === undefined ? {} : { sorting }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function createPagedRequest(input = {}) {
|
|
18
|
+
const query = createPageQueryState(input);
|
|
19
|
+
return {
|
|
20
|
+
skipCount: query.skipCount,
|
|
21
|
+
maxResultCount: query.maxResultCount,
|
|
22
|
+
...(query.sorting === undefined ? {} : { sorting: query.sorting }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function createPageResult(items, input = {}) {
|
|
26
|
+
const query = createPageQueryState(input);
|
|
27
|
+
const totalCount = items.length;
|
|
28
|
+
const pagedItems = items.slice(query.skipCount, query.skipCount + query.pageSize);
|
|
29
|
+
return {
|
|
30
|
+
...query,
|
|
31
|
+
items: pagedItems,
|
|
32
|
+
totalCount,
|
|
33
|
+
pageCount: resolvePageCount(totalCount, query.pageSize),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function resolvePageCount(totalCount, pageSize) {
|
|
37
|
+
return Math.max(1, Math.ceil(Math.max(0, totalCount) / normalizePositiveInteger(pageSize, DEFAULT_PAGE_SIZE)));
|
|
38
|
+
}
|
|
39
|
+
function normalizePositiveInteger(value, fallback) {
|
|
40
|
+
if (!Number.isFinite(value) || !value || value < 1) {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
return Math.floor(value);
|
|
44
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { PageQueryState, PageResult } from '../page.js';
|
|
2
|
+
import type { YoyoMutationContext, YoyoMutationInvalidationRequest } from './mutation.js';
|
|
3
|
+
export interface YoyoEntityReference<TId = string> {
|
|
4
|
+
id: TId;
|
|
5
|
+
}
|
|
6
|
+
export interface YoyoQueryRequest<TFilter> {
|
|
7
|
+
state: PageQueryState;
|
|
8
|
+
filter: TFilter;
|
|
9
|
+
signal: AbortSignal;
|
|
10
|
+
}
|
|
11
|
+
export interface YoyoGetForEditRequest<TId = string> extends YoyoEntityReference<TId> {
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
export interface YoyoGetForCreateRequest {
|
|
15
|
+
signal: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
export interface YoyoEntityMutationRequest<TId = string> extends YoyoEntityReference<TId> {
|
|
18
|
+
concurrencyToken?: string;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
init?: RequestInit;
|
|
21
|
+
}
|
|
22
|
+
export interface YoyoWriteRequest<TValue> {
|
|
23
|
+
value: TValue;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
init?: RequestInit;
|
|
26
|
+
}
|
|
27
|
+
export interface YoyoUpdateRequest<TValue> extends YoyoWriteRequest<TValue> {
|
|
28
|
+
concurrencyToken?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface YoyoSetEnabledRequest<TId = string> extends YoyoEntityMutationRequest<TId> {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface YoyoCrudDataSource<TFilter, TItem, TEdit, TSave, TId = string> {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
query(request: YoyoQueryRequest<TFilter>): Promise<PageResult<TItem>>;
|
|
36
|
+
getForCreate?(request: YoyoGetForCreateRequest): Promise<TEdit>;
|
|
37
|
+
getForEdit(request: YoyoGetForEditRequest<TId>): Promise<TEdit>;
|
|
38
|
+
create?(request: YoyoWriteRequest<TSave>): Promise<TEdit | void>;
|
|
39
|
+
update?(request: YoyoUpdateRequest<TSave>): Promise<TEdit | void>;
|
|
40
|
+
remove?(request: YoyoEntityMutationRequest<TId>): Promise<void>;
|
|
41
|
+
setEnabled?(request: YoyoSetEnabledRequest<TId>): Promise<void>;
|
|
42
|
+
invalidate?(request: YoyoMutationInvalidationRequest<TId>): Promise<void> | void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Provenance is attached at the feature-local adapter boundary so UI/runtime
|
|
46
|
+
* consumers cannot turn a mock into a production source by configuration.
|
|
47
|
+
*/
|
|
48
|
+
export type YoyoDatasourceBindingKind = 'fixture' | 'production';
|
|
49
|
+
export type YoyoLinkingEnvironment = 'test' | 'demo' | 'development' | 'production';
|
|
50
|
+
export interface YoyoDatasourceBinding<TDatasource extends {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
}> {
|
|
53
|
+
readonly kind: YoyoDatasourceBindingKind;
|
|
54
|
+
readonly datasource: TDatasource;
|
|
55
|
+
}
|
|
56
|
+
export interface YoyoDatasourceBindingLinkingInput<TDatasource extends {
|
|
57
|
+
readonly id: string;
|
|
58
|
+
}> {
|
|
59
|
+
readonly environment: YoyoLinkingEnvironment;
|
|
60
|
+
readonly binding: YoyoDatasourceBinding<TDatasource>;
|
|
61
|
+
}
|
|
62
|
+
export interface YoyoDatasourceBindingDiagnostic {
|
|
63
|
+
readonly code: 'YDSB001';
|
|
64
|
+
readonly severity: 'blocking';
|
|
65
|
+
readonly message: string;
|
|
66
|
+
readonly guidance: string;
|
|
67
|
+
}
|
|
68
|
+
export declare class YoyoDatasourceBindingLinkingError extends Error {
|
|
69
|
+
readonly diagnostics: readonly YoyoDatasourceBindingDiagnostic[];
|
|
70
|
+
constructor(diagnostics: readonly YoyoDatasourceBindingDiagnostic[]);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The sole provenance gate for runtime datasource inputs. Fixture adapters use
|
|
74
|
+
* the same CRUD contract, but cannot cross the production linking boundary.
|
|
75
|
+
*/
|
|
76
|
+
export declare function linkYoyoDatasourceBinding<TDatasource extends {
|
|
77
|
+
readonly id: string;
|
|
78
|
+
}>(input: YoyoDatasourceBindingLinkingInput<TDatasource>): YoyoDatasourceBinding<TDatasource>;
|
|
79
|
+
export declare function defineYoyoCrudDataSource<TFilter, TItem, TEdit, TSave, TId = string>(datasource: YoyoCrudDataSource<TFilter, TItem, TEdit, TSave, TId>): YoyoCrudDataSource<TFilter, TItem, TEdit, TSave, TId>;
|
|
80
|
+
export interface YoyoDatasourceRegistry<TDatasource extends {
|
|
81
|
+
readonly id: string;
|
|
82
|
+
}> {
|
|
83
|
+
resolve(id: string): TDatasource;
|
|
84
|
+
}
|
|
85
|
+
export declare function createYoyoDatasourceRegistry<TDatasource extends {
|
|
86
|
+
readonly id: string;
|
|
87
|
+
}>(datasources: readonly TDatasource[]): YoyoDatasourceRegistry<TDatasource>;
|
|
88
|
+
export type YoyoCreateMutationCommand<TSave> = {
|
|
89
|
+
readonly operation: 'create';
|
|
90
|
+
readonly request: YoyoWriteRequest<TSave>;
|
|
91
|
+
};
|
|
92
|
+
export type YoyoUpdateMutationCommand<TSave> = {
|
|
93
|
+
readonly operation: 'update';
|
|
94
|
+
readonly request: YoyoUpdateRequest<TSave>;
|
|
95
|
+
};
|
|
96
|
+
export type YoyoEntityMutationCommand<TId> = {
|
|
97
|
+
readonly operation: 'remove';
|
|
98
|
+
readonly request: YoyoEntityMutationRequest<TId>;
|
|
99
|
+
};
|
|
100
|
+
export type YoyoSetEnabledMutationCommand<TId> = {
|
|
101
|
+
readonly operation: 'setEnabled';
|
|
102
|
+
readonly request: YoyoSetEnabledRequest<TId>;
|
|
103
|
+
};
|
|
104
|
+
/** Binds every mutation operation to its only valid payload shape. */
|
|
105
|
+
export type YoyoCrudMutationCommand<TSave, TId> = YoyoCreateMutationCommand<TSave> | YoyoUpdateMutationCommand<TSave> | YoyoEntityMutationCommand<TId> | YoyoSetEnabledMutationCommand<TId>;
|
|
106
|
+
/**
|
|
107
|
+
* 标准页面固定先等待后端 mutation 成功,再失效查询;失败时故意不触碰当前列表与编辑输入。
|
|
108
|
+
*/
|
|
109
|
+
export declare function executePessimisticMutation<TFilter, TItem, TEdit, TSave, TId>(datasource: YoyoCrudDataSource<TFilter, TItem, TEdit, TSave, TId>, command: YoyoCrudMutationCommand<TSave, TId>, context: YoyoMutationContext): Promise<void>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export class YoyoDatasourceBindingLinkingError extends Error {
|
|
2
|
+
diagnostics;
|
|
3
|
+
constructor(diagnostics) {
|
|
4
|
+
super(`Yoyo datasource binding linking failed: ${diagnostics.map((diagnostic) => diagnostic.code).join(', ')}`);
|
|
5
|
+
this.name = 'YoyoDatasourceBindingLinkingError';
|
|
6
|
+
this.diagnostics = diagnostics;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The sole provenance gate for runtime datasource inputs. Fixture adapters use
|
|
11
|
+
* the same CRUD contract, but cannot cross the production linking boundary.
|
|
12
|
+
*/
|
|
13
|
+
export function linkYoyoDatasourceBinding(input) {
|
|
14
|
+
if (input.environment === 'production' && input.binding.kind === 'fixture') {
|
|
15
|
+
throw new YoyoDatasourceBindingLinkingError([{
|
|
16
|
+
code: 'YDSB001',
|
|
17
|
+
severity: 'blocking',
|
|
18
|
+
message: `Fixture datasource '${input.binding.datasource.id}' cannot link in production.`,
|
|
19
|
+
guidance: 'Use a production feature-local datasource adapter before creating the runtime.',
|
|
20
|
+
}]);
|
|
21
|
+
}
|
|
22
|
+
return input.binding;
|
|
23
|
+
}
|
|
24
|
+
export function defineYoyoCrudDataSource(datasource) {
|
|
25
|
+
if (!datasource.id.trim()) {
|
|
26
|
+
throw new Error('Yoyo datasource id must not be empty.');
|
|
27
|
+
}
|
|
28
|
+
return datasource;
|
|
29
|
+
}
|
|
30
|
+
export function createYoyoDatasourceRegistry(datasources) {
|
|
31
|
+
const entries = new Map();
|
|
32
|
+
for (const datasource of datasources) {
|
|
33
|
+
if (!datasource.id.trim()) {
|
|
34
|
+
throw new Error('Datasource registry does not accept an empty id.');
|
|
35
|
+
}
|
|
36
|
+
if (entries.has(datasource.id)) {
|
|
37
|
+
throw new Error(`Datasource '${datasource.id}' is already registered.`);
|
|
38
|
+
}
|
|
39
|
+
entries.set(datasource.id, datasource);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
resolve(id) {
|
|
43
|
+
const datasource = entries.get(id);
|
|
44
|
+
if (!datasource) {
|
|
45
|
+
throw new Error(`Datasource '${id}' is not registered.`);
|
|
46
|
+
}
|
|
47
|
+
return datasource;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 标准页面固定先等待后端 mutation 成功,再失效查询;失败时故意不触碰当前列表与编辑输入。
|
|
53
|
+
*/
|
|
54
|
+
export async function executePessimisticMutation(datasource, command, context) {
|
|
55
|
+
switch (command.operation) {
|
|
56
|
+
case 'create':
|
|
57
|
+
if (!datasource.create) {
|
|
58
|
+
throw new Error(`Datasource '${datasource.id}' does not support create.`);
|
|
59
|
+
}
|
|
60
|
+
await datasource.create(command.request);
|
|
61
|
+
break;
|
|
62
|
+
case 'update':
|
|
63
|
+
if (!datasource.update) {
|
|
64
|
+
throw new Error(`Datasource '${datasource.id}' does not support update.`);
|
|
65
|
+
}
|
|
66
|
+
await datasource.update(command.request);
|
|
67
|
+
break;
|
|
68
|
+
case 'remove':
|
|
69
|
+
if (!datasource.remove) {
|
|
70
|
+
throw new Error(`Datasource '${datasource.id}' does not support remove.`);
|
|
71
|
+
}
|
|
72
|
+
await datasource.remove(command.request);
|
|
73
|
+
break;
|
|
74
|
+
case 'setEnabled':
|
|
75
|
+
if (!datasource.setEnabled) {
|
|
76
|
+
throw new Error(`Datasource '${datasource.id}' does not support setEnabled.`);
|
|
77
|
+
}
|
|
78
|
+
await datasource.setEnabled(command.request);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
await datasource.invalidate?.({
|
|
82
|
+
operation: command.operation === 'remove' ? 'softDelete' : command.operation,
|
|
83
|
+
...(command.operation === 'remove' || command.operation === 'setEnabled'
|
|
84
|
+
? { entityId: command.request.id }
|
|
85
|
+
: {}),
|
|
86
|
+
reason: 'success',
|
|
87
|
+
context,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type YoyoDatasourceErrorKind = 'validation' | 'forbidden' | 'notFound' | 'conflict' | 'transient' | 'unknown';
|
|
2
|
+
export interface YoyoDatasourceErrorInput {
|
|
3
|
+
status?: number;
|
|
4
|
+
message?: string;
|
|
5
|
+
validationErrors?: Record<string, readonly string[]>;
|
|
6
|
+
}
|
|
7
|
+
export interface YoyoDatasourceError {
|
|
8
|
+
kind: YoyoDatasourceErrorKind;
|
|
9
|
+
status: number;
|
|
10
|
+
message: string;
|
|
11
|
+
fieldErrors: Record<string, string[]>;
|
|
12
|
+
}
|
|
13
|
+
export interface YoyoDatasourceErrorHandling {
|
|
14
|
+
preserveInput: boolean;
|
|
15
|
+
showFieldErrors: boolean;
|
|
16
|
+
reload: boolean;
|
|
17
|
+
retry: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface YoyoDatasourceErrorPort<TTransportError = unknown> {
|
|
20
|
+
normalize(error: TTransportError): YoyoDatasourceError;
|
|
21
|
+
}
|
|
22
|
+
export declare function classifyYoyoDatasourceError(input: YoyoDatasourceErrorInput): YoyoDatasourceError;
|
|
23
|
+
export declare function resolveYoyoDatasourceErrorHandling(kind: YoyoDatasourceErrorKind): YoyoDatasourceErrorHandling;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function classifyYoyoDatasourceError(input) {
|
|
2
|
+
const fieldErrors = normalizeFieldErrors(input.validationErrors);
|
|
3
|
+
const status = Number.isInteger(input.status) ? input.status : 0;
|
|
4
|
+
return {
|
|
5
|
+
kind: classifyKind(status, fieldErrors),
|
|
6
|
+
status,
|
|
7
|
+
message: input.message?.trim() || '请求失败,请稍后重试。',
|
|
8
|
+
fieldErrors,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function resolveYoyoDatasourceErrorHandling(kind) {
|
|
12
|
+
switch (kind) {
|
|
13
|
+
case 'validation':
|
|
14
|
+
return { preserveInput: true, showFieldErrors: true, reload: false, retry: false };
|
|
15
|
+
case 'forbidden':
|
|
16
|
+
return { preserveInput: false, showFieldErrors: false, reload: false, retry: false };
|
|
17
|
+
case 'notFound':
|
|
18
|
+
case 'conflict':
|
|
19
|
+
return { preserveInput: true, showFieldErrors: false, reload: true, retry: false };
|
|
20
|
+
case 'transient':
|
|
21
|
+
return { preserveInput: true, showFieldErrors: false, reload: false, retry: true };
|
|
22
|
+
case 'unknown':
|
|
23
|
+
return { preserveInput: true, showFieldErrors: false, reload: false, retry: false };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function classifyKind(status, fieldErrors) {
|
|
27
|
+
if (status === 422 || (status === 400 && Object.keys(fieldErrors).length > 0))
|
|
28
|
+
return 'validation';
|
|
29
|
+
if (status === 401 || status === 403)
|
|
30
|
+
return 'forbidden';
|
|
31
|
+
if (status === 404)
|
|
32
|
+
return 'notFound';
|
|
33
|
+
if (status === 409)
|
|
34
|
+
return 'conflict';
|
|
35
|
+
if (isRetryableStatus(status))
|
|
36
|
+
return 'transient';
|
|
37
|
+
return 'unknown';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 只有真正代表「服务暂时不可用、稍后可能恢复」的状态才归入 transient。
|
|
41
|
+
* 刻意排除 500:ABP 的用户友好异常默认返回 500,业务规则失败(例如「角色已绑定用户,无法删除」)
|
|
42
|
+
* 落在这一档;若把它当作 transient,界面会引导用户「稍后重试」,而重试永远不会成功。
|
|
43
|
+
* 因此 500 归入 unknown —— 保留输入、不自动重新加载、不提示重试。
|
|
44
|
+
*/
|
|
45
|
+
function isRetryableStatus(status) {
|
|
46
|
+
return status === 408 || status === 429 || status === 502 || status === 503 || status === 504;
|
|
47
|
+
}
|
|
48
|
+
function normalizeFieldErrors(errors) {
|
|
49
|
+
if (!errors)
|
|
50
|
+
return {};
|
|
51
|
+
return Object.fromEntries(Object.entries(errors)
|
|
52
|
+
.map(([field, messages]) => [field, messages.filter((message) => message.trim().length > 0)])
|
|
53
|
+
.filter(([, messages]) => messages.length > 0));
|
|
54
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { YoyoDatasourceErrorKind } from './error.js';
|
|
2
|
+
export type YoyoMutationOperation = 'create' | 'update' | 'setEnabled' | 'softDelete';
|
|
3
|
+
/**
|
|
4
|
+
* Host-independent identity for one page mutation. scopeKey is deliberately
|
|
5
|
+
* opaque: the feature adapter owns how Host/Tenant or another boundary is
|
|
6
|
+
* represented, while the runtime only carries it through.
|
|
7
|
+
*/
|
|
8
|
+
export interface YoyoMutationContext {
|
|
9
|
+
readonly pageKey: string;
|
|
10
|
+
readonly actionKey: string;
|
|
11
|
+
readonly scopeKey: string;
|
|
12
|
+
}
|
|
13
|
+
export type YoyoMutationInvalidationReason = 'success' | 'failure';
|
|
14
|
+
export interface YoyoMutationInvalidationRequest<TId = string> {
|
|
15
|
+
readonly operation: YoyoMutationOperation;
|
|
16
|
+
readonly entityId?: TId;
|
|
17
|
+
readonly reason: YoyoMutationInvalidationReason;
|
|
18
|
+
readonly context: YoyoMutationContext;
|
|
19
|
+
}
|
|
20
|
+
export declare function createYoyoMutationContext(input: YoyoMutationContext): YoyoMutationContext;
|
|
21
|
+
export interface YoyoMutationConfirmRequest<TId = string> {
|
|
22
|
+
operation: YoyoMutationOperation;
|
|
23
|
+
entityId?: TId;
|
|
24
|
+
context: YoyoMutationContext;
|
|
25
|
+
}
|
|
26
|
+
export interface YoyoMutationConfirmPort<TId = string> {
|
|
27
|
+
confirm(request: YoyoMutationConfirmRequest<TId>): Promise<boolean>;
|
|
28
|
+
}
|
|
29
|
+
export type YoyoMutationFeedbackEvent<TId = string> = {
|
|
30
|
+
operation: YoyoMutationOperation;
|
|
31
|
+
entityId?: TId;
|
|
32
|
+
outcome: 'success';
|
|
33
|
+
context: YoyoMutationContext;
|
|
34
|
+
} | {
|
|
35
|
+
operation: YoyoMutationOperation;
|
|
36
|
+
entityId?: TId;
|
|
37
|
+
outcome: 'failure';
|
|
38
|
+
errorKind: YoyoDatasourceErrorKind;
|
|
39
|
+
/**
|
|
40
|
+
* Backend-owned friendly message for this failure. The runtime carries the
|
|
41
|
+
* normalized backend message verbatim; presentation decides how to show it
|
|
42
|
+
* (toast/warning/form) but must not replace it with a fixed string.
|
|
43
|
+
*/
|
|
44
|
+
message: string;
|
|
45
|
+
context: YoyoMutationContext;
|
|
46
|
+
};
|
|
47
|
+
export interface YoyoMutationFeedbackPort<TId = string> {
|
|
48
|
+
publish(event: YoyoMutationFeedbackEvent<TId>): Promise<void> | void;
|
|
49
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function createYoyoMutationContext(input) {
|
|
2
|
+
for (const [key, value] of Object.entries(input)) {
|
|
3
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
4
|
+
throw new Error(`Yoyo mutation context '${key}' must be a non-empty string.`);
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
pageKey: input.pageKey,
|
|
9
|
+
actionKey: input.actionKey,
|
|
10
|
+
scopeKey: input.scopeKey,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { YoyoAdminFeatureKey, YoyoAdminPermissionKey } from '../generated/capability/index.js';
|
|
2
|
+
export interface YoyoCapabilityPolicy {
|
|
3
|
+
permissions?: readonly YoyoAdminPermissionKey[];
|
|
4
|
+
features?: readonly YoyoAdminFeatureKey[];
|
|
5
|
+
}
|
|
6
|
+
export interface YoyoCapabilityRuntime {
|
|
7
|
+
grantedPermissions: ReadonlySet<YoyoAdminPermissionKey>;
|
|
8
|
+
enabledFeatures: ReadonlySet<YoyoAdminFeatureKey>;
|
|
9
|
+
}
|
|
10
|
+
export interface YoyoCapabilityPolicyResult {
|
|
11
|
+
allowed: boolean;
|
|
12
|
+
missingPermissions: YoyoAdminPermissionKey[];
|
|
13
|
+
missingFeatures: YoyoAdminFeatureKey[];
|
|
14
|
+
}
|
|
15
|
+
/** 前端只解释当前 runtime grant;后端仍是最终授权裁决者。 */
|
|
16
|
+
export declare function evaluateYoyoCapabilityPolicy(policy: YoyoCapabilityPolicy, runtime: YoyoCapabilityRuntime): YoyoCapabilityPolicyResult;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** 前端只解释当前 runtime grant;后端仍是最终授权裁决者。 */
|
|
2
|
+
export function evaluateYoyoCapabilityPolicy(policy, runtime) {
|
|
3
|
+
const missingPermissions = (policy.permissions ?? []).filter((permission) => !runtime.grantedPermissions.has(permission));
|
|
4
|
+
const missingFeatures = (policy.features ?? []).filter((feature) => !runtime.enabledFeatures.has(feature));
|
|
5
|
+
return {
|
|
6
|
+
allowed: missingPermissions.length === 0 && missingFeatures.length === 0,
|
|
7
|
+
missingPermissions,
|
|
8
|
+
missingFeatures,
|
|
9
|
+
};
|
|
10
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { YoyoTemporalContext } from '../contracts/index.js';
|
|
2
|
+
export declare const YOYO_WALL_CLOCK_FORMAT = "YYYY-MM-DD HH:mm:ss";
|
|
3
|
+
export declare const YOYO_DATE_ONLY_FORMAT = "YYYY-MM-DD";
|
|
4
|
+
export declare const YOYO_TIME_ONLY_FORMAT = "HH:mm:ss";
|
|
5
|
+
export type YoyoTemporalErrorCode = 'invalid-instant' | 'invalid-wall-clock' | 'nonexistent-wall-clock' | 'invalid-date-only' | 'invalid-time-only';
|
|
6
|
+
export declare class YoyoTemporalConversionError extends Error {
|
|
7
|
+
readonly code: YoyoTemporalErrorCode;
|
|
8
|
+
readonly value: string;
|
|
9
|
+
constructor(code: YoyoTemporalErrorCode, value: string, message: string);
|
|
10
|
+
}
|
|
11
|
+
export interface YoyoTemporalRuntime {
|
|
12
|
+
readonly context: YoyoTemporalContext;
|
|
13
|
+
instantToWallClock(instant: string, format?: string): string;
|
|
14
|
+
wallClockToInstant(wallClock: string, format?: string): string;
|
|
15
|
+
formatInstant(instant: string, format?: string): string;
|
|
16
|
+
dateOnly(value: string): string;
|
|
17
|
+
timeOnly(value: string): string;
|
|
18
|
+
expiryDateToInstant(dateOnly: string): string;
|
|
19
|
+
instantToExpiryDate(instant: string): string;
|
|
20
|
+
}
|
|
21
|
+
export declare function createYoyoTemporalRuntime(context: YoyoTemporalContext): YoyoTemporalRuntime;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import dayjs from 'dayjs';
|
|
2
|
+
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
|
|
3
|
+
import timezone from 'dayjs/plugin/timezone.js';
|
|
4
|
+
import utc from 'dayjs/plugin/utc.js';
|
|
5
|
+
dayjs.extend(utc);
|
|
6
|
+
dayjs.extend(timezone);
|
|
7
|
+
dayjs.extend(customParseFormat);
|
|
8
|
+
export const YOYO_WALL_CLOCK_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
|
9
|
+
export const YOYO_DATE_ONLY_FORMAT = 'YYYY-MM-DD';
|
|
10
|
+
export const YOYO_TIME_ONLY_FORMAT = 'HH:mm:ss';
|
|
11
|
+
export class YoyoTemporalConversionError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
value;
|
|
14
|
+
constructor(code, value, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'YoyoTemporalConversionError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.value = value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function createYoyoTemporalRuntime(context) {
|
|
22
|
+
const frozenContext = Object.freeze({ ...context });
|
|
23
|
+
assertTimeZone(frozenContext.timeZoneIana);
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
context: frozenContext,
|
|
26
|
+
instantToWallClock(instant, format = YOYO_WALL_CLOCK_FORMAT) {
|
|
27
|
+
const parsed = dayjs.utc(instant);
|
|
28
|
+
if (!parsed.isValid()) {
|
|
29
|
+
throw new YoyoTemporalConversionError('invalid-instant', instant, '时间不是有效的 UTC instant。');
|
|
30
|
+
}
|
|
31
|
+
return parsed.tz(frozenContext.timeZoneIana).format(format);
|
|
32
|
+
},
|
|
33
|
+
wallClockToInstant(wallClock, format = YOYO_WALL_CLOCK_FORMAT) {
|
|
34
|
+
const strictInput = dayjs(wallClock, format, true);
|
|
35
|
+
if (!strictInput.isValid() || strictInput.format(format) !== wallClock) {
|
|
36
|
+
throw new YoyoTemporalConversionError('invalid-wall-clock', wallClock, '时间格式无效。');
|
|
37
|
+
}
|
|
38
|
+
const parsed = dayjs.tz(wallClock, format, frozenContext.timeZoneIana);
|
|
39
|
+
if (parsed.format(format) !== wallClock) {
|
|
40
|
+
throw new YoyoTemporalConversionError('nonexistent-wall-clock', wallClock, '该本地时间因夏令时切换而不存在。');
|
|
41
|
+
}
|
|
42
|
+
return parsed.utc().toISOString();
|
|
43
|
+
},
|
|
44
|
+
formatInstant(instant, format = YOYO_WALL_CLOCK_FORMAT) {
|
|
45
|
+
return this.instantToWallClock(instant, format);
|
|
46
|
+
},
|
|
47
|
+
dateOnly(value) {
|
|
48
|
+
return validatePlainValue(value, YOYO_DATE_ONLY_FORMAT, 'invalid-date-only', '日期格式无效。');
|
|
49
|
+
},
|
|
50
|
+
timeOnly(value) {
|
|
51
|
+
return validatePlainValue(value, YOYO_TIME_ONLY_FORMAT, 'invalid-time-only', '时间格式无效。');
|
|
52
|
+
},
|
|
53
|
+
expiryDateToInstant(dateOnly) {
|
|
54
|
+
const date = validatePlainValue(dateOnly, YOYO_DATE_ONLY_FORMAT, 'invalid-date-only', '日期格式无效。');
|
|
55
|
+
const nextDate = dayjs(date, YOYO_DATE_ONLY_FORMAT, true).add(1, 'day').format(YOYO_DATE_ONLY_FORMAT);
|
|
56
|
+
return this.wallClockToInstant(`${nextDate} 00:00:00`);
|
|
57
|
+
},
|
|
58
|
+
instantToExpiryDate(instant) {
|
|
59
|
+
const boundaryDate = this.instantToWallClock(instant, YOYO_DATE_ONLY_FORMAT);
|
|
60
|
+
return dayjs(boundaryDate, YOYO_DATE_ONLY_FORMAT, true).subtract(1, 'day').format(YOYO_DATE_ONLY_FORMAT);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function validatePlainValue(value, format, code, message) {
|
|
65
|
+
const parsed = dayjs(value, format, true);
|
|
66
|
+
if (!parsed.isValid() || parsed.format(format) !== value) {
|
|
67
|
+
throw new YoyoTemporalConversionError(code, value, message);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function assertTimeZone(timeZoneIana) {
|
|
72
|
+
try {
|
|
73
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timeZoneIana }).format(0);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new YoyoTemporalConversionError('invalid-wall-clock', timeZoneIana, '业务时区不是有效的 IANA timezone。');
|
|
77
|
+
}
|
|
78
|
+
}
|