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:
dsh-agent
2026-09-11 10:28:32 +08:00
parent 5d14a28b0f
commit 507bc90ed2
33 changed files with 334 additions and 318 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ src/
│ ├── ele/ 电能数据导入 │ ├── ele/ 电能数据导入
│ ├── admin/ 反馈后台 │ ├── admin/ 反馈后台
│ └── *-heatmap/ 两类热力图 │ └── *-heatmap/ 两类热力图
├── shared/ 前后端共享的叶子层(角色常量、跨端 DTO) ├── shared/ 前后端共享的叶子层(角色常量、跨端 DTO、日期区间、Excel、高德接入
└── server/ └── server/
├── config.ts 环境变量集中读取 + 启动期校验 ├── config.ts 环境变量集中读取 + 启动期校验
├── app.ts 应用装配(无副作用,便于测试) ├── app.ts 应用装配(无副作用,便于测试)
+13 -2
View File
@@ -122,6 +122,17 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
- **运行时建表仍分散在业务代码里**:`ele``feedback``mileage` 在首次调用时执行 - **运行时建表仍分散在业务代码里**:`ele``feedback``mileage` 在首次调用时执行
`CREATE TABLE`,而 `read-only` 中间件只按 HTTP 方法拦截,所以只读预览下 GET 仍可能触发建表。 `CREATE TABLE`,而 `read-only` 中间件只按 HTTP 方法拦截,所以只读预览下 GET 仍可能触发建表。
应收敛到显式的迁移函数并只由 `bootstrap.ts` 调用。 应收敛到显式的迁移函数并只由 `bootstrap.ts` 调用。
- **重复实现尚未合并**:Excel 导出有 3 套、高德初始化 2 份、数值格式化多处; - **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个
公共实现 `modules/energy/hydrogen/model/display-format.ts` 目前只被自己的测试使用 传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`
- **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kBgzip 50 kB),来自原型渲染方式(大量内联样式)。 - **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kBgzip 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` |
+21
View File
@@ -114,6 +114,27 @@ test("纯模型文件不得依赖 React(保证可被 node:test 直接测)",
assert.deepEqual(violations, [], "model.ts 应只包含可在 Node 中直接执行的纯函数"); 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 原型快照", () => { test("类型豁免清单只减不增:@ts-nocheck 仅限已登记的 8113 原型快照", () => {
// 这三个文件是逐字节保留的验收原型(UI/CSS 冻结),显式登记为唯一豁免。 // 这三个文件是逐字节保留的验收原型(UI/CSS 冻结),显式登记为唯一豁免。
const allowed = [ const allowed = [
-17
View File
@@ -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>;
}
-3
View File
@@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, type ComponentType, type ElementType, Sus
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Building2, ChevronRight } from 'lucide-react'; import { Building2, ChevronRight } from 'lucide-react';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab'; import FeedbackFab from './FeedbackFab';
import { cn } from '../lib/cn'; import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface'; import { LoadingState } from './ui/surface';
@@ -93,7 +92,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}, [user]); }, [user]);
return ( return (
<DemoModeProvider enabled={false}>
<div className="enterprise-grid-bg flex min-h-screen"> <div className="enterprise-grid-bg flex min-h-screen">
{/* 氢费看板按原型交付;其他模块继续保留全局水印。 */} {/* 氢费看板按原型交付;其他模块继续保留全局水印。 */}
{!isHydrogenPrototype ? ( {!isHydrogenPrototype ? (
@@ -220,6 +218,5 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
</div> </div>
</nav> </nav>
</div> </div>
</DemoModeProvider>
); );
} }
+14 -17
View File
@@ -9,7 +9,7 @@ import {
MapPin, MapPin,
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import * as XLSX from 'xlsx'; import { buildJsonSheet, writeWorkbook } from '../../shared/xlsx';
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -40,7 +40,6 @@ import {
} from './model'; } from './model';
import { SearchSelect } from '../../components/SearchSelect'; import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface'; import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
import { AssetsHeader } from './components/AssetsHeader'; import { AssetsHeader } from './components/AssetsHeader';
@@ -393,7 +392,7 @@ export default function AssetsModule() {
业务负责人: item.manager || '', 业务负责人: item.manager || '',
客户: item.customerName || '', 客户: item.customerName || '',
})); }));
const ws = XLSX.utils.json_to_sheet(table); const ws = buildJsonSheet(table);
ws['!cols'] = [ ws['!cols'] = [
{ wch: 14 }, { wch: 14 },
{ wch: 8 }, { wch: 8 },
@@ -404,9 +403,7 @@ export default function AssetsModule() {
{ wch: 14 }, { wch: 14 },
{ wch: 24 }, { wch: 24 },
]; ];
const wb = XLSX.utils.book_new(); writeWorkbook([{ name: '明细', sheet: ws }], `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
XLSX.utils.book_append_sheet(wb, ws, '明细');
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
}, [flowRange.end, flowRange.start, flowStats]); }, [flowRange.end, flowRange.start, flowStats]);
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]); const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
@@ -705,7 +702,7 @@ export default function AssetsModule() {
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />} {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> </div>
<button <button
onClick={(e) => { 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"> <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" />} {isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
<Blur>{m.manager}</Blur> {m.manager}
</td> </td>
<td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td>
<td <td
@@ -907,7 +904,7 @@ export default function AssetsModule() {
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />} {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> </div>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -986,7 +983,7 @@ export default function AssetsModule() {
<div className="flex items-center gap-2 flex-1 min-w-0"> <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" />} {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"> <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> <span className="text-[11px] text-gray-500 shrink-0">{m.department}</span>
<div <div
className="text-[11px] font-bold text-blue-600 whitespace-nowrap" 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"> <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" />} {isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />}
<Blur>{cust.customer}</Blur> {cust.customer}
</td> </td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"> <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> <span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span>
</td> </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: 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: '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> <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="grid grid-cols-4 gap-2">
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <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-[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>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <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-[10px] text-gray-400 uppercase mb-1"></div>
@@ -1540,7 +1537,7 @@ export default function AssetsModule() {
</div> </div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <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-[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>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <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-[10px] text-gray-400 uppercase mb-1"></div>
@@ -1574,7 +1571,7 @@ export default function AssetsModule() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />} {isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />}
<div className="flex flex-col"> <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> <span className="text-[10px] text-emerald-600 font-medium">{cust.region}</span>
</div> </div>
</div> </div>
@@ -1592,7 +1589,7 @@ export default function AssetsModule() {
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <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-[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>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <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-[8px] text-gray-400 uppercase mb-1"></div>
@@ -1602,7 +1599,7 @@ export default function AssetsModule() {
</div> </div>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <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-[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>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <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-[8px] text-gray-400 uppercase mb-1"></div>
@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react'; import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import Blur from '../../../components/Blur';
import { SearchSelect } from '../../../components/SearchSelect'; import { SearchSelect } from '../../../components/SearchSelect';
import type { WeeklyDetailItem } from '../api'; import type { WeeklyDetailItem } from '../api';
import type { ModalVehicleFilters, VehicleModalSelection } from '../model'; import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
@@ -167,8 +166,8 @@ export function VehicleDetailModal({
<tbody className="text-[11px]"> <tbody className="text-[11px]">
{filteredModalWeeklyDetail.map((v, i) => ( {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'}`}> <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 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"><Blur>{v.customer_name || '—'}</Blur></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> <td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
</tr> </tr>
))} ))}
@@ -215,12 +214,12 @@ export function VehicleDetailModal({
{showPlateNumbers.source === 'customer' ? ( {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 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.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-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 text-gray-500 text-[10px]">{v.subjectOrg || '—'}</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-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'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></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"> <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 ${ <span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
v.status === 'Operating' ? 'bg-green-100 text-green-700' : 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-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-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 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' && ( {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.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
@@ -20,7 +20,7 @@ import {
X, X,
Zap, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { downloadExcelAoa } from '../common/download-xls'; import { exportAoaSheet } from '../../../../shared/xlsx';
import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton'; import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton';
import { import {
SOURCE_LABEL, SOURCE_LABEL,
@@ -3241,7 +3241,7 @@ function HostDailyView({
const fileDateStr = `${startDate.replace(/[/]/g, '')}-${endDate.replace(/[/]/g, '')}`; const fileDateStr = `${startDate.replace(/[/]/g, '')}-${endDate.replace(/[/]/g, '')}`;
const fleetName = fleetType === 'all' ? '全部车辆' : fleetType === 'own' ? '羚牛车辆' : '外部车辆'; const fleetName = fleetType === 'all' ? '全部车辆' : fleetType === 'own' ? '羚牛车辆' : '外部车辆';
downloadExcelAoa(aoa, `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, '每日加氢明细'); exportAoaSheet(aoa, `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, '每日加氢明细');
}; };
return ( 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 { Download, RefreshCw, Truck } from "lucide-react";
import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls"; import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format"; import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
import { formatFixed } from "../model/display-format";
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "../api"; import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "../api";
import { downloadExcelAoa } from "../common/prototype-download"; import { exportAoaSheet } from "../../../../shared/xlsx";
import "./real-daily-mobile.css"; import "./real-daily-mobile.css";
import type { import type {
H2BiDailyResponse, H2BiDailyResponse,
@@ -13,11 +14,6 @@ import type {
H2BiVehicleScope, H2BiVehicleScope,
} from "../types"; } from "../types";
const format = (value: number, digits = 2) =>
value.toLocaleString("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
const toScope = (scope: "all" | "own" | "external"): H2BiVehicleScope => const toScope = (scope: "all" | "own" | "external"): H2BiVehicleScope =>
scope === "own" ? "lingniu" : scope; scope === "own" ? "lingniu" : scope;
const sourceLabel = (source: unknown) => { const sourceLabel = (source: unknown) => {
@@ -226,7 +222,7 @@ export function PrototypeRealDailyView({
}; };
const exportRows = () => { const exportRows = () => {
if (!daily) return; if (!daily) return;
downloadExcelAoa( exportAoaSheet(
dailySummaryRows(daily), dailySummaryRows(daily),
`每日加氢日期汇总_${startDate}_${endDate}.xlsx`, `每日加氢日期汇总_${startDate}_${endDate}.xlsx`,
"日期汇总", "日期汇总",
@@ -342,7 +338,7 @@ export function PrototypeRealDailyView({
<div className="ehb-daily-kpi-card"> <div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div> <div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val"> <div className="ehb-daily-kpi-val">
{format(daily?.kpis.totalKg ?? 0)} <small>Kg</small> {formatFixed(daily?.kpis.totalKg ?? 0)} <small>Kg</small>
</div> </div>
<div className="ehb-daily-kpi-sub"> <div className="ehb-daily-kpi-sub">
{startDate} {endDate} {startDate} {endDate}
@@ -351,7 +347,7 @@ export function PrototypeRealDailyView({
<div className="ehb-daily-kpi-card"> <div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div> <div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val"> <div className="ehb-daily-kpi-val">
¥{format(daily?.kpis.totalCost ?? 0)} ¥{formatFixed(daily?.kpis.totalCost ?? 0)}
</div> </div>
<div className="ehb-daily-kpi-sub"></div> <div className="ehb-daily-kpi-sub"></div>
</div> </div>
@@ -359,7 +355,7 @@ export function PrototypeRealDailyView({
<div className="ehb-daily-kpi-title"></div> <div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">{daily?.kpis.activeDays ?? 0}</div> <div className="ehb-daily-kpi-val">{daily?.kpis.activeDays ?? 0}</div>
<div className="ehb-daily-kpi-sub"> <div className="ehb-daily-kpi-sub">
{format(daily?.kpis.averageDailyKg ?? 0)} Kg {formatFixed(daily?.kpis.averageDailyKg ?? 0)} Kg
</div> </div>
</div> </div>
<div className="ehb-daily-kpi-card"> <div className="ehb-daily-kpi-card">
@@ -395,12 +391,12 @@ export function PrototypeRealDailyView({
<div className="ehb-daily-summary-pills"> <div className="ehb-daily-summary-pills">
<div className="ehb-daily-pill-item"> <div className="ehb-daily-pill-item">
<span></span> <span></span>
<strong>{peak ? `${peak.date} ${format(peak.kg)} Kg` : "—"}</strong> <strong>{peak ? `${peak.date} ${formatFixed(peak.kg)} Kg` : "—"}</strong>
</div> </div>
<div className="ehb-daily-pill-item"> <div className="ehb-daily-pill-item">
<span></span> <span></span>
<strong> <strong>
{trough ? `${trough.date} ${format(trough.kg)} Kg` : "—"} {trough ? `${trough.date} ${formatFixed(trough.kg)} Kg` : "—"}
</strong> </strong>
</div> </div>
<div className="ehb-daily-pill-item"> <div className="ehb-daily-pill-item">
@@ -416,7 +412,7 @@ export function PrototypeRealDailyView({
}} }}
> >
<span className="ehb-daily-avg-label"> <span className="ehb-daily-avg-label">
{format(averageKg)} Kg {formatFixed(averageKg)} Kg
</span> </span>
</div> </div>
{trend.map((row) => { {trend.map((row) => {
@@ -430,7 +426,7 @@ export function PrototypeRealDailyView({
key={row.date} key={row.date}
className="ehb-daily-bar-col" className="ehb-daily-bar-col"
onClick={() => openDate(row.date, true)} onClick={() => openDate(row.date, true)}
title={`${row.date} 加氢总量 ${format(row.kg)} Kg;点击展开并定位当日明细`} title={`${row.date} 加氢总量 ${formatFixed(row.kg)} Kg;点击展开并定位当日明细`}
aria-label={`展开并定位${row.date}当日加氢明细`} aria-label={`展开并定位${row.date}当日加氢明细`}
> >
<div <div
@@ -539,8 +535,8 @@ export function PrototypeRealDailyView({
<tr style={{ background: "#f8fafc", fontWeight: 700 }}> <tr style={{ background: "#f8fafc", fontWeight: 700 }}>
<td></td> <td></td>
<td /> <td />
<td>{format(daily?.kpis.totalKg ?? 0)}</td> <td>{formatFixed(daily?.kpis.totalKg ?? 0)}</td>
<td>¥{format(daily?.kpis.totalCost ?? 0)}</td> <td>¥{formatFixed(daily?.kpis.totalCost ?? 0)}</td>
<td></td> <td></td>
</tr> </tr>
{(daily?.days ?? []).map((day) => { {(daily?.days ?? []).map((day) => {
@@ -567,9 +563,9 @@ export function PrototypeRealDailyView({
</DailyTreeButton> </DailyTreeButton>
</td> </td>
<td></td> <td></td>
<td>{format(day.kg)}</td> <td>{formatFixed(day.kg)}</td>
<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> <small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small>
</td> </td>
<td></td> <td></td>
@@ -597,8 +593,8 @@ export function PrototypeRealDailyView({
</DailyTreeButton> </DailyTreeButton>
</td> </td>
<td></td> <td></td>
<td>{format(station.kg)}</td> <td>{formatFixed(station.kg)}</td>
<td>¥{format(station.cost)}</td> <td>¥{formatFixed(station.cost)}</td>
<td></td> <td></td>
</tr> </tr>
{stationOpen && station.customers.length === 0 ? <DailyBranchState {stationOpen && station.customers.length === 0 ? <DailyBranchState
@@ -636,8 +632,8 @@ export function PrototypeRealDailyView({
</DailyTreeButton> </DailyTreeButton>
</td> </td>
<td></td> <td></td>
<td>{format(customer.kg)}</td> <td>{formatFixed(customer.kg)}</td>
<td>¥{format(customer.cost)}</td> <td>¥{formatFixed(customer.cost)}</td>
<td></td> <td></td>
</tr> </tr>
{customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState {customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState
@@ -672,15 +668,15 @@ export function PrototypeRealDailyView({
</span> </span>
</td> </td>
<td> <td>
{format( {formatFixed(
Number(record.unitPrice ?? 0), Number(record.unitPrice ?? 0),
)} )}
</td> </td>
<td> <td>
{format(Number(record.kg ?? 0))} {formatFixed(Number(record.kg ?? 0))}
</td> </td>
<td> <td>
¥{format(Number(record.cost ?? 0))} ¥{formatFixed(Number(record.cost ?? 0))}
</td> </td>
<td></td> <td></td>
</tr> </tr>
@@ -4,8 +4,9 @@ import { createPortal } from "react-dom";
import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react"; import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react";
import { MobileListFullscreenButton } from "../common/MobileListFullscreenButton"; import { MobileListFullscreenButton } from "../common/MobileListFullscreenButton";
import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "../api"; import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "../api";
import { downloadExcelAoa } from "../common/prototype-download"; import { exportAoaSheet } from "../../../../shared/xlsx";
import { bearingLabels } from "../model/bearing-labels"; import { bearingLabels } from "../model/bearing-labels";
import { formatFixed } from "../model/display-format";
import type { import type {
H2BiDrillGroupBy, H2BiDrillGroupBy,
H2BiDrillGroupRow, H2BiDrillGroupRow,
@@ -79,11 +80,6 @@ type DrillState = Pick<
stationName?: string; 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 => const fleetScope = (value: "all" | "own" | "external"): H2BiVehicleScope =>
value === "own" ? "lingniu" : value; value === "own" ? "lingniu" : value;
const verifyLabel = (status: unknown) => { const verifyLabel = (status: unknown) => {
@@ -461,7 +457,7 @@ function ExpandedChildren({ data, state, query, kind, label, depth = 1 }: {
function ExpandedMetrics({ row, kind, label, amountScope }: { function ExpandedMetrics({ row, kind, label, amountScope }: {
row: { kg?: unknown; cost?: unknown; revenue?: unknown }; kind: DrillKind; label: string; amountScope: H2BiAmountScope; 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 (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></>; 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}</>; 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> <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> <span>{row.name}</span>
</div></td> </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} /> <ExpandedMetrics row={row} kind={kind} label={label} amountScope={state.amountScope} />
</tr> </tr>
{expanded ? live.loading || live.error || !live.data ? <tr className="ehb-inline-child-status"><td colSpan={columns}>{live.error ? `加载失败:${live.error},请收起后重试` : "正在加载子级数据…"}</td></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} {region ? <td>{index + 1}</td> : null}
<td style={{ fontWeight: 650 }}>{row.name}</td> <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> <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} {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 === "客户收入" ? `¥${formatNumber(row.revenue)}` : monthMetric === "成本支出" ? `¥${formatNumber(row.cost)}` : formatNumber(row.kg)}</td> <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} {stationCustomer || region ? <td style={{ textAlign: "right" }}>{totalKg > 0 ? `${((Number(row.kg) / totalKg) * 100).toFixed(1)}%` : "0.0%"}</td> : null}
</tr> </tr>
))}</tbody> ))}</tbody>
@@ -552,10 +548,10 @@ function GroupTable({
); );
} }
if (kind === "station" && state.level === "date") { 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") { 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") { if (state.level === "record") {
return ( return (
@@ -628,9 +624,9 @@ function GroupTable({
<td style={{ textAlign: "right" }}> <td style={{ textAlign: "right" }}>
1 1
</td> </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" }}> <td className="ehb-key-income" style={{ textAlign: "right" }}>
¥{formatNumber(amountFor(record))} ¥{formatFixed(amountFor(record))}
</td> </td>
</tr> </tr>
))} ))}
@@ -748,7 +744,7 @@ function GroupTable({
</span> </span>
) : state.level === "station" ? ( ) : state.level === "station" ? (
<span className="ehb-tree-node-sub"> <span className="ehb-tree-node-sub">
{formatNumber(row.customerCount, 0)} {formatFixed(row.customerCount, 0)}
</span> </span>
) : ( ) : (
<span className="ehb-tree-node-sub"></span> <span className="ehb-tree-node-sub"></span>
@@ -767,8 +763,8 @@ function GroupTable({
{state.level === "station" ? "汇总" : "点击展开"} {state.level === "station" ? "汇总" : "点击展开"}
</span> </span>
</td> </td>
<td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} </td> <td style={{ textAlign: "right" }}>{formatFixed(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></>} {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> </tr>
{expanded && !expandedLoading && !expandedError && expandedData ? <ExpandedChildren data={expandedData} state={nextState(state, row)} query={query} kind={kind} label={label} /> : null} {expanded && !expandedLoading && !expandedError && expandedData ? <ExpandedChildren data={expandedData} state={nextState(state, row)} query={query} kind={kind} label={label} /> : null}
{expanded ? ( {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 externalKg = groups.reduce((sum, row) => sum + Number(row.externalKg || 0), 0);
const monthMetric = label.match(/^\d{4}年\d{1,2}月(加氢量|客户收入|成本支出)$/)?.[1]; const monthMetric = label.match(/^\d{4}年\d{1,2}月(加氢量|客户收入|成本支出)$/)?.[1];
let cards: Array<[string, string, string?]>; let cards: Array<[string, string, string?]>;
if (label === "加氢利润") cards = [["收入合计", `¥${formatNumber(revenue)}`, "income"], ["成本合计", `¥${formatNumber(cost)}`, "cost"], ["加氢利润", `¥${formatNumber(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount}`]]; if (label === "加氢利润") cards = [["收入合计", `¥${formatFixed(revenue)}`, "income"], ["成本合计", `¥${formatFixed(cost)}`, "cost"], ["加氢利润", `¥${formatFixed(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (label === "本月加氢") cards = [["本月加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatNumber(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount}`]]; else if (label === "本月加氢") cards = [["本月加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (label === "本日加氢") cards = [["本日加氢量", `${formatNumber(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatNumber(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount}`]]; else if (label === "本日加氢") cards = [["本日加氢量", `${formatFixed(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatFixed(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatNumber(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatNumber(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatNumber(kg)} Kg`], ["覆盖加氢站数", `${stationCount}`]]; else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatNumber(revenue)}`, "income"], ["站均收入", `¥${formatNumber(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount}`]]; else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatFixed(revenue)}`, "income"], ["站均收入", `¥${formatFixed(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount}`]];
else if (monthMetric === "成本支出") cards = [["成本支出合计", `¥${formatNumber(cost)}`, "cost"], ["站均成本", `¥${formatNumber(cost / 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(/^加氢站客户量:/, "")], ["羚牛车辆加氢总量", `${formatNumber(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatNumber(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatNumber(kg)} Kg`]]; 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(/^区域(?:市|省)/, "")], ["加氢总量", `${formatNumber(kg / 1000)} T`, "volume"], ["覆盖加氢站数", `${stationCount}`]]; else if (/^区域(?:市|省)/.test(label)) cards = [["区域", label.replace(/^区域(?:市|省)/, "")], ["加氢总量", `${formatFixed(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 === "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 = [["承担方", "客户承担"], ["加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatNumber(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatNumber(revenue)}`, "income"], ["已收", "未接入"], ["未收", "未接入"]]; else if (kind === "customer") cards = [["承担方", "客户承担"], ["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatFixed(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"]]; 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>; 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]), rows.push([row.name, row.kg, row.cost, row.revenue]),
); );
} }
downloadExcelAoa(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透"); exportAoaSheet(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透");
}; };
const exportCurrent = () => { const exportCurrent = () => {
if (data) exportData(data, `${page}`); if (data) exportData(data, `${page}`);
@@ -1244,8 +1240,8 @@ export function PrototypeDrillModal({
{cleanLabel === "加氢利润" && customerBearingScope ? ( {cleanLabel === "加氢利润" && customerBearingScope ? (
<div className="ehb-modal-hint-text" style={{ marginBottom: 10 }}> <div className="ehb-modal-hint-text" style={{ marginBottom: 10 }}>
¥ ¥
{formatNumber(live.data?.summary.revenue)} ¥ {formatFixed(live.data?.summary.revenue)} ¥
{formatNumber(live.data?.summary.cost)} = ¥{formatNumber(profit)} {formatFixed(live.data?.summary.cost)} = ¥{formatFixed(profit)}
</div> </div>
) : null} ) : null}
<div className="ehb-real-drill-primary-actions"> <div className="ehb-real-drill-primary-actions">
@@ -1,13 +1,19 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; 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"]) { for (const value of [null, undefined, NaN, Infinity, -Infinity, "0"]) {
assert.equal(finiteNumber(value), null); assert.equal(isFiniteNumberValue(value), false);
assert.equal(formatNumber(value), "—"); assert.equal(formatFixed(value, 2, { blankForMissing: true }), "—");
assert.equal(formatScaled(value, 1000), "—");
} }
assert.equal(formatNumber(0), "0.00"); assert.equal(formatFixed(0, 2, { blankForMissing: true }), "0.00");
assert.equal(formatScaled(0, 1000), "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 => { export interface FormatOptions {
const safe = finiteNumber(value); /**
return safe === null * 缺失或不可用(非有限数)时返回 "—" 而不是 0。
? "—" * 默认 false:明细表按 0 展示,与既有氢能账本口径一致;
: safe.toLocaleString("zh-CN", { * 需要区分"真实零值"与"接口未返回"时显式开启。
maximumFractionDigits: digits, */
minimumFractionDigits: digits, blankForMissing?: boolean;
}); }
};
export const formatScaled = (value: unknown, divisor: number, digits = 2) => { /** 该值本身是否为可参与计算的有限数字(不把 null / undefined / "0" 视为数字)。 */
const safe = finiteNumber(value); export function isFiniteNumberValue(value: unknown): value is number {
return safe === null ? "—" : formatNumber(safe / divisor, digits); 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 React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ChevronDown, ChevronUp, Download, RefreshCw } from 'lucide-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 { MobileListFullscreenButton } from '../common/MobileListFullscreenButton';
import { import {
SPOT_PAY_METHOD_LABEL, SPOT_PAY_METHOD_LABEL,
@@ -364,7 +364,7 @@ export const StationDailyDetailView: React.FC<{
r.amountYuan, r.amountYuan,
]), ]),
]; ];
downloadExcelAoa(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证'); exportAoaSheet(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证');
} catch (reason) { } catch (reason) {
if (!controller.signal.aborted) setExportError(reason instanceof Error ? reason.message : '导出失败,请重试'); if (!controller.signal.aborted) setExportError(reason instanceof Error ? reason.message : '导出失败,请重试');
} finally { } finally {
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types'; import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types';
import { createHeatmapMap, loadAmap, type AmapInstance } from '../../shared/amap';
type Props = { type Props = {
config: AmapConfig; config: AmapConfig;
@@ -10,19 +11,6 @@ type Props = {
onMapClick: (longitude: number, latitude: number) => void; 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[]) { function getBounds(points: HydrogenHeatmapPoint[]) {
if (!points.length) return null; if (!points.length) return null;
@@ -62,36 +50,9 @@ export default function HydrogenAmapCanvas({ config, points, max, metric, focusQ
async function initialize() { async function initialize() {
try { try {
window._AMapSecurityConfig = { securityJsCode: config.securityCode }; const AMap = await loadAmap(config);
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;
if (cancelled || !container) return; if (cancelled || !container) return;
const map = new AMap.Map(container, { const { map, heatmap } = createHeatmapMap(AMap, container, { radius: 34, opacity: [0.14, 0.84] });
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',
},
});
map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat())); map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()));
mapRef.current = map; mapRef.current = map;
heatmapRef.current = heatmap; heatmapRef.current = heatmap;
@@ -5,6 +5,7 @@ import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel';
import HydrogenHeatmapFilters from './HydrogenHeatmapFilters'; import HydrogenHeatmapFilters from './HydrogenHeatmapFilters';
import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api'; import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api';
import { recentDayRange } from '../../shared/date-range'; import { recentDayRange } from '../../shared/date-range';
import { HEATMAP_LEGEND_GRADIENT } from '../../shared/amap';
import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types'; import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types';
// 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。 // 接口返回数据水位前的兜底范围:近 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="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> <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> <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 className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span></span><span></span></div>
</div> </div>
+2 -3
View File
@@ -6,7 +6,6 @@ import {
} from 'recharts'; } from 'recharts';
import type { MileageSourceGroup, MonitoringVehicle } from './types'; import type { MileageSourceGroup, MonitoringVehicle } from './types';
import { fetchVehicleRecent, type VehicleRecentDay } from './api'; import { fetchVehicleRecent, type VehicleRecentDay } from './api';
import Blur from '../../components/Blur';
interface Props { interface Props {
vehicle: MonitoringVehicle | null; vehicle: MonitoringVehicle | null;
@@ -160,7 +159,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <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'}`}> <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 ? '在线' : '离线'} {vehicle.isOnline ? '在线' : '离线'}
</span> </span>
@@ -169,7 +168,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
{vehicle.rentStatus || ''} {vehicle.rentStatus || ''}
{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''} {vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}
{vehicle.customer ? ` · ` : ''} {vehicle.customer ? ` · ` : ''}
{vehicle.customer && <Blur>{vehicle.customer}</Blur>} {vehicle.customer}
</div> </div>
</div> </div>
</div> </div>
@@ -21,7 +21,6 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import Blur from '../../../components/Blur';
import type { MileageReportGroup } from '../api'; import type { MileageReportGroup } from '../api';
import { import {
filterAndSortVehicles, filterAndSortVehicles,
@@ -167,7 +166,7 @@ export default function VehicleTable({ group }: { group: MileageReportGroup }) {
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-black text-slate-900"> <div className="flex items-center gap-2 text-xs font-black text-slate-900">
<BarChart3 size={15} className="text-blue-600" /> <BarChart3 size={15} className="text-blue-600" />
<Blur>{trendVehicle.plate}</Blur> {trendVehicle.plate}
<span className="font-bold text-slate-400">7</span> <span className="font-bold text-slate-400">7</span>
</div> </div>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[10px] font-bold text-slate-500"> <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' : ''}`} 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}`}> <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 className={`mt-0.5 font-sans text-[9px] md:hidden ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
</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={`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">{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'}`}> <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 {fmtKm(vehicle.dailyMileage)} km
</span> </span>
@@ -1,7 +1,6 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react'; import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import Blur from '../../../../components/Blur';
import type { MileageSourceGroup, MonitoringFilters, MonitoringStats, MonitoringVehicle } from '../../types'; import type { MileageSourceGroup, MonitoringFilters, MonitoringStats, MonitoringVehicle } from '../../types';
import { vehicleStatisticTime } from '../oneos-time'; import { vehicleStatisticTime } from '../oneos-time';
import { MILEAGE_SOURCE_META, vehicleSourceDisplay } from '../source-display'; import { MILEAGE_SOURCE_META, vehicleSourceDisplay } from '../source-display';
@@ -292,7 +291,7 @@ export default function FullscreenMonitoring({
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex items-center gap-1.5"> <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 <span
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`} className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title} title={sourceDisplay.title}
@@ -304,7 +303,7 @@ export default function FullscreenMonitoring({
{statisticTime.label} {statisticTime.label}
</div> </div>
</td> </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.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.rentStatus || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</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 type { RefObject } from 'react';
import { Truck } from 'lucide-react'; import { Truck } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import Blur from '../../../../components/Blur';
import type { MonitoringVehicle } from '../../types'; import type { MonitoringVehicle } from '../../types';
import { vehicleStatisticTime } from '../oneos-time'; import { vehicleStatisticTime } from '../oneos-time';
import { vehicleSourceDisplay } from '../source-display'; import { vehicleSourceDisplay } from '../source-display';
@@ -73,7 +72,7 @@ export default function VehicleList({
</div> </div>
<div className="overflow-hidden flex-1"> <div className="overflow-hidden flex-1">
<div className="flex flex-wrap items-center gap-1.5"> <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`}> <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 ? '在线' : '离线'} {v.isOnline ? '在线' : '离线'}
</span> </span>
@@ -89,12 +88,12 @@ export default function VehicleList({
</div> </div>
<div className="flex items-center gap-1.5 md:hidden"> <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-[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>
</div> </div>
<div className="hidden min-w-0 md:block"> <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"> <div className="mt-1 truncate text-[9px] font-medium text-slate-400">
{[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'} {[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'}
</div> </div>
@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { ArrowUpDown, Calendar, Search, Truck, X } from 'lucide-react'; import { ArrowUpDown, Calendar, Search, Truck, X } from 'lucide-react';
import Blur from '../../../components/Blur';
import type { TargetVehicle } from '../types'; import type { TargetVehicle } from '../types';
import { import {
filterAndSortTargetVehicles, filterAndSortTargetVehicles,
@@ -116,7 +115,7 @@ export default function AllVehiclesPanel({
</div> </div>
<div className="overflow-hidden flex-1"> <div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5"> <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`}> <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 ? '在线' : '离线'} {vehicle.isOnline ? '在线' : '离线'}
</span> </span>
@@ -1,6 +1,5 @@
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { ChevronDown, Maximize2, Truck } from 'lucide-react'; import { ChevronDown, Maximize2, Truck } from 'lucide-react';
import Blur from '../../../components/Blur';
import type { TargetSummary, TargetVehicle } from '../types'; import type { TargetSummary, TargetVehicle } from '../types';
import { fmtDateLabel, fmtKm, fmtPercent, getTargetAssessment } from './model'; import { fmtDateLabel, fmtKm, fmtPercent, getTargetAssessment } from './model';
@@ -212,7 +211,7 @@ export default function TargetDetailPanel({
{vehicles.slice(0, 5).map(vehicle => ( {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 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"> <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 className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
线 线
</span> </span>
+6 -7
View File
@@ -1,4 +1,5 @@
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { buildAoaSheet, writeWorkbook } from '../../shared/xlsx';
import type { MonitoringVehicle } from './types'; import type { MonitoringVehicle } from './types';
interface ExportContext { 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 numFixedCols = BASE_HEADERS.length;
const wsCols: { wch: number }[] = [ 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:保留原有格式 // 每日明细 sheet:保留原有格式
const sheets: Array<{ name: string; sheet: XLSX.WorkSheet }> = [{ name: '车辆汇总', sheet: ws }];
if (dayKeys.length > 0) { if (dayKeys.length > 0) {
const detailHeaders = [ const detailHeaders = [
'车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域', '车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域',
@@ -140,7 +139,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
v.totalKm != null ? v.totalKm : '', v.totalKm != null ? v.totalKm : '',
]), ]),
]; ];
const detailWs = XLSX.utils.aoa_to_sheet(detailData); const detailWs = buildAoaSheet(detailData);
detailWs['!cols'] = [ detailWs['!cols'] = [
{ wch: 12 }, { wch: 12 },
{ wch: 16 }, { wch: 16 },
@@ -160,7 +159,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########'; 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(); const now = new Date();
@@ -178,5 +177,5 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
? '统计时间' ? '统计时间'
: isRange ? '区间' : '今日'; : isRange ? '区间' : '今日';
const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`; 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 { motion, AnimatePresence } from 'motion/react';
import { fetchNotifications, updateNotification } from './api'; import { fetchNotifications, updateNotification } from './api';
import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types'; import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
import SwapPreview from './SwapPreview'; import SwapPreview from './SwapPreview';
interface Props { 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 justify-between gap-2 mb-1">
<div className="flex items-center gap-1.5 text-xs min-w-0"> <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="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>
<div className="flex items-center gap-1.5 flex-shrink-0"> <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}`}> <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"> <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.department && <span className="font-medium">{shortDept(v.department)}</span>}
{v.manager && <span>{v.manager}</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>
)} )}
<div className="flex items-center gap-3 text-[10px] text-slate-400"> <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>
<div className="px-4 py-4 space-y-3"> <div className="px-4 py-4 space-y-3">
<div className="text-xs text-slate-500"> <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="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>
<div> <div>
<label className="text-[10px] text-slate-400 uppercase font-bold block mb-1"> (km, )</label> <label className="text-[10px] text-slate-400 uppercase font-bold block mb-1"> (km, )</label>
+4 -5
View File
@@ -4,7 +4,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import type { SchedulingSuggestion, CandidateVehicle } from './types'; import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
import SwapPreview from './SwapPreview'; import SwapPreview from './SwapPreview';
type SortKey = 'predicted' | 'current'; 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 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 justify-between px-3 py-2">
<div className="flex items-center gap-2 flex-wrap"> <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'}`}> <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 && ' · 跨区'} <MapPin size={9} />{c.region}{!c.isSameRegion && ' · 跨区'}
</span> </span>
@@ -174,7 +173,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
{/* Header — same style as candidate header */} {/* Header — same style as candidate header */}
<div className="flex items-center justify-between px-3 py-2"> <div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 flex-wrap"> <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-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-400">{v.vehicleType}</span>
<span className="text-[9px] text-slate-300">{v.targetName}</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.department && <span><b className="text-slate-700">{v.department}</b></span>}
{v.manager && <span><b className="text-slate-700">{v.manager}</b></span>} {v.manager && <span><b className="text-slate-700">{v.manager}</b></span>}
{(v.department || v.manager) && <span className="text-slate-200">|</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> <span>
30 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km 30 <b className="text-slate-700">{Math.round(v.customerAvgDaily)}</b> km
</span> </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"> <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" /> <Lock size={12} className="mt-0.5 flex-shrink-0" />
<span> <span>
<b className="font-mono"><Blur>{activeIntervention.plateNumber}</Blur></b> <b className="font-mono">{activeIntervention.plateNumber}</b>
</span> </span>
</div> </div>
)} )}
+2 -3
View File
@@ -2,7 +2,6 @@ import { useState, useMemo } from 'react';
import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react'; import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import type { SchedulingSuggestion } from './types'; import type { SchedulingSuggestion } from './types';
import Blur from '../../components/Blur';
interface Props { interface Props {
suggestions: SchedulingSuggestion[]; 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 justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-black text-slate-900 font-mono"> <span className="text-xs font-black text-slate-900 font-mono">
<Blur>{v.plateNumber}</Blur> {v.plateNumber}
</span> </span>
<span className="text-[9px] text-slate-400">{v.vehicleType}</span> <span className="text-[9px] text-slate-400">{v.vehicleType}</span>
<span className="text-[9px] text-slate-300">·</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"> <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.department && <span className="text-slate-500 font-medium">{v.department.replace('业务', '')}</span>}
{v.manager && <span className="text-slate-500">{v.manager}</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>
<div className="flex items-center gap-2 flex-shrink-0 ml-2"> <div className="flex items-center gap-2 flex-shrink-0 ml-2">
<span className="text-slate-500"> <span className="text-slate-500">
+3 -4
View File
@@ -2,7 +2,6 @@ import { useState } from 'react';
import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react'; import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react';
import { sendNotify, updateNotification } from './api'; import { sendNotify, updateNotification } from './api';
import type { SchedulingSuggestion, CandidateVehicle } from './types'; import type { SchedulingSuggestion, CandidateVehicle } from './types';
import Blur from '../../components/Blur';
interface Props { interface Props {
suggestion: SchedulingSuggestion; 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="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <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 className="text-[10px] text-slate-400 mt-0.5">{v.vehicleType} · {v.targetName}</div>
</div> </div>
<div className="text-right"> <div className="text-right">
@@ -82,7 +81,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
</div> </div>
</div> </div>
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500"> <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="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> <span> <b className={v.completionRate >= 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}</b></span>
</div> </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="bg-white rounded-2xl p-4 border border-emerald-300 shadow-sm">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <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 className="text-[10px] text-slate-400 mt-0.5">{c.vehicleType} · {c.targetName || '库存'} · {c.region}</div>
</div> </div>
<div className="text-right"> <div className="text-right">
@@ -1,6 +1,5 @@
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import Blur from '../../../components/Blur';
import type { BatchItem } from './model'; import type { BatchItem } from './model';
interface BatchConfirmModalProps { interface BatchConfirmModalProps {
@@ -44,9 +43,9 @@ export default function BatchConfirmModal({
{batchItems.map(({ suggestion, candidate }) => ( {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 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"> <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="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> </div>
{candidate.canQualifyAfterSwap ? ( {candidate.canQualifyAfterSwap ? (
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0"></span> <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 { useEffect, useMemo, useRef, useState } from 'react';
import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types'; import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types';
import { createHeatmapMap, loadAmap, type AmapInstance } from '../../shared/amap';
type Props = { type Props = {
config: AmapConfig; config: AmapConfig;
@@ -10,19 +11,6 @@ type Props = {
onMapClick: (longitude: number, latitude: number) => void; 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[]) { function getBounds(points: HeatmapPoint[]) {
if (points.length === 0) return null; if (points.length === 0) return null;
@@ -68,37 +56,9 @@ export default function AmapHeatmapCanvas({ config, points, max, metric, focusQu
async function initialize() { async function initialize() {
try { try {
window._AMapSecurityConfig = { securityJsCode: config.securityCode }; const AMap = await loadAmap(config);
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;
if (cancelled || !container) return; if (cancelled || !container) return;
const { map, heatmap } = createHeatmapMap(AMap, container, { radius: 25, opacity: [0.12, 0.82] });
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',
},
});
map.on('click', (event: any) => { map.on('click', (event: any) => {
clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()); clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat());
}); });
@@ -5,6 +5,7 @@ import HeatmapDetailPanel from './HeatmapDetailPanel';
import HeatmapFilters from './HeatmapFilters'; import HeatmapFilters from './HeatmapFilters';
import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api'; import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api';
import { recentDayRange } from '../../shared/date-range'; import { recentDayRange } from '../../shared/date-range';
import { HEATMAP_LEGEND_GRADIENT } from '../../shared/amap';
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types'; import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
// 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。 // 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。
@@ -225,7 +226,7 @@ export default function VehicleHeatmapModule() {
? '同一网格累计每日首个定位,同车跨天重复计数;对数平滑强度。' ? '同一网格累计每日首个定位,同车跨天重复计数;对数平滑强度。'
: '同一网格按 VIN 去重,同车仅计 1 辆;平方根平滑强度。'} : '同一网格按 VIN 去重,同车仅计 1 辆;平方根平滑强度。'}
</p> </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 className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span></span><span></span></div>
</div> </div>
+77
View File
@@ -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 };
}
+59
View File
@@ -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);
}