create-lumal-admin 0.0.4-beta → 0.0.6-beta
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/CHANGELOG.md +25 -0
- package/README.md +98 -12
- package/dist/cli.d.ts +18 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +308 -28
- package/dist/cli.js.map +1 -1
- package/dist/create.d.ts +11 -1
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +98 -2
- package/dist/create.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/presets.d.ts +41 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +58 -0
- package/dist/presets.js.map +1 -0
- package/dist/project-cli.d.ts +3 -0
- package/dist/project-cli.d.ts.map +1 -0
- package/dist/project-cli.js +13 -0
- package/dist/project-cli.js.map +1 -0
- package/dist/projects.d.ts +21 -0
- package/dist/projects.d.ts.map +1 -0
- package/dist/projects.js +124 -0
- package/dist/projects.js.map +1 -0
- package/dist/scaffold.d.ts +2 -11
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +2 -1602
- package/dist/scaffold.js.map +1 -1
- package/dist/templates.d.ts +7 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +91 -0
- package/dist/templates.js.map +1 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +13 -4
- package/templates/base/.env.example +4 -0
- package/templates/base/API-CONTRACT.md +84 -0
- package/templates/base/README.md +59 -0
- package/templates/base/index.html +12 -0
- package/templates/base/package.json +38 -0
- package/templates/base/src/App.vue +206 -0
- package/templates/base/src/app/components/AppHeaderActions.vue +46 -0
- package/templates/base/src/app/components/AppSettingsDrawer.vue +37 -0
- package/templates/base/src/app/menu-policy.ts +163 -0
- package/templates/base/src/app/module.ts +46 -0
- package/templates/base/src/app/router/index.ts +107 -0
- package/templates/base/src/app/router/views.ts +12 -0
- package/templates/base/src/app/services/access.ts +2 -0
- package/templates/base/src/app/services/contracts.ts +54 -0
- package/templates/base/src/app/services/local-contracts.ts +18 -0
- package/templates/base/src/app/services/preferences.ts +41 -0
- package/templates/base/src/app/services/session-runtime.ts +186 -0
- package/templates/base/src/app/services/session.ts +14 -0
- package/templates/base/src/app/stored-menus.ts +72 -0
- package/templates/base/src/app/views/SafeIframeView.vue +34 -0
- package/templates/base/src/app/views/auth/LoginView.vue +75 -0
- package/templates/base/src/app/views/error/ForbiddenView.vue +20 -0
- package/templates/base/src/app/views/error/NotFoundView.vue +16 -0
- package/templates/base/src/main.ts +40 -0
- package/templates/base/src/modules/business/README.md +33 -0
- package/templates/base/src/modules/dashboard/DashboardView.vue +27 -0
- package/templates/base/src/modules/dashboard/module.ts +14 -0
- package/templates/base/src/shared/api.ts +53 -0
- package/templates/base/src/shared/components/BaseEntityDialog.vue +39 -0
- package/templates/base/src/shared/composables/use-async-data.ts +35 -0
- package/templates/base/src/shared/composables/use-entity-editor.ts +77 -0
- package/templates/base/src/shared/status.ts +4 -0
- package/templates/base/src/shared/tree.ts +41 -0
- package/templates/base/src/styles.scss +173 -0
- package/templates/base/tests/menu-policy.test.ts.template +89 -0
- package/templates/base/tests/session.test.ts.template +45 -0
- package/templates/base/vite.config.ts +54 -0
- package/templates/blank/README.md +6 -0
- package/templates/blank/src/App.vue +11 -0
- package/templates/common/AGENTS.md +13 -0
- package/templates/common/_editorconfig +15 -0
- package/templates/common/_gitattributes +6 -0
- package/templates/common/_gitignore +7 -0
- package/templates/common/_prettierignore +6 -0
- package/templates/common/docs/DEVELOPMENT.md +26 -0
- package/templates/common/eslint.config.mjs +60 -0
- package/templates/common/package.json +39 -0
- package/templates/common/prettier.config.mjs +8 -0
- package/templates/common/tsconfig.json +19 -0
- package/templates/common/vitest.config.ts +3 -0
- package/templates/features/examples/src/modules/examples/CrudExampleView.vue +51 -0
- package/templates/features/examples/src/modules/examples/FormExampleView.vue +32 -0
- package/templates/features/examples/src/modules/examples/module.ts +27 -0
- package/templates/features/system/src/modules/system/config/ConfigView.vue +104 -0
- package/templates/features/system/src/modules/system/config/api.ts +38 -0
- package/templates/features/system/src/modules/system/config/routes.ts +21 -0
- package/templates/features/system/src/modules/system/dictionary/DictionaryItemView.vue +141 -0
- package/templates/features/system/src/modules/system/dictionary/DictionaryTypeView.vue +107 -0
- package/templates/features/system/src/modules/system/dictionary/api.ts +61 -0
- package/templates/features/system/src/modules/system/dictionary/cache.ts +9 -0
- package/templates/features/system/src/modules/system/dictionary/routes.ts +32 -0
- package/templates/features/system/src/modules/system/dictionary/types.ts +22 -0
- package/templates/features/system/src/modules/system/local.ts +348 -0
- package/templates/features/system/src/modules/system/menu/MenuView.vue +133 -0
- package/templates/features/system/src/modules/system/menu/api.ts +73 -0
- package/templates/features/system/src/modules/system/menu/components/MenuEditorDialog.vue +106 -0
- package/templates/features/system/src/modules/system/menu/routes.ts +23 -0
- package/templates/features/system/src/modules/system/menu/types.ts +18 -0
- package/templates/features/system/src/modules/system/module.ts +22 -0
- package/templates/features/system/src/modules/system/organization/OrganizationView.vue +134 -0
- package/templates/features/system/src/modules/system/organization/api.ts +38 -0
- package/templates/features/system/src/modules/system/organization/routes.ts +23 -0
- package/templates/features/system/src/modules/system/organization/types.ts +17 -0
- package/templates/features/system/src/modules/system/role/RoleView.vue +90 -0
- package/templates/features/system/src/modules/system/role/api.ts +40 -0
- package/templates/features/system/src/modules/system/role/components/RoleAuthorizationDialog.vue +110 -0
- package/templates/features/system/src/modules/system/role/permission-tree.ts +31 -0
- package/templates/features/system/src/modules/system/role/routes.ts +24 -0
- package/templates/features/system/src/modules/system/role/types.ts +12 -0
- package/templates/features/system/src/modules/system/user/UserView.vue +297 -0
- package/templates/features/system/src/modules/system/user/api.ts +45 -0
- package/templates/features/system/src/modules/system/user/components/UserOrganizationFilter.vue +61 -0
- package/templates/features/system/src/modules/system/user/components/UserRoleDialog.vue +93 -0
- package/templates/features/system/src/modules/system/user/routes.ts +27 -0
- package/templates/features/system/src/modules/system/user/types.ts +28 -0
- package/templates/features/system/tests/system.test.ts.template +266 -0
- package/templates/modes/http/src/app/services/data-source.ts +124 -0
- package/templates/modes/http/src/app/services/http-adapter.ts +128 -0
- package/templates/modes/http/tests/http.test.ts.template +218 -0
- package/templates/modes/local/src/app/services/data-source.ts +113 -0
- package/templates/modes/local/src/app/services/local-database.ts +67 -0
- package/templates/modes/local/tests/local.test.ts.template +132 -0
- package/templates/presets/standard/src/modules/dashboard/DashboardView.vue +52 -0
- package/templates/presets/standard/src/modules/dashboard/components/DashboardSessionStatus.vue +41 -0
- package/templates/presets/standard/src/modules/dashboard/components/DashboardShortcuts.vue +51 -0
- package/templates/presets/standard/src/modules/profile/ProfileView.vue +109 -0
- package/templates/presets/standard/src/modules/profile/api.ts +49 -0
- package/templates/presets/standard/src/modules/profile/components/ProfilePasswordForm.vue +68 -0
- package/templates/presets/standard/src/modules/profile/local.ts +40 -0
- package/templates/presets/standard/src/modules/profile/module.ts +11 -0
- package/templates/presets/standard/tests/profile.test.ts.template +72 -0
- package/templates/screen/README.md +6 -0
- package/templates/screen/src/App.vue +7 -0
- package/templates/screen/src/screen/ScreenEntry.vue +18 -0
- package/templates/screen/src/screen/ScreenView.vue +48 -0
- package/templates/screen/src/screen/components/ScreenMetrics.vue +40 -0
- package/templates/screen-auth/src/screen/ScreenEntry.vue +52 -0
- package/templates/screen-auth/src/screen/components/ScreenLoginForm.vue +44 -0
- package/templates/screen-auth/src/services/session.ts +61 -0
- package/templates/screen-auth/tests/session.test.ts.template +54 -0
- package/templates/screen-auth-http/src/services/auth-adapter.ts +14 -0
- package/templates/screen-auth-http/tests/auth-adapter.test.ts.template +17 -0
- package/templates/screen-auth-local-demo/src/services/auth-adapter.ts +15 -0
- package/templates/screen-auth-local-demo/tests/auth-adapter.test.ts.template +18 -0
- package/templates/screen-data/src/screen/ScreenView.vue +69 -0
- package/templates/screen-data/src/services/metrics-types.ts +4 -0
- package/templates/screen-data-http/src/services/metrics.ts +20 -0
- package/templates/screen-data-http/src/services/request.ts +3 -0
- package/templates/screen-data-http/tests/metrics.test.ts.template +21 -0
- package/templates/screen-data-local/src/services/metrics.ts +11 -0
- package/templates/screen-data-local/tests/metrics.test.ts.template +9 -0
- package/templates/screen-http/API-CONTRACT.md +20 -0
- package/templates/screen-http/src/services/http.ts +17 -0
- package/templates/screen-request-auth/src/services/request.ts +15 -0
- package/templates/screen-router/src/App.vue +7 -0
- package/templates/screen-router/src/main.ts +13 -0
- package/templates/screen-router/src/router.ts +7 -0
- package/templates/screen-router/tests/app.test.ts.template +20 -0
- package/templates/screen-viewport/src/screen/ScreenViewport.vue +30 -0
- package/templates/screen-visualization-charts/src/screen/components/ScreenVisualization.vue +22 -0
- package/templates/screen-visualization-cockpit/src/screen/components/ScreenVisualization.vue +33 -0
- package/templates/screen-visualization-cockpit/src/screen/components/ScreenWidget.vue +10 -0
- package/templates/screen-visualization-datav/src/screen/components/ScreenVisualization.vue +11 -0
- package/templates/vben-compatible/MIGRATION.md +30 -0
- package/templates/vben-compatible/src/modules/vben-migration/VbenFormView.vue +11 -0
- package/templates/vben-compatible/src/modules/vben-migration/VbenUsersView.vue +12 -0
- package/templates/vben-compatible/src/modules/vben-migration/components/VbenFormExample.vue +64 -0
- package/templates/vben-compatible/src/modules/vben-migration/components/VbenUsers.vue +144 -0
- package/templates/vben-compatible/src/modules/vben-migration/local.ts +68 -0
- package/templates/vben-compatible/src/modules/vben-migration/module.ts +42 -0
- package/templates/vue/index.html +12 -0
- package/templates/vue/src/main.ts +5 -0
- package/templates/vue/src/styles.css +19 -0
- package/templates/vue/tests/app.test.ts.template +10 -0
- package/templates/vue/vite.config.ts +9 -0
- package/templates/vue/vitest.config.ts +4 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { SchemaFormModel } from '@lumal/core/components';
|
|
2
|
+
import type { BusinessTransport } from '../../../app/services/contracts';
|
|
3
|
+
import type { DictionaryItem, DictionaryItemInput, DictionaryType, DictionaryTypeInput } from './types';
|
|
4
|
+
import { formStatus, integer, object, rows, text } from '../../../shared/api';
|
|
5
|
+
|
|
6
|
+
export function dictionaryTypeInput(model: SchemaFormModel): DictionaryTypeInput {
|
|
7
|
+
return {
|
|
8
|
+
name: text(model.name, '字典名称', true),
|
|
9
|
+
code: text(model.code, '字典编码', true),
|
|
10
|
+
description: text(model.description, '说明'),
|
|
11
|
+
status: formStatus(model),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function dictionaryItemInput(model: SchemaFormModel): DictionaryItemInput {
|
|
15
|
+
const color = text(model.color, '颜色');
|
|
16
|
+
if (color && !/^#[\da-f]{6}$/i.test(color)) throw new Error('颜色必须是六位十六进制值');
|
|
17
|
+
return {
|
|
18
|
+
typeCode: text(model.typeCode, '字典分类', true),
|
|
19
|
+
label: text(model.label, '显示名称', true),
|
|
20
|
+
value: text(model.value, '字典值', true),
|
|
21
|
+
color,
|
|
22
|
+
order: integer(model.order ?? 0),
|
|
23
|
+
status: formStatus(model),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function parseType(value: unknown): DictionaryType {
|
|
27
|
+
const raw = object(value);
|
|
28
|
+
return { ...dictionaryTypeInput(raw), id: text(raw.id, '字典 ID', true) };
|
|
29
|
+
}
|
|
30
|
+
export function parseItem(value: unknown): DictionaryItem {
|
|
31
|
+
const raw = object(value);
|
|
32
|
+
return { ...dictionaryItemInput(raw), id: text(raw.id, '字典项 ID', true) };
|
|
33
|
+
}
|
|
34
|
+
export function createDictionaryService(transport: BusinessTransport, invalidate: () => void = () => {}) {
|
|
35
|
+
async function mutation<T>(
|
|
36
|
+
method: 'POST' | 'PUT' | 'DELETE',
|
|
37
|
+
path: string,
|
|
38
|
+
input: object | undefined,
|
|
39
|
+
parse: (value: unknown) => T,
|
|
40
|
+
) {
|
|
41
|
+
const result = await transport.request(method, path, { body: input });
|
|
42
|
+
// 先使旧请求失效再刷新消费者,防止旧字典响应回填。
|
|
43
|
+
invalidate();
|
|
44
|
+
return parse(result);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
types: async () => rows(await transport.request('GET', '/system/dictionaries/types'), parseType),
|
|
48
|
+
items: async (typeCode: string) =>
|
|
49
|
+
rows(await transport.request('GET', '/system/dictionaries/items', { query: { typeCode } }), parseItem),
|
|
50
|
+
createType: (input: DictionaryTypeInput) => mutation('POST', '/system/dictionaries/types', input, parseType),
|
|
51
|
+
updateType: (id: string, input: DictionaryTypeInput) =>
|
|
52
|
+
mutation('PUT', `/system/dictionaries/types/${encodeURIComponent(id)}`, input, parseType),
|
|
53
|
+
removeType: (id: string) =>
|
|
54
|
+
mutation('DELETE', `/system/dictionaries/types/${encodeURIComponent(id)}`, undefined, () => undefined),
|
|
55
|
+
createItem: (input: DictionaryItemInput) => mutation('POST', '/system/dictionaries/items', input, parseItem),
|
|
56
|
+
updateItem: (id: string, input: DictionaryItemInput) =>
|
|
57
|
+
mutation('PUT', `/system/dictionaries/items/${encodeURIComponent(id)}`, input, parseItem),
|
|
58
|
+
removeItem: (id: string) =>
|
|
59
|
+
mutation('DELETE', `/system/dictionaries/items/${encodeURIComponent(id)}`, undefined, () => undefined),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createDictionaryStore } from '@lumal/core/dictionary';
|
|
2
|
+
import { application, service } from '../../../app/services/session';
|
|
3
|
+
|
|
4
|
+
export const dictionaryStore = createDictionaryStore({
|
|
5
|
+
fetcher: async (code) => ({
|
|
6
|
+
items: await service.request('GET', `/system/dictionaries/options/${encodeURIComponent(code)}`),
|
|
7
|
+
}),
|
|
8
|
+
});
|
|
9
|
+
application.onReset(() => dictionaryStore.clearDictionary());
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AppModule } from '../../../app/module';
|
|
2
|
+
|
|
3
|
+
export const menus: AppModule['menus'] = [
|
|
4
|
+
{
|
|
5
|
+
path: '/system/dictionaries/types',
|
|
6
|
+
name: 'DictionaryTypes',
|
|
7
|
+
component: 'system/dictionaries/types',
|
|
8
|
+
meta: { title: '字典分类', authority: ['system:dict:list'], keepAlive: true, order: 5 },
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
path: '/system/dictionaries/items',
|
|
12
|
+
name: 'DictionaryItems',
|
|
13
|
+
component: 'system/dictionaries/items',
|
|
14
|
+
meta: { title: '字典项管理', authority: ['system:dict:list'], keepAlive: true, order: 6 },
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
export const views: AppModule['views'] = {
|
|
18
|
+
'system/dictionaries/types': () => import('./DictionaryTypeView.vue'),
|
|
19
|
+
'system/dictionaries/items': () => import('./DictionaryItemView.vue'),
|
|
20
|
+
};
|
|
21
|
+
export const permissions: AppModule['permissions'] = [
|
|
22
|
+
{
|
|
23
|
+
id: 'system-dictionary',
|
|
24
|
+
label: '字典管理',
|
|
25
|
+
children: [
|
|
26
|
+
{ id: 'system:dict:list', label: '查询字典', permission: 'system:dict:list' },
|
|
27
|
+
{ id: 'system:dict:create', label: '新增字典', permission: 'system:dict:create' },
|
|
28
|
+
{ id: 'system:dict:update', label: '修改字典', permission: 'system:dict:update' },
|
|
29
|
+
{ id: 'system:dict:delete', label: '删除字典', permission: 'system:dict:delete' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Status } from '../../../shared/api';
|
|
2
|
+
|
|
3
|
+
export interface DictionaryTypeInput {
|
|
4
|
+
name: string;
|
|
5
|
+
code: string;
|
|
6
|
+
description: string;
|
|
7
|
+
status: Status;
|
|
8
|
+
}
|
|
9
|
+
export interface DictionaryType extends DictionaryTypeInput {
|
|
10
|
+
id: string;
|
|
11
|
+
}
|
|
12
|
+
export interface DictionaryItemInput {
|
|
13
|
+
typeCode: string;
|
|
14
|
+
label: string;
|
|
15
|
+
value: string;
|
|
16
|
+
color: string;
|
|
17
|
+
order: number;
|
|
18
|
+
status: Status;
|
|
19
|
+
}
|
|
20
|
+
export interface DictionaryItem extends DictionaryItemInput {
|
|
21
|
+
id: string;
|
|
22
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type { DataRow } from '../../app/services/contracts';
|
|
2
|
+
import type { LocalBusinessContext, LocalHandler } from '../../app/services/local-contracts';
|
|
3
|
+
import { object, stringArray } from '../../app/menu-policy';
|
|
4
|
+
import { permissionCodes, permissionTree, viewComponents } from '../../app/router/views';
|
|
5
|
+
import { buildStoredMenus } from '../../app/stored-menus';
|
|
6
|
+
import { assertHierarchy, buildTree } from '../../shared/tree';
|
|
7
|
+
import { configInput } from './config/api';
|
|
8
|
+
import { dictionaryItemInput, dictionaryTypeInput } from './dictionary/api';
|
|
9
|
+
import { menuInput } from './menu/api';
|
|
10
|
+
import { organizationInput } from './organization/api';
|
|
11
|
+
import { roleInput } from './role/api';
|
|
12
|
+
import { userInput } from './user/api';
|
|
13
|
+
|
|
14
|
+
const defaultConfig = {
|
|
15
|
+
appName: 'Lumal Admin',
|
|
16
|
+
colorPrimary: '#1677ff',
|
|
17
|
+
layout: 'mixed-nav',
|
|
18
|
+
tabbarEnable: true,
|
|
19
|
+
transitionEnable: true,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** 本地业务模型独立于页面,使用与 HTTP 相同的业务端点和关联约束。 */
|
|
23
|
+
export function createLocalHandler({ db, user, assertPermission }: LocalBusinessContext): LocalHandler {
|
|
24
|
+
const initial: Record<string, DataRow[]> = {
|
|
25
|
+
organizations: [
|
|
26
|
+
{
|
|
27
|
+
id: 'root',
|
|
28
|
+
parentId: '',
|
|
29
|
+
name: '总部',
|
|
30
|
+
code: 'root',
|
|
31
|
+
leader: '管理员',
|
|
32
|
+
phone: '',
|
|
33
|
+
email: '',
|
|
34
|
+
status: 'enabled',
|
|
35
|
+
order: 0,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
'dictionaries/types': [
|
|
39
|
+
{ id: 'status', name: '状态', code: 'status', description: '开发演示状态', status: 'enabled' },
|
|
40
|
+
],
|
|
41
|
+
'dictionaries/items': [
|
|
42
|
+
{
|
|
43
|
+
id: 'enabled',
|
|
44
|
+
typeCode: 'status',
|
|
45
|
+
label: '启用',
|
|
46
|
+
value: 'enabled',
|
|
47
|
+
color: '#15803d',
|
|
48
|
+
order: 1,
|
|
49
|
+
status: 'enabled',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'disabled',
|
|
53
|
+
typeCode: 'status',
|
|
54
|
+
label: '停用',
|
|
55
|
+
value: 'disabled',
|
|
56
|
+
color: '#64748b',
|
|
57
|
+
order: 2,
|
|
58
|
+
status: 'enabled',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
const read = (resource: string) => db.read(resource, () => initial[resource] ?? []);
|
|
63
|
+
const cleanUser = ({ password: _password, ...row }: DataRow) => row;
|
|
64
|
+
const hierarchy = (rows: DataRow[]) =>
|
|
65
|
+
rows.map((row): DataRow & { id: string; parentId: string } => ({
|
|
66
|
+
...row,
|
|
67
|
+
id: String(row.id),
|
|
68
|
+
parentId: String(row.parentId ?? ''),
|
|
69
|
+
}));
|
|
70
|
+
function requireRecord(rows: DataRow[], id: string): DataRow {
|
|
71
|
+
const record = rows.find((row) => row.id === id);
|
|
72
|
+
if (!record) throw new Error('记录不存在');
|
|
73
|
+
return record;
|
|
74
|
+
}
|
|
75
|
+
function validateUser(model: DataRow, id?: string): DataRow {
|
|
76
|
+
const input = userInput(model);
|
|
77
|
+
if (input.nickname.length > 32 || (input.phone && !/^1\d{10}$/.test(input.phone)) || !input.roles.length)
|
|
78
|
+
throw new Error('用户资料无效');
|
|
79
|
+
if (read('users').some((row) => row.id !== id && row.username === input.username)) throw new Error('用户名重复');
|
|
80
|
+
if (
|
|
81
|
+
input.organizationId &&
|
|
82
|
+
!read('organizations').some((row) => row.id === input.organizationId && row.status === 'enabled')
|
|
83
|
+
)
|
|
84
|
+
throw new Error('所属机构不存在或已停用');
|
|
85
|
+
if (input.roles.some((code) => !read('roles').some((row) => row.code === code && row.status === 'enabled')))
|
|
86
|
+
throw new Error('角色不存在或已停用');
|
|
87
|
+
const previous = id ? requireRecord(read('users'), id) : undefined;
|
|
88
|
+
if (
|
|
89
|
+
previous?.username === 'admin' &&
|
|
90
|
+
(input.username !== 'admin' || input.status !== 'enabled' || !input.roles.includes('admin'))
|
|
91
|
+
)
|
|
92
|
+
throw new Error('不能停用或移除内置管理员角色');
|
|
93
|
+
if (previous && JSON.stringify(previous.roles) !== JSON.stringify(input.roles))
|
|
94
|
+
assertPermission('system:user:assign-roles');
|
|
95
|
+
if (previous && previous.status !== input.status) assertPermission('system:user:status');
|
|
96
|
+
return {
|
|
97
|
+
password: 'lumal123',
|
|
98
|
+
...previous,
|
|
99
|
+
...input,
|
|
100
|
+
id: id ?? crypto.randomUUID(),
|
|
101
|
+
createdAt: previous?.createdAt ?? new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function saveRows(resource: string, record: DataRow, id?: string) {
|
|
105
|
+
const rows = read(resource);
|
|
106
|
+
db.write(resource, id ? rows.map((row) => (row.id === id ? record : row)) : [...rows, record]);
|
|
107
|
+
return resource === 'users' ? cleanUser(record) : record;
|
|
108
|
+
}
|
|
109
|
+
function validate(resource: string, body: DataRow, id?: string): DataRow {
|
|
110
|
+
if (resource === 'users') return validateUser(body, id);
|
|
111
|
+
const rows = read(resource);
|
|
112
|
+
const previous = id ? requireRecord(rows, id) : undefined;
|
|
113
|
+
let input: DataRow;
|
|
114
|
+
if (resource === 'roles') {
|
|
115
|
+
if (Object.hasOwn(body, 'permissions')) throw new Error('角色资料与授权必须分别保存');
|
|
116
|
+
input = { ...roleInput(body), builtIn: previous?.builtIn ?? false, permissions: previous?.permissions ?? [] };
|
|
117
|
+
if (previous && input.code !== previous.code) throw new Error('角色编码不允许修改');
|
|
118
|
+
if (previous?.builtIn && input.status !== 'enabled') throw new Error('不能停用内置角色');
|
|
119
|
+
} else if (resource === 'organizations') {
|
|
120
|
+
input = { ...organizationInput(body) };
|
|
121
|
+
} else if (resource === 'menus') {
|
|
122
|
+
input = { ...menuInput(body) };
|
|
123
|
+
} else if (resource === 'dictionaries/types') {
|
|
124
|
+
input = { ...dictionaryTypeInput(body) };
|
|
125
|
+
if (previous && input.code !== previous.code) throw new Error('字典编码不允许修改');
|
|
126
|
+
} else {
|
|
127
|
+
input = { ...dictionaryItemInput(body) };
|
|
128
|
+
if (!read('dictionaries/types').some((row) => row.code === input.typeCode)) throw new Error('字典分类不存在');
|
|
129
|
+
if (rows.some((row) => row.id !== id && row.typeCode === input.typeCode && row.value === input.value))
|
|
130
|
+
throw new Error('同分类字典值重复');
|
|
131
|
+
}
|
|
132
|
+
const unique = ['roles', 'organizations', 'dictionaries/types'].includes(resource) ? 'code' : undefined;
|
|
133
|
+
if (unique && rows.some((row) => row.id !== id && row[unique] === input[unique])) throw new Error('编码重复');
|
|
134
|
+
const record: DataRow = { ...previous, ...input, id: id ?? crypto.randomUUID() };
|
|
135
|
+
const next = id ? rows.map((row) => (row.id === id ? record : row)) : [...rows, record];
|
|
136
|
+
if (resource === 'organizations') assertHierarchy(hierarchy(next));
|
|
137
|
+
if (resource === 'menus') {
|
|
138
|
+
assertHierarchy(hierarchy(next));
|
|
139
|
+
const active = next.filter((row) => row.type !== 'button');
|
|
140
|
+
if (
|
|
141
|
+
new Set(active.map((row) => row.path)).size !== active.length ||
|
|
142
|
+
new Set(active.map((row) => row.name)).size !== active.length
|
|
143
|
+
)
|
|
144
|
+
throw new Error('菜单路径或路由名重复');
|
|
145
|
+
buildStoredMenus(next, viewComponents);
|
|
146
|
+
}
|
|
147
|
+
return record;
|
|
148
|
+
}
|
|
149
|
+
function remove(resource: string, id: string): void {
|
|
150
|
+
const rows = read(resource);
|
|
151
|
+
const record = requireRecord(rows, id);
|
|
152
|
+
if ((resource === 'users' && record.username === 'admin') || (resource === 'roles' && record.builtIn))
|
|
153
|
+
throw new Error('不能删除内置管理员或内置角色');
|
|
154
|
+
if (resource === 'roles' && read('users').some((row) => stringArray(row.roles).includes(String(record.code))))
|
|
155
|
+
throw new Error('角色仍被用户引用');
|
|
156
|
+
if (['menus', 'organizations'].includes(resource) && rows.some((row) => row.parentId === id))
|
|
157
|
+
throw new Error('请先删除子节点');
|
|
158
|
+
if (resource === 'organizations' && read('users').some((row) => row.organizationId === id))
|
|
159
|
+
throw new Error('机构仍有关联用户');
|
|
160
|
+
if (resource === 'dictionaries/types')
|
|
161
|
+
db.write(
|
|
162
|
+
'dictionaries/items',
|
|
163
|
+
read('dictionaries/items').filter((row) => row.typeCode !== record.code),
|
|
164
|
+
);
|
|
165
|
+
db.write(
|
|
166
|
+
resource,
|
|
167
|
+
rows.filter((row) => row.id !== id),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
handles: (path) => path.startsWith('/system/'),
|
|
172
|
+
async request(method, path, request) {
|
|
173
|
+
user();
|
|
174
|
+
const assertMethod = (...allowed: string[]) => {
|
|
175
|
+
if (!allowed.includes(method)) throw new Error(`此接口不支持 ${method} 操作`);
|
|
176
|
+
};
|
|
177
|
+
const body = request.body === undefined ? {} : object(request.body);
|
|
178
|
+
if (path === '/system/roles/options') {
|
|
179
|
+
assertMethod('GET');
|
|
180
|
+
assertPermission('system:user:list');
|
|
181
|
+
return read('roles').map((row) => ({ label: row.name, value: row.code, disabled: row.status !== 'enabled' }));
|
|
182
|
+
}
|
|
183
|
+
if (path === '/system/organizations/options') {
|
|
184
|
+
assertMethod('GET');
|
|
185
|
+
assertPermission('system:user:list');
|
|
186
|
+
const convert = (items: (DataRow & { id: string; children?: DataRow[] })[]): unknown[] =>
|
|
187
|
+
items.map((row) => ({
|
|
188
|
+
label: row.name,
|
|
189
|
+
value: row.id,
|
|
190
|
+
disabled: row.status !== 'enabled',
|
|
191
|
+
children: convert((row.children ?? []).map((child) => ({ ...child, id: String(child.id) }))),
|
|
192
|
+
}));
|
|
193
|
+
return convert(buildTree(hierarchy(read('organizations'))));
|
|
194
|
+
}
|
|
195
|
+
if (path === '/system/permissions/tree') {
|
|
196
|
+
assertMethod('GET');
|
|
197
|
+
assertPermission('system:role:authorize');
|
|
198
|
+
return structuredClone(permissionTree);
|
|
199
|
+
}
|
|
200
|
+
const grant = path.match(/^\/system\/roles\/([^/]+)\/permissions$/);
|
|
201
|
+
if (grant) {
|
|
202
|
+
assertMethod('GET', 'PUT');
|
|
203
|
+
assertPermission('system:role:authorize');
|
|
204
|
+
const code = decodeURIComponent(grant[1]!);
|
|
205
|
+
const roles = read('roles');
|
|
206
|
+
const role = roles.find((row) => row.code === code);
|
|
207
|
+
if (!role) throw new Error('角色不存在');
|
|
208
|
+
if (method === 'GET') return stringArray(role.permissions ?? []);
|
|
209
|
+
const permissions = stringArray(body.permissions);
|
|
210
|
+
if (permissions.some((code) => !permissionCodes.includes(code))) throw new Error('授权包含未注册权限');
|
|
211
|
+
if (role.code === 'admin' && !permissionCodes.every((code) => permissions.includes(code)))
|
|
212
|
+
throw new Error('不能移除内置管理员权限');
|
|
213
|
+
db.write(
|
|
214
|
+
'roles',
|
|
215
|
+
roles.map((row) => (row.id === role.id ? { ...row, permissions } : row)),
|
|
216
|
+
);
|
|
217
|
+
return permissions;
|
|
218
|
+
}
|
|
219
|
+
const batch = path.match(/^\/system\/users\/batch\/(status|roles)$/);
|
|
220
|
+
const special = path.match(/^\/system\/users\/([^/]+)\/(status|roles|reset-password)$/);
|
|
221
|
+
if (batch || special) {
|
|
222
|
+
const action = batch?.[1] ?? special?.[2];
|
|
223
|
+
assertMethod(action === 'status' ? 'PATCH' : action === 'roles' ? 'PUT' : 'POST');
|
|
224
|
+
assertPermission(`system:user:${action === 'roles' ? 'assign-roles' : action}`);
|
|
225
|
+
const all = read('users');
|
|
226
|
+
const ids = batch ? stringArray(body.ids) : [decodeURIComponent(special![1]!)];
|
|
227
|
+
if (!ids.length) throw new Error('请先选择用户');
|
|
228
|
+
const selected = ids.map((id) => requireRecord(all, id));
|
|
229
|
+
if (action === 'reset-password') {
|
|
230
|
+
const temporaryPassword = crypto.randomUUID().replaceAll('-', '').slice(0, 16);
|
|
231
|
+
db.write(
|
|
232
|
+
'users',
|
|
233
|
+
all.map((row) => (ids.includes(String(row.id)) ? { ...row, password: temporaryPassword } : row)),
|
|
234
|
+
);
|
|
235
|
+
return { temporaryPassword };
|
|
236
|
+
}
|
|
237
|
+
if (batch && action === 'roles' && !['append', 'replace'].includes(String(body.mode)))
|
|
238
|
+
throw new Error('角色分配方式无效');
|
|
239
|
+
const changed = selected.map((row) => {
|
|
240
|
+
const roles = stringArray(body.roles ?? []);
|
|
241
|
+
return validateUser(
|
|
242
|
+
{
|
|
243
|
+
...row,
|
|
244
|
+
...(action === 'status'
|
|
245
|
+
? { status: body.status }
|
|
246
|
+
: {
|
|
247
|
+
roles:
|
|
248
|
+
batch && body.mode === 'append' ? [...new Set([...stringArray(row.roles), ...roles])] : roles,
|
|
249
|
+
}),
|
|
250
|
+
},
|
|
251
|
+
String(row.id),
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
db.write(
|
|
255
|
+
'users',
|
|
256
|
+
all.map((row) => changed.find((item) => item.id === row.id) ?? row),
|
|
257
|
+
);
|
|
258
|
+
return batch ? { items: changed.map(cleanUser), updated: changed.length } : cleanUser(changed[0]!);
|
|
259
|
+
}
|
|
260
|
+
if (path.startsWith('/system/dictionaries/options/')) {
|
|
261
|
+
assertMethod('GET');
|
|
262
|
+
const code = decodeURIComponent(path.split('/').at(-1)!);
|
|
263
|
+
if (!read('dictionaries/types').some((row) => row.code === code && row.status === 'enabled')) return [];
|
|
264
|
+
return read('dictionaries/items')
|
|
265
|
+
.filter((row) => row.typeCode === code && row.status === 'enabled')
|
|
266
|
+
.sort((a, b) => Number(a.order) - Number(b.order))
|
|
267
|
+
.map((row) => ({ label: row.label, value: row.value, color: row.color }));
|
|
268
|
+
}
|
|
269
|
+
if (path === '/system/config' || path === '/system/config/restore') {
|
|
270
|
+
assertMethod(...(path.endsWith('/restore') ? ['POST'] : ['GET', 'PUT']));
|
|
271
|
+
assertPermission(`system:config:${method === 'GET' ? 'view' : 'update'}`);
|
|
272
|
+
const config =
|
|
273
|
+
method === 'GET'
|
|
274
|
+
? db.read('config', () => [{ ...defaultConfig }])[0]!
|
|
275
|
+
: path.endsWith('/restore')
|
|
276
|
+
? defaultConfig
|
|
277
|
+
: configInput(body);
|
|
278
|
+
if (method !== 'GET') db.write('config', [{ ...config }]);
|
|
279
|
+
return config;
|
|
280
|
+
}
|
|
281
|
+
const match = path.match(
|
|
282
|
+
/^\/system\/(users|roles|menus|organizations|dictionaries\/(?:types|items))(?:\/([^/]+))?$/,
|
|
283
|
+
);
|
|
284
|
+
if (!match) throw new Error(`本地不支持此接口:${method} ${path}`);
|
|
285
|
+
const resource = match[1]!;
|
|
286
|
+
const id = match[2] ? decodeURIComponent(match[2]) : undefined;
|
|
287
|
+
assertMethod(...(id ? ['PUT', 'DELETE'] : ['GET', 'POST']));
|
|
288
|
+
const prefix = {
|
|
289
|
+
users: 'user',
|
|
290
|
+
roles: 'role',
|
|
291
|
+
menus: 'menu',
|
|
292
|
+
organizations: 'organization',
|
|
293
|
+
'dictionaries/types': 'dict',
|
|
294
|
+
'dictionaries/items': 'dict',
|
|
295
|
+
}[resource]!;
|
|
296
|
+
assertPermission(
|
|
297
|
+
`system:${prefix}:${method === 'GET' ? 'list' : method === 'POST' ? 'create' : method === 'DELETE' ? 'delete' : 'update'}`,
|
|
298
|
+
);
|
|
299
|
+
if (method === 'GET') {
|
|
300
|
+
const query = request.query ?? {};
|
|
301
|
+
let result = read(resource);
|
|
302
|
+
if (query.keyword)
|
|
303
|
+
result = result.filter((row) =>
|
|
304
|
+
Object.values(row).some((value) => String(value).includes(String(query.keyword))),
|
|
305
|
+
);
|
|
306
|
+
if (query.status) result = result.filter((row) => row.status === query.status);
|
|
307
|
+
if (query.typeCode) result = result.filter((row) => row.typeCode === query.typeCode);
|
|
308
|
+
if (query.roles) result = result.filter((row) => stringArray(row.roles).includes(String(query.roles)));
|
|
309
|
+
if (query.organizationId) {
|
|
310
|
+
const organizations = hierarchy(read('organizations'));
|
|
311
|
+
const descendants = new Set([String(query.organizationId)]);
|
|
312
|
+
for (let size = -1; size !== descendants.size;) {
|
|
313
|
+
size = descendants.size;
|
|
314
|
+
organizations.filter((row) => descendants.has(row.parentId)).forEach((row) => descendants.add(row.id));
|
|
315
|
+
}
|
|
316
|
+
result = result.filter((row) => descendants.has(String(row.organizationId)));
|
|
317
|
+
}
|
|
318
|
+
if (resource === 'menus' || resource === 'organizations')
|
|
319
|
+
return buildTree(
|
|
320
|
+
hierarchy(result).map((row) => ({
|
|
321
|
+
...row,
|
|
322
|
+
...(resource === 'organizations'
|
|
323
|
+
? { userCount: read('users').filter((user) => user.organizationId === row.id).length }
|
|
324
|
+
: {}),
|
|
325
|
+
})),
|
|
326
|
+
);
|
|
327
|
+
if (resource.startsWith('dictionaries/'))
|
|
328
|
+
return result.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0));
|
|
329
|
+
const page = Number(query.page ?? 1);
|
|
330
|
+
const size = Number(query.pageSize ?? 10);
|
|
331
|
+
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(size) || size < 1 || size > 1000)
|
|
332
|
+
throw new Error('分页参数无效');
|
|
333
|
+
return {
|
|
334
|
+
items: result
|
|
335
|
+
.slice((page - 1) * size, page * size)
|
|
336
|
+
.map((row) => (resource === 'users' ? cleanUser(row) : row)),
|
|
337
|
+
total: result.length,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (method === 'DELETE') {
|
|
341
|
+
remove(resource, id ?? '');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const record = validate(resource, body, id);
|
|
345
|
+
return saveRows(resource, record, id);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SchemaTableColumn } from '@lumal/core/components';
|
|
3
|
+
import type { Menu } from './types';
|
|
4
|
+
import { LumalPage, LumalSchemaTable } from '@lumal/core/components';
|
|
5
|
+
import { LumalIcon } from '@lumal/icons-vue';
|
|
6
|
+
import { ElAlert, ElButton, ElInput, ElMessage } from 'element-plus';
|
|
7
|
+
import { computed, shallowRef } from 'vue';
|
|
8
|
+
import { application, permissionStore, service } from '../../../app/services/session';
|
|
9
|
+
import { useAsyncData } from '../../../shared/composables/use-async-data';
|
|
10
|
+
import { useEntityEditor } from '../../../shared/composables/use-entity-editor';
|
|
11
|
+
import { flattenTree } from '../../../shared/tree';
|
|
12
|
+
import { createMenuService, menuInput, parseMenu } from './api';
|
|
13
|
+
import MenuEditorDialog from './components/MenuEditorDialog.vue';
|
|
14
|
+
|
|
15
|
+
/***********************菜单树与独立刷新*********************/
|
|
16
|
+
const api = createMenuService(service);
|
|
17
|
+
const state = useAsyncData(api.list, [] as Menu[]);
|
|
18
|
+
const keyword = shallowRef('');
|
|
19
|
+
const refreshing = shallowRef(false);
|
|
20
|
+
const refreshError = shallowRef('');
|
|
21
|
+
const can = (action: string) => permissionStore.hasPermission(`system:menu:${action}`);
|
|
22
|
+
const records = computed(() => flattenTree(state.data.value));
|
|
23
|
+
function filterTree(items: Menu[]): Menu[] {
|
|
24
|
+
return items.flatMap((item) => {
|
|
25
|
+
const children = filterTree(item.children ?? []);
|
|
26
|
+
return !keyword.value || item.title.includes(keyword.value) || children.length ? [{ ...item, children }] : [];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const displayed = computed(() => filterTree(state.data.value));
|
|
30
|
+
const columns: SchemaTableColumn<Menu>[] = [
|
|
31
|
+
{ field: 'title', label: '标题', minWidth: 210 },
|
|
32
|
+
{
|
|
33
|
+
field: 'type',
|
|
34
|
+
label: '类型',
|
|
35
|
+
width: 100,
|
|
36
|
+
formatter: (value) =>
|
|
37
|
+
({ directory: '目录', menu: '菜单', button: '按钮', external: '外链', embedded: '内嵌' })[String(value)] ?? value,
|
|
38
|
+
},
|
|
39
|
+
{ field: 'path', label: '路径', minWidth: 170 },
|
|
40
|
+
{ field: 'component', label: '组件', minWidth: 170 },
|
|
41
|
+
{ field: 'order', label: '排序', width: 80 },
|
|
42
|
+
{ field: 'hidden', label: '显示', width: 80, formatter: (value) => (value ? '隐藏' : '显示') },
|
|
43
|
+
];
|
|
44
|
+
const editor = useEntityEditor<Menu, ReturnType<typeof menuInput>>({
|
|
45
|
+
create: api.create,
|
|
46
|
+
update: api.update,
|
|
47
|
+
remove: api.remove,
|
|
48
|
+
toInput: menuInput,
|
|
49
|
+
reload: state.load,
|
|
50
|
+
});
|
|
51
|
+
async function refreshMenus(): Promise<void> {
|
|
52
|
+
refreshing.value = true;
|
|
53
|
+
refreshError.value = '';
|
|
54
|
+
try {
|
|
55
|
+
await application.refreshAccess();
|
|
56
|
+
ElMessage.success('导航与权限已重新加载');
|
|
57
|
+
} catch (reason) {
|
|
58
|
+
refreshError.value = reason instanceof Error ? reason.message : '导航刷新失败';
|
|
59
|
+
} finally {
|
|
60
|
+
refreshing.value = false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function create(parent?: Menu): void {
|
|
64
|
+
editor.openCreate({
|
|
65
|
+
parentId: parent?.id ?? '',
|
|
66
|
+
type: parent?.type === 'menu' ? 'button' : 'menu',
|
|
67
|
+
hidden: false,
|
|
68
|
+
order: 0,
|
|
69
|
+
permissions: [],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<LumalPage class="lumal-admin-page" title="菜单管理" no-padding>
|
|
76
|
+
<ElAlert
|
|
77
|
+
v-if="editor.error.value || refreshError"
|
|
78
|
+
:title="editor.error.value || refreshError"
|
|
79
|
+
type="error"
|
|
80
|
+
:closable="false"
|
|
81
|
+
/>
|
|
82
|
+
<LumalSchemaTable
|
|
83
|
+
:rows="displayed"
|
|
84
|
+
:columns="columns"
|
|
85
|
+
:loading="state.loading.value"
|
|
86
|
+
:error="state.error.value"
|
|
87
|
+
default-expand-all
|
|
88
|
+
:action-width="240"
|
|
89
|
+
@retry="state.reload"
|
|
90
|
+
>
|
|
91
|
+
<template #toolbar>
|
|
92
|
+
<ElInput
|
|
93
|
+
v-model="keyword"
|
|
94
|
+
placeholder="搜索菜单标题"
|
|
95
|
+
aria-label="搜索菜单"
|
|
96
|
+
clearable
|
|
97
|
+
style="max-width: 260px"
|
|
98
|
+
/>
|
|
99
|
+
<ElButton v-if="can('create')" type="primary" @click="create()"> 新增节点 </ElButton>
|
|
100
|
+
</template>
|
|
101
|
+
<template #toolbar-tools>
|
|
102
|
+
<ElButton :loading="refreshing" @click="refreshMenus"> 刷新导航 </ElButton>
|
|
103
|
+
<ElButton title="重新加载" aria-label="重新加载" @click="state.reload">
|
|
104
|
+
<LumalIcon name="lumal:refresh" />
|
|
105
|
+
</ElButton>
|
|
106
|
+
</template>
|
|
107
|
+
<template #actions="{ row }">
|
|
108
|
+
<ElButton v-if="can('create') && ['directory', 'menu'].includes(row.type)" link @click="create(parseMenu(row))">
|
|
109
|
+
新增子节点
|
|
110
|
+
</ElButton>
|
|
111
|
+
<ElButton v-if="can('update')" link @click="editor.openEdit(parseMenu(row))"> 编辑 </ElButton>
|
|
112
|
+
<ElButton
|
|
113
|
+
v-if="can('delete')"
|
|
114
|
+
link
|
|
115
|
+
type="danger"
|
|
116
|
+
:disabled="editor.saving.value"
|
|
117
|
+
@click="editor.remove(parseMenu(row))"
|
|
118
|
+
>
|
|
119
|
+
删除
|
|
120
|
+
</ElButton>
|
|
121
|
+
</template>
|
|
122
|
+
</LumalSchemaTable>
|
|
123
|
+
<MenuEditorDialog
|
|
124
|
+
v-model:visible="editor.visible.value"
|
|
125
|
+
v-model:model="editor.model.value"
|
|
126
|
+
:records="records"
|
|
127
|
+
:editing-id="editor.editing.value?.id"
|
|
128
|
+
:saving="editor.saving.value"
|
|
129
|
+
:error="editor.error.value"
|
|
130
|
+
@submit="editor.submit"
|
|
131
|
+
/>
|
|
132
|
+
</LumalPage>
|
|
133
|
+
</template>
|