openxiangda 1.0.73 → 1.0.75
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 +1 -1
- package/templates/openxiangda-react-spa/package.json +3 -0
- package/templates/openxiangda-react-spa/src/app/navigation.ts +5 -1
- package/templates/openxiangda-react-spa/src/app/starter-content.ts +138 -0
- package/templates/openxiangda-react-spa/src/layouts/AdminShell.tsx +137 -21
- package/templates/openxiangda-react-spa/src/pages/admin/AdminDashboardPage.tsx +162 -19
- package/templates/openxiangda-react-spa/src/pages/defaults/DataRoutePage.tsx +11 -76
- package/templates/openxiangda-react-spa/src/pages/defaults/FormRoutePage.tsx +32 -28
- package/templates/openxiangda-react-spa/src/shared/mac-admin.tsx +319 -0
- package/templates/openxiangda-react-spa/src/shared/ui.tsx +9 -0
- package/templates/openxiangda-react-spa/src/styles/index.css +44 -0
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ComponentType, SVGProps } from "react";
|
|
2
|
-
import { Home } from "lucide-react";
|
|
2
|
+
import { BriefcaseBusiness, Home } from "lucide-react";
|
|
3
3
|
|
|
4
4
|
export type StarterNavigationItem = {
|
|
5
5
|
code?: string;
|
|
@@ -13,6 +13,9 @@ export type StarterNavigationItem = {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
export type StarterNavigationGroup = {
|
|
16
|
+
icon: ComponentType<
|
|
17
|
+
SVGProps<SVGSVGElement> & { size?: string | number; strokeWidth?: string | number }
|
|
18
|
+
>;
|
|
16
19
|
title: string;
|
|
17
20
|
items: StarterNavigationItem[];
|
|
18
21
|
};
|
|
@@ -29,6 +32,7 @@ export function buildStarterAdminNavigation({
|
|
|
29
32
|
}: BuildStarterNavigationOptions): StarterNavigationGroup[] {
|
|
30
33
|
return [
|
|
31
34
|
{
|
|
35
|
+
icon: BriefcaseBusiness,
|
|
32
36
|
title: "应用工作台",
|
|
33
37
|
items: [
|
|
34
38
|
{
|
|
@@ -4,3 +4,141 @@ export const starterBrand = {
|
|
|
4
4
|
name: "OpenXiangda",
|
|
5
5
|
subtitle: "React SPA Starter",
|
|
6
6
|
};
|
|
7
|
+
|
|
8
|
+
export const dashboardContent = {
|
|
9
|
+
activities: {
|
|
10
|
+
actionText: "查看全部活动",
|
|
11
|
+
items: [
|
|
12
|
+
{
|
|
13
|
+
key: "release",
|
|
14
|
+
subtitle: "发布了新的业务页面与权限配置",
|
|
15
|
+
time: "10:18",
|
|
16
|
+
title: "应用版本已更新",
|
|
17
|
+
tone: "blue",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
key: "form",
|
|
21
|
+
subtitle: "表单字段与校验规则已同步",
|
|
22
|
+
time: "09:42",
|
|
23
|
+
title: "业务表单已更新",
|
|
24
|
+
tone: "emerald",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: "data",
|
|
28
|
+
subtitle: "数据视图完成自动刷新",
|
|
29
|
+
time: "昨天",
|
|
30
|
+
title: "数据看板已刷新",
|
|
31
|
+
tone: "amber",
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
title: "近期活动",
|
|
35
|
+
},
|
|
36
|
+
aiReview: {
|
|
37
|
+
centerLabel: "总数",
|
|
38
|
+
centerValue: "1,248",
|
|
39
|
+
items: [
|
|
40
|
+
{ color: "#22c55e", label: "通过", percent: "68.6%", value: 856 },
|
|
41
|
+
{ color: "#f59e0b", label: "需人工复核", percent: "25.0%", value: 312 },
|
|
42
|
+
{ color: "#ef4444", label: "不通过", percent: "6.4%", value: 80 },
|
|
43
|
+
],
|
|
44
|
+
title: "智能校验识别(近 7 天)",
|
|
45
|
+
},
|
|
46
|
+
environment: {
|
|
47
|
+
actionText: "查看运行详情",
|
|
48
|
+
rows: [
|
|
49
|
+
{ key: "env", label: "当前环境", status: "success", value: "生产环境" },
|
|
50
|
+
{ key: "mode", label: "运行模式", value: "React SPA" },
|
|
51
|
+
{ key: "version", label: "部署版本", status: "success", value: "v1.0" },
|
|
52
|
+
{ key: "service", label: "服务状态", status: "success", value: "运行中" },
|
|
53
|
+
],
|
|
54
|
+
title: "运行环境",
|
|
55
|
+
},
|
|
56
|
+
metrics: [
|
|
57
|
+
{
|
|
58
|
+
caption: "较昨日",
|
|
59
|
+
delta: "↑ 12.6%",
|
|
60
|
+
icon: "chart",
|
|
61
|
+
key: "page_visits",
|
|
62
|
+
label: "页面访问",
|
|
63
|
+
tone: "blue",
|
|
64
|
+
value: "24,680",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
caption: "较昨日",
|
|
68
|
+
delta: "↑ 9.1%",
|
|
69
|
+
icon: "clipboard",
|
|
70
|
+
key: "pending_tasks",
|
|
71
|
+
label: "待办事项",
|
|
72
|
+
tone: "emerald",
|
|
73
|
+
value: "12",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
caption: "较昨日",
|
|
77
|
+
delta: "↑ 8.3%",
|
|
78
|
+
icon: "file",
|
|
79
|
+
key: "form_submit",
|
|
80
|
+
label: "表单提交",
|
|
81
|
+
tone: "violet",
|
|
82
|
+
value: "1,248",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
caption: "较昨日",
|
|
86
|
+
delta: "↑ 15.4%",
|
|
87
|
+
icon: "refresh",
|
|
88
|
+
key: "sync_jobs",
|
|
89
|
+
label: "数据视图刷新",
|
|
90
|
+
tone: "amber",
|
|
91
|
+
value: "356",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
todos: {
|
|
95
|
+
actionText: "查看全部(12)",
|
|
96
|
+
items: [
|
|
97
|
+
{
|
|
98
|
+
key: "expense",
|
|
99
|
+
meta: "申请人:李明 | 金额:¥2,450.00",
|
|
100
|
+
time: "10:24",
|
|
101
|
+
title: "费用报销申请",
|
|
102
|
+
tone: "amber",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
key: "purchase",
|
|
106
|
+
meta: "申请人:王芳 | 金额:¥18,600.00",
|
|
107
|
+
time: "09:58",
|
|
108
|
+
title: "采购申请",
|
|
109
|
+
tone: "violet",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
key: "leave",
|
|
113
|
+
meta: "申请人:张三 | 类型:年假 2 天",
|
|
114
|
+
time: "昨天",
|
|
115
|
+
title: "请假申请",
|
|
116
|
+
tone: "blue",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
key: "contract",
|
|
120
|
+
meta: "申请人:陈晨 | 合同金额:¥120,000.00",
|
|
121
|
+
time: "昨天",
|
|
122
|
+
title: "合同审批",
|
|
123
|
+
tone: "violet",
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
title: "待办审批",
|
|
127
|
+
},
|
|
128
|
+
trend: {
|
|
129
|
+
labels: ["05-09", "05-10", "05-11", "05-12", "05-13", "05-14", "05-15"],
|
|
130
|
+
series: [
|
|
131
|
+
{
|
|
132
|
+
color: "#3b82f6",
|
|
133
|
+
data: [2100, 2800, 3100, 4300, 3000, 2200, 2700],
|
|
134
|
+
name: "页面访问数",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
color: "#22c55e",
|
|
138
|
+
data: [1100, 1500, 1600, 2100, 1600, 1200, 1500],
|
|
139
|
+
name: "独立访客数",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
title: "访问趋势",
|
|
143
|
+
},
|
|
144
|
+
};
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
Shield,
|
|
9
9
|
X,
|
|
10
10
|
} from "lucide-react";
|
|
11
|
-
import { useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
12
12
|
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
|
13
13
|
import {
|
|
14
14
|
PermissionBoundary,
|
|
@@ -41,6 +41,24 @@ type PlatformMenuLike = {
|
|
|
41
41
|
type?: string | null;
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
type AdminPageMeta = {
|
|
45
|
+
breadcrumbs?: string[];
|
|
46
|
+
title?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type NavigationState = {
|
|
50
|
+
activePath?: string;
|
|
51
|
+
breadcrumbs: string[];
|
|
52
|
+
title: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const AdminPageMetaContext = createContext<((meta: AdminPageMeta | null) => void) | null>(null);
|
|
56
|
+
const noopSetAdminPageMeta = () => undefined;
|
|
57
|
+
|
|
58
|
+
export function useAdminPageMetaController() {
|
|
59
|
+
return useContext(AdminPageMetaContext) ?? noopSetAdminPageMeta;
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
export function AdminShell() {
|
|
45
63
|
const { appType = "" } = useParams();
|
|
46
64
|
const location = useLocation();
|
|
@@ -52,8 +70,8 @@ export function AdminShell() {
|
|
|
52
70
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
53
71
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
|
54
72
|
const [loggingOut, setLoggingOut] = useState(false);
|
|
73
|
+
const [pageMeta, setPageMeta] = useState<AdminPageMeta | null>(null);
|
|
55
74
|
|
|
56
|
-
const appName = String(bootstrap.data?.app?.name || starterBrand.fallbackName);
|
|
57
75
|
const userName = String(
|
|
58
76
|
bootstrap.data?.user?.name ||
|
|
59
77
|
bootstrap.data?.user?.nickName ||
|
|
@@ -85,6 +103,15 @@ export function AdminShell() {
|
|
|
85
103
|
),
|
|
86
104
|
[appType, menuCodes],
|
|
87
105
|
);
|
|
106
|
+
const navigationState = useMemo(
|
|
107
|
+
() => resolveNavigationState(groups, location.pathname, location.search, appType),
|
|
108
|
+
[appType, groups, location.pathname, location.search],
|
|
109
|
+
);
|
|
110
|
+
const headerTitle = pageMeta?.title || navigationState.title || starterBrand.fallbackName;
|
|
111
|
+
const breadcrumbs =
|
|
112
|
+
pageMeta?.breadcrumbs && pageMeta.breadcrumbs.length > 0
|
|
113
|
+
? pageMeta.breadcrumbs
|
|
114
|
+
: navigationState.breadcrumbs;
|
|
88
115
|
|
|
89
116
|
const handleLogout = async () => {
|
|
90
117
|
setLoggingOut(true);
|
|
@@ -109,39 +136,49 @@ export function AdminShell() {
|
|
|
109
136
|
</div>
|
|
110
137
|
</div>
|
|
111
138
|
|
|
112
|
-
<nav className={cn("ox-scrollbar min-h-0 flex-1 overflow-y-auto py-
|
|
139
|
+
<nav className={cn("ox-scrollbar min-h-0 flex-1 overflow-y-auto py-3", compact ? "px-2" : "space-y-2 px-2.5")}>
|
|
113
140
|
{groups.map(group => {
|
|
114
141
|
const collapsed = collapsedGroups[group.title];
|
|
142
|
+
const groupActive = group.items.some(item => navigationState.activePath === item.path);
|
|
143
|
+
const GroupIcon = group.icon;
|
|
115
144
|
return (
|
|
116
|
-
<section key={group.title}>
|
|
145
|
+
<section className="min-w-0" key={group.title}>
|
|
117
146
|
<button
|
|
118
147
|
aria-label={group.title}
|
|
119
148
|
className={cn(
|
|
120
|
-
"flex h-
|
|
121
|
-
compact ? "justify-center px-0" : "justify-between px-2.5",
|
|
149
|
+
"flex h-9 w-full items-center rounded-xl text-[12px] font-semibold text-slate-700 transition hover:bg-slate-50 hover:text-slate-950",
|
|
150
|
+
compact ? "justify-center px-0" : "justify-between gap-2 px-2.5",
|
|
151
|
+
groupActive && "bg-slate-50 text-slate-950",
|
|
122
152
|
)}
|
|
123
153
|
onClick={() => setCollapsedGroups(prev => ({ ...prev, [group.title]: !collapsed }))}
|
|
124
154
|
type="button"
|
|
125
155
|
>
|
|
126
|
-
<span className={cn(compact && "
|
|
156
|
+
<span className={cn("flex min-w-0 items-center gap-2", compact && "justify-center")}>
|
|
157
|
+
<GroupIcon
|
|
158
|
+
className={cn(groupActive ? "text-blue-600" : "text-slate-500")}
|
|
159
|
+
size={15}
|
|
160
|
+
strokeWidth={2}
|
|
161
|
+
/>
|
|
162
|
+
<span className={cn("truncate", compact && "sr-only")}>{group.title}</span>
|
|
163
|
+
</span>
|
|
127
164
|
{compact ? (
|
|
128
|
-
|
|
165
|
+
null
|
|
129
166
|
) : collapsed ? (
|
|
130
|
-
<ChevronRight className="text-slate-500" size={
|
|
167
|
+
<ChevronRight className="shrink-0 text-slate-500" size={15} strokeWidth={2} />
|
|
131
168
|
) : (
|
|
132
|
-
<ChevronDown className="text-slate-500" size={
|
|
169
|
+
<ChevronDown className="shrink-0 text-slate-500" size={15} strokeWidth={2} />
|
|
133
170
|
)}
|
|
134
171
|
</button>
|
|
135
172
|
{!collapsed ? (
|
|
136
|
-
<div className={cn("mt-1 space-y-
|
|
173
|
+
<div className={cn("mt-1 space-y-0.5", compact && "mt-1")}>
|
|
137
174
|
{group.items.map(item => {
|
|
138
|
-
const active =
|
|
175
|
+
const active = navigationState.activePath === item.path;
|
|
139
176
|
return (
|
|
140
177
|
<Link
|
|
141
178
|
aria-label={item.name}
|
|
142
179
|
className={cn(
|
|
143
|
-
"group relative flex h-
|
|
144
|
-
compact ? "justify-center px-0" : "gap-
|
|
180
|
+
"group relative flex h-9 items-center rounded-xl text-sm transition",
|
|
181
|
+
compact ? "justify-center px-0" : "gap-2.5 px-3",
|
|
145
182
|
active
|
|
146
183
|
? "bg-[#eef6ff] text-[#1677ff]"
|
|
147
184
|
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900",
|
|
@@ -152,7 +189,7 @@ export function AdminShell() {
|
|
|
152
189
|
>
|
|
153
190
|
<span
|
|
154
191
|
className={cn(
|
|
155
|
-
"absolute -left-
|
|
192
|
+
"absolute -left-2.5 top-1 h-7 w-1 rounded-r-full transition",
|
|
156
193
|
active ? "bg-[#1677ff]" : "bg-transparent",
|
|
157
194
|
compact && "-left-2",
|
|
158
195
|
)}
|
|
@@ -163,7 +200,7 @@ export function AdminShell() {
|
|
|
163
200
|
active ? "text-[#1677ff]" : "text-slate-500 group-hover:text-slate-700",
|
|
164
201
|
)}
|
|
165
202
|
>
|
|
166
|
-
<item.icon size={
|
|
203
|
+
<item.icon size={16} strokeWidth={2} />
|
|
167
204
|
</span>
|
|
168
205
|
<span className={cn("min-w-0 flex-1 truncate font-medium", compact && "sr-only")}>{item.name}</span>
|
|
169
206
|
</Link>
|
|
@@ -241,8 +278,10 @@ export function AdminShell() {
|
|
|
241
278
|
<Menu size={20} />
|
|
242
279
|
</button>
|
|
243
280
|
<div className="min-w-0">
|
|
244
|
-
<div className="truncate text-xs font-medium text-slate-500"
|
|
245
|
-
|
|
281
|
+
<div className="truncate text-xs font-medium text-slate-500">
|
|
282
|
+
{breadcrumbs.join(" / ")}
|
|
283
|
+
</div>
|
|
284
|
+
<div className="mt-1 truncate text-lg font-semibold text-slate-950">{headerTitle}</div>
|
|
246
285
|
</div>
|
|
247
286
|
</div>
|
|
248
287
|
|
|
@@ -296,9 +335,11 @@ export function AdminShell() {
|
|
|
296
335
|
</div>
|
|
297
336
|
</header>
|
|
298
337
|
|
|
299
|
-
<
|
|
300
|
-
<
|
|
301
|
-
|
|
338
|
+
<AdminPageMetaContext.Provider value={setPageMeta}>
|
|
339
|
+
<div className="ox-admin-content mx-auto min-w-0 max-w-[1440px] px-4 py-5 sm:px-6">
|
|
340
|
+
<Outlet />
|
|
341
|
+
</div>
|
|
342
|
+
</AdminPageMetaContext.Provider>
|
|
302
343
|
</main>
|
|
303
344
|
</div>
|
|
304
345
|
</PermissionBoundary>
|
|
@@ -439,3 +480,78 @@ function isMenuActive(pathname: string, search: string, target: string) {
|
|
|
439
480
|
if (target.includes("?")) return `${pathname}${search}` === target;
|
|
440
481
|
return normalize(pathname) === normalize(target);
|
|
441
482
|
}
|
|
483
|
+
|
|
484
|
+
function resolveNavigationState(
|
|
485
|
+
groups: StarterNavigationGroup[],
|
|
486
|
+
pathname: string,
|
|
487
|
+
search: string,
|
|
488
|
+
appType: string,
|
|
489
|
+
): NavigationState {
|
|
490
|
+
const fallback: NavigationState = {
|
|
491
|
+
breadcrumbs: ["首页"],
|
|
492
|
+
title: starterBrand.fallbackName,
|
|
493
|
+
};
|
|
494
|
+
const entries = groups.flatMap(group => group.items.map(item => ({ group, item })));
|
|
495
|
+
const exact = entries.find(({ item }) => isMenuActive(pathname, search, item.path));
|
|
496
|
+
if (exact) {
|
|
497
|
+
return {
|
|
498
|
+
activePath: exact.item.path,
|
|
499
|
+
breadcrumbs: ["首页", exact.group.title, exact.item.name],
|
|
500
|
+
title: exact.item.name,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const route = parseAdminRoute(pathname, appType);
|
|
505
|
+
if (!route) return fallback;
|
|
506
|
+
|
|
507
|
+
const related = findRelatedNavigationEntry(entries, route);
|
|
508
|
+
if (related) {
|
|
509
|
+
const title =
|
|
510
|
+
route.kind === "form-detail" || route.kind === "process-detail"
|
|
511
|
+
? `${related.item.name}详情`
|
|
512
|
+
: related.item.name;
|
|
513
|
+
return {
|
|
514
|
+
activePath: related.item.path,
|
|
515
|
+
breadcrumbs: ["首页", related.group.title, related.item.name],
|
|
516
|
+
title,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (route.kind === "data") {
|
|
521
|
+
return { breadcrumbs: ["首页", "数据管理"], title: "业务数据" };
|
|
522
|
+
}
|
|
523
|
+
if (route.kind === "form-new") {
|
|
524
|
+
return { breadcrumbs: ["首页", "业务办理"], title: "发起申请" };
|
|
525
|
+
}
|
|
526
|
+
if (route.kind === "form-detail") {
|
|
527
|
+
return { breadcrumbs: ["首页", "业务记录"], title: "记录详情" };
|
|
528
|
+
}
|
|
529
|
+
return { breadcrumbs: ["首页", "流程办理"], title: "流程详情" };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function parseAdminRoute(pathname: string, appType: string) {
|
|
533
|
+
const escapedAppType = appType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
534
|
+
const prefix = new RegExp(`^/view/${escapedAppType}/admin/`);
|
|
535
|
+
if (!prefix.test(pathname)) return null;
|
|
536
|
+
const rest = pathname.replace(prefix, "");
|
|
537
|
+
const [kind, formUuid, tail] = rest.split("/");
|
|
538
|
+
if (kind === "data" && formUuid) return { formUuid, kind: "data" as const };
|
|
539
|
+
if (kind === "forms" && formUuid && tail === "new") return { formUuid, kind: "form-new" as const };
|
|
540
|
+
if (kind === "forms" && formUuid) return { formUuid, kind: "form-detail" as const };
|
|
541
|
+
if (kind === "process" && formUuid) return { formUuid, kind: "process-detail" as const };
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function findRelatedNavigationEntry(
|
|
546
|
+
entries: Array<{ group: StarterNavigationGroup; item: StarterNavigationGroup["items"][number] }>,
|
|
547
|
+
route: NonNullable<ReturnType<typeof parseAdminRoute>>,
|
|
548
|
+
) {
|
|
549
|
+
const formPathNeedle = `/forms/${route.formUuid}/new`;
|
|
550
|
+
const dataPathNeedle = `/data/${route.formUuid}`;
|
|
551
|
+
if (route.kind === "data") return entries.find(({ item }) => item.path.includes(dataPathNeedle));
|
|
552
|
+
if (route.kind === "form-new") return entries.find(({ item }) => item.path.includes(formPathNeedle));
|
|
553
|
+
return (
|
|
554
|
+
entries.find(({ item }) => item.path.includes(dataPathNeedle)) ||
|
|
555
|
+
entries.find(({ item }) => item.path.includes(formPathNeedle))
|
|
556
|
+
);
|
|
557
|
+
}
|
|
@@ -1,27 +1,170 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
BarChart3,
|
|
3
|
+
CheckCircle2,
|
|
4
|
+
ClipboardList,
|
|
5
|
+
Clock3,
|
|
6
|
+
FileText,
|
|
7
|
+
RefreshCw,
|
|
8
|
+
Server,
|
|
9
|
+
ShieldCheck,
|
|
10
|
+
Sparkles,
|
|
11
|
+
} from "lucide-react";
|
|
2
12
|
|
|
3
|
-
import {
|
|
13
|
+
import { dashboardContent } from "@/app/starter-content";
|
|
14
|
+
import {
|
|
15
|
+
ActivityItem,
|
|
16
|
+
ChartPanel,
|
|
17
|
+
DashboardMetricCard,
|
|
18
|
+
DonutChart,
|
|
19
|
+
EnvironmentRow,
|
|
20
|
+
TodoItem,
|
|
21
|
+
TrendLineChart,
|
|
22
|
+
type AppTone,
|
|
23
|
+
type DonutItem,
|
|
24
|
+
type TrendSeries,
|
|
25
|
+
} from "@/shared/ui";
|
|
26
|
+
|
|
27
|
+
type DashboardIconKey = "chart" | "clipboard" | "file" | "refresh" | "shield" | "server";
|
|
28
|
+
|
|
29
|
+
const metricIcons = {
|
|
30
|
+
chart: BarChart3,
|
|
31
|
+
clipboard: ClipboardList,
|
|
32
|
+
file: FileText,
|
|
33
|
+
refresh: RefreshCw,
|
|
34
|
+
server: Server,
|
|
35
|
+
shield: ShieldCheck,
|
|
36
|
+
} satisfies Record<DashboardIconKey, typeof BarChart3>;
|
|
37
|
+
|
|
38
|
+
const itemIcons = {
|
|
39
|
+
amber: Clock3,
|
|
40
|
+
blue: CheckCircle2,
|
|
41
|
+
emerald: ShieldCheck,
|
|
42
|
+
rose: Sparkles,
|
|
43
|
+
slate: Server,
|
|
44
|
+
violet: ClipboardList,
|
|
45
|
+
} satisfies Record<AppTone, typeof CheckCircle2>;
|
|
4
46
|
|
|
5
47
|
export function AdminDashboardPage() {
|
|
6
48
|
return (
|
|
7
|
-
<div className="min-w-0 space-y-
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
49
|
+
<div className="min-w-0 space-y-4">
|
|
50
|
+
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
51
|
+
{dashboardContent.metrics.map(metric => {
|
|
52
|
+
const Icon = metricIcons[(metric.icon as DashboardIconKey) || "chart"] ?? BarChart3;
|
|
53
|
+
return (
|
|
54
|
+
<DashboardMetricCard
|
|
55
|
+
caption={metric.caption}
|
|
56
|
+
delta={metric.delta}
|
|
57
|
+
icon={Icon}
|
|
58
|
+
key={metric.key}
|
|
59
|
+
label={metric.label}
|
|
60
|
+
tone={metric.tone as AppTone}
|
|
61
|
+
value={metric.value}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</section>
|
|
66
|
+
|
|
67
|
+
<section className="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
|
68
|
+
<ChartPanel
|
|
69
|
+
action={<span className="rounded-lg border border-slate-200 px-2.5 py-1 text-xs text-slate-500">近 7 天</span>}
|
|
70
|
+
title={dashboardContent.trend.title}
|
|
71
|
+
>
|
|
72
|
+
<TrendLineChart
|
|
73
|
+
labels={[...dashboardContent.trend.labels]}
|
|
74
|
+
series={dashboardContent.trend.series.map(item => ({ ...item })) as TrendSeries[]}
|
|
75
|
+
/>
|
|
76
|
+
</ChartPanel>
|
|
77
|
+
|
|
78
|
+
<ChartPanel
|
|
79
|
+
action={<span className="text-xs font-medium text-blue-600">{dashboardContent.todos.actionText}</span>}
|
|
80
|
+
title={dashboardContent.todos.title}
|
|
81
|
+
>
|
|
82
|
+
<div className="space-y-1">
|
|
83
|
+
{dashboardContent.todos.items.map(item => {
|
|
84
|
+
const tone = item.tone as AppTone;
|
|
85
|
+
const Icon = itemIcons[tone] ?? ClipboardList;
|
|
86
|
+
return (
|
|
87
|
+
<TodoItem
|
|
88
|
+
icon={<Icon size={15} strokeWidth={2.2} />}
|
|
89
|
+
key={item.key}
|
|
90
|
+
meta={item.meta}
|
|
91
|
+
time={item.time}
|
|
92
|
+
title={item.title}
|
|
93
|
+
tone={tone}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</div>
|
|
98
|
+
</ChartPanel>
|
|
99
|
+
</section>
|
|
100
|
+
|
|
101
|
+
<section className="grid gap-4 xl:grid-cols-[minmax(280px,0.8fr)_minmax(0,1fr)_minmax(300px,0.9fr)]">
|
|
102
|
+
<ChartPanel title={dashboardContent.aiReview.title}>
|
|
103
|
+
<div className="grid gap-3 md:grid-cols-[minmax(180px,0.85fr)_minmax(0,1fr)] xl:grid-cols-1 2xl:grid-cols-[minmax(180px,0.85fr)_minmax(0,1fr)]">
|
|
104
|
+
<DonutChart
|
|
105
|
+
centerLabel={dashboardContent.aiReview.centerLabel}
|
|
106
|
+
centerValue={dashboardContent.aiReview.centerValue}
|
|
107
|
+
items={dashboardContent.aiReview.items.map(({ color, label, value }) => ({
|
|
108
|
+
color,
|
|
109
|
+
label,
|
|
110
|
+
value,
|
|
111
|
+
})) as DonutItem[]}
|
|
112
|
+
/>
|
|
113
|
+
<div className="flex flex-col justify-center space-y-2">
|
|
114
|
+
{dashboardContent.aiReview.items.map(item => (
|
|
115
|
+
<div className="flex items-center justify-between gap-3 text-sm" key={item.label}>
|
|
116
|
+
<span className="flex min-w-0 items-center gap-2 text-slate-600">
|
|
117
|
+
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: item.color }} />
|
|
118
|
+
<span className="truncate">{item.label}</span>
|
|
119
|
+
</span>
|
|
120
|
+
<span className="shrink-0 font-medium text-slate-900">
|
|
121
|
+
{item.value}
|
|
122
|
+
<span className="ml-2 text-xs font-normal text-slate-500">{item.percent}</span>
|
|
123
|
+
</span>
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</ChartPanel>
|
|
129
|
+
|
|
130
|
+
<ChartPanel
|
|
131
|
+
action={<span className="text-xs font-medium text-blue-600">{dashboardContent.activities.actionText}</span>}
|
|
132
|
+
title={dashboardContent.activities.title}
|
|
133
|
+
>
|
|
134
|
+
<div className="space-y-1">
|
|
135
|
+
{dashboardContent.activities.items.map(item => {
|
|
136
|
+
const tone = item.tone as AppTone;
|
|
137
|
+
const Icon = itemIcons[tone] ?? Sparkles;
|
|
138
|
+
return (
|
|
139
|
+
<ActivityItem
|
|
140
|
+
icon={<Icon size={15} strokeWidth={2.2} />}
|
|
141
|
+
key={item.key}
|
|
142
|
+
subtitle={item.subtitle}
|
|
143
|
+
time={item.time}
|
|
144
|
+
title={item.title}
|
|
145
|
+
tone={tone}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
})}
|
|
149
|
+
</div>
|
|
150
|
+
</ChartPanel>
|
|
151
|
+
|
|
152
|
+
<ChartPanel
|
|
153
|
+
action={<span className="text-xs font-medium text-blue-600">{dashboardContent.environment.actionText}</span>}
|
|
154
|
+
title={dashboardContent.environment.title}
|
|
155
|
+
>
|
|
156
|
+
<div className="rounded-xl border border-slate-100 px-3">
|
|
157
|
+
{dashboardContent.environment.rows.map(row => (
|
|
158
|
+
<EnvironmentRow
|
|
159
|
+
key={row.key}
|
|
160
|
+
label={row.label}
|
|
161
|
+
status={row.status as "default" | "success" | "warning" | undefined}
|
|
162
|
+
value={row.value}
|
|
163
|
+
/>
|
|
164
|
+
))}
|
|
18
165
|
</div>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
保留应用框架和登录账号信息,页面内容由后续业务开发自行接入。
|
|
22
|
-
</p>
|
|
23
|
-
</div>
|
|
24
|
-
</Panel>
|
|
166
|
+
</ChartPanel>
|
|
167
|
+
</section>
|
|
25
168
|
</div>
|
|
26
169
|
);
|
|
27
170
|
}
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import { Database, FilePlus2, Filter, RefreshCw, Rows3 } from "lucide-react";
|
|
2
|
-
import type { ReactNode } from "react";
|
|
3
1
|
import { useParams } from "react-router-dom";
|
|
4
2
|
import { DataManagementList } from "openxiangda";
|
|
5
3
|
|
|
6
|
-
import {
|
|
7
|
-
PageHeader,
|
|
8
|
-
Panel,
|
|
9
|
-
PrimaryButton,
|
|
10
|
-
SecondaryButton,
|
|
11
|
-
StatusPill,
|
|
12
|
-
} from "@/shared/ui";
|
|
13
4
|
import {
|
|
14
5
|
defaultPageOverrides,
|
|
15
6
|
resolveDefaultPageOverride,
|
|
@@ -29,73 +20,17 @@ export function DataRoutePage() {
|
|
|
29
20
|
);
|
|
30
21
|
|
|
31
22
|
return (
|
|
32
|
-
<div className="min-w-0
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
</SecondaryButton>
|
|
44
|
-
<PrimaryButton onClick={() => window.location.assign(`/view/${appType}/admin/forms/${formUuid}/new`)}>
|
|
45
|
-
<FilePlus2 size={17} />
|
|
46
|
-
新增数据
|
|
47
|
-
</PrimaryButton>
|
|
48
|
-
</>
|
|
49
|
-
}
|
|
50
|
-
description="查看、筛选和维护当前账号有权限访问的业务数据。"
|
|
51
|
-
meta={
|
|
52
|
-
<>
|
|
53
|
-
<StatusPill tone="blue">业务台账</StatusPill>
|
|
54
|
-
<StatusPill tone="emerald">权限数据</StatusPill>
|
|
55
|
-
<StatusPill tone="amber">详情联动</StatusPill>
|
|
56
|
-
</>
|
|
57
|
-
}
|
|
58
|
-
title="业务数据"
|
|
59
|
-
/>
|
|
60
|
-
<section className="grid gap-4 md:grid-cols-3">
|
|
61
|
-
<DataSummary icon={<Database size={18} />} label="当前表单" value={formUuid || "--"} />
|
|
62
|
-
<DataSummary icon={<Rows3 size={18} />} label="列表状态" value="可查询" />
|
|
63
|
-
<DataSummary icon={<Filter size={18} />} label="数据范围" value="按权限展示" />
|
|
64
|
-
</section>
|
|
65
|
-
<Panel className="min-w-0 overflow-hidden p-0">
|
|
66
|
-
<div className="min-w-0 overflow-hidden">
|
|
67
|
-
{Override ? (
|
|
68
|
-
<Override
|
|
69
|
-
appType={appType}
|
|
70
|
-
defaultNode={defaultNode}
|
|
71
|
-
formUuid={formUuid}
|
|
72
|
-
kind="data-manage-list"
|
|
73
|
-
/>
|
|
74
|
-
) : (
|
|
75
|
-
defaultNode
|
|
76
|
-
)}
|
|
77
|
-
</div>
|
|
78
|
-
</Panel>
|
|
79
|
-
</div>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function DataSummary({
|
|
84
|
-
icon,
|
|
85
|
-
label,
|
|
86
|
-
value,
|
|
87
|
-
}: {
|
|
88
|
-
icon: ReactNode;
|
|
89
|
-
label: string;
|
|
90
|
-
value: string;
|
|
91
|
-
}) {
|
|
92
|
-
return (
|
|
93
|
-
<div className="rounded-2xl border border-white/70 bg-white/[0.82] p-4 shadow-[0_16px_45px_rgba(15,23,42,0.05)]">
|
|
94
|
-
<div className="flex items-center gap-2 text-sm text-slate-500">
|
|
95
|
-
<span className="text-blue-600">{icon}</span>
|
|
96
|
-
{label}
|
|
97
|
-
</div>
|
|
98
|
-
<div className="mt-2 truncate text-base font-semibold text-slate-950">{value}</div>
|
|
23
|
+
<div className="ox-default-data-route min-w-0 overflow-hidden">
|
|
24
|
+
{Override ? (
|
|
25
|
+
<Override
|
|
26
|
+
appType={appType}
|
|
27
|
+
defaultNode={defaultNode}
|
|
28
|
+
formUuid={formUuid}
|
|
29
|
+
kind="data-manage-list"
|
|
30
|
+
/>
|
|
31
|
+
) : (
|
|
32
|
+
defaultNode
|
|
33
|
+
)}
|
|
99
34
|
</div>
|
|
100
35
|
);
|
|
101
36
|
}
|
|
@@ -5,12 +5,10 @@ import { StandardFormPage } from "openxiangda";
|
|
|
5
5
|
import { normalizeRuntimeFormSchema } from "openxiangda/runtime";
|
|
6
6
|
import { useOpenXiangda, useRuntimeAuth } from "openxiangda/runtime/react";
|
|
7
7
|
|
|
8
|
+
import { useAdminPageMetaController } from "@/layouts/AdminShell";
|
|
8
9
|
import {
|
|
9
|
-
PageHeader,
|
|
10
|
-
Panel,
|
|
11
10
|
PrimaryButton,
|
|
12
11
|
StatePage,
|
|
13
|
-
StatusPill,
|
|
14
12
|
} from "@/shared/ui";
|
|
15
13
|
import {
|
|
16
14
|
defaultPageOverrides,
|
|
@@ -30,6 +28,7 @@ export function FormRoutePage({ mode }: { mode: Mode }) {
|
|
|
30
28
|
const { appType = "", formUuid = "", formInstId = "" } = useParams();
|
|
31
29
|
const navigate = useNavigate();
|
|
32
30
|
const runtime = useOpenXiangda();
|
|
31
|
+
const setAdminPageMeta = useAdminPageMetaController();
|
|
33
32
|
const [schema, setSchema] = useState<any>(null);
|
|
34
33
|
const [error, setError] = useState<PageError | null>(null);
|
|
35
34
|
|
|
@@ -87,6 +86,16 @@ export function FormRoutePage({ mode }: { mode: Mode }) {
|
|
|
87
86
|
? "process-submit"
|
|
88
87
|
: "form-submit";
|
|
89
88
|
const pageCopy = useMemo(() => resolvePageCopy(overrideKind, mode), [mode, overrideKind]);
|
|
89
|
+
const pageTitle = useMemo(
|
|
90
|
+
() => resolvePageTitle(schema, pageCopy.title, overrideKind),
|
|
91
|
+
[overrideKind, pageCopy.title, schema],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!schema) return undefined;
|
|
96
|
+
setAdminPageMeta({ title: pageTitle });
|
|
97
|
+
return () => setAdminPageMeta(null);
|
|
98
|
+
}, [pageTitle, schema, setAdminPageMeta]);
|
|
90
99
|
|
|
91
100
|
if (error) return <DefaultErrorState error={error} />;
|
|
92
101
|
if (!schema) return <DefaultLoadingState description="正在读取页面配置和当前用户权限。" title="正在加载" />;
|
|
@@ -110,31 +119,19 @@ export function FormRoutePage({ mode }: { mode: Mode }) {
|
|
|
110
119
|
);
|
|
111
120
|
|
|
112
121
|
return (
|
|
113
|
-
<div className="min-w-0
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
<Override
|
|
127
|
-
appType={appType}
|
|
128
|
-
defaultNode={defaultNode}
|
|
129
|
-
formInstId={formInstId}
|
|
130
|
-
formUuid={formUuid}
|
|
131
|
-
kind={overrideKind}
|
|
132
|
-
schema={schema}
|
|
133
|
-
/>
|
|
134
|
-
) : (
|
|
135
|
-
defaultNode
|
|
136
|
-
)}
|
|
137
|
-
</Panel>
|
|
122
|
+
<div className="ox-default-form-route min-w-0">
|
|
123
|
+
{Override ? (
|
|
124
|
+
<Override
|
|
125
|
+
appType={appType}
|
|
126
|
+
defaultNode={defaultNode}
|
|
127
|
+
formInstId={formInstId}
|
|
128
|
+
formUuid={formUuid}
|
|
129
|
+
kind={overrideKind}
|
|
130
|
+
schema={schema}
|
|
131
|
+
/>
|
|
132
|
+
) : (
|
|
133
|
+
defaultNode
|
|
134
|
+
)}
|
|
138
135
|
</div>
|
|
139
136
|
);
|
|
140
137
|
}
|
|
@@ -206,6 +203,13 @@ function resolvePageCopy(kind: DefaultPageKind, mode: Mode) {
|
|
|
206
203
|
};
|
|
207
204
|
}
|
|
208
205
|
|
|
206
|
+
function resolvePageTitle(schema: any, fallback: string, kind: DefaultPageKind) {
|
|
207
|
+
const title = String(schema?.formMeta?.title || schema?.title || schema?.template?.title || "").trim();
|
|
208
|
+
if (!title) return fallback;
|
|
209
|
+
if (kind === "form-detail" || kind === "process-detail") return `${title}详情`;
|
|
210
|
+
return title;
|
|
211
|
+
}
|
|
212
|
+
|
|
209
213
|
function createPageError(status: number | undefined, code: number | string | undefined, message: string): PageError {
|
|
210
214
|
const normalizedCode = typeof code === "string" ? Number(code) : code;
|
|
211
215
|
if (status === 401 || normalizedCode === 401) return { message, status, type: "unauthenticated" };
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import ReactECharts from "echarts-for-react";
|
|
2
|
+
import type { EChartsOption } from "echarts";
|
|
3
|
+
import { motion, useReducedMotion } from "framer-motion";
|
|
1
4
|
import type { ComponentType, ReactNode } from "react";
|
|
2
5
|
|
|
3
6
|
export type MacTone =
|
|
@@ -57,6 +60,27 @@ export function cn(...values: Array<string | false | null | undefined>) {
|
|
|
57
60
|
return values.filter(Boolean).join(" ");
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
function useEntranceMotion() {
|
|
64
|
+
const reducedMotion = useReducedMotion();
|
|
65
|
+
if (reducedMotion) return {};
|
|
66
|
+
return {
|
|
67
|
+
animate: { opacity: 1, y: 0 },
|
|
68
|
+
initial: { opacity: 0, y: 10 },
|
|
69
|
+
transition: { duration: 0.32, ease: "easeOut" as const },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function MacChartEmpty({ height }: { height: number }) {
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
className="grid place-items-center rounded-lg bg-slate-50 text-sm text-slate-400"
|
|
77
|
+
style={{ height }}
|
|
78
|
+
>
|
|
79
|
+
暂无图表数据
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
export function MacPageHeader({
|
|
61
85
|
actions,
|
|
62
86
|
description,
|
|
@@ -157,6 +181,301 @@ export function MacMetricCard({
|
|
|
157
181
|
);
|
|
158
182
|
}
|
|
159
183
|
|
|
184
|
+
export function MacDashboardMetricCard({
|
|
185
|
+
caption,
|
|
186
|
+
delta,
|
|
187
|
+
icon: Icon,
|
|
188
|
+
label,
|
|
189
|
+
tone = "blue",
|
|
190
|
+
value,
|
|
191
|
+
}: {
|
|
192
|
+
caption?: ReactNode;
|
|
193
|
+
delta?: ReactNode;
|
|
194
|
+
icon?: ComponentType<{
|
|
195
|
+
className?: string;
|
|
196
|
+
size?: string | number;
|
|
197
|
+
strokeWidth?: string | number;
|
|
198
|
+
}>;
|
|
199
|
+
label: ReactNode;
|
|
200
|
+
tone?: MacTone;
|
|
201
|
+
value: ReactNode;
|
|
202
|
+
}) {
|
|
203
|
+
const classes = toneClasses[tone];
|
|
204
|
+
const motionProps = useEntranceMotion();
|
|
205
|
+
return (
|
|
206
|
+
<motion.div
|
|
207
|
+
{...motionProps}
|
|
208
|
+
className="rounded-xl border border-slate-200/70 bg-white p-4 shadow-[0_10px_28px_rgba(15,23,42,0.045)] transition hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.08)]"
|
|
209
|
+
>
|
|
210
|
+
<div className="flex items-start gap-3">
|
|
211
|
+
<div className={cn("grid h-11 w-11 shrink-0 place-items-center rounded-xl", classes.soft)}>
|
|
212
|
+
{Icon ? <Icon size={21} strokeWidth={2.2} /> : <span className={cn("h-2.5 w-2.5 rounded-full", classes.dot)} />}
|
|
213
|
+
</div>
|
|
214
|
+
<div className="min-w-0 flex-1">
|
|
215
|
+
<div className="text-sm leading-5 text-slate-500">{label}</div>
|
|
216
|
+
<div className="mt-1 text-2xl font-semibold leading-8 text-slate-950">{value}</div>
|
|
217
|
+
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
|
218
|
+
{caption ? <span>{caption}</span> : null}
|
|
219
|
+
{delta ? <span className={classes.text}>{delta}</span> : null}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</motion.div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function MacChartPanel({
|
|
228
|
+
action,
|
|
229
|
+
children,
|
|
230
|
+
className,
|
|
231
|
+
title,
|
|
232
|
+
}: {
|
|
233
|
+
action?: ReactNode;
|
|
234
|
+
children: ReactNode;
|
|
235
|
+
className?: string;
|
|
236
|
+
title: ReactNode;
|
|
237
|
+
}) {
|
|
238
|
+
const motionProps = useEntranceMotion();
|
|
239
|
+
return (
|
|
240
|
+
<motion.section
|
|
241
|
+
{...motionProps}
|
|
242
|
+
className={cn(
|
|
243
|
+
"min-w-0 rounded-xl border border-slate-200/70 bg-white p-4 shadow-[0_12px_34px_rgba(15,23,42,0.045)]",
|
|
244
|
+
className,
|
|
245
|
+
)}
|
|
246
|
+
>
|
|
247
|
+
<div className="mb-3 flex min-w-0 items-center justify-between gap-3">
|
|
248
|
+
<h2 className="truncate text-sm font-semibold text-slate-950">{title}</h2>
|
|
249
|
+
{action ? <div className="shrink-0">{action}</div> : null}
|
|
250
|
+
</div>
|
|
251
|
+
{children}
|
|
252
|
+
</motion.section>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export type MacTrendSeries = {
|
|
257
|
+
color: string;
|
|
258
|
+
data: number[];
|
|
259
|
+
name: string;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export function MacTrendLineChart({
|
|
263
|
+
height = 286,
|
|
264
|
+
labels,
|
|
265
|
+
series,
|
|
266
|
+
}: {
|
|
267
|
+
height?: number;
|
|
268
|
+
labels: string[];
|
|
269
|
+
series: MacTrendSeries[];
|
|
270
|
+
}) {
|
|
271
|
+
if (!labels.length || !series.length) return <MacChartEmpty height={height} />;
|
|
272
|
+
|
|
273
|
+
const option: EChartsOption = {
|
|
274
|
+
color: series.map(item => item.color),
|
|
275
|
+
grid: { bottom: 34, left: 44, right: 18, top: 34 },
|
|
276
|
+
legend: {
|
|
277
|
+
icon: "circle",
|
|
278
|
+
itemHeight: 7,
|
|
279
|
+
itemWidth: 7,
|
|
280
|
+
left: 0,
|
|
281
|
+
textStyle: { color: "#64748b", fontSize: 12 },
|
|
282
|
+
top: 0,
|
|
283
|
+
},
|
|
284
|
+
tooltip: {
|
|
285
|
+
trigger: "axis",
|
|
286
|
+
backgroundColor: "rgba(15, 23, 42, 0.92)",
|
|
287
|
+
borderColor: "transparent",
|
|
288
|
+
textStyle: { color: "#fff" },
|
|
289
|
+
},
|
|
290
|
+
xAxis: {
|
|
291
|
+
type: "category",
|
|
292
|
+
boundaryGap: false,
|
|
293
|
+
data: labels,
|
|
294
|
+
axisLine: { lineStyle: { color: "#e2e8f0" } },
|
|
295
|
+
axisTick: { show: false },
|
|
296
|
+
axisLabel: { color: "#64748b", fontSize: 11 },
|
|
297
|
+
},
|
|
298
|
+
yAxis: {
|
|
299
|
+
type: "value",
|
|
300
|
+
axisLabel: { color: "#64748b", fontSize: 11 },
|
|
301
|
+
splitLine: { lineStyle: { color: "#eef2f7" } },
|
|
302
|
+
},
|
|
303
|
+
series: series.map(item => ({
|
|
304
|
+
name: item.name,
|
|
305
|
+
type: "line",
|
|
306
|
+
smooth: true,
|
|
307
|
+
symbol: "circle",
|
|
308
|
+
symbolSize: 7,
|
|
309
|
+
data: item.data,
|
|
310
|
+
lineStyle: { width: 3 },
|
|
311
|
+
areaStyle: { opacity: 0.08 },
|
|
312
|
+
})),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<ReactECharts
|
|
317
|
+
lazyUpdate
|
|
318
|
+
notMerge
|
|
319
|
+
option={option}
|
|
320
|
+
style={{ height, width: "100%" }}
|
|
321
|
+
/>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export type MacDonutItem = {
|
|
326
|
+
color: string;
|
|
327
|
+
label: string;
|
|
328
|
+
value: number;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
export function MacDonutChart({
|
|
332
|
+
centerLabel,
|
|
333
|
+
centerValue,
|
|
334
|
+
height = 220,
|
|
335
|
+
items,
|
|
336
|
+
}: {
|
|
337
|
+
centerLabel: string;
|
|
338
|
+
centerValue: string;
|
|
339
|
+
height?: number;
|
|
340
|
+
items: MacDonutItem[];
|
|
341
|
+
}) {
|
|
342
|
+
if (!items.length) return <MacChartEmpty height={height} />;
|
|
343
|
+
|
|
344
|
+
const option: EChartsOption = {
|
|
345
|
+
color: items.map(item => item.color),
|
|
346
|
+
graphic: [
|
|
347
|
+
{
|
|
348
|
+
type: "text",
|
|
349
|
+
left: "center",
|
|
350
|
+
top: "42%",
|
|
351
|
+
style: {
|
|
352
|
+
fill: "#0f172a",
|
|
353
|
+
fontSize: 24,
|
|
354
|
+
fontWeight: 700,
|
|
355
|
+
text: centerValue,
|
|
356
|
+
align: "center",
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
type: "text",
|
|
361
|
+
left: "center",
|
|
362
|
+
top: "55%",
|
|
363
|
+
style: {
|
|
364
|
+
fill: "#64748b",
|
|
365
|
+
fontSize: 12,
|
|
366
|
+
text: centerLabel,
|
|
367
|
+
align: "center",
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
series: [
|
|
372
|
+
{
|
|
373
|
+
type: "pie",
|
|
374
|
+
radius: ["62%", "82%"],
|
|
375
|
+
center: ["50%", "50%"],
|
|
376
|
+
avoidLabelOverlap: true,
|
|
377
|
+
label: { show: false },
|
|
378
|
+
labelLine: { show: false },
|
|
379
|
+
data: items.map(item => ({ name: item.label, value: item.value })),
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
tooltip: {
|
|
383
|
+
trigger: "item",
|
|
384
|
+
backgroundColor: "rgba(15, 23, 42, 0.92)",
|
|
385
|
+
borderColor: "transparent",
|
|
386
|
+
textStyle: { color: "#fff" },
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
return (
|
|
391
|
+
<ReactECharts
|
|
392
|
+
lazyUpdate
|
|
393
|
+
notMerge
|
|
394
|
+
option={option}
|
|
395
|
+
style={{ height, width: "100%" }}
|
|
396
|
+
/>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function MacTodoItem({
|
|
401
|
+
icon,
|
|
402
|
+
meta,
|
|
403
|
+
time,
|
|
404
|
+
title,
|
|
405
|
+
tone = "blue",
|
|
406
|
+
}: {
|
|
407
|
+
icon?: ReactNode;
|
|
408
|
+
meta?: ReactNode;
|
|
409
|
+
time?: ReactNode;
|
|
410
|
+
title: ReactNode;
|
|
411
|
+
tone?: MacTone;
|
|
412
|
+
}) {
|
|
413
|
+
const classes = toneClasses[tone];
|
|
414
|
+
return (
|
|
415
|
+
<div className="flex min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition hover:bg-slate-50">
|
|
416
|
+
<div className={cn("grid h-8 w-8 shrink-0 place-items-center rounded-lg", classes.soft)}>
|
|
417
|
+
{icon || <span className={cn("h-2 w-2 rounded-full", classes.dot)} />}
|
|
418
|
+
</div>
|
|
419
|
+
<div className="min-w-0 flex-1">
|
|
420
|
+
<div className="truncate text-sm font-semibold text-slate-950">{title}</div>
|
|
421
|
+
{meta ? <div className="mt-0.5 truncate text-xs text-slate-500">{meta}</div> : null}
|
|
422
|
+
</div>
|
|
423
|
+
{time ? <div className="shrink-0 text-xs text-slate-500">{time}</div> : null}
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function MacActivityItem({
|
|
429
|
+
icon,
|
|
430
|
+
subtitle,
|
|
431
|
+
time,
|
|
432
|
+
title,
|
|
433
|
+
tone = "blue",
|
|
434
|
+
}: {
|
|
435
|
+
icon?: ReactNode;
|
|
436
|
+
subtitle?: ReactNode;
|
|
437
|
+
time?: ReactNode;
|
|
438
|
+
title: ReactNode;
|
|
439
|
+
tone?: MacTone;
|
|
440
|
+
}) {
|
|
441
|
+
const classes = toneClasses[tone];
|
|
442
|
+
return (
|
|
443
|
+
<div className="flex min-w-0 items-start gap-3 py-2">
|
|
444
|
+
<div className={cn("mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-full", classes.soft)}>
|
|
445
|
+
{icon || <span className={cn("h-2 w-2 rounded-full", classes.dot)} />}
|
|
446
|
+
</div>
|
|
447
|
+
<div className="min-w-0 flex-1">
|
|
448
|
+
<div className="truncate text-sm font-semibold text-slate-950">{title}</div>
|
|
449
|
+
{subtitle ? <div className="mt-0.5 truncate text-xs text-slate-500">{subtitle}</div> : null}
|
|
450
|
+
</div>
|
|
451
|
+
{time ? <div className="shrink-0 text-xs text-slate-500">{time}</div> : null}
|
|
452
|
+
</div>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function MacEnvironmentRow({
|
|
457
|
+
label,
|
|
458
|
+
status,
|
|
459
|
+
value,
|
|
460
|
+
}: {
|
|
461
|
+
label: ReactNode;
|
|
462
|
+
status?: "success" | "warning" | "default";
|
|
463
|
+
value: ReactNode;
|
|
464
|
+
}) {
|
|
465
|
+
const statusClass =
|
|
466
|
+
status === "success"
|
|
467
|
+
? "text-emerald-600"
|
|
468
|
+
: status === "warning"
|
|
469
|
+
? "text-amber-600"
|
|
470
|
+
: "text-slate-600";
|
|
471
|
+
return (
|
|
472
|
+
<div className="flex min-w-0 items-center justify-between gap-3 border-b border-slate-100 py-2.5 last:border-0">
|
|
473
|
+
<div className="truncate text-sm text-slate-500">{label}</div>
|
|
474
|
+
<div className={cn("shrink-0 truncate text-right text-sm font-semibold", statusClass)}>{value}</div>
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
160
479
|
export function MacStatusPill({
|
|
161
480
|
children,
|
|
162
481
|
tone = "slate",
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export {
|
|
2
|
+
MacActivityItem as ActivityItem,
|
|
3
|
+
MacChartPanel as ChartPanel,
|
|
4
|
+
MacDashboardMetricCard as DashboardMetricCard,
|
|
5
|
+
MacDonutChart as DonutChart,
|
|
6
|
+
MacEnvironmentRow as EnvironmentRow,
|
|
2
7
|
MacListItem as ListItem,
|
|
3
8
|
MacMetricCard as MetricCard,
|
|
4
9
|
MacPageHeader as PageHeader,
|
|
@@ -7,7 +12,11 @@ export {
|
|
|
7
12
|
MacSecondaryButton as SecondaryButton,
|
|
8
13
|
MacStatePage as StatePage,
|
|
9
14
|
MacStatusPill as StatusPill,
|
|
15
|
+
MacTodoItem as TodoItem,
|
|
16
|
+
MacTrendLineChart as TrendLineChart,
|
|
10
17
|
MacTrendBars as TrendBars,
|
|
11
18
|
cn,
|
|
19
|
+
type MacDonutItem as DonutItem,
|
|
12
20
|
type MacTone as AppTone,
|
|
21
|
+
type MacTrendSeries as TrendSeries,
|
|
13
22
|
} from "./mac-admin";
|
|
@@ -59,3 +59,47 @@ a {
|
|
|
59
59
|
.ox-scrollbar::-webkit-scrollbar-track {
|
|
60
60
|
background: transparent;
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
.ox-admin-content :where(.sy-runtime-page) {
|
|
64
|
+
min-height: auto;
|
|
65
|
+
background: transparent;
|
|
66
|
+
color: inherit;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.ox-admin-content :where(.sy-runtime-page__content) {
|
|
70
|
+
max-width: 100% !important;
|
|
71
|
+
padding: 0 !important;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.ox-admin-content :where(.sy-submit-page) {
|
|
75
|
+
gap: 0;
|
|
76
|
+
min-height: auto;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.ox-admin-content :where(.sy-submit-card, .sy-detail-card) {
|
|
80
|
+
border: 1px solid rgb(226 232 240 / 0.86);
|
|
81
|
+
border-radius: 20px;
|
|
82
|
+
background: rgb(255 255 255 / 0.9);
|
|
83
|
+
box-shadow: 0 18px 55px rgb(15 23 42 / 0.06);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ox-admin-content :where(.sy-submit-card) {
|
|
87
|
+
min-height: auto;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.ox-admin-content :where(.sy-detail-main) {
|
|
91
|
+
max-width: 100%;
|
|
92
|
+
padding: 0 0 32px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.ox-admin-content :where(.sy-detail-header) {
|
|
96
|
+
display: none;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.ox-admin-content :where(.sy-detail-page) {
|
|
100
|
+
background: transparent;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.ox-default-data-route :where(.sy-data-management-list, .sy-runtime-page) {
|
|
104
|
+
min-width: 0;
|
|
105
|
+
}
|