openxiangda 1.0.74 → 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 +23 -13
- 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,39 +136,49 @@ 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}
|
|
146
148
|
className={cn(
|
|
147
|
-
"flex h-
|
|
148
|
-
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",
|
|
149
152
|
)}
|
|
150
153
|
onClick={() => setCollapsedGroups(prev => ({ ...prev, [group.title]: !collapsed }))}
|
|
151
154
|
type="button"
|
|
152
155
|
>
|
|
153
|
-
<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>
|
|
154
164
|
{compact ? (
|
|
155
|
-
|
|
165
|
+
null
|
|
156
166
|
) : collapsed ? (
|
|
157
|
-
<ChevronRight className="text-slate-500" size={
|
|
167
|
+
<ChevronRight className="shrink-0 text-slate-500" size={15} strokeWidth={2} />
|
|
158
168
|
) : (
|
|
159
|
-
<ChevronDown className="text-slate-500" size={
|
|
169
|
+
<ChevronDown className="shrink-0 text-slate-500" size={15} strokeWidth={2} />
|
|
160
170
|
)}
|
|
161
171
|
</button>
|
|
162
172
|
{!collapsed ? (
|
|
163
|
-
<div className={cn("mt-1 space-y-
|
|
173
|
+
<div className={cn("mt-1 space-y-0.5", compact && "mt-1")}>
|
|
164
174
|
{group.items.map(item => {
|
|
165
175
|
const active = navigationState.activePath === item.path;
|
|
166
176
|
return (
|
|
167
177
|
<Link
|
|
168
178
|
aria-label={item.name}
|
|
169
179
|
className={cn(
|
|
170
|
-
"group relative flex h-
|
|
171
|
-
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",
|
|
172
182
|
active
|
|
173
183
|
? "bg-[#eef6ff] text-[#1677ff]"
|
|
174
184
|
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900",
|
|
@@ -179,7 +189,7 @@ export function AdminShell() {
|
|
|
179
189
|
>
|
|
180
190
|
<span
|
|
181
191
|
className={cn(
|
|
182
|
-
"absolute -left-
|
|
192
|
+
"absolute -left-2.5 top-1 h-7 w-1 rounded-r-full transition",
|
|
183
193
|
active ? "bg-[#1677ff]" : "bg-transparent",
|
|
184
194
|
compact && "-left-2",
|
|
185
195
|
)}
|
|
@@ -190,7 +200,7 @@ export function AdminShell() {
|
|
|
190
200
|
active ? "text-[#1677ff]" : "text-slate-500 group-hover:text-slate-700",
|
|
191
201
|
)}
|
|
192
202
|
>
|
|
193
|
-
<item.icon size={
|
|
203
|
+
<item.icon size={16} strokeWidth={2} />
|
|
194
204
|
</span>
|
|
195
205
|
<span className={cn("min-w-0 flex-1 truncate font-medium", compact && "sr-only")}>{item.name}</span>
|
|
196
206
|
</Link>
|
|
@@ -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";
|