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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.74",
3
+ "version": "1.0.76",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -25,6 +25,9 @@
25
25
  "antd": "^6.3.7",
26
26
  "antd-mobile": "^5.37.0",
27
27
  "dayjs": "^1.11.20",
28
+ "echarts": "^6.1.0",
29
+ "echarts-for-react": "^3.0.6",
30
+ "framer-motion": "^12.40.0",
28
31
  "lucide-react": "^0.468.0",
29
32
  "openxiangda": "latest",
30
33
  "react": "18.3.1",
@@ -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-5", compact ? "px-2" : "space-y-7 px-3")}>
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-10 w-full items-center rounded-lg text-[13px] font-semibold text-slate-800 transition hover:bg-slate-50",
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 && "sr-only")}>{group.title}</span>
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
- <Menu className="text-slate-500" size={16} strokeWidth={2} />
156
- ) : collapsed ? (
157
- <ChevronRight className="text-slate-500" size={16} strokeWidth={2} />
166
+ null
158
167
  ) : (
159
- <ChevronDown className="text-slate-500" size={16} strokeWidth={2} />
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
- {!collapsed ? (
163
- <div className={cn("mt-1 space-y-1", compact && "mt-2")}>
164
- {group.items.map(item => {
165
- const active = navigationState.activePath === item.path;
166
- return (
167
- <Link
168
- aria-label={item.name}
169
- className={cn(
170
- "group relative flex h-10 items-center rounded-lg text-sm transition",
171
- compact ? "justify-center px-0" : "gap-3 px-3",
172
- active
173
- ? "bg-[#eef6ff] text-[#1677ff]"
174
- : "text-slate-600 hover:bg-slate-50 hover:text-slate-900",
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
- "grid h-5 w-5 shrink-0 place-items-center transition",
190
- active ? "text-[#1677ff]" : "text-slate-500 group-hover:text-slate-700",
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
- <item.icon size={17} strokeWidth={2} />
194
- </span>
195
- <span className={cn("min-w-0 flex-1 truncate font-medium", compact && "sr-only")}>{item.name}</span>
196
- </Link>
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
- ) : null}
223
+ </div>
201
224
  </section>
202
225
  );
203
226
  })}
@@ -1,27 +1,170 @@
1
- import { Home } from "lucide-react";
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 { PageHeader, Panel, StatusPill } from "@/shared/ui";
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-5">
8
- <PageHeader
9
- description="这是应用默认首页。你可以在这里接入真实业务模块、数据看板或常用操作。"
10
- meta={<StatusPill tone="blue">默认首页</StatusPill>}
11
- title="首页"
12
- />
13
-
14
- <Panel className="min-h-[420px]">
15
- <div className="flex min-h-[360px] flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-white/60 px-6 text-center">
16
- <div className="grid h-14 w-14 place-items-center rounded-2xl bg-blue-50 text-blue-700 ring-1 ring-blue-100">
17
- <Home size={24} />
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
- <h2 className="mt-5 text-lg font-semibold text-slate-950">默认首页</h2>
20
- <p className="mt-2 max-w-md text-sm leading-6 text-slate-500">
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";