create-yoya-ui 0.1.2 → 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 +1 -1
- package/package.json +1 -1
- package/templates/admin/README.md +54 -2
- 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 +4 -2
- 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 +17 -165
- 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/basic/package.json +1 -1
- package/templates/ssr/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,4 +20,4 @@ npm install
|
|
|
20
20
|
|
|
21
21
|
- `templates/basic`:页面壳、按钮事件、表单收集校验、主题切换
|
|
22
22
|
- `templates/ssr`:home-page.js(页面组件 + 词典)、server.mjs(node:http + renderPage)、client.js(hydrateOrMount)
|
|
23
|
-
- `templates/admin
|
|
23
|
+
- `templates/admin`:标准后台管理模板(logo/系统名、顶部导航、用户头像、可折叠菜单、带标题内容区),内置数据概览看板与图表,以及成员 / 角色 / 权限 / 字典管理示例
|
package/package.json
CHANGED
|
@@ -15,11 +15,63 @@ npm install
|
|
|
15
15
|
npm run dev
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
## TypeScript
|
|
19
|
+
|
|
20
|
+
模板默认支持 TypeScript,非侵入接入:现有 `.js` 文件保持不动(`tsconfig.json` 已开启 `allowJs`),需要类型时直接新建或改写为 `.ts` 文件即可(Vite 原生支持)。运行 `npm run typecheck` 做类型检查。
|
|
21
|
+
|
|
18
22
|
## 结构
|
|
19
23
|
|
|
20
24
|
```text
|
|
21
25
|
src/
|
|
22
|
-
main.js
|
|
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
|
|
23
73
|
```
|
|
24
74
|
|
|
25
|
-
|
|
75
|
+
目录规则遵循 yoya-ui skill 的业务模块组织(feature 自包含、页面只编排、service 里 api 管数据交互、state 管状态)。
|
|
76
|
+
|
|
77
|
+
扩展新模块:在 `app/modules.js` 的 `modules` 数组里加一组 `{ key, label, icon, routes }`;有数据交互的页面参考 `features/members` 的 api / pages 分层。
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite",
|
|
8
8
|
"build": "vite build",
|
|
9
|
-
"preview": "vite preview"
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
12
|
-
"@yoyaflow/yoya-ui": "^0.
|
|
13
|
+
"@yoyaflow/yoya-ui": "^0.3.0"
|
|
13
14
|
},
|
|
14
15
|
"devDependencies": {
|
|
16
|
+
"typescript": "^7.0.2",
|
|
15
17
|
"vite": "^8.2.0"
|
|
16
18
|
}
|
|
17
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
|
+
}
|