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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.73",
3
+ "version": "1.0.75",
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
+ };
@@ -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-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")}>
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-10 w-full items-center rounded-lg text-[13px] font-semibold text-slate-800 transition hover:bg-slate-50",
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 && "sr-only")}>{group.title}</span>
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
- <Menu className="text-slate-500" size={16} strokeWidth={2} />
165
+ null
129
166
  ) : collapsed ? (
130
- <ChevronRight className="text-slate-500" size={16} strokeWidth={2} />
167
+ <ChevronRight className="shrink-0 text-slate-500" size={15} strokeWidth={2} />
131
168
  ) : (
132
- <ChevronDown className="text-slate-500" size={16} strokeWidth={2} />
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-1", compact && "mt-2")}>
173
+ <div className={cn("mt-1 space-y-0.5", compact && "mt-1")}>
137
174
  {group.items.map(item => {
138
- const active = isMenuActive(location.pathname, location.search, item.path);
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-10 items-center rounded-lg text-sm transition",
144
- compact ? "justify-center px-0" : "gap-3 px-3",
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-3 top-1 h-8 w-1 rounded-r-full transition",
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={17} strokeWidth={2} />
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">首页 / 应用</div>
245
- <div className="mt-1 truncate text-lg font-semibold text-slate-950">{appName}</div>
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
- <div className="mx-auto min-w-0 max-w-[1440px] px-4 py-5 sm:px-6">
300
- <Outlet />
301
- </div>
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 { 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,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 space-y-5">
33
- <PageHeader
34
- actions={
35
- <>
36
- <SecondaryButton>
37
- <Filter size={17} />
38
- 筛选
39
- </SecondaryButton>
40
- <SecondaryButton onClick={() => window.location.reload()}>
41
- <RefreshCw size={17} />
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 space-y-5">
114
- <PageHeader
115
- description={pageCopy.description}
116
- meta={
117
- <>
118
- <StatusPill tone={isProcessForm ? "amber" : "blue"}>{isProcessForm ? "流程办理" : "业务申请"}</StatusPill>
119
- <StatusPill tone="emerald">权限已校验</StatusPill>
120
- </>
121
- }
122
- title={pageCopy.title}
123
- />
124
- <Panel className="min-w-0 overflow-hidden">
125
- {Override ? (
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
+ }