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() {
>