docs: 添加模块化重构实施计划
7 个 Task 的详细步骤,覆盖 SearchSelect 抽取、文件迁移、 AssetsModule 创建、Shell 布局、里程占位、App.tsx 重写和清理。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
509
docs/superpowers/plans/2026-04-01-modular-refactor.md
Normal file
509
docs/superpowers/plans/2026-04-01-modular-refactor.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# 模块化重构实施计划
|
||||
|
||||
> **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: PASS(AssetsModule 已自包含,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: PASS(Shell 尚未被引用)
|
||||
|
||||
- [ ] **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: 清理残留文件和旧引用"
|
||||
```
|
||||
Reference in New Issue
Block a user