feat: add assessment workbench and September reconciliation

Preserve August hydrogen revision and September audit evidence; calculate interrupted vehicle history across all prior months. Record remaining carry-policy and layout-baseline discrepancies.
This commit is contained in:
shishengliang
2026-10-09 18:21:45 +08:00
parent 3ed3548b9d
commit 65904c8270
108 changed files with 1368801 additions and 3 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.venv/
.data/
+32
View File
@@ -0,0 +1,32 @@
# 里程绩效工作台原型
独立 React + Vite 原型,不改动历史 Excel、原始数据及核算脚本。默认使用六辆虚构车辆,所有演示结果均不可用于发放。
## 启动
在项目根目录运行:
```sh
npm --prefix prototype install
npm --prefix prototype run dev
```
浏览器打开 http://127.0.0.1:5173 。如需真实接口,另开终端:
```sh
python3 -m venv prototype/.venv
prototype/.venv/bin/pip install -r assessment_service/requirements.txt
prototype/.venv/bin/python -m assessment_service --port 8765 --data-dir prototype/.data
```
页面切换「本机 API」。Vite 将 `/api` 转发到本机 8765。数据接入上传 Excel 后,配置规则 JSON,回到月度核算开始试算。可下载规则示例,也可用 `prototype/.venv/bin/python prototype/make_sample.py` 创建配套虚构 Excel 以验证真实接口链路。示例规则月份为 2026-08,车辆 DEMO-001。
## 当前实现与边界
- 演示模式:搜索、状态筛选、异常列表、逐日依据抽屉、月份切换、演示试算、JSON 导出。
- 本机 API:健康检查、Excel 原始二进制上传、导入结果及错误展示、规则 JSON 编辑、真实试算、快照详情与 Excel 导出。切换模式或修改输入会使已试算结果失效。
- 真实 API 复用现存 `assessment_service`,只生成租赁考核源表,**尚不计算奖金、结转、历史补发,不具备审批或发放功能**。
- 现存引擎采用「区间存在纯氢则整段纯氢、测试日清零」等旧规则,与 `docs/8月绩效核算_20260911.md` 最新规则不一致。页面明确提示该差异,不能用此原型替代正式 8 月核算。
- 本机服务无鉴权,仅绑定 loopback。接入生产系统前应完成最新规则迁移、历史台账与分钱精度验证、权限及审批、批次锁定、任务队列和审计记录。
详见 [API 对接与系统化方案](../docs/里程绩效原型_API对接.md)。
+33
View File
@@ -0,0 +1,33 @@
"""对运行中的开发代理和本机 API 执行虚构样本集成检查。"""
import json
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import HTTPError
from io import BytesIO
from openpyxl import load_workbook
BASE='http://127.0.0.1:5173/api'
def call(path, data=None):
headers={'Content-Type':'application/octet-stream'}
if isinstance(data,dict):
data=json.dumps(data).encode(); headers={'Content-Type':'application/json'}
with urlopen(Request(BASE+path,data=data,headers=headers)) as r: return json.loads(r.read())
samples=Path(__file__).parent/'.data'/'samples'
lease=call('/imports/leases',(samples/'demo-leases.xlsx').read_bytes())
mileage=call('/imports/mileage?source=tbox&provider=vehicle-platform',(samples/'demo-mileage.xlsx').read_bytes())
config=json.loads((Path(__file__).parents[1]/'assessment_service/example_config.json').read_text())
config['month']='2026-08'; config['rules'][0].update(valid_from='2026-08-01',valid_to='2026-08-31')
body={'lease_import_id':lease['id'],'mileage_import_ids':[mileage['id']],'config':config}
a=call('/assessments',body); full=call('/assessments/'+a['id'])
assert a['status']=='ready',a
assert full['records'][0]['实际行驶里程(km)']==3720
assert len(full['daily_trace'])==31
assert call('/assessments',body)['id']==a['id']
with urlopen(BASE+'/assessments/'+a['id']+'/export.xlsx') as r:
wb=load_workbook(BytesIO(r.read()),read_only=True); assert wb.active.max_row==2; wb.close()
# 无里程产生异常,禁止导出。
body['mileage_import_ids']=[]
b=call('/assessments',body)
assert b['status']=='needs_review'
try: urlopen(BASE+'/assessments/'+b['id']+'/export.xlsx'); raise AssertionError('应拒绝导出')
except HTTPError as e: assert e.code==409
print('PASS: 上传、代理、3720 km 试算、31日依据、幂等快照、Excel导出、缺失拦截与409。')
+1
View File
@@ -0,0 +1 @@
{"$schema":"https://ui.shadcn.com/schema.json","style":"new-york","rsc":false,"tsx":true,"tailwind":{"config":"","css":"src/oneos/shared/theme/tokens.css","baseColor":"neutral","cssVariables":true},"aliases":{"components":"@/shared","utils":"@/shared/utils","ui":"@/shared/ui","lib":"@/shared","hooks":"@/shared/hooks"},"iconLibrary":"lucide"}
+29
View File
@@ -0,0 +1,29 @@
# 原型验证记录
设计参考:`concept.png`。浏览器实测:Chrome Browser 工具,桌面 1536×1024、手机 390×844。`desktop.png`、`mobile.png` 为浏览器截图,未采用外部 Playwright。已通过 view_image 检查概念图与桌面实现图。
## 设计对照
| 检查点 | 参考与实现 | 处理 |
|---|---|---|
| 导航与布局 | 左侧导航、顶部面包屑、主区四步流程 | 保留,手机切换顶部导航 |
| 配色 | 白色面板、冷灰背景、深蓝文字、青绿主色 | 保留 |
| 字体 | 中文无衬线、层次分明,统一表单字体 | 统一 CSS;桌面标题 34px、表格 13px |
| 表格结构 | 六行车辆、完成率条、三态结果、详情入口 | 保留,修正生成图中 7980/8000 误标达标的示例 |
| 留白与密度 | 四项指标、宽表格 | 首轮桌面行距过大,已压缩为 12px 垂直 padding |
| 状态与操作 | 主按钮开始试算、搜索与状态过滤 | 实现可交互状态、异常列表与追溯抽屉 |
| 响应式 | 桌面完整表格 | 手机指标两列、宽表局部横向滚动,无页面横向溢出 |
首屏文案对照:标题、副标题、四步流程、导航、主操作和指标名称保留。业务修订:演示目标改为项目已有的 3000/5000/6000 档位;缺失值显示“—”,不生成伪完成率;底部将“数据来源优先级”改为“数据来源”,避免误导为适用所有日期的简单优先级。新增模式切换、异常计数、快照/导出工具条为实际接口联调所需。Logo 使用清晰的活动线图标,未复制生成图中虚构品牌图形;去除没有登录支撑的“管理员”入口。实现遵循参考的主要布局、颜色和组件体系,这些有意差异不属于逐像素复刻。
## 功能检查
- npm run build 通过。
- 浏览器:搜索定位 DEMO-006,查看逐日依据;清空搜索并按达标筛选得到 3 条;演示试算成功;JSON 基本结构检查成功;API 健康检测成功。浏览器无 error 日志。
- API 集成:`prototype/.venv/bin/python prototype/check_api.py` 通过上传、开发代理、3720 km 试算、31 条日依据、重复请求同快照 ID、Excel 内容验证、缺里程异常与 409 拦截。
- 浏览器扩展自动上传被文件权限限制,未完成浏览器端文件选择到上传的端到端验证;文件上传 HTTP 路径已通过同一个 Vite 代理验证。用户手动选择文件不依赖自动化扩展的文件访问能力。
- 手机 390×844 已检查:26px 标题,页面宽度未溢出。
## 范围限制
奖金、结转、补发、最新业务算法迁移、审批、生产数据源拉取尚未实现。API 文档明确区分已实现接口与建议接口。快照选用状态仅在页面内存中保存,刷新后需重新选择导入文件;已有服务快照不删除。
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>羚牛 · 里程绩效工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>羚牛 · 里程绩效工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>
+14
View File
@@ -0,0 +1,14 @@
"""生成纯虚构、可经过真实本机 API 试算的样本。"""
from pathlib import Path
from openpyxl import Workbook
out = Path(__file__).parent / '.data' / 'samples'
out.mkdir(parents=True, exist_ok=True)
w = Workbook(); s = w.active
s.append(['vehicle_id','plate_number','contract_id','contract_code','project_name','customer_name','business_department_name','business_manager_name','delivery_vehicle_id','delivery_time','return_vehicle_task_id','return_time'])
s.append(['demo-v1','DEMO-001','demo-c1','DEMO-CONTRACT-1','演示项目','演示客户','演示部门','示例甲','demo-l1','2026-08-01',None,None])
w.save(out / 'demo-leases.xlsx')
w = Workbook(); s = w.active
s.append(['plate_number','date','mileage_km'])
for d in range(1,32): s.append(['DEMO-001',f'2026-08-{d:02d}',120])
w.save(out / 'demo-mileage.xlsx')
print(out)
+5777
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"name":"mileage-assessment-prototype","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite --host 127.0.0.1 --port 5173","build":"vite build"},"dependencies":{"@refinedev/core":"^5.0.12","@refinedev/react-router":"^2.0.4","@tanstack/react-table":"^8.21.3","class-variance-authority":"^0.7.1","clsx":"^2.1.1","cn":"^0.4.0","echarts":"^6.1.0","lucide-react":"^0.468.0","radix-ui":"^1.7.0","react":"^19.1.0","react-dom":"^19.1.0","react-router":"^7.18.4","tailwind-merge":"^3.7.0","zod":"^4.6.5"},"devDependencies":{"@playwright/test":"^1.64.0","@tailwindcss/vite":"^4.3.3","@testing-library/jest-dom":"^7.0.1","@testing-library/react":"^16.3.3","@testing-library/user-event":"^14.6.7","@types/node":"^26.6.4","@types/react":"^19.3.0","@types/react-dom":"^19.3.0","jsdom":"^30.1.2","tailwindcss":"^4.3.3","typescript":"^7.0.2","vite":"^6.1.0","vitest":"^5.0.3"}}
+16
View File
@@ -0,0 +1,16 @@
export async function request(path, options = {}) {
const response = await fetch(`/api${path}`, options);
const text = await response.text();
let data;
try { data = JSON.parse(text); } catch { throw new Error('API 未响应 JSON,请确认本机核算服务已启动。'); }
if (!response.ok) throw new Error(data.error || `接口失败:${response.status}`);
return data;
}
export const configTemplate = {month:'2026-08',test_plates:[],test_threshold_km:50,source_priority:{hydrogen:['guangzhou'],tbox:['vehicle-platform'],gps:['g7','dongfang','xinda','guangan']},rules:[{plate:'DEMO-001',target_id:1,target_name:'交投40辆4.5T普货',monthly_target_km:3000,valid_from:'2026-08-01',valid_to:'2026-08-31'}]};
export function demo(month) {
const days = new Date(+month.slice(0,4),+month.slice(5),0).getDate();
const targets=[3000,3000,5000,5000,6000,3000], actual=[3620,2320,5850,5180,5210,null];
const records=targets.map((t,i)=>({'车牌号':`DEMO-00${i+1}`,'销售经理':`示例${'甲乙丙丁戊己'[i]}`,'客户名称':'演示客户','合同编号':`DEMO-CONTRACT-${i+1}`,'部门名称':'示例业务部','考核目标':'演示目标','考核天数':days,'考核开始日期':`${month}-01`,'考核结束日期':`${month}-${days}`,'应考核里程(km)':t,'实际行驶里程(km)':actual[i],'完成率(%)':actual[i]===null?null:actual[i]/t*100,'是否达标':actual[i]===null?'待核对':actual[i]>=t?'达标':'未达标'}));
return {month,status:'needs_review',records,issues:[{plate:'DEMO-006',code:'missing_mileage_day',message:`${month}-${days} 缺少仪表里程,空值不能按 0 处理`,source_row:days+1}],daily_trace:records.flatMap((r,i)=>Array.from({length:days},(_,d)=>({plate:r['车牌号'],lease_id:`DEMO-${i}`,date:`${month}-${String(d+1).padStart(2,'0')}`,source:'tbox',test_day:false,included_km:i===5&&d===days-1?null:Math.round((actual[i]??2700)*100/days)/100,evidence:{tbox:{provider:'演示车机',source_row:d+2,km:i===5&&d===days-1?null:Math.round((actual[i]??2700)*100/days)/100}}}))),summary:{vehicles:6,records:6,issues:1}};
}
export function download(name, value) { const url=URL.createObjectURL(new Blob([JSON.stringify(value,null,2)],{type:'application/json'})); const a=document.createElement('a'); a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000); }
+28
View File
@@ -0,0 +1,28 @@
import React, {useState,useEffect,useRef} from 'react';
import {createRoot} from 'react-dom/client';
import {Activity,CalendarDays,Database,FileText,Link,FlaskConical,ChevronRight,Car,ChartNoAxesColumnIncreasing,Clock,Coins,Search,Info,AlertCircle,Download,Upload,X,Check,ArrowRight,RefreshCw} from 'lucide-react';
import {request,configTemplate,demo,download} from './api';
import './style.css';
const nav=[['月度核算',CalendarDays],['数据接入',Database],['规则中心',FileText],['API 对接',Link]];
const fmt=n=>n==null?'—':Number(n).toLocaleString('zh-CN',{maximumFractionDigits:2});
function App(){
const [page,setPage]=useState('月度核算'),[mode,setMode]=useState('demo'),[month,setMonth]=useState('2026-08'),[result,setResult]=useState(()=>demo('2026-08')),[tab,setTab]=useState('考核明细'),[search,setSearch]=useState(''),[status,setStatus]=useState('全部状态'),[selected,setSelected]=useState(null),[busy,setBusy]=useState(false),[notice,setNotice]=useState(''),[imports,setImports]=useState([]),[config,setConfig]=useState(JSON.stringify(configTemplate,null,2)),[health,setHealth]=useState(null),[source,setSource]=useState('tbox'),[provider,setProvider]=useState('vehicle-platform');
const epoch=useRef(0),dialog=useRef(null),detailTrigger=useRef(null);
useEffect(()=>{if(selected){detailTrigger.current=document.activeElement;dialog.current?.focus();}const fn=e=>{if(e.key==='Escape'){setSelected(null);detailTrigger.current?.focus();}if(e.key==='Tab'&&selected){const els=dialog.current?.querySelectorAll('button,[href],input,select,textarea,[tabindex="0"]');if(els?.length){const first=els[0],last=els[els.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}}}};document.addEventListener('keydown',fn);return()=>document.removeEventListener('keydown',fn)},[selected]);
function invalidate(){epoch.current++;setResult(null);setSelected(null);}
function changeMode(m){invalidate();setMode(m);setNotice('');setImports([]);setResult(m==='demo'?demo(month):null)}
async function run(fn){setBusy(true);setNotice('');try{await fn()}catch(e){setNotice(e.message)}finally{setBusy(false)}}
async function calculate(){await run(async()=>{const e=epoch.current;if(mode==='demo'){setResult(demo(month));setNotice('演示试算已更新;未执行奖金发放。');return;}const cfg=JSON.parse(config);if(cfg.month!==month)throw new Error('规则中的月份与当前核算月份不一致,请在规则中心修改。');const lease=imports.find(i=>i.kind==='leases');const miles=imports.filter(i=>i.kind==='mileage');if(!lease||!miles.length)throw new Error('请先导入一份租赁表和至少一份里程表。');const r=await request('/assessments',{method:'POST',body:JSON.stringify({lease_import_id:lease.id,mileage_import_ids:miles.map(i=>i.id),config:cfg}),headers:{'Content-Type':'application/json'}});const full=await request(`/assessments/${r.id}`);if(e===epoch.current)setResult(full);})}
async function upload(file,kind){if(!file)return;await run(async()=>{if(mode!=='api')throw new Error('请先切换到「本机 API」模式再导入文件。');if(file.size>20*1024*1024)throw new Error('文件不能超过 20 MB');const data=await request(kind==='leases'?'/imports/leases':`/imports/mileage?source=${source}&provider=${encodeURIComponent(provider)}`,{method:'POST',body:file,headers:{'Content-Type':'application/octet-stream'}});invalidate();setImports(old=>[...old.filter(i=>kind==='leases'?i.kind!=='leases':i.id!==data.id),{...data,kind,name:file.name,source,provider}]);setNotice(`已导入 ${data.accepted_rows} 行,${data.issues.length} 项问题。`);})}
const records=result?.records||[],issues=result?.issues||[],rows=records.filter(r=>Object.values(r).some(v=>String(v??'').includes(search))&&(status==='全部状态'||r['是否达标']===status)),traces=(result?.daily_trace||[]).filter(d=>selected&&d.plate===selected['车牌号']&&d.date>=selected['考核开始日期']&&d.date<=selected['考核结束日期']);
function close(){setSelected(null);detailTrigger.current?.focus()}
return <div className="app"><aside><div className="brand"><span className="brandmark"><Activity size={29}/></span><div><b>羚牛</b><small>里程绩效</small></div></div><nav>{nav.map(([name,Icon])=><button key={name} className={page===name?'active':''} onClick={()=>setPage(name)}><Icon size={20}/>{name}</button>)}</nav><div className="sidefoot"><span className="dot"/>核算工作台<small>原型预览 · v0.1</small></div></aside><div className="shell"><header><span>绩效管理 <i>/</i> <strong>{page}</strong></span><label className="mode"><FlaskConical size={15}/><select aria-label="数据模式" disabled={busy} value={mode} onChange={e=>changeMode(e.target.value)}><option value="demo">演示模式</option><option value="api">本机 API</option></select></label></header><main><div className="title"><div><h1>{page}</h1><p>{page==='月度核算'?'按日核对里程,让每一笔奖励有据可查。':page==='数据接入'?'固定原始数据快照,保留每一次导入的依据。':page==='规则中心'?'明确考核范围、政策有效期与里程来源。':'从本机试算,逐步接入业务系统。'}</p></div><div className="actions"><label className="month"><CalendarDays size={17}/><input type="month" aria-label="考核月份" value={month} disabled={busy} onChange={e=>{if(!e.target.value)return;invalidate();setMonth(e.target.value);if(mode==='demo')setResult(demo(e.target.value));}}/></label><button className="primary" disabled={busy} onClick={calculate}>{busy?'处理中…':'开始试算'}<ChevronRight size={17}/></button></div></div>
<div className="steps">{['数据接入','规则确认','试算核对','结果导出'].map((s,i)=><button key={s} onClick={()=>{setPage(i===0?'数据接入':i===1?'规则中心':'月度核算');if(i===3)setNotice('考核明细下方可导出结果;真实模式存在异常时禁止导出。')}}><span className={i===0?'current':''}>{i+1}</span><div><b>{s}</b><small>{['接入多源数据,完成清洗','确认考核规则与参数','按日核对里程与结果','生成报表,支持导出'][i]}</small></div>{i<3&&<em/>}</button>)}</div>
<div className="banner"><AlertCircle size={18}/>{mode==='demo'?'演示数据 · 不作为正式发放依据。':'本机 API 使用旧版考核引擎,与最新 8 月口径不同;结果仅用于接口联调,不用于奖金发放。'}</div>{notice&&<div role="status" className="notice"><Info size={17}/>{notice}<button aria-label="关闭提示" onClick={()=>setNotice('')}><X size={16}/></button></div>}
{page==='月度核算'&&<><div className="metrics">{[[result?.summary?.vehicles??'—','考核车辆',Car],[records.filter(r=>r['是否达标']==='达标').length,'达标记录',ChartNoAxesColumnIncreasing],[result?issues.length:'—','待核对',Clock],[mode==='demo'?'仅演示':'未接入','奖金试算',Coins]].map(([value,label,Icon])=><div key={label}><div><strong>{value}</strong><small>{label}</small></div><Icon size={28}/></div>)}</div><section className="tablepanel"><div className="toolbar"><div className="tabs">{['考核明细','异常核对'].map(t=><button key={t} className={tab===t?'selected':''} onClick={()=>setTab(t)}>{t}{t==='异常核对'&&issues.length>0&&<sup>{issues.length}</sup>}</button>)}</div><div className="filters"><label className="search"><Search size={17}/><input placeholder="车牌、负责人、客户" value={search} onChange={e=>setSearch(e.target.value)}/></label><select aria-label="状态筛选" value={status} onChange={e=>setStatus(e.target.value)}>{['全部状态','达标','未达标','待核对'].map(s=><option key={s}>{s}</option>)}</select></div></div>{tab==='考核明细'?<div className="tablewrap"><table><thead><tr>{['车辆 / 负责人','考核目标(公里)','考核天数','实际里程(公里)','完成率','状态','详情'].map(t=><th key={t}>{t}</th>)}</tr></thead><tbody>{rows.map((r,i)=><tr key={i}><td><b>{r['车牌号']}</b><small>{r['销售经理']}</small></td><td>{fmt(r['应考核里程(km)'])}</td><td>{r['考核天数']}</td><td>{fmt(r['实际行驶里程(km)'])}</td><td><div className="completion"><span>{r['完成率(%)']==null?'—':fmt(r['完成率(%)'])+'%'}</span><div><span style={{width:`${Math.min(100,r['完成率(%)']||0)}%`}}/></div></div></td><td><span className={`status ${r['是否达标']==='达标'?'good':r['是否达标']==='待核对'?'warn':''}`}><span className="dot"/>{r['是否达标']}</span></td><td><button className="text" onClick={()=>setSelected(r)}>查看</button></td></tr>)}</tbody></table>{!rows.length&&<div className="empty">{result?'没有符合条件的记录。':'尚无试算结果,请导入数据、确认规则后开始试算。'}</div>}</div>:<div className="issueList">{issues.filter(i=>!search||i.plate.includes(search)||i.message.includes(search)).map((i,n)=><div className="issue" key={n}><AlertCircle size={20}/><div><b>{i.plate||'导入问题'}</b><p>{i.message}</p><small>{i.code} · 原始行 {i.source_row??'—'}</small></div><button onClick={()=>{const r=records.find(r=>r['车牌号']===i.plate);if(r)setSelected(r);else setNotice('该异常未形成考核记录,请在数据接入中修正源文件后重新导入。')}}>查看依据</button></div>)}{!issues.length&&<div className="empty">{result?'没有待核对异常。':'请先执行试算。'}</div>}</div>}<div className="tablefoot"><span>共 {tab==='考核明细'?rows.length:issues.length} 条 · {result?.month||month}{result?.id&&` · 快照 ${result.id.slice(0,12)}`}</span><button disabled={!result||busy||(mode==='api'&&result.status!=='ready')} onClick={()=>{if(mode==='demo')download(`演示核算-${month}.json`,{...result,disclaimer:'演示数据,不作为发放依据'});else window.location.assign(`/api/assessments/${result.id}/export.xlsx`)}}><Download size={15}/>{mode==='demo'?'导出演示 JSON':'导出考核 Excel'}</button></div></section><div className="rulefooter"><Info size={17}/><span>规则说明:缺失不等于 0;逐日取数及政策版本应随核算快照留存。</span><div>���据来源 <b>纯氢</b><b>仪表</b><b>GPS</b></div></div></>}
{page==='数据接入'&&<section className="contentpanel"><h2>接入本次核算数据</h2><p>使用标准 Excel 表头导入。本机快照保留来源行与内容哈希,原文件不会被修改。</p><div className="importgrid"><article><Database/><h3>租赁与交还车记录</h3><p>车辆、合同、负责人、交还车时间。重复导入将替换本次选用的租赁快照。</p><label className="button"><Upload size={16}/>选择租赁 Excel<input disabled={busy||mode!=='api'} type="file" accept=".xlsx" onChange={e=>{upload(e.target.files[0],'leases');e.target.value=''}}/></label></article><article><Activity/><h3>逐日里程</h3><p>必填列:plate_number、date、mileage_km。</p><div className="fields"><select aria-label="里程来源" value={source} disabled={busy} onChange={e=>{setSource(e.target.value);setProvider({hydrogen:'guangzhou',tbox:'vehicle-platform',gps:'g7'}[e.target.value])}}><option value="hydrogen">纯氢里程</option><option value="tbox">仪表 / TBOX</option><option value="gps">GPS</option></select><input aria-label="来源提供方" value={provider} disabled={busy} onChange={e=>setProvider(e.target.value)}/></div><label className="button"><Upload size={16}/>选择里程 Excel<input disabled={busy||mode!=='api'} type="file" accept=".xlsx" onChange={e=>{upload(e.target.files[0],'mileage');e.target.value=''}}/></label></article></div><h3>本次已选快照</h3>{!imports.length?<div className="empty">{mode==='demo'?'切换至本机 API 模式,即可连接服务并上传数据。':'暂无导入,请选择文件。'}</div>:imports.map(i=><div className="importrow" key={i.id}><Check size={18}/><div><b>{i.name}</b><small>{i.accepted_rows} / {i.raw_rows} 行 · {i.issues.length} 项问题 · {i.id.slice(0,16)}</small>{i.issues.map((x,n)=><p key={n} className="warning">第 {x.source_row} 行:{x.message}</p>)}</div><button disabled={busy} onClick={()=>{invalidate();setImports(imports.filter(x=>x.id!==i.id))}}>移除</button></div>)}</section>}
{page==='规则中心'&&<section className="contentpanel"><h2>规则版本与生效范围</h2><p>每次试算携带明确规则。当前编辑器仅配置现有 API 支持的参数。</p><div className="policy"><div><b>最新业务口径</b><p>非测试日:纯氢(含 0)优先,无纯氢取仪表。测试日取 GPS。</p><p>奖金包含当月、结转及补发;离职剔除以批准版本为准。</p></div><div><b>当前 API 的差异</b><p>按区间选择纯氢来源,测试日清零;尚未迁移最新规则。</p><p>仅生成考核源表,不计算奖金。下方参数不能替代算法升级。</p></div></div><div className="sectionhead"><h3>试算参数 JSON</h3><button onClick={()=>download('规则示例.json',configTemplate)}><Download size={15}/>下载示例</button></div><textarea aria-label="试算参数 JSON" spellCheck="false" value={config} disabled={busy} onChange={e=>{setConfig(e.target.value);if(mode==='api')invalidate()}}/><div className="sectionhead"><small>月份需与右上角一致;所有参与考核的车辆均需列入 rules。</small><button onClick={()=>run(async()=>{const c=JSON.parse(config);if(!c.month||!Array.isArray(c.rules)||!c.rules.length)throw new Error('需要 month 与非空 rules 数组');setNotice('JSON 格式及基本结构通过;完整业务校验由服务端在试算时执行。')})}>检查 JSON</button></div></section>}
{page==='API 对接'&&<section className="contentpanel"><div className="sectionhead"><div><h2>本机核算服务</h2><p>前端 /api → 127.0.0.1:8765 · 同源代理</p></div><button disabled={busy} onClick={()=>run(async()=>{setHealth(null);const h=await request('/health');setHealth(h)})}><RefreshCw size={16}/>检测连接</button></div>{health&&<div className="notice"><Check size={17}/>服务已连接 · {health.status} · 引擎 {health.version}</div>}<div className="endpointlist">{[['GET','/health','服务与版本'],['POST','/imports/leases','导入租赁 Excel'],['POST','/imports/mileage?source=tbox&provider=vehicle-platform','导入逐日里程 Excel'],['POST','/assessments','固定输入与规则,生成试算快照'],['GET','/assessments/{id}','查询记录、异常与逐日依据'],['GET','/assessments/{id}/export.xlsx','无异常时导出考核源表']].map(([m,p,d])=><div key={p}><b>{m}</b><code>{p}</code><span>{d}</span></div>)}</div><h3>接入业务系统的下一步</h3><div className="roadmap"><p><strong>01</strong>租赁系统 · 对接交还车区间与人员归属</p><p><strong>02</strong>车辆平台 · 对接纯氢、仪表、GPS 日里程</p><p><strong>03</strong>奖金账本 · 固化历史结转、补发与有效政策</p><p><strong>04</strong>审批归档 · 批次复核、权限控制与结果锁定</p></div><p>完整字段、请求示例与建议接口见项目文档:docs/里程绩效原型_API对接.md</p></section>}
<footer>羚牛 · 里程绩效工作台 <span>数据有来源,规则有版本,结果可追溯。</span></footer></main></div>{selected&&<div className="overlay" onClick={close}><section className="drawer" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-labelledby="detail-title" onClick={e=>e.stopPropagation()}><div className="sectionhead"><div><small>考核依据 / {result.month}</small><h2 id="detail-title">{selected['车牌号']}</h2></div><button onClick={close} aria-label="关闭详情"><X/></button></div><div className="detailmeta"><div>负责人<b>{selected['销售经理']}</b></div><div>客户<b>{selected['客户名称']}</b></div><div>合同<b>{selected['合同编号']}</b></div><div>考核区间<b>{selected['考核开始日期']} — {selected['考核结束日期']}</b></div></div><h3>计算过程</h3><div className="formula">{fmt(selected['实际行驶里程(km)'])} km <span>/</span> {fmt(selected['应考核里程(km)'])} km <span>=</span> {fmt(selected['完成率(%)'])}{selected['完成率(%)']!=null?'%':''}</div><p>目标按考核天数折算;缺少必需里程时不计算完成率。此处不包含奖金计算。</p><h3>逐日取数与来源</h3>{mode==='demo'&&<p>以下为虚构日值演示;日值四舍五入,非正式计算底稿。</p>}<table><thead><tr><th>日期</th><th>采用来源</th><th>计入 km</th><th>依据</th></tr></thead><tbody>{traces.map((d,i)=><tr key={i}><td>{d.date}</td><td>{({tbox:'仪表',hydrogen:'纯氢',gps:'GPS'})[d.source]}{d.test_day?' · 测试日':''}</td><td>{fmt(d.included_km)}</td><td><small>{d.evidence?.[d.source]?.provider||'缺失'}<br/>原始行 {d.evidence?.[d.source]?.source_row??'—'}</small></td></tr>)}</tbody></table>{!traces.length&&<div className="empty">没有逐日依据。</div>}</section></div>}</div>
}
createRoot(document.getElementById('root')).render(<App/>);
@@ -0,0 +1,29 @@
import { useCallback, useEffect, useState } from 'react';
import { useCreate, useCustom, useCustomMutation, useDataProvider, useDelete, useList, useOne, useUpdate } from '@refinedev/core';
import { useQueryClient } from '@tanstack/react-query';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { defaultFilters, filterValidator, type AssessmentFilters, type Resource, type DataRow, type Analytics, type MetricQuery, type Role } from '../contracts';
import type { ColumnSchema } from '../../../shared/schema/types';
export function parseFilters(search: string) { const parsed = filterValidator.safeParse({ ...defaultFilters, ...Object.fromEntries(new URLSearchParams(search)) }); return parsed.success ? parsed.data : { ...defaultFilters }; }
export function filterSearch(filters: AssessmentFilters) { return new URLSearchParams(Object.entries(filters).filter(([, v]) => v)).toString(); }
export function useAssessmentFilters() {
const [params, setParams] = useSearchParams(), location = useLocation(), navigate = useNavigate();
const filters = parseFilters(params.toString());
const setFilters = (patch: Partial<AssessmentFilters>) => setParams(filterSearch({ ...filters, ...patch }), { replace: true });
const go = (path: string, patch: Partial<AssessmentFilters> = {}) => { sessionStorage.setItem(`oneos-filters:${location.pathname}`, params.toString()); navigate(`${path}?${filterSearch({ ...filters, status: '', search: '', source: '', quality: '', exceptionType: '', participation: '', taskId: '', ...patch })}`); };
const nav = (path: string) => { sessionStorage.setItem(`oneos-filters:${location.pathname}`, params.toString()); const saved = sessionStorage.getItem(`oneos-filters:${path}`); navigate(`${path}?${saved ?? filterSearch({ ...filters, status: '', search: '', source: '', quality: '', exceptionType: '', participation: '', taskId: '', vehicleId: '' })}`); };
return { filters, setFilters, reset: () => setParams(filterSearch(defaultFilters), { replace: true }), go, nav };
}
export function useDebounced<T>(value: T, delay = 250) { const [state, setState] = useState(value); useEffect(() => { const timer = setTimeout(() => setState(value), delay); return () => clearTimeout(timer); }, [value, delay]); return state; }
export function useAssessmentList(resource: Resource, filters: AssessmentFilters, role: Role, page: number, pageSize: number, sort: { field: string; order: 'asc' | 'desc' }) { return useList<DataRow>({ resource, pagination: { currentPage: page, pageSize, mode: 'server' }, sorters: [sort], meta: { filters, role }, queryOptions: { retry: false } }); }
export function useAssessmentDetail(resource: Resource, id: string | undefined, filters: AssessmentFilters, role: Role) { return useOne<DataRow>({ resource, id: id ?? '', meta: { filters, role }, queryOptions: { enabled: !!id, retry: false } }); }
export function useAssessmentAnalytics(query: MetricQuery, role: Role) { return useCustom<Analytics>({ url: '/analytics', method: 'get', config: { query }, meta: { role }, queryOptions: { retry: false } }); }
export function useFilterOptions(role: Role) { return useCustom<Record<string, string[]>>({ url: '/options', method: 'get', meta: { role }, queryOptions: { staleTime: Infinity } }); }
export function useAssessmentCommands() {
const create = useCreate(), update = useUpdate(), remove = useDelete(), command = useCustomMutation(), cache = useQueryClient();
const [busy, setBusy] = useState(false), [feedback, setFeedback] = useState(''), [error, setError] = useState('');
async function run(fn: () => Promise<unknown>, message: string) { setBusy(true); setError(''); setFeedback(''); try { await fn(); await cache.invalidateQueries(); setFeedback(message); return true; } catch (e) { setError(e instanceof Error ? e.message : '操作失败,请重试'); return false; } finally { setBusy(false); } }
return { busy, feedback, error, clear: () => { setError(''); setFeedback(''); }, createTask: (values: unknown) => run(() => create.mutateAsync({ resource: 'assessment-tasks', values }), '演示任务已创建'), updateTask: (id: string, values: unknown) => run(() => update.mutateAsync({ resource: 'assessment-tasks', id, values }), '演示任务已更新'), deleteTask: (id: string) => run(() => remove.mutateAsync({ resource: 'assessment-tasks', id }), '草稿任务已删除'), updateException: (id: string, values: unknown) => run(() => update.mutateAsync({ resource: 'assessment-exceptions', id, values }), '演示异常处理状态已更新'), execute: (id: string, type: 'execute' | 'recalculate') => run(() => command.mutateAsync({ url: '/commands', method: 'post', values: { id, command: type } }), '模拟操作完成,已记录演示执行次数;未运行正式考核算法') };
}
export function toCsv(rows: DataRow[], columns: ColumnSchema[]) { const cell = (v: unknown) => { const raw = v == null ? '' : String(v); const safe = /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw; return `"${safe.replaceAll('"', '""')}"`; }; return '\uFEFF' + [columns.map(c => cell(c.title)).join(','), ...rows.map(row => columns.map(c => cell(row[c.field])).join(','))].join('\r\n'); }
export function useAssessmentExport(resource: Resource, filters: AssessmentFilters, columns: ColumnSchema[], role: Role) { const getProvider = useDataProvider(); const [busy, setBusy] = useState(false), [error, setError] = useState(''); const download = useCallback(async (ids?: string[]) => { setBusy(true); setError(''); try { const result = await getProvider().getList<DataRow>({ resource, pagination: { mode: 'off' }, meta: { filters, role } }); const rows = ids?.length ? result.data.filter(r => ids.includes(r.id)) : result.data; const url = URL.createObjectURL(new Blob([toCsv(rows, columns)], { type: 'text/csv;charset=utf-8;' })); const a = document.createElement('a'); a.href = url; a.download = `DEMO-${resource}-${filters.start}-${filters.end}.csv`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } catch (e) { setError(e instanceof Error ? e.message : '导出失败'); } finally { setBusy(false); } }, [getProvider, resource, filters, columns, role]); return { download, busy, error }; }
@@ -0,0 +1,28 @@
import { z } from 'zod';
export type Role = 'company' | 'department' | 'sales';
export const roleLabels: Record<Role, string> = { company: '公司管理员', department: '部门管理员 · 华东', sales: '业务员 · 林晓' };
export type Resource = 'assessment-tasks' | 'assessment-vehicles' | 'daily-mileage' | 'assessment-exceptions' | 'assessment-results';
export type DataRow = { id: string; [key: string]: unknown };
export type VehicleType = '18T' | '4.5T 冷链' | '4.5T 普货';
export interface Vehicle extends DataRow { plate: string; vin: string; vehicleType: VehicleType; company: string; customer: string; department: string; salesperson: string; contract: string; replacement: string | null; active: boolean }
export interface DailyMileage extends DataRow { vehicleId: string; date: string; tbox: number | null; g7: number | null; gps: number | null; hydrogen: number | null; selectedMileage: number | null; targetMileage: number; source: string; testDay: boolean; quality: string; reason: string; ruleVersion: string; rawReference: string; exceptionDescription: string }
export interface AssessmentVehicle extends Vehicle { targetMileage: number; assessedMileage: number; remainingMileage: number; completionRate: number; status: string; exceptionCount: number }
export interface Task extends DataRow { name: string; type: string; periodStart: string; periodEnd: string; vehicleIds: string[]; plannedTarget: number | null; status: string; createdAt: string; lastRun?: string; runCount: number }
export interface AssessmentException extends DataRow { vehicleId: string; date: string; type: string; source: string; description: string; status: string; handler: string; note: string }
export const filterValidator = z.object({ start: z.iso.date(), end: z.iso.date(), search: z.string().default(''), plate: z.string().default(''), vin: z.string().default(''), vehicleType: z.string().default(''), customer: z.string().default(''), department: z.string().default(''), salesperson: z.string().default(''), company: z.string().default(''), vehicleId: z.string().default(''), status: z.string().default(''), source: z.string().default(''), quality: z.string().default(''), taskId: z.string().default(''), exceptionType: z.string().default(''), participation: z.string().default('') }).refine(v => v.start <= v.end, '开始日期不能晚于结束日期');
export type AssessmentFilters = z.infer<typeof filterValidator>;
export const defaultFilters: AssessmentFilters = filterValidator.parse({ start: '2026-09-01', end: '2026-09-30' });
export type Dimension = 'company' | 'department' | 'salesperson' | 'customer' | 'vehicleType' | 'plate';
export interface DimensionDefinition { key: Dimension; label: string }
export const dimensions: DimensionDefinition[] = [{ key: 'department', label: '部门' }, { key: 'customer', label: '客户' }, { key: 'vehicleType', label: '车型' }, { key: 'salesperson', label: '业务员' }, { key: 'company', label: '公司' }, { key: 'plate', label: '车辆' }];
export type MetricKey = 'vehicleCount' | 'participatingCount' | 'achievedCount' | 'achievementRate' | 'targetMileage' | 'assessedMileage' | 'remainingMileage' | 'exceptionVehicleCount' | 'exceptionCount' | 'pendingCount' | 'handledCount';
export interface MetricDefinition { key: MetricKey; label: string; unit: string; description: string }
export interface MetricQuery { filters: AssessmentFilters; dimension: Dimension; metric: 'assessedMileage' | 'completionRate' | 'vehicleCount' }
export interface ChartSeries { name: string; values: number[] }
export interface ChartData { labels: string[]; series: ChartSeries[]; dimension?: string }
export interface Analytics { id: string; metrics: Record<MetricKey, number>; charts: Record<string, ChartData>; recentTasks: DataRow[]; pendingExceptions: DataRow[]; underperformers: AssessmentVehicle[] }
export interface ListRequest { filters: AssessmentFilters; page: number; pageSize: number; sort?: { field: string; order: 'asc' | 'desc' } }
export interface ListResult { data: DataRow[]; total: number }
export interface MileageGateway { list(resource: Resource, request: ListRequest): Promise<ListResult>; one(resource: Resource, id: string, filters: AssessmentFilters): Promise<DataRow>; create(resource: Resource, values: unknown): Promise<DataRow>; update(resource: Resource, id: string, values: unknown): Promise<DataRow>; remove(resource: Resource, id: string): Promise<DataRow>; command(id: string, command: 'execute' | 'recalculate'): Promise<DataRow>; analytics(query: MetricQuery): Promise<Analytics>; options(): Promise<Record<string, string[]>> }
export const taskValidator = z.object({ name: z.string().trim().min(2, '任务名称至少 2 个字').max(60), type: z.enum(['月度考核', '专项考核']), periodStart: z.iso.date(), periodEnd: z.iso.date(), plannedTarget: z.coerce.number().positive('目标里程必须大于 0'), vehicleIds: z.array(z.string()).min(1, '至少选择一辆车') }).refine(v => v.periodStart <= v.periodEnd, '开始日期不能晚于结束日期');
export const exceptionUpdateValidator = z.object({ note: z.string().trim().min(2, '请填写至少 2 个字的处理备注').max(500), status: z.enum(['待处理', '已处理']) });
@@ -0,0 +1,14 @@
import type { MileageGateway, Resource, ListRequest, AssessmentFilters, MetricQuery, ListResult, DataRow, Analytics } from '../contracts';
/** Proposed Spring Boot boundary. Change DTO mapping here, never the page or UI adapters. No live endpoint is enabled by default. */
export class HttpMileageService implements MileageGateway {
constructor(private base: string, private fetcher: typeof fetch = fetch) {}
private async request<T>(path: string, init?: RequestInit): Promise<T> { const r = await this.fetcher(`${this.base}${path}`, { credentials: 'include', ...init, headers: { 'Content-Type': 'application/json', ...init?.headers } }); if (!r.ok) throw Object.assign(new Error(`接口请求失败 (${r.status})`), { statusCode: r.status }); return r.json() as Promise<T>; }
async list(resource: Resource, request: ListRequest): Promise<ListResult> { const dto = await this.request<{ content: DataRow[]; totalElements: number }>(`/${resource}/query`, { method: 'POST', body: JSON.stringify({ ...request.filters, page: request.page - 1, size: request.pageSize, sort: request.sort }) }); return { data: dto.content, total: dto.totalElements }; }
one(resource: Resource, id: string, filters: AssessmentFilters) { return this.request<DataRow>(`/${resource}/${encodeURIComponent(id)}?${new URLSearchParams(filters)}`); }
create(resource: Resource, values: unknown) { return this.request<DataRow>(`/${resource}`, { method: 'POST', body: JSON.stringify(values) }); }
update(resource: Resource, id: string, values: unknown) { return this.request<DataRow>(`/${resource}/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(values) }); }
remove(resource: Resource, id: string) { return this.request<DataRow>(`/${resource}/${encodeURIComponent(id)}`, { method: 'DELETE' }); }
command(id: string, command: 'execute' | 'recalculate') { return this.request<DataRow>(`/assessment-tasks/${encodeURIComponent(id)}/commands`, { method: 'POST', body: JSON.stringify({ command }) }); }
analytics(query: MetricQuery) { return this.request<Analytics>('/analytics/query', { method: 'POST', body: JSON.stringify(query) }); }
options() { return this.request<Record<string, string[]>>('/filter-options'); }
}
@@ -0,0 +1,75 @@
import { defaultFilters, taskValidator, exceptionUpdateValidator, type Role, type Resource, type DataRow, type Vehicle, type AssessmentVehicle, type AssessmentFilters, type ListRequest, type MileageGateway, type MetricQuery, type Analytics, type ChartData, type Task } from '../contracts';
import { createSeed, type DemoDatabase } from './seed';
export class ServiceError extends Error { constructor(message: string, public statusCode = 400) { super(message); } }
export const canWrite = (role: Role) => role !== 'sales';
export function inScope(vehicle: Vehicle, role: Role) { return role === 'company' || (role === 'department' ? vehicle.department === '华东运营部' : vehicle.salesperson === '林晓'); }
const sum = (items: DataRow[], field: string) => items.reduce((n, row) => n + Number(row[field] ?? 0), 0);
export function aggregateVehicles(db: DemoDatabase, filters: AssessmentFilters, role: Role): AssessmentVehicle[] {
const task = filters.taskId ? db.tasks.find(t => t.id === filters.taskId) : null;
const visible = db.vehicles.filter(v => inScope(v, role) && (!filters.taskId || !!task?.vehicleIds.includes(v.id)) && (!filters.vehicleId || v.id === filters.vehicleId) && ['vehicleType', 'customer', 'department', 'salesperson', 'company'].every(k => !filters[k as keyof AssessmentFilters] || v[k] === filters[k as keyof AssessmentFilters]) && (!filters.plate || v.plate.includes(filters.plate)) && (!filters.vin || v.vin.toLowerCase().includes(filters.vin.toLowerCase())) && (!filters.search || [v.plate, v.vin, v.customer, v.salesperson, v.contract].some(value => value.toLowerCase().includes(filters.search.toLowerCase()))));
const daysByVehicle = new Map<string, DataRow[]>(), exByVehicle = new Map<string, number>();
for (const d of db.daily) if (d.date >= filters.start && d.date <= filters.end) { const days = daysByVehicle.get(d.vehicleId) ?? []; days.push(d); daysByVehicle.set(d.vehicleId, days); }
for (const e of db.exceptions) if (e.date >= filters.start && e.date <= filters.end) exByVehicle.set(e.vehicleId, (exByVehicle.get(e.vehicleId) ?? 0) + 1);
return visible.flatMap(v => {
const days = daysByVehicle.get(v.id) ?? [];
if (!days.length) return [];
const targetMileage = sum(days, 'targetMileage'), assessedMileage = sum(days, 'selectedMileage');
const completionRate = targetMileage ? assessedMileage / targetMileage * 100 : 0;
return [{ ...v, targetMileage, assessedMileage, remainingMileage: Math.max(0, targetMileage - assessedMileage), completionRate, status: !v.active ? '空闲' : completionRate >= 100 ? '达标' : completionRate >= 80 ? '部分完成' : '未达标', exceptionCount: exByVehicle.get(v.id) ?? 0 }];
}).filter(v => (!filters.status || (filters.status === '未达标全部' ? v.active && v.completionRate < 100 : v.status === filters.status)) && (!filters.participation || (filters.participation === 'active' ? v.active : filters.participation === 'exceptions' ? v.exceptionCount > 0 : true)));
}
export function metricValues(rows: AssessmentVehicle[], exceptions: DataRow[]) {
const participatingCount = rows.filter(v => v.targetMileage > 0).length, achievedCount = rows.filter(v => v.targetMileage > 0 && v.completionRate >= 100).length;
return { vehicleCount: rows.length, participatingCount, achievedCount, achievementRate: participatingCount ? achievedCount / participatingCount * 100 : 0, targetMileage: sum(rows, 'targetMileage'), assessedMileage: sum(rows, 'assessedMileage'), remainingMileage: sum(rows, 'remainingMileage'), exceptionVehicleCount: new Set(exceptions.map(e => e.vehicleId)).size, exceptionCount: exceptions.length, pendingCount: exceptions.filter(e => e.status === '待处理').length, handledCount: exceptions.filter(e => e.status === '已处理').length };
}
export function groupChart(rows: AssessmentVehicle[], dimension: string, metric: MetricQuery['metric'] | 'achievementRate'): ChartData {
const labels = [...new Set(rows.map(r => String(r[dimension])))];
return { labels, dimension, series: [{ name: metric === 'completionRate' ? '完成率 (%)' : metric === 'achievementRate' ? '达标率 (%)' : metric === 'vehicleCount' ? '车辆数' : '考核里程 (km)', values: labels.map(label => { const group = rows.filter(r => r[dimension] === label), target = sum(group, 'targetMileage'); return metric === 'vehicleCount' ? group.length : metric === 'achievementRate' ? metricValues(group, []).achievementRate : metric === 'completionRate' ? (target ? sum(group, 'assessedMileage') / target * 100 : 0) : sum(group, 'assessedMileage'); }) }] };
}
export class MockMileageService implements MileageGateway {
constructor(public db: DemoDatabase = createSeed(), public role: Role = 'company', private persist?: (db: DemoDatabase) => void, private latency = 120) {}
private async wait() { if (this.latency) await new Promise(resolve => setTimeout(resolve, this.latency)); }
private save() { this.persist?.(this.db); }
private assertWrite() { if (!canWrite(this.role)) throw new ServiceError('当前角色没有此操作权限', 403); }
private vehicles(filters: AssessmentFilters) { return aggregateVehicles(this.db, filters, this.role); }
private taskRow(task: Task, filters: AssessmentFilters): DataRow | null {
const scoped = this.db.vehicles.filter(v => task.vehicleIds.includes(v.id) && inScope(v, this.role));
if (!scoped.length) return null;
const rows = this.vehicles({ ...filters, search: '', status: '', start: task.periodStart > filters.start ? task.periodStart : filters.start, end: task.periodEnd < filters.end ? task.periodEnd : filters.end, taskId: task.id });
if (!rows.length) return null;
const target = task.plannedTarget === null ? sum(rows, 'targetMileage') : task.plannedTarget * rows.length;
return { ...task, vehicleIds: rows.map(v => v.id), vehicleCount: rows.length, targetMileage: target, completionRate: target ? sum(rows, 'assessedMileage') / target * 100 : 0, period: `${task.periodStart} ~ ${task.periodEnd}`, editable: task.vehicleIds.every(id => this.db.vehicles.some(v => v.id === id && inScope(v, this.role))) };
}
rows(resource: Resource, filters: AssessmentFilters): DataRow[] {
if (resource === 'assessment-vehicles' || resource === 'assessment-results') return this.vehicles(filters);
const vehicles = this.vehicles({ ...filters, status: '', search: resource === 'assessment-tasks' ? '' : filters.search });
const map = new Map(vehicles.map(v => [v.id, v]));
if (resource === 'assessment-tasks') return this.db.tasks.filter(t => t.periodStart <= filters.end && t.periodEnd >= filters.start && (!filters.status || t.status === filters.status) && (!filters.search || `${t.id} ${t.name}`.toLowerCase().includes(filters.search.toLowerCase()))).map(t => this.taskRow(t, filters)).filter((t): t is DataRow => t !== null);
if (resource === 'daily-mileage') return this.db.daily.filter(d => map.has(d.vehicleId) && d.date >= filters.start && d.date <= filters.end && (!filters.source || d.source === filters.source) && (!filters.quality || d.quality === filters.quality)).map(d => ({ ...map.get(d.vehicleId), ...d }));
return this.db.exceptions.filter(e => map.has(e.vehicleId) && e.date >= filters.start && e.date <= filters.end && (!filters.status || e.status === filters.status) && (!filters.source || e.source === filters.source) && (!filters.exceptionType || e.type === filters.exceptionType)).map(e => ({ ...map.get(e.vehicleId), ...e }));
}
async list(resource: Resource, request: ListRequest) { await this.wait(); const rows = [...this.rows(resource, request.filters)]; if (request.sort) { const { field, order } = request.sort; rows.sort((a, b) => { const x = a[field], y = b[field]; return (typeof x === 'number' && typeof y === 'number' ? x - y : String(x ?? '').localeCompare(String(y ?? ''), 'zh-CN')) * (order === 'asc' ? 1 : -1); }); } return { data: structuredClone(rows.slice((request.page - 1) * request.pageSize, request.page * request.pageSize)), total: rows.length }; }
async one(resource: Resource, id: string, filters: AssessmentFilters) { await this.wait(); const row = this.rows(resource, filters).find(r => r.id === id); if (!row) throw new ServiceError('记录不存在,或当前角色 / 筛选范围无权访问', 403); return structuredClone(row); }
async create(resource: Resource, values: unknown) { await this.wait(); this.assertWrite(); if (resource !== 'assessment-tasks') throw new ServiceError('此资源不支持创建'); const input = taskValidator.parse(values); if (input.vehicleIds.some(id => !this.db.vehicles.some(v => v.id === id && v.active && inScope(v, this.role)))) throw new ServiceError('所选车辆不在可操作范围', 403); const task: Task = { ...input, vehicleIds: [...new Set(input.vehicleIds)], id: `MA-DEMO-${Date.now()}`, status: '草稿', createdAt: new Date().toISOString().slice(0, 16).replace('T', ' '), runCount: 0 }; this.db.tasks.unshift(task); this.save(); return structuredClone(task); }
private editableTask(id: string) { const task = this.db.tasks.find(t => t.id === id); if (!task || task.vehicleIds.some(vid => !this.db.vehicles.some(v => v.id === vid && inScope(v, this.role)))) throw new ServiceError('没有操作此任务全部车辆的权限', 403); return task; }
async update(resource: Resource, id: string, values: unknown) { await this.wait(); this.assertWrite(); if (resource === 'assessment-tasks') { const task = this.editableTask(id); if (!['草稿', '待执行'].includes(task.status)) throw new ServiceError('已执行任务不可编辑,请模拟重算'); const input = taskValidator.parse(values); if (input.vehicleIds.some(vid => !this.db.vehicles.some(v => v.id === vid && v.active && inScope(v, this.role)))) throw new ServiceError('车辆不在可操作范围', 403); Object.assign(task, input, { vehicleIds: [...new Set(input.vehicleIds)] }); this.save(); return structuredClone(task); } if (resource !== 'assessment-exceptions') throw new ServiceError('此资源不可修改'); const item = this.db.exceptions.find(e => e.id === id); if (!item || !this.db.vehicles.some(v => v.id === item.vehicleId && inScope(v, this.role))) throw new ServiceError('没有处理此异常的权限', 403); const input = exceptionUpdateValidator.parse(values); Object.assign(item, input, { handler: this.role === 'company' ? '演示公司管理员' : '演示华东管理员' }); this.save(); return structuredClone(item); }
async remove(resource: Resource, id: string) { await this.wait(); this.assertWrite(); if (resource !== 'assessment-tasks') throw new ServiceError('此资源不允许删除'); const task = this.editableTask(id); if (task.status !== '草稿') throw new ServiceError('仅允许删除草稿任务'); this.db.tasks = this.db.tasks.filter(t => t.id !== id); this.save(); return task; }
async command(id: string, command: 'execute' | 'recalculate') { await this.wait(); this.assertWrite(); const task = this.editableTask(id); if (command === 'execute' && task.status === '演示已完成') throw new ServiceError('任务已模拟执行,请使用模拟重算'); Object.assign(task, { status: '演示已完成', runCount: task.runCount + 1, lastRun: new Date().toISOString() }); this.save(); return structuredClone(task); }
async options() { await this.wait(); const rows = this.db.vehicles.filter(v => inScope(v, this.role)); return Object.fromEntries(['vehicleType', 'customer', 'department', 'salesperson', 'company'].map(k => [k, [...new Set(rows.map(v => String(v[k])))]])); }
async analytics(query: MetricQuery): Promise<Analytics> {
await this.wait(); const f = query.filters, rows = this.vehicles({ ...f, status: ['待处理', '已处理'].includes(f.status) ? '' : f.status });
const ids = new Set(rows.map(v => v.id));
const exceptions = this.rows('assessment-exceptions', { ...f, status: ['待处理', '已处理'].includes(f.status) ? f.status : '' }).filter(e => ids.has(String(e.vehicleId)));
const months = [...new Set(this.db.daily.filter(d => ids.has(d.vehicleId) && d.date >= f.start && d.date <= f.end).map(d => d.date.slice(0, 7)))];
const monthData = months.map(month => this.vehicles({ ...f, status: '', start: f.start > `${month}-01` ? f.start : `${month}-01`, end: f.end < `${month}-31` ? f.end : `${month}-31` }).filter(v => ids.has(v.id)));
const types = [...new Set(exceptions.map(e => String(e.type)))];
const dates = [...new Set(this.db.daily.filter(d => ids.has(d.vehicleId) && d.date >= f.start && d.date <= f.end).map(d => d.date))];
const trendGroups = months.length === 1 ? dates.map(date => this.vehicles({ ...f, status: '', start: date, end: date }).filter(v => ids.has(v.id))) : monthData;
const labels = months.length === 1 ? dates.map(d => d.slice(5)) : months;
const trend: ChartData = { labels, dimension: months.length === 1 ? 'date' : 'month', series: [{ name: '考核里程 (km)', values: trendGroups.map(g => sum(g, 'assessedMileage')) }, { name: '目标里程 (km)', values: trendGroups.map(g => sum(g, 'targetMileage')) }] };
return { id: 'analytics', metrics: metricValues(rows, exceptions), charts: { trend, comparison: { labels: ['目标里程', '考核里程', '剩余缺口'], series: [{ name: '里程 (km)', values: [sum(rows, 'targetMileage'), sum(rows, 'assessedMileage'), sum(rows, 'remainingMileage')] }] }, achievementTrend: { labels: months, dimension: 'month', series: [{ name: '达标率 (%)', values: monthData.map(g => metricValues(g, []).achievementRate) }] }, vehicleType: groupChart(rows, 'vehicleType', 'achievementRate'), department: groupChart(rows, 'department', 'completionRate'), dimension: groupChart(rows, query.dimension, query.metric), distribution: groupChart(rows, 'vehicleType', 'vehicleCount'), exceptions: { labels: types, dimension: 'exceptionType', series: [{ name: '异常数', values: types.map(t => exceptions.filter(e => e.type === t).length) }] }, exceptionTrend: { labels, series: [{ name: '异常数', values: labels.map(label => exceptions.filter(e => months.length === 1 ? String(e.date).slice(5) === label : String(e.date).startsWith(label)).length) }] }, underperformers: { labels: rows.filter(v => v.active && v.completionRate < 100).sort((a, b) => a.completionRate - b.completionRate).slice(0, 8).map(v => v.plate), dimension: 'plate', series: [{ name: '完成率 (%)', values: rows.filter(v => v.active && v.completionRate < 100).sort((a, b) => a.completionRate - b.completionRate).slice(0, 8).map(v => v.completionRate) }] } }, recentTasks: this.rows('assessment-tasks', { ...f, status: '', search: '' }).slice(0, 4), pendingExceptions: exceptions.filter(e => e.status === '待处理').slice(0, 5), underperformers: rows.filter(v => v.active && v.completionRate < 100).sort((a, b) => a.completionRate - b.completionRate).slice(0, 5) };
}
}
export function browserDatabase() { const seed = createSeed(); try { const saved = JSON.parse(localStorage.getItem('oneos-demo-v1') ?? 'null'); if (saved && Array.isArray(saved.tasks) && Array.isArray(saved.exceptions)) { seed.tasks = saved.tasks; seed.exceptions = saved.exceptions; } } catch { /* Recover only this demo's storage. */ } return seed; }
export function persistDatabase(db: DemoDatabase) { try { localStorage.setItem('oneos-demo-v1', JSON.stringify({ tasks: db.tasks, exceptions: db.exceptions })); } catch { /* Session mutations remain usable without storage. */ } }
export { defaultFilters };
@@ -0,0 +1,27 @@
import type { Vehicle, DailyMileage, Task, AssessmentException } from '../contracts';
export interface DemoDatabase { vehicles: Vehicle[]; daily: DailyMileage[]; tasks: Task[]; exceptions: AssessmentException[] }
/** Deterministic fictional snapshots. Values demonstrate contracts, never implement production assessment rules. */
export function createSeed(): DemoDatabase {
const vehicles: Vehicle[] = Array.from({ length: 36 }, (_, i) => ({ id: `V${String(i + 1).padStart(3, '0')}`, plate: `演示${['沪', '粤', '苏'][i % 3]}A${String(10001 + i)}`, vin: `DEMO000000000${String(i + 1).padStart(4, '0')}`, vehicleType: (['18T', '4.5T 冷链', '4.5T 普货'] as const)[i % 3], company: i % 2 ? '羚牛运营' : '羚牛物流', customer: ['青禾物流', '云帆冷链', '远山供应链', '星河运输'][i % 4], department: ['华东运营部', '华南运营部', '华北运营部'][Math.floor(i / 12)], salesperson: ['林晓', '周宁', '陈宇', '许安', '吴悦', '李辰'][Math.floor(i / 6)], contract: `DEMO-CT-${String(Math.floor(i / 3) + 1).padStart(3, '0')}`, replacement: i === 7 ? 'V007 · 原车已于 2026-09-01 退租' : null, active: i % 12 !== 11 }));
const daily: DailyMileage[] = [], exceptions: AssessmentException[] = [];
for (let month = 1; month <= 9; month++) {
const days = new Date(2026, month, 0).getDate();
for (const [i, v] of vehicles.entries()) for (let day = 1; day <= days; day++) {
const date = `2026-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const missing = v.active && (i + day + month) % 53 === 0;
const delayed = !missing && v.active && (i + day + month) % 47 === 0;
const testDay = v.active && day === 8 && i % 4 === 0;
const targetMileage = v.active ? [140, 100, 90][i % 3] : 0;
const mileage = v.active ? Math.max(0, Math.round(targetMileage * [1.24, .93, .69, 1.13, .85, .48][i % 6] + ((day * 7 + month * 3 + i) % 25) - 12)) : 0;
const selectedMileage = missing ? null : (day === 2 && i === 2) ? 0 : mileage;
const source = v.vehicleType === '4.5T 普货' ? (testDay ? 'GPS' : 'TBOX') : '纯氢';
const quality = missing ? '数据缺失' : delayed ? '数据延迟' : testDay ? '测试日' : '正常';
daily.push({ id: `${v.id}-${date}`, vehicleId: v.id, date, tbox: missing ? null : mileage + 6, g7: missing ? null : mileage + 2, gps: missing ? null : mileage, hydrogen: v.vehicleType === '4.5T 普货' || missing ? null : mileage, selectedMileage, targetMileage, source, testDay, quality, reason: source === '纯氢' ? '演示快照采用纯氢里程;来源:广州新能源智能汽车监测平台' : testDay ? '演示测试日快照采用 GPS' : '演示普通货运快照采用 TBOX', ruleVersion: 'DEMO-2026.09-v1', rawReference: `demo://snapshot/${date}/${v.id}/${source}`, exceptionDescription: missing ? '当日来源未返回,最终里程待补齐' : delayed ? '来源数据延迟 24 小时到达,等待复核' : '' });
if (missing || delayed) exceptions.push({ id: `EX-${date.replaceAll('-', '')}-${v.id}`, vehicleId: v.id, date, type: quality, source, description: missing ? '日里程数据缺失,请核对来源快照' : '来源延迟到达,请复核当日结果', status: day % 3 === 0 ? '已处理' : '待处理', handler: day % 3 === 0 ? '演示管理员' : '—', note: day % 3 === 0 ? '已核对演示快照' : '' });
}
}
// Source values must match the selected snapshot, including a valid zero.
daily.forEach(d => { if (d.source === 'TBOX') d.tbox = d.selectedMileage; if (d.source === 'GPS') d.gps = d.selectedMileage; if (d.source === '纯氢') d.hydrogen = d.selectedMileage; });
const tasks: Task[] = Array.from({ length: 9 }, (_, i) => { const m = 9 - i; return { id: `MA-2026${String(m).padStart(2, '0')}`, name: `2026 年 ${m} 月车辆里程考核`, type: '月度考核', periodStart: `2026-${String(m).padStart(2, '0')}-01`, periodEnd: `2026-${String(m).padStart(2, '0')}-${new Date(2026, m, 0).getDate()}`, vehicleIds: vehicles.filter(v => v.active).map(v => v.id), plannedTarget: null, status: m === 9 ? '待执行' : '演示已完成', createdAt: `2026-${String(m).padStart(2, '0')}-01 09:00`, runCount: m === 9 ? 0 : 1 }; });
return { vehicles, daily, tasks, exceptions };
}
@@ -0,0 +1,28 @@
import type { DataProvider, BaseRecord, AuthProvider, AccessControlProvider } from '@refinedev/core';
import { defaultFilters, filterValidator, type MileageGateway, type Resource, type MetricQuery, type Role, type AssessmentFilters } from '../domains/mileage/contracts';
const resources = new Set(['assessment-tasks', 'assessment-vehicles', 'daily-mileage', 'assessment-exceptions', 'assessment-results']);
function resourceName(name: string): Resource { if (!resources.has(name)) throw new Error(`未知资源:${name}`); return name as Resource; }
/** Refine's generic record contract is contained in this boundary. Gateways never depend on Refine. */
export function createDataProvider(gateway: MileageGateway): DataProvider {
return {
getApiUrl: () => '/mileage-api',
getList: async <T extends BaseRecord>({ resource, pagination, sorters, filters, meta }: Parameters<DataProvider['getList']>[0]) => {
const f: AssessmentFilters = filterValidator.parse({ ...defaultFilters, ...meta?.filters });
for (const filter of filters ?? []) if ('field' in filter && filter.field in f && filter.operator === 'eq') (f as Record<string, string>)[filter.field] = String(filter.value);
const result = await gateway.list(resourceName(resource), { filters: f, page: pagination?.currentPage ?? 1, pageSize: pagination?.mode === 'off' ? Number.MAX_SAFE_INTEGER : pagination?.pageSize ?? 10, sort: sorters?.[0] });
return { data: result.data as unknown as T[], total: result.total };
},
getOne: async <T extends BaseRecord>({ resource, id, meta }: Parameters<DataProvider['getOne']>[0]) => ({ data: await gateway.one(resourceName(resource), String(id), filterValidator.parse({ ...defaultFilters, ...meta?.filters })) as unknown as T }),
create: async <T extends BaseRecord, V>({ resource, variables }: Parameters<DataProvider['create']>[0] & { variables: V }) => ({ data: await gateway.create(resourceName(resource), variables) as unknown as T }),
update: async <T extends BaseRecord, V>({ resource, id, variables }: Parameters<DataProvider['update']>[0] & { variables: V }) => ({ data: await gateway.update(resourceName(resource), String(id), variables) as unknown as T }),
deleteOne: async <T extends BaseRecord>({ resource, id }: Parameters<DataProvider['deleteOne']>[0]) => ({ data: await gateway.remove(resourceName(resource), String(id)) as unknown as T }),
custom: async <T extends BaseRecord>({ url, method, query, payload }: Parameters<NonNullable<DataProvider['custom']>>[0]) => {
if (url === '/analytics' && method === 'get') return { data: await gateway.analytics(query as MetricQuery) as unknown as T };
if (url === '/options' && method === 'get') return { data: await gateway.options() as unknown as T };
if (url === '/commands' && method === 'post') { const p = payload as { id: string; command: 'execute' | 'recalculate' }; return { data: await gateway.command(p.id, p.command) as unknown as T }; }
throw new Error('不支持的数据请求');
},
};
}
export function authProvider(role: Role): AuthProvider { return { login: async () => ({ success: true }), logout: async () => ({ success: true }), check: async () => ({ authenticated: true }), onError: async () => ({}), getIdentity: async () => ({ id: role, name: role }), getPermissions: async () => role }; }
export function accessControlProvider(role: Role): AccessControlProvider { return { can: async ({ action }) => ({ can: ['list', 'show', 'export'].includes(action) || role !== 'sales', reason: '业务员只有查看与导出权限' }) }; }
+65
View File
@@ -0,0 +1,65 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+63
View File
@@ -0,0 +1,63 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+156
View File
@@ -0,0 +1,156 @@
import * as React from "react"
import { cn } from "cn"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/shared/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+246
View File
@@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Label } from "@/shared/ui/label"
import { Separator } from "@/shared/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+23
View File
@@ -0,0 +1,23 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Label as LabelPrimitive } from "radix-ui"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "cn"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+142
View File
@@ -0,0 +1,142 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,13 @@
import { cn } from "cn"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }
+88
View File
@@ -0,0 +1,88 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Tabs as TabsPrimitive } from "radix-ui"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+3
View File
@@ -0,0 +1,3 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"skipLibCheck":true,"esModuleInterop":true,"resolveJsonModule":true,"allowImportingTsExtensions":true,"noEmit":true,"baseUrl":".","paths":{"@/*":["src/oneos/*"]},"types":["vitest/globals","@testing-library/jest-dom","node"]},"include":["src/oneos","vite.config.ts","playwright.config.ts"]}
+2
View File
@@ -0,0 +1,2 @@
import { defineConfig } from 'vite';
export default defineConfig({ server: { proxy: { '/api': { target: 'http://127.0.0.1:8765', rewrite: path => path.replace(/^\/api/, '') } } } });