init: 羚牛车辆数据中心原型 + 部署配置
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
- React 18 + Babel-in-browser SPA 原型,覆盖 8 个画板: 实时地图 / 车辆详情 / 历史查询 / 轨迹回放 / 事件规则 / 通知中心 / ESG 碳减排 / 移动端 - 设计系统:IBM Plex Sans + JetBrains Mono,亮/暗双主题,羚牛绿 #007143 - 数据模型:12 + 40 辆车,TBOX (T) / JT808+1078 (JT) / 双源 (B) - 部署:nginx 静态托管,Dockerfile + woodpecker.yml + docker-compose.yml - 镜像:harbor.lnh2e.com/lingniu-v1/ln-vdc:<branch>-<VERSION> - 容器端口 80,宿主映射 8112,含 /healthz 探活
This commit is contained in:
411
artboards/alarm.jsx
Normal file
411
artboards/alarm.jsx
Normal file
@@ -0,0 +1,411 @@
|
||||
// artboard-alarm.jsx — Event rule engine (告警事件 / 运维通知 / 业务事件)
|
||||
// "事件规则" — 抽象事件,触发条件 + 通知/动作
|
||||
|
||||
const EVENT_KINDS = [
|
||||
{ id: "all", label: "全部", count: 24, color: "var(--fg-2)", bg: "transparent" },
|
||||
{ id: "alarm", label: "告警事件", count: 12, color: "var(--danger)", bg: "var(--danger-soft)", desc: "需立即处理 · P0/P1/P2" },
|
||||
{ id: "ops", label: "运维通知", count: 7, color: "var(--warn)", bg: "var(--warn-soft)", desc: "保养/检修/合同到期" },
|
||||
{ id: "biz", label: "业务事件", count: 4, color: "var(--info)", bg: "var(--info-soft)", desc: "里程/交付/调度状态" },
|
||||
{ id: "auto", label: "自动化", count: 1, color: "var(--accent)", bg: "var(--accent-soft)", desc: "自动派单/路径下发" },
|
||||
];
|
||||
|
||||
const KIND_META = {
|
||||
alarm: { label: "告警", color: "var(--danger)", bg: "var(--danger-soft)" },
|
||||
ops: { label: "运维", color: "var(--warn)", bg: "var(--warn-soft)" },
|
||||
biz: { label: "业务", color: "var(--info)", bg: "var(--info-soft)" },
|
||||
auto: { label: "自动化", color: "var(--accent)", bg: "var(--accent-soft)" },
|
||||
};
|
||||
|
||||
// Mock rule library
|
||||
const RULES = [
|
||||
// 告警 (P0)
|
||||
{ n:"H₂压力异常下降", kind:"alarm", c:"P0", on:true, h:"已触发 3 次", cond:"pressure < 35 MPa", actions:["站内","邮件","短信"], a:false },
|
||||
{ n:"电堆过温保护", kind:"alarm", c:"P0", on:true, h:"已触发 1 次", cond:"stack.temp > 95℃", actions:["站内","短信","Webhook"], a:false },
|
||||
{ n:"电池SOC严重不足", kind:"alarm", c:"P0", on:true, h:"已触发 8 次", cond:"SOC < 15% & 持续 60s", actions:["站内","邮件","路径下发"], a:true },
|
||||
{ n:"胎压异常", kind:"alarm", c:"P1", on:true, h:"已触发 12 次", cond:"tire.pressure > 3.0 MPa", actions:["站内","推送"], a:false },
|
||||
{ n:"超速预警", kind:"alarm", c:"P1", on:true, h:"已触发 47 次", cond:"speed > limit + 10 km/h", actions:["站内"], a:false },
|
||||
{ n:"急加速密集", kind:"alarm", c:"P2", on:true, h:"已触发 18 次", cond:"3 次/分钟 within 5min", actions:["邮件"], a:false },
|
||||
{ n:"夜间行驶", kind:"alarm", c:"P2", on:true, h:"已触发 6 次", cond:"22: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 (
|
||||
<div className="app">
|
||||
<Sidebar active="alarm"/>
|
||||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
|
||||
<Topbar crumbs={["事件规则", "规则引擎", rule.n]} kpis={[]} showSearch={false}/>
|
||||
|
||||
{/* Event-kind tabs */}
|
||||
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:8, alignItems:"center", flexWrap:"wrap"}}>
|
||||
<span className="muted" style={{fontSize:11, marginRight:4}}>事件类型</span>
|
||||
{EVENT_KINDS.map(k => (
|
||||
<span key={k.id}
|
||||
onClick={() => setActiveKind(k.id)}
|
||||
style={{
|
||||
padding:"5px 12px",
|
||||
borderRadius:14,
|
||||
fontSize:11,
|
||||
cursor:"pointer",
|
||||
background: activeKind === k.id ? k.bg : "transparent",
|
||||
color: activeKind === k.id ? k.color : "var(--fg-2)",
|
||||
border: "1px solid " + (activeKind === k.id ? k.color : "var(--border-1)"),
|
||||
display:"flex", alignItems:"center", gap:6,
|
||||
}}>
|
||||
<span>{k.label}</span>
|
||||
<span className="mono" style={{fontSize:10, opacity:.8}}>{k.count}</span>
|
||||
</span>
|
||||
))}
|
||||
<div style={{marginLeft:"auto", display:"flex", gap:6}}>
|
||||
<button className="btn sm"><Icon name="filter" size={11}/> 筛选</button>
|
||||
<button className="btn sm"><Icon name="download" size={11}/> 导出</button>
|
||||
<button className="btn primary sm"><Icon name="plus" size={11}/> 新建规则</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{flex:1, display:"grid", gridTemplateColumns:"260px 1fr 320px", minHeight:0}}>
|
||||
{/* Rules list */}
|
||||
<div style={{borderRight:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
|
||||
<div style={{padding:"10px 14px", borderBottom:"1px solid var(--border-1)"}}>
|
||||
<div className="between" style={{marginBottom:8}}>
|
||||
<span className="eyebrow">规则 · {filtered.length}</span>
|
||||
<span className="muted" style={{fontSize:10}}>共 {RULES.length} 条</span>
|
||||
</div>
|
||||
<div className="search" style={{height:26}}>
|
||||
<Icon name="search" size={12}/><input placeholder="搜索规则名 / 字段"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scroll" style={{flex:1}}>
|
||||
{filtered.map((r,i)=>{
|
||||
const realIdx = RULES.indexOf(r);
|
||||
const meta = KIND_META[r.kind];
|
||||
const isActive = realIdx === activeRule;
|
||||
return (
|
||||
<div key={i}
|
||||
onClick={() => setActiveRule(realIdx)}
|
||||
style={{
|
||||
padding:"10px 14px",
|
||||
borderBottom:"1px solid var(--border-1)",
|
||||
borderLeft: isActive ? "2px solid var(--accent)" : "2px solid transparent",
|
||||
background: isActive ? "var(--accent-soft)" : "transparent",
|
||||
cursor:"pointer"
|
||||
}}>
|
||||
<div className="between">
|
||||
<span className="strong" style={{fontSize:12}}>{r.n}</span>
|
||||
<span style={{fontSize:9, padding:"2px 6px", borderRadius:3, color:meta.color, background:meta.bg, fontWeight:500}}>
|
||||
{meta.label}·{r.c}
|
||||
</span>
|
||||
</div>
|
||||
<div className="muted mono" style={{fontSize:10, marginTop:4, whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis"}}>
|
||||
{r.cond}
|
||||
</div>
|
||||
<div className="between" style={{marginTop:6}}>
|
||||
<span className="muted" style={{fontSize:10}}>{r.h}</span>
|
||||
<span style={{width:22, height:12, borderRadius:6, background: r.on?"var(--accent)":"var(--bg-3)", position:"relative", flexShrink:0}}>
|
||||
<span style={{position:"absolute", top:1, left: r.on?11:1, width:10, height:10, borderRadius:5, background:"#fff", boxShadow:"0 1px 2px rgba(0,0,0,.2)"}}/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rule editor canvas */}
|
||||
<RuleEditor rule={rule}/>
|
||||
|
||||
{/* Right: properties */}
|
||||
<RuleProperties rule={rule}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Rule editor canvas ──────────────────────────────────────
|
||||
const RuleEditor = ({ rule }) => {
|
||||
const meta = KIND_META[rule.kind];
|
||||
const isAlarm = rule.kind === "alarm";
|
||||
const isOps = rule.kind === "ops";
|
||||
const isBiz = rule.kind === "biz";
|
||||
const isAuto = rule.kind === "auto";
|
||||
|
||||
// Build dynamic conditions per rule
|
||||
const conds = (() => {
|
||||
if (rule.n.startsWith("胎压")) return [{lbl:"WHEN", v:"tire.pressure", op:">", val:"3.0 MPa"}];
|
||||
if (rule.n.startsWith("电池SOC")) return [{lbl:"WHEN", v:"vehicle.battery.soc", op:"<", val:"15 %"}, {lbl:"AND", v:"持续时长", op:"≥", val:"60 秒"}, {lbl:"AND NOT", v:"vehicle.location.poi", op:"=", val:"补能站"}];
|
||||
if (rule.n.startsWith("H₂")) return [{lbl:"WHEN", v:"h2.pressure", op:"<", val:"35 MPa"}, {lbl:"AND", v:"vehicle.state", op:"=", val:"行驶中"}];
|
||||
if (rule.n.startsWith("电堆")) return [{lbl:"WHEN", v:"fc.stack.temp", op:">", val:"95 ℃"}, {lbl:"AND", v:"持续时长", op:"≥", val:"30 秒"}];
|
||||
if (rule.n.startsWith("超速")) return [{lbl:"WHEN", v:"vehicle.speed", op:">", val:"道路限速 + 10 km/h"}];
|
||||
if (rule.n.startsWith("急加速")) return [{lbl:"WHEN", v:"急加速次数", op:"≥", val:"3 次"}, {lbl:"WITHIN", v:"时间窗", op:"=", val:"5 分钟"}];
|
||||
if (rule.n.startsWith("夜间")) return [{lbl:"WHEN", v:"local_time", op:"∈", val:"[22:00, 06:00]"}, {lbl:"AND", v:"vehicle.state", op:"=", val:"行驶中"}];
|
||||
if (rule.n.startsWith("总里程")) return [{lbl:"WHEN", v:"vehicle.odometer", op:"% 20,000 ≈", val:"0 km"}, {lbl:"AND", v:"距离上次保养里程", op:">", val:"19,500 km"}];
|
||||
if (rule.n.startsWith("保养")) return [{lbl:"WHEN", v:"距下次保养", op:"≤", val:"30 天"}];
|
||||
if (rule.n.startsWith("保险")) return [{lbl:"WHEN", v:"距保险到期", op:"≤", val:"60 天"}];
|
||||
if (rule.n.startsWith("合同")) return [{lbl:"WHEN", v:"距合同到期", op:"≤", val:"90 天"}];
|
||||
if (rule.n.startsWith("异常静止")) return [{lbl:"WHEN", v:"vehicle.idle_duration", op:">", val:"24 小时"}, {lbl:"AND", v:"asset.status", op:"=", val:"租赁/运营"}];
|
||||
if (rule.n.startsWith("长时间停留")) return [{lbl:"WHEN", v:"stop_duration", op:">", val:"4 小时"}, {lbl:"AND NOT", v:"vehicle.location.poi", op:"=", val:"补能站/停车场"}];
|
||||
if (rule.n.startsWith("今日交付")) return [{lbl:"WHEN", v:"delivery.status", op:"=", val:"已完成"}];
|
||||
if (rule.n.startsWith("偏离")) return [{lbl:"WHEN", v:"distance_from_route", op:">", val:"500 m"}, {lbl:"AND", v:"持续时长", op:"≥", val:"30 秒"}];
|
||||
if (rule.n.startsWith("进入禁行")) return [{lbl:"WHEN", v:"vehicle.geofence", op:"∈", val:"禁行围栏集合"}];
|
||||
if (rule.n.startsWith("调度")) return [{lbl:"WHEN", v:"dispatch.status", op:"changed", val:"任意 → 任意"}];
|
||||
if (rule.n.startsWith("低 SOC")) return [{lbl:"WHEN", v:"SOC", op:"<", val:"20 %"}, {lbl:"AND", v:"vehicle.state", op:"=", val:"行驶中"}, {lbl:"AND", v:"附近补能站", op:"≤", val:"5 km"}];
|
||||
return [{lbl:"WHEN", v:"自定义条件", op:"-", val:"-"}];
|
||||
})();
|
||||
|
||||
// Build dynamic actions per rule
|
||||
const actionDefs = rule.actions.map(a => {
|
||||
if (a === "站内") return {kind:"notif", icon:"inbox", title:"站内消息", who:"业务部门负责人 · 调度组", note:"实时"};
|
||||
if (a === "邮件") return {kind:"notif", icon:"mail", title:"邮件", who:"业务负责人 · 安全官 (3人)", note:"含轨迹截图"};
|
||||
if (a === "短信") return {kind:"notif", icon:"phone", title:"短信", who:"司机 + 业务负责人", note:"P0 级专用"};
|
||||
if (a === "推送") return {kind:"notif", icon:"bell", title:"应用内推送", who:"业务负责人 + 客户联系人", note:"含一键导航"};
|
||||
if (a === "Webhook") return {kind:"action",icon:"plug", title:"Webhook", who:"https://erp.lingniu.cn/hook/v1", note:"POST 事件 JSON"};
|
||||
if (a === "工单") return {kind:"action",icon:"clipboard",title:"创建工单", who:"维保中心 · 自动指派", note:"24h SLA"};
|
||||
if (a === "路径下发") return {kind:"action",icon:"route", title:"路径下发", who:"附近补能站 · TBOX", note:"司机端弹窗确认"};
|
||||
return {kind:"notif", icon:"bell", title:a, who:"-", note:""};
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding:16,
|
||||
display:"flex", flexDirection:"column", minHeight:0,
|
||||
background:"var(--bg-2)",
|
||||
backgroundImage:"radial-gradient(circle, var(--border-1) 1px, transparent 1px)",
|
||||
backgroundSize:"18px 18px", backgroundPosition:"-9px -9px",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div className="between" style={{marginBottom:14}}>
|
||||
<div>
|
||||
<div className="mid gap-2">
|
||||
<span className="strong" style={{fontSize:18, fontWeight:600}}>{rule.n}</span>
|
||||
<span style={{fontSize:10, padding:"3px 8px", borderRadius:4, color:meta.color, background:meta.bg, fontWeight:600, border:"1px solid " + meta.color}}>
|
||||
{meta.label} · {rule.c}
|
||||
</span>
|
||||
<span className="chip ok"><span className="dot ok"/> 已启用</span>
|
||||
<span className="muted" style={{fontSize:10, marginLeft:6}}>v 1.4 · 2026-04-12</span>
|
||||
</div>
|
||||
<div className="muted" style={{fontSize:11, marginTop:4}}>
|
||||
{isAlarm && "条件命中后立即生成告警事件,按通知渠道发送至业务负责人;P0 级支持一键派单。"}
|
||||
{isOps && "条件命中后生成运维通知;如已配置工单动作,将创建保养/检修工单进入维保流程。"}
|
||||
{isBiz && "条件命中后生成业务事件;可推送至 Webhook 联动 ERP / TMS / 调度系统。"}
|
||||
{isAuto && "条件命中后自动执行动作(路径下发 / 状态变更),司机端弹窗确认后生效。"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mid gap-2">
|
||||
<button className="btn"><Icon name="play" size={12}/> 测试</button>
|
||||
<button className="btn"><Icon name="history" size={13}/> 版本</button>
|
||||
<button className="btn primary"><Icon name="bookmark" size={13}/> 保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor — split: WHEN | LOGIC | THEN */}
|
||||
<div className="scroll" style={{flex:1, position:"relative", padding:"4px 0"}}>
|
||||
<div style={{display:"grid", gridTemplateColumns:"260px 220px 1fr", gap:24, alignItems:"start", minHeight:"100%"}}>
|
||||
|
||||
{/* WHEN column */}
|
||||
<div>
|
||||
<div className="eyebrow" style={{marginBottom:8, color:"var(--info)"}}>① 触发条件 · WHEN</div>
|
||||
<div style={{display:"flex", flexDirection:"column", gap:10}}>
|
||||
{conds.map((c,i) => (
|
||||
<div key={i} style={{padding:"10px 12px", background:"var(--bg-1)", border:"1px solid var(--border-2)", borderRadius:8, fontSize:11, boxShadow:"0 1px 2px rgba(0,0,0,.04)", position:"relative"}}>
|
||||
<div className="between">
|
||||
<span style={{fontSize:9, color:"var(--info)", fontWeight:600, letterSpacing:"0.05em"}}>{c.lbl}</span>
|
||||
<Icon name="x" size={10} style={{color:"var(--fg-3)", cursor:"pointer"}}/>
|
||||
</div>
|
||||
<div className="strong" style={{marginTop:6, fontSize:12}}>{c.v}</div>
|
||||
<div className="mid gap-2" style={{marginTop:4, fontSize:11}}>
|
||||
<span style={{padding:"2px 6px", background:"var(--bg-2)", borderRadius:3, fontFamily:"var(--font-mono)", border:"1px solid var(--border-1)"}}>{c.op}</span>
|
||||
<span className="mono strong" style={{color:"var(--accent)"}}>{c.val}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{padding:"8px 12px", background:"var(--bg-1)", borderRadius:6, border:"1px dashed var(--border-2)", fontSize:11, color:"var(--fg-3)", display:"flex", alignItems:"center", gap:6, justifyContent:"center", cursor:"pointer"}}>
|
||||
<Icon name="plus" size={11}/> 添加条件
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LOGIC center */}
|
||||
<div style={{display:"flex", flexDirection:"column", alignItems:"center", paddingTop:32}}>
|
||||
<div style={{padding:"16px 14px", background:"var(--accent-soft)", border:"1.5px solid var(--accent)", borderRadius:10, textAlign:"center", boxShadow:"0 0 24px var(--accent-glow)", width:"100%"}}>
|
||||
<div style={{fontSize:10, color:"var(--accent)", letterSpacing:"0.1em", marginBottom:6}}>LOGIC GATE</div>
|
||||
<div className="strong" style={{fontSize:18, fontFamily:"var(--font-mono)", color:"var(--accent)"}}>
|
||||
{conds.length === 1 ? "A" : conds.map((c,i) => (c.lbl === "AND NOT" ? "¬" : "") + String.fromCharCode(65+i)).join(conds.length > 1 ? " ∧ " : "")}
|
||||
</div>
|
||||
<div className="muted" style={{fontSize:10, marginTop:6}}>{conds.length} 个条件 · 全部满足时触发</div>
|
||||
</div>
|
||||
<div style={{marginTop:14, padding:"10px 12px", background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6, fontSize:10, width:"100%"}}>
|
||||
<div className="muted" style={{marginBottom:4}}>评估窗口</div>
|
||||
<div className="between"><span>采样频率</span><span className="mono strong">10 s · TBOX</span></div>
|
||||
<div className="between" style={{marginTop:3}}><span>抑制窗口</span><span className="mono strong">15 min</span></div>
|
||||
<div className="between" style={{marginTop:3}}><span>事件 ID</span><span className="mono" style={{color:"var(--accent)"}}>EVT-{rule.kind.toUpperCase()}-{rule.c}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* THEN column — actions */}
|
||||
<div>
|
||||
<div className="eyebrow" style={{marginBottom:8, color:"var(--accent)"}}>② 触发动作 · THEN</div>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
|
||||
{actionDefs.map((a,i) => (
|
||||
<div key={i} style={{padding:"10px 12px", background:"var(--bg-1)", border:"1px solid " + (a.kind === "action" ? "var(--accent)" : "var(--border-2)"), borderLeft:"3px solid " + (a.kind === "action" ? "var(--accent)" : "var(--info)"), borderRadius:6, fontSize:11, boxShadow:"0 1px 2px rgba(0,0,0,.04)"}}>
|
||||
<div className="between">
|
||||
<div className="mid gap-2">
|
||||
<Icon name={a.icon} size={12} style={{color: a.kind === "action" ? "var(--accent)" : "var(--info)"}}/>
|
||||
<span className="strong" style={{fontSize:11}}>{a.title}</span>
|
||||
</div>
|
||||
<span style={{fontSize:9, padding:"1px 5px", borderRadius:3, color: a.kind === "action" ? "var(--accent)" : "var(--info)", background: a.kind === "action" ? "var(--accent-soft)" : "var(--info-soft)"}}>
|
||||
{a.kind === "action" ? "动作" : "通知"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="muted" style={{fontSize:10, marginTop:4, lineHeight:1.4}}>{a.who}</div>
|
||||
<div className="mono" style={{fontSize:9, marginTop:3, color:"var(--fg-3)"}}>{a.note}</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{padding:"10px 12px", background:"var(--bg-1)", border:"1px dashed var(--border-2)", borderRadius:6, fontSize:11, color:"var(--fg-3)", display:"flex", alignItems:"center", justifyContent:"center", gap:6, cursor:"pointer", minHeight:64}}>
|
||||
<Icon name="plus" size={11}/> 添加动作
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block library */}
|
||||
<div style={{marginTop:14, padding:"10px 12px", background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6}}>
|
||||
<div className="eyebrow" style={{marginBottom:6}}>组件库 · 拖入条件 / 动作</div>
|
||||
<div style={{display:"flex", flexWrap:"wrap", gap:6}}>
|
||||
{[
|
||||
{ic:"gauge", l:"数值阈值"},
|
||||
{ic:"history", l:"持续时间"},
|
||||
{ic:"pin", l:"地理围栏"},
|
||||
{ic:"timeline", l:"时间窗口"},
|
||||
{ic:"branch", l:"逻辑分支"},
|
||||
{ic:"speed", l:"速度变化率"},
|
||||
{ic:"chart", l:"趋势异常"},
|
||||
{ic:"shield", l:"白名单"},
|
||||
{ic:"clipboard", l:"创建工单"},
|
||||
{ic:"plug", l:"Webhook"},
|
||||
{ic:"route", l:"路径下发"},
|
||||
{ic:"mail", l:"邮件"},
|
||||
].map((b,i)=>(
|
||||
<div key={i} className="mid gap-1" style={{padding:"4px 8px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5, fontSize:10, cursor:"grab"}}>
|
||||
<Icon name={b.ic} size={11} style={{color:"var(--accent)"}}/> <span>{b.l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Right-side properties ──────────────────────────────────
|
||||
const RuleProperties = ({ rule }) => {
|
||||
const meta = KIND_META[rule.kind];
|
||||
const isAlarm = rule.kind === "alarm";
|
||||
const isOps = rule.kind === "ops";
|
||||
|
||||
const channels = [
|
||||
{ic:"inbox", l:"站内消息中心", on: rule.actions.includes("站内"), who:"业务部门(5) · 调度组(8)"},
|
||||
{ic:"mail", l:"邮件", on: rule.actions.includes("邮件"), who:"业务负责人 · 安全官"},
|
||||
{ic:"phone", l:"短信", on: rule.actions.includes("短信"), who:"司机 + 业务负责人"},
|
||||
{ic:"bell", l:"应用内推送", on: rule.actions.includes("推送"), who:"业务负责人 + 客户联系人"},
|
||||
{ic:"plug", l:"Webhook", on: rule.actions.includes("Webhook"), who:"erp.lingniu.cn/hook/v1"},
|
||||
{ic:"clipboard",l:"工单", on: rule.actions.includes("工单"), who:"维保中心 · 24h SLA"},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
|
||||
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
|
||||
<Icon name="sliders" size={13}/><span className="title">规则属性</span>
|
||||
</div>
|
||||
<div className="scroll" style={{flex:1, padding:14}}>
|
||||
<div className="eyebrow" style={{marginBottom:6}}>事件类型</div>
|
||||
<div style={{padding:"8px 10px", background:meta.bg, border:"1px solid " + meta.color, borderRadius:5, fontSize:11, marginBottom:14, color:meta.color, fontWeight:600}}>
|
||||
{meta.label} · {rule.c}
|
||||
</div>
|
||||
|
||||
<div className="eyebrow" style={{marginBottom:6}}>{isAlarm ? "告警优先级" : isOps ? "运维等级" : "事件等级"}</div>
|
||||
<div className="row gap-1" style={{marginBottom:14}}>
|
||||
{(isAlarm ? ["P0","P1","P2"] : isOps ? ["M1","M2","M3"] : ["B1","B2","B3"]).map(p => (
|
||||
<span key={p}
|
||||
className={"chip " + (rule.c === p ? (isAlarm ? "danger" : isOps ? "warn" : "info") : "")}
|
||||
style={{flex:1, justifyContent:"center", padding:"4px 0", opacity: rule.c === p ? 1 : 0.4}}>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="eyebrow" style={{marginBottom:6}}>适用车辆</div>
|
||||
<div style={{padding:8, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5, fontSize:11, marginBottom:14}}>
|
||||
<div className="strong">全部车辆 · 512</div>
|
||||
<div className="muted" style={{fontSize:10, marginTop:2}}>排除维保中 6 辆 · 排除停运 4 辆</div>
|
||||
</div>
|
||||
|
||||
<div className="eyebrow" style={{marginBottom:8}}>通知 / 动作渠道</div>
|
||||
<div className="col gap-2" style={{marginBottom:14}}>
|
||||
{channels.map((c,i)=>(
|
||||
<div key={i} className="between" style={{padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5}}>
|
||||
<div className="mid gap-2">
|
||||
<Icon name={c.ic} size={12} style={{color: c.on?"var(--accent)":"var(--fg-3)"}}/>
|
||||
<div>
|
||||
<div className="strong" style={{fontSize:11}}>{c.l}</div>
|
||||
<div className="muted" style={{fontSize:10}}>{c.who}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{width:22, height:12, borderRadius:6, background: c.on?"var(--accent)":"var(--bg-3)", position:"relative"}}>
|
||||
<span style={{position:"absolute", top:1, left: c.on?11:1, width:10, height:10, borderRadius:5, background:"#fff", boxShadow:"0 1px 2px rgba(0,0,0,.2)"}}/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="eyebrow" style={{marginBottom:6}}>抑制策略</div>
|
||||
<div style={{padding:10, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5, fontSize:11, marginBottom:14}}>
|
||||
<div className="between"><span className="muted">同车去重窗口</span><span className="mono strong">{isOps ? "24 小时" : "15 分钟"}</span></div>
|
||||
<div className="between" style={{marginTop:6}}><span className="muted">每日上限</span><span className="mono strong">{isAlarm && rule.c === "P0" ? "无限制" : "20 次/车"}</span></div>
|
||||
<div className="between" style={{marginTop:6}}><span className="muted">合并策略</span><span className="mono strong">{isOps ? "按车辆合并" : "不合并"}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="eyebrow" style={{marginBottom:6}}>静音时段</div>
|
||||
<div style={{padding:10, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5, fontSize:11, marginBottom:14}}>
|
||||
<div className="muted" style={{fontSize:10, lineHeight:1.5}}>
|
||||
{isAlarm && rule.c === "P0" ? "P0 紧急规则不静音" : isAlarm ? "工作时段:8:00–20:00 推送" : isOps ? "仅工作日发送" : "无静音"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="eyebrow" style={{marginBottom:6}}>近 7 日触发</div>
|
||||
<div style={{padding:10, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:5}}>
|
||||
<div style={{display:"flex", gap:3, height:32, alignItems:"flex-end"}}>
|
||||
{[3,7,2,9,12,5,8].map((v,i) => (
|
||||
<div key={i} style={{flex:1, height: (v/12)*100 + "%", background: v > 8 ? meta.color : "var(--accent-soft)", borderRadius:1}}/>
|
||||
))}
|
||||
</div>
|
||||
<div className="between muted" style={{marginTop:6, fontSize:10}}>
|
||||
<span>4-22</span><span>4-28</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.ArtboardAlarm = ArtboardAlarm;
|
||||
369
artboards/detail.jsx
Normal file
369
artboards/detail.jsx
Normal file
@@ -0,0 +1,369 @@
|
||||
// artboard-detail.jsx — Single vehicle deep detail · Asset-management view
|
||||
const ArtboardDetail = () => {
|
||||
const vehicles = window.VEHICLES || [];
|
||||
const v = vehicles.find(x => x.id === "浙F03980F") || vehicles[0];
|
||||
if (!v) return null;
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar active="fleet"/>
|
||||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
|
||||
<Topbar
|
||||
crumbs={["车辆列表", v.plate, "详情"]}
|
||||
kpis={[]}
|
||||
showSearch={false}
|
||||
/>
|
||||
<div className="scroll" style={{flex:1, padding:16, display:"grid", gridTemplateColumns:"1fr 1fr 1fr", gridAutoRows:"min-content", gap:12}}>
|
||||
{/* Header card spanning 3 */}
|
||||
<div className="panel" style={{gridColumn:"1 / -1", padding:16}}>
|
||||
<div className="between">
|
||||
<div className="mid gap-3">
|
||||
<div style={{width:60, height:60, borderRadius:8, background:"var(--bg-2)", border:"1px solid var(--border-2)", display:"grid", placeItems:"center", color:"var(--accent)"}}>
|
||||
<Icon name="car" size={30}/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mid gap-2">
|
||||
<span className="mono strong" style={{fontSize:22, fontWeight:600}}>{v.plate}</span>
|
||||
<span className="chip" style={{
|
||||
background: v.asset === "leasing" ? "rgba(46,140,140,.15)" : v.asset === "abnormal" ? "var(--danger-soft)" : "var(--accent-soft)",
|
||||
color: v.asset === "leasing" ? "var(--info)" : v.asset === "abnormal" ? "var(--danger)" : "var(--accent)",
|
||||
}}>
|
||||
<span className={"dot " + (v.asset === "abnormal" ? "danger" : v.asset === "leasing" ? "info" : "ok")}/>
|
||||
{v.asset === "leasing" ? "租赁" : v.asset === "abnormal" ? "异常" : "在库"}
|
||||
</span>
|
||||
<span className="chip" style={{background: v.own === "self" ? "rgba(31,139,76,.10)" : "rgba(122,140,46,.12)", color: v.own === "self" ? "var(--accent)" : "#7A8C2E"}}>
|
||||
{v.own === "self" ? "自有" : "外租"}
|
||||
</span>
|
||||
<span className="chip accent"><Icon name="h2" size={11}/> H₂ {v.h2} MPa</span>
|
||||
</div>
|
||||
<div className="muted" style={{fontSize:12, marginTop:4}}>VIN {v.vin} · {v.city} · 等级 {v.grade}级 · 状态时长 {v.statusDays}天</div>
|
||||
<div className="mid gap-2" style={{marginTop:6, fontSize:11}}>
|
||||
<span className="muted">数据来源</span>
|
||||
<SourceBadge src={v.src} size="md"/>
|
||||
<span className="muted mono" style={{fontSize:10}}>TBOX(GB/T 32960-2016) · JT/T 808-2019 · JT/T 1078 视频</span>
|
||||
<span className={"chip " + (v.gps === "online" ? "ok" : "")} style={{fontSize:10}}>
|
||||
<span className={"dot " + (v.gps === "online" ? "ok" : "idle")} style={{width:4, height:4}}/>
|
||||
{v.gps === "online" ? "在线 · 上行 218ms" : "GPS离线"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mid gap-2">
|
||||
<button className="btn"><Icon name="route" size={13}/> 轨迹</button>
|
||||
<button className="btn"><Icon name="history" size={13}/> 历史</button>
|
||||
<button className="btn"><Icon name="bell" size={13}/> 告警</button>
|
||||
<button className="btn primary"><Icon name="pin" size={13}/> 定位</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{marginTop:14, display:"grid", gridTemplateColumns:"repeat(6, 1fr)", gap:0, borderTop:"1px solid var(--border-1)", paddingTop:14}}>
|
||||
{[
|
||||
{l:"累计里程", val:v.totalKm.toLocaleString(), u:"km"},
|
||||
{l:"今日里程", val:"248", u:"km"},
|
||||
{l:"距下次保养", val:v.kmToMaint.toLocaleString(), u:"km"},
|
||||
{l:"今日能耗", val:"18.4", u:"kWh/100km"},
|
||||
{l:"H₂消耗", val:"1.02", u:"kg/100km"},
|
||||
{l:"车辆评级", val:v.grade, u:"级"},
|
||||
].map((k,i)=>(
|
||||
<div key={i} style={{borderRight: i < 5 ? "1px solid var(--border-1)": "none", padding:"0 16px"}}>
|
||||
<div className="eyebrow" style={{marginBottom:6}}>{k.l}</div>
|
||||
<div><span className="mono strong" style={{fontSize:22, fontWeight:600}}>{k.val}</span><span className="muted mono" style={{fontSize:11, marginLeft:4}}>{k.u}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 资产档案 */}
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<Icon name="layers" size={13} style={{color:"var(--accent)"}}/>
|
||||
<span className="title">资产档案</span>
|
||||
</div>
|
||||
<div style={{padding:"14px 16px"}}>
|
||||
<div className="col gap-2" style={{fontSize:11}}>
|
||||
<div className="between"><span className="muted">车牌号</span><span className="mono strong">{v.plate}</span></div>
|
||||
<div className="between"><span className="muted">VIN/车架号</span><span className="mono" style={{fontSize:10}}>{v.vin}</span></div>
|
||||
{v.fleetCode && <div className="between"><span className="muted">车辆编号</span><span className="mono strong">{v.fleetCode}</span></div>}
|
||||
<div className="between"><span className="muted">运营城市</span><span>{v.city}</span></div>
|
||||
<div className="between"><span className="muted">所属公司</span><span style={{fontSize:10, textAlign:"right"}}>{v.ownCompany}</span></div>
|
||||
<div className="between"><span className="muted">车辆等级</span><span className="strong">{v.grade}级</span></div>
|
||||
<div className="between"><span className="muted">归属</span><span className="strong">{v.own === "self" ? "自有" : "外租"}</span></div>
|
||||
<div className="between"><span className="muted">停车场</span><span>{v.parking}</span></div>
|
||||
<div className="between"><span className="muted">资产状态</span><span className="strong" style={{color: v.asset === "abnormal" ? "var(--danger)" : v.asset === "leasing" ? "var(--info)" : "var(--accent)"}}>
|
||||
{v.asset === "leasing" ? "租赁" : v.asset === "abnormal" ? "异常" : "在库"} · {v.statusDays}天
|
||||
</span></div>
|
||||
<div className="between"><span className="muted">营运状态</span><span className="strong">
|
||||
{v.op === "operating" ? "运营中" : v.op === "suspended" ? "停运" : "待整备"}
|
||||
</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 业务关系 */}
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<Icon name="user" size={13} style={{color:"var(--info)"}}/>
|
||||
<span className="title">业务关系</span>
|
||||
</div>
|
||||
<div style={{padding:"14px 16px"}}>
|
||||
<div className="col gap-2" style={{fontSize:11}}>
|
||||
<div className="between"><span className="muted">业务部门</span>
|
||||
<span className="mid gap-1">
|
||||
<span style={{width:8, height:8, background:v.deptColor, borderRadius:1, display:"inline-block"}}/>
|
||||
<span className="strong">{v.deptName}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="between"><span className="muted">业务负责人</span><span className="strong">{v.deptLead}</span></div>
|
||||
<div className="between"><span className="muted">客户全名</span><span className="strong" style={{textAlign:"right", maxWidth:160}}>{v.customer}</span></div>
|
||||
{v.own === "lease" && <div className="between"><span className="muted">租赁公司</span><span style={{fontSize:10, textAlign:"right"}}>{v.company}</span></div>}
|
||||
{v.contractNo && <>
|
||||
<div className="between"><span className="muted">合同编号</span><span className="mono" style={{fontSize:10}}>{v.contractNo}</span></div>
|
||||
<div className="between"><span className="muted">交车里程</span><span className="mono">{v.handoverKm?.toLocaleString()} km</span></div>
|
||||
{v.returnKm != null && <div className="between"><span className="muted">还车里程</span><span className="mono">{v.returnKm.toLocaleString()} km</span></div>}
|
||||
</>}
|
||||
</div>
|
||||
<div style={{marginTop:14, paddingTop:12, borderTop:"1px solid var(--border-1)", display:"flex", gap:6}}>
|
||||
<button className="btn" style={{flex:1, fontSize:11}}>查看合同</button>
|
||||
<button className="btn" style={{flex:1, fontSize:11}}>变更负责人</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 氢电系统 */}
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<Icon name="h2" size={13} style={{color:"var(--accent)"}}/>
|
||||
<span className="title">氢电系统</span>
|
||||
<span className="chip accent" style={{marginLeft:"auto"}}>FCEV</span>
|
||||
</div>
|
||||
<div style={{padding:16}}>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:14}}>
|
||||
<div className="center col gap-2">
|
||||
<Donut size={92} value={v.soc/100} color="var(--accent)" thick={9} label={v.soc + "%"}/>
|
||||
<div className="muted" style={{fontSize:11}}>电池 SOC</div>
|
||||
</div>
|
||||
<div className="center col gap-2">
|
||||
<Donut size={92} value={v.h2Pressure/6} color="var(--info)" thick={9} label={Math.round(v.h2Pressure/6*100) + "%"}/>
|
||||
<div className="muted" style={{fontSize:11}}>H₂ 储量</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{marginTop:14, paddingTop:14, borderTop:"1px solid var(--border-1)", display:"grid", gridTemplateColumns:"1fr 1fr", gap:8, fontSize:11}}>
|
||||
<div className="between"><span className="muted">电堆功率</span><span className="mono strong">28.4 kW</span></div>
|
||||
<div className="between"><span className="muted">电池电压</span><span className="mono strong">386 V</span></div>
|
||||
<div className="between"><span className="muted">电堆温度</span><span className="mono strong">76°C</span></div>
|
||||
<div className="between"><span className="muted">H₂压力</span><span className="mono strong">{v.h2} MPa</span></div>
|
||||
<div className="between"><span className="muted">续航估算</span><span className="mono strong" style={{color:"var(--accent)"}}>{v.range} km</span></div>
|
||||
<div className="between"><span className="muted">电池温度</span><span className="mono strong">32°C</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Speed/RPM curve */}
|
||||
<div className="panel" style={{gridColumn:"span 2"}}>
|
||||
<div className="panel-head">
|
||||
<Icon name="speed" size={13}/>
|
||||
<span className="title">速度 / 电机转速 · 近1小时</span>
|
||||
<div className="actions">
|
||||
<span className="chip">1H</span>
|
||||
<span className="chip" style={{opacity:0.5}}>4H</span>
|
||||
<span className="chip" style={{opacity:0.5}}>1D</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{padding:"14px 16px"}}>
|
||||
<div className="between" style={{marginBottom:10, fontSize:11}}>
|
||||
<div className="mid gap-3">
|
||||
<span className="mid gap-1"><span className="dot" style={{background:"var(--info)"}}/> 速度 km/h</span>
|
||||
<span className="mid gap-1"><span className="dot" style={{background:"var(--accent)"}}/> 电机RPM ÷100</span>
|
||||
</div>
|
||||
<div className="mono muted">avg 52 / max 89 km/h</div>
|
||||
</div>
|
||||
<LineChart data={genSpeed()} w={520} h={120} color="var(--info)" axis baseline={70}/>
|
||||
<div style={{marginTop:-6}}>
|
||||
<LineChart data={genSpeed().map(v => v*1.1)} w={520} h={60} color="var(--accent)" fill={false}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tire pressure */}
|
||||
<div className="panel">
|
||||
<div className="panel-head"><Icon name="tire" size={13}/><span className="title">胎压 / 温度</span></div>
|
||||
<div style={{padding:16, display:"flex", gap:12, alignItems:"center", justifyContent:"center"}}>
|
||||
<svg width="160" height="200" viewBox="0 0 160 200">
|
||||
<rect x="40" y="20" width="80" height="160" rx="20" fill="var(--bg-2)" stroke="var(--border-2)"/>
|
||||
<rect x="50" y="45" width="60" height="50" rx="8" fill="var(--bg-3)" opacity="0.6"/>
|
||||
<rect x="50" y="105" width="60" height="50" rx="8" fill="var(--bg-3)" opacity="0.6"/>
|
||||
{[
|
||||
{x:24, y:38, st:"ok"},{x:120, y:38, st:"ok"},
|
||||
{x:24, y:138, st:"ok"},{x:120, y:138, st:"ok"},
|
||||
].map((t,i)=>(
|
||||
<rect key={i} x={t.x} y={t.y} width="16" height="24" rx="3" fill="var(--ok)" opacity="0.8"/>
|
||||
))}
|
||||
</svg>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:6, fontSize:11, fontFamily:"var(--font-mono)"}}>
|
||||
{[
|
||||
{p:"FL", v:"0.24", t:"32°"},{p:"FR", v:"0.23", t:"34°"},
|
||||
{p:"RL", v:"0.25", t:"36°"},{p:"RR", v:"0.24", t:"35°"},
|
||||
].map((t,i)=>(
|
||||
<div key={i} style={{padding:"6px 8px", background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
|
||||
<div className="muted" style={{fontSize:9}}>{t.p}</div>
|
||||
<div className="strong">{t.v}</div>
|
||||
<div className="muted" style={{fontSize:9}}>{t.t}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保养与维护 */}
|
||||
<div className="panel" style={{gridColumn:"span 2"}}>
|
||||
<div className="panel-head">
|
||||
<Icon name="wrench" size={13}/>
|
||||
<span className="title">保养与维护</span>
|
||||
<span className="actions">
|
||||
<span className={"chip " + (v.kmToMaint < 1000 ? "warn" : "ok")} style={{fontSize:10}}>
|
||||
剩余 {v.kmToMaint.toLocaleString()} km
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{padding:"14px 16px"}}>
|
||||
<div className="between" style={{fontSize:11, marginBottom:8}}>
|
||||
<span className="muted">保养周期 10,000 km</span>
|
||||
<span className="mono">已行 {(10000 - v.kmToMaint).toLocaleString()} / 10,000 km</span>
|
||||
</div>
|
||||
<div className="bar" style={{height:6, marginBottom:14}}>
|
||||
<i style={{width: ((10000 - v.kmToMaint) / 10000 * 100) + "%", background: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)"}}/>
|
||||
</div>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:14, fontSize:11}}>
|
||||
<div>
|
||||
<div className="eyebrow" style={{marginBottom:8}}>上次保养</div>
|
||||
<div className="col gap-1">
|
||||
<div className="between"><span className="muted">日期</span><span className="strong">{v.lastMaintDays}天前</span></div>
|
||||
<div className="between"><span className="muted">里程</span><span className="mono">{v.lastMaintKm.toLocaleString()} km</span></div>
|
||||
<div className="between"><span className="muted">项目</span><span>常规保养·机油机滤</span></div>
|
||||
<div className="between"><span className="muted">技师</span><span>李工</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="eyebrow" style={{marginBottom:8}}>下次保养预约</div>
|
||||
<div className="col gap-1">
|
||||
<div className="between"><span className="muted">里程节点</span><span className="mono">{v.nextMaintKm.toLocaleString()} km</span></div>
|
||||
<div className="between"><span className="muted">距离</span><span className="strong" style={{color: v.kmToMaint < 1000 ? "var(--warn)" : "var(--fg-0)"}}>{v.kmToMaint.toLocaleString()} km</span></div>
|
||||
<div className="between"><span className="muted">推荐站点</span><span>羚牛 · 嘉兴服务站</span></div>
|
||||
<div className="between"><span className="muted">通知</span><span className="strong">{v.deptLead} · {v.deptName}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DTC list */}
|
||||
<div className="panel">
|
||||
<div className="panel-head"><Icon name="wrench" size={13}/><span className="title">故障码 · DTC</span><span className="chip" style={{marginLeft:"auto"}}>{v.asset === "abnormal" ? "2 active" : "0 active"}</span></div>
|
||||
<div style={{padding:0}}>
|
||||
{(v.asset === "abnormal" ? [
|
||||
{c:"P0A7F", n:"电池组性能下降", st:"warn", t:"3小时前"},
|
||||
{c:"U0073", n:"控制模块通信总线A关闭", st:"warn", t:"2天前"},
|
||||
{c:"P0563", n:"系统电压高", st:"info", t:"已清除"},
|
||||
] : [
|
||||
{c:"P0563", n:"系统电压高", st:"info", t:"已清除"},
|
||||
]).map((d,i,arr)=>(
|
||||
<div key={i} className="between" style={{padding:"10px 14px", borderBottom: i < arr.length-1 ? "1px solid var(--border-1)" : "none", fontSize:12}}>
|
||||
<div>
|
||||
<span className="mono strong">{d.c}</span>
|
||||
<span className="muted" style={{marginLeft:8, fontSize:11}}>{d.n}</span>
|
||||
</div>
|
||||
<div className="mid gap-2">
|
||||
<span className={"chip " + d.st}>{d.st === "warn" ? "ACTIVE" : "CLEAR"}</span>
|
||||
<span className="muted mono" style={{fontSize:10}}>{d.t}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Data source / signal channels */}
|
||||
<div className="panel" style={{gridColumn:"1 / -1"}}>
|
||||
<div className="panel-head">
|
||||
<Icon name="wifi" size={13}/>
|
||||
<span className="title">数据源 · 信号通道</span>
|
||||
<div className="actions">
|
||||
<span className="chip ok"><span className="dot ok" style={{width:5,height:5}}/> 双源在线</span>
|
||||
<span className="chip">最近上行 · 218ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{padding:14, display:"grid", gridTemplateColumns:"1fr 1fr 1fr", gap:12}}>
|
||||
{[
|
||||
{
|
||||
src:"T", title:"TBOX · 整车遥信",
|
||||
spec:"GB/T 32960-2016 / GB/T 40432",
|
||||
sub:"国标新能源车数据", up:"10 s",
|
||||
signals:[
|
||||
{n:"整车状态", c:"54 项", st:"ok"},
|
||||
{n:"驱动电机", c:"18 项", st:"ok"},
|
||||
{n:"动力电池", c:"32 项", st:"ok"},
|
||||
{n:"燃料电池/H₂", c:"24 项", st:"ok"},
|
||||
{n:"极值/故障", c:"12 项", st:"warn"},
|
||||
],
|
||||
health: 99.6,
|
||||
},
|
||||
{
|
||||
src:"J", title:"JT/T 808 · 北斗位置",
|
||||
spec:"JT/T 808-2019 部标",
|
||||
sub:"位置/报警/参数", up:"30 s",
|
||||
signals:[
|
||||
{n:"GNSS位置", c:"1 帧", st:"ok"},
|
||||
{n:"行驶记录仪", c:"8 项", st:"ok"},
|
||||
{n:"报警/事件", c:"64 类", st:"ok"},
|
||||
{n:"参数下发", c:"42 项", st:"ok"},
|
||||
{n:"电子围栏", c:"6 区域", st:"ok"},
|
||||
],
|
||||
health: 100,
|
||||
},
|
||||
{
|
||||
src:"J", title:"JT/T 1078 · 视频",
|
||||
spec:"JT/T 1078-2016 部标",
|
||||
sub:"4路实时音视频", up:"H.264",
|
||||
signals:[
|
||||
{n:"CH1 前向", c:"720p", st:"ok"},
|
||||
{n:"CH2 驾驶员", c:"720p", st:"ok"},
|
||||
{n:"CH3 后视", c:"720p", st:"ok"},
|
||||
{n:"CH4 车厢", c:"480p", st:"warn"},
|
||||
{n:"录像存储", c:"1.2 TB", st:"ok"},
|
||||
],
|
||||
health: 92.8,
|
||||
},
|
||||
].map((s,i)=>(
|
||||
<div key={i} style={{padding:14, background:"var(--bg-2)", borderRadius:6, border:"1px solid var(--border-1)"}}>
|
||||
<div className="between">
|
||||
<div className="mid gap-2">
|
||||
<SourceBadge src={s.src} size="md"/>
|
||||
<span className="strong" style={{fontSize:12}}>{s.title}</span>
|
||||
</div>
|
||||
<span className="mono muted" style={{fontSize:10}}>{s.up}</span>
|
||||
</div>
|
||||
<div className="muted mono" style={{fontSize:10, marginTop:4}}>{s.spec}</div>
|
||||
<div className="muted" style={{fontSize:11, marginTop:2}}>{s.sub}</div>
|
||||
<div style={{marginTop:10, display:"flex", flexDirection:"column", gap:4}}>
|
||||
{s.signals.map((sig,j)=>(
|
||||
<div key={j} className="between" style={{fontSize:11, padding:"4px 0"}}>
|
||||
<span className="mid gap-2"><span className={"dot " + sig.st} style={{width:5,height:5}}/><span className="muted">{sig.n}</span></span>
|
||||
<span className="mono">{sig.c}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{marginTop:10, paddingTop:10, borderTop:"1px solid var(--border-1)"}}>
|
||||
<div className="between" style={{fontSize:10}}>
|
||||
<span className="muted">通道完好率</span>
|
||||
<span className="mono strong" style={{color: s.health > 99 ? "var(--ok)" : s.health > 95 ? "var(--info)" : "var(--warn)"}}>{s.health}%</span>
|
||||
</div>
|
||||
<div style={{height:3, background:"var(--bg-3)", borderRadius:2, marginTop:4, overflow:"hidden"}}>
|
||||
<div style={{height:"100%", width: s.health + "%", background: s.health > 99 ? "var(--ok)" : s.health > 95 ? "var(--info)" : "var(--warn)"}}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
window.ArtboardDetail = ArtboardDetail;
|
||||
496
artboards/esg.jsx
Normal file
496
artboards/esg.jsx
Normal file
@@ -0,0 +1,496 @@
|
||||
// artboard-esg.jsx — ESG · Carbon Reduction Cockpit (light green theme)
|
||||
// Mirrors reference: white ground, multi-tier green, China choropleth + KPIs
|
||||
|
||||
const ChinaMapMini = ({ w = 480, h = 360 }) => {
|
||||
// Simplified provincial silhouette — abstract, recognisable. Levels keyed by data.
|
||||
// Each path is roughly positioned on a 480x360 canvas of mainland.
|
||||
const G = { 4: "#1F8B4C", 3: "#4FB46E", 2: "#9DD3A6", 1: "#D7EBD2", 0: "#EEF5EC" };
|
||||
const provs = [
|
||||
// [name, level, polygon]
|
||||
{n:"新疆", l:1, d:"M40 90 L130 70 L150 110 L120 160 L60 150 Z"},
|
||||
{n:"西藏", l:0, d:"M70 160 L150 140 L180 180 L140 220 L80 200 Z"},
|
||||
{n:"青海", l:1, d:"M150 130 L210 130 L210 175 L160 180 Z"},
|
||||
{n:"甘肃", l:2, d:"M180 100 L240 80 L260 120 L220 140 L200 130 Z"},
|
||||
{n:"内蒙", l:3, d:"M180 60 L320 40 L350 70 L330 95 L260 100 L210 90 Z"},
|
||||
{n:"宁夏", l:1, d:"M225 110 L245 100 L255 125 L235 130 Z"},
|
||||
{n:"陕西", l:3, d:"M245 110 L280 105 L290 160 L260 180 L245 145 Z"},
|
||||
{n:"山西", l:2, d:"M285 90 L310 88 L320 145 L295 150 Z"},
|
||||
{n:"河北", l:2, d:"M310 75 L355 70 L365 115 L325 130 L315 100 Z"},
|
||||
{n:"北京", l:4, d:"M335 82 L355 80 L355 95 L338 95 Z"},
|
||||
{n:"天津", l:3, d:"M358 92 L370 92 L370 105 L358 105 Z"},
|
||||
{n:"辽宁", l:2, d:"M360 60 L405 55 L420 90 L385 105 L362 88 Z"},
|
||||
{n:"吉林", l:1, d:"M395 35 L440 30 L450 65 L410 70 Z"},
|
||||
{n:"黑龙江", l:1, d:"M390 5 L460 0 L470 35 L420 40 Z"},
|
||||
{n:"山东", l:3, d:"M325 130 L380 125 L390 165 L335 165 Z"},
|
||||
{n:"河南", l:3, d:"M280 155 L330 150 L335 195 L290 200 Z"},
|
||||
{n:"江苏", l:3, d:"M345 165 L390 165 L395 200 L350 205 Z"},
|
||||
{n:"上海", l:4, d:"M390 195 L405 195 L405 210 L390 210 Z"},
|
||||
{n:"安徽", l:3, d:"M315 195 L350 200 L355 235 L320 235 Z"},
|
||||
{n:"浙江", l:4, d:"M370 210 L400 210 L405 245 L375 245 Z"},
|
||||
{n:"湖北", l:3, d:"M270 195 L320 200 L320 235 L275 230 Z"},
|
||||
{n:"四川", l:2, d:"M195 175 L260 170 L270 230 L210 225 L195 205 Z"},
|
||||
{n:"重庆", l:2, d:"M250 215 L275 210 L275 230 L255 232 Z"},
|
||||
{n:"贵州", l:2, d:"M225 235 L275 235 L275 265 L235 265 Z"},
|
||||
{n:"云南", l:2, d:"M170 240 L235 235 L240 285 L185 290 L160 270 Z"},
|
||||
{n:"湖南", l:3, d:"M275 235 L320 235 L320 270 L280 270 Z"},
|
||||
{n:"江西", l:3, d:"M320 235 L360 235 L365 275 L325 275 Z"},
|
||||
{n:"福建", l:3, d:"M360 245 L395 245 L395 285 L360 280 Z"},
|
||||
{n:"广东", l:4, d:"M270 270 L355 275 L355 305 L280 305 Z"},
|
||||
{n:"广西", l:2, d:"M210 270 L275 270 L275 305 L215 305 Z"},
|
||||
{n:"海南", l:1, d:"M250 320 L275 320 L275 340 L250 340 Z"},
|
||||
{n:"台湾", l:0, d:"M395 270 L410 270 L410 300 L398 300 Z"},
|
||||
];
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height="100%" style={{display:"block"}}>
|
||||
<defs>
|
||||
<pattern id="esgGrid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#E8F1E5" strokeWidth="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="0" width={w} height={h} fill="url(#esgGrid)"/>
|
||||
{/* sea-line decoration */}
|
||||
<path d="M 410 60 Q 440 130 430 220 Q 420 280 380 320" fill="none" stroke="#D5EBEB" strokeWidth="1" strokeDasharray="3 3"/>
|
||||
{provs.map((p,i) => (
|
||||
<g key={i}>
|
||||
<path d={p.d} fill={G[p.l]} stroke="#FFFFFF" strokeWidth="1.2"/>
|
||||
</g>
|
||||
))}
|
||||
{/* highlighted city marker — Beijing */}
|
||||
<g transform="translate(345 88)">
|
||||
<circle r="9" fill="#1F8B4C" opacity="0.18"/>
|
||||
<circle r="4" fill="#1F8B4C"/>
|
||||
<circle r="2" fill="#FFFFFF"/>
|
||||
</g>
|
||||
{/* Shanghai */}
|
||||
<g transform="translate(398 202)">
|
||||
<circle r="3.5" fill="#1F8B4C"/>
|
||||
</g>
|
||||
{/* Guangzhou */}
|
||||
<g transform="translate(310 296)">
|
||||
<circle r="3.5" fill="#1F8B4C"/>
|
||||
</g>
|
||||
{/* Compass / scale */}
|
||||
<g transform="translate(20 320)" fontFamily="JetBrains Mono" fontSize="9" fill="#5C7A66">
|
||||
<line x1="0" y1="0" x2="40" y2="0" stroke="#5C7A66" strokeWidth="1"/>
|
||||
<line x1="0" y1="-3" x2="0" y2="3" stroke="#5C7A66" strokeWidth="1"/>
|
||||
<line x1="40" y1="-3" x2="40" y2="3" stroke="#5C7A66" strokeWidth="1"/>
|
||||
<text x="20" y="14" textAnchor="middle">800 km</text>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Curve helpers
|
||||
const ESGSpark = ({ data, w, h, color = "#1F8B4C", fill = true, baseline = 0 }) => {
|
||||
const max = Math.max(...data) * 1.1, min = Math.min(...data, 0);
|
||||
const range = max - min || 1;
|
||||
const pts = data.map((v,i) => `${(i/(data.length-1))*w},${h - ((v-min)/range)*(h-baseline) - baseline}`);
|
||||
const d = "M" + pts.join(" L");
|
||||
const fillD = d + ` L${w},${h} L0,${h} Z`;
|
||||
return (
|
||||
<svg width={w} height={h} style={{display:"block"}}>
|
||||
{fill && <path d={fillD} fill={color} opacity="0.12"/>}
|
||||
<path d={d} fill="none" stroke={color} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const ESGBars = ({ data, w, h, color = "#1F8B4C", labels }) => {
|
||||
const max = Math.max(...data) * 1.15;
|
||||
const bw = w / data.length * 0.62;
|
||||
const gap = w / data.length * 0.38;
|
||||
return (
|
||||
<svg width={w} height={h} style={{display:"block"}}>
|
||||
{/* y gridlines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p,i) => (
|
||||
<line key={i} x1="0" y1={h - p*h*0.85 - 14} x2={w} y2={h - p*h*0.85 - 14} stroke="#E8F1E5" strokeWidth="1"/>
|
||||
))}
|
||||
{data.map((v,i) => {
|
||||
const bh = (v / max) * (h * 0.85);
|
||||
const x = i * (bw + gap) + gap/2;
|
||||
const y = h - bh - 14;
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x} y={y} width={bw} height={bh} fill={color} rx="1"/>
|
||||
{labels && <text x={x + bw/2} y={h - 2} textAnchor="middle" fontSize="9" fill="#8FA897" fontFamily="JetBrains Mono">{labels[i]}</text>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const DonutSeg = ({ size = 140, segments, label }) => {
|
||||
const r = size/2 - 12, cx = size/2, cy = size/2;
|
||||
const total = segments.reduce((a,s) => a + s.v, 0);
|
||||
let acc = 0;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#F0F5EE" strokeWidth="14"/>
|
||||
{segments.map((s,i) => {
|
||||
const start = (acc / total) * Math.PI * 2 - Math.PI/2;
|
||||
acc += s.v;
|
||||
const end = (acc / total) * Math.PI * 2 - Math.PI/2;
|
||||
const large = (end - start) > Math.PI ? 1 : 0;
|
||||
const x1 = cx + r * Math.cos(start), y1 = cy + r * Math.sin(start);
|
||||
const x2 = cx + r * Math.cos(end), y2 = cy + r * Math.sin(end);
|
||||
return (
|
||||
<path key={i} d={`M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`}
|
||||
stroke={s.c} strokeWidth="14" fill="none" strokeLinecap="butt"/>
|
||||
);
|
||||
})}
|
||||
<text x={cx} y={cy - 4} textAnchor="middle" fontSize="10" fill="#8FA897">合计</text>
|
||||
<text x={cx} y={cy + 14} textAnchor="middle" fontSize="20" fontWeight="600" fill="#1A2A1F" fontFamily="JetBrains Mono">{label}</text>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const ArtboardESG = () => {
|
||||
// mock data
|
||||
const monthlyReduction = [0.95, 1.10, 1.32, 1.55, 1.42, 1.38, 0, 0, 0, 0, 0, 0]; // 吨
|
||||
const monthLabels = ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"];
|
||||
const mileageMonthly = [120, 145, 168, 152, 195, 180, 0,0,0,0,0,0];
|
||||
const h2Monthly = [180, 220, 255, 235, 290, 270, 0,0,0,0,0,0];
|
||||
|
||||
const vehicles = [
|
||||
{p:"浙F·8A03F", km:"18,250 km", h2:"257 m³", reduction:"24.38 kg", revenue:"18.785 元"},
|
||||
{p:"浙F·2C57G", km:"5,367 km", h2:"75 m³", reduction:"7.13 kg", revenue:"181.785 元"},
|
||||
{p:"浙F·9D14B", km:"45,000 km", h2:"234 m³", reduction:"12.82 kg", revenue:"194.382 元"},
|
||||
{p:"浙F·6E72H", km:"55,387 km", h2:"218 m³", reduction:"17.94 kg", revenue:"152.578 元"},
|
||||
{p:"浙F·1B49K", km:"55,925 km", h2:"203 m³", reduction:"17.87 kg", revenue:"148.392 元"},
|
||||
{p:"浙F·4F88M", km:"887,820 km",h2:"152 m³", reduction:"9.6 kg", revenue:"73.627 元"},
|
||||
{p:"浙F·7G31N", km:"3,762 km", h2:"134 m³", reduction:"13.91 kg", revenue:"66.991 元"},
|
||||
{p:"浙F·3H56P", km:"30,058 km", h2:"125 m³", reduction:"13.87 kg", revenue:"82.578 元"},
|
||||
{p:"浙F·5J92Q", km:"3,701 km", h2:"121 m³", reduction:"8.49 kg", revenue:"103.928 元"},
|
||||
{p:"浙F·8K27R", km:"5,829 km", h2:"165 m³", reduction:"15.62 kg", revenue:"76.354 元"},
|
||||
{p:"浙F·2L68S", km:"73,587 km", h2:"185 m³", reduction:"4.85 kg", revenue:"54.812 元"},
|
||||
{p:"浙F·9M03T", km:"38,747 km", h2:"168 m³", reduction:"11.57 kg", revenue:"72.836 元"},
|
||||
];
|
||||
|
||||
const trades = [
|
||||
{ex:"上海环境能源交易所", item:"SHEA", price:"74.28", region:"中国·上海"},
|
||||
{ex:"湖北碳排放权交易中心", item:"CCER", price:"39.33", region:"中国·武汉"},
|
||||
{ex:"全国碳市场自愿减排", item:"CCER", price:"86.55", region:"全国"},
|
||||
{ex:"福建海峡股权交易中心", item:"碳排放配额", price:"25", region:"中国·福州"},
|
||||
{ex:"天津排放权交易所", item:"碳排放配额", price:"73.60", region:"中国·天津"},
|
||||
{ex:"广东省碳排放权交易所", item:"碳排放配额", price:"82.50", region:"中国·广州"},
|
||||
];
|
||||
|
||||
const fleetMix = [
|
||||
{n:"4.5吨冷链车", v:36.2, c:"#1F8B4C"},
|
||||
{n:"18吨重卡", v:4.0, c:"#9DD3A6"},
|
||||
{n:"49吨牵引车", v:21.7, c:"#4FB46E"},
|
||||
{n:"18吨厢式物流车",v:29.5, c:"#76C18B"},
|
||||
{n:"4.5吨货车", v:6.6, c:"#C5E2BD"},
|
||||
{n:"客车", v:2.1, c:"#E5F1DF"},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app" data-theme="light" style={{background:"#F2F5EF", colorScheme:"light"}}>
|
||||
<Sidebar active="esg"/>
|
||||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
|
||||
{/* Top brand bar */}
|
||||
<div style={{
|
||||
height:48, flex:"0 0 48px",
|
||||
background:"#FFFFFF",
|
||||
borderBottom:"1px solid var(--border-1)",
|
||||
display:"flex", alignItems:"center", padding:"0 20px", gap:16,
|
||||
}}>
|
||||
<div className="mid gap-2">
|
||||
<img src="assets/logo_light.svg" alt="羚牛氢能 Lingniu" style={{height:32, display:"block"}}/>
|
||||
<div style={{fontSize:9, color:"#5C7A66", letterSpacing:"0.12em", fontFamily:"JetBrains Mono", paddingLeft:6, borderLeft:"1px solid #D4E2D5", marginLeft:4}}>
|
||||
HYDROGEN<br/>MOBILITY
|
||||
</div>
|
||||
</div>
|
||||
<div style={{flex:1, textAlign:"center", fontWeight:500, color:"#1F8B4C", letterSpacing:"0.06em", fontSize:18, fontFamily:"IBM Plex Sans"}}>
|
||||
Lingniu ESG Link
|
||||
</div>
|
||||
<div className="mono" style={{fontSize:11, color:"#1F8B4C", background:"#DCEFD7", padding:"4px 10px", borderRadius:4, border:"1px solid #B5DDB1"}}>2026-04-28 周二 12:15:13</div>
|
||||
<div className="icon-btn" style={{color:"#5C7A66"}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .61.36 1.16.91 1.39l.13.05A1.65 1.65 0 0 0 21 13"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body grid */}
|
||||
<div className="scroll" style={{flex:1, padding:14, display:"grid", gridTemplateColumns:"380px 1fr 380px", gap:12, gridAutoRows:"min-content"}}>
|
||||
|
||||
{/* ── LEFT COLUMN ── */}
|
||||
<div className="col gap-3" style={{gap:12}}>
|
||||
{/* Two top KPIs: emissions & H₂ */}
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
|
||||
<div className="panel" style={{padding:14}}>
|
||||
<div className="between">
|
||||
<div>
|
||||
<div style={{fontSize:11, color:"#5C7A66"}}>当日减碳量</div>
|
||||
<div className="mono" style={{fontSize:22, fontWeight:600, color:"#1F8B4C", marginTop:4}}>29486.78<span style={{fontSize:11, marginLeft:3, color:"#5C7A66", fontWeight:400}}>kg</span></div>
|
||||
</div>
|
||||
<svg width="34" height="34" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" fill="#DCEFD7"/>
|
||||
<path d="M20 8 c-4 4 -8 8 -8 14 a8 8 0 0 0 16 0 c0 -6 -4 -10 -8 -14z" fill="#1F8B4C"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel" style={{padding:14}}>
|
||||
<div className="between">
|
||||
<div>
|
||||
<div style={{fontSize:11, color:"#5C7A66"}}>当日H₂用量</div>
|
||||
<div className="mono" style={{fontSize:22, fontWeight:600, color:"#1F8B4C", marginTop:4}}>974.7<span style={{fontSize:11, marginLeft:3, color:"#5C7A66", fontWeight:400}}>kg</span></div>
|
||||
</div>
|
||||
<svg width="34" height="34" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" fill="#D5EBEB"/>
|
||||
<circle cx="14" cy="22" r="3" fill="#2E8C8C"/>
|
||||
<circle cx="22" cy="16" r="2.4" fill="#2E8C8C"/>
|
||||
<circle cx="26" cy="24" r="3.6" fill="#2E8C8C"/>
|
||||
<circle cx="18" cy="14" r="2" fill="#2E8C8C"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annual cumulative reduction — hero card */}
|
||||
<div className="panel" style={{
|
||||
padding:16, position:"relative", overflow:"hidden",
|
||||
background:"linear-gradient(135deg, #DCEFD7 0%, #FFFFFF 70%)",
|
||||
border:"1px solid #B5DDB1",
|
||||
}}>
|
||||
<div style={{fontSize:11, color:"#5C7A66"}}>今年累计减碳</div>
|
||||
<div style={{display:"flex", alignItems:"baseline", gap:6, marginTop:6}}>
|
||||
<span className="mono" style={{fontSize:34, fontWeight:600, color:"#1F8B4C", letterSpacing:"-0.02em"}}>4567.14</span>
|
||||
<span style={{fontSize:13, color:"#5C7A66"}}>吨</span>
|
||||
</div>
|
||||
<div style={{fontSize:10, color:"#5C7A66", marginTop:4, display:"flex", alignItems:"center", gap:6}}>
|
||||
<span style={{display:"inline-block", width:14, height:1, background:"#1F8B4C"}}/>
|
||||
相当于种植 18.5 万棵树
|
||||
</div>
|
||||
{/* abstract tree silhouette */}
|
||||
<svg width="100%" height="50" viewBox="0 0 280 50" style={{marginTop:10, opacity:0.55}}>
|
||||
{[...Array(28)].map((_,i) => {
|
||||
const x = i*10 + 4;
|
||||
const heights = [22, 30, 26, 34, 28, 32, 24, 36, 30, 28, 32, 26, 34, 30];
|
||||
const h = heights[i % heights.length];
|
||||
return (
|
||||
<g key={i} transform={`translate(${x} ${50-h})`}>
|
||||
<path d={`M 4 ${h} L 0 ${h-h*0.7} L 3 ${h-h*0.7} L -1 ${h-h*0.85} L 2 ${h-h*0.85} L 0 ${h} z M 4 ${h} L 8 ${h-h*0.7} L 5 ${h-h*0.7} L 9 ${h-h*0.85} L 6 ${h-h*0.85} L 8 ${h} z`} fill="#1F8B4C" opacity={0.4 + (i%3)*0.18}/>
|
||||
<rect x="3.5" y={h-2} width="1" height="2" fill="#5C7A66"/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Monthly reduction bars */}
|
||||
<div className="panel" style={{padding:14}}>
|
||||
<div className="between" style={{marginBottom:10}}>
|
||||
<span style={{fontWeight:600, fontSize:13, color:"#1A2A1F"}}>月度碳减排</span>
|
||||
<span className="chip" style={{fontSize:10}}>单位 · 吨</span>
|
||||
</div>
|
||||
<ESGBars data={monthlyReduction} w={350} h={150} labels={monthLabels}/>
|
||||
</div>
|
||||
|
||||
{/* Monthly mileage / H2 */}
|
||||
<div className="panel" style={{padding:14}}>
|
||||
<div className="between" style={{marginBottom:10}}>
|
||||
<span style={{fontWeight:600, fontSize:13, color:"#1A2A1F"}}>月度行驶里程 & 用氢量</span>
|
||||
</div>
|
||||
<div className="mid gap-3" style={{fontSize:10, color:"#5C7A66", marginBottom:8}}>
|
||||
<span className="mid gap-1"><span className="dot" style={{background:"#1F8B4C", width:8, height:8, borderRadius:1}}/>用氢量</span>
|
||||
<span className="mid gap-1"><span className="dot" style={{background:"#9DD3A6", width:8, height:8, borderRadius:1}}/>行驶里程</span>
|
||||
<span style={{marginLeft:"auto", fontFamily:"JetBrains Mono"}}>kg / km</span>
|
||||
</div>
|
||||
<svg width="350" height="135" viewBox="0 0 350 135" style={{display:"block"}}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p,i) => (
|
||||
<line key={i} x1="0" y1={120 - p*100} x2="350" y2={120 - p*100} stroke="#E8F1E5" strokeWidth="1"/>
|
||||
))}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p,i) => (
|
||||
<text key={i} x="0" y={124 - p*100} fontSize="9" fill="#8FA897" fontFamily="JetBrains Mono">{Math.round(p*400)}</text>
|
||||
))}
|
||||
{/* Curves */}
|
||||
{(() => {
|
||||
const m = (arr, max) => arr.map((v,i)=>`${(i/(arr.length-1))*350},${120 - (v/max)*100}`);
|
||||
const p1 = "M" + m(h2Monthly, 400).join(" L");
|
||||
const p2 = "M" + m(mileageMonthly, 250).join(" L");
|
||||
return (
|
||||
<>
|
||||
<path d={p1 + " L350,120 L0,120 z"} fill="#1F8B4C" opacity="0.13"/>
|
||||
<path d={p1} fill="none" stroke="#1F8B4C" strokeWidth="2"/>
|
||||
<path d={p2} fill="none" stroke="#9DD3A6" strokeWidth="2"/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{monthLabels.map((l,i) => (
|
||||
<text key={i} x={(i/(monthLabels.length-1))*350} y="132" textAnchor="middle" fontSize="8" fill="#8FA897" fontFamily="JetBrains Mono">{l}</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── CENTER COLUMN ── */}
|
||||
<div className="col" style={{gap:12}}>
|
||||
{/* Map panel */}
|
||||
<div className="panel" style={{padding:14, paddingBottom:18}}>
|
||||
<div className="between">
|
||||
<div className="mid gap-2">
|
||||
<span style={{fontWeight:600, fontSize:14, color:"#1A2A1F"}}>羚牛全国车辆信息</span>
|
||||
<span className="chip accent" style={{fontSize:10}}>加氢站</span>
|
||||
</div>
|
||||
<span className="chip" style={{fontSize:10, color:"#1F8B4C", borderColor:"#B5DDB1", background:"#DCEFD7"}}>实时反馈</span>
|
||||
</div>
|
||||
|
||||
<div style={{position:"relative", marginTop:10, height:380}}>
|
||||
<ChinaMapMini w={580} h={380}/>
|
||||
{/* Overlay info card */}
|
||||
<div style={{
|
||||
position:"absolute", top:30, left:200,
|
||||
background:"rgba(255,255,255,0.95)", padding:"10px 14px",
|
||||
borderRadius:6, border:"1px solid #B5DDB1", fontSize:11, color:"#2E4234",
|
||||
boxShadow:"0 4px 16px rgba(31,80,46,.08)",
|
||||
}}>
|
||||
<div style={{fontSize:11, color:"#1F8B4C", fontWeight:600}}>呼和浩特市钢铁工业园区</div>
|
||||
<div className="mid" style={{gap:14, marginTop:6, fontFamily:"JetBrains Mono", fontSize:10}}>
|
||||
<div><div style={{color:"#8FA897"}}>GPS实时数</div><div style={{color:"#1A2A1F", fontWeight:600}}>17</div></div>
|
||||
<div><div style={{color:"#8FA897"}}>当日总减碳</div><div style={{color:"#1A2A1F", fontWeight:600}}>2469.62 kg</div></div>
|
||||
<div><div style={{color:"#8FA897"}}>当日加氢量</div><div style={{color:"#1A2A1F", fontWeight:600}}>9.31 kg</div></div>
|
||||
<div><div style={{color:"#8FA897"}}>当日里程</div><div style={{color:"#1A2A1F", fontWeight:600}}>724.6 kg</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div style={{position:"absolute", bottom:16, left:16, fontSize:10, color:"#5C7A66"}}>
|
||||
<div style={{marginBottom:4, fontWeight:600, color:"#2E4234"}}>车辆数</div>
|
||||
{[
|
||||
{l:"≥ 300 辆", c:"#1F8B4C"},
|
||||
{l:"100–300 辆", c:"#4FB46E"},
|
||||
{l:"50–100 辆", c:"#9DD3A6"},
|
||||
{l:"< 50 辆", c:"#D7EBD2"},
|
||||
].map((x,i) => (
|
||||
<div key={i} className="mid gap-1" style={{marginTop:2}}>
|
||||
<span style={{display:"inline-block", width:14, height:10, background:x.c, border:"1px solid #FFFFFF"}}/>
|
||||
<span style={{fontFamily:"JetBrains Mono"}}>{x.l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carbon trades table */}
|
||||
<div className="panel" style={{padding:0}}>
|
||||
<div className="between" style={{padding:"12px 14px", borderBottom:"1px solid var(--border-1)"}}>
|
||||
<span style={{fontWeight:600, fontSize:13, color:"#1A2A1F"}}>碳交易行情</span>
|
||||
<span className="chip" style={{fontSize:10}}>实时报价</span>
|
||||
</div>
|
||||
<table className="tbl" style={{fontSize:11}}>
|
||||
<thead>
|
||||
<tr><th>交易所</th><th>项目</th><th>价格 (RMB)</th><th>地区</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((t,i) => (
|
||||
<tr key={i}>
|
||||
<td style={{color:"#1A2A1F"}}>{t.ex}</td>
|
||||
<td className="mono" style={{color:"#1F8B4C", fontWeight:600}}>{t.item}</td>
|
||||
<td className="mono" style={{color:"#1A2A1F", fontWeight:600}}>{t.price}</td>
|
||||
<td style={{color:"#5C7A66"}}>{t.region}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT COLUMN ── */}
|
||||
<div className="col" style={{gap:12}}>
|
||||
{/* Two top KPIs: vehicle total & cumulative mileage */}
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
|
||||
<div className="panel" style={{padding:14}}>
|
||||
<div className="between">
|
||||
<div>
|
||||
<div style={{fontSize:11, color:"#5C7A66"}}>车辆总数</div>
|
||||
<div className="mono" style={{fontSize:22, fontWeight:600, color:"#1F8B4C", marginTop:4}}>1006<span style={{fontSize:11, marginLeft:3, color:"#5C7A66", fontWeight:400}}>辆</span></div>
|
||||
</div>
|
||||
<svg width="34" height="34" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" fill="#DCEFD7"/>
|
||||
<rect x="11" y="17" width="18" height="10" rx="2" fill="#1F8B4C"/>
|
||||
<circle cx="14" cy="29" r="2" fill="#1F8B4C"/>
|
||||
<circle cx="26" cy="29" r="2" fill="#1F8B4C"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel" style={{padding:14}}>
|
||||
<div className="between">
|
||||
<div>
|
||||
<div style={{fontSize:11, color:"#5C7A66"}}>当日行驶里程</div>
|
||||
<div className="mono" style={{fontSize:22, fontWeight:600, color:"#1F8B4C", marginTop:4}}>64508.42<span style={{fontSize:11, marginLeft:3, color:"#5C7A66", fontWeight:400}}>km</span></div>
|
||||
</div>
|
||||
<svg width="34" height="34" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" fill="#D5EBEB"/>
|
||||
<path d="M10 22 a10 10 0 0 1 20 0" fill="none" stroke="#2E8C8C" strokeWidth="2"/>
|
||||
<path d="M20 22 L25 14" stroke="#2E8C8C" strokeWidth="2" strokeLinecap="round"/>
|
||||
<circle cx="20" cy="22" r="1.5" fill="#2E8C8C"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle live monitor table */}
|
||||
<div className="panel" style={{padding:0, flex:1, minHeight:380, display:"flex", flexDirection:"column"}}>
|
||||
<div className="between" style={{padding:"12px 14px", borderBottom:"1px solid var(--border-1)"}}>
|
||||
<span style={{fontWeight:600, fontSize:13, color:"#1A2A1F"}}>车辆实时监控</span>
|
||||
<span className="chip ok" style={{fontSize:10}}>· LIVE</span>
|
||||
</div>
|
||||
<div className="scroll" style={{flex:1}}>
|
||||
<table className="tbl" style={{fontSize:11}}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>车牌号</th><th>总里程</th><th>当日里程</th><th>当日减碳</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vehicles.map((v,i) => (
|
||||
<tr key={i}>
|
||||
<td className="mono" style={{color:"#1A2A1F", fontWeight:600}}>{v.p}</td>
|
||||
<td className="mono" style={{color:"#5C7A66"}}>{v.km}</td>
|
||||
<td className="mono" style={{color:"#5C7A66"}}>{v.h2}</td>
|
||||
<td className="mono" style={{color:"#1F8B4C", fontWeight:600}}>{v.reduction}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fleet mix donut */}
|
||||
<div className="panel" style={{padding:14}}>
|
||||
<div className="between" style={{marginBottom:10}}>
|
||||
<span style={{fontWeight:600, fontSize:13, color:"#1A2A1F"}}>车型结构分析</span>
|
||||
</div>
|
||||
<div className="mid gap-3" style={{alignItems:"center"}}>
|
||||
<DonutSeg size={130} segments={fleetMix} label="1006"/>
|
||||
<div style={{flex:1, display:"flex", flexDirection:"column", gap:5, fontSize:11}}>
|
||||
{fleetMix.map((f,i) => (
|
||||
<div key={i} className="between">
|
||||
<span className="mid gap-2">
|
||||
<span style={{width:10, height:10, borderRadius:2, background:f.c}}/>
|
||||
<span style={{color:"#5C7A66"}}>{f.n}</span>
|
||||
</span>
|
||||
<span className="mono" style={{color:"#1A2A1F", fontWeight:600}}>{f.v}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
height:24, flex:"0 0 24px",
|
||||
borderTop:"1px solid var(--border-1)", background:"#FFFFFF",
|
||||
display:"flex", alignItems:"center", justifyContent:"center", gap:14,
|
||||
fontSize:10, color:"#8FA897",
|
||||
}}>
|
||||
<span>© 2026 羚牛氢能 · Lingniu Hydrogen Mobility · All Rights Reserved</span>
|
||||
<span style={{color:"#1F8B4C"}}>· API 接口处理 · Build v4.2.0-stable</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
window.ArtboardESG = ArtboardESG;
|
||||
606
artboards/history.jsx
Normal file
606
artboards/history.jsx
Normal file
@@ -0,0 +1,606 @@
|
||||
// artboard-history.jsx — 数据检索 (data search studio)
|
||||
// Flow: ① 选车辆+时段 → ② 选数据项目 → ③ 选展示方式 → ④ 渲染结果
|
||||
|
||||
const DATA_GROUPS = [
|
||||
{
|
||||
id: "vehicle", label: "车辆运行", icon: "car", color: "var(--info)",
|
||||
items: [
|
||||
{id:"speed", l:"速度", u:"km/h", src:"TBOX", freq:"10s"},
|
||||
{id:"odometer", l:"累计里程", u:"km", src:"TBOX", freq:"60s"},
|
||||
{id:"trip_km", l:"行程里程", u:"km", src:"TBOX", freq:"事件"},
|
||||
{id:"engine_run", l:"运行时长", u:"s", src:"TBOX", freq:"60s"},
|
||||
{id:"gear", l:"档位", u:"-", src:"TBOX", freq:"10s"},
|
||||
{id:"steer", l:"方向盘转角", u:"°", src:"CAN", freq:"100ms"},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "energy", label: "氢电系统", icon: "h2", color: "var(--accent)",
|
||||
items: [
|
||||
{id:"soc", l:"动力电池 SOC", u:"%", src:"BMS", freq:"10s"},
|
||||
{id:"batt_volt", l:"电池总压", u:"V", src:"BMS", freq:"10s"},
|
||||
{id:"batt_curr", l:"电池电流", u:"A", src:"BMS", freq:"10s"},
|
||||
{id:"batt_temp", l:"电池温度", u:"℃", src:"BMS", freq:"10s"},
|
||||
{id:"h2_pressure",l:"H₂ 压力", u:"MPa", src:"FCU", freq:"10s"},
|
||||
{id:"h2_flow", l:"H₂ 流量", u:"g/s", src:"FCU", freq:"10s"},
|
||||
{id:"fc_power", l:"电堆输出功率", u:"kW", src:"FCU", freq:"10s"},
|
||||
{id:"fc_temp", l:"电堆温度", u:"℃", src:"FCU", freq:"10s"},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chassis", label: "底盘 & 安全", icon: "shield", color: "var(--warn)",
|
||||
items: [
|
||||
{id:"tire_p_fl", l:"胎压 左前", u:"MPa", src:"TPMS", freq:"60s"},
|
||||
{id:"tire_p_fr", l:"胎压 右前", u:"MPa", src:"TPMS", freq:"60s"},
|
||||
{id:"tire_p_rl", l:"胎压 左后", u:"MPa", src:"TPMS", freq:"60s"},
|
||||
{id:"tire_p_rr", l:"胎压 右后", u:"MPa", src:"TPMS", freq:"60s"},
|
||||
{id:"brake", l:"制动信号", u:"0/1", src:"CAN", freq:"100ms"},
|
||||
{id:"airbag", l:"安全气囊状态", u:"0/1", src:"CAN", freq:"事件"},
|
||||
{id:"abs", l:"ABS 状态", u:"0/1", src:"CAN", freq:"事件"},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "location", label: "定位 & 通讯", icon: "pin", color: "var(--info)",
|
||||
items: [
|
||||
{id:"gps_lat", l:"GPS 纬度", u:"°", src:"JT808", freq:"10s"},
|
||||
{id:"gps_lng", l:"GPS 经度", u:"°", src:"JT808", freq:"10s"},
|
||||
{id:"gps_alt", l:"海拔", u:"m", src:"JT808", freq:"10s"},
|
||||
{id:"signal", l:"信号强度", u:"dBm", src:"TBOX", freq:"60s"},
|
||||
{id:"network", l:"网络类型", u:"-", src:"TBOX", freq:"60s"},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "driving", label: "驾驶行为", icon: "speed", color: "var(--accent)",
|
||||
items: [
|
||||
{id:"hard_acc", l:"急加速次数", u:"次/h", src:"TBOX", freq:"事件"},
|
||||
{id:"hard_brake", l:"急刹车次数", u:"次/h", src:"TBOX", freq:"事件"},
|
||||
{id:"sharp_turn", l:"急转弯次数", u:"次/h", src:"TBOX", freq:"事件"},
|
||||
{id:"overspeed", l:"超速时长", u:"s/h", src:"TBOX", freq:"事件"},
|
||||
{id:"score", l:"驾驶评分", u:"分", src:"算法", freq:"日"},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const VIEW_MODES = [
|
||||
{id:"line", l:"曲线图", ic:"chart", d:"时间序列趋势"},
|
||||
{id:"area", l:"面积图", ic:"pulse", d:"叠加趋势对比"},
|
||||
{id:"bar", l:"柱状图", ic:"layers", d:"分时统计"},
|
||||
{id:"table", l:"数据表", ic:"list", d:"按时间戳列表"},
|
||||
{id:"heat", l:"热力日历", ic:"history", d:"按日聚合"},
|
||||
{id:"summary",l:"统计摘要", ic:"gauge", d:"min/max/avg/p95"},
|
||||
];
|
||||
|
||||
const QUICK_RANGES = [
|
||||
{id:"1h", l:"近1小时"}, {id:"6h", l:"近6小时"}, {id:"24h", l:"近24小时"},
|
||||
{id:"7d", l:"近7日"}, {id:"30d", l:"近30日"}, {id:"custom", l:"自定义"},
|
||||
];
|
||||
|
||||
const ArtboardHistory = () => {
|
||||
// Wizard step: 1 选范围, 2 选项目, 3 展示
|
||||
const [step, setStep] = React.useState(3);
|
||||
const [vehicle] = React.useState({plate:"浙F03980F", vin:"LJ2A...8814", dept:"业务一部"});
|
||||
const [range, setRange] = React.useState("24h");
|
||||
const [dateFrom] = React.useState("2026-04-27 14:02");
|
||||
const [dateTo] = React.useState("2026-04-28 14:02");
|
||||
|
||||
// Selected data items (ids)
|
||||
const [picked, setPicked] = React.useState(new Set(["speed","soc","h2_pressure"]));
|
||||
const [activeGroup, setActiveGroup] = React.useState("vehicle");
|
||||
|
||||
// Visualization mode
|
||||
const [view, setView] = React.useState("line");
|
||||
|
||||
const togglePick = (id) => {
|
||||
const next = new Set(picked);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setPicked(next);
|
||||
};
|
||||
|
||||
const pickedItems = DATA_GROUPS.flatMap(g => g.items.filter(it => picked.has(it.id)).map(it => ({...it, group:g})));
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar active="history"/>
|
||||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
|
||||
<Topbar crumbs={["数据检索", `${vehicle.plate}`]} kpis={[]} showSearch={false}/>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:24, alignItems:"center"}}>
|
||||
{[
|
||||
{n:1, l:"选择范围"},
|
||||
{n:2, l:"选择数据项目"},
|
||||
{n:3, l:"选择展示方式"},
|
||||
].map((s,i,arr)=>(
|
||||
<React.Fragment key={s.n}>
|
||||
<div className="mid gap-2" style={{cursor:"pointer", opacity: step >= s.n ? 1 : 0.5}} onClick={() => setStep(s.n)}>
|
||||
<span style={{
|
||||
width:22, height:22, borderRadius:11,
|
||||
background: step >= s.n ? "var(--accent)" : "var(--bg-3)",
|
||||
color: step >= s.n ? "#fff" : "var(--fg-3)",
|
||||
display:"grid", placeItems:"center",
|
||||
fontSize:11, fontWeight:600, fontFamily:"var(--font-mono)",
|
||||
}}>{s.n}</span>
|
||||
<span className={step === s.n ? "strong" : ""} style={{fontSize:12}}>{s.l}</span>
|
||||
</div>
|
||||
{i < arr.length - 1 && <span style={{flex:0, width:32, height:1, background:"var(--border-2)"}}/>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<div style={{marginLeft:"auto", display:"flex", gap:6, alignItems:"center"}}>
|
||||
<span className="muted" style={{fontSize:11}}>已选 <span className="strong" style={{color:"var(--accent)"}}>{picked.size}</span> 项</span>
|
||||
<span style={{width:1, height:18, background:"var(--border-1)"}}/>
|
||||
<button className="btn" onClick={() => location.hash = "#/compare"}><Icon name="layers" size={12}/> 多车对比</button>
|
||||
<button className="btn"><Icon name="download" size={12}/> 导出 CSV</button>
|
||||
<button className="btn"><Icon name="bookmark" size={12}/> 保存查询</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main */}
|
||||
<div style={{flex:1, display:"grid", gridTemplateColumns:"320px 1fr", minHeight:0}}>
|
||||
|
||||
{/* LEFT: range + data items selector */}
|
||||
<div style={{borderRight:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
|
||||
|
||||
{/* Vehicle + time */}
|
||||
<div style={{padding:14, borderBottom:"1px solid var(--border-1)"}}>
|
||||
<div className="eyebrow" style={{marginBottom:8}}>查询对象</div>
|
||||
<div className="mid gap-2" style={{padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6}}>
|
||||
<Icon name="car" size={14} style={{color:"var(--accent)"}}/>
|
||||
<div style={{flex:1}}>
|
||||
<div className="mono strong" style={{fontSize:12}}>{vehicle.plate}</div>
|
||||
<div className="muted" style={{fontSize:10}}>{vehicle.vin} · {vehicle.dept}</div>
|
||||
</div>
|
||||
<Icon name="chevDown" size={11} style={{color:"var(--fg-3)"}}/>
|
||||
</div>
|
||||
|
||||
<div className="eyebrow" style={{marginTop:14, marginBottom:8}}>时间范围</div>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(3, 1fr)", gap:4, marginBottom:8}}>
|
||||
{QUICK_RANGES.map(r => (
|
||||
<span key={r.id}
|
||||
onClick={() => setRange(r.id)}
|
||||
className={"chip " + (range === r.id ? "accent" : "")}
|
||||
style={{justifyContent:"center", cursor:"pointer", fontSize:10, padding:"4px 0"}}>
|
||||
{r.l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6, fontSize:11, fontFamily:"var(--font-mono)"}}>
|
||||
<div className="muted" style={{fontSize:9, marginBottom:2}}>起 → 止</div>
|
||||
<div>{dateFrom}</div>
|
||||
<div>{dateTo}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data item picker */}
|
||||
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
|
||||
<Icon name="cube" size={13}/>
|
||||
<span className="title">数据项目</span>
|
||||
<span className="chip accent" style={{marginLeft:"auto"}}>已选 {picked.size}</span>
|
||||
</div>
|
||||
|
||||
<div style={{display:"flex", borderBottom:"1px solid var(--border-1)", padding:"6px 10px", gap:4, flexWrap:"wrap", background:"var(--bg-2)"}}>
|
||||
{DATA_GROUPS.map(g => (
|
||||
<span key={g.id}
|
||||
onClick={() => setActiveGroup(g.id)}
|
||||
className={"chip " + (activeGroup === g.id ? "accent" : "")}
|
||||
style={{cursor:"pointer", fontSize:10, padding:"3px 8px"}}>
|
||||
<Icon name={g.icon} size={10}/> {g.label}
|
||||
<span className="mono" style={{marginLeft:4, opacity:.7}}>
|
||||
{g.items.filter(it => picked.has(it.id)).length || g.items.length}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="scroll" style={{flex:1}}>
|
||||
{DATA_GROUPS.filter(g => g.id === activeGroup).map(g => (
|
||||
<div key={g.id}>
|
||||
{g.items.map(it => {
|
||||
const on = picked.has(it.id);
|
||||
return (
|
||||
<div key={it.id}
|
||||
onClick={() => togglePick(it.id)}
|
||||
style={{
|
||||
display:"flex", alignItems:"center", gap:10,
|
||||
padding:"9px 14px", borderBottom:"1px solid var(--border-1)",
|
||||
cursor:"pointer",
|
||||
background: on ? "var(--accent-soft)" : "transparent",
|
||||
}}>
|
||||
<span style={{
|
||||
width:14, height:14, borderRadius:3,
|
||||
border: "1.5px solid " + (on ? "var(--accent)" : "var(--border-2)"),
|
||||
background: on ? "var(--accent)" : "transparent",
|
||||
display:"grid", placeItems:"center",
|
||||
flexShrink:0,
|
||||
}}>
|
||||
{on && <svg width="9" height="9" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-6" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<div style={{flex:1, minWidth:0}}>
|
||||
<div className="between">
|
||||
<span className="strong" style={{fontSize:12}}>{it.l}</span>
|
||||
<span className="mono muted" style={{fontSize:10}}>{it.u}</span>
|
||||
</div>
|
||||
<div className="mid gap-1" style={{marginTop:2}}>
|
||||
<span style={{fontSize:9, padding:"1px 5px", borderRadius:2, background:"var(--bg-2)", border:"1px solid var(--border-1)", color: g.color, fontFamily:"var(--font-mono)"}}>{it.src}</span>
|
||||
<span className="muted mono" style={{fontSize:9}}>· {it.freq}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{padding:"10px 14px", borderTop:"1px solid var(--border-1)", display:"flex", gap:6}}>
|
||||
<button className="btn" style={{flex:1}} onClick={() => setPicked(new Set())}>清空</button>
|
||||
<button className="btn primary" style={{flex:2}}>
|
||||
<Icon name="search" size={12}/> 查询 {picked.size} 项
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: visualization */}
|
||||
<div style={{display:"flex", flexDirection:"column", minHeight:0, background:"var(--bg-2)"}}>
|
||||
|
||||
{/* View-mode picker */}
|
||||
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:6, alignItems:"center", flexWrap:"wrap"}}>
|
||||
<span className="muted" style={{fontSize:11, marginRight:4}}>展示</span>
|
||||
{VIEW_MODES.map(v => (
|
||||
<span key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
style={{
|
||||
display:"flex", alignItems:"center", gap:6,
|
||||
padding:"5px 10px", borderRadius:5, cursor:"pointer", fontSize:11,
|
||||
background: view === v.id ? "var(--accent-soft)" : "transparent",
|
||||
border: "1px solid " + (view === v.id ? "var(--accent)" : "var(--border-1)"),
|
||||
color: view === v.id ? "var(--accent)" : "var(--fg-1)",
|
||||
}}>
|
||||
<Icon name={v.ic} size={12}/>
|
||||
<span>{v.l}</span>
|
||||
</span>
|
||||
))}
|
||||
<div style={{marginLeft:"auto"}}>
|
||||
<span className="muted" style={{fontSize:11}}>采样:</span>
|
||||
<span className="chip">原始</span>
|
||||
<span className="chip" style={{marginLeft:4}}>1分钟</span>
|
||||
<span className="chip accent" style={{marginLeft:4}}>5分钟</span>
|
||||
<span className="chip" style={{marginLeft:4}}>1小时</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected items pills */}
|
||||
<div style={{padding:"8px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:6, flexWrap:"wrap", alignItems:"center"}}>
|
||||
{pickedItems.length === 0 && <span className="muted" style={{fontSize:11}}>请从左侧选择数据项</span>}
|
||||
{pickedItems.map(it => (
|
||||
<span key={it.id} className="chip" style={{
|
||||
fontSize:10, padding:"3px 8px",
|
||||
background:"var(--bg-2)",
|
||||
borderColor: it.group.color, color: it.group.color,
|
||||
}}>
|
||||
<span style={{width:6, height:6, borderRadius:3, background: it.group.color}}/>
|
||||
{it.l}
|
||||
<span className="muted" style={{fontSize:9, marginLeft:2}}>{it.u}</span>
|
||||
<span style={{cursor:"pointer", marginLeft:2}} onClick={() => togglePick(it.id)}>×</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Render area */}
|
||||
<div className="scroll" style={{flex:1, padding:16}}>
|
||||
{view === "line" && <LineView items={pickedItems}/>}
|
||||
{view === "area" && <AreaView items={pickedItems}/>}
|
||||
{view === "bar" && <BarView items={pickedItems}/>}
|
||||
{view === "table" && <TableView items={pickedItems}/>}
|
||||
{view === "heat" && <HeatView items={pickedItems}/>}
|
||||
{view === "summary" && <SummaryView items={pickedItems}/>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Visualizations ───────────────────────────────────────────
|
||||
|
||||
const colorFor = (it, fallback) => {
|
||||
if (it.id === "speed" || it.id.startsWith("hard_") || it.id === "h2_flow") return "var(--info)";
|
||||
if (it.id === "soc" || it.id.startsWith("batt") || it.id === "fc_power") return "var(--accent)";
|
||||
if (it.id.startsWith("h2_") || it.id.startsWith("fc_temp")) return "var(--warn)";
|
||||
if (it.id.startsWith("tire_")) return "var(--danger)";
|
||||
return it.group?.color || fallback || "var(--accent)";
|
||||
};
|
||||
|
||||
const synth = (id, n=120) => {
|
||||
// Deterministic per-id pseudo-random series
|
||||
const seed = id.split("").reduce((s,c) => s + c.charCodeAt(0), 0);
|
||||
const out = [];
|
||||
for (let i=0;i<n;i++) {
|
||||
const v = Math.sin((i+seed) * 0.18) * 0.4 + Math.cos((i+seed*0.7) * 0.07) * 0.3 + 0.5
|
||||
+ Math.sin((i+seed*1.3) * 0.5) * 0.08;
|
||||
out.push(Math.max(0.02, Math.min(0.98, v)));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const LineView = ({ items }) => {
|
||||
if (!items.length) return <EmptyHint/>;
|
||||
return (
|
||||
<div className="col gap-3">
|
||||
{items.map(it => (
|
||||
<div key={it.id} className="panel">
|
||||
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
|
||||
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
|
||||
<span className="title">{it.l}</span>
|
||||
<span className="muted mono" style={{fontSize:10, marginLeft:4}}>{it.u}</span>
|
||||
<div className="actions">
|
||||
<span className="mono muted" style={{fontSize:10}}>{it.src} · {it.freq}</span>
|
||||
<span className="chip"><Icon name="download" size={9}/> CSV</span>
|
||||
<span className="chip"><Icon name="expand" size={9}/></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{padding:"14px 16px"}}>
|
||||
<Sparkline data={synth(it.id, 240)} h={120} color={colorFor(it)} fill axis/>
|
||||
<TimeAxis/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AreaView = ({ items }) => {
|
||||
if (!items.length) return <EmptyHint/>;
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
|
||||
<Icon name="pulse" size={12}/>
|
||||
<span className="title">{items.length} 项叠加</span>
|
||||
<div className="actions">
|
||||
{items.map(it => (
|
||||
<span key={it.id} className="mid gap-1" style={{fontSize:10}}>
|
||||
<span style={{width:8, height:8, borderRadius:2, background: colorFor(it)}}/>
|
||||
{it.l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{padding:"14px 16px", position:"relative"}}>
|
||||
<svg width="100%" height="320" viewBox="0 0 800 320" preserveAspectRatio="none" style={{display:"block"}}>
|
||||
{[0,0.25,0.5,0.75,1].map((p,i) => (
|
||||
<line key={i} x1="0" y1={300*p+10} x2="800" y2={300*p+10} stroke="var(--border-1)" strokeWidth="1"/>
|
||||
))}
|
||||
{items.map((it,idx) => {
|
||||
const data = synth(it.id, 240);
|
||||
const path = data.map((v,i) => `${(i/(data.length-1))*800},${310 - v*300}`).join(" L");
|
||||
return (
|
||||
<g key={it.id}>
|
||||
<path d={`M ${path} L 800,310 L 0,310 Z`} fill={colorFor(it)} opacity={0.12}/>
|
||||
<path d={`M ${path}`} fill="none" stroke={colorFor(it)} strokeWidth="1.6"/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<TimeAxis/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BarView = ({ items }) => {
|
||||
if (!items.length) return <EmptyHint/>;
|
||||
const buckets = ["00","02","04","06","08","10","12","14","16","18","20","22"];
|
||||
return (
|
||||
<div className="col gap-3">
|
||||
{items.map(it => {
|
||||
const data = synth(it.id, buckets.length).map(v => v * 100);
|
||||
return (
|
||||
<div key={it.id} className="panel">
|
||||
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
|
||||
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
|
||||
<span className="title">{it.l} <span className="muted" style={{fontSize:10, marginLeft:4}}>· 按 2 小时聚合</span></span>
|
||||
<div className="actions"><span className="mono muted" style={{fontSize:10}}>{it.u}</span></div>
|
||||
</div>
|
||||
<div style={{padding:"14px 16px"}}>
|
||||
<svg width="100%" height="120" viewBox="0 0 720 120" preserveAspectRatio="none" style={{display:"block"}}>
|
||||
{data.map((v,i) => {
|
||||
const x = i * (720/data.length) + 8;
|
||||
const w = (720/data.length) - 16;
|
||||
const h = (v/100) * 100;
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x} y={110-h} width={w} height={h} fill={colorFor(it)} opacity="0.85" rx="2"/>
|
||||
<text x={x+w/2} y="118" textAnchor="middle" fontSize="9" fill="var(--fg-3)" fontFamily="var(--font-mono)">{buckets[i]}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TableView = ({ items }) => {
|
||||
if (!items.length) return <EmptyHint/>;
|
||||
// 30 rows of timestamps
|
||||
const rows = Array.from({length:40}, (_,i) => {
|
||||
const d = new Date(2026, 3, 28, 14, 0, 0);
|
||||
d.setMinutes(d.getMinutes() - i * 5);
|
||||
const ts = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")} ${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}:00`;
|
||||
const vals = items.map(it => synth(it.id, 240)[i % 240]);
|
||||
return { ts, vals };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
|
||||
<Icon name="list" size={12}/>
|
||||
<span className="title">数据列表</span>
|
||||
<span className="muted" style={{fontSize:11, marginLeft:6}}>共 1,728 行 · 显示 40</span>
|
||||
<div className="actions">
|
||||
<span className="chip"><Icon name="filter" size={9}/> 筛选</span>
|
||||
<span className="chip"><Icon name="download" size={9}/> CSV</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{maxHeight:520, overflowY:"auto"}}>
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width:160, position:"sticky", top:0, background:"var(--bg-1)"}}>时间戳</th>
|
||||
{items.map(it => (
|
||||
<th key={it.id} style={{textAlign:"right", position:"sticky", top:0, background:"var(--bg-1)"}}>
|
||||
<div>{it.l}</div>
|
||||
<div className="muted mono" style={{fontSize:9, fontWeight:400}}>{it.u} · {it.src}</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r,i) => (
|
||||
<tr key={i}>
|
||||
<td className="mono muted" style={{fontSize:11}}>{r.ts}</td>
|
||||
{r.vals.map((v,j) => {
|
||||
const it = items[j];
|
||||
let display;
|
||||
if (it.id === "speed") display = (v*80).toFixed(1);
|
||||
else if (it.id === "soc") display = (20 + v*70).toFixed(1);
|
||||
else if (it.id.startsWith("h2_pressure")) display = (3.5 + v*1.3).toFixed(2);
|
||||
else if (it.id.startsWith("tire_")) display = (2.7 + v*0.6).toFixed(2);
|
||||
else if (it.id.startsWith("batt_temp")) display = (24 + v*22).toFixed(1);
|
||||
else if (it.id === "odometer") display = (124820 + v*4).toFixed(1);
|
||||
else display = (v*100).toFixed(2);
|
||||
return <td key={j} className="mono" style={{textAlign:"right", color: colorFor(it)}}>{display}</td>;
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HeatView = ({ items }) => {
|
||||
if (!items.length) return <EmptyHint/>;
|
||||
return (
|
||||
<div className="col gap-3">
|
||||
{items.map(it => {
|
||||
const cells = synth(it.id, 30); // 30 days
|
||||
return (
|
||||
<div key={it.id} className="panel">
|
||||
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
|
||||
<span style={{width:8, height:8, borderRadius:4, background: colorFor(it)}}/>
|
||||
<span className="title">{it.l} · 30 日热力</span>
|
||||
<div className="actions"><span className="mono muted" style={{fontSize:10}}>{it.u}</span></div>
|
||||
</div>
|
||||
<div style={{padding:"14px 16px"}}>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(30, 1fr)", gap:3}}>
|
||||
{cells.map((v,i) => (
|
||||
<div key={i} title={`第 ${i+1} 日 · ${(v*100).toFixed(0)}`}
|
||||
style={{
|
||||
aspectRatio:"1",
|
||||
borderRadius:3,
|
||||
background: colorFor(it),
|
||||
opacity: 0.15 + v*0.85,
|
||||
}}/>
|
||||
))}
|
||||
</div>
|
||||
<div className="between muted mono" style={{marginTop:8, fontSize:10}}>
|
||||
<span>30 日前</span><span>今日</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SummaryView = ({ items }) => {
|
||||
if (!items.length) return <EmptyHint/>;
|
||||
return (
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(auto-fit, minmax(280px, 1fr))", gap:12}}>
|
||||
{items.map(it => {
|
||||
const data = synth(it.id, 240).map(v => v*100);
|
||||
const min = Math.min(...data), max = Math.max(...data);
|
||||
const avg = data.reduce((s,v)=>s+v,0)/data.length;
|
||||
const sorted = [...data].sort((a,b)=>a-b);
|
||||
const p95 = sorted[Math.floor(sorted.length*0.95)];
|
||||
const p50 = sorted[Math.floor(sorted.length*0.50)];
|
||||
|
||||
// Scale to plausible units
|
||||
const fmt = (v) => {
|
||||
if (it.id === "speed") return (v*0.8).toFixed(1);
|
||||
if (it.id === "soc") return (20 + v*0.7).toFixed(1);
|
||||
if (it.id.startsWith("h2_pressure")) return (3.5 + v*0.013).toFixed(2);
|
||||
return v.toFixed(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={it.id} className="panel" style={{padding:14}}>
|
||||
<div className="between" style={{marginBottom:10}}>
|
||||
<div>
|
||||
<div className="strong" style={{fontSize:13}}>{it.l}</div>
|
||||
<div className="muted mono" style={{fontSize:10}}>{it.u} · {it.src}</div>
|
||||
</div>
|
||||
<span style={{width:10, height:10, borderRadius:5, background: colorFor(it)}}/>
|
||||
</div>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
|
||||
{[
|
||||
{l:"平均", v:fmt(avg), c:colorFor(it)},
|
||||
{l:"中位", v:fmt(p50), c:"var(--fg-1)"},
|
||||
{l:"最大", v:fmt(max), c:"var(--danger)"},
|
||||
{l:"最小", v:fmt(min), c:"var(--ok)"},
|
||||
{l:"P95", v:fmt(p95), c:"var(--warn)"},
|
||||
{l:"样本", v:"1,728", c:"var(--fg-2)"},
|
||||
].map((s,i) => (
|
||||
<div key={i} style={{padding:"6px 10px", background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
|
||||
<div className="muted" style={{fontSize:10}}>{s.l}</div>
|
||||
<div className="mono strong" style={{fontSize:14, color: s.c, marginTop:2}}>{s.v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{marginTop:10}}>
|
||||
<Sparkline data={synth(it.id, 80)} h={32} color={colorFor(it)} fill/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Sparkline + axis helpers
|
||||
const Sparkline = ({ data, h=80, color="var(--accent)", fill=false, axis=false }) => {
|
||||
const w = 800;
|
||||
const max = Math.max(...data, 0.01);
|
||||
const path = data.map((v,i) => `${(i/(data.length-1))*w},${h - (v/max) * (h-12) - 6}`).join(" L");
|
||||
return (
|
||||
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{display:"block"}}>
|
||||
{axis && [0,0.25,0.5,0.75,1].map((p,i) => (
|
||||
<line key={i} x1="0" y1={(h-12)*p+6} x2={w} y2={(h-12)*p+6} stroke="var(--border-1)" strokeWidth="1"/>
|
||||
))}
|
||||
{fill && <path d={`M ${path} L ${w},${h} L 0,${h} Z`} fill={color} opacity="0.16"/>}
|
||||
<path d={`M ${path}`} fill="none" stroke={color} strokeWidth="1.6"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeAxis = () => (
|
||||
<div className="muted mono" style={{display:"flex", justifyContent:"space-between", marginTop:6, fontSize:10}}>
|
||||
{["00:00","04:00","08:00","12:00","16:00","20:00","24:00"].map(t => <span key={t}>{t}</span>)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const EmptyHint = () => (
|
||||
<div style={{padding:"60px 20px", textAlign:"center", color:"var(--fg-3)"}}>
|
||||
<Icon name="cube" size={48} style={{opacity:.3}}/>
|
||||
<div style={{marginTop:14, fontSize:13}}>从左侧选择数据项目以开始检索</div>
|
||||
<div className="muted" style={{fontSize:11, marginTop:4}}>支持速度 / SOC / H₂ 压力 / 胎压 / 驾驶行为等 30+ 字段</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
window.ArtboardHistory = ArtboardHistory;
|
||||
152
artboards/inbox.jsx
Normal file
152
artboards/inbox.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
// artboard-inbox.jsx — Notification center
|
||||
const ArtboardInbox = () => {
|
||||
const alerts = [
|
||||
{p:"P0", n:"电池SOC严重不足", v:"浙F08638F", t:"刚刚", det:"SOC 9% < 阈值 15% · 持续 4分20秒", st:"new"},
|
||||
{p:"P0", n:"右后胎压低", v:"浙F08638F", t:"3分钟前", det:"0.16 MPa · 阈值 0.20 MPa", st:"new"},
|
||||
{p:"P1", n:"超速预警", v:"浙F02002F", t:"12分钟前", det:"实测 89 km/h · 限速 80 km/h · 持续 12s", st:"new"},
|
||||
{p:"P1", n:"H₂压力异常下降", v:"浙F07179F", t:"32分钟前", det:"5分钟内下降 1.2 MPa · 异常", st:"ack"},
|
||||
{p:"P0", n:"电堆过温保护", v:"浙F00598F", t:"1小时前", det:"电堆温度 95°C · 阈值 90°C", st:"resolved"},
|
||||
{p:"P2", n:"急加速密集", v:"浙F02608F", t:"2小时前", det:"5分钟内 3 次急加速", st:"resolved"},
|
||||
{p:"P1", n:"偏离规划路线", v:"浙F00278F", t:"3小时前", det:"偏离 1.2 km · 持续 6 分钟", st:"resolved"},
|
||||
];
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar active="inbox"/>
|
||||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
|
||||
<Topbar
|
||||
crumbs={["通知中心", "告警时间线"]}
|
||||
kpis={[
|
||||
{lbl:"未处理", val:"3"},
|
||||
{lbl:"今日", val:"24"},
|
||||
{lbl:"本周", val:"187"},
|
||||
]}
|
||||
showSearch={false}
|
||||
/>
|
||||
<div style={{flex:1, display:"grid", gridTemplateColumns:"1fr 320px", minHeight:0}}>
|
||||
<div style={{display:"flex", flexDirection:"column", minHeight:0}}>
|
||||
{/* Filter chips */}
|
||||
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", display:"flex", gap:8, alignItems:"center", background:"var(--bg-1)"}}>
|
||||
<span className="chip accent">全部 · 24</span>
|
||||
<span className="chip">未处理 · 3</span>
|
||||
<span className="chip">P0 · 2</span>
|
||||
<span className="chip">P1 · 8</span>
|
||||
<span className="chip">P2 · 14</span>
|
||||
<span style={{width:1, height:20, background:"var(--border-1)"}}/>
|
||||
<span className="muted" style={{fontSize:11}}>今日</span>
|
||||
<div style={{marginLeft:"auto", display:"flex", gap:6}}>
|
||||
<button className="btn sm">全部已读</button>
|
||||
<button className="btn sm"><Icon name="filter" size={11}/> 筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly distribution */}
|
||||
<div style={{padding:"12px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)"}}>
|
||||
<div className="between" style={{marginBottom:6}}>
|
||||
<span className="eyebrow">24小时告警分布</span>
|
||||
<span className="muted mono" style={{fontSize:10}}>峰值 14:00-15:00</span>
|
||||
</div>
|
||||
<Bars data={[1,0,0,1,0,2,3,5,8,4,3,4,7,12,18,9,7,5,4,3,2,1,1,0]} w={1100} h={48} color="var(--accent)"/>
|
||||
</div>
|
||||
|
||||
{/* Timeline list */}
|
||||
<div className="scroll" style={{flex:1}}>
|
||||
{alerts.map((a,i)=>{
|
||||
const c = a.p === "P0" ? "var(--danger)" : a.p === "P1" ? "var(--warn)" : "var(--info)";
|
||||
return (
|
||||
<div key={i} style={{display:"flex", gap:14, padding:"14px 20px", borderBottom:"1px solid var(--border-1)", background: a.st==="new" ? "oklch(0.20 0.020 245)":"transparent", cursor:"pointer"}}>
|
||||
<div style={{width:40, paddingTop:4, position:"relative"}}>
|
||||
<div style={{width:32, height:32, borderRadius:16, background: a.st==="new"?c:"var(--bg-3)", opacity: a.st==="resolved" ? 0.4 : 1, display:"grid", placeItems:"center", color:"var(--fg-0)", boxShadow: a.st==="new" ? `0 0 16px ${c}` : "none"}}>
|
||||
<Icon name="bell" size={14}/>
|
||||
</div>
|
||||
{i < alerts.length-1 && <span style={{position:"absolute", left:19, top:42, bottom:-14, width:2, background:"var(--border-1)"}}/>}
|
||||
</div>
|
||||
<div style={{flex:1, minWidth:0}}>
|
||||
<div className="between">
|
||||
<div className="mid gap-2">
|
||||
<span className={"chip " + (a.p==="P0"?"danger":a.p==="P1"?"warn":"info")}>{a.p}</span>
|
||||
<span className="strong" style={{fontSize:13}}>{a.n}</span>
|
||||
<span className="mono muted" style={{fontSize:11}}>· {a.v}</span>
|
||||
{a.st==="new" && <span style={{width:6, height:6, borderRadius:3, background:c, boxShadow: `0 0 6px ${c}`}}/>}
|
||||
{a.st==="ack" && <span className="chip" style={{fontSize:9}}>已确认</span>}
|
||||
{a.st==="resolved" && <span className="chip ok" style={{fontSize:9}}>已恢复</span>}
|
||||
</div>
|
||||
<span className="mono muted" style={{fontSize:11}}>{a.t}</span>
|
||||
</div>
|
||||
<div className="muted" style={{fontSize:12, marginTop:4}}>{a.det}</div>
|
||||
{a.st === "new" && (
|
||||
<div className="mid gap-2" style={{marginTop:10}}>
|
||||
<button className="btn sm primary"><Icon name="route" size={11}/> 查看轨迹</button>
|
||||
<button className="btn sm">确认</button>
|
||||
<button className="btn sm">分配</button>
|
||||
<button className="btn sm ghost">忽略</button>
|
||||
<span className="muted mono" style={{fontSize:10, marginLeft:"auto"}}>规则 · {a.n}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: stats */}
|
||||
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
|
||||
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
|
||||
<Icon name="chart" size={13}/><span className="title">告警概览</span>
|
||||
</div>
|
||||
<div className="scroll" style={{flex:1, padding:14}}>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:8, marginBottom:14}}>
|
||||
{[
|
||||
{l:"P0 紧急", v:"2", c:"var(--danger)"},
|
||||
{l:"P1 警告", v:"8", c:"var(--warn)"},
|
||||
{l:"P2 提示", v:"14", c:"var(--info)"},
|
||||
{l:"已恢复", v:"19", c:"var(--ok)"},
|
||||
].map((k,i)=>(
|
||||
<div key={i} style={{padding:10, background:"var(--bg-2)", borderRadius:5, border:"1px solid var(--border-1)"}}>
|
||||
<div className="muted" style={{fontSize:10}}>{k.l}</div>
|
||||
<div className="mono" style={{fontSize:22, fontWeight:600, color:k.c}}>{k.v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="eyebrow" style={{marginBottom:8}}>Top 5 告警类型 · 7日</div>
|
||||
<div className="col gap-2" style={{fontSize:11, marginBottom:14}}>
|
||||
{[
|
||||
{l:"超速预警", v:47, c:"var(--warn)"},
|
||||
{l:"急加速密集", v:31, c:"var(--info)"},
|
||||
{l:"胎压报警", v:18, c:"var(--warn)"},
|
||||
{l:"SOC不足", v:12, c:"var(--danger)"},
|
||||
{l:"H₂压力异常", v:8, c:"var(--danger)"},
|
||||
].map((t,i)=>(
|
||||
<div key={i} className="mid gap-2">
|
||||
<span style={{width:80, fontSize:11}} className="muted">{t.l}</span>
|
||||
<div className="bar" style={{flex:1, height:6}}><i style={{width: (t.v/47*100)+"%", background: t.c}}/></div>
|
||||
<span className="mono" style={{width:24, textAlign:"right"}}>{t.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="eyebrow" style={{marginBottom:8}}>Top 5 告警车辆</div>
|
||||
<div className="col gap-2" style={{fontSize:11}}>
|
||||
{[
|
||||
{n:"浙F08638F", v:8, c:"var(--danger)"},
|
||||
{n:"浙F02002F", v:6, c:"var(--warn)"},
|
||||
{n:"浙F02608F", v:4, c:"var(--warn)"},
|
||||
{n:"浙F00598F", v:3, c:"var(--info)"},
|
||||
{n:"浙F00278F", v:3, c:"var(--info)"},
|
||||
].map((t,i)=>(
|
||||
<div key={i} className="between" style={{padding:"6px 10px", background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
|
||||
<span className="mono strong">{t.n}</span>
|
||||
<span className={"mono"} style={{color:t.c}}>{t.v} 次</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.ArtboardInbox = ArtboardInbox;
|
||||
717
artboards/mobile.jsx
Normal file
717
artboards/mobile.jsx
Normal file
@@ -0,0 +1,717 @@
|
||||
// mobile.jsx — Native mobile layouts for each route
|
||||
// Renders a single-column, gesture-friendly version of each page
|
||||
|
||||
// ── Shared mobile chrome ───────────────────────────────────
|
||||
const MAppBar = ({ title, subtitle, onMenu, right }) => (
|
||||
<div style={{
|
||||
height: 52, flex: "0 0 52px", display: "flex", alignItems: "center",
|
||||
padding: "0 8px 0 4px", gap: 8, position: "relative", zIndex: 5,
|
||||
background: "var(--bg-1)", borderBottom: "1px solid var(--border-1)",
|
||||
}}>
|
||||
<button onClick={onMenu} aria-label="菜单" style={{
|
||||
width: 40, height: 40, display: "grid", placeItems: "center",
|
||||
background: "transparent", border: "none", color: "var(--fg-1)", borderRadius: 8, cursor: "pointer",
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 15, color: "var(--fg-0)", lineHeight: 1.2 }}>{title}</div>
|
||||
{subtitle && <div style={{ fontSize: 11, color: "var(--fg-3)", lineHeight: 1.2 }}>{subtitle}</div>}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MIconBtn = ({ icon, badge, onClick }) => (
|
||||
<button onClick={onClick} style={{
|
||||
width: 40, height: 40, display: "grid", placeItems: "center",
|
||||
background: "transparent", border: "none", color: "var(--fg-1)", borderRadius: 8, cursor: "pointer", position: "relative",
|
||||
}}>
|
||||
<Icon name={icon} size={17}/>
|
||||
{badge && <span style={{ position: "absolute", top: 8, right: 8, minWidth: 14, height: 14, padding: "0 4px", background: "var(--danger)", color: "#fff", fontSize: 9, fontWeight: 600, borderRadius: 7, display: "grid", placeItems: "center", lineHeight: 1 }}>{badge}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
const MTabBar = ({ active, onChange }) => {
|
||||
const tabs = [
|
||||
{ id: "overview", icon: "map", label: "总览" },
|
||||
{ id: "history", icon: "history", label: "查询" },
|
||||
{ id: "playback", icon: "route", label: "回放" },
|
||||
{ id: "inbox", icon: "bell", label: "通知" },
|
||||
{ id: "esg", icon: "chart", label: "ESG" },
|
||||
];
|
||||
return (
|
||||
<div style={{
|
||||
flex: "0 0 56px", height: 56, display: "grid", gridTemplateColumns: `repeat(${tabs.length}, 1fr)`,
|
||||
background: "var(--bg-1)", borderTop: "1px solid var(--border-1)",
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
}}>
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} onClick={() => onChange(t.id)} style={{
|
||||
background: "transparent", border: "none", display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center", gap: 2, cursor: "pointer",
|
||||
color: active === t.id ? "var(--accent)" : "var(--fg-3)",
|
||||
}}>
|
||||
<Icon name={t.icon} size={18}/>
|
||||
<span style={{ fontSize: 10, fontWeight: active === t.id ? 600 : 400 }}>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Mobile shell wrapper ───────────────────────────────────
|
||||
const MobileShell = ({ title, subtitle, right, children, hideTabBar }) => {
|
||||
const ctx = window.useRoute();
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", display: "flex", flexDirection: "column",
|
||||
background: "var(--bg-0)", color: "var(--fg-1)", overflow: "hidden",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}>
|
||||
<MAppBar title={title} subtitle={subtitle} onMenu={ctx.openDrawer} right={right}/>
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative", overflow: "hidden" }}>
|
||||
{children}
|
||||
</div>
|
||||
{!hideTabBar && <MTabBar active={ctx.route} onChange={ctx.navigate}/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 1. Mobile Overview: hero map + bottom sheet vehicle list ──
|
||||
const MobileOverview = () => {
|
||||
const [selected, setSelected] = React.useState("浙F08638F");
|
||||
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||
const [filter, setFilter] = React.useState("all");
|
||||
const v = (window.VEHICLES || []).find(x => x.id === selected) || {};
|
||||
const vehicles = window.VEHICLES || [];
|
||||
const counts = { all: vehicles.length, ok: vehicles.filter(x=>x.status==="ok").length, warn: vehicles.filter(x=>x.status==="warn").length, danger: vehicles.filter(x=>x.status==="danger").length };
|
||||
const filtered = filter === "all" ? vehicles : vehicles.filter(x => x.status === filter);
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
title="实时监控"
|
||||
subtitle="487/512 在线 · 312 行驶中"
|
||||
right={<><MIconBtn icon="search"/><MIconBtn icon="bell" badge="3" onClick={() => window.useRoute().navigate("inbox")}/></>}
|
||||
>
|
||||
{/* Map fills, sheet floats */}
|
||||
<div style={{ position: "absolute", inset: 0 }}>
|
||||
<FleetMap selected={selected} onSelect={setSelected}/>
|
||||
</div>
|
||||
|
||||
{/* KPI strip floating on map */}
|
||||
<div style={{ position: "absolute", top: 12, left: 12, right: 12, display: "flex", gap: 8, overflowX: "auto", scrollbarWidth: "none" }}>
|
||||
{[
|
||||
{ l: "在线率", v: "95.1%", c: "var(--ok)" },
|
||||
{ l: "告警", v: "8", c: "var(--danger)" },
|
||||
{ l: "今日里程", v: "24.7K km", c: "var(--fg-1)" },
|
||||
{ l: "平均能耗", v: "1.16", c: "var(--info)" },
|
||||
].map((k, i) => (
|
||||
<div key={i} style={{
|
||||
flex: "0 0 auto", padding: "8px 12px", background: "var(--bg-1)",
|
||||
border: "1px solid var(--border-1)", borderRadius: 10, boxShadow: "var(--shadow-1)",
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: "var(--fg-3)" }}>{k.l}</div>
|
||||
<div className="mono tnum" style={{ fontSize: 15, fontWeight: 600, color: k.c }}>{k.v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Floating action: locate */}
|
||||
<button style={{
|
||||
position: "absolute", right: 14, bottom: sheetOpen ? "70%" : 130,
|
||||
width: 44, height: 44, borderRadius: 22, border: "1px solid var(--border-1)",
|
||||
background: "var(--bg-1)", color: "var(--accent)", display: "grid", placeItems: "center",
|
||||
boxShadow: "var(--shadow-2)", cursor: "pointer", transition: "bottom 280ms cubic-bezier(.3,0,.2,1)",
|
||||
}}>
|
||||
<Icon name="pin" size={18}/>
|
||||
</button>
|
||||
|
||||
{/* Bottom sheet */}
|
||||
<div style={{
|
||||
position: "absolute", left: 0, right: 0, bottom: 0,
|
||||
height: sheetOpen ? "70%" : 120, background: "var(--bg-1)",
|
||||
borderTop: "1px solid var(--border-1)", borderRadius: "16px 16px 0 0",
|
||||
boxShadow: "0 -8px 24px -8px rgba(0,0,0,0.16)",
|
||||
transition: "height 320ms cubic-bezier(.3,0,.2,1)",
|
||||
display: "flex", flexDirection: "column", overflow: "hidden",
|
||||
}}>
|
||||
{/* Drag handle + selected vehicle quick card */}
|
||||
<div onClick={() => setSheetOpen(s => !s)} style={{ padding: "8px 16px 6px", cursor: "pointer" }}>
|
||||
<div style={{ width: 36, height: 4, background: "var(--border-2)", borderRadius: 2, margin: "0 auto 8px" }}/>
|
||||
<div className="between">
|
||||
<div className="mid gap-2">
|
||||
<span className={"dot " + v.status}/>
|
||||
<span className="mono strong" style={{ fontSize: 14 }}>{v.id}</span>
|
||||
<SourceBadge src={v.src}/>
|
||||
<span className="muted" style={{ fontSize: 11 }}>{v.deptName}</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 13, color: v.soc < 20 ? "var(--danger)" : "var(--fg-1)" }}>{v.soc}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sheetOpen ? (
|
||||
// Mini quick stats when collapsed
|
||||
<div style={{ padding: "0 16px 12px", display: "flex", gap: 16, fontSize: 11 }}>
|
||||
<div className="col"><span className="muted">速度</span><span className="mono strong">{v.speed} km/h</span></div>
|
||||
<div className="col"><span className="muted">续航</span><span className="mono strong">{Math.round((v.soc||0)*6.2)} km</span></div>
|
||||
<div className="col"><span className="muted">温度</span><span className="mono strong">{v.status==="danger"?"102":"68"}°C</span></div>
|
||||
<button onClick={() => window.useRoute().navigate("detail")} className="btn primary sm" style={{ marginLeft: "auto", alignSelf: "center" }}>详情</button>
|
||||
</div>
|
||||
) : (
|
||||
// Full list when expanded
|
||||
<>
|
||||
<div style={{ padding: "0 16px 8px", display: "flex", gap: 6, overflowX: "auto", scrollbarWidth: "none" }}>
|
||||
{[
|
||||
{id:"all", label:`全部 ${counts.all}`, c:""},
|
||||
{id:"ok", label:`行驶 ${counts.ok}`, c:"ok"},
|
||||
{id:"warn", label:`异常 ${counts.warn}`, c:"warn"},
|
||||
{id:"danger", label:`故障 ${counts.danger}`, c:"danger"},
|
||||
].map(t => (
|
||||
<button key={t.id} onClick={() => setFilter(t.id)} className={"chip " + (filter===t.id ? "accent" : t.c)} style={{ flex: "0 0 auto", cursor: "pointer", padding: "6px 12px", fontSize: 12 }}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
{filtered.map(x => (
|
||||
<div key={x.id} onClick={() => { setSelected(x.id); }} style={{
|
||||
padding: "12px 16px", borderBottom: "1px solid var(--border-1)",
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
background: x.id === selected ? "var(--accent-soft)" : "transparent", cursor: "pointer",
|
||||
}}>
|
||||
<span className={"dot " + x.status}/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="mid gap-2">
|
||||
<span className="mono strong" style={{ fontSize: 13 }}>{x.id}</span>
|
||||
<SourceBadge src={x.src}/>
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: 11, marginTop: 2 }}>{x.deptName} · {x.speed} km/h</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="mono" style={{ fontSize: 13, color: x.soc < 20 ? "var(--danger)" : "var(--fg-1)" }}>{x.soc}%</div>
|
||||
<div style={{ width: 50, height: 3, background: "var(--bg-3)", borderRadius: 2, marginTop: 4 }}>
|
||||
<div style={{ width: x.soc + "%", height: "100%", background: x.soc<20?"var(--danger)":x.soc<40?"var(--warn)":"var(--accent)", borderRadius: 2 }}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 2. Mobile Detail ────────────────────────────────────────
|
||||
const MobileDetail = () => {
|
||||
const vehicles = window.VEHICLES || [];
|
||||
const v = vehicles.find(x => x.id === "浙F03980F") || vehicles[0] || {};
|
||||
return (
|
||||
<MobileShell title={v.plate || v.id} subtitle={`${v.deptName || ''} · ${v.customer || ''}`} right={<MIconBtn icon="pin"/>}>
|
||||
<div style={{ height: "100%", overflowY: "auto", padding: 12, paddingBottom: 24 }}>
|
||||
{/* Hero status card */}
|
||||
<div className="panel" style={{ padding: 16, marginBottom: 12 }}>
|
||||
<div className="between" style={{ marginBottom: 12 }}>
|
||||
<div className="mid gap-2">
|
||||
<span className={"chip " + (v.asset === "abnormal" ? "danger" : v.asset === "leasing" ? "info" : "ok")}>
|
||||
<span className={"dot " + (v.asset === "abnormal" ? "danger" : v.asset === "leasing" ? "info" : "ok") + " pulse"}/>
|
||||
{v.asset === "leasing" ? "租赁" : v.asset === "abnormal" ? "异常" : "在库 · 运营中"}
|
||||
</span>
|
||||
<span className="chip" style={{fontSize:10, background: v.own === "self" ? "rgba(31,139,76,.10)" : "rgba(122,140,46,.12)", color: v.own === "self" ? "var(--accent)" : "#7A8C2E"}}>{v.own === "self" ? "自有" : "外租"}</span>
|
||||
</div>
|
||||
<SourceBadge src={v.src}/>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<MStat label="车速" value={v.speed} unit="km/h" big/>
|
||||
<MStat label="续航" value={v.range} unit="km" big/>
|
||||
<MStat label="电池SOC" value={v.soc} unit="%" color={v.soc < 20 ? "var(--danger)" : "var(--ok)"}/>
|
||||
<MStat label="氢气压力" value={v.h2} unit="MPa" color="var(--info)"/>
|
||||
<MStat label="电机温度" value={v.motorTemp} unit="°C"/>
|
||||
<MStat label="累计里程" value={(v.totalKm/1000).toFixed(1)} unit="k km"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MSection title="业务关系">
|
||||
<div className="panel" style={{padding: 14}}>
|
||||
<div className="col gap-2" style={{fontSize:12}}>
|
||||
<div className="between"><span className="muted">业务部门</span>
|
||||
<span className="mid gap-1"><span style={{width:7,height:7,background:v.deptColor,borderRadius:1,display:"inline-block"}}/><span className="strong">{v.deptName}</span></span>
|
||||
</div>
|
||||
<div className="between"><span className="muted">业务负责人</span><span className="strong">{v.deptLead}</span></div>
|
||||
<div className="between"><span className="muted">客户</span><span style={{textAlign:"right", maxWidth:160}}>{v.customer}</span></div>
|
||||
<div className="between"><span className="muted">所属公司</span><span style={{fontSize:11,textAlign:"right"}}>{v.ownCompany}</span></div>
|
||||
{v.contractNo && <div className="between"><span className="muted">合同</span><span className="mono" style={{fontSize:11}}>{v.contractNo}</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<MSection title="氢电系统">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||
<MMini label="燃料电池" value="42.5 kW" sub="输出功率"/>
|
||||
<MMini label="动力电池" value={v.soc + "%"} sub="SOC"/>
|
||||
<MMini label="H₂消耗" value="0.84 kg" sub="本次行程"/>
|
||||
<MMini label="电机扭矩" value="180 N·m" sub="实时"/>
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<MSection title="胎压与温度">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{[
|
||||
{ l: "左前", p: "0.24", t: "32" },
|
||||
{ l: "右前", p: "0.24", t: "33" },
|
||||
{ l: "左后", p: "0.23", t: "31" },
|
||||
{ l: "右后", p: v.asset === "abnormal" ? "0.16" : "0.24", t: "38", warn: v.asset === "abnormal" },
|
||||
].map((tire, i) => (
|
||||
<div key={i} className="panel" style={{ padding: 12, border: tire.warn ? "1px solid var(--danger)" : "1px solid var(--border-1)" }}>
|
||||
<div className="between"><span className="muted" style={{ fontSize: 11 }}>{tire.l}</span>{tire.warn && <span className="chip danger" style={{ fontSize: 9 }}>低压</span>}</div>
|
||||
<div className="mono strong" style={{ fontSize: 18, color: tire.warn ? "var(--danger)" : "var(--fg-0)" }}>{tire.p}<span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}> MPa</span></div>
|
||||
<div className="mono muted" style={{ fontSize: 11 }}>{tire.t}°C</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<MSection title="保养预警">
|
||||
<div className="panel" style={{padding: 14}}>
|
||||
<div className="between" style={{marginBottom:8}}>
|
||||
<span className="muted" style={{fontSize:12}}>距下次保养</span>
|
||||
<span className="mono strong" style={{fontSize:16, color: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)"}}>{v.kmToMaint.toLocaleString()} km</span>
|
||||
</div>
|
||||
<div className="bar" style={{height:5}}>
|
||||
<i style={{width: ((10000 - v.kmToMaint)/10000*100) + "%", background: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)"}}/>
|
||||
</div>
|
||||
<div className="muted" style={{fontSize:11, marginTop:8}}>上次保养 {v.lastMaintDays} 天前 · {v.lastMaintKm.toLocaleString()} km</div>
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<MSection title="数据通道">
|
||||
<div className="col gap-2" style={{ fontSize: 12 }}>
|
||||
{[
|
||||
{ l: "TBOX (3296/2016)", st: "ok", info: "5s 上报 · 信号 -68dBm" },
|
||||
{ l: "JT808 部标", st: "ok", info: "实时 · 北京·朝阳" },
|
||||
{ l: "JT1078 视频", st: "ok", info: "4 路 · 720P" },
|
||||
].map((c, i) => (
|
||||
<div key={i} className="between" style={{ padding: "10px 12px", background: "var(--bg-2)", borderRadius: 8, border: "1px solid var(--border-1)" }}>
|
||||
<div>
|
||||
<div className="strong" style={{ fontSize: 13 }}>{c.l}</div>
|
||||
<div className="muted" style={{ fontSize: 11, marginTop: 2 }}>{c.info}</div>
|
||||
</div>
|
||||
<span className="dot ok"/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginTop: 16 }}>
|
||||
<button onClick={() => window.useRoute().navigate("playback")} className="btn primary" style={{ height: 44 }}><Icon name="route" size={14}/> 轨迹回放</button>
|
||||
<button onClick={() => window.useRoute().navigate("history")} className="btn" style={{ height: 44 }}><Icon name="history" size={14}/> 历史数据</button>
|
||||
</div>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
const MStat = ({ label, value, unit, color, big }) => (
|
||||
<div>
|
||||
<div className="muted" style={{ fontSize: 11 }}>{label}</div>
|
||||
<div className="mono strong tnum" style={{ fontSize: big ? 26 : 18, color: color || "var(--fg-0)", lineHeight: 1.1, marginTop: 2 }}>
|
||||
{value}<span style={{ fontSize: big ? 12 : 10, fontWeight: 400, color: "var(--fg-3)", marginLeft: 4 }}>{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MMini = ({ label, value, sub }) => (
|
||||
<div style={{ padding: 10, background: "var(--bg-2)", borderRadius: 8, border: "1px solid var(--border-1)" }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>{label}</div>
|
||||
<div className="mono strong" style={{ fontSize: 15, marginTop: 2 }}>{value}</div>
|
||||
{sub && <div className="muted" style={{ fontSize: 10, marginTop: 1 }}>{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MSection = ({ title, children, action }) => (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="between" style={{ marginBottom: 8, padding: "0 4px" }}>
|
||||
<span className="eyebrow" style={{ fontSize: 11 }}>{title}</span>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 3. Mobile History ───────────────────────────────────────
|
||||
const MobileHistory = () => {
|
||||
const [showFilter, setShowFilter] = React.useState(false);
|
||||
const trips = [
|
||||
{ d: "04-28", t: "14: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 (
|
||||
<MobileShell title="历史查询" subtitle="近 7 日 · 187 条记录" right={<MIconBtn icon="filter" onClick={()=>setShowFilter(s=>!s)}/>}>
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{/* Search */}
|
||||
<div style={{ padding: "12px 12px 0" }}>
|
||||
<div className="search" style={{ height: 40 }}>
|
||||
<Icon name="search" size={14}/>
|
||||
<input placeholder="车牌 / VIN / 部门 / 客户" style={{ fontSize: 14 }}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter chips - horizontal scroll */}
|
||||
<div style={{ padding: "10px 12px", display: "flex", gap: 6, overflowX: "auto", scrollbarWidth: "none", flexShrink: 0 }}>
|
||||
<span className="chip accent" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>近 7 日</span>
|
||||
<span className="chip" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>编组A</span>
|
||||
<span className="chip" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>全部车型</span>
|
||||
<span className="chip" style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12 }}>有告警</span>
|
||||
</div>
|
||||
|
||||
{/* KPI summary */}
|
||||
<div style={{ padding: "0 12px 12px", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8, flexShrink: 0 }}>
|
||||
{[
|
||||
{ l: "总里程", v: "1,847", u: "km", c: "var(--fg-0)" },
|
||||
{ l: "氢耗", v: "47.2", u: "kg", c: "var(--info)" },
|
||||
{ l: "减碳", v: "118", u: "kg", c: "var(--ok)" },
|
||||
].map((k, i) => (
|
||||
<div key={i} style={{ padding: 10, background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 8 }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>{k.l}</div>
|
||||
<div className="mono strong tnum" style={{ fontSize: 16, color: k.c }}>{k.v}<span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}> {k.u}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Trip list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "0 12px 16px" }}>
|
||||
{trips.map((t, i) => {
|
||||
const cls = t.st === "danger" ? "danger" : t.st === "warn" ? "warn" : "ok";
|
||||
return (
|
||||
<div key={i} className="panel" style={{ padding: 12, marginBottom: 8 }}>
|
||||
<div className="between" style={{ marginBottom: 6 }}>
|
||||
<div className="mid gap-2">
|
||||
<span className="mono strong" style={{ fontSize: 13 }}>{t.v}</span>
|
||||
<span className={"chip " + cls} style={{ fontSize: 9 }}>{t.st === "danger" ? "故障" : t.st === "warn" ? "告警" : "正常"}</span>
|
||||
</div>
|
||||
<span className="muted mono" style={{ fontSize: 11 }}>{t.d} {t.t}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 14, fontSize: 12 }}>
|
||||
<div><span className="muted">里程 </span><span className="mono strong">{t.k}</span></div>
|
||||
<div><span className="muted">氢耗 </span><span className="mono strong">{t.h}</span></div>
|
||||
<button onClick={() => window.useRoute().navigate("playback")} className="btn sm ghost" style={{ marginLeft: "auto", padding: "4px 10px", fontSize: 11 }}>回放</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 4. Mobile Playback ──────────────────────────────────────
|
||||
const MobilePlayback = () => {
|
||||
const [t, setT] = React.useState(38);
|
||||
const [playing, setPlaying] = React.useState(true);
|
||||
const [speed, setSpeed] = React.useState(2);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!playing) return;
|
||||
const id = setInterval(() => setT(v => (v + speed * 0.6) % 100), 200);
|
||||
return () => clearInterval(id);
|
||||
}, [playing, speed]);
|
||||
|
||||
return (
|
||||
<MobileShell title="轨迹回放" subtitle="浙F07179F · 04-28 14:02 → 14:44">
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column" }}>
|
||||
{/* Map area */}
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
<FleetMap selected="浙F07179F" playbackPoint={{ x: 280 + t * 4, y: 260 + Math.sin(t/15) * 80 }}/>
|
||||
{/* Floating speed badge */}
|
||||
<div style={{ position: "absolute", top: 12, left: 12, padding: "8px 12px", background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 10, boxShadow: "var(--shadow-1)" }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>当前速度</div>
|
||||
<div className="mono strong tnum" style={{ fontSize: 16 }}>{Math.round(40 + Math.sin(t/8)*20)} <span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}>km/h</span></div>
|
||||
</div>
|
||||
<div style={{ position: "absolute", top: 12, right: 12, padding: "8px 12px", background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 10, boxShadow: "var(--shadow-1)" }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>SOC</div>
|
||||
<div className="mono strong tnum" style={{ fontSize: 16, color: "var(--ok)" }}>{Math.round(78 - t * 0.15)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom playback panel */}
|
||||
<div style={{
|
||||
flex: "0 0 auto", background: "var(--bg-1)", borderTop: "1px solid var(--border-1)",
|
||||
padding: "12px 14px 16px", boxShadow: "0 -4px 16px -4px rgba(0,0,0,0.08)",
|
||||
}}>
|
||||
{/* Time + scrub */}
|
||||
<div className="between" style={{ marginBottom: 8 }}>
|
||||
<span className="mono strong" style={{ fontSize: 14 }}>14:{String(Math.floor(t * 0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")}</span>
|
||||
<span className="mono muted" style={{ fontSize: 11 }}>已 {Math.round(t*0.42)} 分 / 共 42 分</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" value={t} onChange={e => setT(+e.target.value)} style={{
|
||||
width: "100%", height: 4, accentColor: "var(--accent)", marginBottom: 12,
|
||||
}}/>
|
||||
|
||||
{/* Controls row */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<button onClick={() => setT(0)} style={ctrlBtn}><Icon name="route" size={14}/></button>
|
||||
<button onClick={() => setT(v => Math.max(0, v - 10))} style={ctrlBtn}>« 10s</button>
|
||||
<button onClick={() => setPlaying(p => !p)} style={{ ...ctrlBtn, width: 56, height: 44, background: "var(--accent)", color: "#fff", borderColor: "var(--accent)" }}>{playing ? "⏸" : "▶"}</button>
|
||||
<button onClick={() => setT(v => Math.min(100, v + 10))} style={ctrlBtn}>10s »</button>
|
||||
<select value={speed} onChange={e => setSpeed(+e.target.value)} style={{ ...ctrlBtn, padding: "0 10px" }}>
|
||||
{[0.5, 1, 2, 4, 8, 16].map(s => <option key={s} value={s}>{s}×</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Mini chart timeline events */}
|
||||
<div style={{ marginTop: 12, position: "relative", height: 24, background: "var(--bg-2)", borderRadius: 4 }}>
|
||||
{[12, 38, 65, 88].map((p, i) => (
|
||||
<div key={i} style={{ position: "absolute", left: p+"%", top: 0, bottom: 0, width: 2, background: i===2?"var(--danger)":"var(--warn)" }}/>
|
||||
))}
|
||||
<div style={{ position: "absolute", left: t+"%", top: -4, bottom: -4, width: 2, background: "var(--accent)", boxShadow: "0 0 8px var(--accent)" }}/>
|
||||
</div>
|
||||
<div className="between" style={{ marginTop: 4, fontSize: 10 }}>
|
||||
<span className="muted">事件</span>
|
||||
<span className="mono muted">急刹×1 · 超速×2 · 故障×1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
const ctrlBtn = {
|
||||
flex: 1, height: 40, display: "grid", placeItems: "center",
|
||||
background: "var(--bg-2)", border: "1px solid var(--border-1)",
|
||||
color: "var(--fg-1)", borderRadius: 8, fontSize: 12, fontFamily: "var(--font-mono)", cursor: "pointer",
|
||||
};
|
||||
|
||||
// ── 5. Mobile Alarm Rules (list-based) ──────────────────────
|
||||
const MobileAlarm = () => {
|
||||
const rules = [
|
||||
{ n: "电池SOC严重不足", st: "on", trig: 12, cond: "SOC < 15% 持续 30s", p: "P0" },
|
||||
{ n: "右后胎压低", st: "on", trig: 8, cond: "压力 < 0.20 MPa", p: "P0" },
|
||||
{ n: "超速预警", st: "on", trig: 47, cond: "速度 > 限速 + 5 km/h", p: "P1" },
|
||||
{ n: "H₂压力异常下降", st: "on", trig: 5, cond: "5min 内下降 > 1.0 MPa", p: "P0" },
|
||||
{ n: "电堆过温保护", st: "on", trig: 3, cond: "温度 > 90°C", p: "P0" },
|
||||
{ n: "急加速密集", st: "on", trig: 31, cond: "5min 内 ≥ 3 次", p: "P2" },
|
||||
{ n: "疲劳驾驶", st: "off", trig: 0, cond: "连续驾驶 > 4h", p: "P1" },
|
||||
];
|
||||
return (
|
||||
<MobileShell title="事件规则" subtitle={`${rules.filter(r=>r.st==="on").length} / ${rules.length} 启用`} right={<MIconBtn icon="plus"/>}>
|
||||
<div style={{ height: "100%", overflowY: "auto", padding: 12 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8, marginBottom: 12 }}>
|
||||
{[
|
||||
{ l: "P0 紧急", v: 4, c: "var(--danger)" },
|
||||
{ l: "P1 警告", v: 2, c: "var(--warn)" },
|
||||
{ l: "P2 提示", v: 1, c: "var(--info)" },
|
||||
].map((k, i) => (
|
||||
<div key={i} style={{ padding: 10, background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 8 }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>{k.l}</div>
|
||||
<div className="mono strong" style={{ fontSize: 18, color: k.c }}>{k.v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rules.map((r, i) => (
|
||||
<div key={i} className="panel" style={{ padding: 14, marginBottom: 8 }}>
|
||||
<div className="between" style={{ marginBottom: 6 }}>
|
||||
<div className="mid gap-2">
|
||||
<span className={"chip " + (r.p==="P0"?"danger":r.p==="P1"?"warn":"info")} style={{ fontSize: 9 }}>{r.p}</span>
|
||||
<span className="strong" style={{ fontSize: 14 }}>{r.n}</span>
|
||||
</div>
|
||||
<MSwitch on={r.st==="on"}/>
|
||||
</div>
|
||||
<div className="muted mono" style={{ fontSize: 11, marginBottom: 6 }}>{r.cond}</div>
|
||||
<div className="between" style={{ fontSize: 11 }}>
|
||||
<span className="muted">7日触发 <span className="mono strong" style={{ color: "var(--fg-1)" }}>{r.trig}</span> 次</span>
|
||||
<span className="muted">短信 · App推送 · 邮件</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
const MSwitch = ({ on }) => (
|
||||
<div style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2,
|
||||
background: on ? "var(--accent)" : "var(--bg-3)", transition: "background 200ms",
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 8, background: "#fff",
|
||||
transform: on ? "translateX(16px)" : "translateX(0)",
|
||||
transition: "transform 200ms cubic-bezier(.3,0,.2,1)",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.2)",
|
||||
}}/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 6. Mobile Inbox ─────────────────────────────────────────
|
||||
const MobileInbox = () => {
|
||||
const [filter, setFilter] = React.useState("all");
|
||||
const alerts = [
|
||||
{p:"P0", n:"电池SOC严重不足", v:"浙F08638F", t:"刚刚", det:"SOC 9% < 阈值 15% · 持续 4分20秒", st:"new"},
|
||||
{p:"P0", n:"右后胎压低", v:"浙F08638F", t:"3分钟前", det:"0.16 MPa · 阈值 0.20 MPa", st:"new"},
|
||||
{p:"P1", n:"超速预警", v:"浙F02002F", t:"12分钟前", det:"实测 89 km/h · 限速 80 km/h · 持续 12s", st:"new"},
|
||||
{p:"P1", n:"H₂压力异常下降", v:"浙F07179F", t:"32分钟前", det:"5分钟内下降 1.2 MPa", st:"ack"},
|
||||
{p:"P0", n:"电堆过温保护", v:"浙F00598F", t:"1小时前", det:"电堆温度 95°C · 阈值 90°C", st:"resolved"},
|
||||
{p:"P2", n:"急加速密集", v:"浙F02608F", t:"2小时前", det:"5分钟内 3 次急加速", st:"resolved"},
|
||||
{p:"P1", n:"偏离规划路线", v:"浙F00278F", t:"3小时前", det:"偏离 1.2 km · 持续 6 分钟", st:"resolved"},
|
||||
];
|
||||
const filtered = filter === "all" ? alerts : filter === "new" ? alerts.filter(a=>a.st==="new") : alerts.filter(a=>a.p===filter);
|
||||
return (
|
||||
<MobileShell title="通知中心" subtitle="3 未处理 · 24 今日">
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "10px 12px 8px", display: "flex", gap: 6, overflowX: "auto", scrollbarWidth: "none", flexShrink: 0, borderBottom: "1px solid var(--border-1)" }}>
|
||||
{[
|
||||
{id:"all", l:"全部 24"},
|
||||
{id:"new", l:"未处理 3"},
|
||||
{id:"P0", l:"P0 · 2"},
|
||||
{id:"P1", l:"P1 · 8"},
|
||||
{id:"P2", l:"P2 · 14"},
|
||||
].map(t => (
|
||||
<button key={t.id} onClick={()=>setFilter(t.id)} className={"chip " + (filter===t.id?"accent":"")} style={{ flex: "0 0 auto", padding: "6px 12px", fontSize: 12, cursor: "pointer" }}>{t.l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
{filtered.map((a, i) => {
|
||||
const c = a.p === "P0" ? "var(--danger)" : a.p === "P1" ? "var(--warn)" : "var(--info)";
|
||||
return (
|
||||
<div key={i} style={{
|
||||
display: "flex", gap: 12, padding: "14px 14px",
|
||||
borderBottom: "1px solid var(--border-1)",
|
||||
background: a.st === "new" ? "var(--accent-soft)" : "transparent",
|
||||
}}>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 18, background: a.st === "new" ? c : "var(--bg-3)", opacity: a.st==="resolved"?0.4:1, display: "grid", placeItems: "center", color: "#fff", boxShadow: a.st === "new" ? `0 0 12px ${c}` : "none" }}>
|
||||
<Icon name="bell" size={14}/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="between">
|
||||
<div className="mid gap-2">
|
||||
<span className={"chip " + (a.p==="P0"?"danger":a.p==="P1"?"warn":"info")} style={{ fontSize: 9, padding: "2px 6px" }}>{a.p}</span>
|
||||
<span className="strong" style={{ fontSize: 13 }}>{a.n}</span>
|
||||
</div>
|
||||
<span className="mono muted" style={{ fontSize: 11 }}>{a.t}</span>
|
||||
</div>
|
||||
<div className="mono muted" style={{ fontSize: 11, marginTop: 2 }}>{a.v}</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>{a.det}</div>
|
||||
{a.st === "new" && (
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 10 }}>
|
||||
<button onClick={()=>window.useRoute().navigate("playback")} className="btn sm primary" style={{ fontSize: 11 }}><Icon name="route" size={11}/> 轨迹</button>
|
||||
<button className="btn sm" style={{ fontSize: 11 }}>确认</button>
|
||||
<button className="btn sm ghost" style={{ fontSize: 11 }}>忽略</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 7. Mobile ESG ────────────────────────────────────────────
|
||||
const MobileESG = () => {
|
||||
return (
|
||||
<MobileShell title="ESG · 碳减排" subtitle="羚牛 ESG Link">
|
||||
<div style={{ height: "100%", overflowY: "auto", padding: 12 }}>
|
||||
{/* Hero stat */}
|
||||
<div className="panel" style={{ padding: 16, marginBottom: 12, background: "linear-gradient(135deg, #007143, #00A35F)", color: "#fff", border: "none" }}>
|
||||
<div style={{ fontSize: 11, opacity: 0.85 }}>本年度累计减碳</div>
|
||||
<div className="mono tnum" style={{ fontSize: 36, fontWeight: 700, lineHeight: 1.1, margin: "4px 0" }}>1,847.2<span style={{ fontSize: 14, fontWeight: 400, opacity: 0.85, marginLeft: 6 }}>tCO₂e</span></div>
|
||||
<div style={{ fontSize: 11, opacity: 0.85 }}>较去年同期 ▲ 32.4%</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 12 }}>
|
||||
{[
|
||||
{ l: "氢能消耗", v: "47.2", u: "万 m³", c: "var(--info)" },
|
||||
{ l: "里程", v: "1.84", u: "万 km", c: "var(--fg-0)" },
|
||||
{ l: "碳交易收益", v: "18.78", u: "万元", c: "var(--accent)" },
|
||||
{ l: "覆盖城市", v: "23", u: "个", c: "var(--fg-0)" },
|
||||
].map((k, i) => (
|
||||
<div key={i} className="panel" style={{ padding: 12 }}>
|
||||
<div className="muted" style={{ fontSize: 10 }}>{k.l}</div>
|
||||
<div className="mono strong tnum" style={{ fontSize: 18, color: k.c, marginTop: 2 }}>{k.v}<span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)", marginLeft: 4 }}>{k.u}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MSection title="月度减碳趋势">
|
||||
<div className="panel" style={{ padding: 14 }}>
|
||||
<svg viewBox="0 0 320 120" width="100%" height="100" style={{ overflow: "visible" }}>
|
||||
{[180,220,255,235,290,270,310,345,320,355,380,420].map((v, i, arr) => {
|
||||
const x = 12 + i * 26;
|
||||
const h = (v / 420) * 90;
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x-9} y={108-h} width="18" height={h} fill="var(--accent)" rx="2" opacity={i===11?1:0.65}/>
|
||||
{i%3===0 && <text x={x} y="118" fontSize="9" fill="var(--fg-3)" textAnchor="middle">{i+1}月</text>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</MSection>
|
||||
|
||||
<MSection title="车辆减碳排行 Top 5">
|
||||
<div className="col gap-1">
|
||||
{[
|
||||
{ p:"浙F·8A03F", v: 24.38 },
|
||||
{ p:"浙F·2C57G", v: 22.15 },
|
||||
{ p:"浙F·9D14B", v: 19.84 },
|
||||
{ p:"浙F·6E72H", v: 17.21 },
|
||||
{ p:"浙F·1B49K", v: 15.67 },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="between" style={{ padding: "10px 12px", background: "var(--bg-1)", border: "1px solid var(--border-1)", borderRadius: 8 }}>
|
||||
<span className="mid gap-2">
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--fg-3)", width: 16 }}>#{i+1}</span>
|
||||
<span className="mono strong" style={{ fontSize: 13 }}>{r.p}</span>
|
||||
</span>
|
||||
<span className="mono strong" style={{ fontSize: 13, color: "var(--accent)" }}>{r.v} <span style={{ fontSize: 10, fontWeight: 400, color: "var(--fg-3)" }}>kg</span></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MSection>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Mobile route map ────────────────────────────────────────
|
||||
const MOBILE_PAGES = {
|
||||
overview: MobileOverview,
|
||||
detail: MobileDetail,
|
||||
history: MobileHistory,
|
||||
playback: MobilePlayback,
|
||||
alarm: MobileAlarm,
|
||||
inbox: MobileInbox,
|
||||
esg: MobileESG,
|
||||
};
|
||||
|
||||
const MobileRouter = ({ route }) => {
|
||||
const Cmp = MOBILE_PAGES[route];
|
||||
if (!Cmp) {
|
||||
return (
|
||||
<MobileShell title="设计画板" subtitle="该页面仅桌面端可用">
|
||||
<div style={{ padding: 24, textAlign: "center", color: "var(--fg-2)" }}>
|
||||
<Icon name="settings" size={32}/>
|
||||
<div style={{ marginTop: 12, fontSize: 14 }}>设计画板模式</div>
|
||||
<div style={{ fontSize: 12, color: "var(--fg-3)", marginTop: 4 }}>请在桌面端访问以查看完整设计稿</div>
|
||||
<button onClick={() => window.useRoute().navigate("overview")} className="btn primary" style={{ marginTop: 16, height: 40, padding: "0 20px" }}>返回主页</button>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
return <Cmp/>;
|
||||
};
|
||||
|
||||
window.MobileRouter = MobileRouter;
|
||||
window.MobileShell = MobileShell;
|
||||
385
artboards/overview.jsx
Normal file
385
artboards/overview.jsx
Normal file
@@ -0,0 +1,385 @@
|
||||
// artboard-overview.jsx — Asset-management overview
|
||||
// Filter by: 资产状态 / 部门 / 归属
|
||||
// Card shows: 车牌 + VIN + 城市 + 部门 + 客户 + 资产状态
|
||||
// Detail shows: 资产档案 + 业务关系 + 实时车况 + 保养预警 (no driver)
|
||||
|
||||
const AssetStatusChip = ({ status }) => {
|
||||
const map = {
|
||||
in_stock: { label: "在库", bg: "var(--accent-soft)", fg: "var(--accent)", dot: "ok" },
|
||||
leasing: { label: "租赁" , bg: "rgba(46,140,140,0.15)",fg: "var(--info)", dot: "info" },
|
||||
abnormal: { label: "异常", bg: "var(--danger-soft)", fg: "var(--danger)", dot: "danger" },
|
||||
};
|
||||
const m = map[status] || map.in_stock;
|
||||
return (
|
||||
<span className="chip" style={{background:m.bg, color:m.fg, border:"1px solid " + m.fg + "33", fontSize:10, padding:"2px 7px"}}>
|
||||
<span className={"dot " + m.dot} style={{width:5, height:5}}/> {m.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const OwnChip = ({ own }) => (
|
||||
<span className="chip" style={{
|
||||
fontSize:10, padding:"1px 6px",
|
||||
background: own === "self" ? "rgba(31,139,76,0.10)" : "rgba(122,140,46,0.12)",
|
||||
color: own === "self" ? "var(--accent)" : "#7A8C2E",
|
||||
border: "1px solid " + (own === "self" ? "rgba(31,139,76,0.25)" : "rgba(122,140,46,0.25)"),
|
||||
}}>{own === "self" ? "自有" : "外租"}</span>
|
||||
);
|
||||
|
||||
const DeptDot = ({ dept }) => {
|
||||
const d = (window.DEPARTMENTS || []).find(x => x.id === dept);
|
||||
if (!d) return null;
|
||||
return (
|
||||
<span style={{display:"inline-flex", alignItems:"center", gap:4, fontSize:11}}>
|
||||
<span style={{width:6, height:6, background:d.color, borderRadius:1, display:"inline-block"}}/>
|
||||
<span style={{color:"var(--fg-1)"}}>{d.name}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const ArtboardOverview = () => {
|
||||
const allVehicles = (window.VEHICLES || []);
|
||||
const { role } = (typeof window.useCurrentRole === "function") ? window.useCurrentRole() : { role: null };
|
||||
// Apply role-based scope before user-facing filters
|
||||
const vehicles = React.useMemo(() => {
|
||||
if (!role || role.scope === "all" || role.scope === "ops" || role.scope === "finance") return allVehicles;
|
||||
if (role.scope === "dept") return allVehicles.filter(v => v.dept === role.deptId);
|
||||
return allVehicles;
|
||||
}, [role, allVehicles]);
|
||||
const counts = (window.COUNTS || {});
|
||||
const deps = (window.DEPARTMENTS || []);
|
||||
const isDeptScoped = role && role.scope === "dept";
|
||||
// Scoped counts so KPIs match what the role can actually see
|
||||
const scopedCounts = React.useMemo(() => {
|
||||
const c = { all: vehicles.length, inStock:0, leasing:0, abnormal:0, self:0, lease:0 };
|
||||
vehicles.forEach(v => {
|
||||
if (v.asset === "in_stock") c.inStock++;
|
||||
else if (v.asset === "leasing") c.leasing++;
|
||||
else if (v.asset === "abnormal") c.abnormal++;
|
||||
if (v.own === "self") c.self++; else c.lease++;
|
||||
});
|
||||
return c;
|
||||
}, [vehicles]);
|
||||
|
||||
const [selected, setSelected] = React.useState(vehicles[8]?.id || vehicles[0]?.id);
|
||||
React.useEffect(() => {
|
||||
if (vehicles.length && !vehicles.find(x => x.id === selected)) {
|
||||
setSelected(vehicles[0].id);
|
||||
}
|
||||
}, [vehicles, selected]);
|
||||
const [filterAsset, setFilterAsset] = React.useState("all"); // all | in_stock | leasing | abnormal
|
||||
const [filterDept, setFilterDept] = React.useState("all");
|
||||
const [filterOwn, setFilterOwn] = React.useState("all");
|
||||
const [search, setSearch] = React.useState("");
|
||||
|
||||
const filtered = vehicles.filter(v => {
|
||||
if (filterAsset !== "all" && v.asset !== filterAsset) return false;
|
||||
if (filterDept !== "all" && v.dept !== filterDept) return false;
|
||||
if (filterOwn !== "all" && v.own !== filterOwn) return false;
|
||||
if (search && !v.plate.includes(search) && !v.vin.includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const v = vehicles.find(x => x.id === selected) || vehicles[0];
|
||||
if (!v) return null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar active="map"/>
|
||||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
|
||||
<Topbar
|
||||
crumbs={isDeptScoped ? ["羚牛车辆数据中心", "资产管理", role.name.replace(/.*·/,"")] : ["羚牛车辆数据中心", "资产管理", "总览"]}
|
||||
kpis={[
|
||||
{ lbl: isDeptScoped ? "本部门车辆" : "总车辆", val: scopedCounts.all },
|
||||
{ lbl:"在库", val: scopedCounts.inStock, delta: scopedCounts.all ? Math.round(scopedCounts.inStock/scopedCounts.all*100) + "%" : "0%" },
|
||||
{ lbl:"租赁", val: scopedCounts.leasing, delta: scopedCounts.all ? Math.round(scopedCounts.leasing/scopedCounts.all*100) + "%" : "0%", deltaUp:true },
|
||||
{ lbl:"异常", val: scopedCounts.abnormal, delta: scopedCounts.abnormal > 0 ? "+" + scopedCounts.abnormal : "0", deltaUp:false },
|
||||
]}
|
||||
/>
|
||||
{isDeptScoped && (
|
||||
<div style={{
|
||||
padding:"7px 16px", background:"var(--accent-soft)",
|
||||
borderBottom:"1px solid var(--border-1)", fontSize:11,
|
||||
display:"flex", alignItems:"center", gap:10, color:"var(--fg-1)"
|
||||
}}>
|
||||
<span style={{width:6, height:6, borderRadius:3, background:"var(--accent)"}}/>
|
||||
<span><span className="strong">数据权限:</span>当前以 <span className="strong">{role.name}</span> 身份登录,仅可见本部门 {scopedCounts.all} 辆车 · 全公司共 {counts.all} 辆</span>
|
||||
<span className="muted" style={{marginLeft:"auto"}}>切换身份请使用右下角 Tweaks · 登录身份</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{flex:1, display:"grid", gridTemplateColumns:"320px 1fr 360px", gap:0, minHeight:0}}>
|
||||
{/* Left: fleet list with asset filters */}
|
||||
<div style={{borderRight:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
|
||||
<div style={{padding:"12px 14px 8px"}}>
|
||||
<div className="between" style={{marginBottom:8}}>
|
||||
<span className="eyebrow">车辆 · {filtered.length}/{scopedCounts.all}</span>
|
||||
<span className="muted" style={{fontSize:11, cursor:"pointer"}}><Icon name="filter" size={11} style={{verticalAlign:"middle"}}/> 高级</span>
|
||||
</div>
|
||||
<div className="search" style={{height:28}}>
|
||||
<Icon name="search" size={12}/>
|
||||
<input placeholder="车牌 / VIN" value={search} onChange={e => setSearch(e.target.value)}/>
|
||||
</div>
|
||||
|
||||
{/* Asset status filter */}
|
||||
<div style={{marginTop:8}}>
|
||||
<div className="muted" style={{fontSize:10, marginBottom:4, letterSpacing:".05em"}}>资产状态</div>
|
||||
<div style={{display:"flex", gap:4, flexWrap:"wrap"}}>
|
||||
{[
|
||||
{k:"all", l:"全部", c: scopedCounts.all},
|
||||
{k:"in_stock", l:"在库", c: scopedCounts.inStock},
|
||||
{k:"leasing", l:"租赁" , c: scopedCounts.leasing},
|
||||
{k:"abnormal", l:"异常", c: scopedCounts.abnormal},
|
||||
].map(o => (
|
||||
<span key={o.k}
|
||||
className={"chip" + (filterAsset === o.k ? " accent" : "")}
|
||||
style={{cursor:"pointer", fontSize:11}}
|
||||
onClick={() => setFilterAsset(o.k)}>
|
||||
{o.l} <span className="muted mono" style={{marginLeft:3, fontSize:10}}>{o.c}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ownership */}
|
||||
<div style={{marginTop:6}}>
|
||||
<div className="muted" style={{fontSize:10, marginBottom:4, letterSpacing:".05em"}}>归属</div>
|
||||
<div style={{display:"flex", gap:4, flexWrap:"wrap"}}>
|
||||
{[
|
||||
{k:"all", l:"全部"},
|
||||
{k:"self", l:"自有", c: scopedCounts.self},
|
||||
{k:"lease", l:"外租", c: scopedCounts.lease},
|
||||
].map(o => (
|
||||
<span key={o.k}
|
||||
className={"chip" + (filterOwn === o.k ? " accent" : "")}
|
||||
style={{cursor:"pointer", fontSize:11}}
|
||||
onClick={() => setFilterOwn(o.k)}>
|
||||
{o.l}{o.c != null && <span className="muted mono" style={{marginLeft:3, fontSize:10}}>{o.c}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Department — hidden when role is dept-scoped (only one dept visible) */}
|
||||
{!isDeptScoped && (
|
||||
<div style={{marginTop:6}}>
|
||||
<div className="muted" style={{fontSize:10, marginBottom:4, letterSpacing:".05em"}}>业务部门</div>
|
||||
<div style={{display:"flex", gap:4, flexWrap:"wrap"}}>
|
||||
<span className={"chip" + (filterDept === "all" ? " accent" : "")}
|
||||
style={{cursor:"pointer", fontSize:11}}
|
||||
onClick={() => setFilterDept("all")}>全部</span>
|
||||
{deps.map(d => (
|
||||
<span key={d.id}
|
||||
className={"chip" + (filterDept === d.id ? " accent" : "")}
|
||||
style={{cursor:"pointer", fontSize:11}}
|
||||
onClick={() => setFilterDept(d.id)}>
|
||||
<span style={{width:6, height:6, background:d.color, borderRadius:1, display:"inline-block", marginRight:4, verticalAlign:"middle"}}/>
|
||||
{d.name}
|
||||
<span className="muted mono" style={{marginLeft:3, fontSize:10}}>{counts.byDept?.[d.id] || 0}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="scroll" style={{flex:1, padding:"4px 0"}}>
|
||||
{filtered.map(x => (
|
||||
<div key={x.id} onClick={() => setSelected(x.id)}
|
||||
style={{
|
||||
padding:"10px 14px", borderLeft: "2px solid " + (x.id === selected ? "var(--accent)" : "transparent"),
|
||||
background: x.id === selected ? "var(--accent-soft)" : "transparent",
|
||||
cursor:"pointer", borderBottom:"1px solid var(--border-1)"
|
||||
}}>
|
||||
<div className="between">
|
||||
<span className="mono strong" style={{fontSize:13, color:"var(--fg-0)"}}>{x.plate}</span>
|
||||
<AssetStatusChip status={x.asset}/>
|
||||
</div>
|
||||
<div className="muted mono" style={{fontSize:10, marginTop:3, opacity:.8}}>{x.vin}</div>
|
||||
<div style={{display:"flex", gap:8, marginTop:5, alignItems:"center", flexWrap:"wrap"}}>
|
||||
<DeptDot dept={x.dept}/>
|
||||
<OwnChip own={x.own}/>
|
||||
{x.gps === "offline" && <span className="muted" style={{fontSize:10}}>● GPS离线</span>}
|
||||
</div>
|
||||
<div className="muted" style={{fontSize:10, marginTop:4}}>
|
||||
<Icon name="pin" size={9} style={{verticalAlign:"middle", marginRight:3, opacity:.6}}/>
|
||||
{x.city}
|
||||
{x.customer !== "—" && (<><span style={{margin:"0 4px"}}>·</span>{x.customer}</>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="muted" style={{padding:"40px 16px", textAlign:"center", fontSize:12}}>没有匹配的车辆</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map center */}
|
||||
<div style={{position:"relative", minWidth:0, minHeight:0}}>
|
||||
<FleetMap selectedId={selected} onSelect={(x)=>setSelected(x.id)} />
|
||||
<div style={{position:"absolute", top:12, right:12, display:"flex", flexDirection:"column", gap:6}}>
|
||||
{["layers","plus","close","sat","pin"].map((n,i)=>(
|
||||
<div key={i} className="icon-btn" style={{width:32, height:32, background:"var(--bg-1)", border:"1px solid var(--border-1)"}}>
|
||||
<Icon name={n === "close" ? "expand" : n} size={14}/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Legend by asset */}
|
||||
<div style={{position:"absolute", bottom:12, left:12, padding:"8px 10px", background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6, display:"flex", gap:14, fontSize:10}}>
|
||||
<span className="mid gap-1"><span className="dot ok"/> 在库/正常</span>
|
||||
<span className="mid gap-1"><span className="dot info"/> 租赁</span>
|
||||
<span className="mid gap-1"><span className="dot warn"/> 待整备</span>
|
||||
<span className="mid gap-1"><span className="dot danger"/> 异常</span>
|
||||
<span className="mid gap-1"><span className="dot idle"/> GPS离线</span>
|
||||
</div>
|
||||
<div style={{position:"absolute", top:12, left:12, padding:"6px 10px", background:"var(--bg-1)", border:"1px solid var(--border-1)", borderRadius:6, fontSize:11, fontFamily:"var(--font-mono)", display:"flex", alignItems:"center", gap:10, color:"var(--fg-1)", boxShadow:"var(--shadow-1)"}}>
|
||||
<span style={{display:"inline-flex", alignItems:"center", gap:4}}><span className="dot ok pulse"/> LIVE</span>
|
||||
<span className="muted">|</span>
|
||||
<span>嘉兴市·平湖</span>
|
||||
<span className="muted">|</span>
|
||||
<span>14:32:08</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: vehicle asset detail panel */}
|
||||
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
|
||||
<div style={{padding:"14px 16px 12px", borderBottom:"1px solid var(--border-1)"}}>
|
||||
<div className="between">
|
||||
<div style={{minWidth:0, flex:1}}>
|
||||
<div className="eyebrow">{v.deptName} · {v.deptLead}</div>
|
||||
<div className="mono strong" style={{fontSize:18, fontWeight:600, marginTop:4}}>{v.plate}</div>
|
||||
<div className="muted mono" style={{fontSize:10, marginTop:2}}>{v.vin}</div>
|
||||
</div>
|
||||
<div className="col gap-1" style={{alignItems:"flex-end"}}>
|
||||
<AssetStatusChip status={v.asset}/>
|
||||
<OwnChip own={v.own}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mid gap-2" style={{marginTop:8, fontSize:10}}>
|
||||
<span className="muted">车辆等级</span>
|
||||
<span className="strong">{v.grade}级</span>
|
||||
<span className="muted">·</span>
|
||||
<span className="muted">状态时长</span>
|
||||
<span className="mono strong">{v.statusDays}天</span>
|
||||
{v.fleetCode && <>
|
||||
<span className="muted">·</span>
|
||||
<span className="muted">编号</span>
|
||||
<span className="mono strong">{v.fleetCode}</span>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scroll" style={{flex:1}}>
|
||||
{/* 业务关系 */}
|
||||
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
|
||||
<div className="eyebrow" style={{marginBottom:10}}>业务关系</div>
|
||||
<div className="col gap-2" style={{fontSize:11}}>
|
||||
<div className="between"><span className="muted">业务部门</span>
|
||||
<span className="mid gap-1"><span style={{width:6,height:6,background:v.deptColor,borderRadius:1,display:"inline-block"}}/><span className="strong">{v.deptName}</span></span>
|
||||
</div>
|
||||
<div className="between"><span className="muted">业务负责人</span><span className="strong">{v.deptLead}</span></div>
|
||||
<div className="between"><span className="muted">客户</span><span className="strong" style={{textAlign:"right"}}>{v.customer}</span></div>
|
||||
<div className="between"><span className="muted">所属公司</span><span style={{fontSize:10, textAlign:"right"}}>{v.ownCompany}</span></div>
|
||||
{v.own === "lease" && <div className="between"><span className="muted">租赁公司</span><span style={{fontSize:10, textAlign:"right"}}>{v.company}</span></div>}
|
||||
{v.contractNo && <div className="between"><span className="muted">合同编号</span><span className="mono" style={{fontSize:10}}>{v.contractNo}</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 实时车况 — 财务身份不可见 */}
|
||||
{role && role.scope === "finance" ? (
|
||||
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
|
||||
<div className="eyebrow" style={{marginBottom:10}}>实时车况</div>
|
||||
<div style={{padding:"14px 12px", background:"var(--bg-2)", border:"1px dashed var(--border-1)", borderRadius:4, textAlign:"center"}}>
|
||||
<div style={{fontSize:18, opacity:0.4, marginBottom:6}}>🔒</div>
|
||||
<div style={{fontSize:11, color:"var(--fg-2)"}}>实时车况数据已隐藏</div>
|
||||
<div style={{fontSize:10, color:"var(--fg-3)", marginTop:3}}>财务身份仅可见资产 · 业务关系 · 合同</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
|
||||
<div className="between" style={{marginBottom:10}}>
|
||||
<span className="eyebrow">实时车况</span>
|
||||
<span className="chip" style={{fontSize:9, padding:"1px 6px"}}>
|
||||
<span className={"dot " + (v.gps === "online" ? "ok" : "idle")} style={{width:5,height:5}}/> GPS{v.gps === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}}>
|
||||
<Gauge value={v.speed/120} label={v.speed} sub="km/h" color="var(--info)"/>
|
||||
<Gauge value={v.soc/100} label={v.soc + "%"} sub="电量" color={v.soc < 20 ? "var(--danger)" : "var(--accent)"}/>
|
||||
</div>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10, marginTop:10, fontSize:11}}>
|
||||
<div className="col gap-1">
|
||||
<span className="muted">氢气压力</span>
|
||||
<span className="mono strong">{v.h2} MPa</span>
|
||||
</div>
|
||||
<div className="col gap-1">
|
||||
<span className="muted">续航</span>
|
||||
<span className="mono strong">{v.range} km</span>
|
||||
</div>
|
||||
<div className="col gap-1">
|
||||
<span className="muted">电机温度</span>
|
||||
<span className="mono strong" style={{color: v.motorTemp > 90 ? "var(--danger)" : "var(--fg-0)"}}>{v.motorTemp}°C</span>
|
||||
</div>
|
||||
<div className="col gap-1">
|
||||
<span className="muted">停车场</span>
|
||||
<span className="strong" style={{fontSize:10}}>{v.parking}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 里程 & 保养 */}
|
||||
<div style={{padding:"14px 16px", borderBottom:"1px solid var(--border-1)"}}>
|
||||
<div className="eyebrow" style={{marginBottom:10}}>里程与保养</div>
|
||||
<div className="col gap-2" style={{fontSize:11}}>
|
||||
<div className="between"><span className="muted">累计里程</span><span className="mono strong">{v.totalKm.toLocaleString()} km</span></div>
|
||||
<div className="between"><span className="muted">上次保养</span><span className="mono">{v.lastMaintDays}天前 · {v.lastMaintKm.toLocaleString()}km</span></div>
|
||||
<div>
|
||||
<div className="between" style={{marginBottom:4}}>
|
||||
<span className="muted">下次保养</span>
|
||||
<span className={"mono strong"} style={{color: v.kmToMaint < 1000 ? "var(--warn)" : "var(--fg-0)"}}>
|
||||
剩余 {v.kmToMaint.toLocaleString()} km
|
||||
</span>
|
||||
</div>
|
||||
<div className="bar" style={{height:4}}>
|
||||
<i style={{
|
||||
width: Math.min(100, ((10000 - v.kmToMaint) / 10000) * 100) + "%",
|
||||
background: v.kmToMaint < 1000 ? "var(--warn)" : "var(--accent)",
|
||||
}}/>
|
||||
</div>
|
||||
</div>
|
||||
{v.handoverKm != null && (
|
||||
<div className="between"><span className="muted">交车里程</span><span className="mono">{v.handoverKm.toLocaleString()} km</span></div>
|
||||
)}
|
||||
{v.returnKm != null && (
|
||||
<div className="between"><span className="muted">还车里程</span><span className="mono">{v.returnKm.toLocaleString()} km</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{v.asset === "abnormal" && (
|
||||
<div style={{padding:"14px 16px"}}>
|
||||
<div className="eyebrow" style={{marginBottom:8}}>异常处理</div>
|
||||
<div style={{padding:"8px 10px", background:"var(--danger-soft)", border:"1px solid oklch(0.68 0.220 25 / 0.4)", borderRadius:4, fontSize:11}}>
|
||||
<div className="between">
|
||||
<span className="strong">资产状态异常</span>
|
||||
<span className="mono muted">{v.statusDays}天</span>
|
||||
</div>
|
||||
<div className="muted" style={{marginTop:3}}>停车场标记为异常 · 待业务部门核查</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{padding:"12px 16px", display:"flex", gap:6}}>
|
||||
<button className="btn primary" style={{flex:1}} onClick={() => location.hash = "#/detail"}><Icon name="route" size={13}/> 详情</button>
|
||||
<button className="btn" style={{flex:1}} onClick={() => location.hash = "#/playback"}><Icon name="history" size={13}/> 轨迹</button>
|
||||
<button className="btn icon"><Icon name="bell" size={13}/></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.ArtboardOverview = ArtboardOverview;
|
||||
418
artboards/playback.jsx
Normal file
418
artboards/playback.jsx
Normal file
@@ -0,0 +1,418 @@
|
||||
// artboard-playback.jsx — Trajectory playback with synchronized data
|
||||
|
||||
// ── Calendar dropdown ─────────────────────────────────────
|
||||
const Calendar = ({ selected, onSelect, onClose }) => {
|
||||
// Anchor: April 2026 — month-view picker
|
||||
const [viewMonth, setViewMonth] = React.useState(() => {
|
||||
const [y, m] = selected.split("-").map(Number);
|
||||
return { y, m: m - 1 }; // 0-indexed
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDoc = (e) => { if (!e.target.closest("[data-cal]")) onClose(); };
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [onClose]);
|
||||
|
||||
const monthName = ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"][viewMonth.m];
|
||||
const firstDay = new Date(viewMonth.y, viewMonth.m, 1).getDay(); // 0=Sun
|
||||
const daysInMth = new Date(viewMonth.y, viewMonth.m + 1, 0).getDate();
|
||||
const today = "2026-04-28";
|
||||
|
||||
// Mock days with trips (heat indicator)
|
||||
const tripDays = {
|
||||
"2026-04-21": 5, "2026-04-22": 6, "2026-04-23": 4, "2026-04-24": 7, "2026-04-25": 3,
|
||||
"2026-04-26": 0, "2026-04-27": 5, "2026-04-28": 6, "2026-04-15": 4, "2026-04-16": 6,
|
||||
"2026-04-08": 3, "2026-04-09": 5, "2026-04-10": 4,
|
||||
};
|
||||
|
||||
const cells = [];
|
||||
for (let i = 0; i < firstDay; i++) cells.push(null);
|
||||
for (let d = 1; d <= daysInMth; d++) cells.push(d);
|
||||
while (cells.length % 7) cells.push(null);
|
||||
|
||||
const fmt = (d) => `${viewMonth.y}-${String(viewMonth.m + 1).padStart(2,"0")}-${String(d).padStart(2,"0")}`;
|
||||
|
||||
return (
|
||||
<div data-cal style={{
|
||||
position:"absolute", top:36, left:0, zIndex:100, width:280,
|
||||
background:"var(--bg-popover)", border:"1px solid var(--border-2)", borderRadius:8,
|
||||
boxShadow:"0 12px 32px rgba(0,0,0,.18)", padding:14,
|
||||
}}>
|
||||
{/* Month nav */}
|
||||
<div className="between" style={{marginBottom:10}}>
|
||||
<button className="btn icon sm" onClick={() => setViewMonth(m => ({ y: m.m === 0 ? m.y-1 : m.y, m: m.m === 0 ? 11 : m.m-1 }))}>
|
||||
<Icon name="chevron" size={12} style={{transform:"rotate(180deg)"}}/>
|
||||
</button>
|
||||
<span className="strong" style={{fontSize:13}}>{viewMonth.y} 年 {monthName}</span>
|
||||
<button className="btn icon sm" onClick={() => setViewMonth(m => ({ y: m.m === 11 ? m.y+1 : m.y, m: m.m === 11 ? 0 : m.m+1 }))}>
|
||||
<Icon name="chevron" size={12}/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Weekdays */}
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(7, 1fr)", gap:2, marginBottom:4}}>
|
||||
{["日","一","二","三","四","五","六"].map(w => (
|
||||
<div key={w} className="muted" style={{fontSize:10, textAlign:"center", padding:"4px 0"}}>{w}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Day cells */}
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(7, 1fr)", gap:2}}>
|
||||
{cells.map((d, i) => {
|
||||
if (!d) return <div key={i} style={{height:30}}/>;
|
||||
const ds = fmt(d);
|
||||
const trips = tripDays[ds] || 0;
|
||||
const isSel = ds === selected;
|
||||
const isToday = ds === today;
|
||||
const heat = trips === 0 ? null : trips < 3 ? "var(--accent-soft)" : trips < 5 ? "rgba(0,113,67,.30)" : "var(--accent)";
|
||||
return (
|
||||
<div key={i}
|
||||
onClick={() => onSelect(ds)}
|
||||
style={{
|
||||
height:30, borderRadius:5, cursor:"pointer",
|
||||
display:"flex", flexDirection:"column", alignItems:"center", justifyContent:"center",
|
||||
fontSize:11, position:"relative",
|
||||
background: isSel ? "var(--accent)" : "transparent",
|
||||
color: isSel ? "#fff" : trips === 0 ? "var(--fg-3)" : "var(--fg-1)",
|
||||
fontWeight: isSel || isToday ? 600 : 400,
|
||||
border: isToday && !isSel ? "1px solid var(--accent)" : "1px solid transparent",
|
||||
}}
|
||||
onMouseEnter={(e) => !isSel && (e.currentTarget.style.background = "var(--bg-2)")}
|
||||
onMouseLeave={(e) => !isSel && (e.currentTarget.style.background = "transparent")}>
|
||||
<span className="mono">{d}</span>
|
||||
{trips > 0 && !isSel && (
|
||||
<span style={{position:"absolute", bottom:3, width:4, height:4, borderRadius:2, background:heat}}/>
|
||||
)}
|
||||
{trips > 0 && isSel && (
|
||||
<span style={{position:"absolute", bottom:2, fontSize:8, opacity:.85}}>{trips}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Footer legend */}
|
||||
<div className="between" style={{marginTop:10, paddingTop:10, borderTop:"1px solid var(--border-1)", fontSize:10}}>
|
||||
<span className="muted">行程频次</span>
|
||||
<div className="mid gap-2">
|
||||
<span className="mid gap-1"><span style={{width:6, height:6, borderRadius:3, background:"var(--accent-soft)"}}/><span className="muted">少</span></span>
|
||||
<span className="mid gap-1"><span style={{width:6, height:6, borderRadius:3, background:"rgba(0,113,67,.30)"}}/><span className="muted">中</span></span>
|
||||
<span className="mid gap-1"><span style={{width:6, height:6, borderRadius:3, background:"var(--accent)"}}/><span className="muted">高</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ArtboardPlayback = () => {
|
||||
const [t, setT] = React.useState(42); // 0-100
|
||||
const [speed, setSpeed] = React.useState(2);
|
||||
const [playing, setPlaying] = React.useState(true);
|
||||
|
||||
// Date / time-range selection
|
||||
const [dateStr, setDateStr] = React.useState("2026-04-28");
|
||||
const [timeFrom, setTimeFrom] = React.useState("14:02");
|
||||
const [timeTo, setTimeTo] = React.useState("14:44");
|
||||
const [calOpen, setCalOpen] = React.useState(false);
|
||||
|
||||
// List of trips on the selected date — each is a candidate range
|
||||
const trips = [
|
||||
{id:"T-001", from:"08:14", to:"09:42", km:42.6, evt:1, route:"总站 → 港务局", active:false},
|
||||
{id:"T-002", from:"10:08", to:"11:35", km:38.1, evt:0, route:"港务局 → 维保中心", active:false},
|
||||
{id:"T-003", from:"12:45", to:"13:38", km:21.4, evt:2, route:"维保中心 → 总站", active:false},
|
||||
{id:"T-004", from:"14:02", to:"14:44", km:32.4, evt:3, route:"总站 → 乍浦港 #2", active:true },
|
||||
{id:"T-005", from:"15:10", to:"16:32", km:48.9, evt:1, route:"乍浦港 → 嘉兴南", active:false},
|
||||
{id:"T-006", from:"17:48", to:"19:05", km:55.2, evt:2, route:"嘉兴南 → 总站", active:false},
|
||||
];
|
||||
|
||||
// Compute marker pos along a path
|
||||
const path = "M 200 540 L 280 480 L 360 420 L 440 380 L 520 340 L 620 320 L 720 320 L 820 340 L 900 380 L 980 440";
|
||||
const points = [
|
||||
[200,540],[280,480],[360,420],[440,380],[520,340],[620,320],[720,320],[820,340],[900,380],[980,440]
|
||||
];
|
||||
const idx = Math.min(points.length - 1, Math.floor((t/100) * (points.length - 1)));
|
||||
const next = Math.min(points.length - 1, idx + 1);
|
||||
const frac = (t/100) * (points.length - 1) - idx;
|
||||
const px = points[idx][0] + (points[next][0] - points[idx][0]) * frac;
|
||||
const py = points[idx][1] + (points[next][1] - points[idx][1]) * frac;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!playing) return;
|
||||
const id = setInterval(() => setT(prev => (prev + 0.4 * speed) % 100), 80);
|
||||
return () => clearInterval(id);
|
||||
}, [playing, speed]);
|
||||
|
||||
const events = [
|
||||
{at:8, type:"start", lbl:"出发·总站"},
|
||||
{at:22, type:"warn", lbl:"急加速"},
|
||||
{at:38, type:"stop", lbl:"信号停车"},
|
||||
{at:55, type:"warn", lbl:"超速"},
|
||||
{at:74, type:"stop", lbl:"补能站"},
|
||||
{at:92, type:"end", lbl:"到达·机场"},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar active="route"/>
|
||||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
|
||||
<Topbar crumbs={["轨迹回放", `浙F07179F · ${dateStr.slice(5)} ${timeFrom} → ${timeTo}`]} kpis={[]} showSearch={false}/>
|
||||
|
||||
{/* Date / time-range selector bar */}
|
||||
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", gap:14, alignItems:"center", flexWrap:"wrap"}}>
|
||||
<div className="mid gap-2">
|
||||
<Icon name="history" size={14} style={{color:"var(--accent)"}}/>
|
||||
<span className="muted" style={{fontSize:11}}>日期</span>
|
||||
</div>
|
||||
|
||||
{/* Date pill with calendar dropdown */}
|
||||
<div style={{position:"relative"}}>
|
||||
<button
|
||||
onClick={() => setCalOpen(!calOpen)}
|
||||
className="btn"
|
||||
style={{height:30, padding:"0 12px", display:"flex", alignItems:"center", gap:8, background:"var(--bg-2)", borderColor: calOpen ? "var(--accent)" : "var(--border-1)"}}>
|
||||
<Icon name="bookmark" size={12}/>
|
||||
<span className="mono strong" style={{fontSize:12}}>{dateStr}</span>
|
||||
<span className="muted" style={{fontSize:10, marginLeft:4}}>周二</span>
|
||||
<Icon name="chevDown" size={11} style={{color:"var(--fg-3)"}}/>
|
||||
</button>
|
||||
{calOpen && (
|
||||
<Calendar selected={dateStr} onSelect={(d) => { setDateStr(d); setCalOpen(false); }} onClose={() => setCalOpen(false)}/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick-range presets */}
|
||||
<div className="row gap-1">
|
||||
{[
|
||||
{l:"今日", v:"today"},
|
||||
{l:"昨日", v:"yesterday"},
|
||||
{l:"近7日", v:"7d"},
|
||||
{l:"近30日",v:"30d"},
|
||||
{l:"自定义",v:"custom"},
|
||||
].map((p,i) => (
|
||||
<span key={i} className={"chip " + (p.v === "custom" ? "accent" : "")}
|
||||
style={{cursor:"pointer", fontSize:10}}
|
||||
onClick={() => {
|
||||
const today = new Date("2026-04-28");
|
||||
if (p.v === "today") setDateStr("2026-04-28");
|
||||
else if (p.v === "yesterday") setDateStr("2026-04-27");
|
||||
else if (p.v === "7d") setDateStr("2026-04-22");
|
||||
else if (p.v === "30d") setDateStr("2026-03-29");
|
||||
}}>{p.l}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span style={{width:1, height:20, background:"var(--border-1)"}}/>
|
||||
|
||||
{/* Time range */}
|
||||
<div className="mid gap-2">
|
||||
<span className="muted" style={{fontSize:11}}>时段</span>
|
||||
<div style={{display:"flex", alignItems:"center", gap:0, background:"var(--bg-2)", border:"1px solid var(--border-1)", borderRadius:6, height:30, padding:"0 4px"}}>
|
||||
<input type="text" value={timeFrom} onChange={e => setTimeFrom(e.target.value)}
|
||||
style={{width:54, height:22, background:"transparent", border:"none", color:"var(--fg-0)", fontFamily:"var(--font-mono)", fontSize:12, textAlign:"center", outline:"none"}}/>
|
||||
<span className="muted mono" style={{fontSize:11}}>→</span>
|
||||
<input type="text" value={timeTo} onChange={e => setTimeTo(e.target.value)}
|
||||
style={{width:54, height:22, background:"transparent", border:"none", color:"var(--fg-0)", fontFamily:"var(--font-mono)", fontSize:12, textAlign:"center", outline:"none"}}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip count badge */}
|
||||
<span className="chip" style={{fontSize:10}}>
|
||||
<span className="muted">当日行程</span>
|
||||
<span className="mono strong" style={{marginLeft:6, color:"var(--accent)"}}>{trips.length}</span>
|
||||
</span>
|
||||
|
||||
<div style={{marginLeft:"auto", display:"flex", gap:6}}>
|
||||
<button className="btn"><Icon name="refresh" size={13}/> 刷新</button>
|
||||
<button className="btn primary"><Icon name="search" size={13}/> 查询轨迹</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{padding:"10px 16px", borderBottom:"1px solid var(--border-1)", display:"flex", gap:12, alignItems:"center", background:"var(--bg-1)"}}>
|
||||
<span className="chip accent">浙F07179F ×</span>
|
||||
<span className="chip">+ 添加车辆对比</span>
|
||||
<span style={{width:1, height:20, background:"var(--border-1)"}}/>
|
||||
<span className="muted" style={{fontSize:11}}>显示</span>
|
||||
<span className="chip accent">轨迹</span>
|
||||
<span className="chip accent">事件</span>
|
||||
<span className="chip">热力</span>
|
||||
<span className="chip">停留点</span>
|
||||
<div style={{marginLeft:"auto", display:"flex", gap:6}}>
|
||||
<button className="btn"><Icon name="download" size={13}/> 导出</button>
|
||||
<button className="btn"><Icon name="bookmark" size={13}/> 保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{flex:1, display:"grid", gridTemplateColumns:"1fr 320px", minHeight:0}}>
|
||||
<div style={{display:"flex", flexDirection:"column", minWidth:0}}>
|
||||
{/* Map */}
|
||||
<div style={{flex:1, position:"relative", minHeight:0}}>
|
||||
<FleetMap
|
||||
vehicles={[]}
|
||||
highlightPath={path}
|
||||
playbackPoint={{x: px, y: py}}
|
||||
/>
|
||||
{/* Event markers on map */}
|
||||
<svg viewBox="0 0 1240 800" width="100%" height="100%" preserveAspectRatio="xMidYMid meet"
|
||||
style={{position:"absolute", inset:0, pointerEvents:"none"}}>
|
||||
{events.map((e,i)=>{
|
||||
const ei = Math.min(points.length - 1, Math.floor((e.at/100) * (points.length - 1)));
|
||||
const ef = (e.at/100) * (points.length - 1) - ei;
|
||||
const en = Math.min(points.length - 1, ei + 1);
|
||||
const x = points[ei][0] + (points[en][0]-points[ei][0])*ef;
|
||||
const y = points[ei][1] + (points[en][1]-points[ei][1])*ef;
|
||||
const c = e.type === "warn" ? "var(--warn)" : e.type === "start" ? "var(--ok)" : e.type === "end" ? "var(--accent)" : "var(--fg-2)";
|
||||
return (
|
||||
<g key={i} transform={`translate(${x} ${y})`}>
|
||||
<circle r="6" fill={c} opacity="0.25"/>
|
||||
<circle r="3" fill={c} stroke="var(--bg-0)" strokeWidth="1"/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{/* Live readout */}
|
||||
<div style={{position:"absolute", top:14, left:14, padding:"10px 14px", background:"var(--bg-popover)", border:"1px solid var(--border-2)", borderRadius:8, fontSize:11, fontFamily:"var(--font-mono)", display:"flex", gap:18, boxShadow:"0 4px 16px rgba(0,0,0,.12)"}}>
|
||||
<div><div className="muted" style={{fontSize:10}}>时间</div><div className="strong">14:{String(Math.floor(t*0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")}</div></div>
|
||||
<div><div className="muted" style={{fontSize:10}}>速度</div><div className="strong" style={{color:"var(--info)"}}>{Math.floor(40 + Math.sin(t*0.1)*30)} km/h</div></div>
|
||||
<div><div className="muted" style={{fontSize:10}}>SOC</div><div className="strong" style={{color:"var(--accent)"}}>{Math.floor(78 - t*0.18)}%</div></div>
|
||||
<div><div className="muted" style={{fontSize:10}}>H₂</div><div className="strong">{(4.2 - t*0.012).toFixed(2)} MPa</div></div>
|
||||
<div><div className="muted" style={{fontSize:10}}>累计</div><div className="strong">{(t*0.32).toFixed(1)} km</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player + multi-curve */}
|
||||
<div style={{borderTop:"1px solid var(--border-1)", background:"var(--bg-1)"}}>
|
||||
{/* Synced data curves */}
|
||||
<div style={{padding:"10px 16px 4px", position:"relative"}}>
|
||||
<div style={{display:"flex", gap:14, fontSize:10, marginBottom:4}}>
|
||||
<span className="mid gap-1"><span className="dot" style={{background:"var(--info)"}}/> 速度 km/h</span>
|
||||
<span className="mid gap-1"><span className="dot" style={{background:"var(--accent)"}}/> SOC %</span>
|
||||
<span className="mid gap-1"><span className="dot" style={{background:"var(--warn)"}}/> H₂压力 MPa</span>
|
||||
</div>
|
||||
<div style={{position:"relative", height:80}}>
|
||||
<div style={{position:"absolute", inset:0}}><LineChart data={genSpeed()} w={920} h={80} color="var(--info)" axis/></div>
|
||||
<div style={{position:"absolute", inset:0}}><LineChart data={genSoc()} w={920} h={80} color="var(--accent)" fill={false}/></div>
|
||||
<div style={{position:"absolute", inset:0}}><LineChart data={genH2().map(v=>v*15)} w={920} h={80} color="var(--warn)" fill={false}/></div>
|
||||
{/* playhead */}
|
||||
<div style={{position:"absolute", top:0, bottom:0, left: (t)+"%", width:1, background:"var(--accent)", boxShadow:"0 0 8px var(--accent-glow)"}}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline + controls */}
|
||||
<div style={{padding:"6px 16px 14px"}}>
|
||||
<div style={{position:"relative", height:36, background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
|
||||
{/* event markers */}
|
||||
{events.map((e,i)=>(
|
||||
<div key={i} style={{position:"absolute", left: e.at+"%", top:0, bottom:0, width:2,
|
||||
background: e.type==="warn"?"var(--warn)":e.type==="stop"?"var(--fg-2)":"var(--accent)"}}
|
||||
title={e.lbl}/>
|
||||
))}
|
||||
{/* progress fill */}
|
||||
<div style={{position:"absolute", left:0, top:0, bottom:0, width: t+"%", background:"var(--accent-soft)"}}/>
|
||||
{/* playhead */}
|
||||
<div style={{position:"absolute", left: t+"%", top:-4, bottom:-4, width:2, background:"var(--accent)", boxShadow:"0 0 12px var(--accent-glow)"}}/>
|
||||
<div style={{position:"absolute", left:`calc(${t}% - 5px)`, top:"50%", marginTop:-5, width:10, height:10, borderRadius:5, background:"var(--accent)", border:"2px solid var(--bg-0)"}}/>
|
||||
{/* time labels */}
|
||||
<div style={{position:"absolute", inset:"auto 0 -16px", display:"flex", justifyContent:"space-between", padding:"0 4px", fontSize:9}} className="muted mono">
|
||||
{["14:02","14:08","14:15","14:23","14:30","14:36","14:44"].map(x=><span key={x}>{x}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="between" style={{marginTop:24}}>
|
||||
<div className="mid gap-2">
|
||||
<button className="btn icon"><Icon name="prev" size={13}/></button>
|
||||
<button className="btn icon primary" onClick={()=>setPlaying(!playing)}>
|
||||
<Icon name={playing?"pause":"play"} size={13}/>
|
||||
</button>
|
||||
<button className="btn icon"><Icon name="next" size={13}/></button>
|
||||
<span className="muted mono" style={{marginLeft:8, fontSize:11}}>14:{String(Math.floor(t*0.42 + 2)).padStart(2,"0")}:{String(Math.floor(t*36)%60).padStart(2,"0")} / 14:44:00</span>
|
||||
</div>
|
||||
<div className="mid gap-1">
|
||||
<span className="muted" style={{fontSize:11, marginRight:6}}>倍速</span>
|
||||
{[0.5, 1, 2, 4, 8, 16].map(s=>(
|
||||
<span key={s} className={"chip " + (s === speed ? "accent" : "")}
|
||||
style={{cursor:"pointer"}} onClick={()=>setSpeed(s)}>{s}×</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side: trips + events + summary */}
|
||||
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", display:"flex", flexDirection:"column", minHeight:0}}>
|
||||
{/* Trips of selected day */}
|
||||
<div style={{borderBottom:"1px solid var(--border-1)"}}>
|
||||
<div className="panel-head">
|
||||
<Icon name="route" size={13}/>
|
||||
<span className="title">当日行程</span>
|
||||
<span className="chip" style={{marginLeft:"auto"}}>{trips.length}</span>
|
||||
</div>
|
||||
<div style={{maxHeight:180, overflowY:"auto"}}>
|
||||
{trips.map((tr,i)=>(
|
||||
<div key={i} style={{
|
||||
display:"flex", gap:10, padding:"8px 14px", cursor:"pointer",
|
||||
borderLeft: tr.active ? "2px solid var(--accent)" : "2px solid transparent",
|
||||
background: tr.active ? "var(--accent-soft)" : "transparent",
|
||||
borderBottom:"1px solid var(--border-1)",
|
||||
}}>
|
||||
<div style={{flex:1, minWidth:0}}>
|
||||
<div className="between">
|
||||
<span className="mono strong" style={{fontSize:11}}>{tr.from} → {tr.to}</span>
|
||||
<span className="mono" style={{fontSize:10, color: tr.active ? "var(--accent)" : "var(--fg-2)"}}>{tr.km} km</span>
|
||||
</div>
|
||||
<div className="between" style={{marginTop:3}}>
|
||||
<span className="muted" style={{fontSize:10, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap"}}>{tr.route}</span>
|
||||
{tr.evt > 0 && <span className="chip" style={{fontSize:9, padding:"1px 5px", color:"var(--warn)", background:"var(--warn-soft)", borderColor:"transparent"}}>{tr.evt}事件</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div className="panel-head" style={{borderBottom:"1px solid var(--border-1)"}}>
|
||||
<Icon name="timeline" size={13}/>
|
||||
<span className="title">事件时间线</span>
|
||||
<span className="chip" style={{marginLeft:"auto"}}>{events.length}</span>
|
||||
</div>
|
||||
<div className="scroll" style={{flex:1, padding:"10px 0"}}>
|
||||
{events.map((e,i)=>{
|
||||
const c = e.type === "warn" ? "var(--warn)" : e.type === "start" ? "var(--ok)" : e.type === "end" ? "var(--accent)" : "var(--fg-2)";
|
||||
return (
|
||||
<div key={i} style={{display:"flex", gap:10, padding:"10px 16px", cursor:"pointer", borderLeft: Math.abs(t - e.at) < 3 ? "2px solid var(--accent)" : "2px solid transparent", background: Math.abs(t - e.at) < 3 ? "var(--accent-soft)" : "transparent"}} onClick={()=>setT(e.at)}>
|
||||
<div style={{position:"relative", width:14, paddingTop:3}}>
|
||||
<span className="dot" style={{background:c, width:8, height:8, borderRadius:4}}/>
|
||||
{i < events.length - 1 && <span style={{position:"absolute", top:14, bottom:-22, left:3, width:2, background:"var(--border-1)"}}/>}
|
||||
</div>
|
||||
<div style={{flex:1}}>
|
||||
<div className="between">
|
||||
<span className="strong" style={{fontSize:12}}>{e.lbl}</span>
|
||||
<span className="mono muted" style={{fontSize:10}}>14:{String(Math.floor(e.at*0.42 + 2)).padStart(2,"0")}</span>
|
||||
</div>
|
||||
<div className="muted" style={{fontSize:11, marginTop:2}}>
|
||||
{e.type==="warn" ? "速度 +18 km/h · 持续 2.4s" :
|
||||
e.type==="stop" ? "停留 1分12秒" :
|
||||
e.type==="start" ? "里程 0 km" : "里程 32.4 km · 平均 49 km/h"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{padding:14, borderTop:"1px solid var(--border-1)"}}>
|
||||
<div className="eyebrow" style={{marginBottom:8}}>本次行程</div>
|
||||
<div className="col gap-2" style={{fontSize:11}}>
|
||||
<div className="between"><span className="muted">里程</span><span className="mono strong">32.4 km</span></div>
|
||||
<div className="between"><span className="muted">时长</span><span className="mono strong">42 分钟</span></div>
|
||||
<div className="between"><span className="muted">平均速度</span><span className="mono strong">46 km/h</span></div>
|
||||
<div className="between"><span className="muted">最高速度</span><span className="mono strong" style={{color:"var(--warn)"}}>89 km/h</span></div>
|
||||
<div className="between"><span className="muted">能耗</span><span className="mono strong">5.8 kWh / 0.32 kg H₂</span></div>
|
||||
<div className="between"><span className="muted">评分</span><span className="mono strong" style={{color:"var(--accent)"}}>87 / 100</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
window.ArtboardPlayback = ArtboardPlayback;
|
||||
132
artboards/variant-dense.jsx
Normal file
132
artboards/variant-dense.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
// artboard-dense.jsx — dense info variation: 4-column with mini-map
|
||||
const ArtboardDense = () => {
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar active="map"/>
|
||||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0, position:"relative", zIndex:1}}>
|
||||
<Topbar
|
||||
crumbs={["羚牛车辆数据中心", "运营驾驶舱"]}
|
||||
kpis={[
|
||||
{ lbl:"在线", val:"487", delta:"95%", deltaUp:true },
|
||||
{ lbl:"行驶", val:"312" },
|
||||
{ lbl:"告警P0", val:"2" },
|
||||
{ lbl:"今日里程", val:"24,781 km" },
|
||||
{ lbl:"H₂消耗", val:"482 kg" },
|
||||
{ lbl:"评分", val:"86.4" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div style={{flex:1, display:"grid", gridTemplateColumns:"1fr 1fr 1fr 1fr", gridTemplateRows:"auto auto 1fr", gap:8, padding:8, minHeight:0}}>
|
||||
{/* KPI cards row */}
|
||||
{[
|
||||
{l:"车辆健康度", v:"94.2", u:"%", c:"var(--accent)", d:[88,90,89,92,91,94,94]},
|
||||
{l:"平均能耗", v:"18.4", u:"kWh/100km", c:"var(--info)", d:[19,18,18.5,18.2,18.4,18.4,18.4]},
|
||||
{l:"H₂利用率", v:"83.1", u:"%", c:"var(--ok)", d:[80,81,82,82,83,83,83.1]},
|
||||
{l:"安全评分", v:"86.4", u:"/100", c:"var(--warn)", d:[85,86,84,87,86,86,86.4]},
|
||||
].map((k,i)=>(
|
||||
<div key={i} className="panel" style={{padding:12}}>
|
||||
<div className="between">
|
||||
<span className="eyebrow">{k.l}</span>
|
||||
<span className="chip ok" style={{fontSize:9}}>▲</span>
|
||||
</div>
|
||||
<div style={{marginTop:6}}>
|
||||
<span className="mono strong" style={{fontSize:24, fontWeight:600}}>{k.v}</span>
|
||||
<span className="muted mono" style={{fontSize:10, marginLeft:3}}>{k.u}</span>
|
||||
</div>
|
||||
<div style={{marginTop:4}}>
|
||||
<LineChart data={k.d} w={220} h={32} color={k.c}/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Map spans 2 cols 2 rows */}
|
||||
<div className="panel" style={{gridColumn:"1 / span 2", gridRow:"2 / span 2", overflow:"hidden", position:"relative"}}>
|
||||
<div className="panel-head" style={{position:"absolute", top:0, left:0, right:0, zIndex:2, background:"oklch(0.18 0.020 245 / 0.85)", backdropFilter:"blur(8px)"}}>
|
||||
<Icon name="map" size={13}/><span className="title">实时分布</span>
|
||||
<div className="actions">
|
||||
<span className="chip">热力</span><span className="chip accent">车辆</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{height:"100%"}}>
|
||||
<FleetMap selectedId="浙F07179F" onSelect={()=>{}} showHeatmap/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status donut */}
|
||||
<div className="panel">
|
||||
<div className="panel-head"><span className="title">车辆状态</span></div>
|
||||
<div style={{padding:10, display:"flex", gap:14, alignItems:"center"}}>
|
||||
<Donut size={90} value={0.61} color="var(--ok)" thick={10} label="312"/>
|
||||
<div className="col gap-2" style={{flex:1, fontSize:11}}>
|
||||
<div className="between"><span className="mid gap-1"><span className="dot ok"/> 行驶</span><span className="mono strong">312</span></div>
|
||||
<div className="between"><span className="mid gap-1"><span className="dot warn"/> 待命</span><span className="mono strong">155</span></div>
|
||||
<div className="between"><span className="mid gap-1"><span className="dot danger"/> 故障</span><span className="mono strong">8</span></div>
|
||||
<div className="between"><span className="mid gap-1"><span className="dot idle"/> 离线</span><span className="mono strong">25</span></div>
|
||||
<div className="between"><span className="muted">维保</span><span className="mono strong">12</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts feed */}
|
||||
<div className="panel">
|
||||
<div className="panel-head"><Icon name="bell" size={13}/><span className="title">实时告警</span><span className="chip danger" style={{marginLeft:"auto"}}>3 NEW</span></div>
|
||||
<div style={{padding:8, display:"flex", flexDirection:"column", gap:6, overflow:"auto", maxHeight:240}}>
|
||||
{[
|
||||
{p:"P0", n:"SOC严重不足", v:"浙F08638F", t:"刚刚"},
|
||||
{p:"P0", n:"右后胎压低", v:"浙F08638F", t:"3m"},
|
||||
{p:"P1", n:"超速预警", v:"浙F02002F", t:"12m"},
|
||||
{p:"P1", n:"H₂下降", v:"浙F07179F", t:"32m"},
|
||||
{p:"P2", n:"急加速", v:"浙F02608F", t:"1h"},
|
||||
].map((a,i)=>(
|
||||
<div key={i} className="between" style={{padding:"6px 8px", background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)", fontSize:11}}>
|
||||
<div className="mid gap-2">
|
||||
<span className={"chip " + (a.p==="P0"?"danger":a.p==="P1"?"warn":"")}>{a.p}</span>
|
||||
<span className="strong">{a.n}</span>
|
||||
<span className="mono muted">· {a.v}</span>
|
||||
</div>
|
||||
<span className="mono muted" style={{fontSize:10}}>{a.t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Energy chart */}
|
||||
<div className="panel">
|
||||
<div className="panel-head"><Icon name="bolt" size={13}/><span className="title">能耗 · 24h</span></div>
|
||||
<div style={{padding:12}}>
|
||||
<Bars data={[12,8,6,5,4,4,9,14,22,28,26,24,22,28,30,28,24,20,18,16,14,12,10,8]} w={240} h={80} color="var(--info)"/>
|
||||
<div className="between" style={{marginTop:8, fontSize:11}}>
|
||||
<span className="muted">总计</span>
|
||||
<span className="mono strong">4,562 kWh</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* H2 stations */}
|
||||
<div className="panel">
|
||||
<div className="panel-head"><Icon name="h2" size={13}/><span className="title">补能站</span></div>
|
||||
<div style={{padding:8, fontSize:11}}>
|
||||
{[
|
||||
{n:"#04 朝阳", v:0.78, k:"4号站 · 78%"},
|
||||
{n:"#02 海淀", v:0.42, k:"2号站 · 42%"},
|
||||
{n:"#07 丰台", v:0.91, k:"7号站 · 91%"},
|
||||
{n:"#11 通州", v:0.25, k:"11号站 · 25%"},
|
||||
].map((s,i)=>(
|
||||
<div key={i} style={{marginBottom:8}}>
|
||||
<div className="between" style={{marginBottom:3}}>
|
||||
<span className="mono">{s.k}</span>
|
||||
<span className="mono strong">{Math.round(s.v*100)}%</span>
|
||||
</div>
|
||||
<div className="bar" style={{height:5}}>
|
||||
<i style={{width: s.v*100+"%", background: s.v < 0.3 ? "var(--danger)" : s.v < 0.5 ? "var(--warn)" : "var(--accent)"}}/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
window.ArtboardDense = ArtboardDense;
|
||||
55
artboards/variant-light.jsx
Normal file
55
artboards/variant-light.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
// artboard-variant-light.jsx — light theme variation of overview
|
||||
const ArtboardLightVariant = () => {
|
||||
return (
|
||||
<div className="app" style={{
|
||||
"--bg-0": "oklch(0.985 0.003 250)",
|
||||
"--bg-1": "oklch(1 0 0)",
|
||||
"--bg-2": "oklch(0.97 0.005 250)",
|
||||
"--bg-3": "oklch(0.93 0.008 250)",
|
||||
"--fg-0": "oklch(0.20 0.018 250)",
|
||||
"--fg-1": "oklch(0.32 0.015 250)",
|
||||
"--fg-2": "oklch(0.50 0.015 250)",
|
||||
"--fg-3": "oklch(0.65 0.015 250)",
|
||||
"--border-1": "oklch(0.88 0.008 250 / 0.9)",
|
||||
"--border-2": "oklch(0.78 0.010 250 / 0.9)",
|
||||
"--accent": "oklch(0.62 0.150 175)",
|
||||
"--accent-soft": "oklch(0.62 0.150 175 / 0.10)"
|
||||
}}>
|
||||
<Sidebar active="map"/>
|
||||
<div style={{flex:1, display:"flex", flexDirection:"column", minWidth:0}}>
|
||||
<Topbar
|
||||
crumbs={["羚牛车辆数据中心", "实时监控", "总览"]}
|
||||
kpis={[
|
||||
{ lbl:"在线", val:"487/512", delta:"95.1%", deltaUp:true },
|
||||
{ lbl:"行驶", val:"312" },
|
||||
{ lbl:"告警", val:"8", delta:"+2", deltaUp:false },
|
||||
]}
|
||||
/>
|
||||
<div style={{flex:1, display:"grid", gridTemplateColumns:"1fr 320px", minHeight:0}}>
|
||||
<div style={{position:"relative", background:"#eef0f2"}}>
|
||||
<FleetMap selectedId="浙F08638F" onSelect={()=>{}} variant="minimal"/>
|
||||
</div>
|
||||
<div style={{borderLeft:"1px solid var(--border-1)", background:"var(--bg-1)", padding:"14px 16px"}}>
|
||||
<div className="eyebrow" style={{marginBottom:6}}>当前选中</div>
|
||||
<div className="mono strong" style={{fontSize:18, fontWeight:600}}>浙F08638F</div>
|
||||
<div className="muted" style={{fontSize:11, marginTop:4}}>孙超 · 状态告警</div>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:8, marginTop:12, fontSize:11}}>
|
||||
{[
|
||||
{l:"速度", v:"0", u:"km/h"},
|
||||
{l:"SOC", v:"9", u:"%"},
|
||||
{l:"H₂", v:"0.8", u:"MPa"},
|
||||
{l:"温度", v:"102", u:"°C"},
|
||||
].map((k,i)=>(
|
||||
<div key={i} style={{padding:8, background:"var(--bg-2)", borderRadius:4, border:"1px solid var(--border-1)"}}>
|
||||
<div className="muted" style={{fontSize:10}}>{k.l}</div>
|
||||
<div><span className="mono strong" style={{fontSize:18}}>{k.v}</span><span className="muted mono" style={{fontSize:10, marginLeft:3}}>{k.u}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
window.ArtboardLightVariant = ArtboardLightVariant;
|
||||
Reference in New Issue
Block a user