commit b2d0016a0d063ea35e60dc871e230c5294fc8cf8 Author: kkfluous Date: Tue Apr 28 15:12:32 2026 +0800 init: 羚牛车辆数据中心原型 + 部署配置 - 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:- - 容器端口 80,宿主映射 8112,含 /healthz 探活 diff --git a/.design-canvas.state.json b/.design-canvas.state.json new file mode 100644 index 0000000..dfe07ff --- /dev/null +++ b/.design-canvas.state.json @@ -0,0 +1 @@ +{"sections":{"detail":{"title":"② 单车详情"},"playback":{"title":"④ 轨迹回放"}}} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3229157 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20ae988 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +.idea/ +.vscode/ +node_modules/ +*.log +*.tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eea7da7 --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/app.jsx b/app.jsx new file mode 100644 index 0000000..0089db8 --- /dev/null +++ b/app.jsx @@ -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) => ( +
{ onNavigate(i.path); onCloseDrawer && onCloseDrawer(); }} + style={isMobile ? {width:"100%", height:44, display:"flex", justifyContent:"flex-start", padding:"0 16px", gap:14, borderRadius:8} : {}} + > + + {isMobile && {i.label}} +
+ ); + + if (isMobile) { + return ( + <> + {drawerOpen && ( +
+ )} +
+
+ 羚牛 +
+
车辆数据中心
+
氢能乘用车队
+
+
+ {ROUTES.map(renderItem)} +
+ {SUB_ROUTES.map(renderItem)} +
+ + ); + } + + return ( +
+
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, + }}> + 羚牛 +
+ {ROUTES.map(renderItem)} +
+ {SUB_ROUTES.map(renderItem)} +
+
ZG
+
+ ); +}; + +// ── Page wrapper: provides full-bleed canvas + page transition ── +const Page = ({ children, route }) => ( +
+ {children} +
+); + +// ── Mobile topbar (replaces desktop topbar on small screens) ── +const MobileTopbar = ({ title, onMenu, onSearch }) => ( +
+ +
{title}
+ + +
+); + +// ── Canvas mode (preserves the original design board) ──────── +const DesignCanvasMode = () => { + const W = 1440, H = 900; + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +// ── 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) || (() =>
页面 {route} 不存在
); + + // close drawer on route change + React.useEffect(() => { setDrawerOpen(false); }, [route]); + + // canvas mode = full-bleed, no chrome + if (route === "canvas") { + return ( +
+ setDrawerOpen(false)}/> +
+ {isMobile && setDrawerOpen(true)}/>} +
+ +
+
+
+ ); + } + + // 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 ( + setDrawerOpen(true) }}> +
+ + setDrawerOpen(false)}/> +
+
+ ); + } + + return ( + setDrawerOpen(true) }}> +
+ + + + {isMobile && ( + setDrawerOpen(false)}/> + )} +
+
+ ); +}; + +// ── 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; diff --git a/artboards/alarm.jsx b/artboards/alarm.jsx new file mode 100644 index 0000000..ae9892a --- /dev/null +++ b/artboards/alarm.jsx @@ -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:00–06: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 ( +
+ +
+ + + {/* Event-kind tabs */} +
+ 事件类型 + {EVENT_KINDS.map(k => ( + 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, + }}> + {k.label} + {k.count} + + ))} +
+ + + +
+
+ +
+ {/* Rules list */} +
+
+
+ 规则 · {filtered.length} + 共 {RULES.length} 条 +
+
+ +
+
+
+ {filtered.map((r,i)=>{ + const realIdx = RULES.indexOf(r); + const meta = KIND_META[r.kind]; + const isActive = realIdx === activeRule; + return ( +
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" + }}> +
+ {r.n} + + {meta.label}·{r.c} + +
+
+ {r.cond} +
+
+ {r.h} + + + +
+
+ ); + })} +
+
+ + {/* Rule editor canvas */} + + + {/* Right: properties */} + +
+
+
+ ); +}; + +// ── 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 ( +
+ {/* Header */} +
+
+
+ {rule.n} + + {meta.label} · {rule.c} + + 已启用 + v 1.4 · 2026-04-12 +
+
+ {isAlarm && "条件命中后立即生成告警事件,按通知渠道发送至业务负责人;P0 级支持一键派单。"} + {isOps && "条件命中后生成运维通知;如已配置工单动作,将创建保养/检修工单进入维保流程。"} + {isBiz && "条件命中后生成业务事件;可推送至 Webhook 联动 ERP / TMS / 调度系统。"} + {isAuto && "条件命中后自动执行动作(路径下发 / 状态变更),司机端弹窗确认后生效。"} +
+
+
+ + + +
+
+ + {/* Editor — split: WHEN | LOGIC | THEN */} +
+
+ + {/* WHEN column */} +
+
① 触发条件 · WHEN
+
+ {conds.map((c,i) => ( +
+
+ {c.lbl} + +
+
{c.v}
+
+ {c.op} + {c.val} +
+
+ ))} +
+ 添加条件 +
+
+
+ + {/* LOGIC center */} +
+
+
LOGIC GATE
+
+ {conds.length === 1 ? "A" : conds.map((c,i) => (c.lbl === "AND NOT" ? "¬" : "") + String.fromCharCode(65+i)).join(conds.length > 1 ? " ∧ " : "")} +
+
{conds.length} 个条件 · 全部满足时触发
+
+
+
评估窗口
+
采样频率10 s · TBOX
+
抑制窗口15 min
+
事件 IDEVT-{rule.kind.toUpperCase()}-{rule.c}
+
+
+ + {/* THEN column — actions */} +
+
② 触发动作 · THEN
+
+ {actionDefs.map((a,i) => ( +
+
+
+ + {a.title} +
+ + {a.kind === "action" ? "动作" : "通知"} + +
+
{a.who}
+
{a.note}
+
+ ))} +
+ 添加动作 +
+
+ + {/* Block library */} +
+
组件库 · 拖入条件 / 动作
+
+ {[ + {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)=>( +
+ {b.l} +
+ ))} +
+
+
+ +
+
+
+ ); +}; + +// ── 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 ( +
+
+ 规则属性 +
+
+
事件类型
+
+ {meta.label} · {rule.c} +
+ +
{isAlarm ? "告警优先级" : isOps ? "运维等级" : "事件等级"}
+
+ {(isAlarm ? ["P0","P1","P2"] : isOps ? ["M1","M2","M3"] : ["B1","B2","B3"]).map(p => ( + + {p} + + ))} +
+ +
适用车辆
+
+
全部车辆 · 512
+
排除维保中 6 辆 · 排除停运 4 辆
+
+ +
通知 / 动作渠道
+
+ {channels.map((c,i)=>( +
+
+ +
+
{c.l}
+
{c.who}
+
+
+ + + +
+ ))} +
+ +
抑制策略
+
+
同车去重窗口{isOps ? "24 小时" : "15 分钟"}
+
每日上限{isAlarm && rule.c === "P0" ? "无限制" : "20 次/车"}
+
合并策略{isOps ? "按车辆合并" : "不合并"}
+
+ +
静音时段
+
+
+ {isAlarm && rule.c === "P0" ? "P0 紧急规则不静音" : isAlarm ? "工作时段:8:00–20:00 推送" : isOps ? "仅工作日发送" : "无静音"} +
+
+ +
近 7 日触发
+
+
+ {[3,7,2,9,12,5,8].map((v,i) => ( +
8 ? meta.color : "var(--accent-soft)", borderRadius:1}}/> + ))} +
+
+ 4-224-28 +
+
+
+
+ ); +}; + +window.ArtboardAlarm = ArtboardAlarm; diff --git a/artboards/detail.jsx b/artboards/detail.jsx new file mode 100644 index 0000000..81c7d7e --- /dev/null +++ b/artboards/detail.jsx @@ -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 ( +
+ +
+ +
+ {/* Header card spanning 3 */} +
+
+
+
+ +
+
+
+ {v.plate} + + + {v.asset === "leasing" ? "租赁" : v.asset === "abnormal" ? "异常" : "在库"} + + + {v.own === "self" ? "自有" : "外租"} + + H₂ {v.h2} MPa +
+
VIN {v.vin} · {v.city} · 等级 {v.grade}级 · 状态时长 {v.statusDays}天
+
+ 数据来源 + + TBOX(GB/T 32960-2016) · JT/T 808-2019 · JT/T 1078 视频 + + + {v.gps === "online" ? "在线 · 上行 218ms" : "GPS离线"} + +
+
+
+
+ + + + +
+
+
+ {[ + {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)=>( +
+
{k.l}
+
{k.val}{k.u}
+
+ ))} +
+
+ + {/* 资产档案 */} +
+
+ + 资产档案 +
+
+
+
车牌号{v.plate}
+
VIN/车架号{v.vin}
+ {v.fleetCode &&
车辆编号{v.fleetCode}
} +
运营城市{v.city}
+
所属公司{v.ownCompany}
+
车辆等级{v.grade}级
+
归属{v.own === "self" ? "自有" : "外租"}
+
停车场{v.parking}
+
资产状态 + {v.asset === "leasing" ? "租赁" : v.asset === "abnormal" ? "异常" : "在库"} · {v.statusDays}天 +
+
营运状态 + {v.op === "operating" ? "运营中" : v.op === "suspended" ? "停运" : "待整备"} +
+
+
+
+ + {/* 业务关系 */} +
+
+ + 业务关系 +
+
+
+
业务部门 + + + {v.deptName} + +
+
业务负责人{v.deptLead}
+
客户全名{v.customer}
+ {v.own === "lease" &&
租赁公司{v.company}
} + {v.contractNo && <> +
合同编号{v.contractNo}
+
交车里程{v.handoverKm?.toLocaleString()} km
+ {v.returnKm != null &&
还车里程{v.returnKm.toLocaleString()} km
} + } +
+
+ + +
+
+
+ + {/* 氢电系统 */} +
+
+ + 氢电系统 + FCEV +
+
+
+
+ +
电池 SOC
+
+
+ +
H₂ 储量
+
+
+
+
电堆功率28.4 kW
+
电池电压386 V
+
电堆温度76°C
+
H₂压力{v.h2} MPa
+
续航估算{v.range} km
+
电池温度32°C
+
+
+
+ + {/* Speed/RPM curve */} +
+
+ + 速度 / 电机转速 · 近1小时 +
+ 1H + 4H + 1D +
+
+
+
+
+ 速度 km/h + 电机RPM ÷100 +
+
avg 52 / max 89 km/h
+
+ +
+ v*1.1)} w={520} h={60} color="var(--accent)" fill={false}/> +
+
+
+ + {/* Tire pressure */} +
+
胎压 / 温度
+
+ + + + + {[ + {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)=>( + + ))} + +
+ {[ + {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)=>( +
+
{t.p}
+
{t.v}
+
{t.t}
+
+ ))} +
+
+
+ + {/* 保养与维护 */} +
+
+ + 保养与维护 + + + 剩余 {v.kmToMaint.toLocaleString()} km + + +
+
+
+ 保养周期 10,000 km + 已行 {(10000 - v.kmToMaint).toLocaleString()} / 10,000 km +
+
+ +
+
+
+
上次保养
+
+
日期{v.lastMaintDays}天前
+
里程{v.lastMaintKm.toLocaleString()} km
+
项目常规保养·机油机滤
+
技师李工
+
+
+
+
下次保养预约
+
+
里程节点{v.nextMaintKm.toLocaleString()} km
+
距离{v.kmToMaint.toLocaleString()} km
+
推荐站点羚牛 · 嘉兴服务站
+
通知{v.deptLead} · {v.deptName}
+
+
+
+
+
+ + {/* DTC list */} +
+
故障码 · DTC{v.asset === "abnormal" ? "2 active" : "0 active"}
+
+ {(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)=>( +
+
+ {d.c} + {d.n} +
+
+ {d.st === "warn" ? "ACTIVE" : "CLEAR"} + {d.t} +
+
+ ))} +
+
+ {/* Data source / signal channels */} +
+
+ + 数据源 · 信号通道 +
+ 双源在线 + 最近上行 · 218ms +
+
+
+ {[ + { + 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)=>( +
+
+
+ + {s.title} +
+ {s.up} +
+
{s.spec}
+
{s.sub}
+
+ {s.signals.map((sig,j)=>( +
+ {sig.n} + {sig.c} +
+ ))} +
+
+
+ 通道完好率 + 99 ? "var(--ok)" : s.health > 95 ? "var(--info)" : "var(--warn)"}}>{s.health}% +
+
+
99 ? "var(--ok)" : s.health > 95 ? "var(--info)" : "var(--warn)"}}/> +
+
+
+ ))} +
+
+
+
+
+ ); +}; +window.ArtboardDetail = ArtboardDetail; diff --git a/artboards/esg.jsx b/artboards/esg.jsx new file mode 100644 index 0000000..8849f52 --- /dev/null +++ b/artboards/esg.jsx @@ -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 ( + + + + + + + + {/* sea-line decoration */} + + {provs.map((p,i) => ( + + + + ))} + {/* highlighted city marker — Beijing */} + + + + + + {/* Shanghai */} + + + + {/* Guangzhou */} + + + + {/* Compass / scale */} + + + + + 800 km + + + ); +}; + +// 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 ( + + {fill && } + + + ); +}; + +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 ( + + {/* y gridlines */} + {[0, 0.25, 0.5, 0.75, 1].map((p,i) => ( + + ))} + {data.map((v,i) => { + const bh = (v / max) * (h * 0.85); + const x = i * (bw + gap) + gap/2; + const y = h - bh - 14; + return ( + + + {labels && {labels[i]}} + + ); + })} + + ); +}; + +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 ( + + + {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 ( + + ); + })} + 合计 + {label} + + ); +}; + +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 ( +
+ +
+ {/* Top brand bar */} +
+
+ 羚牛氢能 Lingniu +
+ HYDROGEN
MOBILITY +
+
+
+ Lingniu ESG Link +
+
2026-04-28 周二 12:15:13
+
+ +
+
+ + {/* Body grid */} +
+ + {/* ── LEFT COLUMN ── */} +
+ {/* Two top KPIs: emissions & H₂ */} +
+
+
+
+
当日减碳量
+
29486.78kg
+
+ + + + +
+
+
+
+
+
当日H₂用量
+
974.7kg
+
+ + + + + + + +
+
+
+ + {/* Annual cumulative reduction — hero card */} +
+
今年累计减碳
+
+ 4567.14 + +
+
+ + 相当于种植 18.5 万棵树 +
+ {/* abstract tree silhouette */} + + {[...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 ( + + + + + ); + })} + +
+ + {/* Monthly reduction bars */} +
+
+ 月度碳减排 + 单位 · 吨 +
+ +
+ + {/* Monthly mileage / H2 */} +
+
+ 月度行驶里程 & 用氢量 +
+
+ 用氢量 + 行驶里程 + kg / km +
+ + {[0, 0.25, 0.5, 0.75, 1].map((p,i) => ( + + ))} + {[0, 0.25, 0.5, 0.75, 1].map((p,i) => ( + {Math.round(p*400)} + ))} + {/* 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 ( + <> + + + + + ); + })()} + {monthLabels.map((l,i) => ( + {l} + ))} + +
+
+ + {/* ── CENTER COLUMN ── */} +
+ {/* Map panel */} +
+
+
+ 羚牛全国车辆信息 + 加氢站 +
+ 实时反馈 +
+ +
+ + {/* Overlay info card */} +
+
呼和浩特市钢铁工业园区
+
+
GPS实时数
17
+
当日总减碳
2469.62 kg
+
当日加氢量
9.31 kg
+
当日里程
724.6 kg
+
+
+ {/* Legend */} +
+
车辆数
+ {[ + {l:"≥ 300 辆", c:"#1F8B4C"}, + {l:"100–300 辆", c:"#4FB46E"}, + {l:"50–100 辆", c:"#9DD3A6"}, + {l:"< 50 辆", c:"#D7EBD2"}, + ].map((x,i) => ( +
+ + {x.l} +
+ ))} +
+
+
+ + {/* Carbon trades table */} +
+
+ 碳交易行情 + 实时报价 +
+ + + + + + {trades.map((t,i) => ( + + + + + + + ))} + +
交易所项目价格 (RMB)地区
{t.ex}{t.item}{t.price}{t.region}
+
+
+ + {/* ── RIGHT COLUMN ── */} +
+ {/* Two top KPIs: vehicle total & cumulative mileage */} +
+
+
+
+
车辆总数
+
1006
+
+ + + + + + +
+
+
+
+
+
当日行驶里程
+
64508.42km
+
+ + + + + + +
+
+
+ + {/* Vehicle live monitor table */} +
+
+ 车辆实时监控 + · LIVE +
+
+ + + + + + + + {vehicles.map((v,i) => ( + + + + + + + ))} + +
车牌号总里程当日里程当日减碳
{v.p}{v.km}{v.h2}{v.reduction}
+
+
+ + {/* Fleet mix donut */} +
+
+ 车型结构分析 +
+
+ +
+ {fleetMix.map((f,i) => ( +
+ + + {f.n} + + {f.v}% +
+ ))} +
+
+
+
+
+ + {/* Footer */} +
+ © 2026 羚牛氢能 · Lingniu Hydrogen Mobility · All Rights Reserved + · API 接口处理 · Build v4.2.0-stable +
+
+
+ ); +}; +window.ArtboardESG = ArtboardESG; diff --git a/artboards/history.jsx b/artboards/history.jsx new file mode 100644 index 0000000..fd86497 --- /dev/null +++ b/artboards/history.jsx @@ -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 ( +
+ +
+ + + {/* Step indicator */} +
+ {[ + {n:1, l:"选择范围"}, + {n:2, l:"选择数据项目"}, + {n:3, l:"选择展示方式"}, + ].map((s,i,arr)=>( + +
= s.n ? 1 : 0.5}} onClick={() => setStep(s.n)}> + = 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} + {s.l} +
+ {i < arr.length - 1 && } +
+ ))} + +
+ 已选 {picked.size} + + + + +
+
+ + {/* Main */} +
+ + {/* LEFT: range + data items selector */} +
+ + {/* Vehicle + time */} +
+
查询对象
+
+ +
+
{vehicle.plate}
+
{vehicle.vin} · {vehicle.dept}
+
+ +
+ +
时间范围
+
+ {QUICK_RANGES.map(r => ( + setRange(r.id)} + className={"chip " + (range === r.id ? "accent" : "")} + style={{justifyContent:"center", cursor:"pointer", fontSize:10, padding:"4px 0"}}> + {r.l} + + ))} +
+
+
起 → 止
+
{dateFrom}
+
{dateTo}
+
+
+ + {/* Data item picker */} +
+ + 数据项目 + 已选 {picked.size} +
+ +
+ {DATA_GROUPS.map(g => ( + setActiveGroup(g.id)} + className={"chip " + (activeGroup === g.id ? "accent" : "")} + style={{cursor:"pointer", fontSize:10, padding:"3px 8px"}}> + {g.label} + + {g.items.filter(it => picked.has(it.id)).length || g.items.length} + + + ))} +
+ +
+ {DATA_GROUPS.filter(g => g.id === activeGroup).map(g => ( +
+ {g.items.map(it => { + const on = picked.has(it.id); + return ( +
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", + }}> + + {on && } + +
+
+ {it.l} + {it.u} +
+
+ {it.src} + · {it.freq} +
+
+
+ ); + })} +
+ ))} +
+ +
+ + +
+
+ + {/* RIGHT: visualization */} +
+ + {/* View-mode picker */} +
+ 展示 + {VIEW_MODES.map(v => ( + 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)", + }}> + + {v.l} + + ))} +
+ 采样: + 原始 + 1分钟 + 5分钟 + 1小时 +
+
+ + {/* Selected items pills */} +
+ {pickedItems.length === 0 && 请从左侧选择数据项} + {pickedItems.map(it => ( + + + {it.l} + {it.u} + togglePick(it.id)}>× + + ))} +
+ + {/* Render area */} +
+ {view === "line" && } + {view === "area" && } + {view === "bar" && } + {view === "table" && } + {view === "heat" && } + {view === "summary" && } +
+
+
+
+
+ ); +}; + +// ── 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 { + if (!items.length) return ; + return ( +
+ {items.map(it => ( +
+
+ + {it.l} + {it.u} +
+ {it.src} · {it.freq} + CSV + +
+
+
+ + +
+
+ ))} +
+ ); +}; + +const AreaView = ({ items }) => { + if (!items.length) return ; + return ( +
+
+ + {items.length} 项叠加 +
+ {items.map(it => ( + + + {it.l} + + ))} +
+
+
+ + {[0,0.25,0.5,0.75,1].map((p,i) => ( + + ))} + {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 ( + + + + + ); + })} + + +
+
+ ); +}; + +const BarView = ({ items }) => { + if (!items.length) return ; + const buckets = ["00","02","04","06","08","10","12","14","16","18","20","22"]; + return ( +
+ {items.map(it => { + const data = synth(it.id, buckets.length).map(v => v * 100); + return ( +
+
+ + {it.l} · 按 2 小时聚合 +
{it.u}
+
+
+ + {data.map((v,i) => { + const x = i * (720/data.length) + 8; + const w = (720/data.length) - 16; + const h = (v/100) * 100; + return ( + + + {buckets[i]} + + ); + })} + +
+
+ ); + })} +
+ ); +}; + +const TableView = ({ items }) => { + if (!items.length) return ; + // 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 ( +
+
+ + 数据列表 + 共 1,728 行 · 显示 40 +
+ 筛选 + CSV +
+
+
+ + + + + {items.map(it => ( + + ))} + + + + {rows.map((r,i) => ( + + + {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 ; + })} + + ))} + +
时间戳 +
{it.l}
+
{it.u} · {it.src}
+
{r.ts}{display}
+
+
+ ); +}; + +const HeatView = ({ items }) => { + if (!items.length) return ; + return ( +
+ {items.map(it => { + const cells = synth(it.id, 30); // 30 days + return ( +
+
+ + {it.l} · 30 日热力 +
{it.u}
+
+
+
+ {cells.map((v,i) => ( +
+ ))} +
+
+ 30 日前今日 +
+
+
+ ); + })} +
+ ); +}; + +const SummaryView = ({ items }) => { + if (!items.length) return ; + return ( +
+ {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 ( +
+
+
+
{it.l}
+
{it.u} · {it.src}
+
+ +
+
+ {[ + {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) => ( +
+
{s.l}
+
{s.v}
+
+ ))} +
+
+ +
+
+ ); + })} +
+ ); +}; + +// 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 ( + + {axis && [0,0.25,0.5,0.75,1].map((p,i) => ( + + ))} + {fill && } + + + ); +}; + +const TimeAxis = () => ( +
+ {["00:00","04:00","08:00","12:00","16:00","20:00","24:00"].map(t => {t})} +
+); + +const EmptyHint = () => ( +
+ +
从左侧选择数据项目以开始检索
+
支持速度 / SOC / H₂ 压力 / 胎压 / 驾驶行为等 30+ 字段
+
+); + +window.ArtboardHistory = ArtboardHistory; diff --git a/artboards/inbox.jsx b/artboards/inbox.jsx new file mode 100644 index 0000000..0146a1c --- /dev/null +++ b/artboards/inbox.jsx @@ -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 ( +
+ +
+ +
+
+ {/* Filter chips */} +
+ 全部 · 24 + 未处理 · 3 + P0 · 2 + P1 · 8 + P2 · 14 + + 今日 +
+ + +
+
+ + {/* Hourly distribution */} +
+
+ 24小时告警分布 + 峰值 14:00-15:00 +
+ +
+ + {/* Timeline list */} +
+ {alerts.map((a,i)=>{ + const c = a.p === "P0" ? "var(--danger)" : a.p === "P1" ? "var(--warn)" : "var(--info)"; + return ( +
+
+
+ +
+ {i < alerts.length-1 && } +
+
+
+
+ {a.p} + {a.n} + · {a.v} + {a.st==="new" && } + {a.st==="ack" && 已确认} + {a.st==="resolved" && 已恢复} +
+ {a.t} +
+
{a.det}
+ {a.st === "new" && ( +
+ + + + + 规则 · {a.n} +
+ )} +
+
+ ); + })} +
+
+ + {/* Right: stats */} +
+
+ 告警概览 +
+
+
+ {[ + {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)=>( +
+
{k.l}
+
{k.v}
+
+ ))} +
+ +
Top 5 告警类型 · 7日
+
+ {[ + {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)=>( +
+ {t.l} +
+ {t.v} +
+ ))} +
+ +
Top 5 告警车辆
+
+ {[ + {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)=>( +
+ {t.n} + {t.v} 次 +
+ ))} +
+
+
+
+
+
+ ); +}; + +window.ArtboardInbox = ArtboardInbox; diff --git a/artboards/mobile.jsx b/artboards/mobile.jsx new file mode 100644 index 0000000..12ca3f3 --- /dev/null +++ b/artboards/mobile.jsx @@ -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 }) => ( +
+ +
+
{title}
+ {subtitle &&
{subtitle}
} +
+ {right} +
+); + +const MIconBtn = ({ icon, badge, onClick }) => ( + +); + +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 ( +
+ {tabs.map(t => ( + + ))} +
+ ); +}; + +// ── Mobile shell wrapper ─────────────────────────────────── +const MobileShell = ({ title, subtitle, right, children, hideTabBar }) => { + const ctx = window.useRoute(); + return ( +
+ +
+ {children} +
+ {!hideTabBar && } +
+ ); +}; + +// ── 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 ( + window.useRoute().navigate("inbox")}/>} + > + {/* Map fills, sheet floats */} +
+ +
+ + {/* KPI strip floating on map */} +
+ {[ + { 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) => ( +
+
{k.l}
+
{k.v}
+
+ ))} +
+ + {/* Floating action: locate */} + + + {/* Bottom sheet */} +
+ {/* Drag handle + selected vehicle quick card */} +
setSheetOpen(s => !s)} style={{ padding: "8px 16px 6px", cursor: "pointer" }}> +
+
+
+ + {v.id} + + {v.deptName} +
+ {v.soc}% +
+
+ + {!sheetOpen ? ( + // Mini quick stats when collapsed +
+
速度{v.speed} km/h
+
续航{Math.round((v.soc||0)*6.2)} km
+
温度{v.status==="danger"?"102":"68"}°C
+ +
+ ) : ( + // Full list when expanded + <> +
+ {[ + {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 => ( + + ))} +
+
+ {filtered.map(x => ( +
{ 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", + }}> + +
+
+ {x.id} + +
+
{x.deptName} · {x.speed} km/h
+
+
+
{x.soc}%
+
+
+
+
+
+ ))} +
+ + )} +
+ + ); +}; + +// ── 2. Mobile Detail ──────────────────────────────────────── +const MobileDetail = () => { + const vehicles = window.VEHICLES || []; + const v = vehicles.find(x => x.id === "浙F03980F") || vehicles[0] || {}; + return ( + }> +
+ {/* Hero status card */} +
+
+
+ + + {v.asset === "leasing" ? "租赁" : v.asset === "abnormal" ? "异常" : "在库 · 运营中"} + + {v.own === "self" ? "自有" : "外租"} +
+ +
+
+ + + + + + +
+
+ + +
+
+
业务部门 + {v.deptName} +
+
业务负责人{v.deptLead}
+
客户{v.customer}
+
所属公司{v.ownCompany}
+ {v.contractNo &&
合同{v.contractNo}
} +
+
+
+ + +
+ + + + +
+
+ + +
+ {[ + { 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) => ( +
+
{tire.l}{tire.warn && 低压}
+
{tire.p} MPa
+
{tire.t}°C
+
+ ))} +
+
+ + +
+
+ 距下次保养 + {v.kmToMaint.toLocaleString()} km +
+
+ +
+
上次保养 {v.lastMaintDays} 天前 · {v.lastMaintKm.toLocaleString()} km
+
+
+ + +
+ {[ + { l: "TBOX (3296/2016)", st: "ok", info: "5s 上报 · 信号 -68dBm" }, + { l: "JT808 部标", st: "ok", info: "实时 · 北京·朝阳" }, + { l: "JT1078 视频", st: "ok", info: "4 路 · 720P" }, + ].map((c, i) => ( +
+
+
{c.l}
+
{c.info}
+
+ +
+ ))} +
+
+ +
+ + +
+
+
+ ); +}; + +const MStat = ({ label, value, unit, color, big }) => ( +
+
{label}
+
+ {value}{unit} +
+
+); + +const MMini = ({ label, value, sub }) => ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+); + +const MSection = ({ title, children, action }) => ( +
+
+ {title} + {action} +
+ {children} +
+); + +// ── 3. Mobile History ─────────────────────────────────────── +const MobileHistory = () => { + const [showFilter, setShowFilter] = React.useState(false); + const trips = [ + { d: "04-28", t: "14:02–14:44", v: "浙F07179F", k: "32.4 km", h: "0.84 kg", st: "ok" }, + { d: "04-28", t: "10:11–11:03", v: "浙F07179F", k: "48.2 km", h: "1.21 kg", st: "ok" }, + { d: "04-28", t: "08:30–09:18", v: "浙F08638F", k: "29.8 km", h: "0.76 kg", st: "warn" }, + { d: "04-27", t: "17:42–18:25", v: "浙F07179F", k: "36.1 km", h: "0.92 kg", st: "ok" }, + { d: "04-27", t: "14:08–15:01", v: "浙F02002F", k: "44.5 km", h: "1.13 kg", st: "danger" }, + { d: "04-27", t: "09:22–10:14", v: "浙F07179F", k: "39.7 km", h: "1.01 kg", st: "ok" }, + ]; + return ( + setShowFilter(s=>!s)}/>}> +
+ {/* Search */} +
+
+ + +
+
+ + {/* Filter chips - horizontal scroll */} +
+ 近 7 日 + 编组A + 全部车型 + 有告警 +
+ + {/* KPI summary */} +
+ {[ + { 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) => ( +
+
{k.l}
+
{k.v} {k.u}
+
+ ))} +
+ + {/* Trip list */} +
+ {trips.map((t, i) => { + const cls = t.st === "danger" ? "danger" : t.st === "warn" ? "warn" : "ok"; + return ( +
+
+
+ {t.v} + {t.st === "danger" ? "故障" : t.st === "warn" ? "告警" : "正常"} +
+ {t.d} {t.t} +
+
+
里程 {t.k}
+
氢耗 {t.h}
+ +
+
+ ); + })} +
+
+
+ ); +}; + +// ── 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 ( + +
+ {/* Map area */} +
+ + {/* Floating speed badge */} +
+
当前速度
+
{Math.round(40 + Math.sin(t/8)*20)} km/h
+
+
+
SOC
+
{Math.round(78 - t * 0.15)}%
+
+
+ + {/* Bottom playback panel */} +
+ {/* Time + scrub */} +
+ 14:{String(Math.floor(t * 0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")} + 已 {Math.round(t*0.42)} 分 / 共 42 分 +
+ setT(+e.target.value)} style={{ + width: "100%", height: 4, accentColor: "var(--accent)", marginBottom: 12, + }}/> + + {/* Controls row */} +
+ + + + + +
+ + {/* Mini chart timeline events */} +
+ {[12, 38, 65, 88].map((p, i) => ( +
+ ))} +
+
+
+ 事件 + 急刹×1 · 超速×2 · 故障×1 +
+
+
+ + ); +}; + +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 ( + r.st==="on").length} / ${rules.length} 启用`} right={}> +
+
+ {[ + { l: "P0 紧急", v: 4, c: "var(--danger)" }, + { l: "P1 警告", v: 2, c: "var(--warn)" }, + { l: "P2 提示", v: 1, c: "var(--info)" }, + ].map((k, i) => ( +
+
{k.l}
+
{k.v}
+
+ ))} +
+ + {rules.map((r, i) => ( +
+
+
+ {r.p} + {r.n} +
+ +
+
{r.cond}
+
+ 7日触发 {r.trig} + 短信 · App推送 · 邮件 +
+
+ ))} +
+
+ ); +}; + +const MSwitch = ({ on }) => ( +
+
+
+); + +// ── 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 ( + +
+
+ {[ + {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 => ( + + ))} +
+
+ {filtered.map((a, i) => { + const c = a.p === "P0" ? "var(--danger)" : a.p === "P1" ? "var(--warn)" : "var(--info)"; + return ( +
+
+
+ +
+
+
+
+
+ {a.p} + {a.n} +
+ {a.t} +
+
{a.v}
+
{a.det}
+ {a.st === "new" && ( +
+ + + +
+ )} +
+
+ ); + })} +
+
+
+ ); +}; + +// ── 7. Mobile ESG ──────────────────────────────────────────── +const MobileESG = () => { + return ( + +
+ {/* Hero stat */} +
+
本年度累计减碳
+
1,847.2tCO₂e
+
较去年同期 ▲ 32.4%
+
+ +
+ {[ + { 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) => ( +
+
{k.l}
+
{k.v}{k.u}
+
+ ))} +
+ + +
+ + {[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 ( + + + {i%3===0 && {i+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) => ( +
+ + #{i+1} + {r.p} + + {r.v} kg +
+ ))} +
+
+
+
+ ); +}; + +// ── 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 ( + +
+ +
设计画板模式
+
请在桌面端访问以查看完整设计稿
+ +
+
+ ); + } + return ; +}; + +window.MobileRouter = MobileRouter; +window.MobileShell = MobileShell; diff --git a/artboards/overview.jsx b/artboards/overview.jsx new file mode 100644 index 0000000..04ab62a --- /dev/null +++ b/artboards/overview.jsx @@ -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 ( + + {m.label} + + ); +}; + +const OwnChip = ({ own }) => ( + {own === "self" ? "自有" : "外租"} +); + +const DeptDot = ({ dept }) => { + const d = (window.DEPARTMENTS || []).find(x => x.id === dept); + if (!d) return null; + return ( + + + {d.name} + + ); +}; + +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 ( +
+ +
+ 0 ? "+" + scopedCounts.abnormal : "0", deltaUp:false }, + ]} + /> + {isDeptScoped && ( +
+ + 数据权限:当前以 {role.name} 身份登录,仅可见本部门 {scopedCounts.all} 辆车 · 全公司共 {counts.all} 辆 + 切换身份请使用右下角 Tweaks · 登录身份 +
+ )} +
+ {/* Left: fleet list with asset filters */} +
+
+
+ 车辆 · {filtered.length}/{scopedCounts.all} + 高级 +
+
+ + setSearch(e.target.value)}/> +
+ + {/* Asset status filter */} +
+
资产状态
+
+ {[ + {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 => ( + setFilterAsset(o.k)}> + {o.l} {o.c} + + ))} +
+
+ + {/* Ownership */} +
+
归属
+
+ {[ + {k:"all", l:"全部"}, + {k:"self", l:"自有", c: scopedCounts.self}, + {k:"lease", l:"外租", c: scopedCounts.lease}, + ].map(o => ( + setFilterOwn(o.k)}> + {o.l}{o.c != null && {o.c}} + + ))} +
+
+ + {/* Department — hidden when role is dept-scoped (only one dept visible) */} + {!isDeptScoped && ( +
+
业务部门
+
+ setFilterDept("all")}>全部 + {deps.map(d => ( + setFilterDept(d.id)}> + + {d.name} + {counts.byDept?.[d.id] || 0} + + ))} +
+
+ )} +
+ +
+ {filtered.map(x => ( +
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)" + }}> +
+ {x.plate} + +
+
{x.vin}
+
+ + + {x.gps === "offline" && ● GPS离线} +
+
+ + {x.city} + {x.customer !== "—" && (<>·{x.customer})} +
+
+ ))} + {filtered.length === 0 && ( +
没有匹配的车辆
+ )} +
+
+ + {/* Map center */} +
+ setSelected(x.id)} /> +
+ {["layers","plus","close","sat","pin"].map((n,i)=>( +
+ +
+ ))} +
+ {/* Legend by asset */} +
+ 在库/正常 + 租赁 + 待整备 + 异常 + GPS离线 +
+
+ LIVE + | + 嘉兴市·平湖 + | + 14:32:08 +
+
+ + {/* Right: vehicle asset detail panel */} +
+
+
+
+
{v.deptName} · {v.deptLead}
+
{v.plate}
+
{v.vin}
+
+
+ + +
+
+
+ 车辆等级 + {v.grade}级 + · + 状态时长 + {v.statusDays}天 + {v.fleetCode && <> + · + 编号 + {v.fleetCode} + } +
+
+ +
+ {/* 业务关系 */} +
+
业务关系
+
+
业务部门 + {v.deptName} +
+
业务负责人{v.deptLead}
+
客户{v.customer}
+
所属公司{v.ownCompany}
+ {v.own === "lease" &&
租赁公司{v.company}
} + {v.contractNo &&
合同编号{v.contractNo}
} +
+
+ + {/* 实时车况 — 财务身份不可见 */} + {role && role.scope === "finance" ? ( +
+
实时车况
+
+
🔒
+
实时车况数据已隐藏
+
财务身份仅可见资产 · 业务关系 · 合同
+
+
+ ) : ( +
+
+ 实时车况 + + GPS{v.gps === "online" ? "在线" : "离线"} + +
+
+ + +
+
+
+ 氢气压力 + {v.h2} MPa +
+
+ 续航 + {v.range} km +
+
+ 电机温度 + 90 ? "var(--danger)" : "var(--fg-0)"}}>{v.motorTemp}°C +
+
+ 停车场 + {v.parking} +
+
+
+ )} + + {/* 里程 & 保养 */} +
+
里程与保养
+
+
累计里程{v.totalKm.toLocaleString()} km
+
上次保养{v.lastMaintDays}天前 · {v.lastMaintKm.toLocaleString()}km
+
+
+ 下次保养 + + 剩余 {v.kmToMaint.toLocaleString()} km + +
+
+ +
+
+ {v.handoverKm != null && ( +
交车里程{v.handoverKm.toLocaleString()} km
+ )} + {v.returnKm != null && ( +
还车里程{v.returnKm.toLocaleString()} km
+ )} +
+
+ + {v.asset === "abnormal" && ( +
+
异常处理
+
+
+ 资产状态异常 + {v.statusDays}天 +
+
停车场标记为异常 · 待业务部门核查
+
+
+ )} + +
+ + + +
+
+
+
+
+
+ ); +}; + +window.ArtboardOverview = ArtboardOverview; diff --git a/artboards/playback.jsx b/artboards/playback.jsx new file mode 100644 index 0000000..12c6c8c --- /dev/null +++ b/artboards/playback.jsx @@ -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 ( +
+ {/* Month nav */} +
+ + {viewMonth.y} 年 {monthName} + +
+ {/* Weekdays */} +
+ {["日","一","二","三","四","五","六"].map(w => ( +
{w}
+ ))} +
+ {/* Day cells */} +
+ {cells.map((d, i) => { + if (!d) return
; + 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 ( +
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")}> + {d} + {trips > 0 && !isSel && ( + + )} + {trips > 0 && isSel && ( + {trips} + )} +
+ ); + })} +
+ {/* Footer legend */} +
+ 行程频次 +
+ + + +
+
+
+ ); +}; + +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 ( +
+ +
+ + + {/* Date / time-range selector bar */} +
+
+ + 日期 +
+ + {/* Date pill with calendar dropdown */} +
+ + {calOpen && ( + { setDateStr(d); setCalOpen(false); }} onClose={() => setCalOpen(false)}/> + )} +
+ + {/* Quick-range presets */} +
+ {[ + {l:"今日", v:"today"}, + {l:"昨日", v:"yesterday"}, + {l:"近7日", v:"7d"}, + {l:"近30日",v:"30d"}, + {l:"自定义",v:"custom"}, + ].map((p,i) => ( + { + 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} + ))} +
+ + + + {/* Time range */} +
+ 时段 +
+ 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"}}/> + + 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"}}/> +
+
+ + {/* Trip count badge */} + + 当日行程 + {trips.length} + + +
+ + +
+
+ + {/* Toolbar */} +
+ 浙F07179F × + + 添加车辆对比 + + 显示 + 轨迹 + 事件 + 热力 + 停留点 +
+ + +
+
+ +
+
+ {/* Map */} +
+ + {/* Event markers on map */} + + {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 ( + + + + + ); + })} + + {/* Live readout */} +
+
时间
14:{String(Math.floor(t*0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")}
+
速度
{Math.floor(40 + Math.sin(t*0.1)*30)} km/h
+
SOC
{Math.floor(78 - t*0.18)}%
+
H₂
{(4.2 - t*0.012).toFixed(2)} MPa
+
累计
{(t*0.32).toFixed(1)} km
+
+
+ + {/* Player + multi-curve */} +
+ {/* Synced data curves */} +
+
+ 速度 km/h + SOC % + H₂压力 MPa +
+
+
+
+
v*15)} w={920} h={80} color="var(--warn)" fill={false}/>
+ {/* playhead */} +
+
+
+ + {/* Timeline + controls */} +
+
+ {/* event markers */} + {events.map((e,i)=>( +
+ ))} + {/* progress fill */} +
+ {/* playhead */} +
+
+ {/* time labels */} +
+ {["14:02","14:08","14:15","14:23","14:30","14:36","14:44"].map(x=>{x})} +
+
+
+
+ + + + 14:{String(Math.floor(t*0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")} / 14:44:00 +
+
+ 倍速 + {[0.5, 1, 2, 4, 8, 16].map(s=>( + setSpeed(s)}>{s}× + ))} +
+
+
+
+
+ + {/* Side: trips + events + summary */} +
+ {/* Trips of selected day */} +
+
+ + 当日行程 + {trips.length} +
+
+ {trips.map((tr,i)=>( +
+
+
+ {tr.from} → {tr.to} + {tr.km} km +
+
+ {tr.route} + {tr.evt > 0 && {tr.evt}事件} +
+
+
+ ))} +
+
+ + {/* Events */} +
+ + 事件时间线 + {events.length} +
+
+ {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 ( +
setT(e.at)}> +
+ + {i < events.length - 1 && } +
+
+
+ {e.lbl} + 14:{String(Math.floor(e.at*0.42 + 2)).padStart(2,"0")} +
+
+ {e.type==="warn" ? "速度 +18 km/h · 持续 2.4s" : + e.type==="stop" ? "停留 1分12秒" : + e.type==="start" ? "里程 0 km" : "里程 32.4 km · 平均 49 km/h"} +
+
+
+ ); + })} +
+
+
本次行程
+
+
里程32.4 km
+
时长42 分钟
+
平均速度46 km/h
+
最高速度89 km/h
+
能耗5.8 kWh / 0.32 kg H₂
+
评分87 / 100
+
+
+
+
+
+
+ ); +}; +window.ArtboardPlayback = ArtboardPlayback; diff --git a/artboards/variant-dense.jsx b/artboards/variant-dense.jsx new file mode 100644 index 0000000..b69bc5c --- /dev/null +++ b/artboards/variant-dense.jsx @@ -0,0 +1,132 @@ +// artboard-dense.jsx — dense info variation: 4-column with mini-map +const ArtboardDense = () => { + return ( +
+ +
+ + +
+ {/* 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)=>( +
+
+ {k.l} + +
+
+ {k.v} + {k.u} +
+
+ +
+
+ ))} + + {/* Map spans 2 cols 2 rows */} +
+
+ 实时分布 +
+ 热力车辆 +
+
+
+ {}} showHeatmap/> +
+
+ + {/* Status donut */} +
+
车辆状态
+
+ +
+
行驶312
+
待命155
+
故障8
+
离线25
+
维保12
+
+
+
+ + {/* Alerts feed */} +
+
实时告警3 NEW
+
+ {[ + {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)=>( +
+
+ {a.p} + {a.n} + · {a.v} +
+ {a.t} +
+ ))} +
+
+ + {/* Energy chart */} +
+
能耗 · 24h
+
+ +
+ 总计 + 4,562 kWh +
+
+
+ + {/* H2 stations */} +
+
补能站
+
+ {[ + {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)=>( +
+
+ {s.k} + {Math.round(s.v*100)}% +
+
+ +
+
+ ))} +
+
+
+
+
+ ); +}; +window.ArtboardDense = ArtboardDense; diff --git a/artboards/variant-light.jsx b/artboards/variant-light.jsx new file mode 100644 index 0000000..7cbc32f --- /dev/null +++ b/artboards/variant-light.jsx @@ -0,0 +1,55 @@ +// artboard-variant-light.jsx — light theme variation of overview +const ArtboardLightVariant = () => { + return ( +
+ +
+ +
+
+ {}} variant="minimal"/> +
+
+
当前选中
+
浙F08638F
+
孙超 · 状态告警
+
+ {[ + {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)=>( +
+
{k.l}
+
{k.v}{k.u}
+
+ ))} +
+
+
+
+
+ ); +}; +window.ArtboardLightVariant = ArtboardLightVariant; diff --git a/assets/logo_light.svg b/assets/logo_light.svg new file mode 100644 index 0000000..626c3d8 --- /dev/null +++ b/assets/logo_light.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/charts.jsx b/components/charts.jsx new file mode 100644 index 0000000..017aede --- /dev/null +++ b/components/charts.jsx @@ -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 ( + + + + + + + + {axis && ( + <> + + {[0.25, 0.5, 0.75].map(p => ( + + ))} + + )} + {baseline != null && ( + + )} + {fill && } + + {showDots && pts.map((p, i) => ( + + ))} + + ); +}; + +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 ( + + {data.map((v, i) => { + const bh = (h - 14) * (v / max); + return ( + + + {labels && {labels[i]}} + + ); + })} + + ); +}; + +// 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 ( + + + + {label && {label}} + + ); +}; + +// 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 ( + + + + {label} + {sub && {sub}} + + ); +}; + +// 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 ( + + {segs.map((s, i) => { + const sw = w * (s.v/total); + const r = ; + x += sw; + return r; + })} + + ); +}; + +// 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; diff --git a/components/chrome.jsx b/components/chrome.jsx new file mode 100644 index 0000000..d7dcfb1 --- /dev/null +++ b/components/chrome.jsx @@ -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 ( +
+
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, + }}> + 羚牛 +
+ {SIDEBAR_ITEMS.map(i => ( +
nav(i.id)} + style={{cursor:"pointer"}}> + +
+ ))} +
+ {SIDEBAR_SUB.map(i => ( +
nav(i.id)} + style={{cursor:"pointer"}}> + +
+ ))} +
+
ZG
+
+ ); +}; + +const Topbar = ({ crumbs = ["羚牛车辆数据中心", "实时监控"], kpis = [], showSearch = true }) => { + const ctx = (typeof window.useRoute === "function") ? window.useRoute() : null; + const isMobile = ctx && ctx.isMobile; + return ( +
+ {isMobile && ( + + )} +
+ {crumbs.map((c, i) => ( + + {i > 0 && /} + {c} + + ))} +
+ +
+ {kpis.map((k, i) => ( +
+ {k.lbl} + {k.val} + {k.delta && {k.deltaUp ? "▲" : "▼"} {k.delta}} +
+ ))} +
+ + {showSearch && ( +
+ + + ⌘K +
+ )} + +
+
+
+ + +
+ +
+ +
+
+ ); +}; + +// 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
; + 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 ( +
+
+ {role.name} + + {role.scope === "all" ? "FULL ACCESS" : role.scope === "dept" ? "DEPT SCOPE" : role.scope === "ops" ? "OPS" : "FINANCE"} + +
+
{initial}
+
+ ); +}; + +// 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 ( + + ); +}; + +window.Sidebar = Sidebar; +window.Topbar = Topbar; \ No newline at end of file diff --git a/components/icons.jsx b/components/icons.jsx new file mode 100644 index 0000000..a3c8fa8 --- /dev/null +++ b/components/icons.jsx @@ -0,0 +1,74 @@ +// icons.jsx — tiny stroke icon set for the cockpit +const Icon = ({ name, size = 16, className = "", style = {} }) => { + const paths = { + map: <>, + car: <>, + history: <>, + route: <>, + bell: <>, + inbox: <>, + settings: <>, + bolt: <>, + fuel: <>, + h2: <>, + gauge: <>, + thermo: <>, + tire: <>, + play: <>, + pause: <>, + next: <>, + prev: <>, + chevron: <>, + chevDown: <>, + plus: <>, + close: <>, + search: <>, + filter: <>, + download: <>, + refresh: <>, + pin: <>, + expand: <>, + grip: <>, + layers: <>, + chart: <>, + user: <>, + shield: <>, + flag: <>, + sliders: <>, + edit: <>, + trash: <>, + plug: <>, + wifi: <>, + sat: <>, + lightning: <>, + wrench: <>, + truck: <>, + list: <>, + fullscreen: <>, + timeline: <>, + branch: <>, + bookmark: <>, + moon: <>, + speed: <>, + leaf: <>, + cube: <>, + pulse: <>, + mail: <>, + phone: <>, + clipboard: <>, + x: <>, + sun: <>, + }; + return ( + + ); +}; + +window.Icon = Icon; diff --git a/components/map.jsx b/components/map.jsx new file mode 100644 index 0000000..05c5b2e --- /dev/null +++ b/components/map.jsx @@ -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 N–S (right side). 乍嘉苏 expressway diagonal. +// Inland canals (东湖、独山港河) cross the city. Pinghu 老城 in the upper-middle. +const ROADS_MAJOR = [ + // G15 沈海高速 (N–S, right) + "M 1020 40 L 1020 200 L 1010 380 L 1000 540 L 1000 620", + // 乍嘉苏高速 (NW–SE diagonal) + "M 60 120 L 240 220 L 420 320 L 600 400 L 760 480 L 880 560", + // 海盐塘公路 (E–W arterial through old town) + "M 60 280 L 280 280 L 520 300 L 780 290 L 1140 280", + // 乍浦大道 (E–W, 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 = [ + // 东湖塘 (E–W 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 ( + + {items.map(k => ( + {k} + ))} + + ); +}; +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 ( + onClick && onClick(v)}> + {/* heading cone */} + {showHeading && v.status !== "idle" && ( + + + + )} + {/* pulse ring (only for moving) */} + {animate && v.status === "ok" && v.speed > 0 && ( + + + + + )} + {/* outer halo */} + + {/* core */} + + {selected && } + + ); +}; + +const PoiMarker = ({ poi }) => ( + + + {poi.label} + +); + +const Compass = () => ( + + + + + N + +); + +const ScaleBar = ({ x = 60, y = 720 }) => ( + + + + + + 500m + +); + +// 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 ( + + + + + + + + + + + + + + + + {/* base */} + + + + {/* 杭州湾 — sea */} + {!isMin && ( + + )} + {/* 港池 — port water basins cut into land */} + {!isMin && PORT_BASINS.map((d, i) => ( + + ))} + {/* coastline marker */} + {!isMin && ( + + )} + {/* sea label */} + {!isMin && ( + 杭州湾 · 乍浦港 + )} + + {/* parks */} + {!isMin && PARKS.map((p, i) => ( + + + {p.label && ( + {p.label} + )} + + ))} + + {/* river */} + {!isMin && RIVERS.map((d, i) => ( + + ))} + + {/* piers */} + {!isMin && PIERS.map((d, i) => ( + + ))} + + {/* heatmap layer */} + {showHeatmap && _vehicles.map((v, i) => ( + + ))} + + {/* minor roads */} + {ROADS_MINOR.map((d, i) => ( + + ))} + + {/* major roads casing + center stripe */} + {ROADS_MAJOR.map((d, i) => ( + + + + + ))} + + {/* vignette */} + + + {/* highlighted path */} + {highlightPath && ( + + + + + )} + + {/* recent traces */} + {showPaths && !highlightPath && TRACES.map((t, i) => ( + + ))} + + {/* POIs */} + {!isMin && POIS.map((p, i) => )} + + {/* vehicles */} + {_vehicles.map(v => ( + + ))} + + {/* selected vehicle label */} + {selectedId && _vehicles.filter(v => v.id === selectedId).map(v => ( + + + {v.id} + {v.speed}km/h + + ))} + + {/* playback marker */} + {playbackPoint && ( + + + + + + )} + + {/* HUD overlays */} + + + + ); +}; + +window.FleetMap = FleetMap; diff --git a/data/fleet.js b/data/fleet.js new file mode 100644 index 0000000..e9673aa --- /dev/null +++ b/data/fleet.js @@ -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, +}); +})(); diff --git a/design-canvas.jsx b/design-canvas.jsx new file mode 100644 index 0000000..9f3fc61 --- /dev/null +++ b/design-canvas.jsx @@ -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: +// +// +// +// +// +// + +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 ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// 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 ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// 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 ( +
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// 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 ( +
+
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+ +
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// 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 }) => ( + + ); + + // 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( +
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) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a84b045 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/index.html b/index.html new file mode 120000 index 0000000..3d1a2ea --- /dev/null +++ b/index.html @@ -0,0 +1 @@ +羚牛车辆数据中心.html \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..c995e55 --- /dev/null +++ b/nginx.conf @@ -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; +} diff --git a/screenshots/alarm-light.png b/screenshots/alarm-light.png new file mode 100644 index 0000000..606c99f Binary files /dev/null and b/screenshots/alarm-light.png differ diff --git a/screenshots/curr-esg-dark.png b/screenshots/curr-esg-dark.png new file mode 100644 index 0000000..15afca0 Binary files /dev/null and b/screenshots/curr-esg-dark.png differ diff --git a/screenshots/curr-esg-dark2.png b/screenshots/curr-esg-dark2.png new file mode 100644 index 0000000..15afca0 Binary files /dev/null and b/screenshots/curr-esg-dark2.png differ diff --git a/screenshots/curr-esg-light.png b/screenshots/curr-esg-light.png new file mode 100644 index 0000000..606c99f Binary files /dev/null and b/screenshots/curr-esg-light.png differ diff --git a/screenshots/curr-overview.png b/screenshots/curr-overview.png new file mode 100644 index 0000000..1572c3c Binary files /dev/null and b/screenshots/curr-overview.png differ diff --git a/screenshots/curr-playback.png b/screenshots/curr-playback.png new file mode 100644 index 0000000..606c99f Binary files /dev/null and b/screenshots/curr-playback.png differ diff --git a/screenshots/curr-playback2.png b/screenshots/curr-playback2.png new file mode 100644 index 0000000..606c99f Binary files /dev/null and b/screenshots/curr-playback2.png differ diff --git a/screenshots/esg-dark.png b/screenshots/esg-dark.png new file mode 100644 index 0000000..15afca0 Binary files /dev/null and b/screenshots/esg-dark.png differ diff --git a/screenshots/esg-light.png b/screenshots/esg-light.png new file mode 100644 index 0000000..606c99f Binary files /dev/null and b/screenshots/esg-light.png differ diff --git a/screenshots/light-active.jpg b/screenshots/light-active.jpg new file mode 100644 index 0000000..bb28343 Binary files /dev/null and b/screenshots/light-active.jpg differ diff --git a/screenshots/light-default.jpg b/screenshots/light-default.jpg new file mode 100644 index 0000000..3183d32 Binary files /dev/null and b/screenshots/light-default.jpg differ diff --git a/screenshots/light-map.jpg b/screenshots/light-map.jpg new file mode 100644 index 0000000..872eab4 Binary files /dev/null and b/screenshots/light-map.jpg differ diff --git a/screenshots/light-overview.jpg b/screenshots/light-overview.jpg new file mode 100644 index 0000000..4569303 Binary files /dev/null and b/screenshots/light-overview.jpg differ diff --git a/screenshots/playback-current.png b/screenshots/playback-current.png new file mode 100644 index 0000000..606c99f Binary files /dev/null and b/screenshots/playback-current.png differ diff --git a/screenshots/spa-overview-2.jpg b/screenshots/spa-overview-2.jpg new file mode 100644 index 0000000..0600ac0 Binary files /dev/null and b/screenshots/spa-overview-2.jpg differ diff --git a/screenshots/spa-overview-3.jpg b/screenshots/spa-overview-3.jpg new file mode 100644 index 0000000..dff6150 Binary files /dev/null and b/screenshots/spa-overview-3.jpg differ diff --git a/screenshots/spa-overview-hq.png b/screenshots/spa-overview-hq.png new file mode 100644 index 0000000..86fa729 Binary files /dev/null and b/screenshots/spa-overview-hq.png differ diff --git a/screenshots/spa-overview.jpg b/screenshots/spa-overview.jpg new file mode 100644 index 0000000..dff6150 Binary files /dev/null and b/screenshots/spa-overview.jpg differ diff --git a/screenshots/zhapu-dark.png b/screenshots/zhapu-dark.png new file mode 100644 index 0000000..4294711 Binary files /dev/null and b/screenshots/zhapu-dark.png differ diff --git a/screenshots/zhapu-dark2.png b/screenshots/zhapu-dark2.png new file mode 100644 index 0000000..dff6150 Binary files /dev/null and b/screenshots/zhapu-dark2.png differ diff --git a/screenshots/zhapu-light.png b/screenshots/zhapu-light.png new file mode 100644 index 0000000..dff6150 Binary files /dev/null and b/screenshots/zhapu-light.png differ diff --git a/screenshots/zhapu-light2.png b/screenshots/zhapu-light2.png new file mode 100644 index 0000000..1fa21eb Binary files /dev/null and b/screenshots/zhapu-light2.png differ diff --git a/styles/design-system.css b/styles/design-system.css new file mode 100644 index 0000000..0961f6c --- /dev/null +++ b/styles/design-system.css @@ -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; } diff --git a/tweaks-panel.jsx b/tweaks-panel.jsx new file mode 100644 index 0000000..184b014 --- /dev/null +++ b/tweaks-panel.jsx @@ -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 , 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 ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +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,"); + 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 ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +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 ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +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 ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/uploads/472ff2cd-2897-450a-9d3f-c9bf447bd24f.png b/uploads/472ff2cd-2897-450a-9d3f-c9bf447bd24f.png new file mode 100644 index 0000000..6701aee Binary files /dev/null and b/uploads/472ff2cd-2897-450a-9d3f-c9bf447bd24f.png differ diff --git a/uploads/logo_light.svg b/uploads/logo_light.svg new file mode 100644 index 0000000..7beb4cf --- /dev/null +++ b/uploads/logo_light.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/uploads/pasted-1777348878369-0.png b/uploads/pasted-1777348878369-0.png new file mode 100644 index 0000000..82c60e2 Binary files /dev/null and b/uploads/pasted-1777348878369-0.png differ diff --git a/uploads/pasted-1777352787300-0.png b/uploads/pasted-1777352787300-0.png new file mode 100644 index 0000000..7197cb6 Binary files /dev/null and b/uploads/pasted-1777352787300-0.png differ diff --git a/uploads/pasted-1777352928351-0.png b/uploads/pasted-1777352928351-0.png new file mode 100644 index 0000000..c598dfe Binary files /dev/null and b/uploads/pasted-1777352928351-0.png differ diff --git a/uploads/pasted-1777356402710-0.png b/uploads/pasted-1777356402710-0.png new file mode 100644 index 0000000..bdea924 Binary files /dev/null and b/uploads/pasted-1777356402710-0.png differ diff --git a/woodpecker.yml b/woodpecker.yml new file mode 100644 index 0000000..f0b6f40 --- /dev/null +++ b/woodpecker.yml @@ -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 < 60 °C、SOC < 10%、胎压偏差、急加急减 +- **运维通知**:保养剩余里程 < 1000 km、合同到期 30 天、年检 60 天 +- **业务事件**:还车里程超标、客户欠费、车辆调拨 +- **自动化动作**:站内消息 / 邮件 / 短信 / Webhook / 自动派工单 + +每条规则带优先级(P0/P1/P2/P3)、抑制策略、生效时段、命中统计。 + +### ④ AI 智能层 · 让数据自己说话 + +- **异常检测** — 基于多维时序的 Isolation Forest / LSTM-AD,对电池一致性、电机温升、氢气泄漏给出**早于阈值告警**的预测 +- **驾驶行为评分** — 急加速 / 急减速 / 急转弯 / 超速时长加权,输出 A–E 评分卡 +- **能耗 & 续航预测** — 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 碳减排**等场景化驾驶舱呈现给运营、运维、财务、管理层四类角色。从车端到决策端,让**每一辆车、每一公里、每一克氢、每一克碳**都可计、可视、可控。 diff --git a/羚牛车辆数据中心.html b/羚牛车辆数据中心.html new file mode 100644 index 0000000..d2919f4 --- /dev/null +++ b/羚牛车辆数据中心.html @@ -0,0 +1,156 @@ + + + + +羚牛车辆数据中心 + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +