@cosider.construction/eapp-maker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -0
- package/src/index.js +741 -0
- package/src/stubs/components/AppConfirm.vue +124 -0
- package/src/stubs/components/AppMenuH.vue +226 -0
- package/src/stubs/components/AppMenuV.vue +146 -0
- package/src/stubs/components/AppModal.vue +59 -0
- package/src/stubs/components/AppToast.vue +69 -0
- package/src/stubs/layouts/DefaultLayout.vue +29 -0
- package/src/stubs/layouts/ModuleLayout.vue +38 -0
- package/src/stubs/loaders/loader.ipc.js +94 -0
- package/src/stubs/loaders/loader.menu.js +123 -0
- package/src/stubs/loaders/loader.routes.js +137 -0
- package/src/stubs/views/AppHome.vue +85 -0
- package/src/stubs/views/ModuleHome.vue +77 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { inject } from 'vue';
|
|
3
|
+
import AppMenuH from '@/components/AppMenuH.vue';
|
|
4
|
+
import AppMenuV from '@/components/AppMenuV.vue';
|
|
5
|
+
import moduleMenu from '@/menus/MODULE_NAME.menu.json';
|
|
6
|
+
|
|
7
|
+
// Views inside this layout can inject 'hMenuConfig' to add a sub hMenu
|
|
8
|
+
// Layout owns the main hMenu and vMenu from the module's menu.json
|
|
9
|
+
const subHMenu = inject('hMenuConfig', null);
|
|
10
|
+
|
|
11
|
+
const hMenuConfig = moduleMenu.hMenu || { style: 'classic', items: [] };
|
|
12
|
+
const vMenuConfig = moduleMenu.vMenu || { items: [] };
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<div class="module-layout">
|
|
17
|
+
<!-- Module hMenu -->
|
|
18
|
+
<AppMenuH :config="hMenuConfig" />
|
|
19
|
+
|
|
20
|
+
<!-- Optional sub hMenu injected by the view -->
|
|
21
|
+
<AppMenuH v-if="subHMenu" :config="subHMenu" sub />
|
|
22
|
+
|
|
23
|
+
<div class="module-layout__body">
|
|
24
|
+
<!-- Module vMenu -->
|
|
25
|
+
<AppMenuV :config="vMenuConfig" />
|
|
26
|
+
|
|
27
|
+
<main class="module-layout__main">
|
|
28
|
+
<slot />
|
|
29
|
+
</main>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<style scoped>
|
|
35
|
+
.module-layout { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
|
36
|
+
.module-layout__body { display: flex; flex: 1; overflow: hidden; }
|
|
37
|
+
.module-layout__main { flex: 1; overflow: auto; padding: 1.5rem; background: #1e1e2e; }
|
|
38
|
+
</style>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ipcMain } from 'electron';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { checkPermission } from '../engine/auth.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const BACKEND = path.resolve(__dirname, '..');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* loader.ipc.js
|
|
12
|
+
* Scans all ipc.json files under shared/ and modules/,
|
|
13
|
+
* registers each declared method as an Electron IPC handler.
|
|
14
|
+
*
|
|
15
|
+
* Channel pattern: <route>:<method>
|
|
16
|
+
* e.g. modules.RH.EMPLOYEE:getAll
|
|
17
|
+
*/
|
|
18
|
+
export async function loadIpc() {
|
|
19
|
+
const dirs = [
|
|
20
|
+
path.join(BACKEND, 'shared'),
|
|
21
|
+
path.join(BACKEND, 'modules'),
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
for (const dir of dirs) {
|
|
25
|
+
if (!fs.existsSync(dir)) continue;
|
|
26
|
+
for (const ipcPath of walkIpc(dir)) {
|
|
27
|
+
await registerIpc(ipcPath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function walkIpc(root, out = []) {
|
|
33
|
+
for (const e of fs.readdirSync(root, { withFileTypes: true })) {
|
|
34
|
+
const full = path.join(root, e.name);
|
|
35
|
+
if (e.isDirectory()) walkIpc(full, out);
|
|
36
|
+
else if (e.name === 'ipc.json') out.push(full);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function registerIpc(ipcJsonPath) {
|
|
42
|
+
let config;
|
|
43
|
+
try {
|
|
44
|
+
config = JSON.parse(fs.readFileSync(ipcJsonPath, 'utf8'));
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.warn('[loader.ipc] Failed to parse:', ipcJsonPath);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { route, methods = {} } = config;
|
|
51
|
+
const entityDir = path.dirname(ipcJsonPath);
|
|
52
|
+
|
|
53
|
+
let ctrl;
|
|
54
|
+
try {
|
|
55
|
+
ctrl = await import(path.join(entityDir, 'controller.js'));
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.warn('[loader.ipc] No controller at:', entityDir);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [methodName, def] of Object.entries(methods)) {
|
|
62
|
+
const handler = typeof def === 'string' ? def : def.handler;
|
|
63
|
+
const permission = typeof def === 'object' ? def.permission : null;
|
|
64
|
+
const channel = `${route}:${methodName}`;
|
|
65
|
+
|
|
66
|
+
if (typeof ctrl[handler] !== 'function') {
|
|
67
|
+
console.warn(`[loader.ipc] "${handler}" not found in ${entityDir}`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
ipcMain.handle(channel, async (_, payload) => {
|
|
72
|
+
// Permission check
|
|
73
|
+
if (permission) {
|
|
74
|
+
const userId = payload?.__userId;
|
|
75
|
+
const ok = await checkPermission(userId, permission);
|
|
76
|
+
if (!ok) return { error: 'FORBIDDEN', message: `Missing permission: ${permission}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const { id, data } = payload || {};
|
|
81
|
+
if (methodName === 'getById') return await ctrl[handler](id);
|
|
82
|
+
if (methodName === 'update') return await ctrl[handler](id, data);
|
|
83
|
+
if (methodName === 'delete') return await ctrl[handler](id);
|
|
84
|
+
if (methodName === 'add') return await ctrl[handler](data);
|
|
85
|
+
return await ctrl[handler](payload);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(`[loader.ipc] Error in ${channel}:`, err.message);
|
|
88
|
+
return { error: 'INTERNAL_ERROR', message: err.message };
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
console.log('[loader.ipc] registered:', channel);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loader.menu.js
|
|
3
|
+
*
|
|
4
|
+
* Scans all *.menu.json files in src/frontend/menus/.
|
|
5
|
+
* Builds two structures:
|
|
6
|
+
* - vMenuTree : full sidebar navigation (merged from all modules)
|
|
7
|
+
* - hMenuMap : keyed by route path, returns hMenu config for that route
|
|
8
|
+
*
|
|
9
|
+
* Layout usage:
|
|
10
|
+
* import { useMenuLoader } from '@/loaders/loader.menu.js'
|
|
11
|
+
* const { vMenuTree, hMenuFor } = useMenuLoader()
|
|
12
|
+
* const subHMenu = computed(() => hMenuFor(route.path))
|
|
13
|
+
*
|
|
14
|
+
* Menu file types:
|
|
15
|
+
* main.menu.json → app-level
|
|
16
|
+
* rh.menu.json → module-level hMenu + vMenu
|
|
17
|
+
* rh.employee.menu.json → view-level sub hMenu (ribbon or classic)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { computed } from 'vue';
|
|
21
|
+
import { useRoute } from 'vue-router';
|
|
22
|
+
import { useAuthStore } from '@/stores/auth.js';
|
|
23
|
+
|
|
24
|
+
// Vite glob — eager load all menu files
|
|
25
|
+
const allMenuFiles = import.meta.glob('@/menus/*.menu.json', { eager: true });
|
|
26
|
+
|
|
27
|
+
function getMenu(file) {
|
|
28
|
+
return file?.default ?? file ?? {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Build structures ─────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
let _vMenuTree = null;
|
|
34
|
+
let _hMenuMap = null;
|
|
35
|
+
|
|
36
|
+
function build(auth) {
|
|
37
|
+
const vMenuTree = [];
|
|
38
|
+
const hMenuMap = {};
|
|
39
|
+
|
|
40
|
+
for (const [filePath, raw] of Object.entries(allMenuFiles)) {
|
|
41
|
+
const menu = getMenu(raw);
|
|
42
|
+
const fileName = filePath.split('/').pop().replace('.menu.json', '');
|
|
43
|
+
const parts = fileName.split('.');
|
|
44
|
+
|
|
45
|
+
if (parts.length === 1 || (parts.length === 2 && parts[0] !== 'main')) {
|
|
46
|
+
// Module-level or main: contribute to vMenuTree
|
|
47
|
+
if (menu.vMenu?.items) {
|
|
48
|
+
const filtered = filterItems(menu.vMenu.items, auth);
|
|
49
|
+
if (filtered.length) vMenuTree.push(...filtered);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Module hMenu keyed by module root route
|
|
53
|
+
if (menu.hMenu && menu.module) {
|
|
54
|
+
hMenuMap[`/${menu.module}`] = menu.hMenu;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (parts.length >= 2) {
|
|
59
|
+
// View-level: rh.employee → keyed by /rh/employee
|
|
60
|
+
const route = '/' + parts.join('/');
|
|
61
|
+
if (menu.hMenu) {
|
|
62
|
+
hMenuMap[route] = menu.hMenu;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { vMenuTree, hMenuMap };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Filter by permission ─────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function filterItems(items, auth) {
|
|
73
|
+
return items
|
|
74
|
+
.filter(item => !item.permission || auth.hasPermission(item.permission))
|
|
75
|
+
.map(item => ({
|
|
76
|
+
...item,
|
|
77
|
+
children: item.children ? filterItems(item.children, auth) : undefined,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Composable ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export function useMenuLoader() {
|
|
84
|
+
const auth = useAuthStore();
|
|
85
|
+
const route = useRoute();
|
|
86
|
+
|
|
87
|
+
const { vMenuTree, hMenuMap } = build(auth);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Returns the hMenu config for a given route path.
|
|
91
|
+
* Checks exact match first, then parent path.
|
|
92
|
+
*/
|
|
93
|
+
function hMenuFor(routePath) {
|
|
94
|
+
if (hMenuMap[routePath]) return hMenuMap[routePath];
|
|
95
|
+
// Try parent path (e.g. /rh/employee/1 → /rh/employee → /rh)
|
|
96
|
+
const parts = routePath.split('/').filter(Boolean);
|
|
97
|
+
while (parts.length > 0) {
|
|
98
|
+
parts.pop();
|
|
99
|
+
const parent = '/' + parts.join('/');
|
|
100
|
+
if (hMenuMap[parent]) return hMenuMap[parent];
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const currentHMenu = computed(() => hMenuFor(route.path));
|
|
106
|
+
const currentSubHMenu = computed(() => {
|
|
107
|
+
// Sub hMenu = view-level menu (2-part key like rh.employee)
|
|
108
|
+
const parts = route.path.split('/').filter(Boolean);
|
|
109
|
+
if (parts.length >= 2) {
|
|
110
|
+
const key = '/' + parts.slice(0, 2).join('/');
|
|
111
|
+
return hMenuMap[key] || null;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
vMenuTree,
|
|
118
|
+
hMenuMap,
|
|
119
|
+
hMenuFor,
|
|
120
|
+
currentHMenu,
|
|
121
|
+
currentSubHMenu,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loader.routes.js
|
|
3
|
+
*
|
|
4
|
+
* Discovers all view components under src/frontend/views/
|
|
5
|
+
* and builds Vue Router route entries automatically.
|
|
6
|
+
*
|
|
7
|
+
* Convention:
|
|
8
|
+
* views/AppHome.vue → /
|
|
9
|
+
* views/modules/rh/RhHome.vue → /rh
|
|
10
|
+
* views/modules/rh/employee/EmployeeIndex.vue → /rh/employee
|
|
11
|
+
* views/shared/client/ClientIndex.vue → /shared/client
|
|
12
|
+
*
|
|
13
|
+
* Layout mapping:
|
|
14
|
+
* views/modules/<mod>/... → tries to import <Mod>Layout, falls back to DefaultLayout
|
|
15
|
+
* views/shared/... → DefaultLayout
|
|
16
|
+
*
|
|
17
|
+
* Meta:
|
|
18
|
+
* Each route gets meta.permission from the module/entity name
|
|
19
|
+
* e.g. /rh/employee → 'rh.employee.view'
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createRouter, createWebHashHistory } from 'vue-router';
|
|
23
|
+
import { useAuthStore } from '@/stores/auth.js';
|
|
24
|
+
|
|
25
|
+
// Vite globs — discover all Index and Home views
|
|
26
|
+
const indexViews = import.meta.glob('@/views/modules/*/*/Index.vue'); // not matching — see below
|
|
27
|
+
const moduleViews = import.meta.glob([
|
|
28
|
+
'@/views/modules/**/*Index.vue',
|
|
29
|
+
'@/views/modules/**/*Home.vue',
|
|
30
|
+
'@/views/shared/**/*Index.vue',
|
|
31
|
+
'@/views/AppHome.vue',
|
|
32
|
+
'@/views/LoginView.vue',
|
|
33
|
+
'@/views/AccessDeniedView.vue',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// Layout map — tries PascalCase module layout, falls back to Default
|
|
37
|
+
const layouts = import.meta.glob('@/layouts/*Layout.vue');
|
|
38
|
+
|
|
39
|
+
async function resolveLayout(moduleName) {
|
|
40
|
+
if (!moduleName) return () => import('@/layouts/DefaultLayout.vue');
|
|
41
|
+
const key = `/src/frontend/layouts/${moduleName.charAt(0).toUpperCase() + moduleName.slice(1)}Layout.vue`;
|
|
42
|
+
return layouts[key] || (() => import('@/layouts/DefaultLayout.vue'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Build routes ─────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function pathFromFile(filePath) {
|
|
48
|
+
// e.g. /src/frontend/views/modules/rh/employee/EmployeeIndex.vue → /rh/employee
|
|
49
|
+
const match = filePath.match(/views\/modules\/([^/]+)\/([^/]+)\/\w+Index\.vue$/);
|
|
50
|
+
if (match) return `/${match[1]}/${match[2]}`;
|
|
51
|
+
|
|
52
|
+
const homeMatch = filePath.match(/views\/modules\/([^/]+)\/\w+Home\.vue$/);
|
|
53
|
+
if (homeMatch) return `/${homeMatch[1]}`;
|
|
54
|
+
|
|
55
|
+
const sharedMatch = filePath.match(/views\/shared\/([^/]+)\/\w+Index\.vue$/);
|
|
56
|
+
if (sharedMatch) return `/shared/${sharedMatch[1]}`;
|
|
57
|
+
|
|
58
|
+
if (filePath.includes('AppHome.vue')) return '/';
|
|
59
|
+
if (filePath.includes('LoginView.vue')) return '/login';
|
|
60
|
+
if (filePath.includes('AccessDeniedView.vue')) return '/access-denied';
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function moduleFromPath(routePath) {
|
|
66
|
+
const parts = routePath.split('/').filter(Boolean);
|
|
67
|
+
return parts[0] || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function permissionFromPath(routePath) {
|
|
71
|
+
const parts = routePath.split('/').filter(Boolean);
|
|
72
|
+
if (parts.length >= 2) return `${parts[0]}.${parts[1]}.view`;
|
|
73
|
+
if (parts.length === 1) return `${parts[0]}.view`;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const routes = [
|
|
78
|
+
// Static auth routes always present
|
|
79
|
+
{
|
|
80
|
+
path: '/login',
|
|
81
|
+
component: () => import('@/views/LoginView.vue'),
|
|
82
|
+
meta: { public: true },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
path: '/access-denied',
|
|
86
|
+
component: () => import('@/views/AccessDeniedView.vue'),
|
|
87
|
+
meta: { public: true },
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// Dynamically discovered routes
|
|
92
|
+
for (const [filePath, component] of Object.entries(moduleViews)) {
|
|
93
|
+
if (filePath.includes('LoginView') || filePath.includes('AccessDeniedView')) continue;
|
|
94
|
+
|
|
95
|
+
const routePath = pathFromFile(filePath);
|
|
96
|
+
if (!routePath) continue;
|
|
97
|
+
|
|
98
|
+
const mod = moduleFromPath(routePath);
|
|
99
|
+
const permission = permissionFromPath(routePath);
|
|
100
|
+
|
|
101
|
+
routes.push({
|
|
102
|
+
path: routePath,
|
|
103
|
+
component,
|
|
104
|
+
meta: {
|
|
105
|
+
permission,
|
|
106
|
+
module: mod,
|
|
107
|
+
layout: mod,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Create router ────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const router = createRouter({
|
|
115
|
+
history: createWebHashHistory(),
|
|
116
|
+
routes,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ─── Navigation guard ─────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
router.beforeEach((to, _, next) => {
|
|
122
|
+
if (to.meta?.public) return next();
|
|
123
|
+
|
|
124
|
+
const auth = useAuthStore();
|
|
125
|
+
|
|
126
|
+
if (!auth.isAuthenticated) {
|
|
127
|
+
return next({ path: '/login', query: { redirect: to.fullPath } });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (to.meta?.permission && !auth.canAccess(to)) {
|
|
131
|
+
return next('/access-denied');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
next();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export default router;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
// Widget grid config — edit this to rearrange widgets
|
|
5
|
+
// Each widget: { id, col, row, colSpan, rowSpan, type, title, config }
|
|
6
|
+
const gridConfig = ref([
|
|
7
|
+
{ id: 'w1', col: 1, row: 1, colSpan: 2, rowSpan: 1, type: 'placeholder', title: 'Widget 1' },
|
|
8
|
+
{ id: 'w2', col: 3, row: 1, colSpan: 1, rowSpan: 1, type: 'placeholder', title: 'Widget 2' },
|
|
9
|
+
{ id: 'w3', col: 1, row: 2, colSpan: 1, rowSpan: 1, type: 'placeholder', title: 'Widget 3' },
|
|
10
|
+
{ id: 'w4', col: 2, row: 2, colSpan: 2, rowSpan: 1, type: 'placeholder', title: 'Widget 4' },
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const COLS = 3; // grid columns
|
|
14
|
+
|
|
15
|
+
function gridStyle(widget) {
|
|
16
|
+
return {
|
|
17
|
+
gridColumn: `${widget.col} / span ${widget.colSpan}`,
|
|
18
|
+
gridRow: `${widget.row} / span ${widget.rowSpan}`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="app-home">
|
|
25
|
+
<div class="app-home__header">
|
|
26
|
+
<h1 class="app-home__title">Dashboard</h1>
|
|
27
|
+
<p class="app-home__sub">Welcome back</p>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="widget-grid" :style="{ '--grid-cols': COLS }">
|
|
31
|
+
<div
|
|
32
|
+
v-for="widget in gridConfig"
|
|
33
|
+
:key="widget.id"
|
|
34
|
+
class="widget"
|
|
35
|
+
:style="gridStyle(widget)"
|
|
36
|
+
>
|
|
37
|
+
<div class="widget__header">
|
|
38
|
+
<span class="widget__title">{{ widget.title }}</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="widget__body">
|
|
41
|
+
<!-- placeholder — replace with real widget components -->
|
|
42
|
+
<div class="widget__placeholder">
|
|
43
|
+
<span>{{ widget.type }}</span>
|
|
44
|
+
<small>Configure in gridConfig</small>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<style scoped>
|
|
53
|
+
.app-home { padding: 1.5rem; color: #cdd6f4; }
|
|
54
|
+
.app-home__header { margin-bottom: 1.5rem; }
|
|
55
|
+
.app-home__title { font-size: 1.5rem; font-weight: 700; margin: 0; }
|
|
56
|
+
.app-home__sub { color: #6c7086; margin: .25rem 0 0; font-size: .875rem; }
|
|
57
|
+
|
|
58
|
+
.widget-grid {
|
|
59
|
+
display: grid;
|
|
60
|
+
grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
|
|
61
|
+
gap: 1rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.widget {
|
|
65
|
+
background: #313244;
|
|
66
|
+
border: 1px solid #45475a;
|
|
67
|
+
border-radius: 10px;
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
min-height: 180px;
|
|
72
|
+
}
|
|
73
|
+
.widget__header {
|
|
74
|
+
padding: .75rem 1rem;
|
|
75
|
+
border-bottom: 1px solid #45475a;
|
|
76
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
77
|
+
}
|
|
78
|
+
.widget__title { font-weight: 600; font-size: .875rem; color: #cdd6f4; }
|
|
79
|
+
.widget__body { flex: 1; padding: 1rem; }
|
|
80
|
+
.widget__placeholder {
|
|
81
|
+
height: 100%; display: flex; flex-direction: column;
|
|
82
|
+
align-items: center; justify-content: center; gap: .35rem;
|
|
83
|
+
color: #585b70; font-size: .8rem;
|
|
84
|
+
}
|
|
85
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
// MODULE_NAME home page
|
|
5
|
+
// Customise gridConfig to add widgets for this module
|
|
6
|
+
|
|
7
|
+
const gridConfig = ref([
|
|
8
|
+
{ id: 'w1', col: 1, row: 1, colSpan: 2, rowSpan: 1, type: 'placeholder', title: 'MODULE_NAME Overview' },
|
|
9
|
+
{ id: 'w2', col: 3, row: 1, colSpan: 1, rowSpan: 1, type: 'placeholder', title: 'Quick Stats' },
|
|
10
|
+
{ id: 'w3', col: 1, row: 2, colSpan: 3, rowSpan: 1, type: 'placeholder', title: 'Recent Activity' },
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const COLS = 3;
|
|
14
|
+
|
|
15
|
+
function gridStyle(w) {
|
|
16
|
+
return {
|
|
17
|
+
gridColumn: `${w.col} / span ${w.colSpan}`,
|
|
18
|
+
gridRow: `${w.row} / span ${w.rowSpan}`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="module-home">
|
|
25
|
+
<div class="module-home__header">
|
|
26
|
+
<h1 class="module-home__title">MODULE_LABEL</h1>
|
|
27
|
+
<p class="module-home__sub">Module overview</p>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="widget-grid" :style="{ '--grid-cols': COLS }">
|
|
31
|
+
<div
|
|
32
|
+
v-for="widget in gridConfig"
|
|
33
|
+
:key="widget.id"
|
|
34
|
+
class="widget"
|
|
35
|
+
:style="gridStyle(widget)"
|
|
36
|
+
>
|
|
37
|
+
<div class="widget__header">
|
|
38
|
+
<span class="widget__title">{{ widget.title }}</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="widget__body">
|
|
41
|
+
<div class="widget__placeholder">
|
|
42
|
+
<span>{{ widget.type }}</span>
|
|
43
|
+
<small>Configure in gridConfig</small>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<style scoped>
|
|
52
|
+
.module-home { padding: 1.5rem; color: #cdd6f4; }
|
|
53
|
+
.module-home__header { margin-bottom: 1.5rem; }
|
|
54
|
+
.module-home__title { font-size: 1.4rem; font-weight: 700; margin: 0; }
|
|
55
|
+
.module-home__sub { color: #6c7086; margin: .25rem 0 0; font-size: .875rem; }
|
|
56
|
+
.widget-grid {
|
|
57
|
+
display: grid;
|
|
58
|
+
grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
|
|
59
|
+
gap: 1rem;
|
|
60
|
+
}
|
|
61
|
+
.widget {
|
|
62
|
+
background: #313244; border: 1px solid #45475a;
|
|
63
|
+
border-radius: 10px; overflow: hidden;
|
|
64
|
+
display: flex; flex-direction: column; min-height: 160px;
|
|
65
|
+
}
|
|
66
|
+
.widget__header {
|
|
67
|
+
padding: .75rem 1rem; border-bottom: 1px solid #45475a;
|
|
68
|
+
display: flex; align-items: center;
|
|
69
|
+
}
|
|
70
|
+
.widget__title { font-weight: 600; font-size: .875rem; color: #cdd6f4; }
|
|
71
|
+
.widget__body { flex: 1; padding: 1rem; }
|
|
72
|
+
.widget__placeholder {
|
|
73
|
+
height: 100%; display: flex; flex-direction: column;
|
|
74
|
+
align-items: center; justify-content: center; gap: .35rem;
|
|
75
|
+
color: #585b70; font-size: .8rem;
|
|
76
|
+
}
|
|
77
|
+
</style>
|