Files
ln-bi/docs/superpowers/plans/2026-04-01-modular-refactor.md
kkfluous b495cac0fe docs: 添加模块化重构实施计划
7 个 Task 的详细步骤,覆盖 SearchSelect 抽取、文件迁移、
AssetsModule 创建、Shell 布局、里程占位、App.tsx 重写和清理。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:07:02 +08:00

510 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块化重构实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将单体 App.tsx 拆分为模块化架构,支持多 BI 大类(资产管理、里程管理)通过全局导航切换。
**Architecture:** 新增 Shell 布局组件管理全局导航Web 侧边栏 / 移动端底部导航),每个 BI 模块作为独立目录modules/assets、modules/mileage通过 hash 路由切换。现有资产管理逻辑原样迁入 modules/assets/,去掉其内部底部导航。
**Tech Stack:** React 19, TypeScript, Tailwind CSS 4, Lucide Icons, Vite
---
## 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| 创建 | `src/components/SearchSelect.tsx` | 从 App.tsx 抽取的公共搜索下拉组件 |
| 创建 | `src/components/Shell.tsx` | 全局布局壳(侧边栏 + 底部导航 + 内容区) |
| 移动 | `src/types.ts``src/modules/assets/types.ts` | 资产管理类型定义 |
| 移动 | `src/api.ts``src/modules/assets/api.ts` | 资产管理 API 客户端 |
| 创建 | `src/modules/assets/AssetsModule.tsx` | 资产管理主组件(现 App.tsx 逻辑迁入) |
| 创建 | `src/modules/mileage/MileageModule.tsx` | 里程管理占位组件 |
| 重写 | `src/App.tsx` | 顶层壳:模块注册 + Shell 渲染 |
---
### Task 1: 抽取 SearchSelect 公共组件
**Files:**
- Create: `src/components/SearchSelect.tsx`
- [ ] **Step 1: 创建 SearchSelect 组件文件**
从现有 `src/App.tsx` 第 38-106 行抽取 SearchSelect 组件,加上必要的 import
```tsx
import { useState, useEffect, useMemo, useRef } from 'react';
import { ChevronDown } from 'lucide-react';
export function SearchSelect({ value, onChange, options, placeholder, className }: {
value: string;
onChange: (v: string) => void;
options: string[];
placeholder: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const filtered = useMemo(() => {
if (!query) return options;
const q = query.toLowerCase();
return options.filter((o) => o.toLowerCase().includes(q));
}, [options, query]);
const displayValue = value || '';
return (
<div ref={ref} className="relative">
<div
className={`flex items-center bg-white border border-gray-200 rounded-lg shadow-sm cursor-pointer transition-all focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-500 ${className || 'text-xs py-1.5 px-2'}`}
onClick={() => setOpen(!open)}
>
<input
type="text"
className="flex-1 outline-none bg-transparent min-w-0 text-inherit"
placeholder={displayValue || placeholder}
value={open ? query : displayValue}
onChange={(e) => { setQuery(e.target.value); if (!open) setOpen(true); }}
onFocus={() => { setOpen(true); setQuery(''); }}
/>
<ChevronDown size={14} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
{open && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-auto">
<div
className="px-2 py-1.5 text-xs text-gray-400 cursor-pointer hover:bg-gray-50"
onClick={() => { onChange(''); setQuery(''); setOpen(false); }}
>
{placeholder}
</div>
{filtered.map((o) => (
<div
key={o}
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-blue-50 transition-colors ${o === value ? 'bg-blue-50 text-blue-600 font-bold' : 'text-gray-700'}`}
onClick={() => { onChange(o); setQuery(''); setOpen(false); }}
>
{o}
</div>
))}
{filtered.length === 0 && (
<div className="px-2 py-3 text-xs text-gray-400 text-center"></div>
)}
</div>
)}
</div>
);
}
```
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: 无新增错误SearchSelect 尚未被引用,不影响现有代码)
- [ ] **Step 3: 提交**
```bash
git add src/components/SearchSelect.tsx
git commit -m "refactor: 抽取 SearchSelect 为公共组件"
```
---
### Task 2: 移动 types.ts 和 api.ts 到 assets 模块
**Files:**
- Move: `src/types.ts``src/modules/assets/types.ts`
- Move: `src/api.ts``src/modules/assets/api.ts`
- Modify: `src/App.tsx` (更新 import 路径)
- Modify: `src/modules/assets/api.ts` (更新 import 路径)
- [ ] **Step 1: 创建目录并移动文件**
```bash
mkdir -p src/modules/assets
git mv src/types.ts src/modules/assets/types.ts
git mv src/api.ts src/modules/assets/api.ts
```
- [ ] **Step 2: 更新 api.ts 内部的 import 路径**
`src/modules/assets/api.ts` 第 1-9 行import 路径从 `'./types'` 保持不变(同目录),无需修改。
- [ ] **Step 3: 更新 App.tsx 的 import 路径**
`src/App.tsx` 中的两处 import
```ts
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
import type { WeeklyDetailItem } from './api';
```
改为:
```ts
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './modules/assets/types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './modules/assets/api';
import type { WeeklyDetailItem } from './modules/assets/api';
```
- [ ] **Step 4: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASS无错误
- [ ] **Step 5: 验证开发服务器启动**
Run: `npm run dev:client` (启动后 Ctrl+C 关闭)
Expected: Vite 正常启动,无编译错误
- [ ] **Step 6: 提交**
```bash
git add -A
git commit -m "refactor: 移动 types.ts 和 api.ts 到 modules/assets/"
```
---
### Task 3: 创建 AssetsModule 组件
**Files:**
- Create: `src/modules/assets/AssetsModule.tsx`
- Modify: `src/App.tsx` (后续 Task 5 重写时替换)
这一步将 `src/App.tsx``export default function App()` 及其上方的 `TABS` 常量迁移为 `AssetsModule`,并做以下调整:
- [ ] **Step 1: 创建 AssetsModule.tsx**
复制 `src/App.tsx` 的全部内容到 `src/modules/assets/AssetsModule.tsx`,然后做以下修改:
**修改 1 — import 路径调整(文件顶部):**
将:
```ts
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './modules/assets/types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './modules/assets/api';
import type { WeeklyDetailItem } from './modules/assets/api';
```
改为:
```ts
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
import type { WeeklyDetailItem } from './api';
```
**修改 2 — SearchSelect 改为外部导入:**
删除文件中第 37-106 行的 SearchSelect 组件定义(`// --- SearchSelect Component ---` 到闭合的 `}`),替换为 import
```ts
import { SearchSelect } from '../../components/SearchSelect';
```
**修改 3 — 删除 import 中不再需要的图标:**
从 lucide-react import 中移除 `Users``Building2`(这两个只被底部导航使用,删除底部导航后不再需要)。`MapPin` 保留(在内容区域第 2106 行仍被使用)。
**修改 4 — 组件名改为 AssetsModule**
将:
```ts
export default function App() {
```
改为:
```ts
export default function AssetsModule() {
```
**修改 5 — 删除底部导航栏(原第 2772-2802 行):**
删除从 `{/* Footer / Navigation */}` 到其对应 `</div>` 的整个代码块:
```tsx
{/* Footer / Navigation */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-40">
......
</div>
```
**修改 6 — 去掉底部导航的 padding 留白:**
将根 div 的 className
```
min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 pb-20 md:pb-6 relative
```
改为:
```
min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative
```
即去掉 `pb-20 md:pb-6`,统一使用 `p-6` 的 padding。
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASSAssetsModule 已自包含App.tsx 仍引用旧路径但即将被重写)
- [ ] **Step 3: 提交**
```bash
git add src/modules/assets/AssetsModule.tsx
git commit -m "refactor: 创建 AssetsModule迁移资产管理逻辑"
```
---
### Task 4: 创建 Shell 布局组件
**Files:**
- Create: `src/components/Shell.tsx`
- [ ] **Step 1: 创建 Shell.tsx**
```tsx
import { useState, useEffect, type ComponentType } from 'react';
import { Truck, Route } from 'lucide-react';
export interface ModuleConfig {
id: string;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
component: ComponentType;
}
function getHashModule(modules: ModuleConfig[]): string {
const hash = window.location.hash.slice(1);
return modules.some((m) => m.id === hash) ? hash : modules[0]?.id ?? '';
}
export function Shell({ modules }: { modules: ModuleConfig[] }) {
const [activeModule, setActiveModule] = useState(() => getHashModule(modules));
useEffect(() => {
const onHashChange = () => setActiveModule(getHashModule(modules));
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, [modules]);
useEffect(() => {
if (!window.location.hash) {
window.location.hash = modules[0]?.id ?? '';
}
}, [modules]);
const switchModule = (id: string) => {
window.location.hash = id;
};
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
return (
<div className="flex min-h-screen">
{/* Web 侧边栏 (md 及以上) */}
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
{modules.map((m) => {
const Icon = m.icon;
const isActive = m.id === activeModule;
return (
<button
key={m.id}
onClick={() => switchModule(m.id)}
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
<Icon size={22} />
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
</button>
);
})}
</nav>
{/* 内容区 */}
<main className="flex-1 md:ml-16 pb-16 md:pb-0">
{ActiveComponent && <ActiveComponent />}
</main>
{/* 移动端底部导航 (md 以下) */}
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
{modules.map((m) => {
const Icon = m.icon;
const isActive = m.id === activeModule;
return (
<button
key={m.id}
onClick={() => switchModule(m.id)}
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
>
<Icon size={20} />
<span className="text-[10px] mt-1">{m.label}</span>
</button>
);
})}
</nav>
</div>
);
}
```
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASSShell 尚未被引用)
- [ ] **Step 3: 提交**
```bash
git add src/components/Shell.tsx
git commit -m "feat: 创建 Shell 布局组件(侧边栏 + 底部导航)"
```
---
### Task 5: 创建里程管理占位组件
**Files:**
- Create: `src/modules/mileage/MileageModule.tsx`
- [ ] **Step 1: 创建 MileageModule.tsx**
```tsx
import { Route } from 'lucide-react';
export default function MileageModule() {
return (
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
<div className="text-center">
<Route size={48} className="mx-auto text-gray-300 mb-4" />
<h2 className="text-lg font-semibold text-gray-500"></h2>
<p className="text-sm text-gray-400 mt-2">...</p>
</div>
</div>
);
}
```
- [ ] **Step 2: 提交**
```bash
mkdir -p src/modules/mileage
git add src/modules/mileage/MileageModule.tsx
git commit -m "feat: 创建里程管理占位组件"
```
---
### Task 6: 重写 App.tsx 为顶层壳
**Files:**
- Rewrite: `src/App.tsx`
- [ ] **Step 1: 重写 App.tsx**
用以下内容完全替换 `src/App.tsx`
```tsx
import { Truck, Route } from 'lucide-react';
import { Shell, type ModuleConfig } from './components/Shell';
import AssetsModule from './modules/assets/AssetsModule';
import MileageModule from './modules/mileage/MileageModule';
const MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
export default function App() {
return <Shell modules={MODULES} />;
}
```
- [ ] **Step 2: 验证 TypeScript 编译**
Run: `npx tsc --noEmit`
Expected: PASS无错误
- [ ] **Step 3: 验证 Vite 构建**
Run: `npm run build`
Expected: 构建成功,无错误
- [ ] **Step 4: 本地验证功能**
Run: `npm run dev`
手动检查:
1. 打开 `http://localhost:3000` — 应看到左侧侧边栏Web或底部导航移动端模拟
2. 默认进入资产管理,所有 Tab总览/按部门/按区域/按客户)正常
3. 数据正常加载显示
4. 点击"里程管理"切换到占位页面
5. 点击"资产管理"切回,数据和状态正常
6. URL hash 随切换变化(`#assets` / `#mileage`
7. 原有移动端底部的资产内部导航已消失
- [ ] **Step 5: 提交**
```bash
git add src/App.tsx
git commit -m "refactor: 重写 App.tsx 为模块化顶层壳"
```
---
### Task 7: 清理旧文件引用
**Files:**
- Delete: `src/types.ts` (如果 git mv 未处理干净)
- Delete: `src/api.ts` (如果 git mv 未处理干净)
- Verify: 无残留的旧 import 路径
- [ ] **Step 1: 确认无残留文件**
```bash
ls src/types.ts src/api.ts 2>/dev/null && echo "STALE FILES EXIST" || echo "CLEAN"
```
Expected: `CLEAN`Task 2 已用 `git mv` 移动)
如果有残留,删除它们:
```bash
rm -f src/types.ts src/api.ts
```
- [ ] **Step 2: 确认无旧 import 引用**
```bash
grep -r "from '\./types'" src/ --include="*.tsx" --include="*.ts" | grep -v modules/assets/ | grep -v node_modules
grep -r "from '\./api'" src/ --include="*.tsx" --include="*.ts" | grep -v modules/assets/ | grep -v node_modules
```
Expected: 无输出(所有引用都已更新到新路径)
- [ ] **Step 3: 最终构建验证**
Run: `npm run build`
Expected: 构建成功
- [ ] **Step 4: 提交(如有清理)**
```bash
git add -A
git status
# 如果有变更则提交:
git commit -m "chore: 清理残留文件和旧引用"
```