create-yoya-ui 0.1.1 → 0.2.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/README.md +3 -0
- package/bin/create-yoya-ui.js +4 -2
- package/package.json +1 -1
- package/templates/admin/README.md +77 -0
- package/templates/admin/index.html +12 -0
- package/templates/admin/node_modules/.vite/deps/_metadata.json +8 -0
- package/templates/admin/node_modules/.vite/deps/package.json +3 -0
- package/templates/admin/package.json +19 -0
- package/templates/admin/src/api/domain.api.d.ts +10 -0
- package/templates/admin/src/api/domain.api.js +31 -0
- package/templates/admin/src/api/fetch.api.d.ts +4 -0
- package/templates/admin/src/api/fetch.api.js +20 -0
- package/templates/admin/src/app/admin-shell.js +51 -0
- package/templates/admin/src/app/modules.js +37 -0
- package/templates/admin/src/app/router.js +20 -0
- package/templates/admin/src/features/dashboard/components/dashboard-chart.js +205 -0
- package/templates/admin/src/features/dashboard/pages/dashboard-overview-page.js +197 -0
- package/templates/admin/src/features/dicts/api/dict.mgr.d.ts +79 -0
- package/templates/admin/src/features/dicts/api/dict.mgr.js +160 -0
- package/templates/admin/src/features/dicts/api/dict.mock.js +81 -0
- package/templates/admin/src/features/dicts/api/dict.state.d.ts +23 -0
- package/templates/admin/src/features/dicts/api/dict.state.js +103 -0
- package/templates/admin/src/features/dicts/api/dict.views.d.ts +41 -0
- package/templates/admin/src/features/dicts/api/dict.views.js +23 -0
- package/templates/admin/src/features/dicts/components/dict-editor-dialog.js +218 -0
- package/templates/admin/src/features/dicts/components/dict-item-form-dialog.js +79 -0
- package/templates/admin/src/features/dicts/pages/dict-list-page.js +123 -0
- package/templates/admin/src/features/dicts/utils/options.js +5 -0
- package/templates/admin/src/features/members/api/member.mgr.d.ts +47 -0
- package/templates/admin/src/features/members/api/member.mgr.js +95 -0
- package/templates/admin/src/features/members/api/member.mock.js +74 -0
- package/templates/admin/src/features/members/api/member.req.d.ts +16 -0
- package/templates/admin/src/features/members/api/member.req.js +15 -0
- package/templates/admin/src/features/members/api/member.state.d.ts +23 -0
- package/templates/admin/src/features/members/api/member.state.js +97 -0
- package/templates/admin/src/features/members/api/member.views.d.ts +33 -0
- package/templates/admin/src/features/members/api/member.views.js +23 -0
- package/templates/admin/src/features/members/components/member-form-dialog.js +83 -0
- package/templates/admin/src/features/members/components/member-table.js +58 -0
- package/templates/admin/src/features/members/components/member-toolbar.js +58 -0
- package/templates/admin/src/features/members/pages/member-list-page.js +85 -0
- package/templates/admin/src/features/members/utils/options.d.ts +4 -0
- package/templates/admin/src/features/members/utils/options.js +6 -0
- package/templates/admin/src/features/permissions/api/permission.mgr.d.ts +45 -0
- package/templates/admin/src/features/permissions/api/permission.mgr.js +79 -0
- package/templates/admin/src/features/permissions/api/permission.mock.js +68 -0
- package/templates/admin/src/features/permissions/api/permission.state.d.ts +14 -0
- package/templates/admin/src/features/permissions/api/permission.state.js +46 -0
- package/templates/admin/src/features/permissions/api/permission.views.d.ts +27 -0
- package/templates/admin/src/features/permissions/api/permission.views.js +14 -0
- package/templates/admin/src/features/permissions/components/permission-form-dialog.js +98 -0
- package/templates/admin/src/features/permissions/pages/permission-list-page.js +98 -0
- package/templates/admin/src/features/roles/api/role.mgr.d.ts +57 -0
- package/templates/admin/src/features/roles/api/role.mgr.js +101 -0
- package/templates/admin/src/features/roles/api/role.mock.js +68 -0
- package/templates/admin/src/features/roles/api/role.state.d.ts +23 -0
- package/templates/admin/src/features/roles/api/role.state.js +97 -0
- package/templates/admin/src/features/roles/api/role.views.d.ts +43 -0
- package/templates/admin/src/features/roles/api/role.views.js +24 -0
- package/templates/admin/src/features/roles/components/role-form-dialog.js +86 -0
- package/templates/admin/src/features/roles/components/role-table.js +57 -0
- package/templates/admin/src/features/roles/components/role-toolbar.js +58 -0
- package/templates/admin/src/features/roles/pages/role-list-page.js +100 -0
- package/templates/admin/src/features/roles/utils/options.js +5 -0
- package/templates/admin/src/main.js +18 -0
- package/templates/admin/src/ui/app-navbar.js +67 -0
- package/templates/admin/src/ui/app-sidebar.js +27 -0
- package/templates/admin/src/ui/placeholder-page.js +17 -0
- package/templates/admin/src/ui/row-action-button.js +12 -0
- package/templates/admin/src/ui/sidebar-menu.js +14 -0
- package/templates/admin/tsconfig.json +18 -0
- package/templates/admin/vite.config.js +7 -0
- package/templates/basic/package.json +1 -1
- package/templates/ssr/package.json +1 -1
- package/templates/ssr/src/server.mjs +0 -1
package/README.md
CHANGED
|
@@ -7,14 +7,17 @@
|
|
|
7
7
|
```bash
|
|
8
8
|
npx create-yoya-ui@latest my-app # basic 模板(SPA)
|
|
9
9
|
npx create-yoya-ui@latest my-app --template ssr # SSR 模板
|
|
10
|
+
npx create-yoya-ui@latest my-app --template admin # 后台管理模板
|
|
10
11
|
cd my-app
|
|
11
12
|
npm install
|
|
12
13
|
```
|
|
13
14
|
|
|
14
15
|
- **basic**:默认模板,`npm run dev` 启动 Vite 开发服务器
|
|
15
16
|
- **ssr**:服务端渲染模板,`npm run build && npm start`(renderPage 整页渲染 + hydrateOrMount 客户端接入)
|
|
17
|
+
- **admin**:标准后台管理模板(顶部导航 + 可折叠侧栏 + RouterViews 内容区)
|
|
16
18
|
|
|
17
19
|
## 模板内容
|
|
18
20
|
|
|
19
21
|
- `templates/basic`:页面壳、按钮事件、表单收集校验、主题切换
|
|
20
22
|
- `templates/ssr`:home-page.js(页面组件 + 词典)、server.mjs(node:http + renderPage)、client.js(hydrateOrMount)
|
|
23
|
+
- `templates/admin`:标准后台管理模板(logo/系统名、顶部导航、用户头像、可折叠菜单、带标题内容区),内置数据概览看板与图表,以及成员 / 角色 / 权限 / 字典管理示例
|
package/bin/create-yoya-ui.js
CHANGED
|
@@ -13,7 +13,7 @@ const templateName =
|
|
|
13
13
|
const templateRoot = resolve(fileURLToPath(new URL(`../templates/${templateName}`, import.meta.url)));
|
|
14
14
|
|
|
15
15
|
if (!existsSync(templateRoot)) {
|
|
16
|
-
console.error(`未知模板:${templateName}(可用:basic、ssr)`);
|
|
16
|
+
console.error(`未知模板:${templateName}(可用:basic、ssr、admin)`);
|
|
17
17
|
process.exit(1);
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -40,7 +40,9 @@ if (templateName === 'ssr') {
|
|
|
40
40
|
console.log('\nSSR 模板:renderPage 整页渲染 + hydrateOrMount 客户端接入。\n');
|
|
41
41
|
} else {
|
|
42
42
|
console.log(' npm run dev');
|
|
43
|
-
console.log(
|
|
43
|
+
console.log(
|
|
44
|
+
`\n${templateName === 'admin' ? '管理后台模板:顶部导航 + 可折叠侧栏 + RouterViews 内容区。' : ''}更多:npm run build 构建,npm run preview 预览产物。\n`
|
|
45
|
+
);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
function sanitizeName(name) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# yoya-ui 后台管理模板
|
|
2
|
+
|
|
3
|
+
标准管理后台布局:
|
|
4
|
+
|
|
5
|
+
- 左上角:logo + 系统名
|
|
6
|
+
- 中间:顶级导航
|
|
7
|
+
- 右上角:用户头像
|
|
8
|
+
- 下方:左侧可折叠菜单 + 右侧带标题的 RouterViews 内容区
|
|
9
|
+
- 点击顶级导航切换左侧菜单与内容路由
|
|
10
|
+
|
|
11
|
+
## 启动
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install
|
|
15
|
+
npm run dev
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## TypeScript
|
|
19
|
+
|
|
20
|
+
模板默认支持 TypeScript,非侵入接入:现有 `.js` 文件保持不动(`tsconfig.json` 已开启 `allowJs`),需要类型时直接新建或改写为 `.ts` 文件即可(Vite 原生支持)。运行 `npm run typecheck` 做类型检查。
|
|
21
|
+
|
|
22
|
+
## 结构
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
src/
|
|
26
|
+
main.js 入口:初始化主题、创建路由与外壳并挂载
|
|
27
|
+
api/
|
|
28
|
+
fetch.api.js 原始传输层(fetch 实现,换请求库只改这里)
|
|
29
|
+
domain.api.js 领域请求入口:统一 Result 解析 + mock/真实切换
|
|
30
|
+
app/
|
|
31
|
+
modules.js 模块配置(顶级导航 → 左侧菜单 → 路由)
|
|
32
|
+
router.js 路由组装(占位页兜底)
|
|
33
|
+
admin-shell.js AdminShell 页面组件(顶栏 + 侧栏 + 内容区组合)
|
|
34
|
+
ui/
|
|
35
|
+
placeholder-page.js PlaceholderPage 通用占位页
|
|
36
|
+
app-navbar.js AppNavbar 顶部导航(logo + 顶级导航 + 主题/头像)
|
|
37
|
+
app-sidebar.js AppSidebar 左侧导航外壳
|
|
38
|
+
sidebar-menu.js SidebarMenu 模块菜单组件
|
|
39
|
+
features/
|
|
40
|
+
dashboard/ 工作台(数据概览示例:数字看板 / 趋势卡 / 图表)
|
|
41
|
+
pages/dashboard-overview-page.js 页面编排:核心指标看板 + 趋势卡 + 图表 + 资源使用
|
|
42
|
+
components/dashboard-chart.js vChart 内置 SVG 适配器(柱状图 / 折线图)
|
|
43
|
+
members/ 成员管理业务域(CRUD 示例)
|
|
44
|
+
pages/member-list-page.js 页面编排:组合工具栏、表格、弹窗与分页
|
|
45
|
+
components/
|
|
46
|
+
member-toolbar.js MemberToolbar 搜索/新增工具栏
|
|
47
|
+
member-table.js MemberTable 成员表格
|
|
48
|
+
member-form-dialog.js MemberFormDialog 新增/编辑弹窗
|
|
49
|
+
api/
|
|
50
|
+
member.mgr.js 管理请求命令(增删改查等)
|
|
51
|
+
member.req.js 外部请求命令(其他模块调用本域能力)
|
|
52
|
+
member.views.js 领域结果结构(列表项 / 详情)
|
|
53
|
+
member.state.js 状态类 MembersPageState:持有数据与筛选,动作 submit 命令后驱动视图
|
|
54
|
+
member.mock.js 演示用内存 mock(接入真实后端后删除)
|
|
55
|
+
utils/options.js 领域常量(角色/状态选项与文案)
|
|
56
|
+
permissions/ 权限管理业务域(vTree 权限树示例)
|
|
57
|
+
pages/permission-list-page.js 页面编排:权限树 + 工具栏 + 表单弹窗 + 删除确认
|
|
58
|
+
components/
|
|
59
|
+
permission-form-dialog.js PermissionFormDialog 新增/编辑权限弹窗
|
|
60
|
+
api/
|
|
61
|
+
permission.mgr.js 权限管理请求命令(查询树 / 新增 / 编辑 / 删除)
|
|
62
|
+
permission.state.js 状态类 PermissionsPageState
|
|
63
|
+
permission.views.js 权限节点结构
|
|
64
|
+
permission.mock.js 演示用内存 mock
|
|
65
|
+
roles/ 角色管理业务域(分页 CRUD 示例)
|
|
66
|
+
pages/role-list-page.js 页面编排:搜索 + 表格 + 分页 + 表单弹窗
|
|
67
|
+
components/ RoleToolbar / RoleTable / RoleFormDialog
|
|
68
|
+
api/ 请求命令 / 状态类 / 结果结构 / mock
|
|
69
|
+
dicts/ 字典管理业务域(字典类型 + 字典项主从示例)
|
|
70
|
+
pages/dict-list-page.js 页面编排:字典类型表格 + 编辑弹窗(基本信息 + 字典值表格)
|
|
71
|
+
components/ DictEditorDialog / DictItemFormDialog
|
|
72
|
+
api/ 请求命令 / 状态类 / 结果结构 / mock
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
目录规则遵循 yoya-ui skill 的业务模块组织(feature 自包含、页面只编排、service 里 api 管数据交互、state 管状态)。
|
|
76
|
+
|
|
77
|
+
扩展新模块:在 `app/modules.js` 的 `modules` 数组里加一组 `{ key, label, icon, routes }`;有数据交互的页面参考 `features/members` 的 api / pages 分层。
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>yoya-ui 管理台</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<main id="app"></main>
|
|
10
|
+
<script type="module" src="/src/main.js"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yoya-ui-admin-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@yoyaflow/yoya-ui": "^0.3.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"typescript": "^7.0.2",
|
|
17
|
+
"vite": "^8.2.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** 领域请求入口类型声明(对应 domain.api.js)。 */
|
|
2
|
+
import type { RequestCommand } from '@yoyaflow/yoya-ui';
|
|
3
|
+
|
|
4
|
+
export function mockRequest(
|
|
5
|
+
method: string,
|
|
6
|
+
address: string | RegExp,
|
|
7
|
+
handler: (req: RequestCommand) => Promise<Record<string, unknown>>
|
|
8
|
+
): void;
|
|
9
|
+
|
|
10
|
+
export function domainSubmit(req: RequestCommand): Promise<unknown>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// 领域请求入口:统一 Result 解析 + mock / 真实传输切换。
|
|
2
|
+
import { configureRequest, Result } from '@yoyaflow/yoya-ui';
|
|
3
|
+
import { fetchSubmit } from './fetch.api.js';
|
|
4
|
+
|
|
5
|
+
const mockHandlers = new Map();
|
|
6
|
+
|
|
7
|
+
export function mockRequest(method, address, handler) {
|
|
8
|
+
const pattern =
|
|
9
|
+
address instanceof RegExp
|
|
10
|
+
? new RegExp(`^${method} ${address.source.replace(/^\^/, '').replace(/\$$/, '')}$`)
|
|
11
|
+
: `${method} ${address}`;
|
|
12
|
+
mockHandlers.set(pattern, handler);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function matchMock(req) {
|
|
16
|
+
const key = `${req.method()} ${req.address()}`;
|
|
17
|
+
for (const [pattern, handler] of mockHandlers) {
|
|
18
|
+
if (pattern === key || (pattern instanceof RegExp && pattern.test(key))) {
|
|
19
|
+
return handler;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function domainSubmit(req) {
|
|
26
|
+
const mock = matchMock(req);
|
|
27
|
+
const raw = mock ? await mock(req) : await fetchSubmit(req);
|
|
28
|
+
return Result.from(raw, req);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
configureRequest({ submit: domainSubmit });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// 原始传输层:fetch 实现。更换 ajax / 请求库只改这里。
|
|
2
|
+
export async function fetchSubmit(req) {
|
|
3
|
+
const query = new URLSearchParams(req.params() ?? {}).toString();
|
|
4
|
+
const url = query ? `${req.address()}?${query}` : req.address();
|
|
5
|
+
const headers = { 'Content-Type': 'application/json', ...(req.headers() ?? {}) };
|
|
6
|
+
const body = req.body();
|
|
7
|
+
|
|
8
|
+
const response = await fetch(url, {
|
|
9
|
+
method: req.method(),
|
|
10
|
+
headers,
|
|
11
|
+
credentials: req.cookies() ? 'include' : 'same-origin',
|
|
12
|
+
body: body === null || body === undefined ? undefined : JSON.stringify(body)
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
throw new Error(`请求失败:HTTP ${response.status}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return response.json();
|
|
20
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { vBody, vRouterViews } from '@yoyaflow/yoya-ui';
|
|
2
|
+
import { AppNavbar } from '../ui/app-navbar.js';
|
|
3
|
+
import { AppSidebar } from '../ui/app-sidebar.js';
|
|
4
|
+
|
|
5
|
+
export function AdminShell({ router, modules }) {
|
|
6
|
+
const navbar = AppNavbar({ modules, onModuleChange: switchModule });
|
|
7
|
+
const sidebar = AppSidebar({ router });
|
|
8
|
+
|
|
9
|
+
function switchModule(module) {
|
|
10
|
+
navbar.activate(module.key);
|
|
11
|
+
router.navigate(module.routes[0].path);
|
|
12
|
+
sidebar.showModule(module);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function syncModuleFromUrl() {
|
|
16
|
+
const path = router.currentPath();
|
|
17
|
+
const module = modules.find((entry) => entry.routes.some((route) => route.path === path));
|
|
18
|
+
if (!module) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
navbar.activate(module.key);
|
|
22
|
+
sidebar.showModule(module);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
render() {
|
|
27
|
+
return vBody((shell) => {
|
|
28
|
+
shell.gap(0);
|
|
29
|
+
shell.maxWidth('100%');
|
|
30
|
+
shell.padding(0);
|
|
31
|
+
shell.vContainer((frame) => {
|
|
32
|
+
frame.viewport(true);
|
|
33
|
+
frame.vHeader({ height: 56 }, (header) => {
|
|
34
|
+
header.style('padding', '0');
|
|
35
|
+
header.child(navbar);
|
|
36
|
+
});
|
|
37
|
+
frame.vContainer((body) => {
|
|
38
|
+
body.style({ flex: '1 1 auto', minHeight: '0' });
|
|
39
|
+
body.child(sidebar);
|
|
40
|
+
body.vMain((main) => {
|
|
41
|
+
main.child(vRouterViews(router, { lockTitle: true, title: '内容区' }));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
syncFromUrl() {
|
|
48
|
+
syncModuleFromUrl();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MemberListPage } from '../features/members/pages/member-list-page.js';
|
|
2
|
+
import { PermissionListPage } from '../features/permissions/pages/permission-list-page.js';
|
|
3
|
+
import { RoleListPage } from '../features/roles/pages/role-list-page.js';
|
|
4
|
+
import { DictListPage } from '../features/dicts/pages/dict-list-page.js';
|
|
5
|
+
import { DashboardOverviewPage } from '../features/dashboard/pages/dashboard-overview-page.js';
|
|
6
|
+
|
|
7
|
+
export const modules = [
|
|
8
|
+
{
|
|
9
|
+
key: 'dashboard',
|
|
10
|
+
label: '工作台',
|
|
11
|
+
icon: 'D',
|
|
12
|
+
routes: [
|
|
13
|
+
{ path: '/dashboard/overview', title: '数据概览', view: DashboardOverviewPage },
|
|
14
|
+
{ path: '/dashboard/todos', title: '待办审批', text: '待处理审批与任务。' }
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
key: 'ops',
|
|
19
|
+
label: '运维',
|
|
20
|
+
icon: 'O',
|
|
21
|
+
routes: [
|
|
22
|
+
{ path: '/ops/services', title: '服务清单', text: '服务列表与健康状态。' },
|
|
23
|
+
{ path: '/ops/deploys', title: '部署任务', text: '发布记录与执行状态。' }
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: 'system',
|
|
28
|
+
label: '系统',
|
|
29
|
+
icon: 'S',
|
|
30
|
+
routes: [
|
|
31
|
+
{ path: '/system/members', title: '成员管理', view: MemberListPage },
|
|
32
|
+
{ path: '/system/roles', title: '角色管理', view: RoleListPage },
|
|
33
|
+
{ path: '/system/permissions', title: '权限管理', view: PermissionListPage },
|
|
34
|
+
{ path: '/system/dicts', title: '字典管理', view: DictListPage }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createRouter } from '@yoyaflow/yoya-ui';
|
|
2
|
+
import { modules } from './modules.js';
|
|
3
|
+
import { PlaceholderPage } from '../ui/placeholder-page.js';
|
|
4
|
+
|
|
5
|
+
export function createAppRouter() {
|
|
6
|
+
const router = createRouter((r) => {
|
|
7
|
+
modules.forEach((module) => {
|
|
8
|
+
module.routes.forEach((route) => {
|
|
9
|
+
r.route(route.path, {
|
|
10
|
+
title: route.title,
|
|
11
|
+
view: () =>
|
|
12
|
+
route.view ? route.view() : PlaceholderPage({ title: route.title, text: route.text })
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
r.notFound(() => PlaceholderPage({ title: '未找到', text: '该页面不存在。' }));
|
|
17
|
+
});
|
|
18
|
+
router.default(modules[0].routes[0].path);
|
|
19
|
+
return router;
|
|
20
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { svg, vCard, vChart } from '@yoyaflow/yoya-ui';
|
|
2
|
+
|
|
3
|
+
/* global document */
|
|
4
|
+
|
|
5
|
+
const VIEW_BOX = { height: 260, width: 640 };
|
|
6
|
+
const PAD = { bottom: 34, left: 44, right: 16, top: 14 };
|
|
7
|
+
const SERIES_COLORS = ['#2563eb', '#16a34a', '#f59e0b', '#dc2626', '#8b5cf6'];
|
|
8
|
+
|
|
9
|
+
// vChart 内置 SVG 适配器:bar 柱状图 / line 折线图,零外部依赖。
|
|
10
|
+
export function DashboardChart({ labels = [], series = [], title = '', type = 'bar', height = 280 } = {}) {
|
|
11
|
+
const chart = vChart({
|
|
12
|
+
adapter: createSvgAdapter(),
|
|
13
|
+
data: { labels, series },
|
|
14
|
+
height,
|
|
15
|
+
options: { type }
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
render() {
|
|
20
|
+
return vCard((card) => {
|
|
21
|
+
card.vCardHeader(title);
|
|
22
|
+
card.vCardBody((body) => {
|
|
23
|
+
body.div((host) => {
|
|
24
|
+
host.style({ minWidth: '0', width: '100%' });
|
|
25
|
+
host.child(chart);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
update(next) {
|
|
31
|
+
chart.data(next);
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createSvgAdapter() {
|
|
38
|
+
return {
|
|
39
|
+
init(host, context) {
|
|
40
|
+
const instance = { host };
|
|
41
|
+
renderChart(instance, context);
|
|
42
|
+
return instance;
|
|
43
|
+
},
|
|
44
|
+
update(instance, context) {
|
|
45
|
+
renderChart(instance, context);
|
|
46
|
+
},
|
|
47
|
+
resize() {},
|
|
48
|
+
destroy() {}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderChart(instance, context) {
|
|
53
|
+
const root = buildChartSvg(context.data, context.options);
|
|
54
|
+
const legend = buildLegend(context.data?.series ?? []);
|
|
55
|
+
instance.host.replaceChildren(legend, root.renderDom());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildLegend(series) {
|
|
59
|
+
const legend = document.createElement('div');
|
|
60
|
+
legend.style.cssText =
|
|
61
|
+
'display:flex;flex-wrap:wrap;gap:14px;margin-bottom:8px;' +
|
|
62
|
+
'font-size:12px;color:var(--yoya-color-text-muted,#64748b);';
|
|
63
|
+
series.forEach((entry, index) => {
|
|
64
|
+
const color = entry.color ?? SERIES_COLORS[index % SERIES_COLORS.length];
|
|
65
|
+
const dot = document.createElement('span');
|
|
66
|
+
dot.style.cssText =
|
|
67
|
+
`background:${color};border-radius:2px;display:inline-block;` +
|
|
68
|
+
'height:8px;margin-right:5px;width:8px;';
|
|
69
|
+
const text = document.createElement('span');
|
|
70
|
+
text.textContent = entry.name ?? `系列 ${index + 1}`;
|
|
71
|
+
const item = document.createElement('span');
|
|
72
|
+
item.style.cssText = 'align-items:center;display:inline-flex;';
|
|
73
|
+
item.append(dot, text);
|
|
74
|
+
legend.appendChild(item);
|
|
75
|
+
});
|
|
76
|
+
return legend;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildChartSvg(data, options) {
|
|
80
|
+
const labels = data?.labels ?? [];
|
|
81
|
+
const series = (data?.series ?? []).map((entry, index) => ({
|
|
82
|
+
color: entry.color ?? SERIES_COLORS[index % SERIES_COLORS.length],
|
|
83
|
+
data: entry.data ?? [],
|
|
84
|
+
name: entry.name ?? `系列 ${index + 1}`
|
|
85
|
+
}));
|
|
86
|
+
const type = options?.type ?? 'bar';
|
|
87
|
+
const plotHeight = VIEW_BOX.height - PAD.top - PAD.bottom;
|
|
88
|
+
const plotWidth = VIEW_BOX.width - PAD.left - PAD.right;
|
|
89
|
+
const maxValue = niceCeil(Math.max(1, ...series.flatMap((entry) => entry.data)));
|
|
90
|
+
const toY = (value) => PAD.top + plotHeight - (value / maxValue) * plotHeight;
|
|
91
|
+
|
|
92
|
+
const root = svg((node) => {
|
|
93
|
+
node.attr({
|
|
94
|
+
height: 'calc(100% - 30px)',
|
|
95
|
+
role: 'img',
|
|
96
|
+
viewBox: `0 0 ${VIEW_BOX.width} ${VIEW_BOX.height}`,
|
|
97
|
+
width: '100%'
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
for (let index = 0; index <= 4; index += 1) {
|
|
101
|
+
const ratio = index / 4;
|
|
102
|
+
const lineY = PAD.top + plotHeight * (1 - ratio);
|
|
103
|
+
node.line({
|
|
104
|
+
stroke: index === 0 ? '#cbd5e1' : '#eef1f4',
|
|
105
|
+
'stroke-width': 1,
|
|
106
|
+
x1: PAD.left,
|
|
107
|
+
x2: VIEW_BOX.width - PAD.right,
|
|
108
|
+
y1: lineY,
|
|
109
|
+
y2: lineY
|
|
110
|
+
});
|
|
111
|
+
node.text(String(Math.round(maxValue * ratio)), {
|
|
112
|
+
fill: '#64748b',
|
|
113
|
+
'font-size': 11,
|
|
114
|
+
'text-anchor': 'end',
|
|
115
|
+
x: PAD.left - 8,
|
|
116
|
+
y: lineY + 4
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (type === 'line') {
|
|
121
|
+
drawLineChart(node, { labels, plotHeight, plotWidth, series, toY });
|
|
122
|
+
} else {
|
|
123
|
+
drawBarChart(node, { labels, plotHeight, plotWidth, series, toY });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
labels.forEach((label, index) => {
|
|
127
|
+
const x =
|
|
128
|
+
type === 'line'
|
|
129
|
+
? PAD.left + (plotWidth * index) / Math.max(1, labels.length - 1)
|
|
130
|
+
: PAD.left + (plotWidth * (index + 0.5)) / labels.length;
|
|
131
|
+
node.text(label, {
|
|
132
|
+
fill: '#64748b',
|
|
133
|
+
'font-size': 11,
|
|
134
|
+
'text-anchor': 'middle',
|
|
135
|
+
x,
|
|
136
|
+
y: VIEW_BOX.height - 10
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return root;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function drawBarChart(node, { labels, plotHeight, plotWidth, series, toY }) {
|
|
145
|
+
const groupWidth = plotWidth / Math.max(1, labels.length);
|
|
146
|
+
const barWidth = Math.min(32, (groupWidth * 0.72) / series.length);
|
|
147
|
+
labels.forEach((label, labelIndex) => {
|
|
148
|
+
const centerX = PAD.left + groupWidth * labelIndex + groupWidth / 2;
|
|
149
|
+
series.forEach((entry, seriesIndex) => {
|
|
150
|
+
const value = entry.data[labelIndex] ?? 0;
|
|
151
|
+
const barX = centerX - (barWidth * series.length) / 2 + barWidth * seriesIndex;
|
|
152
|
+
node.rect({
|
|
153
|
+
fill: entry.color,
|
|
154
|
+
height: PAD.top + plotHeight - toY(value),
|
|
155
|
+
rx: 2,
|
|
156
|
+
width: barWidth,
|
|
157
|
+
x: barX,
|
|
158
|
+
y: toY(value)
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function drawLineChart(node, { labels, plotHeight, plotWidth, series, toY }) {
|
|
165
|
+
const stepX = plotWidth / Math.max(1, labels.length - 1);
|
|
166
|
+
series.forEach((entry) => {
|
|
167
|
+
const points = entry.data.map((value, index) => `${PAD.left + stepX * index},${toY(value)}`);
|
|
168
|
+
if (points.length > 1) {
|
|
169
|
+
const area = [
|
|
170
|
+
`${PAD.left},${PAD.top + plotHeight}`,
|
|
171
|
+
...points,
|
|
172
|
+
`${PAD.left + stepX * (points.length - 1)},${PAD.top + plotHeight}`
|
|
173
|
+
];
|
|
174
|
+
node.polygon({ fill: entry.color, opacity: 0.1, points: area.join(' ') });
|
|
175
|
+
}
|
|
176
|
+
node.polyline({
|
|
177
|
+
fill: 'none',
|
|
178
|
+
points: points.join(' '),
|
|
179
|
+
stroke: entry.color,
|
|
180
|
+
'stroke-linecap': 'round',
|
|
181
|
+
'stroke-linejoin': 'round',
|
|
182
|
+
'stroke-width': 2
|
|
183
|
+
});
|
|
184
|
+
entry.data.forEach((value, index) => {
|
|
185
|
+
node.circle({
|
|
186
|
+
cx: PAD.left + stepX * index,
|
|
187
|
+
cy: toY(value),
|
|
188
|
+
fill: '#ffffff',
|
|
189
|
+
r: 3,
|
|
190
|
+
stroke: entry.color,
|
|
191
|
+
'stroke-width': 2
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function niceCeil(value) {
|
|
198
|
+
if (value <= 0) {
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
const power = 10 ** Math.floor(Math.log10(value));
|
|
202
|
+
const normalized = value / power;
|
|
203
|
+
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
|
204
|
+
return nice * power;
|
|
205
|
+
}
|