openxiangda 1.0.74 → 1.0.76
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 +67 -44
- package/templates/openxiangda-react-spa/src/pages/admin/AdminDashboardPage.tsx +162 -19
- package/templates/openxiangda-react-spa/src/shared/mac-admin.tsx +319 -0
- package/templates/openxiangda-react-spa/src/shared/ui.tsx +9 -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
|
+
};
|
|
@@ -136,68 +136,91 @@ export function AdminShell() {
|
|
|
136
136
|
</div>
|
|
137
137
|
</div>
|
|
138
138
|
|
|
139
|
-
<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")}>
|
|
140
140
|
{groups.map(group => {
|
|
141
141
|
const collapsed = collapsedGroups[group.title];
|
|
142
|
+
const groupActive = group.items.some(item => navigationState.activePath === item.path);
|
|
143
|
+
const GroupIcon = group.icon;
|
|
142
144
|
return (
|
|
143
|
-
<section key={group.title}>
|
|
145
|
+
<section className="min-w-0" key={group.title}>
|
|
144
146
|
<button
|
|
145
147
|
aria-label={group.title}
|
|
148
|
+
aria-expanded={!collapsed}
|
|
146
149
|
className={cn(
|
|
147
|
-
"flex h-
|
|
148
|
-
compact ? "justify-center px-0" : "justify-between px-2.5",
|
|
150
|
+
"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",
|
|
151
|
+
compact ? "justify-center px-0" : "justify-between gap-2 px-2.5",
|
|
152
|
+
groupActive && "bg-slate-50 text-slate-950",
|
|
149
153
|
)}
|
|
150
154
|
onClick={() => setCollapsedGroups(prev => ({ ...prev, [group.title]: !collapsed }))}
|
|
151
155
|
type="button"
|
|
152
156
|
>
|
|
153
|
-
<span className={cn(compact && "
|
|
157
|
+
<span className={cn("flex min-w-0 items-center gap-2", compact && "justify-center")}>
|
|
158
|
+
<GroupIcon
|
|
159
|
+
className={cn(groupActive ? "text-blue-600" : "text-slate-500")}
|
|
160
|
+
size={15}
|
|
161
|
+
strokeWidth={2}
|
|
162
|
+
/>
|
|
163
|
+
<span className={cn("truncate", compact && "sr-only")}>{group.title}</span>
|
|
164
|
+
</span>
|
|
154
165
|
{compact ? (
|
|
155
|
-
|
|
156
|
-
) : collapsed ? (
|
|
157
|
-
<ChevronRight className="text-slate-500" size={16} strokeWidth={2} />
|
|
166
|
+
null
|
|
158
167
|
) : (
|
|
159
|
-
<ChevronDown
|
|
168
|
+
<ChevronDown
|
|
169
|
+
className={cn(
|
|
170
|
+
"shrink-0 text-slate-500 transition-transform duration-200 ease-out motion-reduce:transition-none",
|
|
171
|
+
collapsed && "-rotate-90",
|
|
172
|
+
)}
|
|
173
|
+
size={15}
|
|
174
|
+
strokeWidth={2}
|
|
175
|
+
/>
|
|
160
176
|
)}
|
|
161
177
|
</button>
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
)}
|
|
176
|
-
key={`${group.title}-${item.name}-${item.path}`}
|
|
177
|
-
onClick={() => setMobileOpen(false)}
|
|
178
|
-
to={item.path}
|
|
179
|
-
>
|
|
180
|
-
<span
|
|
181
|
-
className={cn(
|
|
182
|
-
"absolute -left-3 top-1 h-8 w-1 rounded-r-full transition",
|
|
183
|
-
active ? "bg-[#1677ff]" : "bg-transparent",
|
|
184
|
-
compact && "-left-2",
|
|
185
|
-
)}
|
|
186
|
-
/>
|
|
187
|
-
<span
|
|
178
|
+
<div
|
|
179
|
+
className={cn(
|
|
180
|
+
"grid transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",
|
|
181
|
+
collapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
|
|
182
|
+
)}
|
|
183
|
+
>
|
|
184
|
+
<div className={cn("min-h-0 overflow-hidden", collapsed && "pointer-events-none")}>
|
|
185
|
+
<div className={cn("mt-1 space-y-0.5", compact && "mt-1")}>
|
|
186
|
+
{group.items.map(item => {
|
|
187
|
+
const active = navigationState.activePath === item.path;
|
|
188
|
+
return (
|
|
189
|
+
<Link
|
|
190
|
+
aria-label={item.name}
|
|
188
191
|
className={cn(
|
|
189
|
-
"
|
|
190
|
-
|
|
192
|
+
"group relative flex h-9 items-center rounded-xl text-sm transition",
|
|
193
|
+
compact ? "justify-center px-0" : "gap-2.5 pl-7 pr-3",
|
|
194
|
+
active
|
|
195
|
+
? "bg-[#eef6ff] text-[#1677ff]"
|
|
196
|
+
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900",
|
|
191
197
|
)}
|
|
198
|
+
key={`${group.title}-${item.name}-${item.path}`}
|
|
199
|
+
onClick={() => setMobileOpen(false)}
|
|
200
|
+
to={item.path}
|
|
192
201
|
>
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
202
|
+
<span
|
|
203
|
+
className={cn(
|
|
204
|
+
"absolute -left-2.5 top-1 h-7 w-1 rounded-r-full transition",
|
|
205
|
+
active ? "bg-[#1677ff]" : "bg-transparent",
|
|
206
|
+
compact && "-left-2",
|
|
207
|
+
)}
|
|
208
|
+
/>
|
|
209
|
+
<span
|
|
210
|
+
className={cn(
|
|
211
|
+
"grid h-5 w-5 shrink-0 place-items-center transition",
|
|
212
|
+
active ? "text-[#1677ff]" : "text-slate-500 group-hover:text-slate-700",
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
<item.icon size={16} strokeWidth={2} />
|
|
216
|
+
</span>
|
|
217
|
+
<span className={cn("min-w-0 flex-1 truncate font-medium", compact && "sr-only")}>{item.name}</span>
|
|
218
|
+
</Link>
|
|
219
|
+
);
|
|
220
|
+
})}
|
|
221
|
+
</div>
|
|
199
222
|
</div>
|
|
200
|
-
|
|
223
|
+
</div>
|
|
201
224
|
</section>
|
|
202
225
|
);
|
|
203
226
|
})}
|
|
@@ -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,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";
|