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,109 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SchemaFormItem, SchemaFormModel } from '@lumal/core/components';
|
|
3
|
+
import { LumalPage, LumalSchemaForm } from '@lumal/core/components';
|
|
4
|
+
import { ElAlert, ElButton, ElMessage, ElTabPane, ElTabs } from 'element-plus';
|
|
5
|
+
import { shallowRef, watch } from 'vue';
|
|
6
|
+
import { application, service } from '../../app/services/session';
|
|
7
|
+
import { useAsyncData } from '../../shared/composables/use-async-data';
|
|
8
|
+
import { createProfileService, profileInput } from './api';
|
|
9
|
+
import ProfilePasswordForm from './components/ProfilePasswordForm.vue';
|
|
10
|
+
|
|
11
|
+
/***********************个人资料读取与保存*********************/
|
|
12
|
+
const api = createProfileService(service);
|
|
13
|
+
const state = useAsyncData(api.get, null);
|
|
14
|
+
const model = shallowRef<SchemaFormModel>({});
|
|
15
|
+
const saving = shallowRef(false);
|
|
16
|
+
const needsSessionRefresh = shallowRef(false);
|
|
17
|
+
const failure = shallowRef('');
|
|
18
|
+
const success = shallowRef('');
|
|
19
|
+
watch(state.data, (value) => {
|
|
20
|
+
if (value) model.value = { ...value };
|
|
21
|
+
});
|
|
22
|
+
const schemas: SchemaFormItem[] = [
|
|
23
|
+
{ field: 'username', label: '用户名', readonly: true },
|
|
24
|
+
{
|
|
25
|
+
field: 'roles',
|
|
26
|
+
label: '角色',
|
|
27
|
+
readonly: true,
|
|
28
|
+
formatter: (value) => (Array.isArray(value) ? value.join('、') : ''),
|
|
29
|
+
},
|
|
30
|
+
{ field: 'nickname', label: '昵称', required: true, rules: [{ max: 32, message: '昵称最多 32 个字符' }] },
|
|
31
|
+
{ field: 'phone', label: '手机号', rules: [{ pattern: /^1\d{10}$/, message: '手机号格式错误' }] },
|
|
32
|
+
];
|
|
33
|
+
async function save(values: SchemaFormModel): Promise<void> {
|
|
34
|
+
if (saving.value || needsSessionRefresh.value) return;
|
|
35
|
+
saving.value = true;
|
|
36
|
+
failure.value = '';
|
|
37
|
+
success.value = '';
|
|
38
|
+
let persisted = false;
|
|
39
|
+
try {
|
|
40
|
+
model.value = { ...(await api.save(profileInput(values))) };
|
|
41
|
+
persisted = true;
|
|
42
|
+
needsSessionRefresh.value = true;
|
|
43
|
+
await application.refreshAccess();
|
|
44
|
+
needsSessionRefresh.value = false;
|
|
45
|
+
success.value = '个人资料已更新';
|
|
46
|
+
ElMessage.success('个人资料已更新');
|
|
47
|
+
} catch (reason) {
|
|
48
|
+
failure.value = `${persisted ? '资料已保存,但会话刷新失败,请重新读取:' : ''}${reason instanceof Error ? reason.message : '保存失败'}`;
|
|
49
|
+
} finally {
|
|
50
|
+
saving.value = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/***********************部分成功后的只读重试*********************/
|
|
55
|
+
async function reload(): Promise<void> {
|
|
56
|
+
if (saving.value || state.loading.value) return;
|
|
57
|
+
saving.value = true;
|
|
58
|
+
failure.value = '';
|
|
59
|
+
try {
|
|
60
|
+
// 已保存的资料不重复提交;恢复会话授权后再读取服务端资料。
|
|
61
|
+
if (needsSessionRefresh.value) {
|
|
62
|
+
await application.refreshAccess();
|
|
63
|
+
needsSessionRefresh.value = false;
|
|
64
|
+
}
|
|
65
|
+
await state.load();
|
|
66
|
+
} catch (reason) {
|
|
67
|
+
failure.value = `${needsSessionRefresh.value ? '资料已保存,但会话刷新失败,请重试:' : ''}${reason instanceof Error ? reason.message : '读取失败'}`;
|
|
68
|
+
} finally {
|
|
69
|
+
saving.value = false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<LumalPage class="lumal-admin-page" title="个人中心">
|
|
76
|
+
<ElAlert v-if="state.error.value || failure" :title="state.error.value || failure" type="error" :closable="false">
|
|
77
|
+
<ElButton link :loading="saving" @click="reload">
|
|
78
|
+
{{ needsSessionRefresh ? '重试会话刷新' : '重新读取' }}
|
|
79
|
+
</ElButton>
|
|
80
|
+
</ElAlert>
|
|
81
|
+
<ElAlert v-if="success" :title="success" type="success" :closable="false" />
|
|
82
|
+
<ElTabs class="profile-tabs">
|
|
83
|
+
<ElTabPane label="个人资料">
|
|
84
|
+
<LumalSchemaForm
|
|
85
|
+
v-if="state.data.value"
|
|
86
|
+
v-model="model"
|
|
87
|
+
:schemas="schemas"
|
|
88
|
+
:columns="1"
|
|
89
|
+
mode="edit"
|
|
90
|
+
show-actions
|
|
91
|
+
:disabled="needsSessionRefresh"
|
|
92
|
+
:submit-disabled="needsSessionRefresh"
|
|
93
|
+
:submit-loading="saving"
|
|
94
|
+
submit-text="保存资料"
|
|
95
|
+
@submit="save"
|
|
96
|
+
/>
|
|
97
|
+
</ElTabPane>
|
|
98
|
+
<ElTabPane label="修改密码" lazy>
|
|
99
|
+
<ProfilePasswordForm :api="api" />
|
|
100
|
+
</ElTabPane>
|
|
101
|
+
</ElTabs>
|
|
102
|
+
</LumalPage>
|
|
103
|
+
</template>
|
|
104
|
+
|
|
105
|
+
<style scoped>
|
|
106
|
+
.profile-tabs {
|
|
107
|
+
max-width: 720px;
|
|
108
|
+
}
|
|
109
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { SchemaFormModel } from '@lumal/core/components';
|
|
2
|
+
import type { BusinessTransport } from '../../app/services/contracts';
|
|
3
|
+
import { object, stringArray, text } from '../../shared/api';
|
|
4
|
+
|
|
5
|
+
export interface Profile {
|
|
6
|
+
id: string;
|
|
7
|
+
username: string;
|
|
8
|
+
nickname: string;
|
|
9
|
+
phone: string;
|
|
10
|
+
roles: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface ProfileInput {
|
|
13
|
+
nickname: string;
|
|
14
|
+
phone: string;
|
|
15
|
+
}
|
|
16
|
+
export interface PasswordInput {
|
|
17
|
+
currentPassword: string;
|
|
18
|
+
newPassword: string;
|
|
19
|
+
}
|
|
20
|
+
export function profileInput(model: SchemaFormModel): ProfileInput {
|
|
21
|
+
const nickname = text(model.nickname, '昵称', true);
|
|
22
|
+
const phone = text(model.phone, '手机号');
|
|
23
|
+
if (nickname.length > 32 || (phone && !/^1\d{10}$/.test(phone)))
|
|
24
|
+
throw new Error('昵称最多 32 个字符,手机号为 11 位数字');
|
|
25
|
+
return { nickname, phone };
|
|
26
|
+
}
|
|
27
|
+
export function passwordInput(model: SchemaFormModel): PasswordInput {
|
|
28
|
+
const currentPassword = text(model.currentPassword, '当前密码', true);
|
|
29
|
+
const newPassword = text(model.newPassword, '新密码', true);
|
|
30
|
+
if (newPassword.length < 8 || newPassword.length > 32 || newPassword !== model.confirmPassword)
|
|
31
|
+
throw new Error('新密码为 8 到 32 位,且两次输入必须相同');
|
|
32
|
+
return { currentPassword, newPassword };
|
|
33
|
+
}
|
|
34
|
+
function parseProfile(value: unknown): Profile {
|
|
35
|
+
const raw = object(value);
|
|
36
|
+
return {
|
|
37
|
+
...profileInput(raw),
|
|
38
|
+
id: text(raw.id, '用户 ID', true),
|
|
39
|
+
username: text(raw.username, '用户名', true),
|
|
40
|
+
roles: stringArray(raw.roles),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function createProfileService(transport: BusinessTransport) {
|
|
44
|
+
return {
|
|
45
|
+
get: async () => parseProfile(await transport.request('GET', '/profile')),
|
|
46
|
+
save: async (input: ProfileInput) => parseProfile(await transport.request('PUT', '/profile', { body: input })),
|
|
47
|
+
changePassword: (input: PasswordInput) => transport.request<void>('PUT', '/profile/password', { body: input }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SchemaFormItem, SchemaFormModel } from '@lumal/core/components';
|
|
3
|
+
import type { createProfileService } from '../api';
|
|
4
|
+
import { LumalSchemaForm } from '@lumal/core/components';
|
|
5
|
+
import { ElAlert } from 'element-plus';
|
|
6
|
+
import { shallowRef } from 'vue';
|
|
7
|
+
import { passwordInput } from '../api';
|
|
8
|
+
|
|
9
|
+
/***********************真实密码更新接口*********************/
|
|
10
|
+
const props = defineProps<{ api: ReturnType<typeof createProfileService> }>();
|
|
11
|
+
const model = shallowRef<SchemaFormModel>({});
|
|
12
|
+
const saving = shallowRef(false);
|
|
13
|
+
const failure = shallowRef('');
|
|
14
|
+
const success = shallowRef('');
|
|
15
|
+
const schemas: SchemaFormItem[] = [
|
|
16
|
+
{
|
|
17
|
+
field: 'currentPassword',
|
|
18
|
+
label: '当前密码',
|
|
19
|
+
required: true,
|
|
20
|
+
component: 'input',
|
|
21
|
+
componentProps: { type: 'password', showPassword: true, autocomplete: 'current-password' },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
field: 'newPassword',
|
|
25
|
+
label: '新密码',
|
|
26
|
+
required: true,
|
|
27
|
+
component: 'input',
|
|
28
|
+
rules: [{ min: 8, max: 32, message: '密码长度为 8 到 32 位' }],
|
|
29
|
+
componentProps: { type: 'password', showPassword: true, autocomplete: 'new-password' },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
field: 'confirmPassword',
|
|
33
|
+
label: '确认新密码',
|
|
34
|
+
required: true,
|
|
35
|
+
component: 'input',
|
|
36
|
+
componentProps: { type: 'password', showPassword: true, autocomplete: 'new-password' },
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
async function submit(values: SchemaFormModel): Promise<void> {
|
|
40
|
+
if (saving.value) return;
|
|
41
|
+
saving.value = true;
|
|
42
|
+
failure.value = '';
|
|
43
|
+
success.value = '';
|
|
44
|
+
try {
|
|
45
|
+
await props.api.changePassword(passwordInput(values));
|
|
46
|
+
model.value = {};
|
|
47
|
+
success.value = '密码已通过接口更新';
|
|
48
|
+
} catch (reason) {
|
|
49
|
+
failure.value = reason instanceof Error ? reason.message : '密码更新失败';
|
|
50
|
+
} finally {
|
|
51
|
+
saving.value = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<ElAlert v-if="failure" :title="failure" type="error" role="alert" :closable="false" />
|
|
58
|
+
<ElAlert v-if="success" :title="success" type="success" :closable="false" />
|
|
59
|
+
<LumalSchemaForm
|
|
60
|
+
v-model="model"
|
|
61
|
+
:schemas="schemas"
|
|
62
|
+
:columns="1"
|
|
63
|
+
show-actions
|
|
64
|
+
submit-text="更新密码"
|
|
65
|
+
:submit-loading="saving"
|
|
66
|
+
@submit="submit"
|
|
67
|
+
/>
|
|
68
|
+
</template>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DataRow } from '../../app/services/contracts';
|
|
2
|
+
import type { LocalBusinessContext, LocalHandler } from '../../app/services/local-contracts';
|
|
3
|
+
import { object } from '../../app/menu-policy';
|
|
4
|
+
import { profileInput } from './api';
|
|
5
|
+
|
|
6
|
+
export function createLocalHandler({ db, user }: LocalBusinessContext): LocalHandler {
|
|
7
|
+
return {
|
|
8
|
+
handles: (path) => path === '/profile' || path === '/profile/password',
|
|
9
|
+
async request(method, path, input) {
|
|
10
|
+
const current = db.read('users').find((row) => row.id === user().id);
|
|
11
|
+
if (!current) throw new Error('当前用户不存在');
|
|
12
|
+
const clean = ({ password: _password, ...profile }: DataRow) => profile;
|
|
13
|
+
if (path === '/profile' && method === 'GET') return clean(current);
|
|
14
|
+
if (path === '/profile' && method === 'PUT') {
|
|
15
|
+
const next = { ...current, ...profileInput(object(input.body)) };
|
|
16
|
+
db.write(
|
|
17
|
+
'users',
|
|
18
|
+
db.read('users').map((row) => (row.id === current.id ? next : row)),
|
|
19
|
+
);
|
|
20
|
+
return clean(next);
|
|
21
|
+
}
|
|
22
|
+
if (path === '/profile/password' && method === 'PUT') {
|
|
23
|
+
const body = object(input.body);
|
|
24
|
+
if (
|
|
25
|
+
body.currentPassword !== current.password ||
|
|
26
|
+
typeof body.newPassword !== 'string' ||
|
|
27
|
+
body.newPassword.length < 8 ||
|
|
28
|
+
body.newPassword.length > 32
|
|
29
|
+
)
|
|
30
|
+
throw new Error('当前密码错误或新密码长度不符合要求');
|
|
31
|
+
db.write(
|
|
32
|
+
'users',
|
|
33
|
+
db.read('users').map((row) => (row.id === current.id ? { ...row, password: body.newPassword } : row)),
|
|
34
|
+
);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
throw new Error('不支持的个人资料操作');
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AppModule } from '../../app/module';
|
|
2
|
+
|
|
3
|
+
export const menus: AppModule['menus'] = [
|
|
4
|
+
{
|
|
5
|
+
path: '/profile',
|
|
6
|
+
name: 'Profile',
|
|
7
|
+
component: 'profile/index',
|
|
8
|
+
meta: { title: '个人中心', icon: 'lumal:user', hideInMenu: true, keepAlive: false },
|
|
9
|
+
},
|
|
10
|
+
];
|
|
11
|
+
export const views: AppModule['views'] = { 'profile/index': () => import('./ProfileView.vue') };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { BusinessTransport, DataRow } from '../src/app/services/contracts';
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { createProfileService, passwordInput, profileInput } from '../src/modules/profile/api';
|
|
4
|
+
import { createLocalHandler } from '../src/modules/profile/local';
|
|
5
|
+
|
|
6
|
+
let users: DataRow[];
|
|
7
|
+
let api: ReturnType<typeof createProfileService>;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
users = [
|
|
10
|
+
{
|
|
11
|
+
id: 'admin',
|
|
12
|
+
username: 'admin',
|
|
13
|
+
nickname: '管理员',
|
|
14
|
+
phone: '',
|
|
15
|
+
roles: ['admin'],
|
|
16
|
+
status: 'enabled',
|
|
17
|
+
password: 'lumal123',
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
const handler = createLocalHandler({
|
|
21
|
+
db: {
|
|
22
|
+
read: <T extends DataRow>() => structuredClone(users) as T[],
|
|
23
|
+
write: (_key, rows) => {
|
|
24
|
+
users = structuredClone(rows);
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
user: () => ({ id: 'admin', username: 'admin', name: '管理员' }),
|
|
28
|
+
assertPermission: () => {},
|
|
29
|
+
});
|
|
30
|
+
const transport: BusinessTransport = {
|
|
31
|
+
request: async <T>(method: Parameters<BusinessTransport['request']>[0], path: string, input = {}) =>
|
|
32
|
+
(await handler.request(method, path, input)) as T,
|
|
33
|
+
};
|
|
34
|
+
api = createProfileService(transport);
|
|
35
|
+
});
|
|
36
|
+
describe('个人资料与密码业务契约', () => {
|
|
37
|
+
it('读取与修改允许字段,不泄露密码或允许修改身份与角色', async () => {
|
|
38
|
+
expect(await api.get()).not.toHaveProperty('password');
|
|
39
|
+
const saved = await api.save(
|
|
40
|
+
profileInput({ nickname: '已修改', phone: '13800138000', username: 'injected', roles: [] }),
|
|
41
|
+
);
|
|
42
|
+
expect(saved.nickname).toBe('已修改');
|
|
43
|
+
expect(saved.username).toBe('admin');
|
|
44
|
+
expect(saved.roles).toEqual(['admin']);
|
|
45
|
+
expect(users[0]?.password).toBe('lumal123');
|
|
46
|
+
expect(() => profileInput({ nickname: '', phone: 'bad' })).toThrow();
|
|
47
|
+
});
|
|
48
|
+
it('错误密码不写入;真实保存后新密码才生效', async () => {
|
|
49
|
+
await expect(api.changePassword({ currentPassword: 'wrong', newPassword: 'new-password' })).rejects.toThrow(
|
|
50
|
+
'当前密码',
|
|
51
|
+
);
|
|
52
|
+
expect(users[0]?.password).toBe('lumal123');
|
|
53
|
+
expect(() =>
|
|
54
|
+
passwordInput({ currentPassword: 'lumal123', newPassword: 'new-password', confirmPassword: 'other' }),
|
|
55
|
+
).toThrow('两次');
|
|
56
|
+
await api.changePassword(
|
|
57
|
+
passwordInput({ currentPassword: 'lumal123', newPassword: 'new-password', confirmPassword: 'new-password' }),
|
|
58
|
+
);
|
|
59
|
+
expect(users[0]?.password).toBe('new-password');
|
|
60
|
+
});
|
|
61
|
+
it('hTTP 能力未接入时向页面传播失败,不伪造保存结果', async () => {
|
|
62
|
+
const http = createProfileService({
|
|
63
|
+
request: async () => {
|
|
64
|
+
throw new Error('HTTP 404:未配置个人资料接口');
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
await expect(http.get()).rejects.toThrow('HTTP 404');
|
|
68
|
+
await expect(http.changePassword({ currentPassword: 'old', newPassword: 'new-password' })).rejects.toThrow(
|
|
69
|
+
'HTTP 404',
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import ScreenView from './ScreenView.vue';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<div class="screen-entry">
|
|
7
|
+
<ScreenView />
|
|
8
|
+
</div>
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<style scoped>
|
|
12
|
+
.screen-entry {
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
height: 100dvh;
|
|
16
|
+
min-height: 0;
|
|
17
|
+
}
|
|
18
|
+
</style>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import ScreenMetrics from './components/ScreenMetrics.vue';
|
|
3
|
+
// SCREEN_VISUALIZATION_IMPORT
|
|
4
|
+
|
|
5
|
+
/***********************静态演示数据*********************/
|
|
6
|
+
const metrics = [
|
|
7
|
+
{ label: '任务总数', value: 128 },
|
|
8
|
+
{ label: '已完成', value: 96 },
|
|
9
|
+
{ label: '进行中', value: 32 },
|
|
10
|
+
];
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<main class="screen-view">
|
|
15
|
+
<header class="screen-view__header">
|
|
16
|
+
<h1>数据总览</h1>
|
|
17
|
+
<span>静态示例</span>
|
|
18
|
+
</header>
|
|
19
|
+
<ScreenMetrics :metrics="metrics" />
|
|
20
|
+
<!-- SCREEN_VISUALIZATION -->
|
|
21
|
+
</main>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<style scoped>
|
|
25
|
+
.screen-view {
|
|
26
|
+
height: 100%;
|
|
27
|
+
min-width: 0;
|
|
28
|
+
min-height: 0;
|
|
29
|
+
flex: 1;
|
|
30
|
+
overflow: auto;
|
|
31
|
+
padding: 32px;
|
|
32
|
+
font-size: max(14px, var(--lumal-viewport-min-font-size, 14px));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.screen-view__header {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-wrap: wrap;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: space-between;
|
|
40
|
+
gap: 16px;
|
|
41
|
+
margin-bottom: 32px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.screen-view__header h1 {
|
|
45
|
+
margin: 0;
|
|
46
|
+
font-size: max(28px, var(--lumal-viewport-min-font-size, 14px));
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/***********************指标输入*********************/
|
|
3
|
+
defineProps<{ metrics: { label: string; value: number }[] }>();
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<dl class="screen-metrics">
|
|
8
|
+
<div v-for="metric in metrics" :key="metric.label" class="screen-metrics__item">
|
|
9
|
+
<dt>{{ metric.label }}</dt>
|
|
10
|
+
<dd>{{ metric.value.toLocaleString() }}</dd>
|
|
11
|
+
</div>
|
|
12
|
+
</dl>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<style scoped>
|
|
16
|
+
.screen-metrics {
|
|
17
|
+
display: grid;
|
|
18
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
|
|
19
|
+
gap: 24px;
|
|
20
|
+
margin: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.screen-metrics__item {
|
|
24
|
+
padding: 24px;
|
|
25
|
+
border: 1px solid #d8e0e6;
|
|
26
|
+
border-top: 3px solid #118c74;
|
|
27
|
+
border-radius: 4px;
|
|
28
|
+
background: #fff;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.screen-metrics__item dt {
|
|
32
|
+
color: #53616f;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.screen-metrics__item dd {
|
|
36
|
+
margin: 12px 0 0;
|
|
37
|
+
font-size: max(36px, var(--lumal-viewport-min-font-size, 14px));
|
|
38
|
+
font-weight: 600;
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { shallowRef } from 'vue';
|
|
3
|
+
import { authDemo, authEnabled } from '../services/auth-adapter';
|
|
4
|
+
import { authenticated, signIn, signOut } from '../services/session';
|
|
5
|
+
import ScreenLoginForm from './components/ScreenLoginForm.vue';
|
|
6
|
+
import ScreenView from './ScreenView.vue';
|
|
7
|
+
|
|
8
|
+
/***********************认证门禁*********************/
|
|
9
|
+
const loading = shallowRef(false);
|
|
10
|
+
const error = shallowRef('');
|
|
11
|
+
async function login(username: string, password: string) {
|
|
12
|
+
loading.value = true;
|
|
13
|
+
error.value = '';
|
|
14
|
+
try {
|
|
15
|
+
await signIn(username, password);
|
|
16
|
+
} catch (cause) {
|
|
17
|
+
error.value = cause instanceof Error ? cause.message : '登录失败';
|
|
18
|
+
} finally {
|
|
19
|
+
loading.value = false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div v-if="authenticated" class="screen-entry">
|
|
26
|
+
<div class="screen-session">
|
|
27
|
+
<span v-if="authDemo">Local Demo</span>
|
|
28
|
+
<button type="button" @click="signOut">退出登录</button>
|
|
29
|
+
</div>
|
|
30
|
+
<ScreenView />
|
|
31
|
+
</div>
|
|
32
|
+
<main v-else>
|
|
33
|
+
<ScreenLoginForm :loading="loading" :error="error" :enabled="authEnabled" :demo="authDemo" @submit="login" />
|
|
34
|
+
</main>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<style scoped>
|
|
38
|
+
.screen-entry {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
height: 100dvh;
|
|
42
|
+
min-height: 0;
|
|
43
|
+
}
|
|
44
|
+
.screen-session {
|
|
45
|
+
flex: none;
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
justify-content: flex-end;
|
|
49
|
+
gap: 16px;
|
|
50
|
+
padding: 12px 32px;
|
|
51
|
+
}
|
|
52
|
+
</style>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { shallowRef } from 'vue';
|
|
3
|
+
|
|
4
|
+
/***********************登录输入*********************/
|
|
5
|
+
defineProps<{ loading: boolean; error: string; enabled: boolean; demo: boolean }>();
|
|
6
|
+
const emit = defineEmits<{ submit: [username: string, password: string] }>();
|
|
7
|
+
const username = shallowRef('');
|
|
8
|
+
const password = shallowRef('');
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<form class="screen-login" @submit.prevent="emit('submit', username, password)">
|
|
13
|
+
<h1>登录</h1>
|
|
14
|
+
<p v-if="demo" role="status">Local Demo 认证,仅限开发:demo / demo</p>
|
|
15
|
+
<p v-if="!enabled" role="alert">生产环境禁止 Local Demo 登录</p>
|
|
16
|
+
<p v-if="error" role="alert">{{ error }}</p>
|
|
17
|
+
<label>用户名<input v-model="username" name="username" autocomplete="username" required /></label>
|
|
18
|
+
<label
|
|
19
|
+
>密码<input v-model="password" name="password" type="password" autocomplete="current-password" required
|
|
20
|
+
/></label>
|
|
21
|
+
<button type="submit" :disabled="loading || !enabled">{{ loading ? '登录中' : '登录' }}</button>
|
|
22
|
+
</form>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<style scoped>
|
|
26
|
+
.screen-login {
|
|
27
|
+
display: grid;
|
|
28
|
+
gap: 20px;
|
|
29
|
+
width: min(360px, calc(100% - 40px));
|
|
30
|
+
margin: 12vh auto;
|
|
31
|
+
}
|
|
32
|
+
.screen-login label {
|
|
33
|
+
display: grid;
|
|
34
|
+
gap: 8px;
|
|
35
|
+
}
|
|
36
|
+
.screen-login input {
|
|
37
|
+
box-sizing: border-box;
|
|
38
|
+
width: 100%;
|
|
39
|
+
min-height: 40px;
|
|
40
|
+
}
|
|
41
|
+
.screen-login button {
|
|
42
|
+
min-height: 40px;
|
|
43
|
+
}
|
|
44
|
+
</style>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AuthSessionData } from '@lumal/core/auth';
|
|
2
|
+
import { createAuthSession } from '@lumal/core/auth';
|
|
3
|
+
import { shallowRef } from 'vue';
|
|
4
|
+
import { authEnabled, authenticate, refreshAuth } from './auth-adapter';
|
|
5
|
+
|
|
6
|
+
/***********************独立会话*********************/
|
|
7
|
+
const session = createAuthSession({
|
|
8
|
+
storage: typeof sessionStorage === 'undefined' ? undefined : sessionStorage,
|
|
9
|
+
tokenKey: 'screen:session',
|
|
10
|
+
});
|
|
11
|
+
if (!authEnabled) session.clearToken();
|
|
12
|
+
const saved = session.getSession();
|
|
13
|
+
if (saved?.expiresAt !== undefined && saved.expiresAt <= Date.now()) session.clearToken();
|
|
14
|
+
export const authenticated = shallowRef(session.isAuthenticated());
|
|
15
|
+
let controller = new AbortController();
|
|
16
|
+
let generation = 0;
|
|
17
|
+
let expiry: ReturnType<typeof setTimeout> | undefined;
|
|
18
|
+
export const getSessionToken = () => session.getToken() || undefined;
|
|
19
|
+
export const getSessionSignal = () => controller.signal;
|
|
20
|
+
export const getSessionScope = () => String(generation);
|
|
21
|
+
|
|
22
|
+
function scheduleExpiry() {
|
|
23
|
+
clearTimeout(expiry);
|
|
24
|
+
const expiresAt = session.getSession()?.expiresAt;
|
|
25
|
+
if (expiresAt === undefined) return;
|
|
26
|
+
const remaining = expiresAt - Date.now();
|
|
27
|
+
if (remaining <= 0) signOut();
|
|
28
|
+
else expiry = setTimeout(scheduleExpiry, Math.min(remaining, 2_147_483_647));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function signOut() {
|
|
32
|
+
generation += 1;
|
|
33
|
+
controller.abort();
|
|
34
|
+
controller = new AbortController();
|
|
35
|
+
clearTimeout(expiry);
|
|
36
|
+
session.clearToken();
|
|
37
|
+
authenticated.value = false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function applySession(data: AuthSessionData, expected: number) {
|
|
41
|
+
if (generation !== expected) throw new Error('会话已切换');
|
|
42
|
+
session.setSession(data);
|
|
43
|
+
authenticated.value = true;
|
|
44
|
+
scheduleExpiry();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function signIn(username: string, password: string) {
|
|
48
|
+
signOut();
|
|
49
|
+
const expected = generation;
|
|
50
|
+
const data = await authenticate(username, password, controller.signal);
|
|
51
|
+
applySession(data, expected);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function refreshSession() {
|
|
55
|
+
const expected = generation;
|
|
56
|
+
const data = await refreshAuth(session.getRefreshToken(), controller.signal);
|
|
57
|
+
applySession(data, expected);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
scheduleExpiry();
|
|
61
|
+
if (import.meta.hot) import.meta.hot.dispose(() => signOut());
|