refactor(stage5): 合并重复实现,移除失效的演示能力
Excel 导出(原先 4 份各自拼装 workbook) - 新增 src/shared/xlsx.ts 作为唯一实现:文件名统一 .xlsx、sheet 名截断到 31 字符, 对外提供 buildAoaSheet / buildJsonSheet / writeWorkbook / exportAoaSheet / exportJsonSheet。 只收敛"组装与写出"这一层,各调用方仍自行决定列宽、冻结与数字格式,导出样式不变。 - 迁移 assets(内联 json_to_sheet + writeFile)、mileage/xlsx-export、 hydrogen 的两份 helper(prototype-download.ts 与 download-xls.js,均已删除)。 高德地图(原先 2 份近乎逐行复制) - 新增 src/shared/amap.ts:SDK 版本、插件列表、安全码注入、底图参数、 控件位置与热力图色带只在此处定义;两个画布只保留各自的半径/透明度。 - 图例渐变条原先把同一组色值又写了一遍,改为复用 shared 的常量,图例与地图不会漂移。 数值格式化 - 两个下钻视图各自复制了同样的 formatNumber/format(共 42 处调用), 统一到 hydrogen/model/display-format.ts 的 formatFixed;默认路径与既有行为逐字一致。 - 原 display-format.ts 里的 finiteNumber/formatNumber/formatScaled 无任何生产调用方, 只被自己的测试引用;改为 formatFixed + blankForMissing 显式选项, 既保留了"真零 vs 不可用"的区分能力,也不再留无人使用的导出。测试同步重写。 失效的演示能力 - Blur / DemoModeProvider 恒为 enabled=false,等于永久 no-op: 移除 13 个文件里 50 处 <Blur> 包裹(渲染结果不变)、删除 components/Blur.tsx 与 Shell 中的 Provider,调用方直接渲染原表达式。 未做(刻意) - src/lib/cn.ts 不引入 tailwind-merge:全仓只有 2 处调用,不值得新增传递依赖。已在文档说明。 架构守护新增 2 条:只有 shared/amap.ts 可加载高德 SDK; modules/** 不得再出现 book_new / book_append_sheet / writeFile(。 lint / test(137) / build 全绿,可达性仍为 0 未引用文件。
This commit is contained in:
@@ -61,7 +61,7 @@ src/
|
||||
│ ├── ele/ 电能数据导入
|
||||
│ ├── admin/ 反馈后台
|
||||
│ └── *-heatmap/ 两类热力图
|
||||
├── shared/ 前后端共享的叶子层(角色常量、跨端 DTO)
|
||||
├── shared/ 前后端共享的叶子层(角色常量、跨端 DTO、日期区间、Excel、高德接入)
|
||||
└── server/
|
||||
├── config.ts 环境变量集中读取 + 启动期校验
|
||||
├── app.ts 应用装配(无副作用,便于测试)
|
||||
|
||||
+13
-2
@@ -122,6 +122,17 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
|
||||
- **运行时建表仍分散在业务代码里**:`ele`、`feedback`、`mileage` 在首次调用时执行
|
||||
`CREATE TABLE`,而 `read-only` 中间件只按 HTTP 方法拦截,所以只读预览下 GET 仍可能触发建表。
|
||||
应收敛到显式的迁移函数并只由 `bootstrap.ts` 调用。
|
||||
- **重复实现尚未合并**:Excel 导出有 3 套、高德初始化 2 份、数值格式化多处;
|
||||
公共实现 `modules/energy/hydrogen/model/display-format.ts` 目前只被自己的测试使用。
|
||||
- **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个
|
||||
传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`。
|
||||
- **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kB(gzip 50 kB),来自原型渲染方式(大量内联样式)。
|
||||
|
||||
### 已收敛的重复实现(不要退回多份)
|
||||
|
||||
| 能力 | 唯一实现 | 由测试守护 |
|
||||
| --- | --- | --- |
|
||||
| Excel 导出(拼装与写出) | `src/shared/xlsx.ts` | 架构测试:`modules/**` 不得再出现 `book_new` / `book_append_sheet` / `writeFile(` |
|
||||
| 高德 JSAPI 加载与底图 | `src/shared/amap.ts` | 架构测试:只有它可 import `@amap/amap-jsapi-loader` |
|
||||
| 自然日区间(近 N 天/快捷区间) | `src/shared/date-range.ts`、`modules/energy/daily-range/model.ts` | `date-range` 无独立测试;改动请补 |
|
||||
| 氢能数值格式化 | `modules/energy/hydrogen/model/display-format.ts` | `display-format.test.ts` |
|
||||
| 角色常量与模块可见性 | `src/shared/auth/roles.ts` | `app/modules.test.ts` |
|
||||
|
||||
|
||||
@@ -114,6 +114,27 @@ test("纯模型文件不得依赖 React(保证可被 node:test 直接测)",
|
||||
assert.deepEqual(violations, [], "model.ts 应只包含可在 Node 中直接执行的纯函数");
|
||||
});
|
||||
|
||||
test("高德 SDK 只在 shared/amap.ts 接入", () => {
|
||||
// 两个热力图曾各自写一份 load + 底图 + 控件 + 色带,导致同类地图长得不一样。
|
||||
const importers = allFiles
|
||||
.filter((f) => rel(f) !== "shared/amap.ts")
|
||||
.filter((f) => specifiers(f).some((s) => s === "@amap/amap-jsapi-loader"))
|
||||
.map(rel);
|
||||
assert.deepEqual(importers, [], "请通过 src/shared/amap.ts 使用高德地图,不要各自加载 SDK");
|
||||
});
|
||||
|
||||
test("Excel 导出只在 shared/xlsx.ts 组装工作簿", () => {
|
||||
// 允许 server 侧直接读 xlsx(导入解析),但不允许前端再次自行拼装 workbook。
|
||||
const offenders = allFiles
|
||||
.filter((f) => rel(f).startsWith("modules/"))
|
||||
.filter((f) => {
|
||||
const source = readFileSync(f, "utf8");
|
||||
return /book_new|book_append_sheet|writeFile\(/.test(source);
|
||||
})
|
||||
.map(rel);
|
||||
assert.deepEqual(offenders, [], "请改用 src/shared/xlsx.ts 的 writeWorkbook / export*Sheet");
|
||||
});
|
||||
|
||||
test("类型豁免清单只减不增:@ts-nocheck 仅限已登记的 8113 原型快照", () => {
|
||||
// 这三个文件是逐字节保留的验收原型(UI/CSS 冻结),显式登记为唯一豁免。
|
||||
const allowed = [
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
const DemoModeContext = createContext(false);
|
||||
|
||||
export function DemoModeProvider({ enabled, children }: { enabled: boolean; children: ReactNode }) {
|
||||
return <DemoModeContext.Provider value={enabled}>{children}</DemoModeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDemoMode() {
|
||||
return useContext(DemoModeContext);
|
||||
}
|
||||
|
||||
export default function Blur({ children }: { children: ReactNode }) {
|
||||
const demo = useContext(DemoModeContext);
|
||||
if (!demo) return <>{children}</>;
|
||||
return <span className="blur-[5px] select-none">{children}</span>;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, type ComponentType, type ElementType, Sus
|
||||
import { motion } from 'motion/react';
|
||||
import { Building2, ChevronRight } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
import { cn } from '../lib/cn';
|
||||
import { LoadingState } from './ui/surface';
|
||||
@@ -93,7 +92,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<DemoModeProvider enabled={false}>
|
||||
<div className="enterprise-grid-bg flex min-h-screen">
|
||||
{/* 氢费看板按原型交付;其他模块继续保留全局水印。 */}
|
||||
{!isHydrogenPrototype ? (
|
||||
@@ -220,6 +218,5 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</DemoModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
MapPin,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { buildJsonSheet, writeWorkbook } from '../../shared/xlsx';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
} from './model';
|
||||
import { SearchSelect } from '../../components/SearchSelect';
|
||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||
import { AssetsHeader } from './components/AssetsHeader';
|
||||
@@ -393,7 +392,7 @@ export default function AssetsModule() {
|
||||
业务负责人: item.manager || '',
|
||||
客户: item.customerName || '',
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(table);
|
||||
const ws = buildJsonSheet(table);
|
||||
ws['!cols'] = [
|
||||
{ wch: 14 },
|
||||
{ wch: 8 },
|
||||
@@ -404,9 +403,7 @@ export default function AssetsModule() {
|
||||
{ wch: 14 },
|
||||
{ wch: 24 },
|
||||
];
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '明细');
|
||||
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
|
||||
writeWorkbook([{ name: '明细', sheet: ws }], `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
|
||||
}, [flowRange.end, flowRange.start, flowStats]);
|
||||
|
||||
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
|
||||
@@ -705,7 +702,7 @@ export default function AssetsModule() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<span className="font-bold text-gray-700 text-xs"><Blur>{m.manager}</Blur></span>
|
||||
<span className="font-bold text-gray-700 text-xs">{m.manager}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -787,7 +784,7 @@ export default function AssetsModule() {
|
||||
>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-1">
|
||||
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
|
||||
<Blur>{m.manager}</Blur>
|
||||
{m.manager}
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td>
|
||||
<td
|
||||
@@ -907,7 +904,7 @@ export default function AssetsModule() {
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
|
||||
<span className="text-[11px] font-bold text-gray-700"><Blur>{m.manager}</Blur></span>
|
||||
<span className="text-[11px] font-bold text-gray-700">{m.manager}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -986,7 +983,7 @@ export default function AssetsModule() {
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h3 className="text-sm font-bold text-gray-800 shrink-0"><Blur>{m.manager}</Blur></h3>
|
||||
<h3 className="text-sm font-bold text-gray-800 shrink-0">{m.manager}</h3>
|
||||
<span className="text-[11px] text-gray-500 shrink-0">{m.department}</span>
|
||||
<div
|
||||
className="text-[11px] font-bold text-blue-600 whitespace-nowrap"
|
||||
@@ -1510,12 +1507,12 @@ export default function AssetsModule() {
|
||||
>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-2">
|
||||
{isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />}
|
||||
<Blur>{cust.customer}</Blur>
|
||||
{cust.customer}
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span>
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{cust.manager}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{cust.manager}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` }); }}>{cust.t4_5}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` }); }}>{cust.t4_5c}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18}</td>
|
||||
@@ -1530,7 +1527,7 @@ export default function AssetsModule() {
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">客户详情</div>
|
||||
<div className="text-sm font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
|
||||
<div className="text-sm font-bold text-gray-700">{cust.customer}</div>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">主要车型</div>
|
||||
@@ -1540,7 +1537,7 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">业务经理</div>
|
||||
<div className="text-sm font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
|
||||
<div className="text-sm font-bold text-gray-700">{cust.manager}</div>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">资产占比</div>
|
||||
@@ -1574,7 +1571,7 @@ export default function AssetsModule() {
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-gray-800 text-sm"><Blur>{cust.customer}</Blur></span>
|
||||
<span className="font-bold text-gray-800 text-sm">{cust.customer}</span>
|
||||
<span className="text-[10px] text-emerald-600 font-medium">{cust.region}区域</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1592,7 +1589,7 @@ export default function AssetsModule() {
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">客户详情</div>
|
||||
<div className="text-[10px] font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
|
||||
<div className="text-[10px] font-bold text-gray-700">{cust.customer}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">主要车型</div>
|
||||
@@ -1602,7 +1599,7 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">业务经理</div>
|
||||
<div className="text-xs font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
|
||||
<div className="text-xs font-bold text-gray-700">{cust.manager}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">资产占比</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import { SearchSelect } from '../../../components/SearchSelect';
|
||||
import type { WeeklyDetailItem } from '../api';
|
||||
import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
|
||||
@@ -167,8 +166,8 @@ export function VehicleDetailModal({
|
||||
<tbody className="text-[11px]">
|
||||
{filteredModalWeeklyDetail.map((v, i) => (
|
||||
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center">{v.plate_number}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.customer_name || '—'}</td>
|
||||
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -215,12 +214,12 @@ export function VehicleDetailModal({
|
||||
{showPlateNumbers.source === 'customer' ? (
|
||||
<>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-medium text-gray-700">{v.customerManager || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]">{v.subjectOrg || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800">{v.customerName || '—'}</td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
|
||||
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
|
||||
@@ -233,13 +232,13 @@ export function VehicleDetailModal({
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
|
||||
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
|
||||
<td className="p-2 text-gray-500 text-[10px]">{v.orgName || '—'}</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td>
|
||||
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center">{v.customerName || '—'}</td>
|
||||
)}
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { downloadExcelAoa } from '../common/download-xls';
|
||||
import { exportAoaSheet } from '../../../../shared/xlsx';
|
||||
import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton';
|
||||
import {
|
||||
SOURCE_LABEL,
|
||||
@@ -3241,7 +3241,7 @@ function HostDailyView({
|
||||
|
||||
const fileDateStr = `${startDate.replace(/[/]/g, '')}-${endDate.replace(/[/]/g, '')}`;
|
||||
const fleetName = fleetType === 'all' ? '全部车辆' : fleetType === 'own' ? '羚牛车辆' : '外部车辆';
|
||||
downloadExcelAoa(aoa, `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, '每日加氢明细');
|
||||
exportAoaSheet(aoa, `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, '每日加氢明细');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* OneOS 表格下载统一出口:产物一律 .xlsx(禁止 CSV 作为默认/模板路径)。
|
||||
* 上传可另兼容 .xls / 过渡期 .csv;本模块只负责写出 Excel。
|
||||
*/
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
/** @param {string} [name] */
|
||||
export function ensureXlsxFilename(name) {
|
||||
const raw = String(name || 'export').trim() || 'export';
|
||||
const base = raw.replace(/\.(csv|xls|xlsx)$/i, '');
|
||||
return `${base}.xlsx`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown[][]} aoa
|
||||
* @param {string} filename
|
||||
* @param {string} [sheetName]
|
||||
*/
|
||||
export function downloadExcelAoa(aoa, filename, sheetName = 'Sheet1') {
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa || []);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName.slice(0, 31) || 'Sheet1');
|
||||
XLSX.writeFile(wb, ensureXlsxFilename(filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>[]} rows
|
||||
* @param {string} filename
|
||||
* @param {string} [sheetName]
|
||||
*/
|
||||
export function downloadExcel(rows, filename, sheetName = 'Sheet1') {
|
||||
const ws = XLSX.utils.json_to_sheet(rows || []);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName.slice(0, 31) || 'Sheet1');
|
||||
XLSX.writeFile(wb, ensureXlsxFilename(filename));
|
||||
}
|
||||
|
||||
/** @deprecated 别名,写出已是 .xlsx */
|
||||
export const downloadXlsAoa = downloadExcelAoa;
|
||||
/** @deprecated 别名,写出已是 .xlsx */
|
||||
export const downloadXls = downloadExcel;
|
||||
@@ -1,12 +0,0 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export function downloadExcelAoa(
|
||||
rows: Array<Array<string | number | boolean | null | undefined>>,
|
||||
fileName: string,
|
||||
sheetName: string,
|
||||
) {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const sheet = XLSX.utils.aoa_to_sheet(rows);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, sheetName.slice(0, 31));
|
||||
XLSX.writeFile(workbook, fileName);
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Download, RefreshCw, Truck } from "lucide-react";
|
||||
import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls";
|
||||
import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
|
||||
import { formatFixed } from "../model/display-format";
|
||||
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "../api";
|
||||
import { downloadExcelAoa } from "../common/prototype-download";
|
||||
import { exportAoaSheet } from "../../../../shared/xlsx";
|
||||
import "./real-daily-mobile.css";
|
||||
import type {
|
||||
H2BiDailyResponse,
|
||||
@@ -13,11 +14,6 @@ import type {
|
||||
H2BiVehicleScope,
|
||||
} from "../types";
|
||||
|
||||
const format = (value: number, digits = 2) =>
|
||||
value.toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
const toScope = (scope: "all" | "own" | "external"): H2BiVehicleScope =>
|
||||
scope === "own" ? "lingniu" : scope;
|
||||
const sourceLabel = (source: unknown) => {
|
||||
@@ -226,7 +222,7 @@ export function PrototypeRealDailyView({
|
||||
};
|
||||
const exportRows = () => {
|
||||
if (!daily) return;
|
||||
downloadExcelAoa(
|
||||
exportAoaSheet(
|
||||
dailySummaryRows(daily),
|
||||
`每日加氢日期汇总_${startDate}_${endDate}.xlsx`,
|
||||
"日期汇总",
|
||||
@@ -342,7 +338,7 @@ export function PrototypeRealDailyView({
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">区间加氢量</div>
|
||||
<div className="ehb-daily-kpi-val">
|
||||
{format(daily?.kpis.totalKg ?? 0)} <small>Kg</small>
|
||||
{formatFixed(daily?.kpis.totalKg ?? 0)} <small>Kg</small>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-sub">
|
||||
{startDate} 至 {endDate}
|
||||
@@ -351,7 +347,7 @@ export function PrototypeRealDailyView({
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">区间成本</div>
|
||||
<div className="ehb-daily-kpi-val">
|
||||
¥{format(daily?.kpis.totalCost ?? 0)}
|
||||
¥{formatFixed(daily?.kpis.totalCost ?? 0)}
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-sub">真实成本台账汇总</div>
|
||||
</div>
|
||||
@@ -359,7 +355,7 @@ export function PrototypeRealDailyView({
|
||||
<div className="ehb-daily-kpi-title">有效天数</div>
|
||||
<div className="ehb-daily-kpi-val">{daily?.kpis.activeDays ?? 0}</div>
|
||||
<div className="ehb-daily-kpi-sub">
|
||||
日均 {format(daily?.kpis.averageDailyKg ?? 0)} Kg
|
||||
日均 {formatFixed(daily?.kpis.averageDailyKg ?? 0)} Kg
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-card">
|
||||
@@ -395,12 +391,12 @@ export function PrototypeRealDailyView({
|
||||
<div className="ehb-daily-summary-pills">
|
||||
<div className="ehb-daily-pill-item">
|
||||
<span>峰值日</span>
|
||||
<strong>{peak ? `${peak.date} ${format(peak.kg)} Kg` : "—"}</strong>
|
||||
<strong>{peak ? `${peak.date} ${formatFixed(peak.kg)} Kg` : "—"}</strong>
|
||||
</div>
|
||||
<div className="ehb-daily-pill-item">
|
||||
<span>低谷日</span>
|
||||
<strong>
|
||||
{trough ? `${trough.date} ${format(trough.kg)} Kg` : "—"}
|
||||
{trough ? `${trough.date} ${formatFixed(trough.kg)} Kg` : "—"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ehb-daily-pill-item">
|
||||
@@ -416,7 +412,7 @@ export function PrototypeRealDailyView({
|
||||
}}
|
||||
>
|
||||
<span className="ehb-daily-avg-label">
|
||||
均值 {format(averageKg)} Kg
|
||||
均值 {formatFixed(averageKg)} Kg
|
||||
</span>
|
||||
</div>
|
||||
{trend.map((row) => {
|
||||
@@ -430,7 +426,7 @@ export function PrototypeRealDailyView({
|
||||
key={row.date}
|
||||
className="ehb-daily-bar-col"
|
||||
onClick={() => openDate(row.date, true)}
|
||||
title={`${row.date} 加氢总量 ${format(row.kg)} Kg;点击展开并定位当日明细`}
|
||||
title={`${row.date} 加氢总量 ${formatFixed(row.kg)} Kg;点击展开并定位当日明细`}
|
||||
aria-label={`展开并定位${row.date}当日加氢明细`}
|
||||
>
|
||||
<div
|
||||
@@ -539,8 +535,8 @@ export function PrototypeRealDailyView({
|
||||
<tr style={{ background: "#f8fafc", fontWeight: 700 }}>
|
||||
<td>合计</td>
|
||||
<td />
|
||||
<td>{format(daily?.kpis.totalKg ?? 0)}</td>
|
||||
<td>¥{format(daily?.kpis.totalCost ?? 0)}</td>
|
||||
<td>{formatFixed(daily?.kpis.totalKg ?? 0)}</td>
|
||||
<td>¥{formatFixed(daily?.kpis.totalCost ?? 0)}</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{(daily?.days ?? []).map((day) => {
|
||||
@@ -567,9 +563,9 @@ export function PrototypeRealDailyView({
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(day.kg)}</td>
|
||||
<td>{formatFixed(day.kg)}</td>
|
||||
<td>
|
||||
<strong className="ehb-daily-cost">{format(day.cost)}</strong>
|
||||
<strong className="ehb-daily-cost">{formatFixed(day.cost)}</strong>
|
||||
<small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small>
|
||||
</td>
|
||||
<td>暂无来源</td>
|
||||
@@ -597,8 +593,8 @@ export function PrototypeRealDailyView({
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(station.kg)}</td>
|
||||
<td>¥{format(station.cost)}</td>
|
||||
<td>{formatFixed(station.kg)}</td>
|
||||
<td>¥{formatFixed(station.cost)}</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{stationOpen && station.customers.length === 0 ? <DailyBranchState
|
||||
@@ -636,8 +632,8 @@ export function PrototypeRealDailyView({
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(customer.kg)}</td>
|
||||
<td>¥{format(customer.cost)}</td>
|
||||
<td>{formatFixed(customer.kg)}</td>
|
||||
<td>¥{formatFixed(customer.cost)}</td>
|
||||
<td>点击查看真实流水</td>
|
||||
</tr>
|
||||
{customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState
|
||||
@@ -672,15 +668,15 @@ export function PrototypeRealDailyView({
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{format(
|
||||
{formatFixed(
|
||||
Number(record.unitPrice ?? 0),
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{format(Number(record.kg ?? 0))}
|
||||
{formatFixed(Number(record.kg ?? 0))}
|
||||
</td>
|
||||
<td>
|
||||
¥{format(Number(record.cost ?? 0))}
|
||||
¥{formatFixed(Number(record.cost ?? 0))}
|
||||
</td>
|
||||
<td>暂无预充值余额</td>
|
||||
</tr>
|
||||
|
||||
@@ -4,8 +4,9 @@ import { createPortal } from "react-dom";
|
||||
import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react";
|
||||
import { MobileListFullscreenButton } from "../common/MobileListFullscreenButton";
|
||||
import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "../api";
|
||||
import { downloadExcelAoa } from "../common/prototype-download";
|
||||
import { exportAoaSheet } from "../../../../shared/xlsx";
|
||||
import { bearingLabels } from "../model/bearing-labels";
|
||||
import { formatFixed } from "../model/display-format";
|
||||
import type {
|
||||
H2BiDrillGroupBy,
|
||||
H2BiDrillGroupRow,
|
||||
@@ -79,11 +80,6 @@ type DrillState = Pick<
|
||||
stationName?: string;
|
||||
};
|
||||
|
||||
const formatNumber = (value: unknown, digits = 2) =>
|
||||
Number(value ?? 0).toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
const fleetScope = (value: "all" | "own" | "external"): H2BiVehicleScope =>
|
||||
value === "own" ? "lingniu" : value;
|
||||
const verifyLabel = (status: unknown) => {
|
||||
@@ -461,7 +457,7 @@ function ExpandedChildren({ data, state, query, kind, label, depth = 1 }: {
|
||||
function ExpandedMetrics({ row, kind, label, amountScope }: {
|
||||
row: { kg?: unknown; cost?: unknown; revenue?: unknown }; kind: DrillKind; label: string; amountScope: H2BiAmountScope;
|
||||
}) {
|
||||
const cell = (value: unknown, money = false) => <td style={{textAlign:"right"}}>{money ? "¥" : ""}{formatNumber(value)}</td>;
|
||||
const cell = (value: unknown, money = false) => <td style={{textAlign:"right"}}>{money ? "¥" : ""}{formatFixed(value)}</td>;
|
||||
if (label === "加氢利润") return <>{cell(row.revenue, true)}{cell(row.cost, true)}{cell(Number(row.revenue ?? 0) - Number(row.cost ?? 0), true)}</>;
|
||||
if (kind === "customer") return <>{cell(row.cost, true)}{cell(row.revenue, true)}<td>未接入</td><td>未接入</td></>;
|
||||
return <>{cell(row.kg)}{cell(amountScope === "customer" ? row.revenue : row.cost, true)}{label === "本日加氢" || label === "本月加氢" ? <td style={{textAlign:"right"}}>—</td> : null}</>;
|
||||
@@ -481,7 +477,7 @@ function ExpandedChild({ row, state, query, kind, label, depth }: {
|
||||
<button type="button" className="ehb-inline-child-toggle" aria-expanded={expanded} aria-label={`${expanded ? "收起" : "展开"}${row.name}`} onClick={() => { setExpanded(!expanded); setPage(1); }}>{expanded ? "▾" : "▸"}</button>
|
||||
<span>{row.name}</span>
|
||||
</div></td>
|
||||
<td>{titleFor(state.level)}</td><td><BearingTags row={row} /></td><td>账本汇总</td><td>汇总</td><td style={{textAlign:"right"}}>{formatNumber(row.recordCount, 0)} 笔</td>
|
||||
<td>{titleFor(state.level)}</td><td><BearingTags row={row} /></td><td>账本汇总</td><td>汇总</td><td style={{textAlign:"right"}}>{formatFixed(row.recordCount, 0)} 笔</td>
|
||||
<ExpandedMetrics row={row} kind={kind} label={label} amountScope={state.amountScope} />
|
||||
</tr>
|
||||
{expanded ? live.loading || live.error || !live.data ? <tr className="ehb-inline-child-status"><td colSpan={columns}>{live.error ? `加载失败:${live.error},请收起后重试` : "正在加载子级数据…"}</td></tr> : <>
|
||||
@@ -543,8 +539,8 @@ function GroupTable({
|
||||
{region ? <td>{index + 1}</td> : null}
|
||||
<td style={{ fontWeight: 650 }}>{row.name}</td>
|
||||
<td>{stationCustomer ? <span className={`ehb-tag ${row.lingniuKg > 0 ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}>{row.lingniuKg > 0 ? "羚牛车辆" : "外部车辆"}</span> : row.province || "未归属"}</td>
|
||||
{monthMetric === "加氢量" ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.lingniuKg)}</td><td className="ehb-key-external" style={{ textAlign: "right" }}>{formatNumber(row.externalKg)}</td></> : null}
|
||||
<td className={monthMetric === "客户收入" ? "ehb-key-income" : monthMetric === "成本支出" ? "ehb-key-cost" : "ehb-key-volume"} style={{ textAlign: "right" }}>{monthMetric === "客户收入" ? `¥${formatNumber(row.revenue)}` : monthMetric === "成本支出" ? `¥${formatNumber(row.cost)}` : formatNumber(row.kg)}</td>
|
||||
{monthMetric === "加氢量" ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.lingniuKg)}</td><td className="ehb-key-external" style={{ textAlign: "right" }}>{formatFixed(row.externalKg)}</td></> : null}
|
||||
<td className={monthMetric === "客户收入" ? "ehb-key-income" : monthMetric === "成本支出" ? "ehb-key-cost" : "ehb-key-volume"} style={{ textAlign: "right" }}>{monthMetric === "客户收入" ? `¥${formatFixed(row.revenue)}` : monthMetric === "成本支出" ? `¥${formatFixed(row.cost)}` : formatFixed(row.kg)}</td>
|
||||
{stationCustomer || region ? <td style={{ textAlign: "right" }}>{totalKg > 0 ? `${((Number(row.kg) / totalKg) * 100).toFixed(1)}%` : "0.0%"}</td> : null}
|
||||
</tr>
|
||||
))}</tbody>
|
||||
@@ -552,10 +548,10 @@ function GroupTable({
|
||||
);
|
||||
}
|
||||
if (kind === "station" && state.level === "date") {
|
||||
return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th>日期</th><th style={{ textAlign: "right" }}>加氢笔数</th><th style={{ textAlign: "right" }}>加氢量 (Kg)</th><th>较前日</th><th style={{ textAlign: "right" }}>氢费收入 (元)</th><th style={{ textAlign: "right" }}>平均单价 (元/Kg)</th></tr></thead><tbody>{data.groups.map((row, index) => { const previous = Number(data.groups[index + 1]?.kg || 0); const change = previous > 0 ? ((Number(row.kg) - previous) / previous) * 100 : null; return <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle">▸</span>📅 {row.name}</td><td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} 笔</td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.kg)}</td><td className={change !== null && change >= 0 ? "ehb-day-change is-up" : "ehb-day-change is-down"}>{change === null ? "—" : `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td style={{ textAlign: "right" }}>¥{Number(row.kg) > 0 ? (Number(row.revenue) / Number(row.kg)).toFixed(2) : "0.00"}</td></tr>; })}</tbody></table>;
|
||||
return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th>日期</th><th style={{ textAlign: "right" }}>加氢笔数</th><th style={{ textAlign: "right" }}>加氢量 (Kg)</th><th>较前日</th><th style={{ textAlign: "right" }}>氢费收入 (元)</th><th style={{ textAlign: "right" }}>平均单价 (元/Kg)</th></tr></thead><tbody>{data.groups.map((row, index) => { const previous = Number(data.groups[index + 1]?.kg || 0); const change = previous > 0 ? ((Number(row.kg) - previous) / previous) * 100 : null; return <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle">▸</span>📅 {row.name}</td><td style={{ textAlign: "right" }}>{formatFixed(row.recordCount, 0)} 笔</td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.kg)}</td><td className={change !== null && change >= 0 ? "ehb-day-change is-up" : "ehb-day-change is-down"}>{change === null ? "—" : `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td style={{ textAlign: "right" }}>¥{Number(row.kg) > 0 ? (Number(row.revenue) / Number(row.kg)).toFixed(2) : "0.00"}</td></tr>; })}</tbody></table>;
|
||||
}
|
||||
if (kind === "customer" && state.level === "date") {
|
||||
return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th>日期 / 车牌明细</th><th>加氢站</th><th>承担</th><th style={{ textAlign: "right" }}>加氢量 (Kg)</th><th style={{ textAlign: "right" }}>成本支出 (元)</th><th style={{ textAlign: "right" }}>应收 (元)</th><th>已收</th><th>未收</th></tr></thead><tbody>{data.groups.map((row) => <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle">▸</span>📅 {row.name}</td><td>—</td><td><span className="ehb-bearer-tag is-cust">客户</span></td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td>未接入</td><td>未接入</td></tr>)}</tbody></table>;
|
||||
return <table className="ehb-modal-table ehb-flat-drill-table"><thead><tr><th>日期 / 车牌明细</th><th>加氢站</th><th>承担</th><th style={{ textAlign: "right" }}>加氢量 (Kg)</th><th style={{ textAlign: "right" }}>成本支出 (元)</th><th style={{ textAlign: "right" }}>应收 (元)</th><th>已收</th><th>未收</th></tr></thead><tbody>{data.groups.map((row) => <tr key={rowIdentity(row)} className="ehb-drill-group-row ehb-drill-group-row--date" onClick={() => onOpen(row)} style={{ cursor: "pointer" }}><td><span className="ehb-tree-toggle">▸</span>📅 {row.name}</td><td>—</td><td><span className="ehb-bearer-tag is-cust">客户</span></td><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td>未接入</td><td>未接入</td></tr>)}</tbody></table>;
|
||||
}
|
||||
if (state.level === "record") {
|
||||
return (
|
||||
@@ -628,9 +624,9 @@ function GroupTable({
|
||||
<td style={{ textAlign: "right" }}>
|
||||
1 笔
|
||||
</td>
|
||||
<td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(record.kg)}</td>
|
||||
<td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(record.kg)}</td>
|
||||
<td className="ehb-key-income" style={{ textAlign: "right" }}>
|
||||
¥{formatNumber(amountFor(record))}
|
||||
¥{formatFixed(amountFor(record))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -748,7 +744,7 @@ function GroupTable({
|
||||
</span>
|
||||
) : state.level === "station" ? (
|
||||
<span className="ehb-tree-node-sub">
|
||||
覆盖 {formatNumber(row.customerCount, 0)} 家客户
|
||||
覆盖 {formatFixed(row.customerCount, 0)} 家客户
|
||||
</span>
|
||||
) : (
|
||||
<span className="ehb-tree-node-sub">聚合</span>
|
||||
@@ -767,8 +763,8 @@ function GroupTable({
|
||||
{state.level === "station" ? "汇总" : "点击展开"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} 笔</td>
|
||||
{isProfit ? <><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}>¥{formatNumber(Number(row.revenue) - Number(row.cost))}</td></> : isMonth || isDay ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatNumber(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(amountFor(row))}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}>—</td></> : isCustomerBill ? <><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatNumber(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(row.revenue)}</td><td>未接入</td><td>未接入</td></> : <><td className="ehb-key-volume" style={{ textAlign: "right", fontWeight: 700 }}>{formatNumber(row.kg)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatNumber(amountFor(row))}</td></>}
|
||||
<td style={{ textAlign: "right" }}>{formatFixed(row.recordCount, 0)} 笔</td>
|
||||
{isProfit ? <><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(row.cost)}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}>¥{formatFixed(Number(row.revenue) - Number(row.cost))}</td></> : isMonth || isDay ? <><td className="ehb-key-volume" style={{ textAlign: "right" }}>{formatFixed(row.kg)}</td><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(amountFor(row))}</td><td className="ehb-key-profit" style={{ textAlign: "right" }}>—</td></> : isCustomerBill ? <><td className="ehb-key-cost" style={{ textAlign: "right" }}>¥{formatFixed(row.cost)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(row.revenue)}</td><td>未接入</td><td>未接入</td></> : <><td className="ehb-key-volume" style={{ textAlign: "right", fontWeight: 700 }}>{formatFixed(row.kg)}</td><td className="ehb-key-income" style={{ textAlign: "right" }}>¥{formatFixed(amountFor(row))}</td></>}
|
||||
</tr>
|
||||
{expanded && !expandedLoading && !expandedError && expandedData ? <ExpandedChildren data={expandedData} state={nextState(state, row)} query={query} kind={kind} label={label} /> : null}
|
||||
{expanded ? (
|
||||
@@ -810,17 +806,17 @@ function DrillSummaryCards({ kind, label: labelInput, data }: { kind: DrillKind;
|
||||
const externalKg = groups.reduce((sum, row) => sum + Number(row.externalKg || 0), 0);
|
||||
const monthMetric = label.match(/^\d{4}年\d{1,2}月(加氢量|客户收入|成本支出)$/)?.[1];
|
||||
let cards: Array<[string, string, string?]>;
|
||||
if (label === "加氢利润") cards = [["收入合计", `¥${formatNumber(revenue)}`, "income"], ["成本合计", `¥${formatNumber(cost)}`, "cost"], ["加氢利润", `¥${formatNumber(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (label === "本月加氢") cards = [["本月加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatNumber(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (label === "本日加氢") cards = [["本日加氢量", `${formatNumber(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatNumber(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatNumber(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatNumber(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatNumber(kg)} Kg`], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatNumber(revenue)}`, "income"], ["站均收入", `¥${formatNumber(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (monthMetric === "成本支出") cards = [["成本支出合计", `¥${formatNumber(cost)}`, "cost"], ["站均成本", `¥${formatNumber(cost / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (/^加氢站客户量:/.test(label)) cards = [["加氢站", label.replace(/^加氢站客户量:/, "")], ["羚牛车辆加氢总量", `${formatNumber(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatNumber(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatNumber(kg)} Kg`]];
|
||||
else if (/^区域(?:市|省):/.test(label)) cards = [["区域", label.replace(/^区域(?:市|省):/, "")], ["加氢总量", `${formatNumber(kg / 1000)} T`, "volume"], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (kind === "station") cards = [["加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["氢费收入", `¥${formatNumber(revenue / 10000)} 万元`, "income"], ["平均单价", `¥${kg > 0 ? formatNumber(revenue / kg) : "0.00"} /Kg`], ["加氢笔数", `${formatNumber(summary?.recordCount ?? 0, 0)} 笔`]];
|
||||
else if (kind === "customer") cards = [["承担方", "客户承担"], ["加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatNumber(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatNumber(revenue)} 元`, "income"], ["已收", "未接入"], ["未收", "未接入"]];
|
||||
else cards = [["数据归集总量", `${formatNumber(kg / 1000)} T`, "volume"], ["数据总金额", `¥${formatNumber(cost / 10000)} 万元`, "income"], ["覆盖加氢站数", `${stationCount} 站`], ["来源记录完整度", `${summary?.recordCount ? formatNumber((Number(summary.traceableRecordCount) / Number(summary.recordCount)) * 100, 0) : "0"}%(含账本来源字段)`, "cost"]];
|
||||
if (label === "加氢利润") cards = [["收入合计", `¥${formatFixed(revenue)}`, "income"], ["成本合计", `¥${formatFixed(cost)}`, "cost"], ["加氢利润", `¥${formatFixed(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (label === "本月加氢") cards = [["本月加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (label === "本日加氢") cards = [["本日加氢量", `${formatFixed(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatFixed(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatFixed(revenue)}`, "income"], ["站均收入", `¥${formatFixed(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (monthMetric === "成本支出") cards = [["成本支出合计", `¥${formatFixed(cost)}`, "cost"], ["站均成本", `¥${formatFixed(cost / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (/^加氢站客户量:/.test(label)) cards = [["加氢站", label.replace(/^加氢站客户量:/, "")], ["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`]];
|
||||
else if (/^区域(?:市|省):/.test(label)) cards = [["区域", label.replace(/^区域(?:市|省):/, "")], ["加氢总量", `${formatFixed(kg / 1000)} T`, "volume"], ["覆盖加氢站数", `${stationCount} 站`]];
|
||||
else if (kind === "station") cards = [["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["氢费收入", `¥${formatFixed(revenue / 10000)} 万元`, "income"], ["平均单价", `¥${kg > 0 ? formatFixed(revenue / kg) : "0.00"} /Kg`], ["加氢笔数", `${formatFixed(summary?.recordCount ?? 0, 0)} 笔`]];
|
||||
else if (kind === "customer") cards = [["承担方", "客户承担"], ["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatFixed(revenue)} 元`, "income"], ["已收", "未接入"], ["未收", "未接入"]];
|
||||
else cards = [["数据归集总量", `${formatFixed(kg / 1000)} T`, "volume"], ["数据总金额", `¥${formatFixed(cost / 10000)} 万元`, "income"], ["覆盖加氢站数", `${stationCount} 站`], ["来源记录完整度", `${summary?.recordCount ? formatFixed((Number(summary.traceableRecordCount) / Number(summary.recordCount)) * 100, 0) : "0"}%(含账本来源字段)`, "cost"]];
|
||||
return <div className="ehb-modal-meta-bar">{cards.map(([title, value, tone]) => <div className="ehb-modal-meta-item" key={title}><span className="ehb-modal-meta-label">{title}</span><span className={`ehb-modal-meta-val ${tone ? `ehb-summary-${tone}` : ""}`}>{value}</span></div>)}</div>;
|
||||
}
|
||||
|
||||
@@ -1082,7 +1078,7 @@ export function PrototypeDrillModal({
|
||||
rows.push([row.name, row.kg, row.cost, row.revenue]),
|
||||
);
|
||||
}
|
||||
downloadExcelAoa(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透");
|
||||
exportAoaSheet(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透");
|
||||
};
|
||||
const exportCurrent = () => {
|
||||
if (data) exportData(data, `第${page}页`);
|
||||
@@ -1244,8 +1240,8 @@ export function PrototypeDrillModal({
|
||||
{cleanLabel === "加氢利润" && customerBearingScope ? (
|
||||
<div className="ehb-modal-hint-text" style={{ marginBottom: 10 }}>
|
||||
利润口径:客户承担订单的对客总价 ¥
|
||||
{formatNumber(live.data?.summary.revenue)} − 成本总价 ¥
|
||||
{formatNumber(live.data?.summary.cost)} = ¥{formatNumber(profit)}
|
||||
{formatFixed(live.data?.summary.revenue)} − 成本总价 ¥
|
||||
{formatFixed(live.data?.summary.cost)} = ¥{formatFixed(profit)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ehb-real-drill-primary-actions">
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { finiteNumber, formatNumber, formatScaled } from "./display-format";
|
||||
import { formatFixed, isFiniteNumberValue } from "./display-format";
|
||||
|
||||
test("能源看板格式化边界区分真实零值与不可用值", () => {
|
||||
test("默认口径:缺失值按 0 展示,与氢能明细表既有行为一致", () => {
|
||||
assert.equal(formatFixed(0), "0.00");
|
||||
assert.equal(formatFixed(null), "0.00");
|
||||
assert.equal(formatFixed(undefined), "0.00");
|
||||
assert.equal(formatFixed(1234.5), "1,234.50");
|
||||
assert.equal(formatFixed(1234.5, 0), "1,235");
|
||||
});
|
||||
|
||||
test("显式开启 blankForMissing 时区分真实零值与不可用值", () => {
|
||||
for (const value of [null, undefined, NaN, Infinity, -Infinity, "0"]) {
|
||||
assert.equal(finiteNumber(value), null);
|
||||
assert.equal(formatNumber(value), "—");
|
||||
assert.equal(formatScaled(value, 1000), "—");
|
||||
assert.equal(isFiniteNumberValue(value), false);
|
||||
assert.equal(formatFixed(value, 2, { blankForMissing: true }), "—");
|
||||
}
|
||||
assert.equal(formatNumber(0), "0.00");
|
||||
assert.equal(formatScaled(0, 1000), "0.00");
|
||||
assert.equal(formatFixed(0, 2, { blankForMissing: true }), "0.00");
|
||||
});
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
export const finiteNumber = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
/**
|
||||
* 氢能看板的数值格式化。
|
||||
*
|
||||
* 此前两个下钻视图各自复制了一份同样的实现(共 42 处调用),另有一份
|
||||
* 无人使用的 "—" 版本只被自己的测试引用。这里收敛为唯一实现,并把
|
||||
* "缺失值是否显示为 0" 变成显式选项,而不是靠不同的函数名区分口径。
|
||||
*/
|
||||
|
||||
export const formatNumber = (value: unknown, digits = 2): string => {
|
||||
const safe = finiteNumber(value);
|
||||
return safe === null
|
||||
? "—"
|
||||
: safe.toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
};
|
||||
export interface FormatOptions {
|
||||
/**
|
||||
* 缺失或不可用(非有限数)时返回 "—" 而不是 0。
|
||||
* 默认 false:明细表按 0 展示,与既有氢能账本口径一致;
|
||||
* 需要区分"真实零值"与"接口未返回"时显式开启。
|
||||
*/
|
||||
blankForMissing?: boolean;
|
||||
}
|
||||
|
||||
export const formatScaled = (value: unknown, divisor: number, digits = 2) => {
|
||||
const safe = finiteNumber(value);
|
||||
return safe === null ? "—" : formatNumber(safe / divisor, digits);
|
||||
};
|
||||
/** 该值本身是否为可参与计算的有限数字(不把 null / undefined / "0" 视为数字)。 */
|
||||
export function isFiniteNumberValue(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定小数位的千分位文案。
|
||||
* 默认路径与既有实现逐字一致:`Number(value ?? 0)` 后按固定小数位格式化。
|
||||
*/
|
||||
export function formatFixed(value: unknown, digits = 2, options: FormatOptions = {}): string {
|
||||
if (options.blankForMissing && !isFiniteNumberValue(value)) return '—';
|
||||
return Number(value ?? 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ChevronDown, ChevronUp, Download, RefreshCw } from 'lucide-react';
|
||||
import { downloadExcelAoa } from '../common/download-xls';
|
||||
import { exportAoaSheet } from '../../../../shared/xlsx';
|
||||
import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton';
|
||||
import {
|
||||
SPOT_PAY_METHOD_LABEL,
|
||||
@@ -364,7 +364,7 @@ export const StationDailyDetailView: React.FC<{
|
||||
r.amountYuan,
|
||||
]),
|
||||
];
|
||||
downloadExcelAoa(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证');
|
||||
exportAoaSheet(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证');
|
||||
} catch (reason) {
|
||||
if (!controller.signal.aborted) setExportError(reason instanceof Error ? reason.message : '导出失败,请重试');
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types';
|
||||
import { createHeatmapMap, loadAmap, type AmapInstance } from '../../shared/amap';
|
||||
|
||||
type Props = {
|
||||
config: AmapConfig;
|
||||
@@ -10,19 +11,6 @@ type Props = {
|
||||
onMapClick: (longitude: number, latitude: number) => void;
|
||||
};
|
||||
|
||||
type AmapInstance = {
|
||||
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
|
||||
HeatMap: new (map: any, options: Record<string, unknown>) => any;
|
||||
ToolBar: new (options?: Record<string, unknown>) => any;
|
||||
Scale: new (options?: Record<string, unknown>) => any;
|
||||
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_AMapSecurityConfig?: { securityJsCode: string };
|
||||
}
|
||||
}
|
||||
|
||||
function getBounds(points: HydrogenHeatmapPoint[]) {
|
||||
if (!points.length) return null;
|
||||
@@ -62,36 +50,9 @@ export default function HydrogenAmapCanvas({ config, points, max, metric, focusQ
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
|
||||
const loaderModule = await import('@amap/amap-jsapi-loader');
|
||||
const AMap = await loaderModule.default.load({
|
||||
key: config.key,
|
||||
version: '2.0',
|
||||
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
|
||||
}) as unknown as AmapInstance;
|
||||
const AMap = await loadAmap(config);
|
||||
if (cancelled || !container) return;
|
||||
const map = new AMap.Map(container, {
|
||||
viewMode: '2D',
|
||||
zoom: 5,
|
||||
center: [105.4, 34.4],
|
||||
mapStyle: 'amap://styles/whitesmoke',
|
||||
resizeEnable: true,
|
||||
showLabel: true,
|
||||
});
|
||||
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
|
||||
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
|
||||
const heatmap = new AMap.HeatMap(map, {
|
||||
radius: 34,
|
||||
opacity: [0.14, 0.84],
|
||||
gradient: {
|
||||
0.1: '#2563eb',
|
||||
0.3: '#0891b2',
|
||||
0.5: '#16a34a',
|
||||
0.68: '#eab308',
|
||||
0.84: '#f97316',
|
||||
1: '#dc2626',
|
||||
},
|
||||
});
|
||||
const { map, heatmap } = createHeatmapMap(AMap, container, { radius: 34, opacity: [0.14, 0.84] });
|
||||
map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()));
|
||||
mapRef.current = map;
|
||||
heatmapRef.current = heatmap;
|
||||
|
||||
@@ -5,6 +5,7 @@ import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel';
|
||||
import HydrogenHeatmapFilters from './HydrogenHeatmapFilters';
|
||||
import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api';
|
||||
import { recentDayRange } from '../../shared/date-range';
|
||||
import { HEATMAP_LEGEND_GRADIENT } from '../../shared/amap';
|
||||
import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types';
|
||||
|
||||
// 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。
|
||||
@@ -207,7 +208,7 @@ export default function HydrogenHeatmapModule() {
|
||||
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[300px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
|
||||
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metricName(metric)}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}</div>
|
||||
<p className="mt-1 text-[10px] leading-4 text-slate-500">{metricDescription(metric)}</p>
|
||||
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
|
||||
<div className="mt-2.5 h-2.5 rounded-full" style={{ background: HEATMAP_LEGEND_GRADIENT }} />
|
||||
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span>相对较低</span><span>相对较高</span></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from 'recharts';
|
||||
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
interface Props {
|
||||
vehicle: MonitoringVehicle | null;
|
||||
@@ -160,7 +159,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
|
||||
<span className="text-sm font-black text-slate-900 font-mono truncate">{vehicle.plate}</span>
|
||||
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
|
||||
{vehicle.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
@@ -169,7 +168,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
{vehicle.rentStatus || ''}
|
||||
{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}
|
||||
{vehicle.customer ? ` · ` : ''}
|
||||
{vehicle.customer && <Blur>{vehicle.customer}</Blur>}
|
||||
{vehicle.customer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { MileageReportGroup } from '../api';
|
||||
import {
|
||||
filterAndSortVehicles,
|
||||
@@ -167,7 +166,7 @@ export default function VehicleTable({ group }: { group: MileageReportGroup }) {
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs font-black text-slate-900">
|
||||
<BarChart3 size={15} className="text-blue-600" />
|
||||
<Blur>{trendVehicle.plate}</Blur>
|
||||
{trendVehicle.plate}
|
||||
<span className="font-bold text-slate-400">近7日里程</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[10px] font-bold text-slate-500">
|
||||
@@ -249,12 +248,12 @@ export default function VehicleTable({ group }: { group: MileageReportGroup }) {
|
||||
className={`grid cursor-pointer grid-cols-[112px_88px_minmax(140px,1fr)_82px_90px_80px_28px] items-center gap-3 px-3 py-2.5 text-[11px] font-bold outline-none transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 md:grid-cols-[110px_72px_100px_minmax(160px,1fr)_90px_100px_88px_32px] ${selected ? 'bg-blue-50/60' : ''}`}
|
||||
>
|
||||
<span className={`sticky left-3 z-10 -my-2.5 flex self-stretch flex-col justify-center font-mono font-black text-slate-800 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex-row md:items-center md:shadow-none ${frozenCellClass}`}>
|
||||
<Blur>{vehicle.plate}</Blur>
|
||||
{vehicle.plate}
|
||||
<span className={`mt-0.5 font-sans text-[9px] md:hidden ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
|
||||
</span>
|
||||
<span className={`sticky left-[110px] z-10 -my-2.5 hidden self-stretch items-center shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex ${frozenCellClass} ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
|
||||
<span className="truncate text-slate-500">{vehicle.department || vehicle.inventoryRegion || '未标注'}</span>
|
||||
<span className="truncate text-slate-500"><Blur>{vehicle.customer || '未绑定客户'}</Blur></span>
|
||||
<span className="truncate text-slate-500">{vehicle.customer || '未绑定客户'}</span>
|
||||
<span className={`text-right tabular-nums ${vehicle.mileageBand === 'HIGH' ? 'text-blue-600' : vehicle.mileageBand === 'INVENTORY' && vehicle.dailyMileage > 0 ? 'text-rose-600' : 'text-slate-700'}`}>
|
||||
{fmtKm(vehicle.dailyMileage)} km
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import Blur from '../../../../components/Blur';
|
||||
import type { MileageSourceGroup, MonitoringFilters, MonitoringStats, MonitoringVehicle } from '../../types';
|
||||
import { vehicleStatisticTime } from '../oneos-time';
|
||||
import { MILEAGE_SOURCE_META, vehicleSourceDisplay } from '../source-display';
|
||||
@@ -292,7 +291,7 @@ export default function FullscreenMonitoring({
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
|
||||
<div className="text-xs font-bold text-white">{v.plate}</div>
|
||||
<span
|
||||
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
||||
title={sourceDisplay.title}
|
||||
@@ -304,7 +303,7 @@ export default function FullscreenMonitoring({
|
||||
{statisticTime.label}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.customer || '-'}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.brand || '-'}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { Truck } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import Blur from '../../../../components/Blur';
|
||||
import type { MonitoringVehicle } from '../../types';
|
||||
import { vehicleStatisticTime } from '../oneos-time';
|
||||
import { vehicleSourceDisplay } from '../source-display';
|
||||
@@ -73,7 +72,7 @@ export default function VehicleList({
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plate}</Blur></span>
|
||||
<span className="text-xs font-black text-slate-900 font-mono">{v.plate}</span>
|
||||
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
||||
{v.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
@@ -89,12 +88,12 @@ export default function VehicleList({
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:hidden">
|
||||
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
|
||||
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||
<span className="text-[9px] font-bold text-slate-600 truncate">{v.customer || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden min-w-0 md:block">
|
||||
<div className="truncate text-xs font-bold text-slate-700"><Blur>{v.customer || '-'}</Blur></div>
|
||||
<div className="truncate text-xs font-bold text-slate-700">{v.customer || '-'}</div>
|
||||
<div className="mt-1 truncate text-[9px] font-medium text-slate-400">
|
||||
{[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { ArrowUpDown, Calendar, Search, Truck, X } from 'lucide-react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { TargetVehicle } from '../types';
|
||||
import {
|
||||
filterAndSortTargetVehicles,
|
||||
@@ -116,7 +115,7 @@ export default function AllVehiclesPanel({
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{vehicle.plateNumber}</Blur></span>
|
||||
<span className="text-xs font-black text-slate-900 font-mono">{vehicle.plateNumber}</span>
|
||||
<span className={`text-[8px] px-1 rounded ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
||||
{vehicle.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { ChevronDown, Maximize2, Truck } from 'lucide-react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { TargetSummary, TargetVehicle } from '../types';
|
||||
import { fmtDateLabel, fmtKm, fmtPercent, getTargetAssessment } from './model';
|
||||
|
||||
@@ -212,7 +211,7 @@ export default function TargetDetailPanel({
|
||||
{vehicles.slice(0, 5).map(vehicle => (
|
||||
<div key={vehicle.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{vehicle.plateNumber}</Blur></span>
|
||||
<span className="text-[10px] font-mono font-bold text-slate-700">{vehicle.plateNumber}</span>
|
||||
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
|
||||
在线
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { buildAoaSheet, writeWorkbook } from '../../shared/xlsx';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
|
||||
interface ExportContext {
|
||||
@@ -74,7 +75,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
}),
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(summaryData);
|
||||
const ws = buildAoaSheet(summaryData);
|
||||
|
||||
const numFixedCols = BASE_HEADERS.length;
|
||||
const wsCols: { wch: number }[] = [
|
||||
@@ -114,10 +115,8 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
}
|
||||
}
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '车辆汇总');
|
||||
|
||||
// 每日明细 sheet:保留原有格式
|
||||
const sheets: Array<{ name: string; sheet: XLSX.WorkSheet }> = [{ name: '车辆汇总', sheet: ws }];
|
||||
if (dayKeys.length > 0) {
|
||||
const detailHeaders = [
|
||||
'车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域',
|
||||
@@ -140,7 +139,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
v.totalKm != null ? v.totalKm : '',
|
||||
]),
|
||||
];
|
||||
const detailWs = XLSX.utils.aoa_to_sheet(detailData);
|
||||
const detailWs = buildAoaSheet(detailData);
|
||||
detailWs['!cols'] = [
|
||||
{ wch: 12 },
|
||||
{ wch: 16 },
|
||||
@@ -160,7 +159,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########';
|
||||
}
|
||||
}
|
||||
XLSX.utils.book_append_sheet(wb, detailWs, '每日明细');
|
||||
sheets.push({ name: '每日明细', sheet: detailWs });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -178,5 +177,5 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
? '统计时间'
|
||||
: isRange ? '区间' : '今日';
|
||||
const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
|
||||
XLSX.writeFile(wb, filename);
|
||||
writeWorkbook(sheets, filename);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2, ChevronRight
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchNotifications, updateNotification } from './api';
|
||||
import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
import SwapPreview from './SwapPreview';
|
||||
|
||||
interface Props {
|
||||
@@ -201,9 +200,9 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-1.5 text-xs min-w-0">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{rec.currentPlate}</Blur></span>
|
||||
<span className="font-mono font-bold text-slate-900">{rec.currentPlate}</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{rec.candidatePlate}</Blur></span>
|
||||
<span className="font-mono font-bold text-blue-700">{rec.candidatePlate}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 ${badge.cls}`}>
|
||||
@@ -216,7 +215,7 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 mb-0.5 truncate">
|
||||
{v.department && <span className="font-medium">{shortDept(v.department)}</span>}
|
||||
{v.manager && <span>{v.manager}</span>}
|
||||
<span className="text-slate-400 truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||
<span className="text-slate-400 truncate">{v.customer || '-'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-[10px] text-slate-400">
|
||||
@@ -281,9 +280,9 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
|
||||
</div>
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
<div className="text-xs text-slate-500">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{executeTarget.currentPlate}</Blur></span>
|
||||
<span className="font-mono font-bold text-slate-900">{executeTarget.currentPlate}</span>
|
||||
<span className="mx-1.5">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{executeTarget.candidatePlate}</Blur></span>
|
||||
<span className="font-mono font-bold text-blue-700">{executeTarget.candidatePlate}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1">执行后里程 (km, 可选)</label>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
import SwapPreview from './SwapPreview';
|
||||
|
||||
type SortKey = 'predicted' | 'current';
|
||||
@@ -83,7 +82,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
<div key={c.plateNumber} className={`rounded-xl border overflow-hidden bg-white ${blockedByOther ? 'border-slate-200 opacity-60' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
|
||||
<span className="text-xs font-black text-slate-900 font-mono">{c.plateNumber}</span>
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded flex items-center gap-0.5 ${c.isSameRegion ? 'bg-slate-100 text-slate-500' : 'bg-amber-50 text-amber-600'}`}>
|
||||
<MapPin size={9} />{c.region}{!c.isSameRegion && ' · 跨区'}
|
||||
</span>
|
||||
@@ -174,7 +173,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
{/* Header — same style as candidate header */}
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></span>
|
||||
<span className="text-xs font-black text-slate-900 font-mono">{v.plateNumber}</span>
|
||||
<span className="text-[9px] text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded flex items-center gap-0.5"><MapPin size={9} />{v.region}</span>
|
||||
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
|
||||
<span className="text-[9px] text-slate-300">{v.targetName}</span>
|
||||
@@ -189,7 +188,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
{v.department && <span><b className="text-slate-700">{v.department}</b></span>}
|
||||
{v.manager && <span><b className="text-slate-700">{v.manager}</b></span>}
|
||||
{(v.department || v.manager) && <span className="text-slate-200">|</span>}
|
||||
<span>客户 <b className="text-slate-700"><Blur>{v.customer || '-'}</Blur></b></span>
|
||||
<span>客户 <b className="text-slate-700">{v.customer || '-'}</b></span>
|
||||
<span>
|
||||
30日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km
|
||||
</span>
|
||||
@@ -264,7 +263,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
||||
<div className="mb-2.5 flex items-start gap-2 rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-800">
|
||||
<Lock size={12} className="mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
此车已干预替换为 <b className="font-mono"><Blur>{activeIntervention.plateNumber}</Blur></b>。如需更换方案,请先在该候选车处解除干预。
|
||||
此车已干预替换为 <b className="font-mono">{activeIntervention.plateNumber}</b>。如需更换方案,请先在该候选车处解除干预。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useMemo } from 'react';
|
||||
import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { SchedulingSuggestion } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
interface Props {
|
||||
suggestions: SchedulingSuggestion[];
|
||||
@@ -126,7 +125,7 @@ export default function SuggestionList({ suggestions, onSelect, selectMode = fal
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs font-black text-slate-900 font-mono">
|
||||
<Blur>{v.plateNumber}</Blur>
|
||||
{v.plateNumber}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400">{v.vehicleType}</span>
|
||||
<span className="text-[9px] text-slate-300">·</span>
|
||||
@@ -146,7 +145,7 @@ export default function SuggestionList({ suggestions, onSelect, selectMode = fal
|
||||
<div className="flex items-center gap-1.5 text-slate-400 truncate">
|
||||
{v.department && <span className="text-slate-500 font-medium">{v.department.replace('业务', '')}</span>}
|
||||
{v.manager && <span className="text-slate-500">{v.manager}</span>}
|
||||
<span className="truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||
<span className="truncate">{v.customer || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<span className="text-slate-500">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react';
|
||||
import { sendNotify, updateNotification } from './api';
|
||||
import type { SchedulingSuggestion, CandidateVehicle } from './types';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
interface Props {
|
||||
suggestion: SchedulingSuggestion;
|
||||
@@ -72,7 +71,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
|
||||
<div className="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{v.plateNumber}</Blur></div>
|
||||
<div className="text-lg font-black text-slate-900 font-mono">{v.plateNumber}</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">{v.vehicleType} · {v.targetName}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -82,7 +81,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
|
||||
<span><Blur>{v.customer || '-'}</Blur></span>
|
||||
<span>{v.customer || '-'}</span>
|
||||
<span>日均 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b></span>
|
||||
<span>完成 <b className={v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}</b></span>
|
||||
</div>
|
||||
@@ -99,7 +98,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
|
||||
<div className="bg-white rounded-2xl p-4 border border-emerald-300 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-lg font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></div>
|
||||
<div className="text-lg font-black text-slate-900 font-mono">{c.plateNumber}</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">{c.vehicleType} · {c.targetName || '库存'} · {c.region}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import type { BatchItem } from './model';
|
||||
|
||||
interface BatchConfirmModalProps {
|
||||
@@ -44,9 +43,9 @@ export default function BatchConfirmModal({
|
||||
{batchItems.map(({ suggestion, candidate }) => (
|
||||
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
|
||||
<span className="font-mono font-bold text-slate-900">{suggestion.currentVehicle.plateNumber}</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
|
||||
<span className="font-mono font-bold text-blue-700">{candidate.plateNumber}</span>
|
||||
</div>
|
||||
{candidate.canQualifyAfterSwap ? (
|
||||
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0">可达标</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types';
|
||||
import { createHeatmapMap, loadAmap, type AmapInstance } from '../../shared/amap';
|
||||
|
||||
type Props = {
|
||||
config: AmapConfig;
|
||||
@@ -10,19 +11,6 @@ type Props = {
|
||||
onMapClick: (longitude: number, latitude: number) => void;
|
||||
};
|
||||
|
||||
type AmapInstance = {
|
||||
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
|
||||
HeatMap: new (map: any, options: Record<string, unknown>) => any;
|
||||
ToolBar: new (options?: Record<string, unknown>) => any;
|
||||
Scale: new (options?: Record<string, unknown>) => any;
|
||||
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_AMapSecurityConfig?: { securityJsCode: string };
|
||||
}
|
||||
}
|
||||
|
||||
function getBounds(points: HeatmapPoint[]) {
|
||||
if (points.length === 0) return null;
|
||||
@@ -68,37 +56,9 @@ export default function AmapHeatmapCanvas({ config, points, max, metric, focusQu
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
|
||||
const loaderModule = await import('@amap/amap-jsapi-loader');
|
||||
const AMap = await loaderModule.default.load({
|
||||
key: config.key,
|
||||
version: '2.0',
|
||||
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
|
||||
}) as unknown as AmapInstance;
|
||||
const AMap = await loadAmap(config);
|
||||
if (cancelled || !container) return;
|
||||
|
||||
const map = new AMap.Map(container, {
|
||||
viewMode: '2D',
|
||||
zoom: 5,
|
||||
center: [105.4, 34.4],
|
||||
mapStyle: 'amap://styles/whitesmoke',
|
||||
resizeEnable: true,
|
||||
showLabel: true,
|
||||
});
|
||||
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
|
||||
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
|
||||
const heatmap = new AMap.HeatMap(map, {
|
||||
radius: 25,
|
||||
opacity: [0.12, 0.82],
|
||||
gradient: {
|
||||
0.1: '#2563eb',
|
||||
0.3: '#0891b2',
|
||||
0.5: '#16a34a',
|
||||
0.68: '#eab308',
|
||||
0.84: '#f97316',
|
||||
1: '#dc2626',
|
||||
},
|
||||
});
|
||||
const { map, heatmap } = createHeatmapMap(AMap, container, { radius: 25, opacity: [0.12, 0.82] });
|
||||
map.on('click', (event: any) => {
|
||||
clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat());
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import HeatmapDetailPanel from './HeatmapDetailPanel';
|
||||
import HeatmapFilters from './HeatmapFilters';
|
||||
import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api';
|
||||
import { recentDayRange } from '../../shared/date-range';
|
||||
import { HEATMAP_LEGEND_GRADIENT } from '../../shared/amap';
|
||||
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
|
||||
|
||||
// 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。
|
||||
@@ -225,7 +226,7 @@ export default function VehicleHeatmapModule() {
|
||||
? '同一网格累计每日首个定位,同车跨天重复计数;对数平滑强度。'
|
||||
: '同一网格按 VIN 去重,同车仅计 1 辆;平方根平滑强度。'}
|
||||
</p>
|
||||
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
|
||||
<div className="mt-2.5 h-2.5 rounded-full" style={{ background: HEATMAP_LEGEND_GRADIENT }} />
|
||||
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span>相对较低</span><span>相对较高</span></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 高德地图 JSAPI 2.0 的唯一接入点。
|
||||
*
|
||||
* 两个热力图模块此前各自重复了:SDK 版本与插件列表、安全码注入、
|
||||
* 底图参数、控件位置以及整条渐变色带。这些必须保持一致,否则"同类地图
|
||||
* 长得不一样",因此收敛到这里。各页面只保留自己的热力图参数(半径/透明度)。
|
||||
*/
|
||||
|
||||
/** 最小结构类型:只声明我们实际用到的构造器。 */
|
||||
export type AmapInstance = {
|
||||
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
|
||||
HeatMap: new (map: any, options: Record<string, unknown>) => any;
|
||||
ToolBar: new (options?: Record<string, unknown>) => any;
|
||||
Scale: new (options?: Record<string, unknown>) => any;
|
||||
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
|
||||
};
|
||||
|
||||
export interface AmapClientConfig {
|
||||
key: string;
|
||||
securityCode: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_AMapSecurityConfig?: { securityJsCode: string };
|
||||
}
|
||||
}
|
||||
|
||||
/** 热力图色带:冷→热,两处地图共用。 */
|
||||
export const HEATMAP_GRADIENT: Record<number, string> = {
|
||||
0.1: '#2563eb',
|
||||
0.3: '#0891b2',
|
||||
0.5: '#16a34a',
|
||||
0.68: '#eab308',
|
||||
0.84: '#f97316',
|
||||
1: '#dc2626',
|
||||
};
|
||||
|
||||
/** 与地图色带对应的 UI 图例渐变,避免图例与地图各写一份色值。 */
|
||||
export const HEATMAP_LEGEND_GRADIENT =
|
||||
'linear-gradient(90deg, #2563eb 0%, #0891b2 25%, #16a34a 45%, #eab308 65%, #f97316 82%, #dc2626 100%)';
|
||||
|
||||
/** 加载 JSAPI:版本、插件列表与安全码只在此处定义。 */
|
||||
export async function loadAmap(config: AmapClientConfig): Promise<AmapInstance> {
|
||||
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
|
||||
const loaderModule = await import('@amap/amap-jsapi-loader');
|
||||
const loaded = await loaderModule.default.load({
|
||||
key: config.key,
|
||||
version: '2.0',
|
||||
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
|
||||
});
|
||||
return loaded as unknown as AmapInstance;
|
||||
}
|
||||
|
||||
/** 创建只读热力图底图:底图样式、控件位置与色带统一。 */
|
||||
export function createHeatmapMap(
|
||||
AMap: AmapInstance,
|
||||
container: HTMLElement,
|
||||
style: { radius: number; opacity: [number, number] },
|
||||
): { map: any; heatmap: any } {
|
||||
const map = new AMap.Map(container, {
|
||||
viewMode: '2D',
|
||||
zoom: 5,
|
||||
center: [105.4, 34.4],
|
||||
mapStyle: 'amap://styles/whitesmoke',
|
||||
resizeEnable: true,
|
||||
showLabel: true,
|
||||
});
|
||||
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
|
||||
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
|
||||
const heatmap = new AMap.HeatMap(map, {
|
||||
radius: style.radius,
|
||||
opacity: style.opacity,
|
||||
gradient: { ...HEATMAP_GRADIENT },
|
||||
});
|
||||
return { map, heatmap };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
/**
|
||||
* Excel 导出的唯一实现。
|
||||
*
|
||||
* 此前仓库里有 4 份各自拼装 workbook 的代码(assets 内联、mileage 内联、
|
||||
* hydrogen 的两份 aoa/json helper),产物后缀与 sheet 名截断规则也不一致。
|
||||
* 这里只收敛"组装与写出"这一层原语;各调用方仍自行决定列宽、冻结与数字格式,
|
||||
* 以免改变已验收的导出样式。
|
||||
*/
|
||||
|
||||
export type AoaRow = Array<string | number | boolean | null | undefined>;
|
||||
|
||||
/** 统一产物后缀:内部一律写出 .xlsx。 */
|
||||
export function ensureXlsxFilename(name: string | undefined): string {
|
||||
const raw = String(name || 'export').trim() || 'export';
|
||||
return `${raw.replace(/\.(csv|xls|xlsx)$/i, '')}.xlsx`;
|
||||
}
|
||||
|
||||
/** 由二维数组构建 sheet。 */
|
||||
export function buildAoaSheet(rows: AoaRow[]): XLSX.WorkSheet {
|
||||
return XLSX.utils.aoa_to_sheet(rows);
|
||||
}
|
||||
|
||||
/** 由对象数组构建 sheet(首行为字段名)。 */
|
||||
export function buildJsonSheet(rows: Record<string, unknown>[]): XLSX.WorkSheet {
|
||||
return XLSX.utils.json_to_sheet(rows);
|
||||
}
|
||||
|
||||
/** Excel 的 sheet 名上限是 31 字符,空名回退为 Sheet1。 */
|
||||
function safeSheetName(name: string): string {
|
||||
return (name || 'Sheet1').slice(0, 31) || 'Sheet1';
|
||||
}
|
||||
|
||||
/** 写出一本工作簿。调用方可先给 sheet 设置 `!cols` / `!freeze` / 单元格格式。 */
|
||||
export function writeWorkbook(
|
||||
sheets: Array<{ name: string; sheet: XLSX.WorkSheet }>,
|
||||
fileName: string,
|
||||
): void {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
for (const { name, sheet } of sheets) {
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, safeSheetName(name));
|
||||
}
|
||||
XLSX.writeFile(workbook, ensureXlsxFilename(fileName));
|
||||
}
|
||||
|
||||
/** 便捷:单个二维数组 sheet 直接下载。 */
|
||||
export function exportAoaSheet(rows: AoaRow[], fileName: string, sheetName = 'Sheet1'): void {
|
||||
writeWorkbook([{ name: sheetName, sheet: buildAoaSheet(rows) }], fileName);
|
||||
}
|
||||
|
||||
/** 便捷:单个对象数组 sheet 直接下载。 */
|
||||
export function exportJsonSheet(
|
||||
rows: Record<string, unknown>[],
|
||||
fileName: string,
|
||||
sheetName = 'Sheet1',
|
||||
): void {
|
||||
writeWorkbook([{ name: sheetName, sheet: buildJsonSheet(rows) }], fileName);
|
||||
}
|
||||
Reference in New Issue
Block a user