diff --git a/README.md b/README.md index c23fbea..63f93a3 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ src/ │ ├── ele/ 电能数据导入 │ ├── admin/ 反馈后台 │ └── *-heatmap/ 两类热力图 -├── shared/ 前后端共享的叶子层(角色常量、跨端 DTO) +├── shared/ 前后端共享的叶子层(角色常量、跨端 DTO、日期区间、Excel、高德接入) └── server/ ├── config.ts 环境变量集中读取 + 启动期校验 ├── app.ts 应用装配(无副作用,便于测试) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 86f9edf..b82c73a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -122,6 +122,17 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域 - **运行时建表仍分散在业务代码里**:`ele`、`feedback`、`mileage` 在首次调用时执行 `CREATE TABLE`,而 `read-only` 中间件只按 HTTP 方法拦截,所以只读预览下 GET 仍可能触发建表。 应收敛到显式的迁移函数并只由 `bootstrap.ts` 调用。 -- **重复实现尚未合并**:Excel 导出有 3 套、高德初始化 2 份、数值格式化多处; - 公共实现 `modules/energy/hydrogen/model/display-format.ts` 目前只被自己的测试使用。 +- **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个 + 传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`。 - **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kB(gzip 50 kB),来自原型渲染方式(大量内联样式)。 + +### 已收敛的重复实现(不要退回多份) + +| 能力 | 唯一实现 | 由测试守护 | +| --- | --- | --- | +| Excel 导出(拼装与写出) | `src/shared/xlsx.ts` | 架构测试:`modules/**` 不得再出现 `book_new` / `book_append_sheet` / `writeFile(` | +| 高德 JSAPI 加载与底图 | `src/shared/amap.ts` | 架构测试:只有它可 import `@amap/amap-jsapi-loader` | +| 自然日区间(近 N 天/快捷区间) | `src/shared/date-range.ts`、`modules/energy/daily-range/model.ts` | `date-range` 无独立测试;改动请补 | +| 氢能数值格式化 | `modules/energy/hydrogen/model/display-format.ts` | `display-format.test.ts` | +| 角色常量与模块可见性 | `src/shared/auth/roles.ts` | `app/modules.test.ts` | + diff --git a/src/architecture.test.ts b/src/architecture.test.ts index db0c77a..3e0cab5 100644 --- a/src/architecture.test.ts +++ b/src/architecture.test.ts @@ -114,6 +114,27 @@ test("纯模型文件不得依赖 React(保证可被 node:test 直接测)", assert.deepEqual(violations, [], "model.ts 应只包含可在 Node 中直接执行的纯函数"); }); +test("高德 SDK 只在 shared/amap.ts 接入", () => { + // 两个热力图曾各自写一份 load + 底图 + 控件 + 色带,导致同类地图长得不一样。 + const importers = allFiles + .filter((f) => rel(f) !== "shared/amap.ts") + .filter((f) => specifiers(f).some((s) => s === "@amap/amap-jsapi-loader")) + .map(rel); + assert.deepEqual(importers, [], "请通过 src/shared/amap.ts 使用高德地图,不要各自加载 SDK"); +}); + +test("Excel 导出只在 shared/xlsx.ts 组装工作簿", () => { + // 允许 server 侧直接读 xlsx(导入解析),但不允许前端再次自行拼装 workbook。 + const offenders = allFiles + .filter((f) => rel(f).startsWith("modules/")) + .filter((f) => { + const source = readFileSync(f, "utf8"); + return /book_new|book_append_sheet|writeFile\(/.test(source); + }) + .map(rel); + assert.deepEqual(offenders, [], "请改用 src/shared/xlsx.ts 的 writeWorkbook / export*Sheet"); +}); + test("类型豁免清单只减不增:@ts-nocheck 仅限已登记的 8113 原型快照", () => { // 这三个文件是逐字节保留的验收原型(UI/CSS 冻结),显式登记为唯一豁免。 const allowed = [ diff --git a/src/components/Blur.tsx b/src/components/Blur.tsx deleted file mode 100644 index bc3d2cc..0000000 --- a/src/components/Blur.tsx +++ /dev/null @@ -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 {children}; -} - -export function useDemoMode() { - return useContext(DemoModeContext); -} - -export default function Blur({ children }: { children: ReactNode }) { - const demo = useContext(DemoModeContext); - if (!demo) return <>{children}; - return {children}; -} diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 5c4da01..6a76785 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, type ComponentType, type ElementType, Sus import { motion } from 'motion/react'; import { Building2, ChevronRight } from 'lucide-react'; import { useAuth } from '../auth/useAuth'; -import { DemoModeProvider } from './Blur'; import FeedbackFab from './FeedbackFab'; import { cn } from '../lib/cn'; import { LoadingState } from './ui/surface'; @@ -93,7 +92,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { }, [user]); return ( -
{/* 氢费看板按原型交付;其他模块继续保留全局水印。 */} {!isHydrogenPrototype ? ( @@ -220,6 +218,5 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
-
); } diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index f582e45..a58038d 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -9,7 +9,7 @@ import { MapPin, } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; -import * as XLSX from 'xlsx'; +import { buildJsonSheet, writeWorkbook } from '../../shared/xlsx'; import { BarChart, Bar, @@ -40,7 +40,6 @@ import { } from './model'; import { SearchSelect } from '../../components/SearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect'; -import Blur from '../../components/Blur'; import RotatingFooterHint from '../../components/RotatingFooterHint'; import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface'; import { AssetsHeader } from './components/AssetsHeader'; @@ -393,7 +392,7 @@ export default function AssetsModule() { 业务负责人: item.manager || '', 客户: item.customerName || '', })); - const ws = XLSX.utils.json_to_sheet(table); + const ws = buildJsonSheet(table); ws['!cols'] = [ { wch: 14 }, { wch: 8 }, @@ -404,9 +403,7 @@ export default function AssetsModule() { { wch: 14 }, { wch: 24 }, ]; - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, '明细'); - XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`); + writeWorkbook([{ name: '明细', sheet: ws }], `${title}-${flowRange.start}-${flowRange.end}.xlsx`); }, [flowRange.end, flowRange.start, flowStats]); const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]); @@ -705,7 +702,7 @@ export default function AssetsModule() { >
{isManagerExpanded ? : } - {m.manager} + {m.manager}
{row.name} - {titleFor(state.level)}账本汇总汇总{formatNumber(row.recordCount, 0)} 笔 + {titleFor(state.level)}账本汇总汇总{formatFixed(row.recordCount, 0)} 笔 {expanded ? live.loading || live.error || !live.data ? {live.error ? `加载失败:${live.error},请收起后重试` : "正在加载子级数据…"} : <> @@ -543,8 +539,8 @@ function GroupTable({ {region ? {index + 1} : null} {row.name} {stationCustomer ? 0 ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}>{row.lingniuKg > 0 ? "羚牛车辆" : "外部车辆"} : row.province || "未归属"} - {monthMetric === "加氢量" ? <>{formatNumber(row.lingniuKg)}{formatNumber(row.externalKg)} : null} - {monthMetric === "客户收入" ? `¥${formatNumber(row.revenue)}` : monthMetric === "成本支出" ? `¥${formatNumber(row.cost)}` : formatNumber(row.kg)} + {monthMetric === "加氢量" ? <>{formatFixed(row.lingniuKg)}{formatFixed(row.externalKg)} : null} + {monthMetric === "客户收入" ? `¥${formatFixed(row.revenue)}` : monthMetric === "成本支出" ? `¥${formatFixed(row.cost)}` : formatFixed(row.kg)} {stationCustomer || region ? {totalKg > 0 ? `${((Number(row.kg) / totalKg) * 100).toFixed(1)}%` : "0.0%"} : null} ))} @@ -552,10 +548,10 @@ function GroupTable({ ); } if (kind === "station" && state.level === "date") { - return {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 onOpen(row)} style={{ cursor: "pointer" }}>; })}
日期加氢笔数加氢量 (Kg)较前日氢费收入 (元)平均单价 (元/Kg)
📅 {row.name}{formatNumber(row.recordCount, 0)} 笔{formatNumber(row.kg)}= 0 ? "ehb-day-change is-up" : "ehb-day-change is-down"}>{change === null ? "—" : `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`}¥{formatNumber(row.revenue)}¥{Number(row.kg) > 0 ? (Number(row.revenue) / Number(row.kg)).toFixed(2) : "0.00"}
; + return {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 onOpen(row)} style={{ cursor: "pointer" }}>; })}
日期加氢笔数加氢量 (Kg)较前日氢费收入 (元)平均单价 (元/Kg)
📅 {row.name}{formatFixed(row.recordCount, 0)} 笔{formatFixed(row.kg)}= 0 ? "ehb-day-change is-up" : "ehb-day-change is-down"}>{change === null ? "—" : `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`}¥{formatFixed(row.revenue)}¥{Number(row.kg) > 0 ? (Number(row.revenue) / Number(row.kg)).toFixed(2) : "0.00"}
; } if (kind === "customer" && state.level === "date") { - return {data.groups.map((row) => onOpen(row)} style={{ cursor: "pointer" }}>)}
日期 / 车牌明细加氢站承担加氢量 (Kg)成本支出 (元)应收 (元)已收未收
📅 {row.name}客户{formatNumber(row.kg)}¥{formatNumber(row.cost)}¥{formatNumber(row.revenue)}未接入未接入
; + return {data.groups.map((row) => onOpen(row)} style={{ cursor: "pointer" }}>)}
日期 / 车牌明细加氢站承担加氢量 (Kg)成本支出 (元)应收 (元)已收未收
📅 {row.name}客户{formatFixed(row.kg)}¥{formatFixed(row.cost)}¥{formatFixed(row.revenue)}未接入未接入
; } if (state.level === "record") { return ( @@ -628,9 +624,9 @@ function GroupTable({ 1 笔 - {formatNumber(record.kg)} + {formatFixed(record.kg)} - ¥{formatNumber(amountFor(record))} + ¥{formatFixed(amountFor(record))} ))} @@ -748,7 +744,7 @@ function GroupTable({ ) : state.level === "station" ? ( - 覆盖 {formatNumber(row.customerCount, 0)} 家客户 + 覆盖 {formatFixed(row.customerCount, 0)} 家客户 ) : ( 聚合 @@ -767,8 +763,8 @@ function GroupTable({ {state.level === "station" ? "汇总" : "点击展开"} - {formatNumber(row.recordCount, 0)} 笔 - {isProfit ? <>¥{formatNumber(row.revenue)}¥{formatNumber(row.cost)}¥{formatNumber(Number(row.revenue) - Number(row.cost))} : isMonth || isDay ? <>{formatNumber(row.kg)}¥{formatNumber(amountFor(row))}— : isCustomerBill ? <>¥{formatNumber(row.cost)}¥{formatNumber(row.revenue)}未接入未接入 : <>{formatNumber(row.kg)}¥{formatNumber(amountFor(row))}} + {formatFixed(row.recordCount, 0)} 笔 + {isProfit ? <>¥{formatFixed(row.revenue)}¥{formatFixed(row.cost)}¥{formatFixed(Number(row.revenue) - Number(row.cost))} : isMonth || isDay ? <>{formatFixed(row.kg)}¥{formatFixed(amountFor(row))}— : isCustomerBill ? <>¥{formatFixed(row.cost)}¥{formatFixed(row.revenue)}未接入未接入 : <>{formatFixed(row.kg)}¥{formatFixed(amountFor(row))}} {expanded && !expandedLoading && !expandedError && expandedData ? : null} {expanded ? ( @@ -810,17 +806,17 @@ function DrillSummaryCards({ kind, label: labelInput, data }: { kind: DrillKind; const externalKg = groups.reduce((sum, row) => sum + Number(row.externalKg || 0), 0); const monthMetric = label.match(/^\d{4}年\d{1,2}月(加氢量|客户收入|成本支出)$/)?.[1]; let cards: Array<[string, string, string?]>; - if (label === "加氢利润") cards = [["收入合计", `¥${formatNumber(revenue)}`, "income"], ["成本合计", `¥${formatNumber(cost)}`, "cost"], ["加氢利润", `¥${formatNumber(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount} 站`]]; - else if (label === "本月加氢") cards = [["本月加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatNumber(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount} 站`]]; - else if (label === "本日加氢") cards = [["本日加氢量", `${formatNumber(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatNumber(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount} 站`]]; - else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatNumber(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatNumber(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatNumber(kg)} Kg`], ["覆盖加氢站数", `${stationCount} 站`]]; - else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatNumber(revenue)}`, "income"], ["站均收入", `¥${formatNumber(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount} 站`]]; - else if (monthMetric === "成本支出") cards = [["成本支出合计", `¥${formatNumber(cost)}`, "cost"], ["站均成本", `¥${formatNumber(cost / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount} 站`]]; - else if (/^加氢站客户量:/.test(label)) cards = [["加氢站", label.replace(/^加氢站客户量:/, "")], ["羚牛车辆加氢总量", `${formatNumber(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatNumber(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatNumber(kg)} Kg`]]; - else if (/^区域(?:市|省):/.test(label)) cards = [["区域", label.replace(/^区域(?:市|省):/, "")], ["加氢总量", `${formatNumber(kg / 1000)} T`, "volume"], ["覆盖加氢站数", `${stationCount} 站`]]; - else if (kind === "station") cards = [["加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["氢费收入", `¥${formatNumber(revenue / 10000)} 万元`, "income"], ["平均单价", `¥${kg > 0 ? formatNumber(revenue / kg) : "0.00"} /Kg`], ["加氢笔数", `${formatNumber(summary?.recordCount ?? 0, 0)} 笔`]]; - else if (kind === "customer") cards = [["承担方", "客户承担"], ["加氢量", `${formatNumber(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatNumber(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatNumber(revenue)} 元`, "income"], ["已收", "未接入"], ["未收", "未接入"]]; - else cards = [["数据归集总量", `${formatNumber(kg / 1000)} T`, "volume"], ["数据总金额", `¥${formatNumber(cost / 10000)} 万元`, "income"], ["覆盖加氢站数", `${stationCount} 站`], ["来源记录完整度", `${summary?.recordCount ? formatNumber((Number(summary.traceableRecordCount) / Number(summary.recordCount)) * 100, 0) : "0"}%(含账本来源字段)`, "cost"]]; + if (label === "加氢利润") cards = [["收入合计", `¥${formatFixed(revenue)}`, "income"], ["成本合计", `¥${formatFixed(cost)}`, "cost"], ["加氢利润", `¥${formatFixed(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (label === "本月加氢") cards = [["本月加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (label === "本日加氢") cards = [["本日加氢量", `${formatFixed(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatFixed(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatFixed(revenue)}`, "income"], ["站均收入", `¥${formatFixed(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (monthMetric === "成本支出") cards = [["成本支出合计", `¥${formatFixed(cost)}`, "cost"], ["站均成本", `¥${formatFixed(cost / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (/^加氢站客户量:/.test(label)) cards = [["加氢站", label.replace(/^加氢站客户量:/, "")], ["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`]]; + else if (/^区域(?:市|省):/.test(label)) cards = [["区域", label.replace(/^区域(?:市|省):/, "")], ["加氢总量", `${formatFixed(kg / 1000)} T`, "volume"], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (kind === "station") cards = [["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["氢费收入", `¥${formatFixed(revenue / 10000)} 万元`, "income"], ["平均单价", `¥${kg > 0 ? formatFixed(revenue / kg) : "0.00"} /Kg`], ["加氢笔数", `${formatFixed(summary?.recordCount ?? 0, 0)} 笔`]]; + else if (kind === "customer") cards = [["承担方", "客户承担"], ["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatFixed(revenue)} 元`, "income"], ["已收", "未接入"], ["未收", "未接入"]]; + else cards = [["数据归集总量", `${formatFixed(kg / 1000)} T`, "volume"], ["数据总金额", `¥${formatFixed(cost / 10000)} 万元`, "income"], ["覆盖加氢站数", `${stationCount} 站`], ["来源记录完整度", `${summary?.recordCount ? formatFixed((Number(summary.traceableRecordCount) / Number(summary.recordCount)) * 100, 0) : "0"}%(含账本来源字段)`, "cost"]]; return
{cards.map(([title, value, tone]) =>
{title}{value}
)}
; } @@ -1082,7 +1078,7 @@ export function PrototypeDrillModal({ rows.push([row.name, row.kg, row.cost, row.revenue]), ); } - downloadExcelAoa(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透"); + exportAoaSheet(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透"); }; const exportCurrent = () => { if (data) exportData(data, `第${page}页`); @@ -1244,8 +1240,8 @@ export function PrototypeDrillModal({ {cleanLabel === "加氢利润" && customerBearingScope ? (
利润口径:客户承担订单的对客总价 ¥ - {formatNumber(live.data?.summary.revenue)} − 成本总价 ¥ - {formatNumber(live.data?.summary.cost)} = ¥{formatNumber(profit)} + {formatFixed(live.data?.summary.revenue)} − 成本总价 ¥ + {formatFixed(live.data?.summary.cost)} = ¥{formatFixed(profit)}
) : null}
diff --git a/src/modules/energy/hydrogen/model/display-format.test.ts b/src/modules/energy/hydrogen/model/display-format.test.ts index 5a86723..bf00070 100644 --- a/src/modules/energy/hydrogen/model/display-format.test.ts +++ b/src/modules/energy/hydrogen/model/display-format.test.ts @@ -1,13 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { finiteNumber, formatNumber, formatScaled } from "./display-format"; +import { formatFixed, isFiniteNumberValue } from "./display-format"; -test("能源看板格式化边界区分真实零值与不可用值", () => { +test("默认口径:缺失值按 0 展示,与氢能明细表既有行为一致", () => { + assert.equal(formatFixed(0), "0.00"); + assert.equal(formatFixed(null), "0.00"); + assert.equal(formatFixed(undefined), "0.00"); + assert.equal(formatFixed(1234.5), "1,234.50"); + assert.equal(formatFixed(1234.5, 0), "1,235"); +}); + +test("显式开启 blankForMissing 时区分真实零值与不可用值", () => { for (const value of [null, undefined, NaN, Infinity, -Infinity, "0"]) { - assert.equal(finiteNumber(value), null); - assert.equal(formatNumber(value), "—"); - assert.equal(formatScaled(value, 1000), "—"); + assert.equal(isFiniteNumberValue(value), false); + assert.equal(formatFixed(value, 2, { blankForMissing: true }), "—"); } - assert.equal(formatNumber(0), "0.00"); - assert.equal(formatScaled(0, 1000), "0.00"); + assert.equal(formatFixed(0, 2, { blankForMissing: true }), "0.00"); }); diff --git a/src/modules/energy/hydrogen/model/display-format.ts b/src/modules/energy/hydrogen/model/display-format.ts index f67771a..8d30239 100644 --- a/src/modules/energy/hydrogen/model/display-format.ts +++ b/src/modules/energy/hydrogen/model/display-format.ts @@ -1,17 +1,33 @@ -export const finiteNumber = (value: unknown): number | null => - typeof value === "number" && Number.isFinite(value) ? value : null; +/** + * 氢能看板的数值格式化。 + * + * 此前两个下钻视图各自复制了一份同样的实现(共 42 处调用),另有一份 + * 无人使用的 "—" 版本只被自己的测试引用。这里收敛为唯一实现,并把 + * "缺失值是否显示为 0" 变成显式选项,而不是靠不同的函数名区分口径。 + */ -export const formatNumber = (value: unknown, digits = 2): string => { - const safe = finiteNumber(value); - return safe === null - ? "—" - : safe.toLocaleString("zh-CN", { - maximumFractionDigits: digits, - minimumFractionDigits: digits, - }); -}; +export interface FormatOptions { + /** + * 缺失或不可用(非有限数)时返回 "—" 而不是 0。 + * 默认 false:明细表按 0 展示,与既有氢能账本口径一致; + * 需要区分"真实零值"与"接口未返回"时显式开启。 + */ + blankForMissing?: boolean; +} -export const formatScaled = (value: unknown, divisor: number, digits = 2) => { - const safe = finiteNumber(value); - return safe === null ? "—" : formatNumber(safe / divisor, digits); -}; +/** 该值本身是否为可参与计算的有限数字(不把 null / undefined / "0" 视为数字)。 */ +export function isFiniteNumberValue(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +/** + * 固定小数位的千分位文案。 + * 默认路径与既有实现逐字一致:`Number(value ?? 0)` 后按固定小数位格式化。 + */ +export function formatFixed(value: unknown, digits = 2, options: FormatOptions = {}): string { + if (options.blankForMissing && !isFiniteNumberValue(value)) return '—'; + return Number(value ?? 0).toLocaleString('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} diff --git a/src/modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx b/src/modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx index a7772d9..983cd83 100644 --- a/src/modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx +++ b/src/modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx @@ -5,7 +5,7 @@ */ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeft, ChevronDown, ChevronUp, Download, RefreshCw } from 'lucide-react'; -import { downloadExcelAoa } from '../common/download-xls'; +import { exportAoaSheet } from '../../../../shared/xlsx'; import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton'; import { SPOT_PAY_METHOD_LABEL, @@ -364,7 +364,7 @@ export const StationDailyDetailView: React.FC<{ r.amountYuan, ]), ]; - downloadExcelAoa(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证'); + exportAoaSheet(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证'); } catch (reason) { if (!controller.signal.aborted) setExportError(reason instanceof Error ? reason.message : '导出失败,请重试'); } finally { diff --git a/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx b/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx index 3ac891f..e6b6cff 100644 --- a/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx +++ b/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types'; +import { createHeatmapMap, loadAmap, type AmapInstance } from '../../shared/amap'; type Props = { config: AmapConfig; @@ -10,19 +11,6 @@ type Props = { onMapClick: (longitude: number, latitude: number) => void; }; -type AmapInstance = { - Map: new (container: HTMLElement, options: Record) => any; - HeatMap: new (map: any, options: Record) => any; - ToolBar: new (options?: Record) => any; - Scale: new (options?: Record) => any; - Bounds: new (southWest: [number, number], northEast: [number, number]) => any; -}; - -declare global { - interface Window { - _AMapSecurityConfig?: { securityJsCode: string }; - } -} function getBounds(points: HydrogenHeatmapPoint[]) { if (!points.length) return null; @@ -62,36 +50,9 @@ export default function HydrogenAmapCanvas({ config, points, max, metric, focusQ async function initialize() { try { - window._AMapSecurityConfig = { securityJsCode: config.securityCode }; - const loaderModule = await import('@amap/amap-jsapi-loader'); - const AMap = await loaderModule.default.load({ - key: config.key, - version: '2.0', - plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'], - }) as unknown as AmapInstance; + const AMap = await loadAmap(config); if (cancelled || !container) return; - const map = new AMap.Map(container, { - viewMode: '2D', - zoom: 5, - center: [105.4, 34.4], - mapStyle: 'amap://styles/whitesmoke', - resizeEnable: true, - showLabel: true, - }); - map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } })); - map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } })); - const heatmap = new AMap.HeatMap(map, { - radius: 34, - opacity: [0.14, 0.84], - gradient: { - 0.1: '#2563eb', - 0.3: '#0891b2', - 0.5: '#16a34a', - 0.68: '#eab308', - 0.84: '#f97316', - 1: '#dc2626', - }, - }); + const { map, heatmap } = createHeatmapMap(AMap, container, { radius: 34, opacity: [0.14, 0.84] }); map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat())); mapRef.current = map; heatmapRef.current = heatmap; diff --git a/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx b/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx index a0359c6..4ef5cee 100644 --- a/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx +++ b/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx @@ -5,6 +5,7 @@ import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel'; import HydrogenHeatmapFilters from './HydrogenHeatmapFilters'; import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api'; import { recentDayRange } from '../../shared/date-range'; +import { HEATMAP_LEGEND_GRADIENT } from '../../shared/amap'; import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types'; // 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。 @@ -207,7 +208,7 @@ export default function HydrogenHeatmapModule() {
{metricName(metric)}{loading ? : null}

{metricDescription(metric)}

-
+
相对较低相对较高
diff --git a/src/modules/mileage/VehicleDetailModal.tsx b/src/modules/mileage/VehicleDetailModal.tsx index 5ef2a0a..8ce82f1 100644 --- a/src/modules/mileage/VehicleDetailModal.tsx +++ b/src/modules/mileage/VehicleDetailModal.tsx @@ -6,7 +6,6 @@ import { } from 'recharts'; import type { MileageSourceGroup, MonitoringVehicle } from './types'; import { fetchVehicleRecent, type VehicleRecentDay } from './api'; -import Blur from '../../components/Blur'; interface Props { vehicle: MonitoringVehicle | null; @@ -160,7 +159,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
- {vehicle.plate} + {vehicle.plate} {vehicle.isOnline ? '在线' : '离线'} @@ -169,7 +168,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: {vehicle.rentStatus || ''} {vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''} {vehicle.customer ? ` · ` : ''} - {vehicle.customer && {vehicle.customer}} + {vehicle.customer}
diff --git a/src/modules/mileage/daily-report/VehicleTable.tsx b/src/modules/mileage/daily-report/VehicleTable.tsx index 6c3aa35..5a71d3c 100644 --- a/src/modules/mileage/daily-report/VehicleTable.tsx +++ b/src/modules/mileage/daily-report/VehicleTable.tsx @@ -21,7 +21,6 @@ import { XAxis, YAxis, } from 'recharts'; -import Blur from '../../../components/Blur'; import type { MileageReportGroup } from '../api'; import { filterAndSortVehicles, @@ -167,7 +166,7 @@ export default function VehicleTable({ group }: { group: MileageReportGroup }) {
- {trendVehicle.plate} + {trendVehicle.plate} 近7日里程
@@ -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' : ''}`} > - {vehicle.plate} + {vehicle.plate} {vehicle.status} {vehicle.department || vehicle.inventoryRegion || '未标注'} - {vehicle.customer || '未绑定客户'} + {vehicle.customer || '未绑定客户'} 0 ? 'text-rose-600' : 'text-slate-700'}`}> {fmtKm(vehicle.dailyMileage)} km diff --git a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx index 38ea06e..50af1f6 100644 --- a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx +++ b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx @@ -1,7 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react'; import { motion } from 'motion/react'; -import Blur from '../../../../components/Blur'; import type { MileageSourceGroup, MonitoringFilters, MonitoringStats, MonitoringVehicle } from '../../types'; import { vehicleStatisticTime } from '../oneos-time'; import { MILEAGE_SOURCE_META, vehicleSourceDisplay } from '../source-display'; @@ -292,7 +291,7 @@ export default function FullscreenMonitoring({
-
{v.plate}
+
{v.plate}
- {v.customer || '-'} + {v.customer || '-'} {v.brand || '-'} {v.rentStatus || '-'} {v.department || '-'} diff --git a/src/modules/mileage/monitoring/components/VehicleList.tsx b/src/modules/mileage/monitoring/components/VehicleList.tsx index 922523c..a6015c7 100644 --- a/src/modules/mileage/monitoring/components/VehicleList.tsx +++ b/src/modules/mileage/monitoring/components/VehicleList.tsx @@ -1,7 +1,6 @@ import type { RefObject } from 'react'; import { Truck } from 'lucide-react'; import { motion } from 'motion/react'; -import Blur from '../../../../components/Blur'; import type { MonitoringVehicle } from '../../types'; import { vehicleStatisticTime } from '../oneos-time'; import { vehicleSourceDisplay } from '../source-display'; @@ -73,7 +72,7 @@ export default function VehicleList({
- {v.plate} + {v.plate} {v.isOnline ? '在线' : '离线'} @@ -89,12 +88,12 @@ export default function VehicleList({
{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''} - {v.customer || '-'} + {v.customer || '-'}
-
{v.customer || '-'}
+
{v.customer || '-'}
{[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'}
diff --git a/src/modules/mileage/statistics/AllVehiclesPanel.tsx b/src/modules/mileage/statistics/AllVehiclesPanel.tsx index ff356d0..dfb0995 100644 --- a/src/modules/mileage/statistics/AllVehiclesPanel.tsx +++ b/src/modules/mileage/statistics/AllVehiclesPanel.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { ArrowUpDown, Calendar, Search, Truck, X } from 'lucide-react'; -import Blur from '../../../components/Blur'; import type { TargetVehicle } from '../types'; import { filterAndSortTargetVehicles, @@ -116,7 +115,7 @@ export default function AllVehiclesPanel({
- {vehicle.plateNumber} + {vehicle.plateNumber} {vehicle.isOnline ? '在线' : '离线'} diff --git a/src/modules/mileage/statistics/TargetDetailPanel.tsx b/src/modules/mileage/statistics/TargetDetailPanel.tsx index 0f4ac5b..1bfbecd 100644 --- a/src/modules/mileage/statistics/TargetDetailPanel.tsx +++ b/src/modules/mileage/statistics/TargetDetailPanel.tsx @@ -1,6 +1,5 @@ import { AnimatePresence, motion } from 'motion/react'; import { ChevronDown, Maximize2, Truck } from 'lucide-react'; -import Blur from '../../../components/Blur'; import type { TargetSummary, TargetVehicle } from '../types'; import { fmtDateLabel, fmtKm, fmtPercent, getTargetAssessment } from './model'; @@ -212,7 +211,7 @@ export default function TargetDetailPanel({ {vehicles.slice(0, 5).map(vehicle => (
- {vehicle.plateNumber} + {vehicle.plateNumber} 在线 diff --git a/src/modules/mileage/xlsx-export.ts b/src/modules/mileage/xlsx-export.ts index c77ffd7..eb489f6 100644 --- a/src/modules/mileage/xlsx-export.ts +++ b/src/modules/mileage/xlsx-export.ts @@ -1,4 +1,5 @@ import * as XLSX from 'xlsx'; +import { buildAoaSheet, writeWorkbook } from '../../shared/xlsx'; import type { MonitoringVehicle } from './types'; interface ExportContext { @@ -74,7 +75,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont }), ]; - const ws = XLSX.utils.aoa_to_sheet(summaryData); + const ws = buildAoaSheet(summaryData); const numFixedCols = BASE_HEADERS.length; const wsCols: { wch: number }[] = [ @@ -114,10 +115,8 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont } } - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, '车辆汇总'); - // 每日明细 sheet:保留原有格式 + const sheets: Array<{ name: string; sheet: XLSX.WorkSheet }> = [{ name: '车辆汇总', sheet: ws }]; if (dayKeys.length > 0) { const detailHeaders = [ '车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域', @@ -140,7 +139,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont v.totalKm != null ? v.totalKm : '', ]), ]; - const detailWs = XLSX.utils.aoa_to_sheet(detailData); + const detailWs = buildAoaSheet(detailData); detailWs['!cols'] = [ { wch: 12 }, { wch: 16 }, @@ -160,7 +159,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########'; } } - XLSX.utils.book_append_sheet(wb, detailWs, '每日明细'); + sheets.push({ name: '每日明细', sheet: detailWs }); } const now = new Date(); @@ -178,5 +177,5 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont ? '统计时间' : isRange ? '区间' : '今日'; const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`; - XLSX.writeFile(wb, filename); + writeWorkbook(sheets, filename); } diff --git a/src/modules/scheduling/NotificationHistory.tsx b/src/modules/scheduling/NotificationHistory.tsx index 6f665f8..2821249 100644 --- a/src/modules/scheduling/NotificationHistory.tsx +++ b/src/modules/scheduling/NotificationHistory.tsx @@ -3,7 +3,6 @@ import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2, ChevronRight import { motion, AnimatePresence } from 'motion/react'; import { fetchNotifications, updateNotification } from './api'; import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types'; -import Blur from '../../components/Blur'; import SwapPreview from './SwapPreview'; interface Props { @@ -201,9 +200,9 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa >
- {rec.currentPlate} + {rec.currentPlate} - {rec.candidatePlate} + {rec.candidatePlate}
@@ -216,7 +215,7 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
{v.department && {shortDept(v.department)}} {v.manager && {v.manager}} - {v.customer || '-'} + {v.customer || '-'}
)}
@@ -281,9 +280,9 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
- {executeTarget.currentPlate} + {executeTarget.currentPlate} - {executeTarget.candidatePlate} + {executeTarget.candidatePlate}
diff --git a/src/modules/scheduling/SuggestionDetail.tsx b/src/modules/scheduling/SuggestionDetail.tsx index 1ced05d..dd1dc0c 100644 --- a/src/modules/scheduling/SuggestionDetail.tsx +++ b/src/modules/scheduling/SuggestionDetail.tsx @@ -4,7 +4,6 @@ import { } from 'lucide-react'; import { motion } from 'motion/react'; import type { SchedulingSuggestion, CandidateVehicle } from './types'; -import Blur from '../../components/Blur'; import SwapPreview from './SwapPreview'; type SortKey = 'predicted' | 'current'; @@ -83,7 +82,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
- {c.plateNumber} + {c.plateNumber} {c.region}{!c.isSameRegion && ' · 跨区'} @@ -174,7 +173,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce {/* Header — same style as candidate header */}
- {v.plateNumber} + {v.plateNumber} {v.region} {v.vehicleType} {v.targetName} @@ -189,7 +188,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce {v.department && {v.department}} {v.manager && {v.manager}} {(v.department || v.manager) && |} - 客户 {v.customer || '-'} + 客户 {v.customer || '-'} 30日均 {Math.round(v.customerAvgDaily)} km @@ -264,7 +263,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
- 此车已干预替换为 {activeIntervention.plateNumber}。如需更换方案,请先在该候选车处解除干预。 + 此车已干预替换为 {activeIntervention.plateNumber}。如需更换方案,请先在该候选车处解除干预。
)} diff --git a/src/modules/scheduling/SuggestionList.tsx b/src/modules/scheduling/SuggestionList.tsx index 4e6d478..ec9ba5d 100644 --- a/src/modules/scheduling/SuggestionList.tsx +++ b/src/modules/scheduling/SuggestionList.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from 'react'; import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react'; import { motion } from 'motion/react'; import type { SchedulingSuggestion } from './types'; -import Blur from '../../components/Blur'; interface Props { suggestions: SchedulingSuggestion[]; @@ -126,7 +125,7 @@ export default function SuggestionList({ suggestions, onSelect, selectMode = fal
- {v.plateNumber} + {v.plateNumber} {v.vehicleType} · @@ -146,7 +145,7 @@ export default function SuggestionList({ suggestions, onSelect, selectMode = fal
{v.department && {v.department.replace('业务', '')}} {v.manager && {v.manager}} - {v.customer || '-'} + {v.customer || '-'}
diff --git a/src/modules/scheduling/SwapPreview.tsx b/src/modules/scheduling/SwapPreview.tsx index 5871e95..299e51c 100644 --- a/src/modules/scheduling/SwapPreview.tsx +++ b/src/modules/scheduling/SwapPreview.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react'; import { sendNotify, updateNotification } from './api'; import type { SchedulingSuggestion, CandidateVehicle } from './types'; -import Blur from '../../components/Blur'; interface Props { suggestion: SchedulingSuggestion; @@ -72,7 +71,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
-
{v.plateNumber}
+
{v.plateNumber}
{v.vehicleType} · {v.targetName}
@@ -82,7 +81,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
- {v.customer || '-'} + {v.customer || '-'} 日均 {Math.round(v.customerAvgDaily)} 完成 = 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}
@@ -99,7 +98,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
-
{c.plateNumber}
+
{c.plateNumber}
{c.vehicleType} · {c.targetName || '库存'} · {c.region}
diff --git a/src/modules/scheduling/scheduling-module/BatchConfirmModal.tsx b/src/modules/scheduling/scheduling-module/BatchConfirmModal.tsx index 0045efc..ee2c16c 100644 --- a/src/modules/scheduling/scheduling-module/BatchConfirmModal.tsx +++ b/src/modules/scheduling/scheduling-module/BatchConfirmModal.tsx @@ -1,6 +1,5 @@ import { X } from 'lucide-react'; import { motion } from 'motion/react'; -import Blur from '../../../components/Blur'; import type { BatchItem } from './model'; interface BatchConfirmModalProps { @@ -44,9 +43,9 @@ export default function BatchConfirmModal({ {batchItems.map(({ suggestion, candidate }) => (
- {suggestion.currentVehicle.plateNumber} + {suggestion.currentVehicle.plateNumber} - {candidate.plateNumber} + {candidate.plateNumber}
{candidate.canQualifyAfterSwap ? ( 可达标 diff --git a/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx b/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx index 222f3e0..0fa7891 100644 --- a/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx +++ b/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types'; +import { createHeatmapMap, loadAmap, type AmapInstance } from '../../shared/amap'; type Props = { config: AmapConfig; @@ -10,19 +11,6 @@ type Props = { onMapClick: (longitude: number, latitude: number) => void; }; -type AmapInstance = { - Map: new (container: HTMLElement, options: Record) => any; - HeatMap: new (map: any, options: Record) => any; - ToolBar: new (options?: Record) => any; - Scale: new (options?: Record) => any; - Bounds: new (southWest: [number, number], northEast: [number, number]) => any; -}; - -declare global { - interface Window { - _AMapSecurityConfig?: { securityJsCode: string }; - } -} function getBounds(points: HeatmapPoint[]) { if (points.length === 0) return null; @@ -68,37 +56,9 @@ export default function AmapHeatmapCanvas({ config, points, max, metric, focusQu async function initialize() { try { - window._AMapSecurityConfig = { securityJsCode: config.securityCode }; - const loaderModule = await import('@amap/amap-jsapi-loader'); - const AMap = await loaderModule.default.load({ - key: config.key, - version: '2.0', - plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'], - }) as unknown as AmapInstance; + const AMap = await loadAmap(config); if (cancelled || !container) return; - - const map = new AMap.Map(container, { - viewMode: '2D', - zoom: 5, - center: [105.4, 34.4], - mapStyle: 'amap://styles/whitesmoke', - resizeEnable: true, - showLabel: true, - }); - map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } })); - map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } })); - const heatmap = new AMap.HeatMap(map, { - radius: 25, - opacity: [0.12, 0.82], - gradient: { - 0.1: '#2563eb', - 0.3: '#0891b2', - 0.5: '#16a34a', - 0.68: '#eab308', - 0.84: '#f97316', - 1: '#dc2626', - }, - }); + const { map, heatmap } = createHeatmapMap(AMap, container, { radius: 25, opacity: [0.12, 0.82] }); map.on('click', (event: any) => { clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()); }); diff --git a/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx b/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx index e9a8a10..1b19006 100644 --- a/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx +++ b/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx @@ -5,6 +5,7 @@ import HeatmapDetailPanel from './HeatmapDetailPanel'; import HeatmapFilters from './HeatmapFilters'; import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api'; import { recentDayRange } from '../../shared/date-range'; +import { HEATMAP_LEGEND_GRADIENT } from '../../shared/amap'; import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types'; // 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。 @@ -225,7 +226,7 @@ export default function VehicleHeatmapModule() { ? '同一网格累计每日首个定位,同车跨天重复计数;对数平滑强度。' : '同一网格按 VIN 去重,同车仅计 1 辆;平方根平滑强度。'}

-
+
相对较低相对较高
diff --git a/src/shared/amap.ts b/src/shared/amap.ts new file mode 100644 index 0000000..bd9d664 --- /dev/null +++ b/src/shared/amap.ts @@ -0,0 +1,77 @@ +/** + * 高德地图 JSAPI 2.0 的唯一接入点。 + * + * 两个热力图模块此前各自重复了:SDK 版本与插件列表、安全码注入、 + * 底图参数、控件位置以及整条渐变色带。这些必须保持一致,否则"同类地图 + * 长得不一样",因此收敛到这里。各页面只保留自己的热力图参数(半径/透明度)。 + */ + +/** 最小结构类型:只声明我们实际用到的构造器。 */ +export type AmapInstance = { + Map: new (container: HTMLElement, options: Record) => any; + HeatMap: new (map: any, options: Record) => any; + ToolBar: new (options?: Record) => any; + Scale: new (options?: Record) => 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 = { + 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 { + 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 }; +} diff --git a/src/shared/xlsx.ts b/src/shared/xlsx.ts new file mode 100644 index 0000000..ad56c71 --- /dev/null +++ b/src/shared/xlsx.ts @@ -0,0 +1,59 @@ +import * as XLSX from 'xlsx'; + +/** + * Excel 导出的唯一实现。 + * + * 此前仓库里有 4 份各自拼装 workbook 的代码(assets 内联、mileage 内联、 + * hydrogen 的两份 aoa/json helper),产物后缀与 sheet 名截断规则也不一致。 + * 这里只收敛"组装与写出"这一层原语;各调用方仍自行决定列宽、冻结与数字格式, + * 以免改变已验收的导出样式。 + */ + +export type AoaRow = Array; + +/** 统一产物后缀:内部一律写出 .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[]): 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[], + fileName: string, + sheetName = 'Sheet1', +): void { + writeWorkbook([{ name: sheetName, sheet: buildJsonSheet(rows) }], fileName); +}