init: 羚牛车辆数据中心原型 + 部署配置
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful

- React 18 + Babel-in-browser SPA 原型,覆盖 8 个画板:
  实时地图 / 车辆详情 / 历史查询 / 轨迹回放 / 事件规则 / 通知中心 / ESG 碳减排 / 移动端
- 设计系统:IBM Plex Sans + JetBrains Mono,亮/暗双主题,羚牛绿 #007143
- 数据模型:12 + 40 辆车,TBOX (T) / JT808+1078 (JT) / 双源 (B)
- 部署:nginx 静态托管,Dockerfile + woodpecker.yml + docker-compose.yml
- 镜像:harbor.lnh2e.com/lingniu-v1/ln-vdc:<branch>-<VERSION>
- 容器端口 80,宿主映射 8112,含 /healthz 探活
This commit is contained in:
kkfluous
2026-04-28 15:12:32 +08:00
commit b2d0016a0d
59 changed files with 6938 additions and 0 deletions

View File

@@ -0,0 +1 @@
{"sections":{"detail":{"title":"② 单车详情"},"playback":{"title":"④ 轨迹回放"}}}

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
# 大体积、本地开发资源不进镜像
screenshots/
uploads/pasted-*.png
uploads/472ff2cd-*.png
.design-canvas.state.json
.DS_Store
.git/
.gitignore
*.md
node_modules/
Dockerfile
docker-compose.yml
woodpecker.yml
.dockerignore

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.DS_Store
.idea/
.vscode/
node_modules/
*.log
*.tmp

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# 第一阶段:准备静态资源(与 ln-bi 保持两阶段结构对称,便于后续接入构建工具)
FROM alpine:3.20 AS builder
WORKDIR /app
COPY . .
# 清理不需要进镜像的本地开发产物(.dockerignore 已过滤大头,这里再兜底一次)
RUN rm -rf screenshots .design-canvas.state.json .DS_Store \
&& find uploads -name 'pasted-*.png' -delete 2>/dev/null || true \
&& find uploads -name '472ff2cd-*.png' -delete 2>/dev/null || true
# 第二阶段nginx 静态托管
FROM nginx:1.27-alpine
# 拷贝 nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 拷贝静态资源
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY --from=builder /app/ ./
# 创建 index.html 软链 → 羚牛车辆数据中心.html避免中文 URL 编码问题)
RUN ln -sf "羚牛车辆数据中心.html" index.html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.0.0

257
app.jsx Normal file
View File

@@ -0,0 +1,257 @@
// app.jsx — SPA router + responsive shell for 羚牛车辆数据中心
// hash routes: #/, #/overview, #/detail, #/history, #/playback, #/alarm, #/inbox, #/esg, #/canvas
const ROUTES = [
{ path: "overview", icon: "map", label: "实时地图", crumbs: ["羚牛车辆数据中心", "实时监控", "总览"], component: "ArtboardOverview" },
{ path: "detail", icon: "car", label: "车辆详情", crumbs: ["羚牛车辆数据中心", "实时监控", "单车详情"], component: "ArtboardDetail" },
{ path: "history", icon: "history", label: "历史查询", crumbs: ["羚牛车辆数据中心", "数据分析", "历史查询"], component: "ArtboardHistory" },
{ path: "playback", icon: "route", label: "轨迹回放", crumbs: ["羚牛车辆数据中心", "数据分析", "轨迹回放"], component: "ArtboardPlayback" },
{ path: "alarm", icon: "bell", label: "事件规则", crumbs: ["羚牛车辆数据中心", "事件中心", "规则编排"], component: "ArtboardAlarm" },
{ path: "inbox", icon: "inbox", label: "通知中心", crumbs: ["羚牛车辆数据中心", "事件中心", "通知中心"], component: "ArtboardInbox" },
];
const SUB_ROUTES = [
{ path: "esg", icon: "chart", label: "ESG·碳减排", crumbs: ["羚牛车辆数据中心", "运营分析", "ESG驾驶舱"], component: "ArtboardESG" },
{ path: "canvas", icon: "settings", label: "设计画板", crumbs: ["羚牛车辆数据中心", "设计画板"], component: "DesignCanvasMode" },
];
const ALL_ROUTES = [...ROUTES, ...SUB_ROUTES];
const DEFAULT_ROUTE = "overview";
// ── Hash router hook ────────────────────────────────────────
const useHashRoute = () => {
const parse = () => {
const h = (window.location.hash || "").replace(/^#\/?/, "").split("?")[0];
return h || DEFAULT_ROUTE;
};
const [route, setRoute] = React.useState(parse());
React.useEffect(() => {
const onHash = () => setRoute(parse());
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
const navigate = (p) => { window.location.hash = "#/" + p; };
return [route, navigate];
};
// ── Viewport hook ───────────────────────────────────────────
const useIsMobile = () => {
const [m, setM] = React.useState(() => window.innerWidth < 900);
React.useEffect(() => {
const on = () => setM(window.innerWidth < 900);
window.addEventListener('resize', on);
return () => window.removeEventListener('resize', on);
}, []);
return m;
};
// ── Responsive sidebar (desktop rail / mobile drawer) ───────
const RouterSidebar = ({ active, onNavigate, isMobile, drawerOpen, onCloseDrawer }) => {
const renderItem = (i) => (
<div
key={i.path}
className={"nav-item" + (i.path === active ? " active" : "")}
title={i.label}
onClick={() => { onNavigate(i.path); onCloseDrawer && onCloseDrawer(); }}
style={isMobile ? {width:"100%", height:44, display:"flex", justifyContent:"flex-start", padding:"0 16px", gap:14, borderRadius:8} : {}}
>
<Icon name={i.icon} size={18}/>
{isMobile && <span style={{fontSize:14}}>{i.label}</span>}
</div>
);
if (isMobile) {
return (
<>
{drawerOpen && (
<div onClick={onCloseDrawer} style={{
position:"fixed", inset:0, background:"oklch(0 0 0 / 0.4)", zIndex:50, backdropFilter:"blur(2px)"
}}/>
)}
<div className="sidebar mobile" style={{
position:"fixed", left:0, top:0, bottom:0, width:260, zIndex:51,
background:"var(--bg-1)", borderRight:"1px solid var(--border-1)",
padding:"16px 12px", alignItems:"stretch", gap:2,
transform: drawerOpen ? "translateX(0)" : "translateX(-100%)",
transition:"transform 220ms cubic-bezier(.4,0,.2,1)",
}}>
<div style={{display:"flex", alignItems:"center", gap:10, padding:"4px 8px 16px"}}>
<img src="assets/logo_light.svg" alt="羚牛" style={{height:28, display:"block"}}/>
<div style={{borderLeft:"1px solid var(--border-1)", paddingLeft:10}}>
<div style={{fontWeight:600, fontSize:13, color:"var(--fg-0)"}}>车辆数据中心</div>
<div style={{fontSize:10, color:"var(--fg-3)", letterSpacing:"0.04em"}}>氢能乘用车队</div>
</div>
</div>
{ROUTES.map(renderItem)}
<div className="sidebar-divider" style={{margin:"12px 8px"}}/>
{SUB_ROUTES.map(renderItem)}
</div>
</>
);
}
return (
<div className="sidebar">
<div className="logo" title="羚牛 · Lingniu" onClick={() => onNavigate(DEFAULT_ROUTE)} style={{
cursor:"pointer",
background:"#FFFFFF",
border:"1px solid var(--border-1)",
boxShadow:"0 1px 2px rgba(47,40,40,.06)",
overflow:"hidden",
padding:0,
}}>
<img src="assets/logo_light.svg" alt="羚牛"
style={{height:24, width:"auto", maxWidth:"none", marginLeft:-3, display:"block", pointerEvents:"none"}}/>
</div>
{ROUTES.map(renderItem)}
<div className="sidebar-divider"/>
{SUB_ROUTES.map(renderItem)}
<div style={{flex:1}}/>
<div className="avatar" title="张工">ZG</div>
</div>
);
};
// ── Page wrapper: provides full-bleed canvas + page transition ──
const Page = ({ children, route }) => (
<div key={route} style={{
width:"100%", height:"100%", position:"relative",
animation:"pageFade 180ms cubic-bezier(.2,0,.2,1)",
}}>
{children}
</div>
);
// ── Mobile topbar (replaces desktop topbar on small screens) ──
const MobileTopbar = ({ title, onMenu, onSearch }) => (
<div style={{
height:52, flex:"0 0 52px", display:"flex", alignItems:"center", padding:"0 12px", gap:8,
background:"var(--bg-1)", borderBottom:"1px solid var(--border-1)", zIndex:2,
}}>
<button onClick={onMenu} style={{
width:36, height:36, display:"grid", placeItems:"center", borderRadius:8,
background:"transparent", border:"none", color:"var(--fg-1)", cursor:"pointer",
}} aria-label="菜单">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div style={{fontWeight:600, fontSize:15, color:"var(--fg-0)", flex:1}}>{title}</div>
<button style={{
width:36, height:36, display:"grid", placeItems:"center", borderRadius:8,
background:"transparent", border:"none", color:"var(--fg-2)",
}} aria-label="搜索"><Icon name="search" size={16}/></button>
<button style={{
width:36, height:36, display:"grid", placeItems:"center", borderRadius:8,
background:"transparent", border:"none", color:"var(--fg-2)", position:"relative",
}} aria-label="通知"><Icon name="bell" size={16}/><span className="dot" style={{position:"absolute", top:8, right:8}}/></button>
</div>
);
// ── Canvas mode (preserves the original design board) ────────
const DesignCanvasMode = () => {
const W = 1440, H = 900;
return (
<div style={{width:"100%", height:"100%", overflow:"hidden", background:"var(--bg-0)"}}>
<DesignCanvas>
<DCSection id="primary" title="① 主总览 · 实时监控" subtitle="地图主导布局 · 三栏:车辆列表 / 地图 / 详情面板">
<DCArtboard id="overview" label="A · 主总览(深色驾驶舱)" width={W} height={H}><ArtboardOverview/></DCArtboard>
<DCArtboard id="dense" label="B · 信息密集驾驶舱KPI网格" width={W} height={H}><ArtboardDense/></DCArtboard>
<DCArtboard id="light" label="C · 浅色变体(极简)" width={W} height={H}><ArtboardLightVariant/></DCArtboard>
</DCSection>
<DCSection id="detail" title="② 单车详情">
<DCArtboard id="detail" label="单车详情页" width={W} height={H}><ArtboardDetail/></DCArtboard>
</DCSection>
<DCSection id="history" title="③ 历史信息查询">
<DCArtboard id="history" label="历史查询" width={W} height={H}><ArtboardHistory/></DCArtboard>
</DCSection>
<DCSection id="playback" title="④ 轨迹回放">
<DCArtboard id="playback" label="轨迹回放" width={W} height={H}><ArtboardPlayback/></DCArtboard>
</DCSection>
<DCSection id="alarm" title="⑤ 事件规则引擎">
<DCArtboard id="alarm" label="规则编排" width={W} height={H}><ArtboardAlarm/></DCArtboard>
</DCSection>
<DCSection id="esg" title="ESG · 碳减排驾驶舱">
<DCArtboard id="esg" label="ESG·碳减排全国" width={1440} height={900}><ArtboardESG/></DCArtboard>
</DCSection>
<DCSection id="inbox" title="⑥ 通知中心">
<DCArtboard id="inbox" label="通知中心" width={W} height={H}><ArtboardInbox/></DCArtboard>
</DCSection>
</DesignCanvas>
</div>
);
};
// ── Component lookup (deferred to render time so window globals are ready) ──
const RESOLVE = (name) => window[name];
// ── Main router app ─────────────────────────────────────────
const RouterApp = () => {
const [route, navigate] = useHashRoute();
const isMobile = useIsMobile();
const [drawerOpen, setDrawerOpen] = React.useState(false);
const meta = ALL_ROUTES.find(r => r.path === route) || ALL_ROUTES[0];
const Cmp = RESOLVE(meta.component) || (() => <div style={{padding:40}}>页面 {route} 不存在</div>);
// close drawer on route change
React.useEffect(() => { setDrawerOpen(false); }, [route]);
// canvas mode = full-bleed, no chrome
if (route === "canvas") {
return (
<div className="app" style={{flexDirection: isMobile ? "column" : "row"}} data-screen-label={meta.label}>
<RouterSidebar active={route} onNavigate={navigate} isMobile={isMobile} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)}/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, background:"var(--bg-0)"}}>
{isMobile && <MobileTopbar title={meta.label} onMenu={() => setDrawerOpen(true)}/>}
<div style={{flex:1, position:"relative", overflow:"hidden"}}>
<Page route={route}><DesignCanvasMode/></Page>
</div>
</div>
</div>
);
}
// Pages render with their own internal chrome (overview etc include sidebar+topbar inside)
// To avoid double chrome, pages get rendered as full-page content — and we DON'T add an outer shell here.
// Instead, we inject a route-aware Sidebar+Topbar via the `chrome` system.
// SIMPLER: pages own their own .app .sidebar .topbar via the artboard.
// We need to intercept their sidebar to make it clickable.
// So we use a wrapper that overrides chrome by passing context.
// On mobile: render purpose-built MobileRouter with native single-column layouts
if (isMobile && window.MobileRouter && route !== "canvas") {
return (
<RouteContext.Provider value={{ route, navigate, isMobile, openDrawer: () => setDrawerOpen(true) }}>
<div data-screen-label={meta.label} style={{width:"100%", height:"100%", position:"relative", overflow:"hidden"}}>
<window.MobileRouter route={route}/>
<RouterSidebar active={route} onNavigate={navigate} isMobile={true} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)}/>
</div>
</RouteContext.Provider>
);
}
return (
<RouteContext.Provider value={{ route, navigate, isMobile, openDrawer: () => setDrawerOpen(true) }}>
<div data-screen-label={meta.label} style={{width:"100%", height:"100%", position:"relative"}}>
<Page route={route}>
<Cmp/>
</Page>
{isMobile && (
<RouterSidebar active={route} onNavigate={navigate} isMobile={true} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)}/>
)}
</div>
</RouteContext.Provider>
);
};
// ── Context for child artboards to read route & nav ─────────
const RouteContext = React.createContext({ route: DEFAULT_ROUTE, navigate: () => {}, isMobile: false, openDrawer: () => {} });
const useRoute = () => React.useContext(RouteContext);
window.RouterApp = RouterApp;
window.useRoute = useRoute;
window.RouteContext = RouteContext;
window.ROUTES = ROUTES;
window.SUB_ROUTES = SUB_ROUTES;
window.ALL_ROUTES = ALL_ROUTES;
window.MobileTopbar = MobileTopbar;
window.RouterSidebar = RouterSidebar;

411
artboards/alarm.jsx Normal file
View File

@@ -0,0 +1,411 @@
// artboard-alarm.jsx — Event rule engine (告警事件 / 运维通知 / 业务事件)
// "事件规则" — 抽象事件,触发条件 + 通知/动作
const EVENT_KINDS = [
{ id: "all", label: "全部", count: 24, color: "var(--fg-2)", bg: "transparent" },
{ id: "alarm", label: "告警事件", count: 12, color: "var(--danger)", bg: "var(--danger-soft)", desc: "需立即处理 · P0/P1/P2" },
{ id: "ops", label: "运维通知", count: 7, color: "var(--warn)", bg: "var(--warn-soft)", desc: "保养/检修/合同到期" },
{ id: "biz", label: "业务事件", count: 4, color: "var(--info)", bg: "var(--info-soft)", desc: "里程/交付/调度状态" },
{ id: "auto", label: "自动化", count: 1, color: "var(--accent)", bg: "var(--accent-soft)", desc: "自动派单/路径下发" },
];
const KIND_META = {
alarm: { label: "告警", color: "var(--danger)", bg: "var(--danger-soft)" },
ops: { label: "运维", color: "var(--warn)", bg: "var(--warn-soft)" },
biz: { label: "业务", color: "var(--info)", bg: "var(--info-soft)" },
auto: { label: "自动化", color: "var(--accent)", bg: "var(--accent-soft)" },
};
// Mock rule library
const RULES = [
// 告警 (P0)
{ n:"H₂压力异常下降", kind:"alarm", c:"P0", on:true, h:"已触发 3 次", cond:"pressure < 35 MPa", actions:["站内","邮件","短信"], a:false },
{ n:"电堆过温保护", kind:"alarm", c:"P0", on:true, h:"已触发 1 次", cond:"stack.temp > 95℃", actions:["站内","短信","Webhook"], a:false },
{ n:"电池SOC严重不足", kind:"alarm", c:"P0", on:true, h:"已触发 8 次", cond:"SOC < 15% & 持续 60s", actions:["站内","邮件","路径下发"], a:true },
{ n:"胎压异常", kind:"alarm", c:"P1", on:true, h:"已触发 12 次", cond:"tire.pressure > 3.0 MPa", actions:["站内","推送"], a:false },
{ n:"超速预警", kind:"alarm", c:"P1", on:true, h:"已触发 47 次", cond:"speed > limit + 10 km/h", actions:["站内"], a:false },
{ n:"急加速密集", kind:"alarm", c:"P2", on:true, h:"已触发 18 次", cond:"3 次/分钟 within 5min", actions:["邮件"], a:false },
{ n:"夜间行驶", kind:"alarm", c:"P2", on:true, h:"已触发 6 次", cond:"22:0006:00 + 行驶中", actions:["站内"], a:false },
// 运维通知
{ n:"总里程到达保养点", kind:"ops", c:"M1", on:true, h:"今日 4 辆", cond:"odometer % 20000 ≈ 0", actions:["站内","工单"], a:false },
{ n:"保养到期 30 天", kind:"ops", c:"M2", on:true, h:"今日 9 辆", cond:"days_to_maintenance ≤ 30", actions:["邮件","工单"], a:false },
{ n:"保险即将到期", kind:"ops", c:"M2", on:true, h:"本月 6 辆", cond:"days_to_insurance_end ≤ 60", actions:["邮件"], a:false },
{ n:"合同到期", kind:"ops", c:"M2", on:true, h:"本月 2 辆", cond:"days_to_contract_end ≤ 90", actions:["邮件","工单"], a:false },
{ n:"异常静止超 24h", kind:"ops", c:"M3", on:true, h:"今日 2 辆", cond:"idle_duration > 24h", actions:["站内"], a:false },
{ n:"长时间停留", kind:"ops", c:"M3", on:false, h:"已禁用", cond:"stop_duration > 4h & 非补能站", actions:["站内"], a:false },
// 业务事件
{ n:"今日交付完成", kind:"biz", c:"B1", on:true, h:"今日 38 单", cond:"delivery.status = 完成", actions:["站内","Webhook"], a:false },
{ n:"偏离规划路线", kind:"biz", c:"B2", on:true, h:"已触发 2 次", cond:"distance_from_route > 500m", actions:["站内","邮件"], a:false },
{ n:"进入禁行区域", kind:"biz", c:"B1", on:true, h:"已触发 0 次", cond:"geofence ∈ 禁行集合", actions:["站内","推送"], a:false },
{ n:"调度状态变更", kind:"biz", c:"B3", on:true, h:"实时", cond:"dispatch.status changed", actions:["Webhook"], a:false },
// 自动化
{ n:"低 SOC 自动派单至最近补能站", kind:"auto", c:"A1", on:true, h:"已执行 5 次", cond:"SOC < 20% & 行驶中", actions:["路径下发","站内"], a:false },
];
const ArtboardAlarm = () => {
const [activeKind, setActiveKind] = React.useState("all");
const [activeRule, setActiveRule] = React.useState(RULES.findIndex(r => r.a));
const filtered = RULES.filter(r => activeKind === "all" || r.kind === activeKind);
const rule = RULES[activeRule] || RULES[0];
return (
<div className="app">
<Sidebar active="alarm"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar crumbs={["事件规则", "规则引擎", rule.n]} kpis={[]} showSearch={false}/>
{/* Event-kind tabs */}
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:8, alignItems:"center", flexWrap:"wrap"}}>
<span className="muted" style={{fontSize:11, marginRight:4}}>事件类型</span>
{EVENT_KINDS.map(k => (
<span key={k.id}
onClick={() => setActiveKind(k.id)}
style={{
padding:"5px 12px",
borderRadius:14,
fontSize:11,
cursor:"pointer",
background: activeKind === k.id ? k.bg : "transparent",
color: activeKind === k.id ? k.color : "var(--fg-2)",
border: "1px solid " + (activeKind === k.id ? k.color : "var(--border-1)"),
display:"flex", alignItems:"center", gap:6,
}}>
<span>{k.label}</span>
<span className="mono" style={{fontSize:10, opacity:.8}}>{k.count}</span>
</span>
))}
<div style={{marginLeft:"auto", display:"flex", gap:6}}>
<button className="btn sm"><Icon name="filter" size={11}/> 筛选</button>
<button className="btn sm"><Icon name="download" size={11}/> 导出</button>
<button className="btn primary sm"><Icon name="plus" size={11}/> 新建规则</button>
</div>
</div>
<div style={{flex:1, display:"grid", gridTemplateColumns:"260px 1fr 320px", minHeight:0}}>
{/* Rules list */}
<div style={{borderRight:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
<div style={{padding:"10px 14px", borderBottom:"1px solid var(--border-1)"}}>
<div className="between" style={{marginBottom:8}}>
<span className="eyebrow">规则 · {filtered.length}</span>
<span className="muted" style={{fontSize:10}}> {RULES.length} </span>
</div>
<div className="search" style={{height:26}}>
<Icon name="search" size={12}/><input placeholder="搜索规则名 / 字段"/>
</div>
</div>
<div className="scroll" style={{flex:1}}>
{filtered.map((r,i)=>{
const realIdx = RULES.indexOf(r);
const meta = KIND_META[r.kind];
const isActive = realIdx === activeRule;
return (
<div key={i}
onClick={() => setActiveRule(realIdx)}
style={{
padding:"10px 14px",
borderBottom:"1px solid var(--border-1)",
borderLeft: isActive ? "2px solid var(--accent)" : "2px solid transparent",
background: isActive ? "var(--accent-soft)" : "transparent",
cursor:"pointer"
}}>
<div className="between">
<span className="strong" style={{fontSize:12}}>{r.n}</span>
<span style={{fontSize:9, padding:"2px 6px", borderRadius:3, color:meta.color, background:meta.bg, fontWeight:500}}>
{meta.label}·{r.c}
</span>
</div>
<div className="muted mono" style={{fontSize:10, marginTop:4, whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis"}}>
{r.cond}
</div>
<div className="between" style={{marginTop:6}}>
<span className="muted" style={{fontSize:10}}>{r.h}</span>
<span style={{width:22, height:12, borderRadius:6, background: r.on?"var(--accent)":"var(--bg-3)", position:"relative", flexShrink:0}}>
<span style={{position:"absolute", top:1, left: r.on?11:1, width:10, height:10, borderRadius:5, background:"#fff", boxShadow:"0 1px 2px rgba(0,0,0,.2)"}}/>
</span>
</div>
</div>
);
})}
</div>
</div>
{/* Rule editor canvas */}
<RuleEditor rule={rule}/>
{/* Right: properties */}
<RuleProperties rule={rule}/>
</div>
</div>
</div>
);
};
// ── Rule editor canvas ──────────────────────────────────────
const RuleEditor = ({ rule }) => {
const meta = KIND_META[rule.kind];
const isAlarm = rule.kind === "alarm";
const isOps = rule.kind === "ops";
const isBiz = rule.kind === "biz";
const isAuto = rule.kind === "auto";
// Build dynamic conditions per rule
const conds = (() => {
if (rule.n.startsWith("胎压")) return [{lbl:"WHEN", v:"tire.pressure", op:">", val:"3.0 MPa"}];
if (rule.n.startsWith("电池SOC")) return [{lbl:"WHEN", v:"vehicle.battery.soc", op:"<", val:"15 %"}, {lbl:"AND", v:"持续时长", op:"≥", val:"60 秒"}, {lbl:"AND NOT", v:"vehicle.location.poi", op:"=", val:"补能站"}];
if (rule.n.startsWith("H₂")) return [{lbl:"WHEN", v:"h2.pressure", op:"<", val:"35 MPa"}, {lbl:"AND", v:"vehicle.state", op:"=", val:"行驶中"}];
if (rule.n.startsWith("电堆")) return [{lbl:"WHEN", v:"fc.stack.temp", op:">", val:"95 ℃"}, {lbl:"AND", v:"持续时长", op:"≥", val:"30 秒"}];
if (rule.n.startsWith("超速")) return [{lbl:"WHEN", v:"vehicle.speed", op:">", val:"道路限速 + 10 km/h"}];
if (rule.n.startsWith("急加速")) return [{lbl:"WHEN", v:"急加速次数", op:"≥", val:"3 次"}, {lbl:"WITHIN", v:"时间窗", op:"=", val:"5 分钟"}];
if (rule.n.startsWith("夜间")) return [{lbl:"WHEN", v:"local_time", op:"∈", val:"[22:00, 06:00]"}, {lbl:"AND", v:"vehicle.state", op:"=", val:"行驶中"}];
if (rule.n.startsWith("总里程")) return [{lbl:"WHEN", v:"vehicle.odometer", op:"% 20,000 ≈", val:"0 km"}, {lbl:"AND", v:"距离上次保养里程", op:">", val:"19,500 km"}];
if (rule.n.startsWith("保养")) return [{lbl:"WHEN", v:"距下次保养", op:"≤", val:"30 天"}];
if (rule.n.startsWith("保险")) return [{lbl:"WHEN", v:"距保险到期", op:"≤", val:"60 天"}];
if (rule.n.startsWith("合同")) return [{lbl:"WHEN", v:"距合同到期", op:"≤", val:"90 天"}];
if (rule.n.startsWith("异常静止")) return [{lbl:"WHEN", v:"vehicle.idle_duration", op:">", val:"24 小时"}, {lbl:"AND", v:"asset.status", op:"=", val:"租赁/运营"}];
if (rule.n.startsWith("长时间停留")) return [{lbl:"WHEN", v:"stop_duration", op:">", val:"4 小时"}, {lbl:"AND NOT", v:"vehicle.location.poi", op:"=", val:"补能站/停车场"}];
if (rule.n.startsWith("今日交付")) return [{lbl:"WHEN", v:"delivery.status", op:"=", val:"已完成"}];
if (rule.n.startsWith("偏离")) return [{lbl:"WHEN", v:"distance_from_route", op:">", val:"500 m"}, {lbl:"AND", v:"持续时长", op:"≥", val:"30 秒"}];
if (rule.n.startsWith("进入禁行")) return [{lbl:"WHEN", v:"vehicle.geofence", op:"∈", val:"禁行围栏集合"}];
if (rule.n.startsWith("调度")) return [{lbl:"WHEN", v:"dispatch.status", op:"changed", val:"任意 → 任意"}];
if (rule.n.startsWith("低 SOC")) return [{lbl:"WHEN", v:"SOC", op:"<", val:"20 %"}, {lbl:"AND", v:"vehicle.state", op:"=", val:"行驶中"}, {lbl:"AND", v:"附近补能站", op:"≤", val:"5 km"}];
return [{lbl:"WHEN", v:"自定义条件", op:"-", val:"-"}];
})();
// Build dynamic actions per rule
const actionDefs = rule.actions.map(a => {
if (a === "站内") return {kind:"notif", icon:"inbox", title:"站内消息", who:"业务部门负责人 · 调度组", note:"实时"};
if (a === "邮件") return {kind:"notif", icon:"mail", title:"邮件", who:"业务负责人 · 安全官 (3人)", note:"含轨迹截图"};
if (a === "短信") return {kind:"notif", icon:"phone", title:"短信", who:"司机 + 业务负责人", note:"P0 级专用"};
if (a === "推送") return {kind:"notif", icon:"bell", title:"应用内推送", who:"业务负责人 + 客户联系人", note:"含一键导航"};
if (a === "Webhook") return {kind:"action",icon:"plug", title:"Webhook", who:"https://erp.lingniu.cn/hook/v1", note:"POST 事件 JSON"};
if (a === "工单") return {kind:"action",icon:"clipboard",title:"创建工单", who:"维保中心 · 自动指派", note:"24h SLA"};
if (a === "路径下发") return {kind:"action",icon:"route", title:"路径下发", who:"附近补能站 · TBOX", note:"司机端弹窗确认"};
return {kind:"notif", icon:"bell", title:a, who:"-", note:""};
});
return (
<div style={{
padding:16,
display:"flex", flexDirection:"column", minHeight:0,
background:"var(--bg-2)",
backgroundImage:"radial-gradient(circle, var(--border-1) 1px, transparent 1px)",
backgroundSize:"18px 18px", backgroundPosition:"-9px -9px",
}}>
{/* Header */}
<div className="between" style={{marginBottom:14}}>
<div>
<div className="mid gap-2">
<span className="strong" style={{fontSize:18, fontWeight:600}}>{rule.n}</span>
<span style={{fontSize:10, padding:"3px 8px", borderRadius:4, color:meta.color, background:meta.bg, fontWeight:600, border:"1px solid " + meta.color}}>
{meta.label} · {rule.c}
</span>
<span className="chip ok"><span className="dot ok"/> 已启用</span>
<span className="muted" style={{fontSize:10, marginLeft:6}}>v 1.4 · 2026-04-12</span>
</div>
<div className="muted" style={{fontSize:11, marginTop:4}}>
{isAlarm && "条件命中后立即生成告警事件按通知渠道发送至业务负责人P0 级支持一键派单。"}
{isOps && "条件命中后生成运维通知;如已配置工单动作,将创建保养/检修工单进入维保流程。"}
{isBiz && "条件命中后生成业务事件;可推送至 Webhook 联动 ERP / TMS / 调度系统。"}
{isAuto && "条件命中后自动执行动作(路径下发 / 状态变更),司机端弹窗确认后生效。"}
</div>
</div>
<div className="mid gap-2">
<button className="btn"><Icon name="play" size={12}/> 测试</button>
<button className="btn"><Icon name="history" size={13}/> 版本</button>
<button className="btn primary"><Icon name="bookmark" size={13}/> 保存</button>
</div>
</div>
{/* Editor — split: WHEN | LOGIC | THEN */}
<div className="scroll" style={{flex:1, position:"relative", padding:"4px 0"}}>
<div style={{display:"grid", gridTemplateColumns:"260px 220px 1fr", gap:24, alignItems:"start", minHeight:"100%"}}>
{/* WHEN column */}
<div>
<div className="eyebrow" style={{marginBottom:8, color:"var(--info)"}}> 触发条件 · WHEN</div>
<div style={{display:"flex", flexDirection:"column", gap:10}}>
{conds.map((c,i) => (
<div key={i} style={{padding:"10px 12px", background:"var(--bg-1)", border:"1px solid var(--border-2)", borderRadius:8, fontSize:11, boxShadow:"0 1px 2px rgba(0,0,0,.04)", position:"relative"}}>
<div className="between">
<span style={{fontSize:9, color:"var(--info)", fontWeight:600, letterSpacing:"0.05em"}}>{c.lbl}</span>
<Icon name="x" size={10} style={{color:"var(--fg-3)", cursor:"pointer"}}/>
</div>
<div className="strong" style={{marginTop:6, fontSize:12}}>{c.v}</div>
<div className="mid gap-2" style={{marginTop:4, fontSize:11}}>
<span style={{padding:"2px 6px", background:"var(--bg-2)", borderRadius:3, fontFamily:"var(--font-mono)", border:"1px solid var(--border-1)"}}>{c.op}</span>
<span className="mono strong" style={{color:"var(--accent)"}}>{c.val}</span>
</div>
</div>
))}
<div style={{padding:"8px 12px", background:"var(--bg-1)", borderRadius:6, border:"1px dashed var(--border-2)", fontSize:11, color:"var(--fg-3)", display:"flex", alignItems:"center", gap:6, justifyContent:"center", cursor:"pointer"}}>
<Icon name="plus" size={11}/> 添加条件
</div>
</div>
</div>
{/* LOGIC center */}
<div style={{display:"flex", flexDirection:"column", alignItems:"center", paddingTop:32}}>
<div style={{padding:"16px 14px", background:"var(--accent-soft)", border:"1.5px solid var(--accent)", borderRadius:10, textAlign:"center", boxShadow:"0 0 24px var(--accent-glow)", width:"100%"}}>
<div style={{fontSize:10, color:"var(--accent)", letterSpacing:"0.1em", marginBottom:6}}>LOGIC GATE</div>
<div className="strong" style={{fontSize:18, fontFamily:"var(--font-mono)", color:"var(--accent)"}}>
{conds.length === 1 ? "A" : conds.map((c,i) => (c.lbl === "AND NOT" ? "¬" : "") + String.fromCharCode(65+i)).join(conds.length > 1 ? " ∧ " : "")}
</div>
<div className="muted" style={{fontSize:10, marginTop:6}}>{conds.length} 个条件 · 全部满足时触发</div>
</div>
<div style={{marginTop:14, padding:"10px 12px", background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6, fontSize:10, width:"100%"}}>
<div className="muted" style={{marginBottom:4}}>评估窗口</div>
<div className="between"><span>采样频率</span><span className="mono strong">10 s · TBOX</span></div>
<div className="between" style={{marginTop:3}}><span>抑制窗口</span><span className="mono strong">15 min</span></div>
<div className="between" style={{marginTop:3}}><span>事件 ID</span><span className="mono" style={{color:"var(--accent)"}}>EVT-{rule.kind.toUpperCase()}-{rule.c}</span></div>
</div>
</div>
{/* THEN column — actions */}
<div>
<div className="eyebrow" style={{marginBottom:8, color:"var(--accent)"}}> 触发动作 · THEN</div>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
{actionDefs.map((a,i) => (
<div key={i} style={{padding:"10px 12px", background:"var(--bg-1)", border:"1px solid " + (a.kind === "action" ? "var(--accent)" : "var(--border-2)"), borderLeft:"3px solid " + (a.kind === "action" ? "var(--accent)" : "var(--info)"), borderRadius:6, fontSize:11, boxShadow:"0 1px 2px rgba(0,0,0,.04)"}}>
<div className="between">
<div className="mid gap-2">
<Icon name={a.icon} size={12} style={{color: a.kind === "action" ? "var(--accent)" : "var(--info)"}}/>
<span className="strong" style={{fontSize:11}}>{a.title}</span>
</div>
<span style={{fontSize:9, padding:"1px 5px", borderRadius:3, color: a.kind === "action" ? "var(--accent)" : "var(--info)", background: a.kind === "action" ? "var(--accent-soft)" : "var(--info-soft)"}}>
{a.kind === "action" ? "动作" : "通知"}
</span>
</div>
<div className="muted" style={{fontSize:10, marginTop:4, lineHeight:1.4}}>{a.who}</div>
<div className="mono" style={{fontSize:9, marginTop:3, color:"var(--fg-3)"}}>{a.note}</div>
</div>
))}
<div style={{padding:"10px 12px", background:"var(--bg-1)", border:"1px dashed var(--border-2)", borderRadius:6, fontSize:11, color:"var(--fg-3)", display:"flex", alignItems:"center", justifyContent:"center", gap:6, cursor:"pointer", minHeight:64}}>
<Icon name="plus" size={11}/> 添加动作
</div>
</div>
{/* Block library */}
<div style={{marginTop:14, padding:"10px 12px", background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6}}>
<div className="eyebrow" style={{marginBottom:6}}>组件库 · 拖入条件 / 动作</div>
<div style={{display:"flex", flexWrap:"wrap", gap:6}}>
{[
{ic:"gauge", l:"数值阈值"},
{ic:"history", l:"持续时间"},
{ic:"pin", l:"地理围栏"},
{ic:"timeline", l:"时间窗口"},
{ic:"branch", l:"逻辑分支"},
{ic:"speed", l:"速度变化率"},
{ic:"chart", l:"趋势异常"},
{ic:"shield", l:"白名单"},
{ic:"clipboard", l:"创建工单"},
{ic:"plug", l:"Webhook"},
{ic:"route", l:"路径下发"},
{ic:"mail", l:"邮件"},
].map((b,i)=>(
<div key={i} className="mid gap-1" style={{padding:"4px 8px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5, fontSize:10, cursor:"grab"}}>
<Icon name={b.ic} size={11} style={{color:"var(--accent)"}}/> <span>{b.l}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
// ── Right-side properties ──────────────────────────────────
const RuleProperties = ({ rule }) => {
const meta = KIND_META[rule.kind];
const isAlarm = rule.kind === "alarm";
const isOps = rule.kind === "ops";
const channels = [
{ic:"inbox", l:"站内消息中心", on: rule.actions.includes("站内"), who:"业务部门(5) · 调度组(8)"},
{ic:"mail", l:"邮件", on: rule.actions.includes("邮件"), who:"业务负责人 · 安全官"},
{ic:"phone", l:"短信", on: rule.actions.includes("短信"), who:"司机 + 业务负责人"},
{ic:"bell", l:"应用内推送", on: rule.actions.includes("推送"), who:"业务负责人 + 客户联系人"},
{ic:"plug", l:"Webhook", on: rule.actions.includes("Webhook"), who:"erp.lingniu.cn/hook/v1"},
{ic:"clipboard",l:"工单", on: rule.actions.includes("工单"), who:"维保中心 · 24h SLA"},
];
return (
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="sliders" size={13}/><span className="title">规则属性</span>
</div>
<div className="scroll" style={{flex:1, padding:14}}>
<div className="eyebrow" style={{marginBottom:6}}>事件类型</div>
<div style={{padding:"8px 10px", background:meta.bg, border:"1px solid " + meta.color, borderRadius:5, fontSize:11, marginBottom:14, color:meta.color, fontWeight:600}}>
{meta.label} · {rule.c}
</div>
<div className="eyebrow" style={{marginBottom:6}}>{isAlarm ? "告警优先级" : isOps ? "运维等级" : "事件等级"}</div>
<div className="row gap-1" style={{marginBottom:14}}>
{(isAlarm ? ["P0","P1","P2"] : isOps ? ["M1","M2","M3"] : ["B1","B2","B3"]).map(p => (
<span key={p}
className={"chip " + (rule.c === p ? (isAlarm ? "danger" : isOps ? "warn" : "info") : "")}
style={{flex:1, justifyContent:"center", padding:"4px 0", opacity: rule.c === p ? 1 : 0.4}}>
{p}
</span>
))}
</div>
<div className="eyebrow" style={{marginBottom:6}}>适用车辆</div>
<div style={{padding:8, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5, fontSize:11, marginBottom:14}}>
<div className="strong">全部车辆 · 512</div>
<div className="muted" style={{fontSize:10, marginTop:2}}>排除维保中 6 · 排除停运 4 </div>
</div>
<div className="eyebrow" style={{marginBottom:8}}>通知 / 动作渠道</div>
<div className="col gap-2" style={{marginBottom:14}}>
{channels.map((c,i)=>(
<div key={i} className="between" style={{padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5}}>
<div className="mid gap-2">
<Icon name={c.ic} size={12} style={{color: c.on?"var(--accent)":"var(--fg-3)"}}/>
<div>
<div className="strong" style={{fontSize:11}}>{c.l}</div>
<div className="muted" style={{fontSize:10}}>{c.who}</div>
</div>
</div>
<span style={{width:22, height:12, borderRadius:6, background: c.on?"var(--accent)":"var(--bg-3)", position:"relative"}}>
<span style={{position:"absolute", top:1, left: c.on?11:1, width:10, height:10, borderRadius:5, background:"#fff", boxShadow:"0 1px 2px rgba(0,0,0,.2)"}}/>
</span>
</div>
))}
</div>
<div className="eyebrow" style={{marginBottom:6}}>抑制策略</div>
<div style={{padding:10, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5, fontSize:11, marginBottom:14}}>
<div className="between"><span className="muted">同车去重窗口</span><span className="mono strong">{isOps ? "24 小时" : "15 分钟"}</span></div>
<div className="between" style={{marginTop:6}}><span className="muted">每日上限</span><span className="mono strong">{isAlarm && rule.c === "P0" ? "无限制" : "20 次/车"}</span></div>
<div className="between" style={{marginTop:6}}><span className="muted">合并策略</span><span className="mono strong">{isOps ? "按车辆合并" : "不合并"}</span></div>
</div>
<div className="eyebrow" style={{marginBottom:6}}>静音时段</div>
<div style={{padding:10, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5, fontSize:11, marginBottom:14}}>
<div className="muted" style={{fontSize:10, lineHeight:1.5}}>
{isAlarm && rule.c === "P0" ? "P0 紧急规则不静音" : isAlarm ? "工作时段8:0020:00 推送" : isOps ? "仅工作日发送" : "无静音"}
</div>
</div>
<div className="eyebrow" style={{marginBottom:6}}> 7 日触发</div>
<div style={{padding:10, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5}}>
<div style={{display:"flex", gap:3, height:32, alignItems:"flex-end"}}>
{[3,7,2,9,12,5,8].map((v,i) => (
<div key={i} style={{flex:1, height: (v/12)*100 + "%", background: v > 8 ? meta.color : "var(--accent-soft)", borderRadius:1}}/>
))}
</div>
<div className="between muted" style={{marginTop:6, fontSize:10}}>
<span>4-22</span><span>4-28</span>
</div>
</div>
</div>
</div>
);
};
window.ArtboardAlarm = ArtboardAlarm;

369
artboards/detail.jsx Normal file
View File

@@ -0,0 +1,369 @@
// artboard-detail.jsx — Single vehicle deep detail · Asset-management view
const ArtboardDetail = () => {
const vehicles = window.VEHICLES || [];
const v = vehicles.find(x => x.id === "浙F03980F") || vehicles[0];
if (!v) return null;
return (
<div className="app">
<Sidebar active="fleet"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar
crumbs={["车辆列表", v.plate, "详情"]}
kpis={[]}
showSearch={false}
/>
<div className="scroll" style={{flex:1, padding:16, display:"grid", gridTemplateColumns:"1fr 1fr 1fr", gridAutoRows:"min-content", gap:12}}>
{/* Header card spanning 3 */}
<div className="panel" style={{gridColumn:"1 / -1", padding:16}}>
<div className="between">
<div className="mid gap-3">
<div style={{width:60, height:60, borderRadius:8, background:"var(--bg-2)", border:"1px solid var(--border-2)", display:"grid", placeItems:"center", color:"var(--accent)"}}>
<Icon name="car" size={30}/>
</div>
<div>
<div className="mid gap-2">
<span className="mono strong" style={{fontSize:22, fontWeight:600}}>{v.plate}</span>
<span className="chip" style={{
background: v.asset === "leasing" ? "rgba(46,140,140,.15)" : v.asset === "abnormal" ? "var(--danger-soft)" : "var(--accent-soft)",
color: v.asset === "leasing" ? "var(--info)" : v.asset === "abnormal" ? "var(--danger)" : "var(--accent)",
}}>
<span className={"dot " + (v.asset === "abnormal" ? "danger" : v.asset === "leasing" ? "info" : "ok")}/>
{v.asset === "leasing" ? "租赁" : v.asset === "abnormal" ? "异常" : "在库"}
</span>
<span className="chip" style={{background: v.own === "self" ? "rgba(31,139,76,.10)" : "rgba(122,140,46,.12)", color: v.own === "self" ? "var(--accent)" : "#7A8C2E"}}>
{v.own === "self" ? "自有" : "外租"}
</span>
<span className="chip accent"><Icon name="h2" size={11}/> H₂ {v.h2} MPa</span>
</div>
<div className="muted" style={{fontSize:12, marginTop:4}}>VIN {v.vin} · {v.city} · 等级 {v.grade} · 状态时长 {v.statusDays}</div>
<div className="mid gap-2" style={{marginTop:6, fontSize:11}}>
<span className="muted">数据来源</span>
<SourceBadge src={v.src} size="md"/>
<span className="muted mono" style={{fontSize:10}}>TBOX(GB/T 32960-2016) · JT/T 808-2019 · JT/T 1078 视频</span>
<span className={"chip " + (v.gps === "online" ? "ok" : "")} style={{fontSize:10}}>
<span className={"dot " + (v.gps === "online" ? "ok" : "idle")} style={{width:4, height:4}}/>
{v.gps === "online" ? "在线 · 上行 218ms" : "GPS离线"}
</span>
</div>
</div>
</div>
<div className="mid gap-2">
<button className="btn"><Icon name="route" size={13}/> 轨迹</button>
<button className="btn"><Icon name="history" size={13}/> 历史</button>
<button className="btn"><Icon name="bell" size={13}/> 告警</button>
<button className="btn primary"><Icon name="pin" size={13}/> 定位</button>
</div>
</div>
<div style={{marginTop:14, display:"grid", gridTemplateColumns:"repeat(6, 1fr)", gap:0, borderTop:"1px solid var(--border-1)", paddingTop:14}}>
{[
{l:"累计里程", val:v.totalKm.toLocaleString(), u:"km"},
{l:"今日里程", val:"248", u:"km"},
{l:"距下次保养", val:v.kmToMaint.toLocaleString(), u:"km"},
{l:"今日能耗", val:"18.4", u:"kWh/100km"},
{l:"H₂消耗", val:"1.02", u:"kg/100km"},
{l:"车辆评级", val:v.grade, u:"级"},
].map((k,i)=>(
<div key={i} style={{borderRight: i < 5 ? "1px solid var(--border-1)": "none", padding:"0 16px"}}>
<div className="eyebrow" style={{marginBottom:6}}>{k.l}</div>
<div><span className="mono strong" style={{fontSize:22, fontWeight:600}}>{k.val}</span><span className="muted mono" style={{fontSize:11, marginLeft:4}}>{k.u}</span></div>
</div>
))}
</div>
</div>
{/* 资产档案 */}
<div className="panel">
<div className="panel-head">
<Icon name="layers" size={13} style={{color:"var(--accent)"}}/>
<span className="title">资产档案</span>
</div>
<div style={{padding:"14px 16px"}}>
<div className="col gap-2" style={{fontSize:11}}>
<div className="between"><span className="muted">车牌号</span><span className="mono strong">{v.plate}</span></div>
<div className="between"><span className="muted">VIN/车架号</span><span className="mono" style={{fontSize:10}}>{v.vin}</span></div>
{v.fleetCode && <div className="between"><span className="muted">车辆编号</span><span className="mono strong">{v.fleetCode}</span></div>}
<div className="between"><span className="muted">运营城市</span><span>{v.city}</span></div>
<div className="between"><span className="muted">所属公司</span><span style={{fontSize:10, textAlign:"right"}}>{v.ownCompany}</span></div>
<div className="between"><span className="muted">车辆等级</span><span className="strong">{v.grade}</span></div>
<div className="between"><span className="muted">归属</span><span className="strong">{v.own === "self" ? "自有" : "外租"}</span></div>
<div className="between"><span className="muted">停车场</span><span>{v.parking}</span></div>
<div className="between"><span className="muted">资产状态</span><span className="strong" style={{color: v.asset === "abnormal" ? "var(--danger)" : v.asset === "leasing" ? "var(--info)" : "var(--accent)"}}>
{v.asset === "leasing" ? "租赁" : v.asset === "abnormal" ? "异常" : "在库"} · {v.statusDays}
</span></div>
<div className="between"><span className="muted">营运状态</span><span className="strong">
{v.op === "operating" ? "运营中" : v.op === "suspended" ? "停运" : "待整备"}
</span></div>
</div>
</div>
</div>
{/* 业务关系 */}
<div className="panel">
<div className="panel-head">
<Icon name="user" size={13} style={{color:"var(--info)"}}/>
<span className="title">业务关系</span>
</div>
<div style={{padding:"14px 16px"}}>
<div className="col gap-2" style={{fontSize:11}}>
<div className="between"><span className="muted">业务部门</span>
<span className="mid gap-1">
<span style={{width:8, height:8, background:v.deptColor, borderRadius:1, display:"inline-block"}}/>
<span className="strong">{v.deptName}</span>
</span>
</div>
<div className="between"><span className="muted">业务负责人</span><span className="strong">{v.deptLead}</span></div>
<div className="between"><span className="muted">客户全名</span><span className="strong" style={{textAlign:"right", maxWidth:160}}>{v.customer}</span></div>
{v.own === "lease" && <div className="between"><span className="muted">租赁公司</span><span style={{fontSize:10, textAlign:"right"}}>{v.company}</span></div>}
{v.contractNo && <>
<div className="between"><span className="muted">合同编号</span><span className="mono" style={{fontSize:10}}>{v.contractNo}</span></div>
<div className="between"><span className="muted">交车里程</span><span className="mono">{v.handoverKm?.toLocaleString()} km</span></div>
{v.returnKm != null && <div className="between"><span className="muted">还车里程</span><span className="mono">{v.returnKm.toLocaleString()} km</span></div>}
</>}
</div>
<div style={{marginTop:14, paddingTop:12, borderTop:"1px solid var(--border-1)", display:"flex", gap:6}}>
<button className="btn" style={{flex:1, fontSize:11}}>查看合同</button>
<button className="btn" style={{flex:1, fontSize:11}}>变更负责人</button>
</div>
</div>
</div>
{/* 氢电系统 */}
<div className="panel">
<div className="panel-head">
<Icon name="h2" size={13} style={{color:"var(--accent)"}}/>
<span className="title">氢电系统</span>
<span className="chip accent" style={{marginLeft:"auto"}}>FCEV</span>
</div>
<div style={{padding:16}}>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:14}}>
<div className="center col gap-2">
<Donut size={92} value={v.soc/100} color="var(--accent)" thick={9} label={v.soc + "%"}/>
<div className="muted" style={{fontSize:11}}>电池 SOC</div>
</div>
<div className="center col gap-2">
<Donut size={92} value={v.h2Pressure/6} color="var(--info)" thick={9} label={Math.round(v.h2Pressure/6*100) + "%"}/>
<div className="muted" style={{fontSize:11}}>H₂ 储量</div>
</div>
</div>
<div style={{marginTop:14, paddingTop:14, borderTop:"1px solid var(--border-1)", display:"grid", gridTemplateColumns:"1fr 1fr", gap:8, fontSize:11}}>
<div className="between"><span className="muted">电堆功率</span><span className="mono strong">28.4 kW</span></div>
<div className="between"><span className="muted">电池电压</span><span className="mono strong">386 V</span></div>
<div className="between"><span className="muted">电堆温度</span><span className="mono strong">76°C</span></div>
<div className="between"><span className="muted">H₂压力</span><span className="mono strong">{v.h2} MPa</span></div>
<div className="between"><span className="muted">续航估算</span><span className="mono strong" style={{color:"var(--accent)"}}>{v.range} km</span></div>
<div className="between"><span className="muted">电池温度</span><span className="mono strong">32°C</span></div>
</div>
</div>
</div>
{/* Speed/RPM curve */}
<div className="panel" style={{gridColumn:"span 2"}}>
<div className="panel-head">
<Icon name="speed" size={13}/>
<span className="title">速度 / 电机转速 · 近1小时</span>
<div className="actions">
<span className="chip">1H</span>
<span className="chip" style={{opacity:0.5}}>4H</span>
<span className="chip" style={{opacity:0.5}}>1D</span>
</div>
</div>
<div style={{padding:"14px 16px"}}>
<div className="between" style={{marginBottom:10, fontSize:11}}>
<div className="mid gap-3">
<span className="mid gap-1"><span className="dot" style={{background:"var(--info)"}}/> 速度 km/h</span>
<span className="mid gap-1"><span className="dot" style={{background:"var(--accent)"}}/> 电机RPM ÷100</span>
</div>
<div className="mono muted">avg 52 / max 89 km/h</div>
</div>
<LineChart data={genSpeed()} w={520} h={120} color="var(--info)" axis baseline={70}/>
<div style={{marginTop:-6}}>
<LineChart data={genSpeed().map(v => v*1.1)} w={520} h={60} color="var(--accent)" fill={false}/>
</div>
</div>
</div>
{/* Tire pressure */}
<div className="panel">
<div className="panel-head"><Icon name="tire" size={13}/><span className="title">胎压 / 温度</span></div>
<div style={{padding:16, display:"flex", gap:12, alignItems:"center", justifyContent:"center"}}>
<svg width="160" height="200" viewBox="0 0 160 200">
<rect x="40" y="20" width="80" height="160" rx="20" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="50" y="45" width="60" height="50" rx="8" fill="var(--bg-3)" opacity="0.6"/>
<rect x="50" y="105" width="60" height="50" rx="8" fill="var(--bg-3)" opacity="0.6"/>
{[
{x:24, y:38, st:"ok"},{x:120, y:38, st:"ok"},
{x:24, y:138, st:"ok"},{x:120, y:138, st:"ok"},
].map((t,i)=>(
<rect key={i} x={t.x} y={t.y} width="16" height="24" rx="3" fill="var(--ok)" opacity="0.8"/>
))}
</svg>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:6, fontSize:11, fontFamily:"var(--font-mono)"}}>
{[
{p:"FL", v:"0.24", t:"32°"},{p:"FR", v:"0.23", t:"34°"},
{p:"RL", v:"0.25", t:"36°"},{p:"RR", v:"0.24", t:"35°"},
].map((t,i)=>(
<div key={i} style={{padding:"6px 8px", background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
<div className="muted" style={{fontSize:9}}>{t.p}</div>
<div className="strong">{t.v}</div>
<div className="muted" style={{fontSize:9}}>{t.t}</div>
</div>
))}
</div>
</div>
</div>
{/* 保养与维护 */}
<div className="panel" style={{gridColumn:"span 2"}}>
<div className="panel-head">
<Icon name="wrench" size={13}/>
<span className="title">保养与维护</span>
<span className="actions">
<span className={"chip " + (v.kmToMaint < 1000 ? "warn" : "ok")} style={{fontSize:10}}>
剩余 {v.kmToMaint.toLocaleString()} km
</span>
</span>
</div>
<div style={{padding:"14px 16px"}}>
<div className="between" style={{fontSize:11, marginBottom:8}}>
<span className="muted">保养周期 10,000 km</span>
<span className="mono">已行 {(10000 - v.kmToMaint).toLocaleString()} / 10,000 km</span>
</div>
<div className="bar" style={{height:6, marginBottom:14}}>
<i style={{width: ((10000 - v.kmToMaint) / 10000 * 100) + "%", background: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)"}}/>
</div>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:14, fontSize:11}}>
<div>
<div className="eyebrow" style={{marginBottom:8}}>上次保养</div>
<div className="col gap-1">
<div className="between"><span className="muted">日期</span><span className="strong">{v.lastMaintDays}天前</span></div>
<div className="between"><span className="muted">里程</span><span className="mono">{v.lastMaintKm.toLocaleString()} km</span></div>
<div className="between"><span className="muted">项目</span><span>常规保养·机油机滤</span></div>
<div className="between"><span className="muted">技师</span><span>李工</span></div>
</div>
</div>
<div>
<div className="eyebrow" style={{marginBottom:8}}>下次保养预约</div>
<div className="col gap-1">
<div className="between"><span className="muted">里程节点</span><span className="mono">{v.nextMaintKm.toLocaleString()} km</span></div>
<div className="between"><span className="muted">距离</span><span className="strong" style={{color: v.kmToMaint < 1000 ? "var(--warn)" : "var(--fg-0)"}}>{v.kmToMaint.toLocaleString()} km</span></div>
<div className="between"><span className="muted">推荐站点</span><span>羚牛 · 嘉兴服务站</span></div>
<div className="between"><span className="muted">通知</span><span className="strong">{v.deptLead} · {v.deptName}</span></div>
</div>
</div>
</div>
</div>
</div>
{/* DTC list */}
<div className="panel">
<div className="panel-head"><Icon name="wrench" size={13}/><span className="title">故障码 · DTC</span><span className="chip" style={{marginLeft:"auto"}}>{v.asset === "abnormal" ? "2 active" : "0 active"}</span></div>
<div style={{padding:0}}>
{(v.asset === "abnormal" ? [
{c:"P0A7F", n:"电池组性能下降", st:"warn", t:"3小时前"},
{c:"U0073", n:"控制模块通信总线A关闭", st:"warn", t:"2天前"},
{c:"P0563", n:"系统电压高", st:"info", t:"已清除"},
] : [
{c:"P0563", n:"系统电压高", st:"info", t:"已清除"},
]).map((d,i,arr)=>(
<div key={i} className="between" style={{padding:"10px 14px", borderBottom: i < arr.length-1 ? "1px solid var(--border-1)" : "none", fontSize:12}}>
<div>
<span className="mono strong">{d.c}</span>
<span className="muted" style={{marginLeft:8, fontSize:11}}>{d.n}</span>
</div>
<div className="mid gap-2">
<span className={"chip " + d.st}>{d.st === "warn" ? "ACTIVE" : "CLEAR"}</span>
<span className="muted mono" style={{fontSize:10}}>{d.t}</span>
</div>
</div>
))}
</div>
</div>
{/* Data source / signal channels */}
<div className="panel" style={{gridColumn:"1 / -1"}}>
<div className="panel-head">
<Icon name="wifi" size={13}/>
<span className="title">数据源 · 信号通道</span>
<div className="actions">
<span className="chip ok"><span className="dot ok" style={{width:5,height:5}}/> 双源在线</span>
<span className="chip">最近上行 · 218ms</span>
</div>
</div>
<div style={{padding:14, display:"grid", gridTemplateColumns:"1fr 1fr 1fr", gap:12}}>
{[
{
src:"T", title:"TBOX · 整车遥信",
spec:"GB/T 32960-2016 / GB/T 40432",
sub:"国标新能源车数据", up:"10 s",
signals:[
{n:"整车状态", c:"54 项", st:"ok"},
{n:"驱动电机", c:"18 项", st:"ok"},
{n:"动力电池", c:"32 项", st:"ok"},
{n:"燃料电池/H₂", c:"24 项", st:"ok"},
{n:"极值/故障", c:"12 项", st:"warn"},
],
health: 99.6,
},
{
src:"J", title:"JT/T 808 · 北斗位置",
spec:"JT/T 808-2019 部标",
sub:"位置/报警/参数", up:"30 s",
signals:[
{n:"GNSS位置", c:"1 帧", st:"ok"},
{n:"行驶记录仪", c:"8 项", st:"ok"},
{n:"报警/事件", c:"64 类", st:"ok"},
{n:"参数下发", c:"42 项", st:"ok"},
{n:"电子围栏", c:"6 区域", st:"ok"},
],
health: 100,
},
{
src:"J", title:"JT/T 1078 · 视频",
spec:"JT/T 1078-2016 部标",
sub:"4路实时音视频", up:"H.264",
signals:[
{n:"CH1 前向", c:"720p", st:"ok"},
{n:"CH2 驾驶员", c:"720p", st:"ok"},
{n:"CH3 后视", c:"720p", st:"ok"},
{n:"CH4 车厢", c:"480p", st:"warn"},
{n:"录像存储", c:"1.2 TB", st:"ok"},
],
health: 92.8,
},
].map((s,i)=>(
<div key={i} style={{padding:14, background:"var(--bg-2)", borderRadius:6, border:"1px solid var(--border-1)"}}>
<div className="between">
<div className="mid gap-2">
<SourceBadge src={s.src} size="md"/>
<span className="strong" style={{fontSize:12}}>{s.title}</span>
</div>
<span className="mono muted" style={{fontSize:10}}>{s.up}</span>
</div>
<div className="muted mono" style={{fontSize:10, marginTop:4}}>{s.spec}</div>
<div className="muted" style={{fontSize:11, marginTop:2}}>{s.sub}</div>
<div style={{marginTop:10, display:"flex", flexDirection:"column", gap:4}}>
{s.signals.map((sig,j)=>(
<div key={j} className="between" style={{fontSize:11, padding:"4px 0"}}>
<span className="mid gap-2"><span className={"dot " + sig.st} style={{width:5,height:5}}/><span className="muted">{sig.n}</span></span>
<span className="mono">{sig.c}</span>
</div>
))}
</div>
<div style={{marginTop:10, paddingTop:10, borderTop:"1px solid var(--border-1)"}}>
<div className="between" style={{fontSize:10}}>
<span className="muted">通道完好率</span>
<span className="mono strong" style={{color: s.health > 99 ? "var(--ok)" : s.health > 95 ? "var(--info)" : "var(--warn)"}}>{s.health}%</span>
</div>
<div style={{height:3, background:"var(--bg-3)", borderRadius:2, marginTop:4, overflow:"hidden"}}>
<div style={{height:"100%", width: s.health + "%", background: s.health > 99 ? "var(--ok)" : s.health > 95 ? "var(--info)" : "var(--warn)"}}/>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
window.ArtboardDetail = ArtboardDetail;

496
artboards/esg.jsx Normal file
View File

@@ -0,0 +1,496 @@
// artboard-esg.jsx — ESG · Carbon Reduction Cockpit (light green theme)
// Mirrors reference: white ground, multi-tier green, China choropleth + KPIs
const ChinaMapMini = ({ w = 480, h = 360 }) => {
// Simplified provincial silhouette — abstract, recognisable. Levels keyed by data.
// Each path is roughly positioned on a 480x360 canvas of mainland.
const G = { 4: "#1F8B4C", 3: "#4FB46E", 2: "#9DD3A6", 1: "#D7EBD2", 0: "#EEF5EC" };
const provs = [
// [name, level, polygon]
{n:"新疆", l:1, d:"M40 90 L130 70 L150 110 L120 160 L60 150 Z"},
{n:"西藏", l:0, d:"M70 160 L150 140 L180 180 L140 220 L80 200 Z"},
{n:"青海", l:1, d:"M150 130 L210 130 L210 175 L160 180 Z"},
{n:"甘肃", l:2, d:"M180 100 L240 80 L260 120 L220 140 L200 130 Z"},
{n:"内蒙", l:3, d:"M180 60 L320 40 L350 70 L330 95 L260 100 L210 90 Z"},
{n:"宁夏", l:1, d:"M225 110 L245 100 L255 125 L235 130 Z"},
{n:"陕西", l:3, d:"M245 110 L280 105 L290 160 L260 180 L245 145 Z"},
{n:"山西", l:2, d:"M285 90 L310 88 L320 145 L295 150 Z"},
{n:"河北", l:2, d:"M310 75 L355 70 L365 115 L325 130 L315 100 Z"},
{n:"北京", l:4, d:"M335 82 L355 80 L355 95 L338 95 Z"},
{n:"天津", l:3, d:"M358 92 L370 92 L370 105 L358 105 Z"},
{n:"辽宁", l:2, d:"M360 60 L405 55 L420 90 L385 105 L362 88 Z"},
{n:"吉林", l:1, d:"M395 35 L440 30 L450 65 L410 70 Z"},
{n:"黑龙江", l:1, d:"M390 5 L460 0 L470 35 L420 40 Z"},
{n:"山东", l:3, d:"M325 130 L380 125 L390 165 L335 165 Z"},
{n:"河南", l:3, d:"M280 155 L330 150 L335 195 L290 200 Z"},
{n:"江苏", l:3, d:"M345 165 L390 165 L395 200 L350 205 Z"},
{n:"上海", l:4, d:"M390 195 L405 195 L405 210 L390 210 Z"},
{n:"安徽", l:3, d:"M315 195 L350 200 L355 235 L320 235 Z"},
{n:"浙江", l:4, d:"M370 210 L400 210 L405 245 L375 245 Z"},
{n:"湖北", l:3, d:"M270 195 L320 200 L320 235 L275 230 Z"},
{n:"四川", l:2, d:"M195 175 L260 170 L270 230 L210 225 L195 205 Z"},
{n:"重庆", l:2, d:"M250 215 L275 210 L275 230 L255 232 Z"},
{n:"贵州", l:2, d:"M225 235 L275 235 L275 265 L235 265 Z"},
{n:"云南", l:2, d:"M170 240 L235 235 L240 285 L185 290 L160 270 Z"},
{n:"湖南", l:3, d:"M275 235 L320 235 L320 270 L280 270 Z"},
{n:"江西", l:3, d:"M320 235 L360 235 L365 275 L325 275 Z"},
{n:"福建", l:3, d:"M360 245 L395 245 L395 285 L360 280 Z"},
{n:"广东", l:4, d:"M270 270 L355 275 L355 305 L280 305 Z"},
{n:"广西", l:2, d:"M210 270 L275 270 L275 305 L215 305 Z"},
{n:"海南", l:1, d:"M250 320 L275 320 L275 340 L250 340 Z"},
{n:"台湾", l:0, d:"M395 270 L410 270 L410 300 L398 300 Z"},
];
return (
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height="100%" style={{display:"block"}}>
<defs>
<pattern id="esgGrid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#E8F1E5" strokeWidth="0.5"/>
</pattern>
</defs>
<rect x="0" y="0" width={w} height={h} fill="url(#esgGrid)"/>
{/* sea-line decoration */}
<path d="M 410 60 Q 440 130 430 220 Q 420 280 380 320" fill="none" stroke="#D5EBEB" strokeWidth="1" strokeDasharray="3 3"/>
{provs.map((p,i) => (
<g key={i}>
<path d={p.d} fill={G[p.l]} stroke="#FFFFFF" strokeWidth="1.2"/>
</g>
))}
{/* highlighted city marker — Beijing */}
<g transform="translate(345 88)">
<circle r="9" fill="#1F8B4C" opacity="0.18"/>
<circle r="4" fill="#1F8B4C"/>
<circle r="2" fill="#FFFFFF"/>
</g>
{/* Shanghai */}
<g transform="translate(398 202)">
<circle r="3.5" fill="#1F8B4C"/>
</g>
{/* Guangzhou */}
<g transform="translate(310 296)">
<circle r="3.5" fill="#1F8B4C"/>
</g>
{/* Compass / scale */}
<g transform="translate(20 320)" fontFamily="JetBrains Mono" fontSize="9" fill="#5C7A66">
<line x1="0" y1="0" x2="40" y2="0" stroke="#5C7A66" strokeWidth="1"/>
<line x1="0" y1="-3" x2="0" y2="3" stroke="#5C7A66" strokeWidth="1"/>
<line x1="40" y1="-3" x2="40" y2="3" stroke="#5C7A66" strokeWidth="1"/>
<text x="20" y="14" textAnchor="middle">800 km</text>
</g>
</svg>
);
};
// Curve helpers
const ESGSpark = ({ data, w, h, color = "#1F8B4C", fill = true, baseline = 0 }) => {
const max = Math.max(...data) * 1.1, min = Math.min(...data, 0);
const range = max - min || 1;
const pts = data.map((v,i) => `${(i/(data.length-1))*w},${h - ((v-min)/range)*(h-baseline) - baseline}`);
const d = "M" + pts.join(" L");
const fillD = d + ` L${w},${h} L0,${h} Z`;
return (
<svg width={w} height={h} style={{display:"block"}}>
{fill && <path d={fillD} fill={color} opacity="0.12"/>}
<path d={d} fill="none" stroke={color} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
};
const ESGBars = ({ data, w, h, color = "#1F8B4C", labels }) => {
const max = Math.max(...data) * 1.15;
const bw = w / data.length * 0.62;
const gap = w / data.length * 0.38;
return (
<svg width={w} height={h} style={{display:"block"}}>
{/* y gridlines */}
{[0, 0.25, 0.5, 0.75, 1].map((p,i) => (
<line key={i} x1="0" y1={h - p*h*0.85 - 14} x2={w} y2={h - p*h*0.85 - 14} stroke="#E8F1E5" strokeWidth="1"/>
))}
{data.map((v,i) => {
const bh = (v / max) * (h * 0.85);
const x = i * (bw + gap) + gap/2;
const y = h - bh - 14;
return (
<g key={i}>
<rect x={x} y={y} width={bw} height={bh} fill={color} rx="1"/>
{labels && <text x={x + bw/2} y={h - 2} textAnchor="middle" fontSize="9" fill="#8FA897" fontFamily="JetBrains Mono">{labels[i]}</text>}
</g>
);
})}
</svg>
);
};
const DonutSeg = ({ size = 140, segments, label }) => {
const r = size/2 - 12, cx = size/2, cy = size/2;
const total = segments.reduce((a,s) => a + s.v, 0);
let acc = 0;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#F0F5EE" strokeWidth="14"/>
{segments.map((s,i) => {
const start = (acc / total) * Math.PI * 2 - Math.PI/2;
acc += s.v;
const end = (acc / total) * Math.PI * 2 - Math.PI/2;
const large = (end - start) > Math.PI ? 1 : 0;
const x1 = cx + r * Math.cos(start), y1 = cy + r * Math.sin(start);
const x2 = cx + r * Math.cos(end), y2 = cy + r * Math.sin(end);
return (
<path key={i} d={`M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`}
stroke={s.c} strokeWidth="14" fill="none" strokeLinecap="butt"/>
);
})}
<text x={cx} y={cy - 4} textAnchor="middle" fontSize="10" fill="#8FA897">合计</text>
<text x={cx} y={cy + 14} textAnchor="middle" fontSize="20" fontWeight="600" fill="#1A2A1F" fontFamily="JetBrains Mono">{label}</text>
</svg>
);
};
const ArtboardESG = () => {
// mock data
const monthlyReduction = [0.95, 1.10, 1.32, 1.55, 1.42, 1.38, 0, 0, 0, 0, 0, 0]; // 吨
const monthLabels = ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"];
const mileageMonthly = [120, 145, 168, 152, 195, 180, 0,0,0,0,0,0];
const h2Monthly = [180, 220, 255, 235, 290, 270, 0,0,0,0,0,0];
const vehicles = [
{p:"浙F·8A03F", km:"18,250 km", h2:"257 m³", reduction:"24.38 kg", revenue:"18.785 元"},
{p:"浙F·2C57G", km:"5,367 km", h2:"75 m³", reduction:"7.13 kg", revenue:"181.785 元"},
{p:"浙F·9D14B", km:"45,000 km", h2:"234 m³", reduction:"12.82 kg", revenue:"194.382 元"},
{p:"浙F·6E72H", km:"55,387 km", h2:"218 m³", reduction:"17.94 kg", revenue:"152.578 元"},
{p:"浙F·1B49K", km:"55,925 km", h2:"203 m³", reduction:"17.87 kg", revenue:"148.392 元"},
{p:"浙F·4F88M", km:"887,820 km",h2:"152 m³", reduction:"9.6 kg", revenue:"73.627 元"},
{p:"浙F·7G31N", km:"3,762 km", h2:"134 m³", reduction:"13.91 kg", revenue:"66.991 元"},
{p:"浙F·3H56P", km:"30,058 km", h2:"125 m³", reduction:"13.87 kg", revenue:"82.578 元"},
{p:"浙F·5J92Q", km:"3,701 km", h2:"121 m³", reduction:"8.49 kg", revenue:"103.928 元"},
{p:"浙F·8K27R", km:"5,829 km", h2:"165 m³", reduction:"15.62 kg", revenue:"76.354 元"},
{p:"浙F·2L68S", km:"73,587 km", h2:"185 m³", reduction:"4.85 kg", revenue:"54.812 元"},
{p:"浙F·9M03T", km:"38,747 km", h2:"168 m³", reduction:"11.57 kg", revenue:"72.836 元"},
];
const trades = [
{ex:"上海环境能源交易所", item:"SHEA", price:"74.28", region:"中国·上海"},
{ex:"湖北碳排放权交易中心", item:"CCER", price:"39.33", region:"中国·武汉"},
{ex:"全国碳市场自愿减排", item:"CCER", price:"86.55", region:"全国"},
{ex:"福建海峡股权交易中心", item:"碳排放配额", price:"25", region:"中国·福州"},
{ex:"天津排放权交易所", item:"碳排放配额", price:"73.60", region:"中国·天津"},
{ex:"广东省碳排放权交易所", item:"碳排放配额", price:"82.50", region:"中国·广州"},
];
const fleetMix = [
{n:"4.5吨冷链车", v:36.2, c:"#1F8B4C"},
{n:"18吨重卡", v:4.0, c:"#9DD3A6"},
{n:"49吨牵引车", v:21.7, c:"#4FB46E"},
{n:"18吨厢式物流车",v:29.5, c:"#76C18B"},
{n:"4.5吨货车", v:6.6, c:"#C5E2BD"},
{n:"客车", v:2.1, c:"#E5F1DF"},
];
return (
<div className="app" data-theme="light" style={{background:"#F2F5EF", colorScheme:"light"}}>
<Sidebar active="esg"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
{/* Top brand bar */}
<div style={{
height:48, flex:"0 0 48px",
background:"#FFFFFF",
borderBottom:"1px solid var(--border-1)",
display:"flex", alignItems:"center", padding:"0 20px", gap:16,
}}>
<div className="mid gap-2">
<img src="assets/logo_light.svg" alt="羚牛氢能 Lingniu" style={{height:32, display:"block"}}/>
<div style={{fontSize:9, color:"#5C7A66", letterSpacing:"0.12em", fontFamily:"JetBrains Mono", paddingLeft:6, borderLeft:"1px solid #D4E2D5", marginLeft:4}}>
HYDROGEN<br/>MOBILITY
</div>
</div>
<div style={{flex:1, textAlign:"center", fontWeight:500, color:"#1F8B4C", letterSpacing:"0.06em", fontSize:18, fontFamily:"IBM Plex Sans"}}>
Lingniu ESG Link
</div>
<div className="mono" style={{fontSize:11, color:"#1F8B4C", background:"#DCEFD7", padding:"4px 10px", borderRadius:4, border:"1px solid #B5DDB1"}}>2026-04-28 周二 12:15:13</div>
<div className="icon-btn" style={{color:"#5C7A66"}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .61.36 1.16.91 1.39l.13.05A1.65 1.65 0 0 0 21 13"/></svg>
</div>
</div>
{/* Body grid */}
<div className="scroll" style={{flex:1, padding:14, display:"grid", gridTemplateColumns:"380px 1fr 380px", gap:12, gridAutoRows:"min-content"}}>
{/* ── LEFT COLUMN ── */}
<div className="col gap-3" style={{gap:12}}>
{/* Two top KPIs: emissions & H₂ */}
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
<div className="panel" style={{padding:14}}>
<div className="between">
<div>
<div style={{fontSize:11, color:"#5C7A66"}}>当日减碳量</div>
<div className="mono" style={{fontSize:22, fontWeight:600, color:"#1F8B4C", marginTop:4}}>29486.78<span style={{fontSize:11, marginLeft:3, color:"#5C7A66", fontWeight:400}}>kg</span></div>
</div>
<svg width="34" height="34" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" fill="#DCEFD7"/>
<path d="M20 8 c-4 4 -8 8 -8 14 a8 8 0 0 0 16 0 c0 -6 -4 -10 -8 -14z" fill="#1F8B4C"/>
</svg>
</div>
</div>
<div className="panel" style={{padding:14}}>
<div className="between">
<div>
<div style={{fontSize:11, color:"#5C7A66"}}>当日H₂用量</div>
<div className="mono" style={{fontSize:22, fontWeight:600, color:"#1F8B4C", marginTop:4}}>974.7<span style={{fontSize:11, marginLeft:3, color:"#5C7A66", fontWeight:400}}>kg</span></div>
</div>
<svg width="34" height="34" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" fill="#D5EBEB"/>
<circle cx="14" cy="22" r="3" fill="#2E8C8C"/>
<circle cx="22" cy="16" r="2.4" fill="#2E8C8C"/>
<circle cx="26" cy="24" r="3.6" fill="#2E8C8C"/>
<circle cx="18" cy="14" r="2" fill="#2E8C8C"/>
</svg>
</div>
</div>
</div>
{/* Annual cumulative reduction — hero card */}
<div className="panel" style={{
padding:16, position:"relative", overflow:"hidden",
background:"linear-gradient(135deg, #DCEFD7 0%, #FFFFFF 70%)",
border:"1px solid #B5DDB1",
}}>
<div style={{fontSize:11, color:"#5C7A66"}}>今年累计减碳</div>
<div style={{display:"flex", alignItems:"baseline", gap:6, marginTop:6}}>
<span className="mono" style={{fontSize:34, fontWeight:600, color:"#1F8B4C", letterSpacing:"-0.02em"}}>4567.14</span>
<span style={{fontSize:13, color:"#5C7A66"}}></span>
</div>
<div style={{fontSize:10, color:"#5C7A66", marginTop:4, display:"flex", alignItems:"center", gap:6}}>
<span style={{display:"inline-block", width:14, height:1, background:"#1F8B4C"}}/>
相当于种植 18.5 万棵树
</div>
{/* abstract tree silhouette */}
<svg width="100%" height="50" viewBox="0 0 280 50" style={{marginTop:10, opacity:0.55}}>
{[...Array(28)].map((_,i) => {
const x = i*10 + 4;
const heights = [22, 30, 26, 34, 28, 32, 24, 36, 30, 28, 32, 26, 34, 30];
const h = heights[i % heights.length];
return (
<g key={i} transform={`translate(${x} ${50-h})`}>
<path d={`M 4 ${h} L 0 ${h-h*0.7} L 3 ${h-h*0.7} L -1 ${h-h*0.85} L 2 ${h-h*0.85} L 0 ${h} z M 4 ${h} L 8 ${h-h*0.7} L 5 ${h-h*0.7} L 9 ${h-h*0.85} L 6 ${h-h*0.85} L 8 ${h} z`} fill="#1F8B4C" opacity={0.4 + (i%3)*0.18}/>
<rect x="3.5" y={h-2} width="1" height="2" fill="#5C7A66"/>
</g>
);
})}
</svg>
</div>
{/* Monthly reduction bars */}
<div className="panel" style={{padding:14}}>
<div className="between" style={{marginBottom:10}}>
<span style={{fontWeight:600, fontSize:13, color:"#1A2A1F"}}>月度碳减排</span>
<span className="chip" style={{fontSize:10}}>单位 · </span>
</div>
<ESGBars data={monthlyReduction} w={350} h={150} labels={monthLabels}/>
</div>
{/* Monthly mileage / H2 */}
<div className="panel" style={{padding:14}}>
<div className="between" style={{marginBottom:10}}>
<span style={{fontWeight:600, fontSize:13, color:"#1A2A1F"}}>月度行驶里程 & 用氢量</span>
</div>
<div className="mid gap-3" style={{fontSize:10, color:"#5C7A66", marginBottom:8}}>
<span className="mid gap-1"><span className="dot" style={{background:"#1F8B4C", width:8, height:8, borderRadius:1}}/>用氢量</span>
<span className="mid gap-1"><span className="dot" style={{background:"#9DD3A6", width:8, height:8, borderRadius:1}}/>行驶里程</span>
<span style={{marginLeft:"auto", fontFamily:"JetBrains Mono"}}>kg / km</span>
</div>
<svg width="350" height="135" viewBox="0 0 350 135" style={{display:"block"}}>
{[0, 0.25, 0.5, 0.75, 1].map((p,i) => (
<line key={i} x1="0" y1={120 - p*100} x2="350" y2={120 - p*100} stroke="#E8F1E5" strokeWidth="1"/>
))}
{[0, 0.25, 0.5, 0.75, 1].map((p,i) => (
<text key={i} x="0" y={124 - p*100} fontSize="9" fill="#8FA897" fontFamily="JetBrains Mono">{Math.round(p*400)}</text>
))}
{/* Curves */}
{(() => {
const m = (arr, max) => arr.map((v,i)=>`${(i/(arr.length-1))*350},${120 - (v/max)*100}`);
const p1 = "M" + m(h2Monthly, 400).join(" L");
const p2 = "M" + m(mileageMonthly, 250).join(" L");
return (
<>
<path d={p1 + " L350,120 L0,120 z"} fill="#1F8B4C" opacity="0.13"/>
<path d={p1} fill="none" stroke="#1F8B4C" strokeWidth="2"/>
<path d={p2} fill="none" stroke="#9DD3A6" strokeWidth="2"/>
</>
);
})()}
{monthLabels.map((l,i) => (
<text key={i} x={(i/(monthLabels.length-1))*350} y="132" textAnchor="middle" fontSize="8" fill="#8FA897" fontFamily="JetBrains Mono">{l}</text>
))}
</svg>
</div>
</div>
{/* ── CENTER COLUMN ── */}
<div className="col" style={{gap:12}}>
{/* Map panel */}
<div className="panel" style={{padding:14, paddingBottom:18}}>
<div className="between">
<div className="mid gap-2">
<span style={{fontWeight:600, fontSize:14, color:"#1A2A1F"}}>羚牛全国车辆信息</span>
<span className="chip accent" style={{fontSize:10}}>加氢站</span>
</div>
<span className="chip" style={{fontSize:10, color:"#1F8B4C", borderColor:"#B5DDB1", background:"#DCEFD7"}}>实时反馈</span>
</div>
<div style={{position:"relative", marginTop:10, height:380}}>
<ChinaMapMini w={580} h={380}/>
{/* Overlay info card */}
<div style={{
position:"absolute", top:30, left:200,
background:"rgba(255,255,255,0.95)", padding:"10px 14px",
borderRadius:6, border:"1px solid #B5DDB1", fontSize:11, color:"#2E4234",
boxShadow:"0 4px 16px rgba(31,80,46,.08)",
}}>
<div style={{fontSize:11, color:"#1F8B4C", fontWeight:600}}>呼和浩特市钢铁工业园区</div>
<div className="mid" style={{gap:14, marginTop:6, fontFamily:"JetBrains Mono", fontSize:10}}>
<div><div style={{color:"#8FA897"}}>GPS实时数</div><div style={{color:"#1A2A1F", fontWeight:600}}>17</div></div>
<div><div style={{color:"#8FA897"}}>当日总减碳</div><div style={{color:"#1A2A1F", fontWeight:600}}>2469.62 kg</div></div>
<div><div style={{color:"#8FA897"}}>当日加氢量</div><div style={{color:"#1A2A1F", fontWeight:600}}>9.31 kg</div></div>
<div><div style={{color:"#8FA897"}}>当日里程</div><div style={{color:"#1A2A1F", fontWeight:600}}>724.6 kg</div></div>
</div>
</div>
{/* Legend */}
<div style={{position:"absolute", bottom:16, left:16, fontSize:10, color:"#5C7A66"}}>
<div style={{marginBottom:4, fontWeight:600, color:"#2E4234"}}>车辆数</div>
{[
{l:"≥ 300 辆", c:"#1F8B4C"},
{l:"100300 辆", c:"#4FB46E"},
{l:"50100 辆", c:"#9DD3A6"},
{l:"< 50 辆", c:"#D7EBD2"},
].map((x,i) => (
<div key={i} className="mid gap-1" style={{marginTop:2}}>
<span style={{display:"inline-block", width:14, height:10, background:x.c, border:"1px solid #FFFFFF"}}/>
<span style={{fontFamily:"JetBrains Mono"}}>{x.l}</span>
</div>
))}
</div>
</div>
</div>
{/* Carbon trades table */}
<div className="panel" style={{padding:0}}>
<div className="between" style={{padding:"12px 14px", borderBottom:"1px solid var(--border-1)"}}>
<span style={{fontWeight:600, fontSize:13, color:"#1A2A1F"}}>碳交易行情</span>
<span className="chip" style={{fontSize:10}}>实时报价</span>
</div>
<table className="tbl" style={{fontSize:11}}>
<thead>
<tr><th>交易所</th><th>项目</th><th>价格 (RMB)</th><th>地区</th></tr>
</thead>
<tbody>
{trades.map((t,i) => (
<tr key={i}>
<td style={{color:"#1A2A1F"}}>{t.ex}</td>
<td className="mono" style={{color:"#1F8B4C", fontWeight:600}}>{t.item}</td>
<td className="mono" style={{color:"#1A2A1F", fontWeight:600}}>{t.price}</td>
<td style={{color:"#5C7A66"}}>{t.region}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ── RIGHT COLUMN ── */}
<div className="col" style={{gap:12}}>
{/* Two top KPIs: vehicle total & cumulative mileage */}
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
<div className="panel" style={{padding:14}}>
<div className="between">
<div>
<div style={{fontSize:11, color:"#5C7A66"}}>车辆总数</div>
<div className="mono" style={{fontSize:22, fontWeight:600, color:"#1F8B4C", marginTop:4}}>1006<span style={{fontSize:11, marginLeft:3, color:"#5C7A66", fontWeight:400}}></span></div>
</div>
<svg width="34" height="34" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" fill="#DCEFD7"/>
<rect x="11" y="17" width="18" height="10" rx="2" fill="#1F8B4C"/>
<circle cx="14" cy="29" r="2" fill="#1F8B4C"/>
<circle cx="26" cy="29" r="2" fill="#1F8B4C"/>
</svg>
</div>
</div>
<div className="panel" style={{padding:14}}>
<div className="between">
<div>
<div style={{fontSize:11, color:"#5C7A66"}}>当日行驶里程</div>
<div className="mono" style={{fontSize:22, fontWeight:600, color:"#1F8B4C", marginTop:4}}>64508.42<span style={{fontSize:11, marginLeft:3, color:"#5C7A66", fontWeight:400}}>km</span></div>
</div>
<svg width="34" height="34" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" fill="#D5EBEB"/>
<path d="M10 22 a10 10 0 0 1 20 0" fill="none" stroke="#2E8C8C" strokeWidth="2"/>
<path d="M20 22 L25 14" stroke="#2E8C8C" strokeWidth="2" strokeLinecap="round"/>
<circle cx="20" cy="22" r="1.5" fill="#2E8C8C"/>
</svg>
</div>
</div>
</div>
{/* Vehicle live monitor table */}
<div className="panel" style={{padding:0, flex:1, minHeight:380, display:"flex", flexDirection:"column"}}>
<div className="between" style={{padding:"12px 14px", borderBottom:"1px solid var(--border-1)"}}>
<span style={{fontWeight:600, fontSize:13, color:"#1A2A1F"}}>车辆实时监控</span>
<span className="chip ok" style={{fontSize:10}}>· LIVE</span>
</div>
<div className="scroll" style={{flex:1}}>
<table className="tbl" style={{fontSize:11}}>
<thead>
<tr>
<th>车牌号</th><th>总里程</th><th>当日里程</th><th>当日减碳</th>
</tr>
</thead>
<tbody>
{vehicles.map((v,i) => (
<tr key={i}>
<td className="mono" style={{color:"#1A2A1F", fontWeight:600}}>{v.p}</td>
<td className="mono" style={{color:"#5C7A66"}}>{v.km}</td>
<td className="mono" style={{color:"#5C7A66"}}>{v.h2}</td>
<td className="mono" style={{color:"#1F8B4C", fontWeight:600}}>{v.reduction}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Fleet mix donut */}
<div className="panel" style={{padding:14}}>
<div className="between" style={{marginBottom:10}}>
<span style={{fontWeight:600, fontSize:13, color:"#1A2A1F"}}>车型结构分析</span>
</div>
<div className="mid gap-3" style={{alignItems:"center"}}>
<DonutSeg size={130} segments={fleetMix} label="1006"/>
<div style={{flex:1, display:"flex", flexDirection:"column", gap:5, fontSize:11}}>
{fleetMix.map((f,i) => (
<div key={i} className="between">
<span className="mid gap-2">
<span style={{width:10, height:10, borderRadius:2, background:f.c}}/>
<span style={{color:"#5C7A66"}}>{f.n}</span>
</span>
<span className="mono" style={{color:"#1A2A1F", fontWeight:600}}>{f.v}%</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div style={{
height:24, flex:"0 0 24px",
borderTop:"1px solid var(--border-1)", background:"#FFFFFF",
display:"flex", alignItems:"center", justifyContent:"center", gap:14,
fontSize:10, color:"#8FA897",
}}>
<span>© 2026 羚牛氢能 · Lingniu Hydrogen Mobility · All Rights Reserved</span>
<span style={{color:"#1F8B4C"}}>· API 接口处理 · Build v4.2.0-stable</span>
</div>
</div>
</div>
);
};
window.ArtboardESG = ArtboardESG;

606
artboards/history.jsx Normal file
View File

@@ -0,0 +1,606 @@
// artboard-history.jsx — 数据检索 (data search studio)
// Flow: ① 选车辆+时段 → ② 选数据项目 → ③ 选展示方式 → ④ 渲染结果
const DATA_GROUPS = [
{
id: "vehicle", label: "车辆运行", icon: "car", color: "var(--info)",
items: [
{id:"speed", l:"速度", u:"km/h", src:"TBOX", freq:"10s"},
{id:"odometer", l:"累计里程", u:"km", src:"TBOX", freq:"60s"},
{id:"trip_km", l:"行程里程", u:"km", src:"TBOX", freq:"事件"},
{id:"engine_run", l:"运行时长", u:"s", src:"TBOX", freq:"60s"},
{id:"gear", l:"档位", u:"-", src:"TBOX", freq:"10s"},
{id:"steer", l:"方向盘转角", u:"°", src:"CAN", freq:"100ms"},
],
},
{
id: "energy", label: "氢电系统", icon: "h2", color: "var(--accent)",
items: [
{id:"soc", l:"动力电池 SOC", u:"%", src:"BMS", freq:"10s"},
{id:"batt_volt", l:"电池总压", u:"V", src:"BMS", freq:"10s"},
{id:"batt_curr", l:"电池电流", u:"A", src:"BMS", freq:"10s"},
{id:"batt_temp", l:"电池温度", u:"℃", src:"BMS", freq:"10s"},
{id:"h2_pressure",l:"H₂ 压力", u:"MPa", src:"FCU", freq:"10s"},
{id:"h2_flow", l:"H₂ 流量", u:"g/s", src:"FCU", freq:"10s"},
{id:"fc_power", l:"电堆输出功率", u:"kW", src:"FCU", freq:"10s"},
{id:"fc_temp", l:"电堆温度", u:"℃", src:"FCU", freq:"10s"},
],
},
{
id: "chassis", label: "底盘 & 安全", icon: "shield", color: "var(--warn)",
items: [
{id:"tire_p_fl", l:"胎压 左前", u:"MPa", src:"TPMS", freq:"60s"},
{id:"tire_p_fr", l:"胎压 右前", u:"MPa", src:"TPMS", freq:"60s"},
{id:"tire_p_rl", l:"胎压 左后", u:"MPa", src:"TPMS", freq:"60s"},
{id:"tire_p_rr", l:"胎压 右后", u:"MPa", src:"TPMS", freq:"60s"},
{id:"brake", l:"制动信号", u:"0/1", src:"CAN", freq:"100ms"},
{id:"airbag", l:"安全气囊状态", u:"0/1", src:"CAN", freq:"事件"},
{id:"abs", l:"ABS 状态", u:"0/1", src:"CAN", freq:"事件"},
],
},
{
id: "location", label: "定位 & 通讯", icon: "pin", color: "var(--info)",
items: [
{id:"gps_lat", l:"GPS 纬度", u:"°", src:"JT808", freq:"10s"},
{id:"gps_lng", l:"GPS 经度", u:"°", src:"JT808", freq:"10s"},
{id:"gps_alt", l:"海拔", u:"m", src:"JT808", freq:"10s"},
{id:"signal", l:"信号强度", u:"dBm", src:"TBOX", freq:"60s"},
{id:"network", l:"网络类型", u:"-", src:"TBOX", freq:"60s"},
],
},
{
id: "driving", label: "驾驶行为", icon: "speed", color: "var(--accent)",
items: [
{id:"hard_acc", l:"急加速次数", u:"次/h", src:"TBOX", freq:"事件"},
{id:"hard_brake", l:"急刹车次数", u:"次/h", src:"TBOX", freq:"事件"},
{id:"sharp_turn", l:"急转弯次数", u:"次/h", src:"TBOX", freq:"事件"},
{id:"overspeed", l:"超速时长", u:"s/h", src:"TBOX", freq:"事件"},
{id:"score", l:"驾驶评分", u:"分", src:"算法", freq:"日"},
],
},
];
const VIEW_MODES = [
{id:"line", l:"曲线图", ic:"chart", d:"时间序列趋势"},
{id:"area", l:"面积图", ic:"pulse", d:"叠加趋势对比"},
{id:"bar", l:"柱状图", ic:"layers", d:"分时统计"},
{id:"table", l:"数据表", ic:"list", d:"按时间戳列表"},
{id:"heat", l:"热力日历", ic:"history", d:"按日聚合"},
{id:"summary",l:"统计摘要", ic:"gauge", d:"min/max/avg/p95"},
];
const QUICK_RANGES = [
{id:"1h", l:"近1小时"}, {id:"6h", l:"近6小时"}, {id:"24h", l:"近24小时"},
{id:"7d", l:"近7日"}, {id:"30d", l:"近30日"}, {id:"custom", l:"自定义"},
];
const ArtboardHistory = () => {
// Wizard step: 1 选范围, 2 选项目, 3 展示
const [step, setStep] = React.useState(3);
const [vehicle] = React.useState({plate:"浙F03980F", vin:"LJ2A...8814", dept:"业务一部"});
const [range, setRange] = React.useState("24h");
const [dateFrom] = React.useState("2026-04-27 14:02");
const [dateTo] = React.useState("2026-04-28 14:02");
// Selected data items (ids)
const [picked, setPicked] = React.useState(new Set(["speed","soc","h2_pressure"]));
const [activeGroup, setActiveGroup] = React.useState("vehicle");
// Visualization mode
const [view, setView] = React.useState("line");
const togglePick = (id) => {
const next = new Set(picked);
if (next.has(id)) next.delete(id); else next.add(id);
setPicked(next);
};
const pickedItems = DATA_GROUPS.flatMap(g => g.items.filter(it => picked.has(it.id)).map(it => ({...it, group:g})));
return (
<div className="app">
<Sidebar active="history"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar crumbs={["数据检索", `${vehicle.plate}`]} kpis={[]} showSearch={false}/>
{/* Step indicator */}
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:24, alignItems:"center"}}>
{[
{n:1, l:"选择范围"},
{n:2, l:"选择数据项目"},
{n:3, l:"选择展示方式"},
].map((s,i,arr)=>(
<React.Fragment key={s.n}>
<div className="mid gap-2" style={{cursor:"pointer", opacity: step >= s.n ? 1 : 0.5}} onClick={() => setStep(s.n)}>
<span style={{
width:22, height:22, borderRadius:11,
background: step >= s.n ? "var(--accent)" : "var(--bg-3)",
color: step >= s.n ? "#fff" : "var(--fg-3)",
display:"grid", placeItems:"center",
fontSize:11, fontWeight:600, fontFamily:"var(--font-mono)",
}}>{s.n}</span>
<span className={step === s.n ? "strong" : ""} style={{fontSize:12}}>{s.l}</span>
</div>
{i < arr.length - 1 && <span style={{flex:0, width:32, height:1, background:"var(--border-2)"}}/>}
</React.Fragment>
))}
<div style={{marginLeft:"auto", display:"flex", gap:6, alignItems:"center"}}>
<span className="muted" style={{fontSize:11}}>已选 <span className="strong" style={{color:"var(--accent)"}}>{picked.size}</span> </span>
<span style={{width:1, height:18, background:"var(--border-1)"}}/>
<button className="btn" onClick={() => location.hash = "#/compare"}><Icon name="layers" size={12}/> 多车对比</button>
<button className="btn"><Icon name="download" size={12}/> 导出 CSV</button>
<button className="btn"><Icon name="bookmark" size={12}/> 保存查询</button>
</div>
</div>
{/* Main */}
<div style={{flex:1, display:"grid", gridTemplateColumns:"320px 1fr", minHeight:0}}>
{/* LEFT: range + data items selector */}
<div style={{borderRight:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
{/* Vehicle + time */}
<div style={{padding:14, borderBottom:"1px solid var(--border-1)"}}>
<div className="eyebrow" style={{marginBottom:8}}>查询对象</div>
<div className="mid gap-2" style={{padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6}}>
<Icon name="car" size={14} style={{color:"var(--accent)"}}/>
<div style={{flex:1}}>
<div className="mono strong" style={{fontSize:12}}>{vehicle.plate}</div>
<div className="muted" style={{fontSize:10}}>{vehicle.vin} · {vehicle.dept}</div>
</div>
<Icon name="chevDown" size={11} style={{color:"var(--fg-3)"}}/>
</div>
<div className="eyebrow" style={{marginTop:14, marginBottom:8}}>时间范围</div>
<div style={{display:"grid", gridTemplateColumns:"repeat(3, 1fr)", gap:4, marginBottom:8}}>
{QUICK_RANGES.map(r => (
<span key={r.id}
onClick={() => setRange(r.id)}
className={"chip " + (range === r.id ? "accent" : "")}
style={{justifyContent:"center", cursor:"pointer", fontSize:10, padding:"4px 0"}}>
{r.l}
</span>
))}
</div>
<div style={{padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6, fontSize:11, fontFamily:"var(--font-mono)"}}>
<div className="muted" style={{fontSize:9, marginBottom:2}}> </div>
<div>{dateFrom}</div>
<div>{dateTo}</div>
</div>
</div>
{/* Data item picker */}
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="cube" size={13}/>
<span className="title">数据项目</span>
<span className="chip accent" style={{marginLeft:"auto"}}>已选 {picked.size}</span>
</div>
<div style={{display:"flex", borderBottom:"1px solid var(--border-1)", padding:"6px 10px", gap:4, flexWrap:"wrap", background:"var(--bg-2)"}}>
{DATA_GROUPS.map(g => (
<span key={g.id}
onClick={() => setActiveGroup(g.id)}
className={"chip " + (activeGroup === g.id ? "accent" : "")}
style={{cursor:"pointer", fontSize:10, padding:"3px 8px"}}>
<Icon name={g.icon} size={10}/> {g.label}
<span className="mono" style={{marginLeft:4, opacity:.7}}>
{g.items.filter(it => picked.has(it.id)).length || g.items.length}
</span>
</span>
))}
</div>
<div className="scroll" style={{flex:1}}>
{DATA_GROUPS.filter(g => g.id === activeGroup).map(g => (
<div key={g.id}>
{g.items.map(it => {
const on = picked.has(it.id);
return (
<div key={it.id}
onClick={() => togglePick(it.id)}
style={{
display:"flex", alignItems:"center", gap:10,
padding:"9px 14px", borderBottom:"1px solid var(--border-1)",
cursor:"pointer",
background: on ? "var(--accent-soft)" : "transparent",
}}>
<span style={{
width:14, height:14, borderRadius:3,
border: "1.5px solid " + (on ? "var(--accent)" : "var(--border-2)"),
background: on ? "var(--accent)" : "transparent",
display:"grid", placeItems:"center",
flexShrink:0,
}}>
{on && <svg width="9" height="9" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-6" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</span>
<div style={{flex:1, minWidth:0}}>
<div className="between">
<span className="strong" style={{fontSize:12}}>{it.l}</span>
<span className="mono muted" style={{fontSize:10}}>{it.u}</span>
</div>
<div className="mid gap-1" style={{marginTop:2}}>
<span style={{fontSize:9, padding:"1px 5px", borderRadius:2, background:"var(--bg-2)", border:"1px solid var(--border-1)", color: g.color, fontFamily:"var(--font-mono)"}}>{it.src}</span>
<span className="muted mono" style={{fontSize:9}}>· {it.freq}</span>
</div>
</div>
</div>
);
})}
</div>
))}
</div>
<div style={{padding:"10px 14px", borderTop:"1px solid var(--border-1)", display:"flex", gap:6}}>
<button className="btn" style={{flex:1}} onClick={() => setPicked(new Set())}>清空</button>
<button className="btn primary" style={{flex:2}}>
<Icon name="search" size={12}/> 查询 {picked.size}
</button>
</div>
</div>
{/* RIGHT: visualization */}
<div style={{display:"flex", flexDirection:"column", minHeight:0, background:"var(--bg-2)"}}>
{/* View-mode picker */}
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:6, alignItems:"center", flexWrap:"wrap"}}>
<span className="muted" style={{fontSize:11, marginRight:4}}>展示</span>
{VIEW_MODES.map(v => (
<span key={v.id}
onClick={() => setView(v.id)}
style={{
display:"flex", alignItems:"center", gap:6,
padding:"5px 10px", borderRadius:5, cursor:"pointer", fontSize:11,
background: view === v.id ? "var(--accent-soft)" : "transparent",
border: "1px solid " + (view === v.id ? "var(--accent)" : "var(--border-1)"),
color: view === v.id ? "var(--accent)" : "var(--fg-1)",
}}>
<Icon name={v.ic} size={12}/>
<span>{v.l}</span>
</span>
))}
<div style={{marginLeft:"auto"}}>
<span className="muted" style={{fontSize:11}}>采样</span>
<span className="chip">原始</span>
<span className="chip" style={{marginLeft:4}}>1分钟</span>
<span className="chip accent" style={{marginLeft:4}}>5分钟</span>
<span className="chip" style={{marginLeft:4}}>1小时</span>
</div>
</div>
{/* Selected items pills */}
<div style={{padding:"8px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:6, flexWrap:"wrap", alignItems:"center"}}>
{pickedItems.length === 0 && <span className="muted" style={{fontSize:11}}>请从左侧选择数据项</span>}
{pickedItems.map(it => (
<span key={it.id} className="chip" style={{
fontSize:10, padding:"3px 8px",
background:"var(--bg-2)",
borderColor: it.group.color, color: it.group.color,
}}>
<span style={{width:6, height:6, borderRadius:3, background: it.group.color}}/>
{it.l}
<span className="muted" style={{fontSize:9, marginLeft:2}}>{it.u}</span>
<span style={{cursor:"pointer", marginLeft:2}} onClick={() => togglePick(it.id)}>×</span>
</span>
))}
</div>
{/* Render area */}
<div className="scroll" style={{flex:1, padding:16}}>
{view === "line" && <LineView items={pickedItems}/>}
{view === "area" && <AreaView items={pickedItems}/>}
{view === "bar" && <BarView items={pickedItems}/>}
{view === "table" && <TableView items={pickedItems}/>}
{view === "heat" && <HeatView items={pickedItems}/>}
{view === "summary" && <SummaryView items={pickedItems}/>}
</div>
</div>
</div>
</div>
</div>
);
};
// ── Visualizations ───────────────────────────────────────────
const colorFor = (it, fallback) => {
if (it.id === "speed" || it.id.startsWith("hard_") || it.id === "h2_flow") return "var(--info)";
if (it.id === "soc" || it.id.startsWith("batt") || it.id === "fc_power") return "var(--accent)";
if (it.id.startsWith("h2_") || it.id.startsWith("fc_temp")) return "var(--warn)";
if (it.id.startsWith("tire_")) return "var(--danger)";
return it.group?.color || fallback || "var(--accent)";
};
const synth = (id, n=120) => {
// Deterministic per-id pseudo-random series
const seed = id.split("").reduce((s,c) => s + c.charCodeAt(0), 0);
const out = [];
for (let i=0;i<n;i++) {
const v = Math.sin((i+seed) * 0.18) * 0.4 + Math.cos((i+seed*0.7) * 0.07) * 0.3 + 0.5
+ Math.sin((i+seed*1.3) * 0.5) * 0.08;
out.push(Math.max(0.02, Math.min(0.98, v)));
}
return out;
};
const LineView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div className="col gap-3">
{items.map(it => (
<div key={it.id} className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
<span className="title">{it.l}</span>
<span className="muted mono" style={{fontSize:10, marginLeft:4}}>{it.u}</span>
<div className="actions">
<span className="mono muted" style={{fontSize:10}}>{it.src} · {it.freq}</span>
<span className="chip"><Icon name="download" size={9}/> CSV</span>
<span className="chip"><Icon name="expand" size={9}/></span>
</div>
</div>
<div style={{padding:"14px 16px"}}>
<Sparkline data={synth(it.id, 240)} h={120} color={colorFor(it)} fill axis/>
<TimeAxis/>
</div>
</div>
))}
</div>
);
};
const AreaView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="pulse" size={12}/>
<span className="title">{items.length} 项叠加</span>
<div className="actions">
{items.map(it => (
<span key={it.id} className="mid gap-1" style={{fontSize:10}}>
<span style={{width:8, height:8, borderRadius:2, background: colorFor(it)}}/>
{it.l}
</span>
))}
</div>
</div>
<div style={{padding:"14px 16px", position:"relative"}}>
<svg width="100%" height="320" viewBox="0 0 800 320" preserveAspectRatio="none" style={{display:"block"}}>
{[0,0.25,0.5,0.75,1].map((p,i) => (
<line key={i} x1="0" y1={300*p+10} x2="800" y2={300*p+10} stroke="var(--border-1)" strokeWidth="1"/>
))}
{items.map((it,idx) => {
const data = synth(it.id, 240);
const path = data.map((v,i) => `${(i/(data.length-1))*800},${310 - v*300}`).join(" L");
return (
<g key={it.id}>
<path d={`M ${path} L 800,310 L 0,310 Z`} fill={colorFor(it)} opacity={0.12}/>
<path d={`M ${path}`} fill="none" stroke={colorFor(it)} strokeWidth="1.6"/>
</g>
);
})}
</svg>
<TimeAxis/>
</div>
</div>
);
};
const BarView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
const buckets = ["00","02","04","06","08","10","12","14","16","18","20","22"];
return (
<div className="col gap-3">
{items.map(it => {
const data = synth(it.id, buckets.length).map(v => v * 100);
return (
<div key={it.id} className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
<span className="title">{it.l} <span className="muted" style={{fontSize:10, marginLeft:4}}>· 2 小时聚合</span></span>
<div className="actions"><span className="mono muted" style={{fontSize:10}}>{it.u}</span></div>
</div>
<div style={{padding:"14px 16px"}}>
<svg width="100%" height="120" viewBox="0 0 720 120" preserveAspectRatio="none" style={{display:"block"}}>
{data.map((v,i) => {
const x = i * (720/data.length) + 8;
const w = (720/data.length) - 16;
const h = (v/100) * 100;
return (
<g key={i}>
<rect x={x} y={110-h} width={w} height={h} fill={colorFor(it)} opacity="0.85" rx="2"/>
<text x={x+w/2} y="118" textAnchor="middle" fontSize="9" fill="var(--fg-3)" fontFamily="var(--font-mono)">{buckets[i]}</text>
</g>
);
})}
</svg>
</div>
</div>
);
})}
</div>
);
};
const TableView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
// 30 rows of timestamps
const rows = Array.from({length:40}, (_,i) => {
const d = new Date(2026, 3, 28, 14, 0, 0);
d.setMinutes(d.getMinutes() - i * 5);
const ts = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")} ${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}:00`;
const vals = items.map(it => synth(it.id, 240)[i % 240]);
return { ts, vals };
});
return (
<div className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="list" size={12}/>
<span className="title">数据列表</span>
<span className="muted" style={{fontSize:11, marginLeft:6}}> 1,728 · 显示 40</span>
<div className="actions">
<span className="chip"><Icon name="filter" size={9}/> 筛选</span>
<span className="chip"><Icon name="download" size={9}/> CSV</span>
</div>
</div>
<div style={{maxHeight:520, overflowY:"auto"}}>
<table className="tbl">
<thead>
<tr>
<th style={{width:160, position:"sticky", top:0, background:"var(--bg-1)"}}>时间戳</th>
{items.map(it => (
<th key={it.id} style={{textAlign:"right", position:"sticky", top:0, background:"var(--bg-1)"}}>
<div>{it.l}</div>
<div className="muted mono" style={{fontSize:9, fontWeight:400}}>{it.u} · {it.src}</div>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r,i) => (
<tr key={i}>
<td className="mono muted" style={{fontSize:11}}>{r.ts}</td>
{r.vals.map((v,j) => {
const it = items[j];
let display;
if (it.id === "speed") display = (v*80).toFixed(1);
else if (it.id === "soc") display = (20 + v*70).toFixed(1);
else if (it.id.startsWith("h2_pressure")) display = (3.5 + v*1.3).toFixed(2);
else if (it.id.startsWith("tire_")) display = (2.7 + v*0.6).toFixed(2);
else if (it.id.startsWith("batt_temp")) display = (24 + v*22).toFixed(1);
else if (it.id === "odometer") display = (124820 + v*4).toFixed(1);
else display = (v*100).toFixed(2);
return <td key={j} className="mono" style={{textAlign:"right", color: colorFor(it)}}>{display}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
const HeatView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div className="col gap-3">
{items.map(it => {
const cells = synth(it.id, 30); // 30 days
return (
<div key={it.id} className="panel">
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
<span className="title">{it.l} · 30 日热力</span>
<div className="actions"><span className="mono muted" style={{fontSize:10}}>{it.u}</span></div>
</div>
<div style={{padding:"14px 16px"}}>
<div style={{display:"grid", gridTemplateColumns:"repeat(30, 1fr)", gap:3}}>
{cells.map((v,i) => (
<div key={i} title={`${i+1} 日 · ${(v*100).toFixed(0)}`}
style={{
aspectRatio:"1",
borderRadius:3,
background: colorFor(it),
opacity: 0.15 + v*0.85,
}}/>
))}
</div>
<div className="between muted mono" style={{marginTop:8, fontSize:10}}>
<span>30 日前</span><span>今日</span>
</div>
</div>
</div>
);
})}
</div>
);
};
const SummaryView = ({ items }) => {
if (!items.length) return <EmptyHint/>;
return (
<div style={{display:"grid", gridTemplateColumns:"repeat(auto-fit, minmax(280px, 1fr))", gap:12}}>
{items.map(it => {
const data = synth(it.id, 240).map(v => v*100);
const min = Math.min(...data), max = Math.max(...data);
const avg = data.reduce((s,v)=>s+v,0)/data.length;
const sorted = [...data].sort((a,b)=>a-b);
const p95 = sorted[Math.floor(sorted.length*0.95)];
const p50 = sorted[Math.floor(sorted.length*0.50)];
// Scale to plausible units
const fmt = (v) => {
if (it.id === "speed") return (v*0.8).toFixed(1);
if (it.id === "soc") return (20 + v*0.7).toFixed(1);
if (it.id.startsWith("h2_pressure")) return (3.5 + v*0.013).toFixed(2);
return v.toFixed(1);
};
return (
<div key={it.id} className="panel" style={{padding:14}}>
<div className="between" style={{marginBottom:10}}>
<div>
<div className="strong" style={{fontSize:13}}>{it.l}</div>
<div className="muted mono" style={{fontSize:10}}>{it.u} · {it.src}</div>
</div>
<span style={{width:10, height:10, borderRadius:5, background: colorFor(it)}}/>
</div>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
{[
{l:"平均", v:fmt(avg), c:colorFor(it)},
{l:"中位", v:fmt(p50), c:"var(--fg-1)"},
{l:"最大", v:fmt(max), c:"var(--danger)"},
{l:"最小", v:fmt(min), c:"var(--ok)"},
{l:"P95", v:fmt(p95), c:"var(--warn)"},
{l:"样本", v:"1,728", c:"var(--fg-2)"},
].map((s,i) => (
<div key={i} style={{padding:"6px 10px", background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
<div className="muted" style={{fontSize:10}}>{s.l}</div>
<div className="mono strong" style={{fontSize:14, color: s.c, marginTop:2}}>{s.v}</div>
</div>
))}
</div>
<div style={{marginTop:10}}>
<Sparkline data={synth(it.id, 80)} h={32} color={colorFor(it)} fill/>
</div>
</div>
);
})}
</div>
);
};
// Sparkline + axis helpers
const Sparkline = ({ data, h=80, color="var(--accent)", fill=false, axis=false }) => {
const w = 800;
const max = Math.max(...data, 0.01);
const path = data.map((v,i) => `${(i/(data.length-1))*w},${h - (v/max) * (h-12) - 6}`).join(" L");
return (
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{display:"block"}}>
{axis && [0,0.25,0.5,0.75,1].map((p,i) => (
<line key={i} x1="0" y1={(h-12)*p+6} x2={w} y2={(h-12)*p+6} stroke="var(--border-1)" strokeWidth="1"/>
))}
{fill && <path d={`M ${path} L ${w},${h} L 0,${h} Z`} fill={color} opacity="0.16"/>}
<path d={`M ${path}`} fill="none" stroke={color} strokeWidth="1.6"/>
</svg>
);
};
const TimeAxis = () => (
<div className="muted mono" style={{display:"flex", justifyContent:"space-between", marginTop:6, fontSize:10}}>
{["00:00","04:00","08:00","12:00","16:00","20:00","24:00"].map(t => <span key={t}>{t}</span>)}
</div>
);
const EmptyHint = () => (
<div style={{padding:"60px 20px", textAlign:"center", color:"var(--fg-3)"}}>
<Icon name="cube" size={48} style={{opacity:.3}}/>
<div style={{marginTop:14, fontSize:13}}>从左侧选择数据项目以开始检索</div>
<div className="muted" style={{fontSize:11, marginTop:4}}>支持速度 / SOC / H₂ 压力 / 胎压 / 驾驶行为等 30+ 字段</div>
</div>
);
window.ArtboardHistory = ArtboardHistory;

152
artboards/inbox.jsx Normal file
View File

@@ -0,0 +1,152 @@
// artboard-inbox.jsx — Notification center
const ArtboardInbox = () => {
const alerts = [
{p:"P0", n:"电池SOC严重不足", v:"浙F08638F", t:"刚刚", det:"SOC 9% < 阈值 15% · 持续 4分20秒", st:"new"},
{p:"P0", n:"右后胎压低", v:"浙F08638F", t:"3分钟前", det:"0.16 MPa · 阈值 0.20 MPa", st:"new"},
{p:"P1", n:"超速预警", v:"浙F02002F", t:"12分钟前", det:"实测 89 km/h · 限速 80 km/h · 持续 12s", st:"new"},
{p:"P1", n:"H₂压力异常下降", v:"浙F07179F", t:"32分钟前", det:"5分钟内下降 1.2 MPa · 异常", st:"ack"},
{p:"P0", n:"电堆过温保护", v:"浙F00598F", t:"1小时前", det:"电堆温度 95°C · 阈值 90°C", st:"resolved"},
{p:"P2", n:"急加速密集", v:"浙F02608F", t:"2小时前", det:"5分钟内 3 次急加速", st:"resolved"},
{p:"P1", n:"偏离规划路线", v:"浙F00278F", t:"3小时前", det:"偏离 1.2 km · 持续 6 分钟", st:"resolved"},
];
return (
<div className="app">
<Sidebar active="inbox"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar
crumbs={["通知中心", "告警时间线"]}
kpis={[
{lbl:"未处理", val:"3"},
{lbl:"今日", val:"24"},
{lbl:"本周", val:"187"},
]}
showSearch={false}
/>
<div style={{flex:1, display:"grid", gridTemplateColumns:"1fr 320px", minHeight:0}}>
<div style={{display:"flex", flexDirection:"column", minHeight:0}}>
{/* Filter chips */}
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", display:"flex", gap:8, alignItems:"center", background:"var(--bg-1)"}}>
<span className="chip accent">全部 · 24</span>
<span className="chip">未处理 · 3</span>
<span className="chip">P0 · 2</span>
<span className="chip">P1 · 8</span>
<span className="chip">P2 · 14</span>
<span style={{width:1, height:20, background:"var(--border-1)"}}/>
<span className="muted" style={{fontSize:11}}>今日</span>
<div style={{marginLeft:"auto", display:"flex", gap:6}}>
<button className="btn sm">全部已读</button>
<button className="btn sm"><Icon name="filter" size={11}/> 筛选</button>
</div>
</div>
{/* Hourly distribution */}
<div style={{padding:"12px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)"}}>
<div className="between" style={{marginBottom:6}}>
<span className="eyebrow">24小时告警分布</span>
<span className="muted mono" style={{fontSize:10}}>峰值 14:00-15:00</span>
</div>
<Bars data={[1,0,0,1,0,2,3,5,8,4,3,4,7,12,18,9,7,5,4,3,2,1,1,0]} w={1100} h={48} color="var(--accent)"/>
</div>
{/* Timeline list */}
<div className="scroll" style={{flex:1}}>
{alerts.map((a,i)=>{
const c = a.p === "P0" ? "var(--danger)" : a.p === "P1" ? "var(--warn)" : "var(--info)";
return (
<div key={i} style={{display:"flex", gap:14, padding:"14px 20px", borderBottom:"1px solid var(--border-1)", background: a.st==="new" ? "oklch(0.20 0.020 245)":"transparent", cursor:"pointer"}}>
<div style={{width:40, paddingTop:4, position:"relative"}}>
<div style={{width:32, height:32, borderRadius:16, background: a.st==="new"?c:"var(--bg-3)", opacity: a.st==="resolved" ? 0.4 : 1, display:"grid", placeItems:"center", color:"var(--fg-0)", boxShadow: a.st==="new" ? `0 0 16px ${c}` : "none"}}>
<Icon name="bell" size={14}/>
</div>
{i < alerts.length-1 && <span style={{position:"absolute", left:19, top:42, bottom:-14, width:2, background:"var(--border-1)"}}/>}
</div>
<div style={{flex:1, minWidth:0}}>
<div className="between">
<div className="mid gap-2">
<span className={"chip " + (a.p==="P0"?"danger":a.p==="P1"?"warn":"info")}>{a.p}</span>
<span className="strong" style={{fontSize:13}}>{a.n}</span>
<span className="mono muted" style={{fontSize:11}}>· {a.v}</span>
{a.st==="new" && <span style={{width:6, height:6, borderRadius:3, background:c, boxShadow: `0 0 6px ${c}`}}/>}
{a.st==="ack" && <span className="chip" style={{fontSize:9}}>已确认</span>}
{a.st==="resolved" && <span className="chip ok" style={{fontSize:9}}>已恢复</span>}
</div>
<span className="mono muted" style={{fontSize:11}}>{a.t}</span>
</div>
<div className="muted" style={{fontSize:12, marginTop:4}}>{a.det}</div>
{a.st === "new" && (
<div className="mid gap-2" style={{marginTop:10}}>
<button className="btn sm primary"><Icon name="route" size={11}/> 查看轨迹</button>
<button className="btn sm">确认</button>
<button className="btn sm">分配</button>
<button className="btn sm ghost">忽略</button>
<span className="muted mono" style={{fontSize:10, marginLeft:"auto"}}>规则 · {a.n}</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Right: stats */}
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="chart" size={13}/><span className="title">告警概览</span>
</div>
<div className="scroll" style={{flex:1, padding:14}}>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:8, marginBottom:14}}>
{[
{l:"P0 紧急", v:"2", c:"var(--danger)"},
{l:"P1 警告", v:"8", c:"var(--warn)"},
{l:"P2 提示", v:"14", c:"var(--info)"},
{l:"已恢复", v:"19", c:"var(--ok)"},
].map((k,i)=>(
<div key={i} style={{padding:10, background:"var(--bg-2)", borderRadius:5, border:"1px solid var(--border-1)"}}>
<div className="muted" style={{fontSize:10}}>{k.l}</div>
<div className="mono" style={{fontSize:22, fontWeight:600, color:k.c}}>{k.v}</div>
</div>
))}
</div>
<div className="eyebrow" style={{marginBottom:8}}>Top 5 告警类型 · 7</div>
<div className="col gap-2" style={{fontSize:11, marginBottom:14}}>
{[
{l:"超速预警", v:47, c:"var(--warn)"},
{l:"急加速密集", v:31, c:"var(--info)"},
{l:"胎压报警", v:18, c:"var(--warn)"},
{l:"SOC不足", v:12, c:"var(--danger)"},
{l:"H₂压力异常", v:8, c:"var(--danger)"},
].map((t,i)=>(
<div key={i} className="mid gap-2">
<span style={{width:80, fontSize:11}} className="muted">{t.l}</span>
<div className="bar" style={{flex:1, height:6}}><i style={{width: (t.v/47*100)+"%", background: t.c}}/></div>
<span className="mono" style={{width:24, textAlign:"right"}}>{t.v}</span>
</div>
))}
</div>
<div className="eyebrow" style={{marginBottom:8}}>Top 5 告警车辆</div>
<div className="col gap-2" style={{fontSize:11}}>
{[
{n:"浙F08638F", v:8, c:"var(--danger)"},
{n:"浙F02002F", v:6, c:"var(--warn)"},
{n:"浙F02608F", v:4, c:"var(--warn)"},
{n:"浙F00598F", v:3, c:"var(--info)"},
{n:"浙F00278F", v:3, c:"var(--info)"},
].map((t,i)=>(
<div key={i} className="between" style={{padding:"6px 10px", background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
<span className="mono strong">{t.n}</span>
<span className={"mono"} style={{color:t.c}}>{t.v} </span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
window.ArtboardInbox = ArtboardInbox;

717
artboards/mobile.jsx Normal file
View File

@@ -0,0 +1,717 @@
// mobile.jsx — Native mobile layouts for each route
// Renders a single-column, gesture-friendly version of each page
// ── Shared mobile chrome ───────────────────────────────────
const MAppBar = ({ title, subtitle, onMenu, right }) => (
<div style={{
height: 52, flex: "0 0 52px", display: "flex", alignItems: "center",
padding: "0 8px 0 4px", gap: 8, position: "relative", zIndex: 5,
background: "var(--bg-1)", borderBottom: "1px solid var(--border-1)",
}}>
<button onClick={onMenu} aria-label="菜单" style={{
width: 40, height: 40, display: "grid", placeItems: "center",
background: "transparent", border: "none", color: "var(--fg-1)", borderRadius: 8, cursor: "pointer",
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 15, color: "var(--fg-0)", lineHeight: 1.2 }}>{title}</div>
{subtitle && <div style={{ fontSize: 11, color: "var(--fg-3)", lineHeight: 1.2 }}>{subtitle}</div>}
</div>
{right}
</div>
);
const MIconBtn = ({ icon, badge, onClick }) => (
<button onClick={onClick} style={{
width: 40, height: 40, display: "grid", placeItems: "center",
background: "transparent", border: "none", color: "var(--fg-1)", borderRadius: 8, cursor: "pointer", position: "relative",
}}>
<Icon name={icon} size={17}/>
{badge && <span style={{ position: "absolute", top: 8, right: 8, minWidth: 14, height: 14, padding: "0 4px", background: "var(--danger)", color: "#fff", fontSize: 9, fontWeight: 600, borderRadius: 7, display: "grid", placeItems: "center", lineHeight: 1 }}>{badge}</span>}
</button>
);
const MTabBar = ({ active, onChange }) => {
const tabs = [
{ id: "overview", icon: "map", label: "总览" },
{ id: "history", icon: "history", label: "查询" },
{ id: "playback", icon: "route", label: "回放" },
{ id: "inbox", icon: "bell", label: "通知" },
{ id: "esg", icon: "chart", label: "ESG" },
];
return (
<div style={{
flex: "0 0 56px", height: 56, display: "grid", gridTemplateColumns: `repeat(${tabs.length}, 1fr)`,
background: "var(--bg-1)", borderTop: "1px solid var(--border-1)",
paddingBottom: "env(safe-area-inset-bottom)",
}}>
{tabs.map(t => (
<button key={t.id} onClick={() => onChange(t.id)} style={{
background: "transparent", border: "none", display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center", gap: 2, cursor: "pointer",
color: active === t.id ? "var(--accent)" : "var(--fg-3)",
}}>
<Icon name={t.icon} size={18}/>
<span style={{ fontSize: 10, fontWeight: active === t.id ? 600 : 400 }}>{t.label}</span>
</button>
))}
</div>
);
};
// ── Mobile shell wrapper ───────────────────────────────────
const MobileShell = ({ title, subtitle, right, children, hideTabBar }) => {
const ctx = window.useRoute();
return (
<div style={{
width: "100%", height: "100%", display: "flex", flexDirection: "column",
background: "var(--bg-0)", color: "var(--fg-1)", overflow: "hidden",
fontFamily: "var(--font-sans)",
}}>
<MAppBar title={title} subtitle={subtitle} onMenu={ctx.openDrawer} right={right}/>
<div style={{ flex: 1, minHeight: 0, position: "relative", overflow: "hidden" }}>
{children}
</div>
{!hideTabBar && <MTabBar active={ctx.route} onChange={ctx.navigate}/>}
</div>
);
};
// ── 1. Mobile Overview: hero map + bottom sheet vehicle list ──
const MobileOverview = () => {
const [selected, setSelected] = React.useState("浙F08638F");
const [sheetOpen, setSheetOpen] = React.useState(false);
const [filter, setFilter] = React.useState("all");
const v = (window.VEHICLES || []).find(x => x.id === selected) || {};
const vehicles = window.VEHICLES || [];
const counts = { all: vehicles.length, ok: vehicles.filter(x=>x.status==="ok").length, warn: vehicles.filter(x=>x.status==="warn").length, danger: vehicles.filter(x=>x.status==="danger").length };
const filtered = filter === "all" ? vehicles : vehicles.filter(x => x.status === filter);
return (
<MobileShell
title="实时监控"
subtitle="487/512 在线 · 312 行驶中"
right={<><MIconBtn icon="search"/><MIconBtn icon="bell" badge="3" onClick={() => window.useRoute().navigate("inbox")}/></>}
>
{/* Map fills, sheet floats */}
<div style={{ position: "absolute", inset: 0 }}>
<FleetMap selected={selected} onSelect={setSelected}/>
</div>
{/* KPI strip floating on map */}
<div style={{ position: "absolute", top: 12, left: 12, right: 12, display: "flex", gap: 8, overflowX: "auto", scrollbarWidth: "none" }}>
{[
{ l: "在线率", v: "95.1%", c: "var(--ok)" },
{ l: "告警", v: "8", c: "var(--danger)" },
{ l: "今日里程", v: "24.7K km", c: "var(--fg-1)" },
{ l: "平均能耗", v: "1.16", c: "var(--info)" },
].map((k, i) => (
<div key={i} style={{
flex: "0 0 auto", padding: "8px 12px", background: "var(--bg-1)",
border: "1px solid var(--border-1)", borderRadius: 10, boxShadow: "var(--shadow-1)",
}}>
<div style={{ fontSize: 10, color: "var(--fg-3)" }}>{k.l}</div>
<div className="mono tnum" style={{ fontSize: 15, fontWeight: 600, color: k.c }}>{k.v}</div>
</div>
))}
</div>
{/* Floating action: locate */}
<button style={{
position: "absolute", right: 14, bottom: sheetOpen ? "70%" : 130,
width: 44, height: 44, borderRadius: 22, border: "1px solid var(--border-1)",
background: "var(--bg-1)", color: "var(--accent)", display: "grid", placeItems: "center",
boxShadow: "var(--shadow-2)", cursor: "pointer", transition: "bottom 280ms cubic-bezier(.3,0,.2,1)",
}}>
<Icon name="pin" size={18}/>
</button>
{/* Bottom sheet */}
<div style={{
position: "absolute", left: 0, right: 0, bottom: 0,
height: sheetOpen ? "70%" : 120, background: "var(--bg-1)",
borderTop: "1px solid var(--border-1)", borderRadius: "16px 16px 0 0",
boxShadow: "0 -8px 24px -8px rgba(0,0,0,0.16)",
transition: "height 320ms cubic-bezier(.3,0,.2,1)",
display: "flex", flexDirection: "column", overflow: "hidden",
}}>
{/* Drag handle + selected vehicle quick card */}
<div onClick={() => setSheetOpen(s => !s)} style={{ padding: "8px 16px 6px", cursor: "pointer" }}>
<div style={{ width: 36, height: 4, background: "var(--border-2)", borderRadius: 2, margin: "0 auto 8px" }}/>
<div className="between">
<div className="mid gap-2">
<span className={"dot " + v.status}/>
<span className="mono strong" style={{ fontSize: 14 }}>{v.id}</span>
<SourceBadge src={v.src}/>
<span className="muted" style={{ fontSize: 11 }}>{v.deptName}</span>
</div>
<span className="mono" style={{ fontSize: 13, color: v.soc < 20 ? "var(--danger)" : "var(--fg-1)" }}>{v.soc}%</span>
</div>
</div>
{!sheetOpen ? (
// Mini quick stats when collapsed
<div style={{ padding: "0 16px 12px", display: "flex", gap: 16, fontSize: 11 }}>
<div className="col"><span className="muted">速度</span><span className="mono strong">{v.speed} km/h</span></div>
<div className="col"><span className="muted">续航</span><span className="mono strong">{Math.round((v.soc||0)*6.2)} km</span></div>
<div className="col"><span className="muted">温度</span><span className="mono strong">{v.status==="danger"?"102":"68"}°C</span></div>
<button onClick={() => window.useRoute().navigate("detail")} className="btn primary sm" style={{ marginLeft: "auto", alignSelf: "center" }}>详情</button>
</div>
) : (
// Full list when expanded
<>
<div style={{ padding: "0 16px 8px", display: "flex", gap: 6, overflowX: "auto", scrollbarWidth: "none" }}>
{[
{id:"all", label:`全部 ${counts.all}`, c:""},
{id:"ok", label:`行驶 ${counts.ok}`, c:"ok"},
{id:"warn", label:`异常 ${counts.warn}`, c:"warn"},
{id:"danger", label:`故障 ${counts.danger}`, c:"danger"},
].map(t => (
<button key={t.id} onClick={() => setFilter(t.id)} className={"chip " + (filter===t.id ? "accent" : t.c)} style={{ flex: "0 0 auto", cursor: "pointer", padding: "6px 12px", fontSize: 12 }}>
{t.label}
</button>
))}
</div>
<div style={{ flex: 1, overflowY: "auto" }}>
{filtered.map(x => (
<div key={x.id} onClick={() => { setSelected(x.id); }} style={{
padding: "12px 16px", borderBottom: "1px solid var(--border-1)",
display: "flex", alignItems: "center", gap: 12,
background: x.id === selected ? "var(--accent-soft)" : "transparent", cursor: "pointer",
}}>
<span className={"dot " + x.status}/>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="mid gap-2">
<span className="mono strong" style={{ fontSize: 13 }}>{x.id}</span>
<SourceBadge src={x.src}/>
</div>
<div className="muted" style={{ fontSize: 11, marginTop: 2 }}>{x.deptName} · {x.speed} km/h</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="mono" style={{ fontSize: 13, color: x.soc < 20 ? "var(--danger)" : "var(--fg-1)" }}>{x.soc}%</div>
<div style={{ width: 50, height: 3, background: "var(--bg-3)", borderRadius: 2, marginTop: 4 }}>
<div style={{ width: x.soc + "%", height: "100%", background: x.soc<20?"var(--danger)":x.soc<40?"var(--warn)":"var(--accent)", borderRadius: 2 }}/>
</div>
</div>
</div>
))}
</div>
</>
)}
</div>
</MobileShell>
);
};
// ── 2. Mobile Detail ────────────────────────────────────────
const MobileDetail = () => {
const vehicles = window.VEHICLES || [];
const v = vehicles.find(x => x.id === "浙F03980F") || vehicles[0] || {};
return (
<MobileShell title={v.plate || v.id} subtitle={`${v.deptName || ''} · ${v.customer || ''}`} right={<MIconBtn icon="pin"/>}>
<div style={{ height: "100%", overflowY: "auto", padding: 12, paddingBottom: 24 }}>
{/* Hero status card */}
<div className="panel" style={{ padding: 16, marginBottom: 12 }}>
<div className="between" style={{ marginBottom: 12 }}>
<div className="mid gap-2">
<span className={"chip " + (v.asset === "abnormal" ? "danger" : v.asset === "leasing" ? "info" : "ok")}>
<span className={"dot " + (v.asset === "abnormal" ? "danger" : v.asset === "leasing" ? "info" : "ok") + " pulse"}/>
{v.asset === "leasing" ? "租赁" : v.asset === "abnormal" ? "异常" : "在库 · 运营中"}
</span>
<span className="chip" style={{fontSize:10, background: v.own === "self" ? "rgba(31,139,76,.10)" : "rgba(122,140,46,.12)", color: v.own === "self" ? "var(--accent)" : "#7A8C2E"}}>{v.own === "self" ? "自有" : "外租"}</span>
</div>
<SourceBadge src={v.src}/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<MStat label="车速" value={v.speed} unit="km/h" big/>
<MStat label="续航" value={v.range} unit="km" big/>
<MStat label="电池SOC" value={v.soc} unit="%" color={v.soc < 20 ? "var(--danger)" : "var(--ok)"}/>
<MStat label="氢气压力" value={v.h2} unit="MPa" color="var(--info)"/>
<MStat label="电机温度" value={v.motorTemp} unit="°C"/>
<MStat label="累计里程" value={(v.totalKm/1000).toFixed(1)} unit="k km"/>
</div>
</div>
<MSection title="业务关系">
<div className="panel" style={{padding: 14}}>
<div className="col gap-2" style={{fontSize:12}}>
<div className="between"><span className="muted">业务部门</span>
<span className="mid gap-1"><span style={{width:7,height:7,background:v.deptColor,borderRadius:1,display:"inline-block"}}/><span className="strong">{v.deptName}</span></span>
</div>
<div className="between"><span className="muted">业务负责人</span><span className="strong">{v.deptLead}</span></div>
<div className="between"><span className="muted">客户</span><span style={{textAlign:"right", maxWidth:160}}>{v.customer}</span></div>
<div className="between"><span className="muted">所属公司</span><span style={{fontSize:11,textAlign:"right"}}>{v.ownCompany}</span></div>
{v.contractNo && <div className="between"><span className="muted">合同</span><span className="mono" style={{fontSize:11}}>{v.contractNo}</span></div>}
</div>
</div>
</MSection>
<MSection title="氢电系统">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<MMini label="燃料电池" value="42.5 kW" sub="输出功率"/>
<MMini label="动力电池" value={v.soc + "%"} sub="SOC"/>
<MMini label="H₂消耗" value="0.84 kg" sub="本次行程"/>
<MMini label="电机扭矩" value="180 N·m" sub="实时"/>
</div>
</MSection>
<MSection title="胎压与温度">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{[
{ l: "左前", p: "0.24", t: "32" },
{ l: "右前", p: "0.24", t: "33" },
{ l: "左后", p: "0.23", t: "31" },
{ l: "右后", p: v.asset === "abnormal" ? "0.16" : "0.24", t: "38", warn: v.asset === "abnormal" },
].map((tire, i) => (
<div key={i} className="panel" style={{ padding: 12, border: tire.warn ? "1px solid var(--danger)" : "1px solid var(--border-1)" }}>
<div className="between"><span className="muted" style={{ fontSize: 11 }}>{tire.l}</span>{tire.warn && <span className="chip danger" style={{ fontSize: 9 }}>低压</span>}</div>
<div className="mono strong" style={{ fontSize: 18, color: tire.warn ? "var(--danger)" : "var(--fg-0)" }}>{tire.p}<span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}> MPa</span></div>
<div className="mono muted" style={{ fontSize: 11 }}>{tire.t}°C</div>
</div>
))}
</div>
</MSection>
<MSection title="保养预警">
<div className="panel" style={{padding: 14}}>
<div className="between" style={{marginBottom:8}}>
<span className="muted" style={{fontSize:12}}>距下次保养</span>
<span className="mono strong" style={{fontSize:16, color: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)"}}>{v.kmToMaint.toLocaleString()} km</span>
</div>
<div className="bar" style={{height:5}}>
<i style={{width: ((10000 - v.kmToMaint)/10000*100) + "%", background: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)"}}/>
</div>
<div className="muted" style={{fontSize:11, marginTop:8}}>上次保养 {v.lastMaintDays} 天前 · {v.lastMaintKm.toLocaleString()} km</div>
</div>
</MSection>
<MSection title="数据通道">
<div className="col gap-2" style={{ fontSize: 12 }}>
{[
{ l: "TBOX (3296/2016)", st: "ok", info: "5s 上报 · 信号 -68dBm" },
{ l: "JT808 部标", st: "ok", info: "实时 · 北京·朝阳" },
{ l: "JT1078 视频", st: "ok", info: "4 路 · 720P" },
].map((c, i) => (
<div key={i} className="between" style={{ padding: "10px 12px", background: "var(--bg-2)", borderRadius: 8, border: "1px solid var(--border-1)" }}>
<div>
<div className="strong" style={{ fontSize: 13 }}>{c.l}</div>
<div className="muted" style={{ fontSize: 11, marginTop: 2 }}>{c.info}</div>
</div>
<span className="dot ok"/>
</div>
))}
</div>
</MSection>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginTop: 16 }}>
<button onClick={() => window.useRoute().navigate("playback")} className="btn primary" style={{ height: 44 }}><Icon name="route" size={14}/> 轨迹回放</button>
<button onClick={() => window.useRoute().navigate("history")} className="btn" style={{ height: 44 }}><Icon name="history" size={14}/> 历史数据</button>
</div>
</div>
</MobileShell>
);
};
const MStat = ({ label, value, unit, color, big }) => (
<div>
<div className="muted" style={{ fontSize: 11 }}>{label}</div>
<div className="mono strong tnum" style={{ fontSize: big ? 26 : 18, color: color || "var(--fg-0)", lineHeight: 1.1, marginTop: 2 }}>
{value}<span style={{ fontSize: big ? 12 : 10, fontWeight: 400, color: "var(--fg-3)", marginLeft: 4 }}>{unit}</span>
</div>
</div>
);
const MMini = ({ label, value, sub }) => (
<div style={{ padding: 10, background: "var(--bg-2)", borderRadius: 8, border: "1px solid var(--border-1)" }}>
<div className="muted" style={{ fontSize: 10 }}>{label}</div>
<div className="mono strong" style={{ fontSize: 15, marginTop: 2 }}>{value}</div>
{sub && <div className="muted" style={{ fontSize: 10, marginTop: 1 }}>{sub}</div>}
</div>
);
const MSection = ({ title, children, action }) => (
<div style={{ marginBottom: 16 }}>
<div className="between" style={{ marginBottom: 8, padding: "0 4px" }}>
<span className="eyebrow" style={{ fontSize: 11 }}>{title}</span>
{action}
</div>
{children}
</div>
);
// ── 3. Mobile History ───────────────────────────────────────
const MobileHistory = () => {
const [showFilter, setShowFilter] = React.useState(false);
const trips = [
{ d: "04-28", t: "14:0214:44", v: "浙F07179F", k: "32.4 km", h: "0.84 kg", st: "ok" },
{ d: "04-28", t: "10:1111:03", v: "浙F07179F", k: "48.2 km", h: "1.21 kg", st: "ok" },
{ d: "04-28", t: "08:3009:18", v: "浙F08638F", k: "29.8 km", h: "0.76 kg", st: "warn" },
{ d: "04-27", t: "17:4218:25", v: "浙F07179F", k: "36.1 km", h: "0.92 kg", st: "ok" },
{ d: "04-27", t: "14:0815:01", v: "浙F02002F", k: "44.5 km", h: "1.13 kg", st: "danger" },
{ d: "04-27", t: "09:2210:14", v: "浙F07179F", k: "39.7 km", h: "1.01 kg", st: "ok" },
];
return (
<MobileShell title="历史查询" subtitle="近 7 日 · 187 条记录" right={<MIconBtn icon="filter" onClick={()=>setShowFilter(s=>!s)}/>}>
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* Search */}
<div style={{ padding: "12px 12px 0" }}>
<div className="search" style={{ height: 40 }}>
<Icon name="search" size={14}/>
<input placeholder="车牌 / VIN / 部门 / 客户" style={{ fontSize: 14 }}/>
</div>
</div>
{/* Filter chips - horizontal scroll */}
<div style={{ padding: "10px 12px", display: "flex", gap: 6, overflowX: "auto", scrollbarWidth: "none", flexShrink: 0 }}>
<span className="chip accent" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}> 7 </span>
<span className="chip" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>编组A</span>
<span className="chip" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>全部车型</span>
<span className="chip" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>有告警</span>
</div>
{/* KPI summary */}
<div style={{ padding: "0 12px 12px", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8, flexShrink: 0 }}>
{[
{ l: "总里程", v: "1,847", u: "km", c: "var(--fg-0)" },
{ l: "氢耗", v: "47.2", u: "kg", c: "var(--info)" },
{ l: "减碳", v: "118", u: "kg", c: "var(--ok)" },
].map((k, i) => (
<div key={i} style={{ padding: 10, background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 8 }}>
<div className="muted" style={{ fontSize: 10 }}>{k.l}</div>
<div className="mono strong tnum" style={{ fontSize: 16, color: k.c }}>{k.v}<span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}> {k.u}</span></div>
</div>
))}
</div>
{/* Trip list */}
<div style={{ flex: 1, overflowY: "auto", padding: "0 12px 16px" }}>
{trips.map((t, i) => {
const cls = t.st === "danger" ? "danger" : t.st === "warn" ? "warn" : "ok";
return (
<div key={i} className="panel" style={{ padding: 12, marginBottom: 8 }}>
<div className="between" style={{ marginBottom: 6 }}>
<div className="mid gap-2">
<span className="mono strong" style={{ fontSize: 13 }}>{t.v}</span>
<span className={"chip " + cls} style={{ fontSize: 9 }}>{t.st === "danger" ? "故障" : t.st === "warn" ? "告警" : "正常"}</span>
</div>
<span className="muted mono" style={{ fontSize: 11 }}>{t.d} {t.t}</span>
</div>
<div style={{ display: "flex", gap: 14, fontSize: 12 }}>
<div><span className="muted">里程 </span><span className="mono strong">{t.k}</span></div>
<div><span className="muted">氢耗 </span><span className="mono strong">{t.h}</span></div>
<button onClick={() => window.useRoute().navigate("playback")} className="btn sm ghost" style={{ marginLeft: "auto", padding: "4px 10px", fontSize: 11 }}>回放</button>
</div>
</div>
);
})}
</div>
</div>
</MobileShell>
);
};
// ── 4. Mobile Playback ──────────────────────────────────────
const MobilePlayback = () => {
const [t, setT] = React.useState(38);
const [playing, setPlaying] = React.useState(true);
const [speed, setSpeed] = React.useState(2);
React.useEffect(() => {
if (!playing) return;
const id = setInterval(() => setT(v => (v + speed * 0.6) % 100), 200);
return () => clearInterval(id);
}, [playing, speed]);
return (
<MobileShell title="轨迹回放" subtitle="浙F07179F · 04-28 14:02 → 14:44">
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column" }}>
{/* Map area */}
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
<FleetMap selected="浙F07179F" playbackPoint={{ x: 280 + t * 4, y: 260 + Math.sin(t/15) * 80 }}/>
{/* Floating speed badge */}
<div style={{ position: "absolute", top: 12, left: 12, padding: "8px 12px", background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 10, boxShadow: "var(--shadow-1)" }}>
<div className="muted" style={{ fontSize: 10 }}>当前速度</div>
<div className="mono strong tnum" style={{ fontSize: 16 }}>{Math.round(40 + Math.sin(t/8)*20)} <span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}>km/h</span></div>
</div>
<div style={{ position: "absolute", top: 12, right: 12, padding: "8px 12px", background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 10, boxShadow: "var(--shadow-1)" }}>
<div className="muted" style={{ fontSize: 10 }}>SOC</div>
<div className="mono strong tnum" style={{ fontSize: 16, color: "var(--ok)" }}>{Math.round(78 - t * 0.15)}%</div>
</div>
</div>
{/* Bottom playback panel */}
<div style={{
flex: "0 0 auto", background: "var(--bg-1)", borderTop: "1px solid var(--border-1)",
padding: "12px 14px 16px", boxShadow: "0 -4px 16px -4px rgba(0,0,0,0.08)",
}}>
{/* Time + scrub */}
<div className="between" style={{ marginBottom: 8 }}>
<span className="mono strong" style={{ fontSize: 14 }}>14:{String(Math.floor(t * 0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")}</span>
<span className="mono muted" style={{ fontSize: 11 }}> {Math.round(t*0.42)} / 42 </span>
</div>
<input type="range" min="0" max="100" value={t} onChange={e => setT(+e.target.value)} style={{
width: "100%", height: 4, accentColor: "var(--accent)", marginBottom: 12,
}}/>
{/* Controls row */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button onClick={() => setT(0)} style={ctrlBtn}><Icon name="route" size={14}/></button>
<button onClick={() => setT(v => Math.max(0, v - 10))} style={ctrlBtn}>« 10s</button>
<button onClick={() => setPlaying(p => !p)} style={{ ...ctrlBtn, width: 56, height: 44, background: "var(--accent)", color: "#fff", borderColor: "var(--accent)" }}>{playing ? "⏸" : "▶"}</button>
<button onClick={() => setT(v => Math.min(100, v + 10))} style={ctrlBtn}>10s »</button>
<select value={speed} onChange={e => setSpeed(+e.target.value)} style={{ ...ctrlBtn, padding: "0 10px" }}>
{[0.5, 1, 2, 4, 8, 16].map(s => <option key={s} value={s}>{s}×</option>)}
</select>
</div>
{/* Mini chart timeline events */}
<div style={{ marginTop: 12, position: "relative", height: 24, background: "var(--bg-2)", borderRadius: 4 }}>
{[12, 38, 65, 88].map((p, i) => (
<div key={i} style={{ position: "absolute", left: p+"%", top: 0, bottom: 0, width: 2, background: i===2?"var(--danger)":"var(--warn)" }}/>
))}
<div style={{ position: "absolute", left: t+"%", top: -4, bottom: -4, width: 2, background: "var(--accent)", boxShadow: "0 0 8px var(--accent)" }}/>
</div>
<div className="between" style={{ marginTop: 4, fontSize: 10 }}>
<span className="muted">事件</span>
<span className="mono muted">急刹×1 · 超速×2 · 故障×1</span>
</div>
</div>
</div>
</MobileShell>
);
};
const ctrlBtn = {
flex: 1, height: 40, display: "grid", placeItems: "center",
background: "var(--bg-2)", border: "1px solid var(--border-1)",
color: "var(--fg-1)", borderRadius: 8, fontSize: 12, fontFamily: "var(--font-mono)", cursor: "pointer",
};
// ── 5. Mobile Alarm Rules (list-based) ──────────────────────
const MobileAlarm = () => {
const rules = [
{ n: "电池SOC严重不足", st: "on", trig: 12, cond: "SOC < 15% 持续 30s", p: "P0" },
{ n: "右后胎压低", st: "on", trig: 8, cond: "压力 < 0.20 MPa", p: "P0" },
{ n: "超速预警", st: "on", trig: 47, cond: "速度 > 限速 + 5 km/h", p: "P1" },
{ n: "H₂压力异常下降", st: "on", trig: 5, cond: "5min 内下降 > 1.0 MPa", p: "P0" },
{ n: "电堆过温保护", st: "on", trig: 3, cond: "温度 > 90°C", p: "P0" },
{ n: "急加速密集", st: "on", trig: 31, cond: "5min 内 ≥ 3 次", p: "P2" },
{ n: "疲劳驾驶", st: "off", trig: 0, cond: "连续驾驶 > 4h", p: "P1" },
];
return (
<MobileShell title="事件规则" subtitle={`${rules.filter(r=>r.st==="on").length} / ${rules.length} 启用`} right={<MIconBtn icon="plus"/>}>
<div style={{ height: "100%", overflowY: "auto", padding: 12 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8, marginBottom: 12 }}>
{[
{ l: "P0 紧急", v: 4, c: "var(--danger)" },
{ l: "P1 警告", v: 2, c: "var(--warn)" },
{ l: "P2 提示", v: 1, c: "var(--info)" },
].map((k, i) => (
<div key={i} style={{ padding: 10, background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 8 }}>
<div className="muted" style={{ fontSize: 10 }}>{k.l}</div>
<div className="mono strong" style={{ fontSize: 18, color: k.c }}>{k.v}</div>
</div>
))}
</div>
{rules.map((r, i) => (
<div key={i} className="panel" style={{ padding: 14, marginBottom: 8 }}>
<div className="between" style={{ marginBottom: 6 }}>
<div className="mid gap-2">
<span className={"chip " + (r.p==="P0"?"danger":r.p==="P1"?"warn":"info")} style={{ fontSize: 9 }}>{r.p}</span>
<span className="strong" style={{ fontSize: 14 }}>{r.n}</span>
</div>
<MSwitch on={r.st==="on"}/>
</div>
<div className="muted mono" style={{ fontSize: 11, marginBottom: 6 }}>{r.cond}</div>
<div className="between" style={{ fontSize: 11 }}>
<span className="muted">7日触发 <span className="mono strong" style={{ color: "var(--fg-1)" }}>{r.trig}</span> </span>
<span className="muted">短信 · App推送 · 邮件</span>
</div>
</div>
))}
</div>
</MobileShell>
);
};
const MSwitch = ({ on }) => (
<div style={{
width: 36, height: 20, borderRadius: 10, padding: 2,
background: on ? "var(--accent)" : "var(--bg-3)", transition: "background 200ms",
}}>
<div style={{
width: 16, height: 16, borderRadius: 8, background: "#fff",
transform: on ? "translateX(16px)" : "translateX(0)",
transition: "transform 200ms cubic-bezier(.3,0,.2,1)",
boxShadow: "0 1px 2px rgba(0,0,0,0.2)",
}}/>
</div>
);
// ── 6. Mobile Inbox ─────────────────────────────────────────
const MobileInbox = () => {
const [filter, setFilter] = React.useState("all");
const alerts = [
{p:"P0", n:"电池SOC严重不足", v:"浙F08638F", t:"刚刚", det:"SOC 9% < 阈值 15% · 持续 4分20秒", st:"new"},
{p:"P0", n:"右后胎压低", v:"浙F08638F", t:"3分钟前", det:"0.16 MPa · 阈值 0.20 MPa", st:"new"},
{p:"P1", n:"超速预警", v:"浙F02002F", t:"12分钟前", det:"实测 89 km/h · 限速 80 km/h · 持续 12s", st:"new"},
{p:"P1", n:"H₂压力异常下降", v:"浙F07179F", t:"32分钟前", det:"5分钟内下降 1.2 MPa", st:"ack"},
{p:"P0", n:"电堆过温保护", v:"浙F00598F", t:"1小时前", det:"电堆温度 95°C · 阈值 90°C", st:"resolved"},
{p:"P2", n:"急加速密集", v:"浙F02608F", t:"2小时前", det:"5分钟内 3 次急加速", st:"resolved"},
{p:"P1", n:"偏离规划路线", v:"浙F00278F", t:"3小时前", det:"偏离 1.2 km · 持续 6 分钟", st:"resolved"},
];
const filtered = filter === "all" ? alerts : filter === "new" ? alerts.filter(a=>a.st==="new") : alerts.filter(a=>a.p===filter);
return (
<MobileShell title="通知中心" subtitle="3 未处理 · 24 今日">
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<div style={{ padding: "10px 12px 8px", display: "flex", gap: 6, overflowX: "auto", scrollbarWidth: "none", flexShrink: 0, borderBottom: "1px solid var(--border-1)" }}>
{[
{id:"all", l:"全部 24"},
{id:"new", l:"未处理 3"},
{id:"P0", l:"P0 · 2"},
{id:"P1", l:"P1 · 8"},
{id:"P2", l:"P2 · 14"},
].map(t => (
<button key={t.id} onClick={()=>setFilter(t.id)} className={"chip " + (filter===t.id?"accent":"")} style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12, cursor: "pointer" }}>{t.l}</button>
))}
</div>
<div style={{ flex: 1, overflowY: "auto" }}>
{filtered.map((a, i) => {
const c = a.p === "P0" ? "var(--danger)" : a.p === "P1" ? "var(--warn)" : "var(--info)";
return (
<div key={i} style={{
display: "flex", gap: 12, padding: "14px 14px",
borderBottom: "1px solid var(--border-1)",
background: a.st === "new" ? "var(--accent-soft)" : "transparent",
}}>
<div style={{ flexShrink: 0 }}>
<div style={{ width: 36, height: 36, borderRadius: 18, background: a.st === "new" ? c : "var(--bg-3)", opacity: a.st==="resolved"?0.4:1, display: "grid", placeItems: "center", color: "#fff", boxShadow: a.st === "new" ? `0 0 12px ${c}` : "none" }}>
<Icon name="bell" size={14}/>
</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="between">
<div className="mid gap-2">
<span className={"chip " + (a.p==="P0"?"danger":a.p==="P1"?"warn":"info")} style={{ fontSize: 9, padding: "2px 6px" }}>{a.p}</span>
<span className="strong" style={{ fontSize: 13 }}>{a.n}</span>
</div>
<span className="mono muted" style={{ fontSize: 11 }}>{a.t}</span>
</div>
<div className="mono muted" style={{ fontSize: 11, marginTop: 2 }}>{a.v}</div>
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>{a.det}</div>
{a.st === "new" && (
<div style={{ display: "flex", gap: 6, marginTop: 10 }}>
<button onClick={()=>window.useRoute().navigate("playback")} className="btn sm primary" style={{ fontSize: 11 }}><Icon name="route" size={11}/> 轨迹</button>
<button className="btn sm" style={{ fontSize: 11 }}>确认</button>
<button className="btn sm ghost" style={{ fontSize: 11 }}>忽略</button>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</MobileShell>
);
};
// ── 7. Mobile ESG ────────────────────────────────────────────
const MobileESG = () => {
return (
<MobileShell title="ESG · 碳减排" subtitle="羚牛 ESG Link">
<div style={{ height: "100%", overflowY: "auto", padding: 12 }}>
{/* Hero stat */}
<div className="panel" style={{ padding: 16, marginBottom: 12, background: "linear-gradient(135deg, #007143, #00A35F)", color: "#fff", border: "none" }}>
<div style={{ fontSize: 11, opacity: 0.85 }}>本年度累计减碳</div>
<div className="mono tnum" style={{ fontSize: 36, fontWeight: 700, lineHeight: 1.1, margin: "4px 0" }}>1,847.2<span style={{ fontSize: 14, fontWeight: 400, opacity: 0.85, marginLeft: 6 }}>tCO₂e</span></div>
<div style={{ fontSize: 11, opacity: 0.85 }}>较去年同期 32.4%</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 12 }}>
{[
{ l: "氢能消耗", v: "47.2", u: "万 m³", c: "var(--info)" },
{ l: "里程", v: "1.84", u: "万 km", c: "var(--fg-0)" },
{ l: "碳交易收益", v: "18.78", u: "万元", c: "var(--accent)" },
{ l: "覆盖城市", v: "23", u: "个", c: "var(--fg-0)" },
].map((k, i) => (
<div key={i} className="panel" style={{ padding: 12 }}>
<div className="muted" style={{ fontSize: 10 }}>{k.l}</div>
<div className="mono strong tnum" style={{ fontSize: 18, color: k.c, marginTop: 2 }}>{k.v}<span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)", marginLeft: 4 }}>{k.u}</span></div>
</div>
))}
</div>
<MSection title="月度减碳趋势">
<div className="panel" style={{ padding: 14 }}>
<svg viewBox="0 0 320 120" width="100%" height="100" style={{ overflow: "visible" }}>
{[180,220,255,235,290,270,310,345,320,355,380,420].map((v, i, arr) => {
const x = 12 + i * 26;
const h = (v / 420) * 90;
return (
<g key={i}>
<rect x={x-9} y={108-h} width="18" height={h} fill="var(--accent)" rx="2" opacity={i===11?1:0.65}/>
{i%3===0 && <text x={x} y="118" fontSize="9" fill="var(--fg-3)" textAnchor="middle">{i+1}</text>}
</g>
);
})}
</svg>
</div>
</MSection>
<MSection title="车辆减碳排行 Top 5">
<div className="col gap-1">
{[
{ p:"浙F·8A03F", v: 24.38 },
{ p:"浙F·2C57G", v: 22.15 },
{ p:"浙F·9D14B", v: 19.84 },
{ p:"浙F·6E72H", v: 17.21 },
{ p:"浙F·1B49K", v: 15.67 },
].map((r, i) => (
<div key={i} className="between" style={{ padding: "10px 12px", background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 8 }}>
<span className="mid gap-2">
<span className="mono" style={{ fontSize: 11, color: "var(--fg-3)", width: 16 }}>#{i+1}</span>
<span className="mono strong" style={{ fontSize: 13 }}>{r.p}</span>
</span>
<span className="mono strong" style={{ fontSize: 13, color: "var(--accent)" }}>{r.v} <span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}>kg</span></span>
</div>
))}
</div>
</MSection>
</div>
</MobileShell>
);
};
// ── Mobile route map ────────────────────────────────────────
const MOBILE_PAGES = {
overview: MobileOverview,
detail: MobileDetail,
history: MobileHistory,
playback: MobilePlayback,
alarm: MobileAlarm,
inbox: MobileInbox,
esg: MobileESG,
};
const MobileRouter = ({ route }) => {
const Cmp = MOBILE_PAGES[route];
if (!Cmp) {
return (
<MobileShell title="设计画板" subtitle="该页面仅桌面端可用">
<div style={{ padding: 24, textAlign: "center", color: "var(--fg-2)" }}>
<Icon name="settings" size={32}/>
<div style={{ marginTop: 12, fontSize: 14 }}>设计画板模式</div>
<div style={{ fontSize: 12, color: "var(--fg-3)", marginTop: 4 }}>请在桌面端访问以查看完整设计稿</div>
<button onClick={() => window.useRoute().navigate("overview")} className="btn primary" style={{ marginTop: 16, height: 40, padding: "0 20px" }}>返回主页</button>
</div>
</MobileShell>
);
}
return <Cmp/>;
};
window.MobileRouter = MobileRouter;
window.MobileShell = MobileShell;

385
artboards/overview.jsx Normal file
View File

@@ -0,0 +1,385 @@
// artboard-overview.jsx — Asset-management overview
// Filter by: 资产状态 / 部门 / 归属
// Card shows: 车牌 + VIN + 城市 + 部门 + 客户 + 资产状态
// Detail shows: 资产档案 + 业务关系 + 实时车况 + 保养预警 (no driver)
const AssetStatusChip = ({ status }) => {
const map = {
in_stock: { label: "在库", bg: "var(--accent-soft)", fg: "var(--accent)", dot: "ok" },
leasing: { label: "租赁" , bg: "rgba(46,140,140,0.15)",fg: "var(--info)", dot: "info" },
abnormal: { label: "异常", bg: "var(--danger-soft)", fg: "var(--danger)", dot: "danger" },
};
const m = map[status] || map.in_stock;
return (
<span className="chip" style={{background:m.bg, color:m.fg, border:"1px solid " + m.fg + "33", fontSize:10, padding:"2px 7px"}}>
<span className={"dot " + m.dot} style={{width:5, height:5}}/> {m.label}
</span>
);
};
const OwnChip = ({ own }) => (
<span className="chip" style={{
fontSize:10, padding:"1px 6px",
background: own === "self" ? "rgba(31,139,76,0.10)" : "rgba(122,140,46,0.12)",
color: own === "self" ? "var(--accent)" : "#7A8C2E",
border: "1px solid " + (own === "self" ? "rgba(31,139,76,0.25)" : "rgba(122,140,46,0.25)"),
}}>{own === "self" ? "自有" : "外租"}</span>
);
const DeptDot = ({ dept }) => {
const d = (window.DEPARTMENTS || []).find(x => x.id === dept);
if (!d) return null;
return (
<span style={{display:"inline-flex", alignItems:"center", gap:4, fontSize:11}}>
<span style={{width:6, height:6, background:d.color, borderRadius:1, display:"inline-block"}}/>
<span style={{color:"var(--fg-1)"}}>{d.name}</span>
</span>
);
};
const ArtboardOverview = () => {
const allVehicles = (window.VEHICLES || []);
const { role } = (typeof window.useCurrentRole === "function") ? window.useCurrentRole() : { role: null };
// Apply role-based scope before user-facing filters
const vehicles = React.useMemo(() => {
if (!role || role.scope === "all" || role.scope === "ops" || role.scope === "finance") return allVehicles;
if (role.scope === "dept") return allVehicles.filter(v => v.dept === role.deptId);
return allVehicles;
}, [role, allVehicles]);
const counts = (window.COUNTS || {});
const deps = (window.DEPARTMENTS || []);
const isDeptScoped = role && role.scope === "dept";
// Scoped counts so KPIs match what the role can actually see
const scopedCounts = React.useMemo(() => {
const c = { all: vehicles.length, inStock:0, leasing:0, abnormal:0, self:0, lease:0 };
vehicles.forEach(v => {
if (v.asset === "in_stock") c.inStock++;
else if (v.asset === "leasing") c.leasing++;
else if (v.asset === "abnormal") c.abnormal++;
if (v.own === "self") c.self++; else c.lease++;
});
return c;
}, [vehicles]);
const [selected, setSelected] = React.useState(vehicles[8]?.id || vehicles[0]?.id);
React.useEffect(() => {
if (vehicles.length && !vehicles.find(x => x.id === selected)) {
setSelected(vehicles[0].id);
}
}, [vehicles, selected]);
const [filterAsset, setFilterAsset] = React.useState("all"); // all | in_stock | leasing | abnormal
const [filterDept, setFilterDept] = React.useState("all");
const [filterOwn, setFilterOwn] = React.useState("all");
const [search, setSearch] = React.useState("");
const filtered = vehicles.filter(v => {
if (filterAsset !== "all" && v.asset !== filterAsset) return false;
if (filterDept !== "all" && v.dept !== filterDept) return false;
if (filterOwn !== "all" && v.own !== filterOwn) return false;
if (search && !v.plate.includes(search) && !v.vin.includes(search)) return false;
return true;
});
const v = vehicles.find(x => x.id === selected) || vehicles[0];
if (!v) return null;
return (
<div className="app">
<Sidebar active="map"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar
crumbs={isDeptScoped ? ["羚牛车辆数据中心", "资产管理", role.name.replace(/.*·/,"")] : ["羚牛车辆数据中心", "资产管理", "总览"]}
kpis={[
{ lbl: isDeptScoped ? "本部门车辆" : "总车辆", val: scopedCounts.all },
{ lbl:"在库", val: scopedCounts.inStock, delta: scopedCounts.all ? Math.round(scopedCounts.inStock/scopedCounts.all*100) + "%" : "0%" },
{ lbl:"租赁", val: scopedCounts.leasing, delta: scopedCounts.all ? Math.round(scopedCounts.leasing/scopedCounts.all*100) + "%" : "0%", deltaUp:true },
{ lbl:"异常", val: scopedCounts.abnormal, delta: scopedCounts.abnormal > 0 ? "+" + scopedCounts.abnormal : "0", deltaUp:false },
]}
/>
{isDeptScoped && (
<div style={{
padding:"7px 16px", background:"var(--accent-soft)",
borderBottom:"1px solid var(--border-1)", fontSize:11,
display:"flex", alignItems:"center", gap:10, color:"var(--fg-1)"
}}>
<span style={{width:6, height:6, borderRadius:3, background:"var(--accent)"}}/>
<span><span className="strong">数据权限</span>当前以 <span className="strong">{role.name}</span> 身份登录仅可见本部门 {scopedCounts.all} 辆车 · 全公司共 {counts.all} </span>
<span className="muted" style={{marginLeft:"auto"}}>切换身份请使用右下角 Tweaks · 登录身份</span>
</div>
)}
<div style={{flex:1, display:"grid", gridTemplateColumns:"320px 1fr 360px", gap:0, minHeight:0}}>
{/* Left: fleet list with asset filters */}
<div style={{borderRight:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
<div style={{padding:"12px 14px 8px"}}>
<div className="between" style={{marginBottom:8}}>
<span className="eyebrow">车辆 · {filtered.length}/{scopedCounts.all}</span>
<span className="muted" style={{fontSize:11, cursor:"pointer"}}><Icon name="filter" size={11} style={{verticalAlign:"middle"}}/> 高级</span>
</div>
<div className="search" style={{height:28}}>
<Icon name="search" size={12}/>
<input placeholder="车牌 / VIN" value={search} onChange={e => setSearch(e.target.value)}/>
</div>
{/* Asset status filter */}
<div style={{marginTop:8}}>
<div className="muted" style={{fontSize:10, marginBottom:4, letterSpacing:".05em"}}>资产状态</div>
<div style={{display:"flex", gap:4, flexWrap:"wrap"}}>
{[
{k:"all", l:"全部", c: scopedCounts.all},
{k:"in_stock", l:"在库", c: scopedCounts.inStock},
{k:"leasing", l:"租赁" , c: scopedCounts.leasing},
{k:"abnormal", l:"异常", c: scopedCounts.abnormal},
].map(o => (
<span key={o.k}
className={"chip" + (filterAsset === o.k ? " accent" : "")}
style={{cursor:"pointer", fontSize:11}}
onClick={() => setFilterAsset(o.k)}>
{o.l} <span className="muted mono" style={{marginLeft:3, fontSize:10}}>{o.c}</span>
</span>
))}
</div>
</div>
{/* Ownership */}
<div style={{marginTop:6}}>
<div className="muted" style={{fontSize:10, marginBottom:4, letterSpacing:".05em"}}>归属</div>
<div style={{display:"flex", gap:4, flexWrap:"wrap"}}>
{[
{k:"all", l:"全部"},
{k:"self", l:"自有", c: scopedCounts.self},
{k:"lease", l:"外租", c: scopedCounts.lease},
].map(o => (
<span key={o.k}
className={"chip" + (filterOwn === o.k ? " accent" : "")}
style={{cursor:"pointer", fontSize:11}}
onClick={() => setFilterOwn(o.k)}>
{o.l}{o.c != null && <span className="muted mono" style={{marginLeft:3, fontSize:10}}>{o.c}</span>}
</span>
))}
</div>
</div>
{/* Department — hidden when role is dept-scoped (only one dept visible) */}
{!isDeptScoped && (
<div style={{marginTop:6}}>
<div className="muted" style={{fontSize:10, marginBottom:4, letterSpacing:".05em"}}>业务部门</div>
<div style={{display:"flex", gap:4, flexWrap:"wrap"}}>
<span className={"chip" + (filterDept === "all" ? " accent" : "")}
style={{cursor:"pointer", fontSize:11}}
onClick={() => setFilterDept("all")}>全部</span>
{deps.map(d => (
<span key={d.id}
className={"chip" + (filterDept === d.id ? " accent" : "")}
style={{cursor:"pointer", fontSize:11}}
onClick={() => setFilterDept(d.id)}>
<span style={{width:6, height:6, background:d.color, borderRadius:1, display:"inline-block", marginRight:4, verticalAlign:"middle"}}/>
{d.name}
<span className="muted mono" style={{marginLeft:3, fontSize:10}}>{counts.byDept?.[d.id] || 0}</span>
</span>
))}
</div>
</div>
)}
</div>
<div className="scroll" style={{flex:1, padding:"4px 0"}}>
{filtered.map(x => (
<div key={x.id} onClick={() => setSelected(x.id)}
style={{
padding:"10px 14px", borderLeft: "2px solid " + (x.id === selected ? "var(--accent)" : "transparent"),
background: x.id === selected ? "var(--accent-soft)" : "transparent",
cursor:"pointer", borderBottom:"1px solid var(--border-1)"
}}>
<div className="between">
<span className="mono strong" style={{fontSize:13, color:"var(--fg-0)"}}>{x.plate}</span>
<AssetStatusChip status={x.asset}/>
</div>
<div className="muted mono" style={{fontSize:10, marginTop:3, opacity:.8}}>{x.vin}</div>
<div style={{display:"flex", gap:8, marginTop:5, alignItems:"center", flexWrap:"wrap"}}>
<DeptDot dept={x.dept}/>
<OwnChip own={x.own}/>
{x.gps === "offline" && <span className="muted" style={{fontSize:10}}> GPS离线</span>}
</div>
<div className="muted" style={{fontSize:10, marginTop:4}}>
<Icon name="pin" size={9} style={{verticalAlign:"middle", marginRight:3, opacity:.6}}/>
{x.city}
{x.customer !== "—" && (<><span style={{margin:"0 4px"}}>·</span>{x.customer}</>)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="muted" style={{padding:"40px 16px", textAlign:"center", fontSize:12}}>没有匹配的车辆</div>
)}
</div>
</div>
{/* Map center */}
<div style={{position:"relative", minWidth:0, minHeight:0}}>
<FleetMap selectedId={selected} onSelect={(x)=>setSelected(x.id)} />
<div style={{position:"absolute", top:12, right:12, display:"flex", flexDirection:"column", gap:6}}>
{["layers","plus","close","sat","pin"].map((n,i)=>(
<div key={i} className="icon-btn" style={{width:32, height:32, background:"var(--bg-1)", border:"1px solid var(--border-1)"}}>
<Icon name={n === "close" ? "expand" : n} size={14}/>
</div>
))}
</div>
{/* Legend by asset */}
<div style={{position:"absolute", bottom:12, left:12, padding:"8px 10px", background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6, display:"flex", gap:14, fontSize:10}}>
<span className="mid gap-1"><span className="dot ok"/> 在库/正常</span>
<span className="mid gap-1"><span className="dot info"/> 租赁</span>
<span className="mid gap-1"><span className="dot warn"/> 待整备</span>
<span className="mid gap-1"><span className="dot danger"/> 异常</span>
<span className="mid gap-1"><span className="dot idle"/> GPS离线</span>
</div>
<div style={{position:"absolute", top:12, left:12, padding:"6px 10px", background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6, fontSize:11, fontFamily:"var(--font-mono)", display:"flex", alignItems:"center", gap:10, color:"var(--fg-1)", boxShadow:"var(--shadow-1)"}}>
<span style={{display:"inline-flex", alignItems:"center", gap:4}}><span className="dot ok pulse"/> LIVE</span>
<span className="muted">|</span>
<span>嘉兴市·平湖</span>
<span className="muted">|</span>
<span>14:32:08</span>
</div>
</div>
{/* Right: vehicle asset detail panel */}
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
<div style={{padding:"14px 16px 12px", borderBottom:"1px solid var(--border-1)"}}>
<div className="between">
<div style={{minWidth:0, flex:1}}>
<div className="eyebrow">{v.deptName} · {v.deptLead}</div>
<div className="mono strong" style={{fontSize:18, fontWeight:600, marginTop:4}}>{v.plate}</div>
<div className="muted mono" style={{fontSize:10, marginTop:2}}>{v.vin}</div>
</div>
<div className="col gap-1" style={{alignItems:"flex-end"}}>
<AssetStatusChip status={v.asset}/>
<OwnChip own={v.own}/>
</div>
</div>
<div className="mid gap-2" style={{marginTop:8, fontSize:10}}>
<span className="muted">车辆等级</span>
<span className="strong">{v.grade}</span>
<span className="muted">·</span>
<span className="muted">状态时长</span>
<span className="mono strong">{v.statusDays}</span>
{v.fleetCode && <>
<span className="muted">·</span>
<span className="muted">编号</span>
<span className="mono strong">{v.fleetCode}</span>
</>}
</div>
</div>
<div className="scroll" style={{flex:1}}>
{/* 业务关系 */}
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
<div className="eyebrow" style={{marginBottom:10}}>业务关系</div>
<div className="col gap-2" style={{fontSize:11}}>
<div className="between"><span className="muted">业务部门</span>
<span className="mid gap-1"><span style={{width:6,height:6,background:v.deptColor,borderRadius:1,display:"inline-block"}}/><span className="strong">{v.deptName}</span></span>
</div>
<div className="between"><span className="muted">业务负责人</span><span className="strong">{v.deptLead}</span></div>
<div className="between"><span className="muted">客户</span><span className="strong" style={{textAlign:"right"}}>{v.customer}</span></div>
<div className="between"><span className="muted">所属公司</span><span style={{fontSize:10, textAlign:"right"}}>{v.ownCompany}</span></div>
{v.own === "lease" && <div className="between"><span className="muted">租赁公司</span><span style={{fontSize:10, textAlign:"right"}}>{v.company}</span></div>}
{v.contractNo && <div className="between"><span className="muted">合同编号</span><span className="mono" style={{fontSize:10}}>{v.contractNo}</span></div>}
</div>
</div>
{/* 实时车况 — 财务身份不可见 */}
{role && role.scope === "finance" ? (
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
<div className="eyebrow" style={{marginBottom:10}}>实时车况</div>
<div style={{padding:"14px 12px", background:"var(--bg-2)", border:"1px dashed var(--border-1)", borderRadius:4, textAlign:"center"}}>
<div style={{fontSize:18, opacity:0.4, marginBottom:6}}>🔒</div>
<div style={{fontSize:11, color:"var(--fg-2)"}}>实时车况数据已隐藏</div>
<div style={{fontSize:10, color:"var(--fg-3)", marginTop:3}}>财务身份仅可见资产 · 业务关系 · 合同</div>
</div>
</div>
) : (
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
<div className="between" style={{marginBottom:10}}>
<span className="eyebrow">实时车况</span>
<span className="chip" style={{fontSize:9, padding:"1px 6px"}}>
<span className={"dot " + (v.gps === "online" ? "ok" : "idle")} style={{width:5,height:5}}/> GPS{v.gps === "online" ? "在线" : "离线"}
</span>
</div>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
<Gauge value={v.speed/120} label={v.speed} sub="km/h" color="var(--info)"/>
<Gauge value={v.soc/100} label={v.soc + "%"} sub="电量" color={v.soc < 20 ? "var(--danger)" : "var(--accent)"}/>
</div>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10, marginTop:10, fontSize:11}}>
<div className="col gap-1">
<span className="muted">氢气压力</span>
<span className="mono strong">{v.h2} MPa</span>
</div>
<div className="col gap-1">
<span className="muted">续航</span>
<span className="mono strong">{v.range} km</span>
</div>
<div className="col gap-1">
<span className="muted">电机温度</span>
<span className="mono strong" style={{color: v.motorTemp > 90 ? "var(--danger)" : "var(--fg-0)"}}>{v.motorTemp}°C</span>
</div>
<div className="col gap-1">
<span className="muted">停车场</span>
<span className="strong" style={{fontSize:10}}>{v.parking}</span>
</div>
</div>
</div>
)}
{/* 里程 & 保养 */}
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
<div className="eyebrow" style={{marginBottom:10}}>里程与保养</div>
<div className="col gap-2" style={{fontSize:11}}>
<div className="between"><span className="muted">累计里程</span><span className="mono strong">{v.totalKm.toLocaleString()} km</span></div>
<div className="between"><span className="muted">上次保养</span><span className="mono">{v.lastMaintDays}天前 · {v.lastMaintKm.toLocaleString()}km</span></div>
<div>
<div className="between" style={{marginBottom:4}}>
<span className="muted">下次保养</span>
<span className={"mono strong"} style={{color: v.kmToMaint < 1000 ? "var(--warn)" : "var(--fg-0)"}}>
剩余 {v.kmToMaint.toLocaleString()} km
</span>
</div>
<div className="bar" style={{height:4}}>
<i style={{
width: Math.min(100, ((10000 - v.kmToMaint) / 10000) * 100) + "%",
background: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)",
}}/>
</div>
</div>
{v.handoverKm != null && (
<div className="between"><span className="muted">交车里程</span><span className="mono">{v.handoverKm.toLocaleString()} km</span></div>
)}
{v.returnKm != null && (
<div className="between"><span className="muted">还车里程</span><span className="mono">{v.returnKm.toLocaleString()} km</span></div>
)}
</div>
</div>
{v.asset === "abnormal" && (
<div style={{padding:"14px 16px"}}>
<div className="eyebrow" style={{marginBottom:8}}>异常处理</div>
<div style={{padding:"8px 10px", background:"var(--danger-soft)", border:"1px solid oklch(0.68 0.220 25 / 0.4)", borderRadius:4, fontSize:11}}>
<div className="between">
<span className="strong">资产状态异常</span>
<span className="mono muted">{v.statusDays}</span>
</div>
<div className="muted" style={{marginTop:3}}>停车场标记为异常 · 待业务部门核查</div>
</div>
</div>
)}
<div style={{padding:"12px 16px", display:"flex", gap:6}}>
<button className="btn primary" style={{flex:1}} onClick={() => location.hash = "#/detail"}><Icon name="route" size={13}/> 详情</button>
<button className="btn" style={{flex:1}} onClick={() => location.hash = "#/playback"}><Icon name="history" size={13}/> 轨迹</button>
<button className="btn icon"><Icon name="bell" size={13}/></button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
window.ArtboardOverview = ArtboardOverview;

418
artboards/playback.jsx Normal file
View File

@@ -0,0 +1,418 @@
// artboard-playback.jsx — Trajectory playback with synchronized data
// ── Calendar dropdown ─────────────────────────────────────
const Calendar = ({ selected, onSelect, onClose }) => {
// Anchor: April 2026 — month-view picker
const [viewMonth, setViewMonth] = React.useState(() => {
const [y, m] = selected.split("-").map(Number);
return { y, m: m - 1 }; // 0-indexed
});
React.useEffect(() => {
const onDoc = (e) => { if (!e.target.closest("[data-cal]")) onClose(); };
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [onClose]);
const monthName = ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"][viewMonth.m];
const firstDay = new Date(viewMonth.y, viewMonth.m, 1).getDay(); // 0=Sun
const daysInMth = new Date(viewMonth.y, viewMonth.m + 1, 0).getDate();
const today = "2026-04-28";
// Mock days with trips (heat indicator)
const tripDays = {
"2026-04-21": 5, "2026-04-22": 6, "2026-04-23": 4, "2026-04-24": 7, "2026-04-25": 3,
"2026-04-26": 0, "2026-04-27": 5, "2026-04-28": 6, "2026-04-15": 4, "2026-04-16": 6,
"2026-04-08": 3, "2026-04-09": 5, "2026-04-10": 4,
};
const cells = [];
for (let i = 0; i < firstDay; i++) cells.push(null);
for (let d = 1; d <= daysInMth; d++) cells.push(d);
while (cells.length % 7) cells.push(null);
const fmt = (d) => `${viewMonth.y}-${String(viewMonth.m + 1).padStart(2,"0")}-${String(d).padStart(2,"0")}`;
return (
<div data-cal style={{
position:"absolute", top:36, left:0, zIndex:100, width:280,
background:"var(--bg-popover)", border:"1px solid var(--border-2)", borderRadius:8,
boxShadow:"0 12px 32px rgba(0,0,0,.18)", padding:14,
}}>
{/* Month nav */}
<div className="between" style={{marginBottom:10}}>
<button className="btn icon sm" onClick={() => setViewMonth(m => ({ y: m.m === 0 ? m.y-1 : m.y, m: m.m === 0 ? 11 : m.m-1 }))}>
<Icon name="chevron" size={12} style={{transform:"rotate(180deg)"}}/>
</button>
<span className="strong" style={{fontSize:13}}>{viewMonth.y} {monthName}</span>
<button className="btn icon sm" onClick={() => setViewMonth(m => ({ y: m.m === 11 ? m.y+1 : m.y, m: m.m === 11 ? 0 : m.m+1 }))}>
<Icon name="chevron" size={12}/>
</button>
</div>
{/* Weekdays */}
<div style={{display:"grid", gridTemplateColumns:"repeat(7, 1fr)", gap:2, marginBottom:4}}>
{["日","一","二","三","四","五","六"].map(w => (
<div key={w} className="muted" style={{fontSize:10, textAlign:"center", padding:"4px 0"}}>{w}</div>
))}
</div>
{/* Day cells */}
<div style={{display:"grid", gridTemplateColumns:"repeat(7, 1fr)", gap:2}}>
{cells.map((d, i) => {
if (!d) return <div key={i} style={{height:30}}/>;
const ds = fmt(d);
const trips = tripDays[ds] || 0;
const isSel = ds === selected;
const isToday = ds === today;
const heat = trips === 0 ? null : trips < 3 ? "var(--accent-soft)" : trips < 5 ? "rgba(0,113,67,.30)" : "var(--accent)";
return (
<div key={i}
onClick={() => onSelect(ds)}
style={{
height:30, borderRadius:5, cursor:"pointer",
display:"flex", flexDirection:"column", alignItems:"center", justifyContent:"center",
fontSize:11, position:"relative",
background: isSel ? "var(--accent)" : "transparent",
color: isSel ? "#fff" : trips === 0 ? "var(--fg-3)" : "var(--fg-1)",
fontWeight: isSel || isToday ? 600 : 400,
border: isToday && !isSel ? "1px solid var(--accent)" : "1px solid transparent",
}}
onMouseEnter={(e) => !isSel && (e.currentTarget.style.background = "var(--bg-2)")}
onMouseLeave={(e) => !isSel && (e.currentTarget.style.background = "transparent")}>
<span className="mono">{d}</span>
{trips > 0 && !isSel && (
<span style={{position:"absolute", bottom:3, width:4, height:4, borderRadius:2, background:heat}}/>
)}
{trips > 0 && isSel && (
<span style={{position:"absolute", bottom:2, fontSize:8, opacity:.85}}>{trips}</span>
)}
</div>
);
})}
</div>
{/* Footer legend */}
<div className="between" style={{marginTop:10, paddingTop:10, borderTop:"1px solid var(--border-1)", fontSize:10}}>
<span className="muted">行程频次</span>
<div className="mid gap-2">
<span className="mid gap-1"><span style={{width:6, height:6, borderRadius:3, background:"var(--accent-soft)"}}/><span className="muted"></span></span>
<span className="mid gap-1"><span style={{width:6, height:6, borderRadius:3, background:"rgba(0,113,67,.30)"}}/><span className="muted"></span></span>
<span className="mid gap-1"><span style={{width:6, height:6, borderRadius:3, background:"var(--accent)"}}/><span className="muted"></span></span>
</div>
</div>
</div>
);
};
const ArtboardPlayback = () => {
const [t, setT] = React.useState(42); // 0-100
const [speed, setSpeed] = React.useState(2);
const [playing, setPlaying] = React.useState(true);
// Date / time-range selection
const [dateStr, setDateStr] = React.useState("2026-04-28");
const [timeFrom, setTimeFrom] = React.useState("14:02");
const [timeTo, setTimeTo] = React.useState("14:44");
const [calOpen, setCalOpen] = React.useState(false);
// List of trips on the selected date — each is a candidate range
const trips = [
{id:"T-001", from:"08:14", to:"09:42", km:42.6, evt:1, route:"总站 → 港务局", active:false},
{id:"T-002", from:"10:08", to:"11:35", km:38.1, evt:0, route:"港务局 → 维保中心", active:false},
{id:"T-003", from:"12:45", to:"13:38", km:21.4, evt:2, route:"维保中心 → 总站", active:false},
{id:"T-004", from:"14:02", to:"14:44", km:32.4, evt:3, route:"总站 → 乍浦港 #2", active:true },
{id:"T-005", from:"15:10", to:"16:32", km:48.9, evt:1, route:"乍浦港 → 嘉兴南", active:false},
{id:"T-006", from:"17:48", to:"19:05", km:55.2, evt:2, route:"嘉兴南 → 总站", active:false},
];
// Compute marker pos along a path
const path = "M 200 540 L 280 480 L 360 420 L 440 380 L 520 340 L 620 320 L 720 320 L 820 340 L 900 380 L 980 440";
const points = [
[200,540],[280,480],[360,420],[440,380],[520,340],[620,320],[720,320],[820,340],[900,380],[980,440]
];
const idx = Math.min(points.length - 1, Math.floor((t/100) * (points.length - 1)));
const next = Math.min(points.length - 1, idx + 1);
const frac = (t/100) * (points.length - 1) - idx;
const px = points[idx][0] + (points[next][0] - points[idx][0]) * frac;
const py = points[idx][1] + (points[next][1] - points[idx][1]) * frac;
React.useEffect(() => {
if (!playing) return;
const id = setInterval(() => setT(prev => (prev + 0.4 * speed) % 100), 80);
return () => clearInterval(id);
}, [playing, speed]);
const events = [
{at:8, type:"start", lbl:"出发·总站"},
{at:22, type:"warn", lbl:"急加速"},
{at:38, type:"stop", lbl:"信号停车"},
{at:55, type:"warn", lbl:"超速"},
{at:74, type:"stop", lbl:"补能站"},
{at:92, type:"end", lbl:"到达·机场"},
];
return (
<div className="app">
<Sidebar active="route"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar crumbs={["轨迹回放", `浙F07179F · ${dateStr.slice(5)} ${timeFrom}${timeTo}`]} kpis={[]} showSearch={false}/>
{/* Date / time-range selector bar */}
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:14, alignItems:"center", flexWrap:"wrap"}}>
<div className="mid gap-2">
<Icon name="history" size={14} style={{color:"var(--accent)"}}/>
<span className="muted" style={{fontSize:11}}>日期</span>
</div>
{/* Date pill with calendar dropdown */}
<div style={{position:"relative"}}>
<button
onClick={() => setCalOpen(!calOpen)}
className="btn"
style={{height:30, padding:"0 12px", display:"flex", alignItems:"center", gap:8, background:"var(--bg-2)", borderColor: calOpen ? "var(--accent)" : "var(--border-1)"}}>
<Icon name="bookmark" size={12}/>
<span className="mono strong" style={{fontSize:12}}>{dateStr}</span>
<span className="muted" style={{fontSize:10, marginLeft:4}}>周二</span>
<Icon name="chevDown" size={11} style={{color:"var(--fg-3)"}}/>
</button>
{calOpen && (
<Calendar selected={dateStr} onSelect={(d) => { setDateStr(d); setCalOpen(false); }} onClose={() => setCalOpen(false)}/>
)}
</div>
{/* Quick-range presets */}
<div className="row gap-1">
{[
{l:"今日", v:"today"},
{l:"昨日", v:"yesterday"},
{l:"近7日", v:"7d"},
{l:"近30日",v:"30d"},
{l:"自定义",v:"custom"},
].map((p,i) => (
<span key={i} className={"chip " + (p.v === "custom" ? "accent" : "")}
style={{cursor:"pointer", fontSize:10}}
onClick={() => {
const today = new Date("2026-04-28");
if (p.v === "today") setDateStr("2026-04-28");
else if (p.v === "yesterday") setDateStr("2026-04-27");
else if (p.v === "7d") setDateStr("2026-04-22");
else if (p.v === "30d") setDateStr("2026-03-29");
}}>{p.l}</span>
))}
</div>
<span style={{width:1, height:20, background:"var(--border-1)"}}/>
{/* Time range */}
<div className="mid gap-2">
<span className="muted" style={{fontSize:11}}>时段</span>
<div style={{display:"flex", alignItems:"center", gap:0, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6, height:30, padding:"0 4px"}}>
<input type="text" value={timeFrom} onChange={e => setTimeFrom(e.target.value)}
style={{width:54, height:22, background:"transparent", border:"none", color:"var(--fg-0)", fontFamily:"var(--font-mono)", fontSize:12, textAlign:"center", outline:"none"}}/>
<span className="muted mono" style={{fontSize:11}}></span>
<input type="text" value={timeTo} onChange={e => setTimeTo(e.target.value)}
style={{width:54, height:22, background:"transparent", border:"none", color:"var(--fg-0)", fontFamily:"var(--font-mono)", fontSize:12, textAlign:"center", outline:"none"}}/>
</div>
</div>
{/* Trip count badge */}
<span className="chip" style={{fontSize:10}}>
<span className="muted">当日行程</span>
<span className="mono strong" style={{marginLeft:6, color:"var(--accent)"}}>{trips.length}</span>
</span>
<div style={{marginLeft:"auto", display:"flex", gap:6}}>
<button className="btn"><Icon name="refresh" size={13}/> 刷新</button>
<button className="btn primary"><Icon name="search" size={13}/> 查询轨迹</button>
</div>
</div>
{/* Toolbar */}
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", display:"flex", gap:12, alignItems:"center", background:"var(--bg-1)"}}>
<span className="chip accent">浙F07179F ×</span>
<span className="chip">+ 添加车辆对比</span>
<span style={{width:1, height:20, background:"var(--border-1)"}}/>
<span className="muted" style={{fontSize:11}}>显示</span>
<span className="chip accent">轨迹</span>
<span className="chip accent">事件</span>
<span className="chip">热力</span>
<span className="chip">停留点</span>
<div style={{marginLeft:"auto", display:"flex", gap:6}}>
<button className="btn"><Icon name="download" size={13}/> 导出</button>
<button className="btn"><Icon name="bookmark" size={13}/> 保存</button>
</div>
</div>
<div style={{flex:1, display:"grid", gridTemplateColumns:"1fr 320px", minHeight:0}}>
<div style={{display:"flex", flexDirection:"column", minWidth:0}}>
{/* Map */}
<div style={{flex:1, position:"relative", minHeight:0}}>
<FleetMap
vehicles={[]}
highlightPath={path}
playbackPoint={{x: px, y: py}}
/>
{/* Event markers on map */}
<svg viewBox="0 0 1240 800" width="100%" height="100%" preserveAspectRatio="xMidYMid meet"
style={{position:"absolute", inset:0, pointerEvents:"none"}}>
{events.map((e,i)=>{
const ei = Math.min(points.length - 1, Math.floor((e.at/100) * (points.length - 1)));
const ef = (e.at/100) * (points.length - 1) - ei;
const en = Math.min(points.length - 1, ei + 1);
const x = points[ei][0] + (points[en][0]-points[ei][0])*ef;
const y = points[ei][1] + (points[en][1]-points[ei][1])*ef;
const c = e.type === "warn" ? "var(--warn)" : e.type === "start" ? "var(--ok)" : e.type === "end" ? "var(--accent)" : "var(--fg-2)";
return (
<g key={i} transform={`translate(${x} ${y})`}>
<circle r="6" fill={c} opacity="0.25"/>
<circle r="3" fill={c} stroke="var(--bg-0)" strokeWidth="1"/>
</g>
);
})}
</svg>
{/* Live readout */}
<div style={{position:"absolute", top:14, left:14, padding:"10px 14px", background:"var(--bg-popover)", border:"1px solid var(--border-2)", borderRadius:8, fontSize:11, fontFamily:"var(--font-mono)", display:"flex", gap:18, boxShadow:"0 4px 16px rgba(0,0,0,.12)"}}>
<div><div className="muted" style={{fontSize:10}}>时间</div><div className="strong">14:{String(Math.floor(t*0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")}</div></div>
<div><div className="muted" style={{fontSize:10}}>速度</div><div className="strong" style={{color:"var(--info)"}}>{Math.floor(40 + Math.sin(t*0.1)*30)} km/h</div></div>
<div><div className="muted" style={{fontSize:10}}>SOC</div><div className="strong" style={{color:"var(--accent)"}}>{Math.floor(78 - t*0.18)}%</div></div>
<div><div className="muted" style={{fontSize:10}}>H₂</div><div className="strong">{(4.2 - t*0.012).toFixed(2)} MPa</div></div>
<div><div className="muted" style={{fontSize:10}}>累计</div><div className="strong">{(t*0.32).toFixed(1)} km</div></div>
</div>
</div>
{/* Player + multi-curve */}
<div style={{borderTop:"1px solid var(--border-1)", background:"var(--bg-1)"}}>
{/* Synced data curves */}
<div style={{padding:"10px 16px 4px", position:"relative"}}>
<div style={{display:"flex", gap:14, fontSize:10, marginBottom:4}}>
<span className="mid gap-1"><span className="dot" style={{background:"var(--info)"}}/> 速度 km/h</span>
<span className="mid gap-1"><span className="dot" style={{background:"var(--accent)"}}/> SOC %</span>
<span className="mid gap-1"><span className="dot" style={{background:"var(--warn)"}}/> H₂压力 MPa</span>
</div>
<div style={{position:"relative", height:80}}>
<div style={{position:"absolute", inset:0}}><LineChart data={genSpeed()} w={920} h={80} color="var(--info)" axis/></div>
<div style={{position:"absolute", inset:0}}><LineChart data={genSoc()} w={920} h={80} color="var(--accent)" fill={false}/></div>
<div style={{position:"absolute", inset:0}}><LineChart data={genH2().map(v=>v*15)} w={920} h={80} color="var(--warn)" fill={false}/></div>
{/* playhead */}
<div style={{position:"absolute", top:0, bottom:0, left: (t)+"%", width:1, background:"var(--accent)", boxShadow:"0 0 8px var(--accent-glow)"}}/>
</div>
</div>
{/* Timeline + controls */}
<div style={{padding:"6px 16px 14px"}}>
<div style={{position:"relative", height:36, background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
{/* event markers */}
{events.map((e,i)=>(
<div key={i} style={{position:"absolute", left: e.at+"%", top:0, bottom:0, width:2,
background: e.type==="warn"?"var(--warn)":e.type==="stop"?"var(--fg-2)":"var(--accent)"}}
title={e.lbl}/>
))}
{/* progress fill */}
<div style={{position:"absolute", left:0, top:0, bottom:0, width: t+"%", background:"var(--accent-soft)"}}/>
{/* playhead */}
<div style={{position:"absolute", left: t+"%", top:-4, bottom:-4, width:2, background:"var(--accent)", boxShadow:"0 0 12px var(--accent-glow)"}}/>
<div style={{position:"absolute", left:`calc(${t}% - 5px)`, top:"50%", marginTop:-5, width:10, height:10, borderRadius:5, background:"var(--accent)", border:"2px solid var(--bg-0)"}}/>
{/* time labels */}
<div style={{position:"absolute", inset:"auto 0 -16px", display:"flex", justifyContent:"space-between", padding:"0 4px", fontSize:9}} className="muted mono">
{["14:02","14:08","14:15","14:23","14:30","14:36","14:44"].map(x=><span key={x}>{x}</span>)}
</div>
</div>
<div className="between" style={{marginTop:24}}>
<div className="mid gap-2">
<button className="btn icon"><Icon name="prev" size={13}/></button>
<button className="btn icon primary" onClick={()=>setPlaying(!playing)}>
<Icon name={playing?"pause":"play"} size={13}/>
</button>
<button className="btn icon"><Icon name="next" size={13}/></button>
<span className="muted mono" style={{marginLeft:8, fontSize:11}}>14:{String(Math.floor(t*0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")} / 14:44:00</span>
</div>
<div className="mid gap-1">
<span className="muted" style={{fontSize:11, marginRight:6}}>倍速</span>
{[0.5, 1, 2, 4, 8, 16].map(s=>(
<span key={s} className={"chip " + (s === speed ? "accent" : "")}
style={{cursor:"pointer"}} onClick={()=>setSpeed(s)}>{s}×</span>
))}
</div>
</div>
</div>
</div>
</div>
{/* Side: trips + events + summary */}
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
{/* Trips of selected day */}
<div style={{borderBottom:"1px solid var(--border-1)"}}>
<div className="panel-head">
<Icon name="route" size={13}/>
<span className="title">当日行程</span>
<span className="chip" style={{marginLeft:"auto"}}>{trips.length}</span>
</div>
<div style={{maxHeight:180, overflowY:"auto"}}>
{trips.map((tr,i)=>(
<div key={i} style={{
display:"flex", gap:10, padding:"8px 14px", cursor:"pointer",
borderLeft: tr.active ? "2px solid var(--accent)" : "2px solid transparent",
background: tr.active ? "var(--accent-soft)" : "transparent",
borderBottom:"1px solid var(--border-1)",
}}>
<div style={{flex:1, minWidth:0}}>
<div className="between">
<span className="mono strong" style={{fontSize:11}}>{tr.from} {tr.to}</span>
<span className="mono" style={{fontSize:10, color: tr.active ? "var(--accent)" : "var(--fg-2)"}}>{tr.km} km</span>
</div>
<div className="between" style={{marginTop:3}}>
<span className="muted" style={{fontSize:10, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap"}}>{tr.route}</span>
{tr.evt > 0 && <span className="chip" style={{fontSize:9, padding:"1px 5px", color:"var(--warn)", background:"var(--warn-soft)", borderColor:"transparent"}}>{tr.evt}事件</span>}
</div>
</div>
</div>
))}
</div>
</div>
{/* Events */}
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
<Icon name="timeline" size={13}/>
<span className="title">事件时间线</span>
<span className="chip" style={{marginLeft:"auto"}}>{events.length}</span>
</div>
<div className="scroll" style={{flex:1, padding:"10px 0"}}>
{events.map((e,i)=>{
const c = e.type === "warn" ? "var(--warn)" : e.type === "start" ? "var(--ok)" : e.type === "end" ? "var(--accent)" : "var(--fg-2)";
return (
<div key={i} style={{display:"flex", gap:10, padding:"10px 16px", cursor:"pointer", borderLeft: Math.abs(t - e.at) < 3 ? "2px solid var(--accent)" : "2px solid transparent", background: Math.abs(t - e.at) < 3 ? "var(--accent-soft)" : "transparent"}} onClick={()=>setT(e.at)}>
<div style={{position:"relative", width:14, paddingTop:3}}>
<span className="dot" style={{background:c, width:8, height:8, borderRadius:4}}/>
{i < events.length - 1 && <span style={{position:"absolute", top:14, bottom:-22, left:3, width:2, background:"var(--border-1)"}}/>}
</div>
<div style={{flex:1}}>
<div className="between">
<span className="strong" style={{fontSize:12}}>{e.lbl}</span>
<span className="mono muted" style={{fontSize:10}}>14:{String(Math.floor(e.at*0.42 + 2)).padStart(2,"0")}</span>
</div>
<div className="muted" style={{fontSize:11, marginTop:2}}>
{e.type==="warn" ? "速度 +18 km/h · 持续 2.4s" :
e.type==="stop" ? "停留 1分12秒" :
e.type==="start" ? "里程 0 km" : "里程 32.4 km · 平均 49 km/h"}
</div>
</div>
</div>
);
})}
</div>
<div style={{padding:14, borderTop:"1px solid var(--border-1)"}}>
<div className="eyebrow" style={{marginBottom:8}}>本次行程</div>
<div className="col gap-2" style={{fontSize:11}}>
<div className="between"><span className="muted">里程</span><span className="mono strong">32.4 km</span></div>
<div className="between"><span className="muted">时长</span><span className="mono strong">42 分钟</span></div>
<div className="between"><span className="muted">平均速度</span><span className="mono strong">46 km/h</span></div>
<div className="between"><span className="muted">最高速度</span><span className="mono strong" style={{color:"var(--warn)"}}>89 km/h</span></div>
<div className="between"><span className="muted">能耗</span><span className="mono strong">5.8 kWh / 0.32 kg H₂</span></div>
<div className="between"><span className="muted">评分</span><span className="mono strong" style={{color:"var(--accent)"}}>87 / 100</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
window.ArtboardPlayback = ArtboardPlayback;

132
artboards/variant-dense.jsx Normal file
View File

@@ -0,0 +1,132 @@
// artboard-dense.jsx — dense info variation: 4-column with mini-map
const ArtboardDense = () => {
return (
<div className="app">
<Sidebar active="map"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
<Topbar
crumbs={["羚牛车辆数据中心", "运营驾驶舱"]}
kpis={[
{ lbl:"在线", val:"487", delta:"95%", deltaUp:true },
{ lbl:"行驶", val:"312" },
{ lbl:"告警P0", val:"2" },
{ lbl:"今日里程", val:"24,781 km" },
{ lbl:"H₂消耗", val:"482 kg" },
{ lbl:"评分", val:"86.4" },
]}
/>
<div style={{flex:1, display:"grid", gridTemplateColumns:"1fr 1fr 1fr 1fr", gridTemplateRows:"auto auto 1fr", gap:8, padding:8, minHeight:0}}>
{/* KPI cards row */}
{[
{l:"车辆健康度", v:"94.2", u:"%", c:"var(--accent)", d:[88,90,89,92,91,94,94]},
{l:"平均能耗", v:"18.4", u:"kWh/100km", c:"var(--info)", d:[19,18,18.5,18.2,18.4,18.4,18.4]},
{l:"H₂利用率", v:"83.1", u:"%", c:"var(--ok)", d:[80,81,82,82,83,83,83.1]},
{l:"安全评分", v:"86.4", u:"/100", c:"var(--warn)", d:[85,86,84,87,86,86,86.4]},
].map((k,i)=>(
<div key={i} className="panel" style={{padding:12}}>
<div className="between">
<span className="eyebrow">{k.l}</span>
<span className="chip ok" style={{fontSize:9}}></span>
</div>
<div style={{marginTop:6}}>
<span className="mono strong" style={{fontSize:24, fontWeight:600}}>{k.v}</span>
<span className="muted mono" style={{fontSize:10, marginLeft:3}}>{k.u}</span>
</div>
<div style={{marginTop:4}}>
<LineChart data={k.d} w={220} h={32} color={k.c}/>
</div>
</div>
))}
{/* Map spans 2 cols 2 rows */}
<div className="panel" style={{gridColumn:"1 / span 2", gridRow:"2 / span 2", overflow:"hidden", position:"relative"}}>
<div className="panel-head" style={{position:"absolute", top:0, left:0, right:0, zIndex:2, background:"oklch(0.18 0.020 245 / 0.85)", backdropFilter:"blur(8px)"}}>
<Icon name="map" size={13}/><span className="title">实时分布</span>
<div className="actions">
<span className="chip">热力</span><span className="chip accent">车辆</span>
</div>
</div>
<div style={{height:"100%"}}>
<FleetMap selectedId="浙F07179F" onSelect={()=>{}} showHeatmap/>
</div>
</div>
{/* Status donut */}
<div className="panel">
<div className="panel-head"><span className="title">车辆状态</span></div>
<div style={{padding:10, display:"flex", gap:14, alignItems:"center"}}>
<Donut size={90} value={0.61} color="var(--ok)" thick={10} label="312"/>
<div className="col gap-2" style={{flex:1, fontSize:11}}>
<div className="between"><span className="mid gap-1"><span className="dot ok"/> 行驶</span><span className="mono strong">312</span></div>
<div className="between"><span className="mid gap-1"><span className="dot warn"/> 待命</span><span className="mono strong">155</span></div>
<div className="between"><span className="mid gap-1"><span className="dot danger"/> 故障</span><span className="mono strong">8</span></div>
<div className="between"><span className="mid gap-1"><span className="dot idle"/> 离线</span><span className="mono strong">25</span></div>
<div className="between"><span className="muted">维保</span><span className="mono strong">12</span></div>
</div>
</div>
</div>
{/* Alerts feed */}
<div className="panel">
<div className="panel-head"><Icon name="bell" size={13}/><span className="title">实时告警</span><span className="chip danger" style={{marginLeft:"auto"}}>3 NEW</span></div>
<div style={{padding:8, display:"flex", flexDirection:"column", gap:6, overflow:"auto", maxHeight:240}}>
{[
{p:"P0", n:"SOC严重不足", v:"浙F08638F", t:"刚刚"},
{p:"P0", n:"右后胎压低", v:"浙F08638F", t:"3m"},
{p:"P1", n:"超速预警", v:"浙F02002F", t:"12m"},
{p:"P1", n:"H₂下降", v:"浙F07179F", t:"32m"},
{p:"P2", n:"急加速", v:"浙F02608F", t:"1h"},
].map((a,i)=>(
<div key={i} className="between" style={{padding:"6px 8px", background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)", fontSize:11}}>
<div className="mid gap-2">
<span className={"chip " + (a.p==="P0"?"danger":a.p==="P1"?"warn":"")}>{a.p}</span>
<span className="strong">{a.n}</span>
<span className="mono muted">· {a.v}</span>
</div>
<span className="mono muted" style={{fontSize:10}}>{a.t}</span>
</div>
))}
</div>
</div>
{/* Energy chart */}
<div className="panel">
<div className="panel-head"><Icon name="bolt" size={13}/><span className="title">能耗 · 24h</span></div>
<div style={{padding:12}}>
<Bars data={[12,8,6,5,4,4,9,14,22,28,26,24,22,28,30,28,24,20,18,16,14,12,10,8]} w={240} h={80} color="var(--info)"/>
<div className="between" style={{marginTop:8, fontSize:11}}>
<span className="muted">总计</span>
<span className="mono strong">4,562 kWh</span>
</div>
</div>
</div>
{/* H2 stations */}
<div className="panel">
<div className="panel-head"><Icon name="h2" size={13}/><span className="title">补能站</span></div>
<div style={{padding:8, fontSize:11}}>
{[
{n:"#04 朝阳", v:0.78, k:"4号站 · 78%"},
{n:"#02 海淀", v:0.42, k:"2号站 · 42%"},
{n:"#07 丰台", v:0.91, k:"7号站 · 91%"},
{n:"#11 通州", v:0.25, k:"11号站 · 25%"},
].map((s,i)=>(
<div key={i} style={{marginBottom:8}}>
<div className="between" style={{marginBottom:3}}>
<span className="mono">{s.k}</span>
<span className="mono strong">{Math.round(s.v*100)}%</span>
</div>
<div className="bar" style={{height:5}}>
<i style={{width: s.v*100+"%", background: s.v < 0.3 ? "var(--danger)" : s.v < 0.5 ? "var(--warn)" : "var(--accent)"}}/>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
window.ArtboardDense = ArtboardDense;

View File

@@ -0,0 +1,55 @@
// artboard-variant-light.jsx — light theme variation of overview
const ArtboardLightVariant = () => {
return (
<div className="app" style={{
"--bg-0": "oklch(0.985 0.003 250)",
"--bg-1": "oklch(1 0 0)",
"--bg-2": "oklch(0.97 0.005 250)",
"--bg-3": "oklch(0.93 0.008 250)",
"--fg-0": "oklch(0.20 0.018 250)",
"--fg-1": "oklch(0.32 0.015 250)",
"--fg-2": "oklch(0.50 0.015 250)",
"--fg-3": "oklch(0.65 0.015 250)",
"--border-1": "oklch(0.88 0.008 250 / 0.9)",
"--border-2": "oklch(0.78 0.010 250 / 0.9)",
"--accent": "oklch(0.62 0.150 175)",
"--accent-soft": "oklch(0.62 0.150 175 / 0.10)"
}}>
<Sidebar active="map"/>
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0}}>
<Topbar
crumbs={["羚牛车辆数据中心", "实时监控", "总览"]}
kpis={[
{ lbl:"在线", val:"487/512", delta:"95.1%", deltaUp:true },
{ lbl:"行驶", val:"312" },
{ lbl:"告警", val:"8", delta:"+2", deltaUp:false },
]}
/>
<div style={{flex:1, display:"grid", gridTemplateColumns:"1fr 320px", minHeight:0}}>
<div style={{position:"relative", background:"#eef0f2"}}>
<FleetMap selectedId="浙F08638F" onSelect={()=>{}} variant="minimal"/>
</div>
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", padding:"14px 16px"}}>
<div className="eyebrow" style={{marginBottom:6}}>当前选中</div>
<div className="mono strong" style={{fontSize:18, fontWeight:600}}>浙F08638F</div>
<div className="muted" style={{fontSize:11, marginTop:4}}>孙超 · 状态告警</div>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:8, marginTop:12, fontSize:11}}>
{[
{l:"速度", v:"0", u:"km/h"},
{l:"SOC", v:"9", u:"%"},
{l:"H₂", v:"0.8", u:"MPa"},
{l:"温度", v:"102", u:"°C"},
].map((k,i)=>(
<div key={i} style={{padding:8, background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
<div className="muted" style={{fontSize:10}}>{k.l}</div>
<div><span className="mono strong" style={{fontSize:18}}>{k.v}</span><span className="muted mono" style={{fontSize:10, marginLeft:3}}>{k.u}</span></div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
window.ArtboardLightVariant = ArtboardLightVariant;

43
assets/logo_light.svg Normal file
View File

@@ -0,0 +1,43 @@
<svg width="150" height="36" viewBox="0 0 150 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.0459 4.69253L24.4084 4.6875L17.3427 16.9334C16.0108 19.2369 14.67 21.5335 13.357 23.8492C12.8279 24.6836 12.7967 25.5367 14.0379 25.5663C17.6611 25.6526 21.3268 25.5356 24.9495 25.6042C24.4224 26.7246 23.2874 28.5534 22.6381 29.6706L21.6984 31.3061L13.8032 31.3074C11.2823 31.3084 7.42295 31.774 6.15339 28.9623C5.10923 26.6498 6.73472 24.2701 7.86608 22.3109L10.1604 18.3408L18.0459 4.69253Z" fill="#2F2828"/>
<path d="M30.7029 4.69293L38.1664 4.6931C40.8704 4.68656 45.5224 4.1044 46.4284 7.56236C47.0408 9.9001 45.3543 12.286 44.21 14.218C42.2951 14.125 39.809 14.1965 37.8457 14.1908C38.3321 13.3838 40.2747 10.8182 38.8199 10.5324C37.4531 10.2639 35.1947 10.4548 33.7653 10.4209C32.0072 13.3847 30.3349 16.4036 28.5844 19.3723C28.4964 19.5215 28.3602 19.7716 28.257 19.9038L21.9204 19.9023L30.7029 4.69293Z" fill="#2F2828"/>
<path d="M89.1232 4.67032C89.7835 4.62775 90.9099 4.66078 91.6053 4.65867L90.978 7.75816L98.7808 7.75999C98.6607 8.46499 98.5297 9.16816 98.3877 9.86921L90.5769 9.86774C90.1934 11.0614 89.853 13.7315 89.4731 15.1689L97.9522 15.1851C97.8321 15.8946 97.6872 16.5697 97.5364 17.2721C94.9175 17.1811 91.7297 17.2538 89.0954 17.2677C88.594 19.3603 88.1541 21.9308 87.7303 24.0635C86.9061 24.0335 86.0812 24.0264 85.2563 24.0421C85.7525 22.0904 86.18 19.3243 86.6177 17.2638L85.2372 17.257L77.0092 17.2648C77.1446 16.5758 77.2727 15.8857 77.3957 15.1944C79.8624 15.1112 82.3687 15.2077 84.839 15.1714C85.5417 15.161 86.2942 15.1588 86.9939 15.2057C87.447 13.5537 87.6608 11.5742 88.1087 9.87872C86.2986 9.82772 84.3142 9.86591 82.4909 9.86569C81.7604 11.226 81.0028 12.4816 80.2657 13.8163L77.5362 13.8114C79.6816 10.4975 80.6808 8.44223 82.2311 4.86287C83.0545 4.83579 84.0075 4.85801 84.8405 4.85771C84.4152 5.82928 83.998 6.81499 83.512 7.75669C85.1516 7.77967 86.8351 7.76247 88.4769 7.76452C88.7163 6.8407 88.9417 5.61995 89.1232 4.67032Z" fill="#2F2828"/>
<path d="M70.2503 4.65824L73.7308 4.65625C74.7336 7.08713 76.1309 9.57515 77.5099 11.8054C76.5818 11.7752 75.527 11.798 74.5887 11.7967C73.5397 9.99962 72.6069 8.13727 71.7957 6.22117C71.2298 6.89947 70.7149 7.63591 70.1079 8.35496L72.1324 8.35415C72.359 9.56198 72.4332 11.2339 72.4619 12.4558L70.2398 12.4478C70.2372 11.0171 70.1331 9.90113 70.0025 8.48344C69.1539 9.57259 67.962 10.814 66.9868 11.7877L65.7182 11.7932L64.2958 11.7963C64.2202 12.4282 64.0755 13.0643 63.9444 13.6881L60.6789 13.6869C60.5363 14.5168 60.3766 15.3436 60.1998 16.1668L63.9171 16.1664L63.5281 18.0826C62.277 18.0555 60.9445 18.0784 59.687 18.0782C58.9474 20.2403 57.828 22.2532 56.3813 24.0224C55.6509 24.0768 54.3913 24.03 53.5917 24.0442C55.3706 21.7397 56.0946 20.7987 57.3125 18.0802L53.6008 18.0731C53.7608 17.396 53.882 16.8655 53.9824 16.1718C55.2848 16.1587 56.5873 16.1591 57.8898 16.1732L58.3652 13.6896L54.9231 13.6853C55.1139 13.0172 55.1958 12.5027 55.3015 11.8164C56.2519 11.7504 57.7795 11.7881 58.7587 11.8109C58.8945 11.0981 59.037 10.3867 59.1861 9.67657C57.9106 9.6642 56.6351 9.66427 55.3597 9.67657C55.4544 9.05996 55.5544 8.44415 55.66 7.8293C57.5354 7.80764 59.4484 7.82827 61.3266 7.83003C61.9933 6.86852 62.6551 5.74668 63.0825 4.66341L65.5379 4.65888C64.9966 5.88817 64.5907 6.67983 63.9019 7.83288L65.5427 7.82645C65.4452 8.4301 65.2987 9.07927 65.1744 9.68205C63.9717 9.64854 62.7013 9.67152 61.4934 9.67532C61.3775 10.3714 61.2146 11.1007 61.0712 11.7947L63.9987 11.7913C66.5788 9.20798 68.0943 7.61586 70.2503 4.65824Z" fill="#2F2828"/>
<path d="M122.532 11.8166C125.798 11.7231 129.441 11.7977 132.734 11.7984C132.466 13.5695 131.978 15.624 131.64 17.4038C131.42 18.5691 130.997 20.9151 130.604 21.9327C130.313 22.7085 129.869 23.4177 129.299 24.0182C128.475 24.0895 127.218 24.0195 126.289 24.0612C126.736 23.701 127.014 23.4316 127.42 23.0228C128.176 22.1519 128.359 21.6669 128.744 20.5722C126.97 20.5048 124.822 20.5601 123.018 20.5584C122.945 21.3158 122.507 23.1882 122.339 24.0641C121.646 24.031 120.793 24.0186 120.111 24.0547C120.247 23.0506 120.522 21.8389 120.724 20.8256L121.799 15.4428C121.945 14.7076 122.299 12.3722 122.532 11.8166ZM129.104 18.8694C129.21 18.2708 129.304 17.7024 129.465 17.1139C127.646 17.0946 125.48 17.1746 123.726 17.1053C123.609 17.6915 123.487 18.2767 123.359 18.8607C125.251 18.8633 127.22 18.8364 129.104 18.8694ZM124.059 15.3809C124.989 15.3848 129.088 15.5282 129.805 15.3507C129.899 14.7602 130.003 14.261 130.135 13.6774C128.223 13.6522 126.311 13.6515 124.399 13.6752C124.302 14.1834 124.187 14.895 124.059 15.3809Z" fill="#2F2828"/>
<path d="M28.257 19.9042C30.3408 19.8373 32.5067 19.9107 34.6003 19.8799C33.5304 21.7989 32.4375 23.705 31.3217 25.5977L37.6027 25.587C37.1076 26.6547 36.2454 28.0061 35.655 29.0623C35.3804 29.5534 34.6405 30.8847 34.3403 31.3077L28.0049 31.3022C28.8125 29.6956 29.8532 28.1298 30.7172 26.5471C30.8889 26.2323 31.0858 25.9059 31.2849 25.6084C29.1922 25.6072 27.049 25.5816 24.9604 25.61C25.6026 24.4466 26.26 23.2918 26.9324 22.1456C27.3144 21.4843 27.8407 20.5151 28.257 19.9042Z" fill="#007143"/>
<path d="M99.7206 10.1076C106.203 9.98573 113.088 10.0971 119.599 10.1018C118.777 13.8389 118.005 17.7955 117.477 21.5875C117.37 22.3581 118.045 23.5899 118.033 24.042C117.125 24.0338 116.218 24.0361 115.311 24.0488C114.92 21.1109 115.519 19.0866 116.068 16.2425C116.357 14.7439 116.652 13.209 116.981 11.7202L99.4205 11.7137C99.5633 11.1642 99.6343 10.6691 99.7206 10.1076Z" fill="#2F2828"/>
<path d="M64.5255 12.945C68.4291 12.8445 72.7488 12.9376 76.6835 12.9394C76.5327 13.651 76.4317 14.5237 75.9691 15.0919C75.4018 15.7882 74.7928 16.4582 74.197 17.1312L70.7419 21.0305L69.7438 21.059C70.2176 22.0474 70.6693 23.0462 71.0985 24.0548C70.1752 24.0352 69.2515 24.0344 68.3282 24.0525C67.9964 23.2947 67.6583 22.5397 67.3138 21.7875C66.4548 19.874 65.5159 17.9972 64.5 16.1621C65.3591 16.1484 66.2391 16.156 67.1001 16.1542C67.8294 17.3343 68.5019 18.5485 69.1155 19.7926C70.6881 18.1842 72.2203 16.5366 73.7103 14.8514L64.1427 14.8496C64.2745 14.2155 64.402 13.5807 64.5255 12.945Z" fill="#2F2828"/>
<path d="M100.218 12.4635C104.946 12.4535 109.674 12.4727 114.401 12.5212C114.273 12.9715 114.112 13.3776 113.946 13.814C112.657 14.4639 111.179 15.1061 109.857 15.7056C111.599 15.949 113.275 15.9778 115.027 16.028C114.844 16.6716 114.645 17.3102 114.427 17.943C112.64 17.957 110.455 17.6924 108.705 17.3225C108.285 17.2407 107.508 16.991 107.072 16.8654C104.084 18.0184 101.851 17.949 98.7192 17.9316C98.791 17.2822 98.9052 16.6738 99.0208 16.0327C102.633 15.9161 105.599 15.8462 108.933 14.123C106.178 14.2266 102.668 14.1367 99.8604 14.135C99.9731 13.5764 100.092 13.0192 100.218 12.4635Z" fill="#2F2828"/>
<path d="M98.7625 18.3504C100.425 18.3036 102.282 18.3451 103.963 18.3452L114.077 18.3459C113.979 18.982 113.891 19.4244 113.733 20.0481C111.681 19.9793 109.274 20.0318 107.196 20.0488C107.14 20.5854 106.873 21.7822 106.759 22.3601C108.872 22.2731 111.751 22.3361 113.883 22.3568C113.718 22.9049 113.8 23.5534 113.504 23.9553C113.406 24.0871 113.154 24.042 112.96 24.0414C107.731 23.9823 102.283 24.0035 97.0497 24.0421C97.15 23.4629 97.2678 22.9333 97.3996 22.3599C99.6372 22.3158 102.265 22.3081 104.496 22.3613C104.62 21.6 104.787 20.8055 104.936 20.0457C102.829 19.9936 100.54 20.0389 98.4199 20.0394L98.7625 18.3504Z" fill="#2F2828"/>
<path d="M133.256 13.9166C133.997 13.9023 134.788 13.9161 135.533 13.9168C135.386 14.8072 135.174 15.7896 135.001 16.6831C137.384 16.4394 139.769 16.2153 142.155 16.0109C141.98 16.6792 141.965 17.2281 141.716 17.9362C141.421 17.9321 141.131 17.9513 140.839 17.9823C138.756 18.2028 136.663 18.3336 134.585 18.5982C134.457 19.6086 134.11 21.1394 133.89 22.1662L135.596 22.1591H140.893C140.792 22.7924 140.684 23.4247 140.569 24.0557C137.586 23.9698 134.244 24.0318 131.246 24.0396C131.9 20.9278 132.738 17.0234 133.256 13.9166Z" fill="#2F2828"/>
<path d="M135.098 4.62931C135.853 4.62166 136.607 4.62419 137.363 4.6369C137.162 5.41299 137.017 6.28154 136.868 7.07464C139.102 6.84738 141.908 6.69114 144.053 6.37755C143.943 7.01342 143.835 7.73358 143.657 8.34683L136.465 8.98722C136.314 9.81163 136.157 10.6349 135.994 11.4569C136.445 11.4309 137.106 11.4499 137.573 11.4482C139.397 11.4591 141.222 11.4559 143.046 11.4386C142.935 12.1076 142.839 12.6758 142.674 13.3374C139.624 13.2772 136.387 13.3227 133.323 13.3214C133.552 12.5671 133.793 11.1594 133.955 10.3413C134.324 8.43507 134.706 6.53103 135.098 4.62931Z" fill="#2F2828"/>
<path d="M102.916 4.63357C103.716 4.61473 104.573 4.6323 105.378 4.63596L104.934 5.48128L121.832 5.48318C121.739 6.10161 121.672 6.52446 121.507 7.12456C119.818 7.05448 117.769 7.10604 116.053 7.10625L103.984 7.11772C103.38 7.9858 102.762 8.89904 102.054 9.68109C101.302 9.67604 100.124 9.63799 99.419 9.71446C99.8816 9.06719 100.543 8.33907 101.04 7.66743C101.837 6.59222 102.296 5.78928 102.916 4.63357Z" fill="#2F2828"/>
<path d="M126 4.67433C126.546 4.62029 128.007 4.65657 128.619 4.65502C127.72 6.37437 126.984 7.53498 125.875 9.09798C127.492 9.05503 129.258 9.08605 130.886 9.08474C130.822 8.136 130.773 7.13368 130.53 6.2156C131.306 6.15771 132.166 6.17288 132.949 6.17043C133.021 7.06163 133.221 8.13834 133.316 9.09988C133.309 9.63037 133.157 10.3841 133.057 10.9028C129.465 10.8026 125.423 10.8945 121.795 10.8945C123.602 8.78078 124.778 7.14823 126 4.67433Z" fill="#2F2828"/>
<path d="M37.8456 14.1928C39.809 14.1984 42.2951 14.127 44.2099 14.22L41.9937 18.0919C41.7788 18.4647 41.1512 19.6063 40.9155 19.9025L34.5791 19.8957C35.6336 17.9833 36.7911 16.1017 37.8456 14.1928Z" fill="#007143"/>
<path d="M40.9155 19.902C43.0195 19.8499 45.1502 19.9224 47.2506 19.8789C46.4721 21.4168 44.8928 24.105 43.9715 25.602L37.6138 25.6077C38.4831 23.9831 39.9311 21.447 40.9155 19.902Z" fill="#007143"/>
<path d="M104.415 7.76391L120.496 7.76172C120.35 8.3135 120.238 8.87355 120.16 9.43887C118.084 9.37345 115.82 9.41552 113.733 9.4153L104.085 9.42408C104.155 8.93055 104.312 8.26469 104.415 7.76391Z" fill="#2F2828"/>
<path d="M96.4304 26.879C99.2507 26.6667 98.9579 30.7714 95.9312 31.3474C92.5488 31.3098 93.6973 27.1881 96.4304 26.879ZM95.9883 30.5709C96.9304 30.2786 97.7955 29.3384 97.4662 28.3229C97.3944 28.096 97.2327 27.9087 97.0175 27.8056C96.7964 27.6975 96.4978 27.6448 96.2518 27.6614C95.3083 27.9393 94.49 28.8604 94.7842 29.8668C94.8501 30.099 95.0075 30.2944 95.2205 30.4079C95.4752 30.5427 95.7065 30.5599 95.9883 30.5709Z" fill="#2F2828"/>
<path d="M120.579 26.8756C123.424 26.7179 123.034 30.7691 120.086 31.3456C116.806 31.3978 117.733 27.1994 120.579 26.8756ZM120.22 30.5368C122.019 29.9774 122.223 27.4751 120.314 27.6674C119.642 27.9156 119.439 28.0285 119.093 28.7081C118.616 29.6484 119.009 30.7721 120.22 30.5368Z" fill="#2F2828"/>
<path d="M116.66 26.9238L117.992 26.9219C117.747 28.2834 117.347 29.9417 117.049 31.3153L116.157 31.3083C116.347 30.6023 116.495 29.7902 116.644 29.0672C116.688 28.9313 116.767 28.502 116.801 28.3408C116.395 29.0335 115.459 30.9511 114.675 31.3444C114.566 31.399 114.465 31.3038 114.371 31.2359C114.165 30.71 113.925 28.8772 113.836 28.242L113.198 31.3117L112.31 31.3165L113.23 26.9279L114.491 26.9225C114.605 27.7811 114.833 28.7942 114.973 29.6896C115.472 28.9993 116.215 27.6823 116.66 26.9238Z" fill="#2F2828"/>
<path d="M86.5921 26.9211C86.9917 26.9147 87.4287 26.9264 87.8225 26.9095C90.4759 26.7956 90.1867 29.6754 88.5223 30.9111C87.5305 31.4374 86.7699 31.3173 85.6661 31.3024C85.7957 30.2878 86.3578 28.007 86.5921 26.9211ZM86.7231 30.4522C87.1359 30.4503 87.7347 30.4871 88.1007 30.316C88.6401 29.8127 89.3033 28.7778 88.6848 28.0956C88.3158 27.6887 87.7976 27.7551 87.2911 27.7533C87.1257 28.5612 86.9295 29.669 86.7231 30.4522Z" fill="#2F2828"/>
<path d="M123.457 26.9226C124.213 26.9143 125.634 26.8007 126.186 27.3128C126.71 27.7978 125.896 28.5423 125.68 28.9376C125.595 29.0947 126.087 29.5814 126.078 29.8632C126.065 30.2604 125.746 30.6635 125.492 30.9391C124.737 31.4476 123.501 31.3147 122.564 31.3117C122.81 29.8751 123.187 28.3774 123.457 26.9226ZM123.593 30.5161C124.141 30.5116 124.358 30.5255 124.891 30.3884C125.067 30.151 125.129 30.0825 125.184 29.7933C125.024 29.2759 124.325 29.3801 123.862 29.3789L123.593 30.5161ZM123.999 28.6156C124.282 28.6098 124.866 28.6145 125.123 28.5796C125.245 28.3715 125.336 28.252 125.328 28.0082C125.082 27.6121 124.658 27.6901 124.214 27.6711C124.144 27.9762 124.058 28.3111 123.999 28.6156Z" fill="#2F2828"/>
<path d="M90.735 26.9215C92.024 26.9243 94.8238 26.5442 93.2493 29.0441C93.0876 29.3009 92.7831 29.446 92.5137 29.583C92.7384 30.1413 92.9478 30.7419 93.1556 31.3095C92.8314 31.3198 92.3036 31.3998 92.0979 31.1551C91.9179 30.6395 91.7971 30.0956 91.3931 29.741C90.8792 29.6812 90.7592 30.8999 90.6765 31.3136L89.8201 31.3149C90.1114 29.8477 90.4166 28.3831 90.735 26.9215ZM91.2167 28.8258C91.4736 28.8227 92.2707 28.8268 92.4815 28.7911C92.6447 28.5511 92.7304 28.4484 92.7926 28.1652C92.6755 27.6152 91.9171 27.7496 91.4458 27.7535C91.366 28.1101 91.2891 28.4675 91.2167 28.8258Z" fill="#2F2828"/>
<path d="M69.3081 26.9222L70.1771 26.9141C69.9698 28.186 69.5337 30.0383 69.2388 31.3177L68.1065 31.3098C67.8398 30.3494 67.4692 29.2815 67.1696 28.3128C67.0189 29.2546 66.7757 30.3753 66.5616 31.3076L65.6787 31.3079L66.6075 26.9221L67.7409 26.9223C68.0639 27.8733 68.37 28.898 68.6717 29.8604C68.873 28.8787 69.0851 27.8992 69.3081 26.9222Z" fill="#2F2828"/>
<path d="M108.881 29.8868C109.027 29.2478 109.289 27.5001 109.54 27.0002C109.723 26.889 110.12 26.9136 110.352 26.9105C110.263 27.8288 109.679 30.3103 109.457 31.3037L108.341 31.3085C108.011 30.3531 107.719 29.367 107.384 28.4004L106.772 31.3134L105.9 31.3077C106.155 29.911 106.523 28.3126 106.832 26.9175L107.966 26.9206C108.2 27.8185 108.6 28.9795 108.881 29.8868Z" fill="#2F2828"/>
<path d="M61.0197 26.9225L61.8629 26.9102C61.7079 28.0991 61.2332 30.0627 60.9722 31.3099L59.8503 31.3086C59.519 30.3592 59.2069 29.3429 58.8931 28.3815L58.2722 31.3125L57.3802 31.3049C57.7123 30.0129 58.0673 28.24 58.3208 26.9184L59.4601 26.922C59.6904 27.8033 60.1149 29.0009 60.4008 29.8927C60.5467 28.9635 60.7999 27.8383 61.0197 26.9225Z" fill="#2F2828"/>
<path d="M78.6518 26.9215L79.5294 26.9146C79.4255 27.4595 79.2959 28.0172 79.1766 28.5603C79.689 28.553 80.216 28.5592 80.7298 28.5592L81.0951 26.923L81.9493 26.9141C81.8519 27.9269 81.2576 30.0868 81.0526 31.1643C81.0343 31.2615 80.9867 31.26 80.8865 31.3094C80.6281 31.3129 80.44 31.3262 80.1889 31.262C80.2658 30.6683 80.4253 30.0042 80.5534 29.4127L78.998 29.4118C78.867 30.0925 78.7586 30.6456 78.5669 31.3166L77.7339 31.3159C77.9893 29.9039 78.3363 28.3198 78.6518 26.9215Z" fill="#2F2828"/>
<path d="M103.391 26.918L106.201 26.9222L106.032 27.7871C105.48 27.7389 104.623 27.7531 104.059 27.7493C104.008 28.0771 103.939 28.4261 103.88 28.7546L105.576 28.7521L105.445 29.4786C105.093 29.4693 104.014 29.408 103.727 29.5427L103.677 29.7236L103.488 30.4471L105.46 30.4415C105.391 30.7111 105.332 31.0336 105.273 31.3098C104.347 31.2889 103.359 31.3061 102.428 31.3067C102.714 29.9523 103.028 28.2233 103.391 26.918Z" fill="#2F2828"/>
<path d="M101.23 26.8806C101.741 26.8186 102.269 26.978 102.715 27.2175C102.67 27.4649 102.637 27.8569 102.466 28.0164C102.325 28.0441 101.618 27.7141 101.272 27.6162C100.683 27.7595 100.181 27.9029 99.8684 28.4645C99.2265 29.6163 99.5405 30.8072 101.032 30.4857C101.475 30.0635 101.026 29.4324 101.687 29.1473C101.888 29.1663 102.02 29.1878 102.216 29.2257C102.312 29.5647 101.987 30.9407 101.742 31.0531C97.9931 32.7738 97.5708 27.2933 101.23 26.8806Z" fill="#2F2828"/>
<path d="M74.9385 26.9214C75.1712 26.9118 75.4808 26.8744 75.6821 26.9707C75.7905 27.2226 75.7246 27.2838 75.6726 27.6035C75.3345 28.829 75.382 30.2578 74.2211 31.0737C73.6941 31.4969 72.454 31.4555 72.0957 30.8256C71.57 29.9017 72.2194 27.9607 72.4365 26.9236L73.3245 26.923C73.2315 27.4568 72.5294 29.9806 72.8643 30.2786C74.3522 31.6025 74.7181 27.8186 74.9385 26.9214Z" fill="#2F2828"/>
<path d="M64.4934 26.8798C65.0658 26.8499 65.4038 26.9622 65.9183 27.1614C65.9004 27.4821 65.8231 27.8782 65.7694 28.201C65.3237 27.8267 65.0369 27.7669 64.4898 27.6111C64.0346 27.7299 63.4555 27.898 63.1815 28.3015C62.8659 28.7125 62.5666 30.01 63.1207 30.2988C64.5127 31.0242 64.4083 30.3038 64.6865 29.2341C64.6957 29.1988 65.0789 29.1547 65.1737 29.1403L65.443 29.2169C65.5798 29.5824 65.2474 30.6016 65.1325 31.0264C64.7903 31.1835 64.4256 31.2859 64.0516 31.3298C60.9761 31.6856 61.3322 27.1955 64.4934 26.8798Z" fill="#2F2828"/>
<path d="M85.1552 26.9215L86.2297 26.918C85.7693 27.4771 85.3097 28.0605 84.8551 28.6258C84.028 29.5226 84.0163 30.1406 83.7762 31.3103L82.8773 31.3089C83.4446 29.0983 83.1291 29.0171 82.3298 26.9199L83.3107 26.9197C83.512 27.4507 83.7059 27.9844 83.8926 28.5204L85.1552 26.9215Z" fill="#2F2828"/>
<path d="M139.373 26.9247L140.422 26.918C140.11 27.4009 139.441 28.178 139.059 28.6479C138.207 29.6578 138.305 30.0537 137.985 31.3112L137.113 31.3094C137.262 30.7606 137.396 30.0375 137.516 29.4702C137.197 28.6188 136.873 27.7696 136.543 26.9227L137.516 26.921C137.728 27.4476 137.932 27.9767 138.13 28.5083C138.556 27.9893 138.97 27.4613 139.373 26.9247Z" fill="#2F2828"/>
<path d="M57.3993 4.67527C58.1061 4.65383 58.8637 4.66377 59.5748 4.66016C59.8347 5.63537 59.8946 6.41442 59.9652 7.40979C59.3798 7.36896 58.4268 7.39837 57.8139 7.39757C57.7331 6.3945 57.6463 5.64817 57.3993 4.67527Z" fill="#2F2828"/>
<path d="M133.406 26.9219L136.447 26.9253L136.333 27.7751C135.986 27.7619 135.585 27.7671 135.234 27.7638C135.054 28.5778 134.891 29.4212 134.722 30.2397L134.468 31.3148L133.589 31.3145C133.858 30.1379 134.11 28.9573 134.343 27.7731C134.007 27.7561 133.613 27.7725 133.273 27.7783L133.406 26.9219Z" fill="#2F2828"/>
<path d="M53.4287 26.918L54.3232 26.9254L53.5875 30.453L55.5179 30.4498L55.3763 31.3123C54.4204 31.2926 53.4227 31.3031 52.4631 31.2988C52.7142 30.6151 53.2746 27.7241 53.4287 26.918Z" fill="#2F2828"/>
<path d="M128.859 26.924L129.753 26.9219C129.475 28.0093 129.226 29.3489 128.993 30.4563L130.915 30.4496L130.763 31.3133C129.848 31.2883 128.851 31.3078 127.93 31.3087L128.859 26.924Z" fill="#2F2828"/>
<path d="M127.308 26.9204L128.164 26.918L127.239 31.3152L126.351 31.3151C126.65 29.8952 126.949 28.3149 127.308 26.9204Z" fill="#2F2828"/>
<path d="M56.7724 26.918L57.6656 26.9141C57.3133 28.3287 57.0132 29.8795 56.7123 31.3129L55.8318 31.3086C56.1293 29.8417 56.4429 28.3781 56.7724 26.918Z" fill="#2F2828"/>
<path d="M70.8595 26.9214L71.7381 26.918C71.567 28.0722 71.0941 30.1443 70.8119 31.3022L69.9707 31.3105C70.1852 29.9437 70.5812 28.3064 70.8595 26.9214Z" fill="#2F2828"/>
<path d="M132.146 26.9187L133.056 26.918C132.809 28.082 132.418 30.2402 132.087 31.3152L131.25 31.3052C131.533 29.8444 131.902 28.3846 132.146 26.9187Z" fill="#2F2828"/>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

150
components/charts.jsx Normal file
View File

@@ -0,0 +1,150 @@
// charts.jsx — minimal SVG charts for the cockpit
// Sparkline / line chart
const LineChart = ({ data, w = 300, h = 80, color = "var(--accent)", fill = true, axis = false, showDots = false, baseline = null }) => {
const min = Math.min(...data), max = Math.max(...data);
const span = max - min || 1;
const stepX = w / (data.length - 1);
const padY = 6;
const y = v => padY + (h - padY * 2) * (1 - (v - min) / span);
const pts = data.map((v, i) => [i * stepX, y(v)]);
const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0] + " " + p[1]).join(" ");
const areaPath = path + ` L ${w} ${h} L 0 ${h} Z`;
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: "block" }}>
<defs>
<linearGradient id={`g-${color.replace(/[^a-z]/gi,'')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={color} stopOpacity="0.35"/>
<stop offset="1" stopColor={color} stopOpacity="0"/>
</linearGradient>
</defs>
{axis && (
<>
<line x1="0" y1={h-1} x2={w} y2={h-1} stroke="var(--border-1)" strokeWidth="1"/>
{[0.25, 0.5, 0.75].map(p => (
<line key={p} x1="0" y1={h*p} x2={w} y2={h*p} stroke="var(--border-1)" strokeWidth="0.5" strokeDasharray="2 3"/>
))}
</>
)}
{baseline != null && (
<line x1="0" y1={y(baseline)} x2={w} y2={y(baseline)} stroke="var(--warn)" strokeWidth="1" strokeDasharray="3 3" opacity="0.6"/>
)}
{fill && <path d={areaPath} fill={`url(#g-${color.replace(/[^a-z]/gi,'')})`}/>}
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
{showDots && pts.map((p, i) => (
<circle key={i} cx={p[0]} cy={p[1]} r="2" fill={color}/>
))}
</svg>
);
};
const Bars = ({ data, w = 300, h = 80, color = "var(--accent)", labels = null }) => {
const max = Math.max(...data) || 1;
const slot = w / data.length;
const bw = Math.max(slot * 0.55, 3);
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: "block" }}>
{data.map((v, i) => {
const bh = (h - 14) * (v / max);
return (
<g key={i}>
<rect x={i*slot + (slot-bw)/2} y={h - 14 - bh} width={bw} height={bh} fill={color} rx="1.5" opacity={0.85}/>
{labels && <text x={i*slot + slot/2} y={h-3} textAnchor="middle" fill="var(--fg-3)" fontSize="9" fontFamily="var(--font-mono)">{labels[i]}</text>}
</g>
);
})}
</svg>
);
};
// Donut for pie share
const Donut = ({ size = 80, value = 0.7, color = "var(--accent)", track = "var(--bg-3)", thick = 8, label }) => {
const r = size/2 - thick/2;
const c = 2*Math.PI*r;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={track} strokeWidth={thick}/>
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={color} strokeWidth={thick}
strokeDasharray={`${c*value} ${c}`} strokeDashoffset={c*0.25}
transform={`rotate(-90 ${size/2} ${size/2})`}
strokeLinecap="round"/>
{label && <text x={size/2} y={size/2+1} textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--fg-0)" fontFamily="var(--font-mono)">{label}</text>}
</svg>
);
};
// Radial gauge (for speed / soc)
const Gauge = ({ value = 0.6, size = 100, color = "var(--accent)", label, sub }) => {
const r = size/2 - 8;
const c = Math.PI * r; // half circle
return (
<svg width={size} height={size*0.65} viewBox={`0 0 ${size} ${size*0.65}`}>
<path d={`M 8 ${size/2} A ${r} ${r} 0 0 1 ${size-8} ${size/2}`}
fill="none" stroke="var(--bg-3)" strokeWidth="6" strokeLinecap="round"/>
<path d={`M 8 ${size/2} A ${r} ${r} 0 0 1 ${size-8} ${size/2}`}
fill="none" stroke={color} strokeWidth="6" strokeLinecap="round"
strokeDasharray={`${c*value} ${c}`}/>
<text x={size/2} y={size/2 - 4} textAnchor="middle" fontSize="18" fontWeight="600" fill="var(--fg-0)" fontFamily="var(--font-mono)">{label}</text>
{sub && <text x={size/2} y={size/2 + 10} textAnchor="middle" fontSize="9" fill="var(--fg-3)" letterSpacing="0.1em">{sub}</text>}
</svg>
);
};
// Stacked horizontal bar (battery + h2)
const Stack = ({ segs, w = 200, h = 8 }) => {
const total = segs.reduce((a, s) => a + s.v, 0) || 1;
let x = 0;
return (
<svg width={w} height={h} style={{display: "block"}}>
{segs.map((s, i) => {
const sw = w * (s.v/total);
const r = <rect key={i} x={x} y="0" width={sw} height={h} fill={s.color || "var(--accent)"}/>;
x += sw;
return r;
})}
</svg>
);
};
// Sample data generators (deterministic)
const seed = (s) => () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
const genSpeed = () => {
const r = seed(12);
const out = [];
let v = 50;
for (let i = 0; i < 60; i++) {
v += (r() - 0.5) * 12;
v = Math.max(0, Math.min(95, v));
out.push(v);
}
return out;
};
const genSoc = () => {
const out = [];
let v = 92;
for (let i = 0; i < 60; i++) {
v -= 0.08 + Math.random()*0.4;
out.push(Math.max(15, v));
}
return out;
};
const genH2 = () => {
const out = [];
let v = 4.8;
for (let i = 0; i < 60; i++) {
v -= 0.005 + Math.random()*0.04;
out.push(Math.max(0.5, v));
}
return out;
};
window.LineChart = LineChart;
window.Bars = Bars;
window.Donut = Donut;
window.Gauge = Gauge;
window.Stack = Stack;
window.genSpeed = genSpeed;
window.genSoc = genSoc;
window.genH2 = genH2;

166
components/chrome.jsx Normal file
View File

@@ -0,0 +1,166 @@
// chrome.jsx — Sidebar, Topbar shared chrome (route-aware)
const SIDEBAR_ITEMS = [
{ id: "overview", icon: "map", label: "实时地图" },
{ id: "detail", icon: "car", label: "车辆详情" },
{ id: "history", icon: "history", label: "历史查询" },
{ id: "playback", icon: "route", label: "轨迹回放" },
{ id: "alarm", icon: "bell", label: "事件规则" },
{ id: "inbox", icon: "inbox", label: "通知中心" },
];
const SIDEBAR_SUB = [
{ id: "esg", icon: "chart", label: "ESG·碳减排" },
{ id: "canvas", icon: "settings", label: "设计画板" },
];
const Sidebar = ({ active }) => {
// resolve current route from context if present, fall back to prop
const ctx = (typeof window.useRoute === "function") ? window.useRoute() : null;
const cur = (ctx && ctx.route) || active || "overview";
const nav = (ctx && ctx.navigate) || ((p) => { window.location.hash = "#/" + p; });
return (
<div className="sidebar">
<div className="logo" title="羚牛 · Lingniu" onClick={() => nav("overview")} style={{
cursor:"pointer",
background:"#FFFFFF",
border:"1px solid var(--border-1)",
boxShadow:"0 1px 2px rgba(47,40,40,.06)",
overflow:"hidden",
padding:0,
}}>
<img src="assets/logo_light.svg" alt="羚牛"
style={{height:24, width:"auto", maxWidth:"none", marginLeft:-3, display:"block", pointerEvents:"none"}}/>
</div>
{SIDEBAR_ITEMS.map(i => (
<div key={i.id}
className={"nav-item" + (i.id === cur ? " active" : "")}
title={i.label}
onClick={() => nav(i.id)}
style={{cursor:"pointer"}}>
<Icon name={i.icon} size={18}/>
</div>
))}
<div className="sidebar-divider"/>
{SIDEBAR_SUB.map(i => (
<div key={i.id}
className={"nav-item" + (i.id === cur ? " active" : "")}
title={i.label}
onClick={() => nav(i.id)}
style={{cursor:"pointer"}}>
<Icon name={i.icon} size={18}/>
</div>
))}
<div style={{flex:1}}/>
<div className="avatar" title="张工">ZG</div>
</div>
);
};
const Topbar = ({ crumbs = ["羚牛车辆数据中心", "实时监控"], kpis = [], showSearch = true }) => {
const ctx = (typeof window.useRoute === "function") ? window.useRoute() : null;
const isMobile = ctx && ctx.isMobile;
return (
<div className="topbar">
{isMobile && (
<button onClick={ctx.openDrawer} aria-label="菜单" style={{
width:36, height:36, display:"grid", placeItems:"center", borderRadius:8,
background:"transparent", border:"none", color:"var(--fg-1)", cursor:"pointer",
marginLeft:-4,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
)}
<div className="crumbs">
{crumbs.map((c, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="sep">/</span>}
<span className={i === crumbs.length - 1 ? "now" : "muted"}>{c}</span>
</React.Fragment>
))}
</div>
<div className="kpi-row">
{kpis.map((k, i) => (
<div key={i} className="kpi-mini">
<span className="lbl">{k.lbl}</span>
<span className="val tnum">{k.val}</span>
{k.delta && <span className={"delta " + (k.deltaUp ? "up" : "down")}>{k.deltaUp ? "▲" : "▼"} {k.delta}</span>}
</div>
))}
</div>
{showSearch && (
<div className="search" style={{marginLeft: "auto"}}>
<Icon name="search" size={13}/>
<input placeholder="搜索车牌 / VIN / 部门 / 客户…"/>
<kbd>K</kbd>
</div>
)}
<div className="right">
<div className="icon-btn"><Icon name="refresh" size={15}/></div>
<div className="icon-btn">
<Icon name="bell" size={15}/>
<span className="dot"/>
</div>
<ThemeToggle/>
<div className="icon-btn"><Icon name="settings" size={15}/></div>
<RoleBadge/>
</div>
</div>
);
};
// Role badge — shows current logged-in user, opens tweaks panel on click
const RoleBadge = () => {
const { role } = (typeof window.useCurrentRole === "function") ? window.useCurrentRole() : { role: null };
if (!role) return <div className="avatar" style={{marginLeft: 4}}></div>;
const initial = role.name.replace(/.*·/,"").charAt(0) || role.name.charAt(0);
const isAdmin = role.scope === "all";
const tip = role.scope === "dept" ? `仅可见 ${role.deptId === "biz1" ? "业务一部" : "业务二部"} 车辆` : role.desc;
return (
<div title={tip} style={{
display:"flex", alignItems:"center", gap:8, padding:"4px 4px 4px 10px",
borderRadius: 16, border:"1px solid var(--border-1)",
background: isAdmin ? "transparent" : "var(--accent-soft)",
cursor:"pointer", marginLeft: 4,
}}>
<div className="col" style={{lineHeight:1.1, alignItems:"flex-end"}}>
<span style={{fontSize:11, color:"var(--fg-1)", fontWeight:500}}>{role.name}</span>
<span style={{fontSize:9, color: isAdmin ? "var(--fg-3)" : "var(--accent)", textTransform:"uppercase", letterSpacing:0.5}}>
{role.scope === "all" ? "FULL ACCESS" : role.scope === "dept" ? "DEPT SCOPE" : role.scope === "ops" ? "OPS" : "FINANCE"}
</span>
</div>
<div className="avatar" style={{margin:0}}>{initial}</div>
</div>
);
};
// Theme toggle — switches between light & dark themes
const ThemeToggle = () => {
const ctx = (typeof window.useTheme === "function") ? window.useTheme() : null;
if (!ctx) return null;
const { theme, setTheme } = ctx;
const isDark = theme === "dark";
return (
<button className="icon-btn" onClick={() => setTheme(isDark ? "light" : "dark")}
title={isDark ? "切换到亮色" : "切换到暗色"}
style={{background:"transparent", border:"none", cursor:"pointer", padding:0}}>
{isDark ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"/>
</svg>
)}
</button>
);
};
window.Sidebar = Sidebar;
window.Topbar = Topbar;

74
components/icons.jsx Normal file
View File

@@ -0,0 +1,74 @@
// icons.jsx — tiny stroke icon set for the cockpit
const Icon = ({ name, size = 16, className = "", style = {} }) => {
const paths = {
map: <><path d="M9 3 3 5v13l6-2 6 2 6-2V3l-6 2Z"/><path d="M9 3v13M15 5v13"/></>,
car: <><path d="M3 12l2-5h14l2 5"/><rect x="2" y="12" width="20" height="6" rx="1"/><circle cx="7" cy="18" r="1.5"/><circle cx="17" cy="18" r="1.5"/></>,
history: <><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></>,
route: <><circle cx="6" cy="19" r="2"/><circle cx="18" cy="5" r="2"/><path d="M8 19h6a4 4 0 0 0 0-8h-4a4 4 0 0 1 0-8h6"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 9H3c0-2 3-2 3-9Z"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
inbox: <><path d="M3 13h5l1 2h6l1-2h5"/><path d="M5 5h14l2 8v6H3v-6Z"/></>,
settings: <><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4.9a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.5a7 7 0 0 0-2 1.2l-2.4-.9-2 3.4 2 1.6A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.6 2 3.4 2.4-.9c.6.5 1.3.9 2 1.2L10 21h4l.5-2.5c.7-.3 1.4-.7 2-1.2l2.4.9 2-3.4-2-1.6c.1-.4.1-.8.1-1.2Z"/></>,
bolt: <><path d="M13 2 4 13h7l-1 9 9-11h-7l1-9Z"/></>,
fuel: <><path d="M3 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M3 21h11M14 8h2a2 2 0 0 1 2 2v6a2 2 0 0 0 4 0V8l-3-3"/></>,
h2: <><path d="M5 6v12M5 12h6M11 6v12"/><path d="M15 14a3 3 0 1 1 6 0c0 2-3 2-3 4h3"/></>,
gauge: <><path d="M21 12a9 9 0 1 0-15.5 6.2"/><path d="m12 12 5-3"/><circle cx="12" cy="12" r="1.4"/></>,
thermo: <><path d="M14 14V5a2 2 0 0 0-4 0v9a4 4 0 1 0 4 0Z"/></>,
tire: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6M3 12h6M15 12h6M5.6 5.6l4.2 4.2M14.2 14.2l4.2 4.2M5.6 18.4l4.2-4.2M14.2 9.8l4.2-4.2"/></>,
play: <><path d="M6 4l14 8-14 8Z"/></>,
pause: <><path d="M7 4v16M17 4v16"/></>,
next: <><path d="M5 4v16l14-8Z"/></>,
prev: <><path d="M19 20V4L5 12Z"/></>,
chevron: <><path d="m9 6 6 6-6 6"/></>,
chevDown: <><path d="m6 9 6 6 6-6"/></>,
plus: <><path d="M12 5v14M5 12h14"/></>,
close: <><path d="M6 6l12 12M18 6 6 18"/></>,
search: <><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></>,
filter: <><path d="M3 5h18l-7 9v6l-4-2v-4Z"/></>,
download: <><path d="M12 4v12m-5-5 5 5 5-5M4 20h16"/></>,
refresh: <><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5M20 12a8 8 0 0 1-14 5.3L3 15M3 20v-5h5"/></>,
pin: <><path d="M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12Z"/><circle cx="12" cy="10" r="2.5"/></>,
expand: <><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></>,
grip: <><circle cx="9" cy="6" r="1.2"/><circle cx="15" cy="6" r="1.2"/><circle cx="9" cy="12" r="1.2"/><circle cx="15" cy="12" r="1.2"/><circle cx="9" cy="18" r="1.2"/><circle cx="15" cy="18" r="1.2"/></>,
layers: <><path d="m12 3 9 5-9 5-9-5Z"/><path d="m3 13 9 5 9-5M3 18l9 5 9-5"/></>,
chart: <><path d="M4 4v16h16"/><path d="m8 14 3-4 3 3 5-7"/></>,
user: <><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></>,
shield: <><path d="M12 3 4 6v6c0 5 3.5 8 8 9 4.5-1 8-4 8-9V6Z"/></>,
flag: <><path d="M5 21V4M5 4h13l-3 5 3 5H5"/></>,
sliders: <><path d="M4 6h12M20 6h0M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="18" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></>,
edit: <><path d="M4 20h4l11-11-4-4L4 16Z"/></>,
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></>,
plug: <><path d="M9 2v6M15 2v6M7 8h10v3a5 5 0 0 1-10 0Z"/><path d="M12 16v6"/></>,
wifi: <><path d="M5 12a10 10 0 0 1 14 0M8 15a6 6 0 0 1 8 0M11 18h2"/></>,
sat: <><circle cx="12" cy="12" r="3"/><path d="M5 12a7 7 0 0 1 7-7M19 12a7 7 0 0 1-7 7M3 12a9 9 0 0 1 9-9M21 12a9 9 0 0 1-9 9"/></>,
lightning: <><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8Z"/></>,
wrench: <><path d="M14 7a4 4 0 0 1 5 5l-2-1-2 2 1 2a4 4 0 0 1-5-5l-7 7 3 3 7-7"/></>,
truck: <><path d="M3 7h11v10H3zM14 10h4l3 3v4h-7"/><circle cx="7" cy="18" r="1.5"/><circle cx="17" cy="18" r="1.5"/></>,
list: <><path d="M4 6h16M4 12h16M4 18h16"/></>,
fullscreen: <><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></>,
timeline: <><path d="M3 12h18"/><circle cx="6" cy="12" r="2"/><circle cx="14" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></>,
branch: <><path d="M6 3v18M18 21V9a4 4 0 0 0-4-4h-4"/><circle cx="6" cy="3" r="2"/><circle cx="6" cy="21" r="2"/><circle cx="18" cy="9" r="2"/></>,
bookmark: <><path d="M5 3h14v18l-7-5-7 5Z"/></>,
moon: <><path d="M21 13a9 9 0 1 1-10-10 7 7 0 0 0 10 10Z"/></>,
speed: <><circle cx="12" cy="13" r="9"/><path d="m12 13 5-3M12 4v2"/></>,
leaf: <><path d="M4 20c0-9 7-16 16-16 0 9-7 16-16 16Z"/><path d="M4 20c5-5 11-9 16-13"/></>,
cube: <><path d="m12 3 9 5v8l-9 5-9-5V8Z"/><path d="m3 8 9 5 9-5M12 13v9"/></>,
pulse: <><path d="M3 12h4l3-7 4 14 3-7h4"/></>,
mail: <><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></>,
phone: <><path d="M5 4h4l2 5-3 2a11 11 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 6a2 2 0 0 1 2-2Z"/></>,
clipboard: <><rect x="6" y="4" width="12" height="17" rx="2"/><rect x="9" y="2" width="6" height="4" rx="1"/><path d="M9 11h6M9 15h4"/></>,
x: <><path d="M6 6l12 12M18 6 6 18"/></>,
sun: <><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></>,
};
return (
<svg
viewBox="0 0 24 24"
width={size} height={size}
fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
className={className}
style={style}
aria-hidden="true"
>{paths[name] || null}</svg>
);
};
window.Icon = Icon;

318
components/map.jsx Normal file
View File

@@ -0,0 +1,318 @@
// map.jsx — Stylized cockpit map. SVG-based road network. Theme-aware via CSS vars.
const MAP_BG = "var(--map-bg)";
const MAP_GRID = "var(--map-grid)";
const MAP_PARK = "var(--map-park)";
const MAP_PARK_STROKE = "var(--map-park-stroke)";
const MAP_RIVER = "var(--map-river)";
const MAP_ROAD_MINOR = "var(--map-road-minor)";
const MAP_ROAD_MAJOR_OUTER = "var(--map-road-major-outer)";
const MAP_ROAD_MAJOR_INNER = "var(--map-road-major-inner)";
// 嘉兴乍浦 (Zhapu Port, Jiaxing) — port city on Hangzhou Bay.
// Layout: Hangzhou Bay sea fills the south (y > ~620). Port piers jutting south.
// G15 Shen-Hai expressway runs roughly NS (right side). 乍嘉苏 expressway diagonal.
// Inland canals (东湖、独山港河) cross the city. Pinghu 老城 in the upper-middle.
const ROADS_MAJOR = [
// G15 沈海高速 (NS, right)
"M 1020 40 L 1020 200 L 1010 380 L 1000 540 L 1000 620",
// 乍嘉苏高速 (NWSE diagonal)
"M 60 120 L 240 220 L 420 320 L 600 400 L 760 480 L 880 560",
// 海盐塘公路 (EW arterial through old town)
"M 60 280 L 280 280 L 520 300 L 780 290 L 1140 280",
// 乍浦大道 (EW, mid, leading to port)
"M 60 460 L 280 460 L 520 470 L 800 470 L 1140 470",
// 港区疏港路 (curves down to port)
"M 600 470 L 600 560 L 580 620",
"M 800 470 L 820 560 L 840 620",
// 外环 — connector
"M 240 220 L 240 460 L 260 600",
"M 880 200 L 880 400 L 880 560",
];
const ROADS_MINOR = [
// city grid (north of bay)
"M 80 160 L 1140 160","M 80 220 L 1140 220","M 80 360 L 1140 360","M 80 410 L 1140 410","M 80 540 L 980 540",
"M 160 60 L 160 600","M 320 60 L 320 600","M 400 60 L 400 600","M 520 60 L 520 600","M 680 60 L 680 600","M 760 60 L 760 600","M 880 60 L 880 600","M 940 60 L 940 600",
// port grid
"M 540 540 L 540 620","M 660 540 L 660 620","M 720 540 L 720 620","M 780 540 L 780 620",
];
// 杭州湾 + 内河水系
const RIVERS = [
// 东湖塘 (EW canal in city)
"M 0 350 Q 200 340 380 360 T 760 350 Q 920 340 1240 360",
// 独山港河 / pier inlet
"M 460 470 L 470 540 L 480 600",
];
// 杭州湾 — fills bottom of map
const SEA_PATH = "M -20 620 L 1260 620 L 1260 820 L -20 820 Z";
// 港池 — port basins (water inlets cut into land)
const PORT_BASINS = [
"M 360 620 L 380 540 L 440 540 L 460 620 Z",
"M 600 620 L 620 580 L 700 580 L 720 620 Z",
"M 820 620 L 840 580 L 920 580 L 940 620 Z",
];
// 防波堤 / 码头 — piers extending into the sea
const PIERS = [
"M 480 620 L 480 700 L 540 700 L 540 620",
"M 740 620 L 740 720 L 800 720 L 800 620",
"M 960 620 L 960 680 L 1020 680 L 1020 620",
];
const PARKS = [
// 九龙山 / 南湾绿地
{ x: 220, y: 80, w: 90, h: 70, label: "九龙山" },
// 东湖公园
{ x: 440, y: 220, w: 80, h: 50, label: "东湖" },
// 临港绿带
{ x: 80, y: 500, w: 140, h: 90, label: "南湾绿带" },
];
const POIS = [
{ x: 320, y: 180, label: "总站·乍浦城区" },
{ x: 600, y: 240, label: "氢能补能站·东湖" },
{ x: 880, y: 320, label: "维保中心·G15" },
{ x: 700, y: 540, label: "调度中心·港区" },
{ x: 960, y: 540, label: "重卡停车场" },
{ x: 240, y: 540, label: "化工园补能站" },
];
// 12 vehicles around the city
// src: T = TBOX 3296/2016国标, J = JT808/1078, B = both (TBOX + JT)
// VEHICLES dataset comes from data/fleet.js (window.VEHICLES) — asset-management model.
// Source badge — small T/JT chip
const SourceBadge = ({ src, size = "sm" }) => {
const items = src === "B" ? ["T","JT"] : src === "T" ? ["T"] : ["JT"];
const colors = { T: "var(--info)", JT: "var(--accent)" };
return (
<span style={{display:"inline-flex", gap:2}}>
{items.map(k => (
<span key={k} style={{
fontFamily:"var(--font-mono)", fontSize: size === "sm" ? 9 : 10,
padding: size === "sm" ? "1px 4px" : "2px 5px",
borderRadius:3, lineHeight:1.2,
color: colors[k], background: `oklch(from ${colors[k]} l c h / 0.15)`,
border: `1px solid ${colors[k]}`, opacity:0.85, letterSpacing:"0.04em", fontWeight:600,
}}>{k}</span>
))}
</span>
);
};
window.SourceBadge = SourceBadge;
// recent path traces (last 30 min ghost trails) for animation feel
const TRACES = [
{ id: "浙F07179F", d: "M 200 260 L 240 250 L 280 260", color: "ok" },
{ id: "浙F02002F", d: "M 540 260 L 580 256 L 620 260", color: "ok" },
{ id: "浙F08638F", d: "M 540 460 L 560 500 L 580 540", color: "danger" },
];
const StatusColor = {
ok: "var(--ok)",
warn: "var(--warn)",
danger: "var(--danger)",
idle: "var(--fg-3)",
};
const VehiclePin = ({ v, selected, onClick, showHeading = true, animate = true }) => {
const color = StatusColor[v.status];
return (
<g transform={`translate(${v.x} ${v.y})`} style={{cursor:"pointer"}} onClick={() => onClick && onClick(v)}>
{/* heading cone */}
{showHeading && v.status !== "idle" && (
<g transform={`rotate(${v.h})`} opacity={0.5}>
<path d="M -10 -2 L 0 -22 L 10 -2 Z" fill={color} opacity="0.35"/>
</g>
)}
{/* pulse ring (only for moving) */}
{animate && v.status === "ok" && v.speed > 0 && (
<circle r="14" fill="none" stroke={color} strokeWidth="1" opacity="0.6">
<animate attributeName="r" from="6" to="20" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.7" to="0" dur="2s" repeatCount="indefinite"/>
</circle>
)}
{/* outer halo */}
<circle r="9" fill={color} opacity="0.18"/>
{/* core */}
<circle r="5" fill={color} stroke="var(--map-bg)" strokeWidth="1.5"/>
{selected && <circle r="11" fill="none" stroke={color} strokeWidth="1.5"/>}
</g>
);
};
const PoiMarker = ({ poi }) => (
<g transform={`translate(${poi.x} ${poi.y})`}>
<rect x="-3" y="-3" width="6" height="6" fill="var(--accent)" opacity="0.8" transform="rotate(45)"/>
<text x="8" y="3" fill="var(--fg-2)" fontSize="9" fontFamily="var(--font-mono)" letterSpacing="0.05em">{poi.label}</text>
</g>
);
const Compass = () => (
<g transform="translate(60 60)">
<circle r="22" fill="var(--bg-1)" opacity="0.85" stroke="var(--border-2)"/>
<path d="M 0 -14 L 4 0 L 0 14 L -4 0 Z" fill="var(--accent)" opacity="0.5"/>
<path d="M 0 -14 L 4 0 L 0 4 L -4 0 Z" fill="var(--accent)"/>
<text textAnchor="middle" y="-26" fontSize="9" fill="var(--fg-2)" fontFamily="var(--font-mono)">N</text>
</g>
);
const ScaleBar = ({ x = 60, y = 720 }) => (
<g transform={`translate(${x} ${y})`}>
<line x1="0" y1="0" x2="100" y2="0" stroke="var(--fg-1)" strokeWidth="1.5"/>
<line x1="0" y1="-3" x2="0" y2="3" stroke="var(--fg-1)" strokeWidth="1.5"/>
<line x1="50" y1="-3" x2="50" y2="3" stroke="var(--fg-1)" strokeWidth="1.5"/>
<line x1="100" y1="-3" x2="100" y2="3" stroke="var(--fg-1)" strokeWidth="1.5"/>
<text x="100" y="-6" fontSize="9" fill="var(--fg-2)" fontFamily="var(--font-mono)">500m</text>
</g>
);
// the main rendered map
const FleetMap = ({
selectedId,
onSelect,
vehicles,
showLabels = false,
showHeatmap = false,
showPaths = true,
highlightPath = null, // for playback view: a polyline string
playbackProgress = 0,
playbackPoint = null,
variant = "default", // "default" | "minimal" | "satellite"
}) => {
const isMin = variant === "minimal";
// Default: pull from global fleet, only those with map coords
const _vehicles = vehicles || (window.VEHICLES || []).filter(v => v.x != null && v.y != null);
return (
<svg viewBox="0 0 1240 800" width="100%" height="100%" style={{display:"block", background: MAP_BG}}>
<defs>
<linearGradient id="mapVignette" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--map-vignette)" stopOpacity="0"/>
<stop offset="1" stopColor="var(--map-vignette)" stopOpacity="var(--map-vignette-strength)"/>
</linearGradient>
<radialGradient id="heat" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stopColor="var(--accent)" stopOpacity="0.5"/>
<stop offset="1" stopColor="var(--accent)" stopOpacity="0"/>
</radialGradient>
<pattern id="mapGrid" x="0" y="0" width="80" height="80" patternUnits="userSpaceOnUse">
<path d="M 80 0 L 0 0 0 80" fill="none" stroke={MAP_GRID} strokeWidth="0.5"/>
</pattern>
</defs>
{/* base */}
<rect width="1240" height="800" fill={MAP_BG}/>
<rect width="1240" height="800" fill="url(#mapGrid)"/>
{/* 杭州湾 — sea */}
{!isMin && (
<path d={SEA_PATH} fill={MAP_RIVER} opacity="0.55"/>
)}
{/* 港池 — port water basins cut into land */}
{!isMin && PORT_BASINS.map((d, i) => (
<path key={"pb"+i} d={d} fill={MAP_RIVER} opacity="0.55"/>
))}
{/* coastline marker */}
{!isMin && (
<line x1="0" y1="620" x2="1240" y2="620"
stroke="var(--map-park-stroke)" strokeWidth="0.6" strokeDasharray="3 3" opacity="0.6"/>
)}
{/* sea label */}
{!isMin && (
<text x="1140" y="700" fontSize="14" fill="var(--fg-3)" textAnchor="end"
fontFamily="var(--font-sans)" letterSpacing="2" opacity="0.55">杭州湾 · 乍浦港</text>
)}
{/* parks */}
{!isMin && PARKS.map((p, i) => (
<g key={i}>
<rect x={p.x} y={p.y} width={p.w} height={p.h}
fill={MAP_PARK} stroke={MAP_PARK_STROKE} strokeWidth="0.5" rx="3"/>
{p.label && (
<text x={p.x + p.w/2} y={p.y + p.h/2 + 3} fontSize="9"
fill="var(--fg-3)" textAnchor="middle" opacity="0.7">{p.label}</text>
)}
</g>
))}
{/* river */}
{!isMin && RIVERS.map((d, i) => (
<path key={i} d={d} stroke={MAP_RIVER} strokeWidth="10" fill="none" strokeLinecap="round"/>
))}
{/* piers */}
{!isMin && PIERS.map((d, i) => (
<path key={"pier"+i} d={d} fill="none" stroke="var(--map-road-major-outer)"
strokeWidth="8" strokeLinecap="butt"/>
))}
{/* heatmap layer */}
{showHeatmap && _vehicles.map((v, i) => (
<ellipse key={i} cx={v.x} cy={v.y} rx="50" ry="50" fill="url(#heat)"/>
))}
{/* minor roads */}
{ROADS_MINOR.map((d, i) => (
<path key={i} d={d} fill="none" stroke={MAP_ROAD_MINOR} strokeWidth="1"/>
))}
{/* major roads casing + center stripe */}
{ROADS_MAJOR.map((d, i) => (
<g key={i}>
<path d={d} fill="none" stroke={MAP_ROAD_MAJOR_OUTER} strokeWidth="6" strokeLinecap="round"/>
<path d={d} fill="none" stroke={MAP_ROAD_MAJOR_INNER} strokeWidth="3" strokeLinecap="round"/>
</g>
))}
{/* vignette */}
<rect width="1240" height="800" fill="url(#mapVignette)" pointerEvents="none"/>
{/* highlighted path */}
{highlightPath && (
<g>
<path d={highlightPath} fill="none" stroke="var(--accent)" strokeWidth="3" strokeLinecap="round" opacity="0.35"/>
<path d={highlightPath} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round"
strokeDasharray="6 4" style={{filter: "drop-shadow(0 0 6px var(--accent-glow))"}}/>
</g>
)}
{/* recent traces */}
{showPaths && !highlightPath && TRACES.map((t, i) => (
<path key={i} d={t.d} fill="none" stroke={StatusColor[t.color]} strokeWidth="2" opacity="0.35" strokeLinecap="round"/>
))}
{/* POIs */}
{!isMin && POIS.map((p, i) => <PoiMarker key={i} poi={p}/>)}
{/* vehicles */}
{_vehicles.map(v => (
<VehiclePin key={v.id} v={v}
selected={v.id === selectedId}
onClick={onSelect}
showHeading
animate={!highlightPath}/>
))}
{/* selected vehicle label */}
{selectedId && _vehicles.filter(v => v.id === selectedId).map(v => (
<g key={v.id} transform={`translate(${v.x + 14} ${v.y - 14})`}>
<rect x="0" y="-12" width="86" height="22" rx="4" fill="var(--bg-1)" stroke={StatusColor[v.status]} strokeWidth="1"/>
<text x="6" y="3" fill="var(--fg-0)" fontSize="11" fontFamily="var(--font-mono)" fontWeight="500">{v.id}</text>
<text x="56" y="3" fill={StatusColor[v.status]} fontSize="10" fontFamily="var(--font-mono)">{v.speed}km/h</text>
</g>
))}
{/* playback marker */}
{playbackPoint && (
<g transform={`translate(${playbackPoint.x} ${playbackPoint.y})`}>
<circle r="14" fill="var(--accent)" opacity="0.18"/>
<circle r="7" fill="var(--accent)" stroke="var(--map-bg)" strokeWidth="2"/>
<circle r="3" fill="var(--map-bg)"/>
</g>
)}
{/* HUD overlays */}
<Compass/>
<ScaleBar/>
</svg>
);
};
window.FleetMap = FleetMap;

193
data/fleet.js Normal file
View File

@@ -0,0 +1,193 @@
// data/fleet.js — 羚牛 Hydrogen Fleet · Asset-management dataset
// Switched from driver-centric to asset-centric model per business spec.
// Vehicles relate to: 业务部门 / 业务负责人 / 客户 / 所属公司 / 租赁公司
// Driver field intentionally removed.
(function(){
// ── Companies & departments ──────────────────────────────
const COMPANIES = {
own: ["浙江羚牛氢能科技有限公司", "嘉兴羚牛新能源运营公司"],
lease: ["JXLN-23 浙F氢能", "JXGW-G 浙F氢能", "LNZLHT 嘉兴港区"],
};
const DEPARTMENTS = [
{ id: "biz1", name: "业务一部", lead: "高伟", color: "#1F8B4C" },
{ id: "biz2", name: "业务二部", lead: "陈高伟", color: "#2E8C8C" },
{ id: "biz3", name: "业务三部", lead: "尚建华", color: "#7A8C2E" },
{ id: "biz4", name: "业务四部", lead: "刘念念", color: "#C97A3D" },
{ id: "ops", name: "运营部", lead: "张兰", color: "#5C6E7C" },
];
const CUSTOMERS = [
"嘉兴公司自营", "嘉兴氢能业务一部", "欧宝软件",
"汇通运营公司", "嘉兴港区二部", "嘉兴县嘉一部",
"午潮停车场", "—",
];
const PARKINGS = [
"平湖停车场", "平湖停车场异常", "嘉兴公司自营", "欧宝停车场",
"汇通停车场", "嘉兴港区停车场", "午潮码头停车场", "云洋停车场",
];
const CITIES = [
"浙江省·嘉兴市·平湖", "浙江省·嘉兴市", "浙江省·嘉兴市·港区",
"浙江省·嘉兴市·南湖", "广东省·云浮市", "浙江省·杭州市·萧山",
];
// VIN/车架号 prefix patterns from screenshot: LA9HE6.. / LA9G6.. / LJRC1..
const VIN_PREFIXES = ["LA9HE6F2N1A", "LA9G6E7L4N1B", "LJRC14A22NA0", "LA9HE6F8N1C"];
// ── Asset/operation status enums ──────────────────────────
// asset: in_stock(在库) | leasing(租赁中) | abnormal(异常)
// own: self(自有) | lease(外租)
// op: operating(运营中) | suspended(停运) | maintenance(待整备)
// gps: online(在线) | offline(离线)
// grade: A | B | C
// status (legacy): ok | warn | danger | idle (kept for map color coding)
// ── Helper: deterministic pseudo-random ───────────────────
const seed = (n) => { let x = (n*9301+49297)%233280; return () => (x = (x*9301+49297)%233280) / 233280; };
// ── Build vehicles ────────────────────────────────────────
// Start with the 12 mapped vehicles (preserve x/y for the map),
// enrich with business fields. Then add 40 more (no map coords).
const _mapped = [
// Real plate numbers from the data screenshot · 浙F prefix
// Coordinates positioned within Jiaxing Zhapu Port viewport (1240×800, sea below y=620)
{ id: "浙F03980F", x: 320, y: 180, h: 320, status: "ok", speed: 56, soc: 78, src: "B" },
{ id: "浙F03311F", x: 460, y: 280, h: 60, status: "warn", speed: 0, soc: 24, src: "T" },
{ id: "浙F03000F", x: 600, y: 240, h: 110, status: "ok", speed: 78, soc: 31, src: "B" },
{ id: "浙FK800F", x: 760, y: 290, h: 200, status: "ok", speed: 64, soc: 82, src: "T" },
{ id: "浙FK808F", x: 880, y: 410, h: 280, status: "ok", speed: 51, soc: 47, src: "B" },
{ id: "浙F07918F", x: 240, y: 540, h: 30, status: "idle", speed: 0, soc: 96, src: "T" },
{ id: "浙F01505F", x: 600, y: 470, h: 160, status: "ok", speed: 44, soc: 55, src: "B" },
{ id: "浙F30778F", x: 540, y: 380, h: 240, status: "ok", speed: 38, soc: 71, src: "T" },
{ id: "浙F39086F", x: 1000, y: 360, h: 90, status: "danger", speed: 0, soc: 9, src: "B" },
{ id: "浙F02618F", x: 700, y: 540, h: 350, status: "ok", speed: 42, soc: 64, src: "T" },
{ id: "浙F09860F", x: 960, y: 540, h: 70, status: "warn", speed: 0, soc: 18, src: "B" },
{ id: "浙F02399F", x: 820, y: 470, h: 190, status: "ok", speed: 48, soc: 88, src: "J" },
];
// Additional 40 vehicles — list-only, no map coords
const _extra = [
"浙F08991F","浙F05969F","浙F07179F","浙F08278F","浙F02002F","浙F01689F",
"浙F00598F","浙F02608F","浙F08638F","浙F00278F","浙F02289F","浙F06196F",
"浙F00885F","浙F08889F","浙F03127F","浙F04421F","浙F05538F","浙F06693F",
"浙F07412F","浙F08810F","浙F09125F","浙F10232F","浙F11456F","浙F12674F",
"浙F13891F","浙F14037F","浙F15268F","浙F16495F","浙F17712F","浙F18934F",
"浙F19156F","浙F20389F","浙F21516F","浙F22748F","浙F23973F","浙F24196F",
"浙F25425F","浙F26658F","浙F27873F","浙F28095F",
].map(id => ({ id, x: null, y: null, h: 0, status: "ok", speed: 0, soc: 0, src: "T" }));
// ── Enrichment ────────────────────────────────────────────
const _enrich = (v, i) => {
const r = seed(i + 1);
const isLeased = r() < 0.55; // 55% 外租
const dept = DEPARTMENTS[Math.floor(r() * 5)];
const company = isLeased ? COMPANIES.lease[Math.floor(r() * 3)] : COMPANIES.own[Math.floor(r() * 2)];
const ownCompany = COMPANIES.own[Math.floor(r() * 2)];
// Asset status correlates with vehicle status
let asset, op, gps;
if (v.status === "danger") { asset = "abnormal"; op = "suspended"; gps = "offline"; }
else if (v.status === "idle") { asset = "in_stock"; op = "maintenance"; gps = r() < 0.4 ? "online" : "offline"; }
else if (isLeased) { asset = "leasing"; op = "operating"; gps = v.status === "warn" ? "offline" : "online"; }
else { asset = "in_stock"; op = r() < 0.4 ? "maintenance" : "operating"; gps = "online"; }
// Status duration in days
const statusDays = Math.floor(r() * 120) + 1;
// Mileage
const totalKm = Math.floor(r() * 80000) + 12000;
const lastMaintKm = totalKm - Math.floor(r() * 8000) - 1000;
const nextMaintKm = lastMaintKm + 10000;
const kmToMaint = nextMaintKm - totalKm;
// Last maintenance date (days ago)
const lastMaintDays = Math.floor(r() * 90) + 1;
// Grade
const grade = ["A","B","B","C"][Math.floor(r()*4)];
// VIN
const vin = VIN_PREFIXES[Math.floor(r() * VIN_PREFIXES.length)] + String(1000 + Math.floor(r()*9000));
const fleetCode = (i < 12 && r() < 0.3) ? (Math.floor(r()*99)+10) + "Q" : null;
// Operating city
const city = CITIES[Math.floor(r() * CITIES.length)];
// Customer (only if leased)
const customer = asset === "leasing" ? CUSTOMERS[Math.floor(r() * 6)] : (asset === "in_stock" ? "—" : CUSTOMERS[Math.floor(r() * 6)]);
// Parking
const parking = PARKINGS[Math.floor(r() * PARKINGS.length)];
// Contract
const hasContract = asset === "leasing" || (asset === "abnormal" && r() < 0.5);
const contractNo = hasContract ? "JX-" + dept.id.toUpperCase() + "-2024-" + String(2000 + i) : null;
const handoverKm = hasContract ? Math.floor(r() * 3000) + 200 : null;
const returnKm = (asset === "in_stock" && hasContract) ? handoverKm + Math.floor(r()*40000) + 5000 : null;
// Hydrogen pressure (MPa) and motor temp
const h2 = v.status === "danger" ? 0.8 : (v.soc / 100 * 5.6 + 0.2).toFixed(1);
const motorTemp = v.status === "danger" ? 102 : 58 + Math.floor(r()*15);
return {
...v,
plate: v.id,
vin, fleetCode,
city, parking,
asset, own: isLeased ? "lease" : "self", op, gps, grade, statusDays,
dept: dept.id, deptName: dept.name, deptLead: dept.lead, deptColor: dept.color,
customer,
company, ownCompany,
contractNo, handoverKm, returnKm,
totalKm, lastMaintKm, nextMaintKm, kmToMaint, lastMaintDays,
h2, motorTemp,
// Hydrogen-specific
h2Pressure: parseFloat(h2),
range: Math.round(v.soc * 6.2),
};
};
const VEHICLES = [..._mapped, ..._extra].map(_enrich);
// ── Aggregations for filters ──────────────────────────────
const COUNTS = {
all: VEHICLES.length,
// Asset status
inStock: VEHICLES.filter(v => v.asset === "in_stock").length,
leasing: VEHICLES.filter(v => v.asset === "leasing").length,
abnormal: VEHICLES.filter(v => v.asset === "abnormal").length,
// Ownership
self: VEHICLES.filter(v => v.own === "self").length,
lease: VEHICLES.filter(v => v.own === "lease").length,
// Operation
operating: VEHICLES.filter(v => v.op === "operating").length,
suspended: VEHICLES.filter(v => v.op === "suspended").length,
maintenance: VEHICLES.filter(v => v.op === "maintenance").length,
// GPS
online: VEHICLES.filter(v => v.gps === "online").length,
offline: VEHICLES.filter(v => v.gps === "offline").length,
// Departments
byDept: DEPARTMENTS.reduce((acc, d) => {
acc[d.id] = VEHICLES.filter(v => v.dept === d.id).length;
return acc;
}, {}),
};
// ── User roles for permission demo ────────────────────────
const ROLES = [
{ id: "admin", name: "总管理员", scope: "all", desc: "可见所有车辆 · 所有部门" },
{ id: "biz1_lead",name: "业务一部·负责人", scope: "dept", deptId: "biz1", desc: "仅可见业务一部车辆" },
{ id: "biz2_lead",name: "业务二部·负责人", scope: "dept", deptId: "biz2", desc: "仅可见业务二部车辆" },
{ id: "ops", name: "运营岗", scope: "ops", desc: "可见所有车辆 · 仅运维操作" },
{ id: "finance", name: "财务岗", scope: "finance", desc: "可见合同/资产 · 隐藏实时车况" },
];
// expose
Object.assign(window, {
VEHICLES, DEPARTMENTS, COMPANIES, CUSTOMERS, PARKINGS, CITIES, COUNTS, ROLES,
});
})();

622
design-canvas.jsx Normal file
View File

@@ -0,0 +1,622 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
// State persists to a .design-canvas.state.json sidecar via the host
// bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
'.dc-card{transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
'[data-dc-slot]:hover .dc-expand{opacity:1}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, focused
// artboard). Order/titles/labels persist to a .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
// host's window.omelette bridge — editing requires the omelette runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
function DesignCanvas({ children, minScale, maxScale, style }) {
const [state, setState] = React.useState({ sections: {}, focus: null });
// Hold rendering until the sidecar read settles so the saved order/titles
// appear on first paint (no source-order flash). didRead gates writes until
// the read settles so the empty initial state can't clobber a slow read;
// skipNextWrite suppresses the one echo-write that would otherwise follow
// hydration.
const [ready, setReady] = React.useState(false);
const didRead = React.useRef(false);
const skipNextWrite = React.useRef(false);
React.useEffect(() => {
let off = false;
fetch('./' + DC_STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((saved) => {
if (off || !saved || !saved.sections) return;
skipNextWrite.current = true;
setState((s) => ({ ...s, sections: saved.sections }));
})
.catch(() => {})
.finally(() => { didRead.current = true; if (!off) setReady(true); });
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
return () => { off = true; clearTimeout(t); };
}, []);
React.useEffect(() => {
if (!didRead.current) return;
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
const t = setTimeout(() => {
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
}, 250);
return () => clearTimeout(t);
}, [state.sections]);
// Build registries synchronously from children so FocusOverlay can read
// them in the same render. Only direct DCSection > DCArtboard children are
// walked — wrapping them in other elements opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
React.Children.forEach(children, (sec) => {
if (!sec || sec.type !== DCSection) return;
const sid = sec.props.id ?? sec.props.title;
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const srcIds = [];
React.Children.forEach(sec.props.children, (ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (!aid) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
sectionMeta[sid] = {
title: persisted.title ?? sec.props.title,
subtitle: sec.props.subtitle,
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
};
});
const api = React.useMemo(() => ({
state,
section: (id) => state.sections[id] || {},
patchSection: (id, p) => setState((s) => ({
...s,
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
})),
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
}), [state]);
// Esc exits focus; any outside pointerdown commits an in-progress rename.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
const onPd = (e) => {
const ae = document.activeElement;
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPd, true);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPd, true);
};
}, [api]);
return (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
}, []);
React.useEffect(() => {
const vp = vpRef.current;
if (!vp) return;
const zoomAt = (cx, cy, factor) => {
const r = vp.getBoundingClientRect();
const px = cx - r.left, py = cy - r.top;
const t = tf.current;
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
const k = next / t.scale;
// keep the world point under the cursor fixed
t.x = px - (px - t.x) * k;
t.y = py - (py - t.y) * k;
t.scale = next;
apply();
};
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if (e.ctrlKey) {
// trackpad pinch (or explicit ctrl+wheel)
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
// Drag-pan: middle button anywhere, or primary button on canvas
// background (anything that isn't an artboard or an inline editor).
let drag = null;
const onPointerDown = (e) => {
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
e.preventDefault();
vp.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
vp.style.cursor = 'grabbing';
};
const onPointerMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
tf.current.x += e.clientX - drag.lx;
tf.current.y += e.clientY - drag.ly;
drag.lx = e.clientX; drag.ly = e.clientY;
apply();
};
const onPointerUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
vp.releasePointerCapture(e.pointerId);
drag = null;
vp.style.cursor = '';
};
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
vp.addEventListener('pointerdown', onPointerDown);
vp.addEventListener('pointermove', onPointerMove);
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', onPointerDown);
vp.removeEventListener('pointermove', onPointerMove);
vp.removeEventListener('pointerup', onPointerUp);
vp.removeEventListener('pointercancel', onPointerUp);
};
}, [apply, minScale, maxScale]);
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
return (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(children);
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
const sec = (ctx && sid && ctx.section(sid)) || {};
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
}, [sec.order, srcOrder.join('|')]);
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
return (
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
<div style={{ padding: '0 60px 56px' }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
// changes on drop.
const onGripDown = (e) => {
e.preventDefault(); e.stopPropagation();
const me = ref.current;
// translateX is applied in local (pre-scale) space but pointer deltas and
// getBoundingClientRect().left are screen-space — divide by the viewport's
// current scale so the dragged card tracks the cursor at any zoom level.
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
const slotXs = homes.map((h) => h.x);
const startIdx = order.indexOf(id);
const startX = e.clientX;
let liveOrder = order.slice();
me.classList.add('dc-dragging');
const layout = () => {
for (const h of homes) {
if (h.id === id) continue;
const slot = liveOrder.indexOf(h.id);
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
}
};
const move = (ev) => {
const dx = ev.clientX - startX;
me.style.transform = `translateX(${dx / scale}px)`;
const cur = homes[startIdx].x + dx;
let nearest = 0, best = Infinity;
for (let i = 0; i < slotXs.length; i++) {
const d = Math.abs(slotXs[i] - cur);
if (d < best) { best = d; nearest = i; }
}
if (liveOrder.indexOf(id) !== nearest) {
liveOrder = order.filter((k) => k !== id);
liveOrder.splice(nearest, 0, id);
layout();
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const finalSlot = liveOrder.indexOf(id);
me.classList.remove('dc-dragging');
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
// After the settle transition, kill transitions + clear transforms +
// commit the reorder in the same frame so there's no visual snap-back.
setTimeout(() => {
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const h of homes) h.el.style.transition = '';
}));
}, 180);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
};
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
<div className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) ctx.setFocus(`${ns}/${first}`);
};
React.useEffect(() => {
const k = (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
});
const { width = 260, height = 480, children } = artboard.props;
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
const [ddOpen, setDd] = React.useState(false);
const Arrow = ({ dir, onClick }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
version: '3.8'
services:
ln-vdc:
image: harbor.lnh2e.com/lingniu-v1/ln-vdc:main-1.0.0
ports:
- "8112:80"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
deploy:
replicas: 1
restart_policy:
condition: on-failure
placement:
constraints: [node.role == manager]
labels:
- portainer.hide=false
- project=lingniu

1
index.html Symbolic link
View File

@@ -0,0 +1 @@
羚牛车辆数据中心.html

53
nginx.conf Normal file
View File

@@ -0,0 +1,53 @@
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
charset utf-8;
# index.html 软链由 Dockerfile 创建,指向 羚牛车辆数据中心.html
index index.html 羚牛车辆数据中心.html;
# gzip
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_vary on;
gzip_types
text/plain text/css text/javascript
application/javascript application/json application/xml
image/svg+xml;
# SPA hash 路由 — 任意未命中文件都回到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存 7 天
location ~* \.(svg|png|jpg|jpeg|gif|webp|woff2?|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
}
# JSX/JS/CSS 短缓存(方便热更新)
location ~* \.(jsx|js|css)$ {
expires 1h;
add_header Cache-Control "public, max-age=3600";
}
# HTML 不缓存
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# 健康检查
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# 隐藏 nginx 版本
server_tokens off;
}

BIN
screenshots/alarm-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
screenshots/esg-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
screenshots/esg-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
screenshots/light-map.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
screenshots/zhapu-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
screenshots/zhapu-dark2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
screenshots/zhapu-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

468
styles/design-system.css Normal file
View File

@@ -0,0 +1,468 @@
/* 羚牛车辆数据中心 — Design System
Modern data cockpit · Hydrogen passenger vehicle fleet
*/
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
/* ── Surfaces (羚牛 ink — warm-cool deep neutral, anchored on logo #2F2828) ── */
--bg-0: oklch(0.16 0.012 165); /* canvas — slight green undertone */
--bg-1: oklch(0.20 0.014 165); /* panel */
--bg-2: oklch(0.24 0.016 165); /* elevated card */
--bg-3: oklch(0.28 0.018 165); /* hover */
--bg-popover: oklch(0.22 0.014 165);
/* ── Text ── */
--fg-0: oklch(0.97 0.005 165);
--fg-1: oklch(0.84 0.010 165);
--fg-2: oklch(0.66 0.015 165);
--fg-3: oklch(0.50 0.018 165);
/* ── Borders ── */
--border-1: oklch(0.32 0.018 165 / 0.55);
--border-2: oklch(0.42 0.022 165 / 0.45);
--border-3: oklch(0.55 0.028 165 / 0.35);
/* ── Accents (羚牛绿系 — derived from logo #007143) ── */
--accent: oklch(0.74 0.170 155); /* bright forest-green for dark UI */
--accent-soft: oklch(0.74 0.170 155 / 0.16);
--accent-glow: oklch(0.74 0.170 155 / 0.32);
--info: oklch(0.72 0.110 200); /* slate-teal */
--info-soft: oklch(0.72 0.110 200 / 0.16);
/* ── Status ── */
--ok: oklch(0.78 0.160 150);
--ok-soft: oklch(0.78 0.160 150 / 0.18);
--warn: oklch(0.80 0.160 85);
--warn-soft: oklch(0.80 0.160 85 / 0.18);
--danger: oklch(0.68 0.220 25);
--danger-soft: oklch(0.68 0.220 25 / 0.18);
/* ── Type ── */
--font-sans: "IBM Plex Sans", -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, Menlo, monospace;
/* ── Radii ── */
--r-1: 4px;
--r-2: 6px;
--r-3: 10px;
--r-4: 14px;
/* ── Shadows ── */
--shadow-1: 0 1px 0 0 oklch(1 0 0 / 0.04) inset, 0 1px 2px 0 oklch(0 0 0 / 0.4);
--shadow-2: 0 1px 0 0 oklch(1 0 0 / 0.05) inset, 0 8px 24px -8px oklch(0 0 0 / 0.6);
/* ── Map (dark) ── */
--map-bg: oklch(0.14 0.014 165);
--map-grid: oklch(0.30 0.012 165 / 0.18);
--map-park: oklch(0.32 0.06 150 / 0.18);
--map-park-stroke: oklch(0.5 0.08 150 / 0.28);
--map-river: oklch(0.40 0.07 220 / 0.32);
--map-road-minor: oklch(0.28 0.014 165 / 0.6);
--map-road-major-outer: oklch(0.30 0.014 165 / 0.8);
--map-road-major-inner: oklch(0.45 0.014 165 / 0.5);
--map-vignette: oklch(0.10 0.012 165);
--map-vignette-strength: 0.6;
}
/* ── Light Theme — 羚牛 logo palette ──────────────────────
Off-white ground · ink #2F2828 · saturated forest green #007143
*/
:root[data-theme="light"] {
--bg-0: #FAFAF7; /* warm paper */
--bg-1: #FFFFFF;
--bg-2: #F2F1ED; /* warm tint */
--bg-3: #E8E7E2;
--bg-popover: #FFFFFF;
--fg-0: #2F2828; /* logo ink */
--fg-1: #3D3636;
--fg-2: #6B6363;
--fg-3: #9A938F;
--border-1: #E8E5DF;
--border-2: #D6D2CB;
--border-3: #BFBAB1;
/* logo green ramp */
--accent: #007143; /* logo primary green */
--accent-soft: #E0F0E5; /* tint */
--accent-glow: rgba(0,113,67,.18);
--info: #2F2828; /* ink doubles as informational */
--info-soft: #ECE9E5;
--ok: #007143;
--ok-soft: #DDEEE2;
--warn: #B57A0E; /* deeper amber against warm ground */
--warn-soft: #F5E5C3;
--danger: #B33028; /* deeper brick */
--danger-soft: #F2D5D0;
--shadow-1: 0 1px 0 0 rgba(255,255,255,.6) inset, 0 1px 2px 0 rgba(47,40,40,.04);
--shadow-2: 0 1px 0 0 rgba(255,255,255,.6) inset, 0 6px 18px -6px rgba(47,40,40,.10);
/* ── Map (light · 羚牛) ── */
--map-bg: #F4F2EC; /* warm paper map */
--map-grid: rgba(47,40,40,0.05);
--map-park: rgba(0,113,67,0.13);
--map-park-stroke: rgba(0,113,67,0.30);
--map-river: rgba(122,150,170,0.40);
--map-road-minor: rgba(155,148,138,0.55);
--map-road-major-outer: #C9C3B8;
--map-road-major-inner: #FFFFFF;
--map-vignette: #FAFAF7;
--map-vignette-strength: 0;
}
/* light-theme tweaks for surface bits */
:root[data-theme="light"] .app { color: var(--fg-1); background: var(--bg-0); }
:root[data-theme="light"] .app::before {
background: radial-gradient(ellipse at 50% 0%, rgba(0,113,67,.04), transparent 55%);
}
:root[data-theme="light"] .sidebar {
background: #FFFFFF;
border-right-color: var(--border-1);
}
:root[data-theme="light"] .sidebar .logo {
background: #007143;
color: #fff;
box-shadow: 0 4px 14px rgba(0,113,67,.24);
}
:root[data-theme="light"] .topbar {
background: #FFFFFF;
border-bottom-color: var(--border-1);
}
:root[data-theme="light"] .panel {
background: #FFFFFF;
border-color: var(--border-1);
box-shadow: 0 1px 2px rgba(47,40,40,.03);
}
:root[data-theme="light"] .panel-head { background: #FAFAF7; }
:root[data-theme="light"] .chip {
background: #F2F1ED;
color: var(--fg-2);
border-color: var(--border-1);
}
:root[data-theme="light"] .chip.ok { color: #007143; background: #DDEEE2; border-color: #A8D4B5; }
:root[data-theme="light"] .chip.warn { color: #8C5E0A; background: #F5E5C3; border-color: #E5C98A; }
:root[data-theme="light"] .chip.danger { color: #8E2620; background: #F2D5D0; border-color: #DCA8A2; }
:root[data-theme="light"] .chip.accent { color: #007143; background: #DDEEE2; border-color: #A8D4B5; }
:root[data-theme="light"] .chip.info { color: #2F2828; background: #ECE9E5; border-color: #D6D2CB; }
:root[data-theme="light"] .btn { background: #FFFFFF; color: var(--fg-1); border-color: var(--border-1); }
:root[data-theme="light"] .btn:hover { background: #F2F1ED; border-color: var(--border-2); }
:root[data-theme="light"] .btn.primary { background: #007143; color: #fff; border-color: #007143; }
:root[data-theme="light"] .btn.primary:hover { background: #005A35; border-color: #005A35; }
:root[data-theme="light"] .btn.danger { background: var(--danger-soft); color: var(--danger); border-color: #DCA8A2; }
:root[data-theme="light"] .tbl thead th { background: #FAFAF7; color: var(--fg-3); border-bottom-color: var(--border-1); }
:root[data-theme="light"] .tbl tbody td { border-bottom-color: var(--border-1); color: var(--fg-1); }
:root[data-theme="light"] .tbl tbody tr:hover td { background: #FAFAF7; }
:root[data-theme="light"] .tbl tbody tr.sel td { background: #DDEEE2; }
:root[data-theme="light"] .tbl tbody tr.sel td:first-child { box-shadow: inset 2px 0 0 #007143; }
:root[data-theme="light"] .bar { background: #ECEAE3; }
:root[data-theme="light"] .scroll::-webkit-scrollbar-thumb { background: rgba(47,40,40,.16); }
:root[data-theme="light"] .scroll::-webkit-scrollbar-thumb:hover { background: rgba(47,40,40,.30); }
:root[data-theme="light"] .dot.ok { background: #007143; box-shadow: 0 0 4px rgba(0,113,67,.4); }
:root[data-theme="light"] .dot.warn { background: #B57A0E; box-shadow: 0 0 4px rgba(181,122,14,.35); }
:root[data-theme="light"] .dot.danger { background: #B33028; box-shadow: 0 0 4px rgba(179,48,40,.35); }
:root[data-theme="light"] .search { background: #F2F1ED; border-color: var(--border-1); }
:root[data-theme="light"] .search input { color: var(--fg-1); }
:root[data-theme="light"] .search kbd { background: #fff; border-color: var(--border-1); color: var(--fg-2); }
:root[data-theme="light"] .topbar .icon-btn:hover { background: #F2F1ED; }
:root[data-theme="light"] .nav-item { color: var(--fg-2); }
:root[data-theme="light"] .nav-item:hover { background: #F2F1ED; color: var(--fg-0); }
:root[data-theme="light"] .nav-item.active { color: #007143; background: #DDEEE2; }
:root[data-theme="light"] .nav-item.active::before { background: #007143; box-shadow: 0 0 6px rgba(0,113,67,.5); }
:root[data-theme="light"] .sidebar-divider { background: var(--border-1); }
:root[data-theme="light"] .topbar .crumbs .now { color: var(--fg-0); }
* { box-sizing: border-box; }
.app {
width: 100%;
height: 100%;
background: var(--bg-0);
color: var(--fg-1);
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.4;
letter-spacing: 0.005em;
overflow: hidden;
display: flex;
position: relative;
font-feature-settings: "ss02", "cv11";
}
/* faint vignette for cockpit feel */
.app::before {
content: "";
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(ellipse at 50% 100%, oklch(0.78 0.150 175 / 0.04), transparent 60%);
z-index: 0;
}
.mono { font-family: var(--font-mono); font-feature-settings: "tnum", "zero"; }
.tnum { font-variant-numeric: tabular-nums; }
/* ── Sidebar ── */
.sidebar {
width: 56px;
flex: 0 0 56px;
background: var(--bg-1);
border-right: 1px solid var(--border-1);
display: flex;
flex-direction: column;
align-items: center;
padding: 14px 0;
gap: 4px;
z-index: 1;
}
.sidebar .logo {
width: 32px; height: 32px;
display: grid; place-items: center;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--info));
color: oklch(0.18 0.020 245);
font-weight: 700; font-size: 14px;
letter-spacing: -0.04em;
margin-bottom: 12px;
box-shadow: 0 0 24px var(--accent-glow);
}
.nav-item {
width: 40px; height: 40px;
display: grid; place-items: center;
border-radius: 8px;
color: var(--fg-2);
cursor: pointer;
transition: background .15s, color .15s;
position: relative;
}
.nav-item:hover { color: var(--fg-0); background: var(--bg-2); }
.nav-item.active { color: var(--accent); background: var(--accent-soft); }
.nav-item.active::before {
content: ""; position: absolute; left: -10px; top: 8px; bottom: 8px;
width: 2px; background: var(--accent); border-radius: 2px;
box-shadow: 0 0 8px var(--accent);
}
.sidebar-divider { width: 24px; height: 1px; background: var(--border-1); margin: 8px 0; }
/* ── Topbar ── */
.topbar {
height: 48px;
flex: 0 0 48px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid var(--border-1);
background: var(--bg-1);
gap: 16px;
z-index: 2;
}
.topbar .crumbs { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.topbar .crumbs .sep { color: var(--fg-3); }
.topbar .crumbs .now { color: var(--fg-0); font-weight: 500; }
.kpi-row { display: flex; align-items: center; gap: 18px; margin-left: 8px; }
.kpi-mini { display: flex; align-items: baseline; gap: 6px; }
.kpi-mini .lbl { font-size: 11px; color: var(--fg-3); text-transform: uppercase; letter-spacing: 0.08em; }
.kpi-mini .val { font-family: var(--font-mono); font-size: 14px; font-weight: 500; color: var(--fg-0); }
.kpi-mini .delta { font-family: var(--font-mono); font-size: 11px; }
.kpi-mini .delta.up { color: var(--ok); }
.kpi-mini .delta.down { color: var(--danger); }
.search {
flex: 1; max-width: 320px;
height: 28px;
background: var(--bg-2);
border: 1px solid var(--border-1);
border-radius: 6px;
display: flex; align-items: center; padding: 0 10px; gap: 8px;
color: var(--fg-2); font-size: 12px;
}
.search input {
flex: 1; background: transparent; border: 0; outline: 0;
color: var(--fg-1); font-size: 12px; font-family: inherit;
}
.search kbd {
background: var(--bg-3); padding: 1px 5px; border-radius: 3px;
font-family: var(--font-mono); font-size: 10px; color: var(--fg-2);
border: 1px solid var(--border-2);
}
.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.topbar .icon-btn {
width: 28px; height: 28px; border-radius: 6px;
display: grid; place-items: center;
color: var(--fg-2); cursor: pointer; position: relative;
transition: color .12s, background .12s;
}
.topbar .icon-btn:hover { color: var(--fg-0); background: var(--bg-2); }
.topbar .icon-btn .dot {
position: absolute; top: 4px; right: 4px;
width: 6px; height: 6px; border-radius: 3px;
background: var(--danger);
box-shadow: 0 0 6px var(--danger);
}
.avatar {
width: 28px; height: 28px; border-radius: 14px;
background: linear-gradient(135deg, oklch(0.55 0.10 285), oklch(0.65 0.12 320));
display: grid; place-items: center;
font-size: 11px; font-weight: 600; color: var(--fg-0);
}
/* ── Cards / panels ── */
.panel {
background: var(--bg-1);
border: 1px solid var(--border-1);
border-radius: var(--r-3);
overflow: hidden;
display: flex; flex-direction: column;
}
.panel-head {
height: 36px;
flex: 0 0 36px;
border-bottom: 1px solid var(--border-1);
padding: 0 12px;
display: flex; align-items: center; gap: 8px;
}
.panel-head .title {
font-size: 12px; font-weight: 600; color: var(--fg-0);
letter-spacing: 0.02em;
}
.panel-head .sub { font-size: 11px; color: var(--fg-3); }
.panel-head .actions { margin-left: auto; display: flex; gap: 4px; }
.chip {
display: inline-flex; align-items: center; gap: 4px;
height: 18px; padding: 0 6px;
font-size: 10px;
border-radius: 4px;
background: var(--bg-2);
color: var(--fg-2);
border: 1px solid var(--border-1);
font-family: var(--font-mono);
letter-spacing: 0.02em;
}
.chip.ok { color: var(--ok); background: var(--ok-soft); border-color: oklch(0.78 0.150 155 / 0.3); }
.chip.warn { color: var(--warn); background: var(--warn-soft); border-color: oklch(0.80 0.160 85 / 0.3); }
.chip.danger { color: var(--danger); background: var(--danger-soft); border-color: oklch(0.68 0.220 25 / 0.4); }
.chip.accent { color: var(--accent); background: var(--accent-soft); border-color: oklch(0.78 0.150 175 / 0.3); }
.chip.info { color: var(--info); background: var(--info-soft); border-color: oklch(0.74 0.140 235 / 0.3); }
/* ── Buttons ── */
.btn {
height: 28px; padding: 0 12px;
background: var(--bg-2);
border: 1px solid var(--border-1);
color: var(--fg-1);
font-family: inherit; font-size: 12px; font-weight: 500;
border-radius: 6px;
cursor: pointer;
display: inline-flex; align-items: center; gap: 6px;
transition: background .12s, border-color .12s;
}
.btn:hover { background: var(--bg-3); border-color: var(--border-2); }
.btn.primary {
background: var(--accent); border-color: var(--accent); color: oklch(0.18 0.02 245);
font-weight: 600;
}
.btn.primary:hover { filter: brightness(1.08); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--fg-2); }
.btn.ghost:hover { background: var(--bg-2); color: var(--fg-0); }
.btn.danger { background: var(--danger-soft); border-color: oklch(0.68 0.220 25 / 0.4); color: var(--danger); }
.btn.sm { height: 24px; padding: 0 8px; font-size: 11px; }
.btn.icon { width: 28px; padding: 0; justify-content: center; }
/* ── Tables ── */
.tbl {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
}
.tbl thead th {
position: sticky; top: 0; z-index: 1;
background: var(--bg-1);
text-align: left;
font-weight: 500;
color: var(--fg-3);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 8px 10px;
border-bottom: 1px solid var(--border-1);
}
.tbl tbody td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-1);
color: var(--fg-1);
vertical-align: middle;
}
.tbl tbody tr { cursor: pointer; }
.tbl tbody tr:hover td { background: var(--bg-2); }
.tbl tbody tr.sel td { background: var(--accent-soft); }
.tbl tbody tr.sel td:first-child { box-shadow: inset 2px 0 0 var(--accent); }
/* status dots */
.dot { width: 6px; height: 6px; border-radius: 3px; display: inline-block; }
.dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.dot.warn { background: var(--warn); box-shadow: 0 0 6px var(--warn); }
.dot.danger { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
.dot.idle { background: var(--fg-3); }
/* ── Bars / meters ── */
.bar {
height: 4px; border-radius: 2px;
background: var(--bg-3); overflow: hidden;
position: relative;
}
.bar > i {
display: block; height: 100%;
background: var(--accent);
border-radius: 2px;
}
.bar.warn > i { background: var(--warn); }
.bar.danger > i { background: var(--danger); }
.bar.ok > i { background: var(--ok); }
/* ── Scrollbars ── */
.scroll { overflow: auto; }
.scroll::-webkit-scrollbar { width: 8px; height: 8px; }
.scroll::-webkit-scrollbar-thumb { background: oklch(0.4 0.02 245 / 0.4); border-radius: 4px; }
.scroll::-webkit-scrollbar-thumb:hover { background: oklch(0.5 0.02 245 / 0.5); }
.scroll::-webkit-scrollbar-track { background: transparent; }
/* ── Icons (mini-stroke) ── */
.ic { width: 16px; height: 16px; flex-shrink: 0; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.ic.sm { width: 13px; height: 13px; }
.ic.lg { width: 20px; height: 20px; }
/* keyframes */
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.pulse { animation: pulse 1.6s ease-in-out infinite; }
@keyframes ping {
0% { transform: scale(.4); opacity: .9; }
80%,100% { transform: scale(2.4); opacity: 0; }
}
.ping {
position: absolute; inset: 0; border-radius: 50%;
animation: ping 2s ease-out infinite;
}
@keyframes dash { to { stroke-dashoffset: -100; } }
/* utilities */
.row { display: flex; }
.col { display: flex; flex-direction: column; }
.gap-1 { gap: 4px; } .gap-2 { gap: 8px; } .gap-3 { gap: 12px; } .gap-4 { gap: 16px; }
.f1 { flex: 1; }
.center { display: flex; align-items: center; justify-content: center; }
.mid { display: flex; align-items: center; }
.between { display: flex; align-items: center; justify-content: space-between; }
.wrap { flex-wrap: wrap; }
.muted { color: var(--fg-3); }
.dim { color: var(--fg-2); }
.strong { color: var(--fg-0); }
.eyebrow { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--fg-3); font-weight: 500; }

425
tweaks-panel.jsx Normal file
View File

@@ -0,0 +1,425 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel"
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">{children}</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
function TweakColor({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

43
uploads/logo_light.svg Normal file
View File

@@ -0,0 +1,43 @@
<svg width="150" height="36" viewBox="0 0 150 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.0459 4.69253L24.4084 4.6875L17.3427 16.9334C16.0108 19.2369 14.67 21.5335 13.357 23.8492C12.8279 24.6836 12.7967 25.5367 14.0379 25.5663C17.6611 25.6526 21.3268 25.5356 24.9495 25.6042C24.4224 26.7246 23.2874 28.5534 22.6381 29.6706L21.6984 31.3061L13.8032 31.3074C11.2823 31.3084 7.42295 31.774 6.15339 28.9623C5.10923 26.6498 6.73472 24.2701 7.86608 22.3109L10.1604 18.3408L18.0459 4.69253Z" fill="#2F2828"></path>
<path d="M30.7029 4.69293L38.1664 4.6931C40.8704 4.68656 45.5224 4.1044 46.4284 7.56236C47.0408 9.9001 45.3543 12.286 44.21 14.218C42.2951 14.125 39.809 14.1965 37.8457 14.1908C38.3321 13.3838 40.2747 10.8182 38.8199 10.5324C37.4531 10.2639 35.1947 10.4548 33.7653 10.4209C32.0072 13.3847 30.3349 16.4036 28.5844 19.3723C28.4964 19.5215 28.3602 19.7716 28.257 19.9038L21.9204 19.9023L30.7029 4.69293Z" fill="#2F2828"></path>
<path d="M89.1232 4.67032C89.7835 4.62775 90.9099 4.66078 91.6053 4.65867L90.978 7.75816L98.7808 7.75999C98.6607 8.46499 98.5297 9.16816 98.3877 9.86921L90.5769 9.86774C90.1934 11.0614 89.853 13.7315 89.4731 15.1689L97.9522 15.1851C97.8321 15.8946 97.6872 16.5697 97.5364 17.2721C94.9175 17.1811 91.7297 17.2538 89.0954 17.2677C88.594 19.3603 88.1541 21.9308 87.7303 24.0635C86.9061 24.0335 86.0812 24.0264 85.2563 24.0421C85.7525 22.0904 86.18 19.3243 86.6177 17.2638L85.2372 17.257L77.0092 17.2648C77.1446 16.5758 77.2727 15.8857 77.3957 15.1944C79.8624 15.1112 82.3687 15.2077 84.839 15.1714C85.5417 15.161 86.2942 15.1588 86.9939 15.2057C87.447 13.5537 87.6608 11.5742 88.1087 9.87872C86.2986 9.82772 84.3142 9.86591 82.4909 9.86569C81.7604 11.226 81.0028 12.4816 80.2657 13.8163L77.5362 13.8114C79.6816 10.4975 80.6808 8.44223 82.2311 4.86287C83.0545 4.83579 84.0075 4.85801 84.8405 4.85771C84.4152 5.82928 83.998 6.81499 83.512 7.75669C85.1516 7.77967 86.8351 7.76247 88.4769 7.76452C88.7163 6.8407 88.9417 5.61995 89.1232 4.67032Z" fill="#2F2828"></path>
<path d="M70.2503 4.65824L73.7308 4.65625C74.7336 7.08713 76.1309 9.57515 77.5099 11.8054C76.5818 11.7752 75.527 11.798 74.5887 11.7967C73.5397 9.99962 72.6069 8.13727 71.7957 6.22117C71.2298 6.89947 70.7149 7.63591 70.1079 8.35496L72.1324 8.35415C72.359 9.56198 72.4332 11.2339 72.4619 12.4558L70.2398 12.4478C70.2372 11.0171 70.1331 9.90113 70.0025 8.48344C69.1539 9.57259 67.962 10.814 66.9868 11.7877L65.7182 11.7932L64.2958 11.7963C64.2202 12.4282 64.0755 13.0643 63.9444 13.6881L60.6789 13.6869C60.5363 14.5168 60.3766 15.3436 60.1998 16.1668L63.9171 16.1664L63.5281 18.0826C62.277 18.0555 60.9445 18.0784 59.687 18.0782C58.9474 20.2403 57.828 22.2532 56.3813 24.0224C55.6509 24.0768 54.3913 24.03 53.5917 24.0442C55.3706 21.7397 56.0946 20.7987 57.3125 18.0802L53.6008 18.0731C53.7608 17.396 53.882 16.8655 53.9824 16.1718C55.2848 16.1587 56.5873 16.1591 57.8898 16.1732L58.3652 13.6896L54.9231 13.6853C55.1139 13.0172 55.1958 12.5027 55.3015 11.8164C56.2519 11.7504 57.7795 11.7881 58.7587 11.8109C58.8945 11.0981 59.037 10.3867 59.1861 9.67657C57.9106 9.6642 56.6351 9.66427 55.3597 9.67657C55.4544 9.05996 55.5544 8.44415 55.66 7.8293C57.5354 7.80764 59.4484 7.82827 61.3266 7.83003C61.9933 6.86852 62.6551 5.74668 63.0825 4.66341L65.5379 4.65888C64.9966 5.88817 64.5907 6.67983 63.9019 7.83288L65.5427 7.82645C65.4452 8.4301 65.2987 9.07927 65.1744 9.68205C63.9717 9.64854 62.7013 9.67152 61.4934 9.67532C61.3775 10.3714 61.2146 11.1007 61.0712 11.7947L63.9987 11.7913C66.5788 9.20798 68.0943 7.61586 70.2503 4.65824Z" fill="#2F2828"></path>
<path d="M122.532 11.8166C125.798 11.7231 129.441 11.7977 132.734 11.7984C132.466 13.5695 131.978 15.624 131.64 17.4038C131.42 18.5691 130.997 20.9151 130.604 21.9327C130.313 22.7085 129.869 23.4177 129.299 24.0182C128.475 24.0895 127.218 24.0195 126.289 24.0612C126.736 23.701 127.014 23.4316 127.42 23.0228C128.176 22.1519 128.359 21.6669 128.744 20.5722C126.97 20.5048 124.822 20.5601 123.018 20.5584C122.945 21.3158 122.507 23.1882 122.339 24.0641C121.646 24.031 120.793 24.0186 120.111 24.0547C120.247 23.0506 120.522 21.8389 120.724 20.8256L121.799 15.4428C121.945 14.7076 122.299 12.3722 122.532 11.8166ZM129.104 18.8694C129.21 18.2708 129.304 17.7024 129.465 17.1139C127.646 17.0946 125.48 17.1746 123.726 17.1053C123.609 17.6915 123.487 18.2767 123.359 18.8607C125.251 18.8633 127.22 18.8364 129.104 18.8694ZM124.059 15.3809C124.989 15.3848 129.088 15.5282 129.805 15.3507C129.899 14.7602 130.003 14.261 130.135 13.6774C128.223 13.6522 126.311 13.6515 124.399 13.6752C124.302 14.1834 124.187 14.895 124.059 15.3809Z" fill="#2F2828"></path>
<path d="M28.257 19.9042C30.3408 19.8373 32.5067 19.9107 34.6003 19.8799C33.5304 21.7989 32.4375 23.705 31.3217 25.5977L37.6027 25.587C37.1076 26.6547 36.2454 28.0061 35.655 29.0623C35.3804 29.5534 34.6405 30.8847 34.3403 31.3077L28.0049 31.3022C28.8125 29.6956 29.8532 28.1298 30.7172 26.5471C30.8889 26.2323 31.0858 25.9059 31.2849 25.6084C29.1922 25.6072 27.049 25.5816 24.9604 25.61C25.6026 24.4466 26.26 23.2918 26.9324 22.1456C27.3144 21.4843 27.8407 20.5151 28.257 19.9042Z" fill="#007143"></path>
<path d="M99.7206 10.1076C106.203 9.98573 113.088 10.0971 119.599 10.1018C118.777 13.8389 118.005 17.7955 117.477 21.5875C117.37 22.3581 118.045 23.5899 118.033 24.042C117.125 24.0338 116.218 24.0361 115.311 24.0488C114.92 21.1109 115.519 19.0866 116.068 16.2425C116.357 14.7439 116.652 13.209 116.981 11.7202L99.4205 11.7137C99.5633 11.1642 99.6343 10.6691 99.7206 10.1076Z" fill="#2F2828"></path>
<path d="M64.5255 12.945C68.4291 12.8445 72.7488 12.9376 76.6835 12.9394C76.5327 13.651 76.4317 14.5237 75.9691 15.0919C75.4018 15.7882 74.7928 16.4582 74.197 17.1312L70.7419 21.0305L69.7438 21.059C70.2176 22.0474 70.6693 23.0462 71.0985 24.0548C70.1752 24.0352 69.2515 24.0344 68.3282 24.0525C67.9964 23.2947 67.6583 22.5397 67.3138 21.7875C66.4548 19.874 65.5159 17.9972 64.5 16.1621C65.3591 16.1484 66.2391 16.156 67.1001 16.1542C67.8294 17.3343 68.5019 18.5485 69.1155 19.7926C70.6881 18.1842 72.2203 16.5366 73.7103 14.8514L64.1427 14.8496C64.2745 14.2155 64.402 13.5807 64.5255 12.945Z" fill="#2F2828"></path>
<path d="M100.218 12.4635C104.946 12.4535 109.674 12.4727 114.401 12.5212C114.273 12.9715 114.112 13.3776 113.946 13.814C112.657 14.4639 111.179 15.1061 109.857 15.7056C111.599 15.949 113.275 15.9778 115.027 16.028C114.844 16.6716 114.645 17.3102 114.427 17.943C112.64 17.957 110.455 17.6924 108.705 17.3225C108.285 17.2407 107.508 16.991 107.072 16.8654C104.084 18.0184 101.851 17.949 98.7192 17.9316C98.791 17.2822 98.9052 16.6738 99.0208 16.0327C102.633 15.9161 105.599 15.8462 108.933 14.123C106.178 14.2266 102.668 14.1367 99.8604 14.135C99.9731 13.5764 100.092 13.0192 100.218 12.4635Z" fill="#2F2828"></path>
<path d="M98.7625 18.3504C100.425 18.3036 102.282 18.3451 103.963 18.3452L114.077 18.3459C113.979 18.982 113.891 19.4244 113.733 20.0481C111.681 19.9793 109.274 20.0318 107.196 20.0488C107.14 20.5854 106.873 21.7822 106.759 22.3601C108.872 22.2731 111.751 22.3361 113.883 22.3568C113.718 22.9049 113.8 23.5534 113.504 23.9553C113.406 24.0871 113.154 24.042 112.96 24.0414C107.731 23.9823 102.283 24.0035 97.0497 24.0421C97.15 23.4629 97.2678 22.9333 97.3996 22.3599C99.6372 22.3158 102.265 22.3081 104.496 22.3613C104.62 21.6 104.787 20.8055 104.936 20.0457C102.829 19.9936 100.54 20.0389 98.4199 20.0394L98.7625 18.3504Z" fill="#2F2828"></path>
<path d="M133.256 13.9166C133.997 13.9023 134.788 13.9161 135.533 13.9168C135.386 14.8072 135.174 15.7896 135.001 16.6831C137.384 16.4394 139.769 16.2153 142.155 16.0109C141.98 16.6792 141.965 17.2281 141.716 17.9362C141.421 17.9321 141.131 17.9513 140.839 17.9823C138.756 18.2028 136.663 18.3336 134.585 18.5982C134.457 19.6086 134.11 21.1394 133.89 22.1662L135.596 22.1591H140.893C140.792 22.7924 140.684 23.4247 140.569 24.0557C137.586 23.9698 134.244 24.0318 131.246 24.0396C131.9 20.9278 132.738 17.0234 133.256 13.9166Z" fill="#2F2828"></path>
<path d="M135.098 4.62931C135.853 4.62166 136.607 4.62419 137.363 4.6369C137.162 5.41299 137.017 6.28154 136.868 7.07464C139.102 6.84738 141.908 6.69114 144.053 6.37755C143.943 7.01342 143.835 7.73358 143.657 8.34683L136.465 8.98722C136.314 9.81163 136.157 10.6349 135.994 11.4569C136.445 11.4309 137.106 11.4499 137.573 11.4482C139.397 11.4591 141.222 11.4559 143.046 11.4386C142.935 12.1076 142.839 12.6758 142.674 13.3374C139.624 13.2772 136.387 13.3227 133.323 13.3214C133.552 12.5671 133.793 11.1594 133.955 10.3413C134.324 8.43507 134.706 6.53103 135.098 4.62931Z" fill="#2F2828"></path>
<path d="M102.916 4.63357C103.716 4.61473 104.573 4.6323 105.378 4.63596L104.934 5.48128L121.832 5.48318C121.739 6.10161 121.672 6.52446 121.507 7.12456C119.818 7.05448 117.769 7.10604 116.053 7.10625L103.984 7.11772C103.38 7.9858 102.762 8.89904 102.054 9.68109C101.302 9.67604 100.124 9.63799 99.419 9.71446C99.8816 9.06719 100.543 8.33907 101.04 7.66743C101.837 6.59222 102.296 5.78928 102.916 4.63357Z" fill="#2F2828"></path>
<path d="M126 4.67433C126.546 4.62029 128.007 4.65657 128.619 4.65502C127.72 6.37437 126.984 7.53498 125.875 9.09798C127.492 9.05503 129.258 9.08605 130.886 9.08474C130.822 8.136 130.773 7.13368 130.53 6.2156C131.306 6.15771 132.166 6.17288 132.949 6.17043C133.021 7.06163 133.221 8.13834 133.316 9.09988C133.309 9.63037 133.157 10.3841 133.057 10.9028C129.465 10.8026 125.423 10.8945 121.795 10.8945C123.602 8.78078 124.778 7.14823 126 4.67433Z" fill="#2F2828"></path>
<path d="M37.8456 14.1928C39.809 14.1984 42.2951 14.127 44.2099 14.22L41.9937 18.0919C41.7788 18.4647 41.1512 19.6063 40.9155 19.9025L34.5791 19.8957C35.6336 17.9833 36.7911 16.1017 37.8456 14.1928Z" fill="#007143"></path>
<path d="M40.9155 19.902C43.0195 19.8499 45.1502 19.9224 47.2506 19.8789C46.4721 21.4168 44.8928 24.105 43.9715 25.602L37.6138 25.6077C38.4831 23.9831 39.9311 21.447 40.9155 19.902Z" fill="#007143"></path>
<path d="M104.415 7.76391L120.496 7.76172C120.35 8.3135 120.238 8.87355 120.16 9.43887C118.084 9.37345 115.82 9.41552 113.733 9.4153L104.085 9.42408C104.155 8.93055 104.312 8.26469 104.415 7.76391Z" fill="#2F2828"></path>
<path d="M96.4304 26.879C99.2507 26.6667 98.9579 30.7714 95.9312 31.3474C92.5488 31.3098 93.6973 27.1881 96.4304 26.879ZM95.9883 30.5709C96.9304 30.2786 97.7955 29.3384 97.4662 28.3229C97.3944 28.096 97.2327 27.9087 97.0175 27.8056C96.7964 27.6975 96.4978 27.6448 96.2518 27.6614C95.3083 27.9393 94.49 28.8604 94.7842 29.8668C94.8501 30.099 95.0075 30.2944 95.2205 30.4079C95.4752 30.5427 95.7065 30.5599 95.9883 30.5709Z" fill="#2F2828"></path>
<path d="M120.579 26.8756C123.424 26.7179 123.034 30.7691 120.086 31.3456C116.806 31.3978 117.733 27.1994 120.579 26.8756ZM120.22 30.5368C122.019 29.9774 122.223 27.4751 120.314 27.6674C119.642 27.9156 119.439 28.0285 119.093 28.7081C118.616 29.6484 119.009 30.7721 120.22 30.5368Z" fill="#2F2828"></path>
<path d="M116.66 26.9238L117.992 26.9219C117.747 28.2834 117.347 29.9417 117.049 31.3153L116.157 31.3083C116.347 30.6023 116.495 29.7902 116.644 29.0672C116.688 28.9313 116.767 28.502 116.801 28.3408C116.395 29.0335 115.459 30.9511 114.675 31.3444C114.566 31.399 114.465 31.3038 114.371 31.2359C114.165 30.71 113.925 28.8772 113.836 28.242L113.198 31.3117L112.31 31.3165L113.23 26.9279L114.491 26.9225C114.605 27.7811 114.833 28.7942 114.973 29.6896C115.472 28.9993 116.215 27.6823 116.66 26.9238Z" fill="#2F2828"></path>
<path d="M86.5921 26.9211C86.9917 26.9147 87.4287 26.9264 87.8225 26.9095C90.4759 26.7956 90.1867 29.6754 88.5223 30.9111C87.5305 31.4374 86.7699 31.3173 85.6661 31.3024C85.7957 30.2878 86.3578 28.007 86.5921 26.9211ZM86.7231 30.4522C87.1359 30.4503 87.7347 30.4871 88.1007 30.316C88.6401 29.8127 89.3033 28.7778 88.6848 28.0956C88.3158 27.6887 87.7976 27.7551 87.2911 27.7533C87.1257 28.5612 86.9295 29.669 86.7231 30.4522Z" fill="#2F2828"></path>
<path d="M123.457 26.9226C124.213 26.9143 125.634 26.8007 126.186 27.3128C126.71 27.7978 125.896 28.5423 125.68 28.9376C125.595 29.0947 126.087 29.5814 126.078 29.8632C126.065 30.2604 125.746 30.6635 125.492 30.9391C124.737 31.4476 123.501 31.3147 122.564 31.3117C122.81 29.8751 123.187 28.3774 123.457 26.9226ZM123.593 30.5161C124.141 30.5116 124.358 30.5255 124.891 30.3884C125.067 30.151 125.129 30.0825 125.184 29.7933C125.024 29.2759 124.325 29.3801 123.862 29.3789L123.593 30.5161ZM123.999 28.6156C124.282 28.6098 124.866 28.6145 125.123 28.5796C125.245 28.3715 125.336 28.252 125.328 28.0082C125.082 27.6121 124.658 27.6901 124.214 27.6711C124.144 27.9762 124.058 28.3111 123.999 28.6156Z" fill="#2F2828"></path>
<path d="M90.735 26.9215C92.024 26.9243 94.8238 26.5442 93.2493 29.0441C93.0876 29.3009 92.7831 29.446 92.5137 29.583C92.7384 30.1413 92.9478 30.7419 93.1556 31.3095C92.8314 31.3198 92.3036 31.3998 92.0979 31.1551C91.9179 30.6395 91.7971 30.0956 91.3931 29.741C90.8792 29.6812 90.7592 30.8999 90.6765 31.3136L89.8201 31.3149C90.1114 29.8477 90.4166 28.3831 90.735 26.9215ZM91.2167 28.8258C91.4736 28.8227 92.2707 28.8268 92.4815 28.7911C92.6447 28.5511 92.7304 28.4484 92.7926 28.1652C92.6755 27.6152 91.9171 27.7496 91.4458 27.7535C91.366 28.1101 91.2891 28.4675 91.2167 28.8258Z" fill="#2F2828"></path>
<path d="M69.3081 26.9222L70.1771 26.9141C69.9698 28.186 69.5337 30.0383 69.2388 31.3177L68.1065 31.3098C67.8398 30.3494 67.4692 29.2815 67.1696 28.3128C67.0189 29.2546 66.7757 30.3753 66.5616 31.3076L65.6787 31.3079L66.6075 26.9221L67.7409 26.9223C68.0639 27.8733 68.37 28.898 68.6717 29.8604C68.873 28.8787 69.0851 27.8992 69.3081 26.9222Z" fill="#2F2828"></path>
<path d="M108.881 29.8868C109.027 29.2478 109.289 27.5001 109.54 27.0002C109.723 26.889 110.12 26.9136 110.352 26.9105C110.263 27.8288 109.679 30.3103 109.457 31.3037L108.341 31.3085C108.011 30.3531 107.719 29.367 107.384 28.4004L106.772 31.3134L105.9 31.3077C106.155 29.911 106.523 28.3126 106.832 26.9175L107.966 26.9206C108.2 27.8185 108.6 28.9795 108.881 29.8868Z" fill="#2F2828"></path>
<path d="M61.0197 26.9225L61.8629 26.9102C61.7079 28.0991 61.2332 30.0627 60.9722 31.3099L59.8503 31.3086C59.519 30.3592 59.2069 29.3429 58.8931 28.3815L58.2722 31.3125L57.3802 31.3049C57.7123 30.0129 58.0673 28.24 58.3208 26.9184L59.4601 26.922C59.6904 27.8033 60.1149 29.0009 60.4008 29.8927C60.5467 28.9635 60.7999 27.8383 61.0197 26.9225Z" fill="#2F2828"></path>
<path d="M78.6518 26.9215L79.5294 26.9146C79.4255 27.4595 79.2959 28.0172 79.1766 28.5603C79.689 28.553 80.216 28.5592 80.7298 28.5592L81.0951 26.923L81.9493 26.9141C81.8519 27.9269 81.2576 30.0868 81.0526 31.1643C81.0343 31.2615 80.9867 31.26 80.8865 31.3094C80.6281 31.3129 80.44 31.3262 80.1889 31.262C80.2658 30.6683 80.4253 30.0042 80.5534 29.4127L78.998 29.4118C78.867 30.0925 78.7586 30.6456 78.5669 31.3166L77.7339 31.3159C77.9893 29.9039 78.3363 28.3198 78.6518 26.9215Z" fill="#2F2828"></path>
<path d="M103.391 26.918L106.201 26.9222L106.032 27.7871C105.48 27.7389 104.623 27.7531 104.059 27.7493C104.008 28.0771 103.939 28.4261 103.88 28.7546L105.576 28.7521L105.445 29.4786C105.093 29.4693 104.014 29.408 103.727 29.5427L103.677 29.7236L103.488 30.4471L105.46 30.4415C105.391 30.7111 105.332 31.0336 105.273 31.3098C104.347 31.2889 103.359 31.3061 102.428 31.3067C102.714 29.9523 103.028 28.2233 103.391 26.918Z" fill="#2F2828"></path>
<path d="M101.23 26.8806C101.741 26.8186 102.269 26.978 102.715 27.2175C102.67 27.4649 102.637 27.8569 102.466 28.0164C102.325 28.0441 101.618 27.7141 101.272 27.6162C100.683 27.7595 100.181 27.9029 99.8684 28.4645C99.2265 29.6163 99.5405 30.8072 101.032 30.4857C101.475 30.0635 101.026 29.4324 101.687 29.1473C101.888 29.1663 102.02 29.1878 102.216 29.2257C102.312 29.5647 101.987 30.9407 101.742 31.0531C97.9931 32.7738 97.5708 27.2933 101.23 26.8806Z" fill="#2F2828"></path>
<path d="M74.9385 26.9214C75.1712 26.9118 75.4808 26.8744 75.6821 26.9707C75.7905 27.2226 75.7246 27.2838 75.6726 27.6035C75.3345 28.829 75.382 30.2578 74.2211 31.0737C73.6941 31.4969 72.454 31.4555 72.0957 30.8256C71.57 29.9017 72.2194 27.9607 72.4365 26.9236L73.3245 26.923C73.2315 27.4568 72.5294 29.9806 72.8643 30.2786C74.3522 31.6025 74.7181 27.8186 74.9385 26.9214Z" fill="#2F2828"></path>
<path d="M64.4934 26.8798C65.0658 26.8499 65.4038 26.9622 65.9183 27.1614C65.9004 27.4821 65.8231 27.8782 65.7694 28.201C65.3237 27.8267 65.0369 27.7669 64.4898 27.6111C64.0346 27.7299 63.4555 27.898 63.1815 28.3015C62.8659 28.7125 62.5666 30.01 63.1207 30.2988C64.5127 31.0242 64.4083 30.3038 64.6865 29.2341C64.6957 29.1988 65.0789 29.1547 65.1737 29.1403L65.443 29.2169C65.5798 29.5824 65.2474 30.6016 65.1325 31.0264C64.7903 31.1835 64.4256 31.2859 64.0516 31.3298C60.9761 31.6856 61.3322 27.1955 64.4934 26.8798Z" fill="#2F2828"></path>
<path d="M85.1552 26.9215L86.2297 26.918C85.7693 27.4771 85.3097 28.0605 84.8551 28.6258C84.028 29.5226 84.0163 30.1406 83.7762 31.3103L82.8773 31.3089C83.4446 29.0983 83.1291 29.0171 82.3298 26.9199L83.3107 26.9197C83.512 27.4507 83.7059 27.9844 83.8926 28.5204L85.1552 26.9215Z" fill="#2F2828"></path>
<path d="M139.373 26.9247L140.422 26.918C140.11 27.4009 139.441 28.178 139.059 28.6479C138.207 29.6578 138.305 30.0537 137.985 31.3112L137.113 31.3094C137.262 30.7606 137.396 30.0375 137.516 29.4702C137.197 28.6188 136.873 27.7696 136.543 26.9227L137.516 26.921C137.728 27.4476 137.932 27.9767 138.13 28.5083C138.556 27.9893 138.97 27.4613 139.373 26.9247Z" fill="#2F2828"></path>
<path d="M57.3993 4.67527C58.1061 4.65383 58.8637 4.66377 59.5748 4.66016C59.8347 5.63537 59.8946 6.41442 59.9652 7.40979C59.3798 7.36896 58.4268 7.39837 57.8139 7.39757C57.7331 6.3945 57.6463 5.64817 57.3993 4.67527Z" fill="#2F2828"></path>
<path d="M133.406 26.9219L136.447 26.9253L136.333 27.7751C135.986 27.7619 135.585 27.7671 135.234 27.7638C135.054 28.5778 134.891 29.4212 134.722 30.2397L134.468 31.3148L133.589 31.3145C133.858 30.1379 134.11 28.9573 134.343 27.7731C134.007 27.7561 133.613 27.7725 133.273 27.7783L133.406 26.9219Z" fill="#2F2828"></path>
<path d="M53.4287 26.918L54.3232 26.9254L53.5875 30.453L55.5179 30.4498L55.3763 31.3123C54.4204 31.2926 53.4227 31.3031 52.4631 31.2988C52.7142 30.6151 53.2746 27.7241 53.4287 26.918Z" fill="#2F2828"></path>
<path d="M128.859 26.924L129.753 26.9219C129.475 28.0093 129.226 29.3489 128.993 30.4563L130.915 30.4496L130.763 31.3133C129.848 31.2883 128.851 31.3078 127.93 31.3087L128.859 26.924Z" fill="#2F2828"></path>
<path d="M127.308 26.9204L128.164 26.918L127.239 31.3152L126.351 31.3151C126.65 29.8952 126.949 28.3149 127.308 26.9204Z" fill="#2F2828"></path>
<path d="M56.7724 26.918L57.6656 26.9141C57.3133 28.3287 57.0132 29.8795 56.7123 31.3129L55.8318 31.3086C56.1293 29.8417 56.4429 28.3781 56.7724 26.918Z" fill="#2F2828"></path>
<path d="M70.8595 26.9214L71.7381 26.918C71.567 28.0722 71.0941 30.1443 70.8119 31.3022L69.9707 31.3105C70.1852 29.9437 70.5812 28.3064 70.8595 26.9214Z" fill="#2F2828"></path>
<path d="M132.146 26.9187L133.056 26.918C132.809 28.082 132.418 30.2402 132.087 31.3152L131.25 31.3052C131.533 29.8444 131.902 28.3846 132.146 26.9187Z" fill="#2F2828"></path>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

60
woodpecker.yml Normal file
View File

@@ -0,0 +1,60 @@
steps:
- name: prepare
image: alpine:3.20
when:
event:
- push
- pull_request
- manual
branch:
- master
- develop
- main
commands: |
cd $CI_WORKSPACE
# 获取分支名
BRANCH_NAME=$(echo $CI_COMMIT_BRANCH | tr / -)
echo "Branch name: $BRANCH_NAME"
# 版本号: 分支名-VERSION
PKG_VERSION=$(cat VERSION 2>/dev/null | tr -d '[:space:]' || echo "1.0.0")
PROJECT_VERSION="$BRANCH_NAME-$PKG_VERSION"
echo "Docker tag: $PROJECT_VERSION"
echo $PROJECT_VERSION > $CI_WORKSPACE/project_version.txt
- name: docker-build
image: docker:24.0.5-cli
when:
event:
- push
- pull_request
- manual
branch:
- master
- develop
- main
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands: |
PROJECT_VERSION=$(cat $CI_WORKSPACE/project_version.txt)
MODULE_NAME=ln-vdc
echo "Building Docker image: $MODULE_NAME:$PROJECT_VERSION"
cd $CI_WORKSPACE
docker build -t harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION .
mkdir -p /root/.docker
cat > /root/.docker/config.json <<EOF
{
"auths": {
"harbor.lnh2e.com": {
"auth": "$(echo Y2ljZDpMbkBjaWNkMDE=)"
}
}
}
EOF
docker push harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION

93
技术介绍.md Normal file
View File

@@ -0,0 +1,93 @@
# 羚牛车辆数据中心 · 技术介绍
## 一句话定位
**面向氢能乘用车队的物联网 + AI + 大数据驾驶舱** —— 把车端国标遥信、北斗位置、车载视频和业务系统数据汇聚为统一资产视图,从「能看车」延伸到「会算账、能预警、可决策」的一体化数据中台。
---
## 总体架构(五层)
```
┌─────────────────────────────────────────────────────┐
│ ⑤ 应用层 实时地图 · 资产管理 · 轨迹回放 · 事件中心 │
│ ESG 碳减排 · 多角色权限驾驶舱 · 移动端 │
├─────────────────────────────────────────────────────┤
│ ④ 服务层 OpenAPI · GraphQL · WebSocket 推流 │
│ 规则引擎 · 智能告警 · 报表导出 │
├─────────────────────────────────────────────────────┤
│ ③ 存储层 时序库 (TDengine/InfluxDB) · 关系库 │
│ 对象存储 (视频/快照) · 数据湖 (历史归档) │
├─────────────────────────────────────────────────────┤
│ ② 计算层 Flink 流式清洗 · Spark 离线聚合 │
│ AI 模型 (异常检测/驾驶行为/能耗预测) │
├─────────────────────────────────────────────────────┤
│ ① 接入层 TBOX (GB/T 32960-2016) · JT/T 808-2019 │
│ JT/T 1078 视频 · 第三方 OpenAPI │
└─────────────────────────────────────────────────────┘
```
---
## 五大核心能力
### ① 多源异构接入 · IoT Gateway
单点支持 **三大协议族**
- **TBOX 国标 (GB/T 32960)** — 整车遥信 54 项 / 驱动电机 18 项 / 动力电池 32 项 / 燃料电池 24 项 / 极值故障 12 项,**110 s 上行**
- **JT/T 808** — 北斗位置、行驶记录仪、电子围栏、参数下发,**30 s 心跳**
- **JT/T 1078** — H.264 视频通道4 路实时音视频 + 录像存储)
可水平扩展至 **5000+ 车辆并发**,单车日均消息 ~80 万条。预留 **MQTT / Kafka Connect / 第三方 OpenAPI** 入口对接补能站、维保、ERP、TMS、ESG。
### ② 数据清洗与整合 · 流式 ETL
- **协议解码** — 国标二进制帧 → 标准化语义模型vehicle/battery/motor/fuel-cell/tpms…
- **质量治理** — 时间戳校正、丢包补偿、异常值剔除、单位归一、码值映射
- **多源融合** — 同一辆车的 TBOX + JT 双源数据按 VIN/车牌做事件级对齐,输出**单车主数据总线**
- **冷热分层** — 实时热路径走 Flink 内存计算7 天内热数据驻时序库30 天后转 Parquet 归档至数据湖
### ③ 规则引擎 · 可视化事件编排
**WHEN → LOGIC → THEN** 的 IF-THEN 节点画布,业务无需写代码即可配置:
- **告警事件**:电池温度 > 60 °C、SOC < 10%、胎压偏差、急加急减
- **运维通知**:保养剩余里程 < 1000 km、合同到期 30 天、年检 60 天
- **业务事件**:还车里程超标、客户欠费、车辆调拨
- **自动化动作**:站内消息 / 邮件 / 短信 / Webhook / 自动派工单
每条规则带优先级P0/P1/P2/P3、抑制策略、生效时段、命中统计。
### ④ AI 智能层 · 让数据自己说话
- **异常检测** — 基于多维时序的 Isolation Forest / LSTM-AD对电池一致性、电机温升、氢气泄漏给出**早于阈值告警**的预测
- **驾驶行为评分** — 急加速 / 急减速 / 急转弯 / 超速时长加权,输出 AE 评分卡
- **能耗 & 续航预测** — XGBoost 结合载重、风速、坡度、温度,对剩余里程做误差 < 5% 的滚动预测
- **轨迹挖掘** — DBSCAN 识别停留点、聚类常用路线,反哺补能站选址与电子围栏自动生成
- **ESG 量化** — 实时计算等效碳减排 (kg CO₂)、绿氢消耗、相对柴油基线节省成本
### ⑤ 存储与展示 · 一切可追溯、可下钻、可导出
- **数据驾驶舱**:实时地图 + 资产矩阵 + KPI 雷达 + 视频墙
- **历史检索**:按车辆 / 时段 / 字段30+ 国标字段可选)→ 选择展示形态(曲线 / 柱状 / 数据表 / 热力日历 / 统计摘要)→ 一键导出 CSV
- **轨迹回放**0.5×16× 变速、停留点 & 事件标注、速度 / SOC / H₂ 三曲线联动播头、热力叠加
- **多角色权限**:总管理员 / 部门负责人(按部门 scope/ 运营岗 / 财务岗(隐藏车况,仅看合同),同一套系统不同身份不同视图
---
## 技术亮点
| 维度 | 选择 | 价值 |
|------|------|------|
| **协议完备性** | 同时承载车端国标 + 部标视频 + 业务系统 | 一套系统覆盖**自有车 + 租赁车 + 外采车**全部场景 |
| **数据可信度** | 双源融合 + 通道完好率监控 | 任意时刻可证明「这条数据从哪来、几点采、丢没丢」 |
| **业务可演进** | 规则引擎 + Webhook + OpenAPI | 不发版即可上线新告警、新报表、新对接 |
| **性能边界** | 时序库 + 列存 + 流批一体 | 万车规模下,**单车 1 年明细 < 1 s 出图** |
| **合规可审计** | 全字段血缘 + 操作日志 + 数据留存 7 年 | 满足新能源车监管、双碳核查、租赁合规要求 |
---
## 一段话版本(用于扉页 / PPT 首页 / 投标书)
> 羚牛车辆数据中心是面向**氢能乘用车队**的**物联网 · AI · 大数据**一体化平台。系统通过 **TBOX 国标 + JT/T 808 / 1078 双源接入**,把单车每日数十万条遥信、位置与视频数据,经**流式清洗、多源融合、AI 智能分析**后沉淀至**时序数据库与数据湖**,并以**资产驾驶舱、轨迹回放、规则引擎、ESG 碳减排**等场景化驾驶舱呈现给运营、运维、财务、管理层四类角色。从车端到决策端,让**每一辆车、每一公里、每一克氢、每一克碳**都可计、可视、可控。

View File

@@ -0,0 +1,156 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>羚牛车辆数据中心</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="styles/design-system.css?v=5"/>
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }
body { font-family: "IBM Plex Sans", system-ui, sans-serif; background: var(--bg-0); }
#root { height: 100%; width: 100%; }
/* page transition */
@keyframes pageFade {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
/* responsive — hide artboard's built-in sidebar on mobile (drawer takes over) */
@media (max-width: 899px) {
.app > .sidebar:not(.mobile) { display: none !important; }
.app { flex-direction: column; }
.app > div:not(.sidebar) { width: 100%; }
/* shrink topbar on small screens */
.topbar { padding: 0 12px; gap: 8px; height: 52px; flex: 0 0 52px; }
.topbar .crumbs { font-size: 12px; }
.topbar .crumbs .muted:not(:nth-last-child(2)) { display: none; }
.topbar .crumbs .sep:not(:last-of-type) { display: none; }
.topbar .kpi-row { display: none; }
.topbar .search { max-width: none; flex: 1; }
.topbar .right .icon-btn:nth-child(-n+2) { display: none; }
.topbar .right .avatar { display: none; }
}
@media (max-width: 599px) {
.topbar .search { display: none; }
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="data/fleet.js"></script>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="components/icons.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
<script type="text/babel" src="components/chrome.jsx"></script>
<script type="text/babel" src="components/charts.jsx"></script>
<script type="text/babel" src="components/map.jsx"></script>
<script type="text/babel" src="artboards/overview.jsx"></script>
<script type="text/babel" src="artboards/detail.jsx"></script>
<script type="text/babel" src="artboards/history.jsx"></script>
<script type="text/babel" src="artboards/playback.jsx"></script>
<script type="text/babel" src="artboards/alarm.jsx"></script>
<script type="text/babel" src="artboards/inbox.jsx"></script>
<script type="text/babel" src="artboards/esg.jsx"></script>
<script type="text/babel" src="artboards/variant-light.jsx"></script>
<script type="text/babel" src="artboards/variant-dense.jsx"></script>
<script type="text/babel" src="artboards/mobile.jsx"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "light",
"accentHue": 202,
"density": "comfortable",
"showHeatmap": false,
"mapStyle": "default",
"role": "admin"
}/*EDITMODE-END*/;
const RoleContext = React.createContext({ role: window.ROLES[0], setRole: () => {} });
window.useCurrentRole = () => React.useContext(RoleContext);
const ThemeContext = React.createContext({ theme: "dark", setTheme: () => {} });
window.useTheme = () => React.useContext(ThemeContext);
// Filter visible vehicles by role scope
window.useVisibleVehicles = () => {
const { role } = React.useContext(RoleContext);
return React.useMemo(() => {
if (!role || role.scope === "all" || role.scope === "ops" || role.scope === "finance") return window.VEHICLES;
if (role.scope === "dept") return window.VEHICLES.filter(v => v.dept === role.deptId);
return window.VEHICLES;
}, [role]);
};
const App = () => {
const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
const role = (window.ROLES || []).find(r => r.id === tweaks.role) || window.ROLES[0];
const setRole = React.useCallback((id) => setTweak('role', id), [setTweak]);
const setTheme = React.useCallback((t) => setTweak('theme', t), [setTweak]);
React.useEffect(() => {
document.documentElement.dataset.theme = tweaks.theme || 'light';
}, [tweaks.theme]);
React.useEffect(() => {
if (tweaks.theme === 'dark') {
document.documentElement.style.setProperty('--accent', `oklch(0.74 0.170 ${tweaks.accentHue})`);
document.documentElement.style.setProperty('--accent-soft', `oklch(0.74 0.170 ${tweaks.accentHue} / 0.16)`);
document.documentElement.style.setProperty('--accent-glow', `oklch(0.74 0.170 ${tweaks.accentHue} / 0.32)`);
} else {
document.documentElement.style.removeProperty('--accent');
document.documentElement.style.removeProperty('--accent-soft');
document.documentElement.style.removeProperty('--accent-glow');
}
}, [tweaks.accentHue, tweaks.theme]);
return (
<>
<RoleContext.Provider value={{ role, setRole }}>
<ThemeContext.Provider value={{ theme: tweaks.theme, setTheme }}>
<RouterApp/>
</ThemeContext.Provider>
</RoleContext.Provider>
<TweaksPanel title="Tweaks">
<TweakSection title="登录身份(数据权限演示)">
<TweakSelect value={tweaks.role} options={(window.ROLES || []).map(r => ({value: r.id, label: r.name}))}
onChange={v => setTweak('role', v)}/>
<div style={{fontSize: 11, color: "var(--fg-3)", marginTop: 6, lineHeight: 1.5}}>
{role && role.desc}
</div>
</TweakSection>
<TweakSection title="主题">
<TweakRadio value={tweaks.theme} options={[
{value:"light", label:"亮·羚牛白"},
{value:"dark", label:"暗·驾驶舱"},
]} onChange={v => setTweak('theme', v)}/>
</TweakSection>
<TweakSection title="主色调(暗主题)">
<TweakSlider label="羚牛绿·色相" value={tweaks.accentHue} min={130} max={220} step={1}
onChange={v => setTweak('accentHue', v)}/>
</TweakSection>
<TweakSection title="信息密度">
<TweakRadio value={tweaks.density} options={[
{value:"comfortable", label:"舒适"},
{value:"compact", label:"紧凑"},
]} onChange={v => setTweak('density', v)}/>
</TweakSection>
<TweakSection title="地图">
<TweakToggle label="显示热力图" value={tweaks.showHeatmap} onChange={v => setTweak('showHeatmap', v)}/>
<TweakRadio value={tweaks.mapStyle} options={[
{value:"default", label:"标准"},
{value:"minimal", label:"极简"},
]} onChange={v => setTweak('mapStyle', v)}/>
</TweakSection>
</TweaksPanel>
</>
);
};
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>