|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { definePage } from '@objectstack/spec/ui'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Operations Command Center (大屏) — a dense, full-bleed "big screen" built as a |
| 7 | + * pure SDUI micro-page that FOLLOWS the console theme (light ↔ dark). |
| 8 | + * |
| 9 | + * Density: object-chart's ChartContainer is `h-[350px]` by default, but it |
| 10 | + * honours a consumer className (→ AdvancedChartImpl → ChartContainer), so each |
| 11 | + * chart node carries a `responsiveStyles` height that shrinks it toward the |
| 12 | + * ~280px floor. Compact title + KPI strip + tight 2-col rows then fit the whole |
| 13 | + * board — KPIs, five charts and the work queue — without a long scroll. |
| 14 | + * |
| 15 | + * Theme-adaptive: every colour is a theme token (`hsl(var(--card))` panels, |
| 16 | + * `hsl(var(--foreground))` text, `hsl(var(--border))`). The root overrides |
| 17 | + * `--chart-1..5` with one cohesive mid-lightness ramp (reads on light AND dark); |
| 18 | + * KPI numbers reuse it. |
| 19 | + * |
| 20 | + * Layout note: object-chart must sit in a `display: block` panel, and the root |
| 21 | + * column needs `align-items: stretch` or children shrink to content width. |
| 22 | + */ |
| 23 | + |
| 24 | +const CHART_RAMP = { |
| 25 | + '--chart-1': '192 86% 46%', |
| 26 | + '--chart-2': '214 84% 56%', |
| 27 | + '--chart-3': '256 72% 62%', |
| 28 | + '--chart-4': '168 76% 42%', |
| 29 | + '--chart-5': '322 72% 56%', |
| 30 | +}; |
| 31 | + |
| 32 | +function head(id: string, title: string, accent: string, badge?: string): any { |
| 33 | + return { |
| 34 | + id: id + '_h', type: 'flex', |
| 35 | + responsiveStyles: { large: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px', paddingBottom: '7px', borderBottom: '1px solid hsl(var(--border))' } }, |
| 36 | + properties: { |
| 37 | + children: [ |
| 38 | + { id: id + '_bar', type: 'flex', responsiveStyles: { large: { width: '4px', height: '13px', borderRadius: '2px', background: accent, boxShadow: '0 0 9px ' + accent } }, properties: { children: [] } }, |
| 39 | + { id: id + '_t', type: 'element:text', responsiveStyles: { large: { fontSize: '13px', fontWeight: '700', letterSpacing: '0.03em', color: 'hsl(var(--foreground))' } }, properties: { content: title } }, |
| 40 | + ...(badge |
| 41 | + ? [ |
| 42 | + { id: id + '_sp', type: 'flex', responsiveStyles: { large: { flex: '1 1 auto' } }, properties: { children: [] } }, |
| 43 | + { id: id + '_b', type: 'element:text', responsiveStyles: { large: { fontSize: '10px', fontWeight: '700', color: 'hsl(var(--chart-2))', background: 'hsl(var(--chart-2) / 0.12)', border: '1px solid hsl(var(--chart-2) / 0.4)', borderRadius: '999px', padding: '2px 9px' } }, properties: { content: badge } }, |
| 44 | + ] |
| 45 | + : []), |
| 46 | + ], |
| 47 | + }, |
| 48 | + }; |
| 49 | +} |
| 50 | + |
| 51 | +function panel(o: { id: string; title?: string; accent: string; badge?: string; minHeight?: string; span?: string; pad?: string; child: any }): any { |
| 52 | + return { |
| 53 | + id: o.id, type: 'flex', |
| 54 | + responsiveStyles: { |
| 55 | + large: { |
| 56 | + display: 'block', minWidth: '0', minHeight: o.minHeight ?? '0px', |
| 57 | + ...(o.span ? { gridColumn: o.span } : {}), |
| 58 | + padding: o.pad ?? '12px 14px 12px', borderRadius: '14px', |
| 59 | + background: 'hsl(var(--card))', |
| 60 | + border: '1px solid hsl(var(--border))', |
| 61 | + boxShadow: '0 14px 34px -24px rgba(2,6,23,0.45), inset 0 1px 0 hsl(var(--foreground) / 0.04)', |
| 62 | + }, |
| 63 | + small: { padding: '12px' }, |
| 64 | + }, |
| 65 | + properties: { children: [...(o.title ? [head(o.id, o.title, o.accent, o.badge)] : []), o.child] }, |
| 66 | + }; |
| 67 | +} |
| 68 | + |
| 69 | +function band(id: string, cols: number, children: any[], gap = '12px'): any { |
| 70 | + return { |
| 71 | + id, type: 'flex', |
| 72 | + responsiveStyles: { |
| 73 | + large: { display: 'grid', gridTemplateColumns: 'repeat(' + cols + ', minmax(0,1fr))', gap, alignItems: 'stretch' }, |
| 74 | + medium: { gridTemplateColumns: 'repeat(2, minmax(0,1fr))' }, |
| 75 | + small: { gridTemplateColumns: '1fr' }, |
| 76 | + }, |
| 77 | + properties: { children }, |
| 78 | + }; |
| 79 | +} |
| 80 | + |
| 81 | +function stat(id: string, value: string, label: string, chartVar: string): any { |
| 82 | + return { |
| 83 | + id, type: 'flex', |
| 84 | + responsiveStyles: { large: { display: 'flex', flexDirection: 'column', gap: '1px', padding: '2px 4px' } }, |
| 85 | + properties: { |
| 86 | + children: [ |
| 87 | + { id: id + '_v', type: 'element:text', responsiveStyles: { large: { fontSize: '30px', fontWeight: '800', lineHeight: '1.05', color: 'hsl(var(' + chartVar + '))', fontVariantNumeric: 'tabular-nums' } }, properties: { content: value } }, |
| 88 | + { id: id + '_l', type: 'element:text', responsiveStyles: { large: { fontSize: '11px', fontWeight: '500', letterSpacing: '0.04em', color: 'hsl(var(--muted-foreground))' } }, properties: { content: label } }, |
| 89 | + ], |
| 90 | + }, |
| 91 | + }; |
| 92 | +} |
| 93 | + |
| 94 | +// Each chart node carries a height → shrinks the ChartContainer toward its |
| 95 | +// ~280px floor (denser than the default h-[350px]). |
| 96 | +function chart(id: string, chartType: string, dataset: string, dimensions: string[], values: string[]): any { |
| 97 | + return { id, type: 'object-chart', responsiveStyles: { large: { width: '100%', minWidth: '0', height: '200px' } }, properties: { dataset, dimensions, values, chartType } }; |
| 98 | +} |
| 99 | + |
| 100 | +const A = { c1: 'hsl(var(--chart-1))', c2: 'hsl(var(--chart-2))', c3: 'hsl(var(--chart-3))', c4: 'hsl(var(--chart-4))', c5: 'hsl(var(--chart-5))' }; |
| 101 | + |
| 102 | +export const CommandCenterPage = definePage({ |
| 103 | + name: 'showcase_command_center', |
| 104 | + label: 'Command Center (大屏)', |
| 105 | + type: 'app', |
| 106 | + template: 'default', |
| 107 | + kind: 'full', |
| 108 | + isDefault: false, |
| 109 | + regions: [ |
| 110 | + { |
| 111 | + name: 'main', |
| 112 | + width: 'full', |
| 113 | + components: [ |
| 114 | + { |
| 115 | + id: 'cc_root', |
| 116 | + type: 'flex', |
| 117 | + responsiveStyles: { |
| 118 | + large: { |
| 119 | + ...CHART_RAMP, |
| 120 | + minHeight: '100%', width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: '10px', |
| 121 | + padding: '10px 20px 18px', |
| 122 | + background: |
| 123 | + 'radial-gradient(1200px 520px at 50% -16%, hsl(var(--chart-1) / 0.10) 0%, transparent 60%), ' + |
| 124 | + 'hsl(var(--background))', |
| 125 | + color: 'hsl(var(--foreground))', |
| 126 | + }, |
| 127 | + small: { padding: '12px', gap: '10px' }, |
| 128 | + }, |
| 129 | + properties: { |
| 130 | + children: [ |
| 131 | + // ── Title (compact) ───────────────────────────────────────── |
| 132 | + { |
| 133 | + id: 'cc_titlebar', type: 'flex', |
| 134 | + responsiveStyles: { large: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px', padding: '0' } }, |
| 135 | + properties: { |
| 136 | + children: [ |
| 137 | + { id: 'cc_title', type: 'element:text', responsiveStyles: { large: { fontSize: '23px', fontWeight: '800', letterSpacing: '0.36em', color: 'hsl(var(--foreground))', textShadow: '0 0 22px hsl(var(--chart-1) / 0.45)' }, small: { fontSize: '17px', letterSpacing: '0.14em' } }, properties: { content: '交 付 运 营 数 据 大 屏' } }, |
| 138 | + { id: 'cc_subtitle', type: 'element:text', responsiveStyles: { large: { fontSize: '10px', fontWeight: '600', letterSpacing: '0.34em', color: 'hsl(var(--muted-foreground))' } }, properties: { content: 'DELIVERY OPERATIONS · COMMAND CENTER' } }, |
| 139 | + ], |
| 140 | + }, |
| 141 | + }, |
| 142 | + |
| 143 | + // ── KPI hero strip — 6 metrics across the full width ───────── |
| 144 | + panel({ |
| 145 | + id: 'cc_kpi', accent: A.c3, pad: '10px 16px', |
| 146 | + child: band('cc_kpi_grid', 6, [ |
| 147 | + stat('cc_k1', '5', '项目 Projects', '--chart-1'), |
| 148 | + stat('cc_k2', '10', '任务 Tasks', '--chart-2'), |
| 149 | + stat('cc_k3', '13', '客户 Accounts', '--chart-3'), |
| 150 | + stat('cc_k4', '8', '待办 Open', '--chart-4'), |
| 151 | + stat('cc_k5', '2', '复审 Review', '--chart-5'), |
| 152 | + stat('cc_k6', '1.09M', '预算 Budget', '--chart-1'), |
| 153 | + ], '8px'), |
| 154 | + }), |
| 155 | + |
| 156 | + // ── Row 1 — trend (wide) + status ─────────────────────────── |
| 157 | + band('cc_r1', 3, [ |
| 158 | + panel({ id: 'cc_throughput', title: '任务吞吐趋势 (月)', accent: A.c2, span: 'span 2', child: chart('cc_thr_c', 'area', 'showcase_task_metrics', ['created_at'], ['task_count']) }), |
| 159 | + panel({ id: 'cc_status', title: '任务状态分布', accent: A.c1, child: chart('cc_status_c', 'bar', 'showcase_task_metrics', ['status'], ['task_count']) }), |
| 160 | + ]), |
| 161 | + |
| 162 | + // ── Row 2 — three charts ──────────────────────────────────── |
| 163 | + band('cc_r2', 3, [ |
| 164 | + panel({ id: 'cc_priority', title: '优先级分布', accent: A.c5, child: chart('cc_pri_c', 'bar', 'showcase_task_metrics', ['priority'], ['task_count']) }), |
| 165 | + panel({ id: 'cc_budget', title: '预算 vs 支出 (按客户)', accent: A.c4, child: chart('cc_bud_c', 'bar', 'showcase_project_metrics', ['account'], ['budget_sum', 'spent_sum']) }), |
| 166 | + panel({ id: 'cc_health', title: '项目健康度', accent: A.c4, child: chart('cc_health_c', 'donut', 'showcase_project_metrics', ['health'], ['project_count']) }), |
| 167 | + ]), |
| 168 | + |
| 169 | + // ── Row 3 — work queue, full width, compact ───────────────── |
| 170 | + panel({ |
| 171 | + id: 'cc_queue', title: '待审核列表 · Work Queue', accent: A.c1, badge: '审批中', pad: '12px 14px 8px', |
| 172 | + child: { id: 'cc_queue_g', type: 'object-grid', responsiveStyles: { large: { minWidth: '0', display: 'block' } }, properties: { objectName: 'showcase_task', columns: ['title', 'project', 'status', 'priority', 'due_date'] } }, |
| 173 | + }), |
| 174 | + ], |
| 175 | + }, |
| 176 | + }, |
| 177 | + ], |
| 178 | + }, |
| 179 | + ], |
| 180 | +}); |
0 commit comments