OpenSpec 項目實戰(四) | 佈局改造:給長列表裝上導航骨架
整理版優先睇
用 Tab 導航取代長列表,一個檔案搞掂分類篩選
呢篇文章係術哥分享佢喺 OpenSpec 項目入面,點樣將首頁嘅長列表改造成有 Tab 導航嘅佈局。佢係一個專注 AI 編程同開源嘅技術實踐者,今次嘅改造係因為原本按分類平鋪嘅頁面,隨住工具增加用戶要靠滾動揾嘢,唔夠方便。佢嘅結論係:用 useState 做過濾顯示嘅 Tab 導航方案,喺目前 4 個分類、5 個工具嘅規模下最簡單直接,而且只改咗 2 個檔案就完成。
文章記錄咗成個工作流程:由 Explore 階段比較四種方案(粘性 Tab、側邊導航、錨點跳轉、分類吸頂),揀咗 Tab 導航;到 Propose 階段自動產出提案、規格、設計同任務;Apply 階段實際改 Home.tsx,加咗 useState 控制 activeTab,用 Set 去重抽分類,仲提取咗 ToolCard 組件;最後 Verify 同 Archive 確認冇問題。
呢個案例好明顯展示咗前期基建嘅價值:因為之前做咗工具註冊中心同設計系統,今次只需要改一個核心檔案,TopNav、Layout、catalog.ts 全部唔使鬱。作者特別提醒,apply 完成後一定要人手確認檔案有冇改到,避免 AI 跳過寫檔案嘅 bug。
- Tab 導航方案最適合目前規模:4 個分類、5 個工具,用 useState + 過濾顯示,夠簡單直接
- 對比咗四種導航方案(粘性 Tab、側邊導航、錨點跳轉、分類吸頂),最後揀咗 Tab 欄跟內容滾動、唔吸頂
- 實作只改咗 2 個檔案:Home.tsx 加咗 Tab 邏輯同 ToolCard 組件,Home.test.tsx 加測試
- 前期做好工具註冊中心同設計系統,令後期改動可以集中喺單一檔案,體現「前期投入,後期收益」
- TDD 流程今次終於行得通:先寫測試(RED),再寫實作(GREEN),最後 30 個測試全部通過
問題:長列表用落就覺唔夠好
上期做完 UI 重設計之後,頁面係好睇咗,但所有工具仲係按分類平鋪喺一個長頁面。當初 catalog.ts 得 5 個工具、4 個分類,仲可以接受;但諗深一層,之後每加一個工具,首頁就多一張卡片,用戶就要靠滾動揾嘢,冇分類導航、冇錨點跳轉、冇篩選,成個頁面似列表多過似工具平台。
所以今期做佈局改造,個需求好簡單:畀首頁加上分類導航機制,令用戶可以快速跳到目標分類,唔使喺長列表入面碌嚟碌去。作者用 OpenSpec 嘅工作流,由 Explore 開始,行咗完整嘅 5 步流程。
作者喺 Explore 階段用 /opsx:explore 命令,AI 自動畀咗 4 個候選方案嘅 ASCII 圖
- 1 方案 A:粘性頂部 Tab 導航,直觀點擊即達,但分類多時會逼夾
- 2 方案 B:左側迷你導航(桌面)/ 抽屜式(流動),唔佔內容空間,但流動端交互成本高
- 3 方案 C:錨點式快速跳轉,侵入性最低,但需要內容區有清晰錨點
- 4 方案 D:分類標題吸頂 + 滾動進度,上下文感強,但失去全局概覽
作者揀咗方案 A,因為目前得 4 個分類,夠用;側邊欄太重,未來超過 8 個分類先再考慮換。佢仲決定要「全部」Tab 做默認,Tab 欄放喺首頁內容區頂部,唔改 TopNav(嗰個係全局導航),唔需要獨立 URL。
Propose:自動生成 5 個工件,再 Apply:改一個檔案解決問題
作者確認咗方案之後,AI 自動執行咗 Propose 階段,一次過產出咗 5 個工件:proposal.md、design.md、specs、review.md、tasks.md。proposal 講清楚 Why 同 What Changes,design 記錄咗三個核心決策:用 useState 管理 Tab 狀態、切換時 scroll to top、用 Flexbox 同下劃線高亮活躍 Tab。
到 Apply 階段,AI 自動執行咗 12 個 task,最終只改咗 2 個檔案:src/app/views/Home.tsx 同 Home.test.tsx。TopNav.tsx、Layout.tsx、router、catalog.ts 全部冇鬱,完全符合 Explore 階段嘅決策。
const tools = getTools();
const categories = [...new Set(tools.map((t) => t.category))].sort();
const [activeTab, setActiveTab] = useState<string>('全部');
const handleTabClick = (tab: string) => {
setActiveTab(tab);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const filteredTools =
activeTab === '全部' ? tools : tools.filter((t) => t.category === activeTab);
作者仲提取咗 ToolCard 子組件,將卡片渲染邏輯獨立出來複用。Tab 欄用 nav + button 渲染,活躍態用 border-b-2 下劃線高亮。條件渲染分兩種模式:「全部」時按分類分組展示,單分類時用扁平網格。
實際實現用 activeTab 而非 activeCategory,分類列表用 Set 去重而非從 grouped 對象取
Tab 樣式用下劃線 border-b-2 而非藍色填充背景,因為設計決策留到 apply 階段決定
Verify 同 Archive:雙重確認,避開之前嘅坑
Apply 完成之後,作者行咗 /opsx:verify,三個維度都 pass:Completeness 話 12 個 task 全部 commit 曬,Correctness 話 7 個 spec scenario 全部覆蓋,Coherence 話 design decision 有跟足。不過作者提醒,verify 冇做 apply 前後嘅 diff 對比,所以除咗跑 verify,仲要手動 confirm 關鍵檔案真係改咗。
作者跟住行 npm run build,零 error 零 warning;再 npm run dev 開瀏覽器逐項 check:默認「全部」Tab 激活,點「開發工具」淨係顯示「代碼解釋」一張卡,點「文本處理」顯示兩張卡,切返「全部」恢復曬,Console 冇報錯。
- 作者強調:apply 完成後一定要人手確認檔案有冇改到,因為 AI 有時會跳過實際寫檔案
- 今次只改 2 個檔案,原因係 catalog.ts 已經有 getTools() 同分類數據,design tokens 定義曬顏色變量,TopNav 同 Layout 唔需要鬱
- 作者話呢個就係「前期投入,後期收益」嘅體現
零 error 零 warning,npm run dev 逐項檢查 console 冇報錯
🚩 2026 年「術哥無界」系列實戰文檔 X 篇原創計劃 第 114 篇,OpenSpec 項目實戰「2026」系列第 4 篇
大家好,歡迎嚟到 術哥無界 | ShugeX | 運維有術。
我是術哥,一個專注 AI 編程、AI 智能體、Agent Skills、MCP、雲原生、AIOps、Milvus 向量數據庫嘅技術實踐者同開源佈道者!
Talk is cheap, let's explore。無界探索,有術而行。

圖 1:由單頁長列表到有導航結構嘅頁面 - 今期要做嘅嘢

說明:本文內容係基於 OpenSpec(Fission-AI/OpenSpec)v1.3.1 同 React 19 + TypeScript + Tailwind CSS v4 嘅實際操作記錄整理出嚟,所有命令同代碼都喺 shuge AI Toolbox 項目入面實際驗證過。文入面嘅配置模板同參數建議只係參考,實際效果請以你嘅業務數據同環境測試結果為準。如果有實際使用經驗,歡迎喺留言區分享交流。
1. 長列表仲用得,但唔夠好
第 3 期做完咗 UI 重新設計。CSS 變量體系由零開始搭起,77 行 tokens.css 定義咗完整嘅品牌色、中性色、字體、間距體系。路由包裹 bug 都修好咗——㩒入工具頁,導航欄唔會再消失。
打開 npm run dev 睇一睇:品牌色(藍色系)生效,卡片樣式統一,Beta 同 Planned 標籤顏色一致。靚就真係靚。
但用嚇用嚇就發現一個問題:所有工具仲係按分類平鋪喺一個長頁面上。catalog.ts 入面目前 5 個工具、4 個分類,仲可以接受。但之後每加一個工具,首頁就多一張卡片。到 15 個工具、8 個分類嘅時候,用戶就只能靠碌嚟揾工具。
冇分類導航,冇錨點跳轉,冇篩選。頁面似列表頁,唔似工具平台。
今期做佈局改造。change name: layout-restructure,一句話需求:幫首頁加上分類導航機制,等用戶可以快速定位到目標分類,唔使喺長列表入面碌嚟碌去。
完整流程不變:
Explore → Propose → Apply → Verify → Archive
↓ ↓ ↓ ↓ ↓
澄清 生成 按任務 驗證 歸檔
需求 5 工件 執行 檢查 change
2. Explore:揀邊種導航方案
上期係視覺變更,今期返到功能變更——唔改樣式,改佈局結構。
盤點當前佈局
先睇首頁代碼嘅核心邏輯。第 3 期 apply 之後嘅 src/app/views/Home.tsx,展示核心邏輯,卡片渲染同樣式細節省略:
export default function Home() {
const tools = getTools();
const grouped = tools.reduce<Record<string, typeof tools>>((acc, tool) => {
if (!acc[tool.category]) {
acc[tool.category] = [];
}
acc[tool.category].push(tool);
return acc;
}, {});
const sortedCategories = Object.keys(grouped).sort();
return (
<div className="space-y-12 px-6 py-8">
<div className="text-center py-12">
<h1 className="text-3xl font-bold mb-3"
style={{ color: 'var(--color-neutral-900)' }}>
shuge AI Toolbox
</h1>
<p style={{ color: 'var(--color-neutral-500)' }}>
探索 AI 工具,提升工作效率
</p>
</div>
{sortedCategories.map((category) => {
const categoryTools = grouped[category].sort((a, b) => {
const priorityDiff = stagePriority[a.stage] - stagePriority[b.stage];
if (priorityDiff !== 0) return priorityDiff;
return a.name.localeCompare(b.name);
});
return (
<section key={category}>
<h2 className="text-xl font-semibold mb-4"
style={{ color: 'var(--color-neutral-800)' }}>
{category}
</h2>
{/* 卡片網格 — 每張卡片顯示工具名稱、狀態標籤、描述 */}
</section>
);
})}
</div>
);
}
sortedCategories 按字母排序,map 逐個渲染 section。每個 section 係分類標題 + 工具卡片網格。邏輯清晰,數據流都簡單。但頁面結構係線性嘅——由上到下一串 section,中間冇導航機制。
TopNav 呢?逐字引用 src/layout/TopNav.tsx:
<div className="flex gap-4">
<Link
to="/"
className={`px-3 py-1 rounded-md transition-colors ${
isHome ? 'font-bold' : 'hover:opacity-80'
}`}
style={{
color: isHome ? 'var(--color-primary-500)' : 'var(--color-neutral-600)',
}}
>
首頁
</Link>
</div>
導航欄得一個「首頁」連結。而 catalog.ts 入面明明有 getToolsByCategory() 函數同完整嘅分類數據,TopNav 完全冇用過。
逐字引用 src/tool-registry/catalog.ts 嘅數據部分:
const tools: ToolManifest[] = [
{
id: 'text-summary',
name: '文本摘要',
route: '/tools/text-summary',
category: '文本處理',
description: '快速提取長文本的核心觀點',
stage: 'active',
},
{
id: 'json-formatter',
name: 'JSON 格式化',
route: '/tools/json-formatter',
category: '數據轉換',
description: '美化 JSON 數據結構',
stage: 'active',
},
{
id: 'code-explainer',
name: '代碼解釋',
route: '/tools/code-explainer',
category: '開發工具',
description: '解釋代碼片段的功能和邏輯',
stage: 'beta',
},
{
id: 'image-generator',
name: '圖片生成',
route: '/tools/image-generator',
category: '內容創作',
description: '根據描述生成圖片',
stage: 'planned',
},
{
id: 'markdown-table',
name: 'Markdown 表格',
route: '/tools/markdown-table',
category: '文本處理',
description: '將數據轉換為 Markdown 表格',
stage: 'planned',
},
];
4 個分類:文本處理、數據轉換、開發工具、內容創作。5 個工具。之後會繼續增加。
四種導航方案
執行 /opsx:explore,輸入描述:
今期做一個 layout-restructure 變更。目前首頁係按分類平鋪嘅長列表,冇導航結構。需要加入分類導航機制,等用戶可以快速定位到目標分類。
第 1 輪:4 個方案 + 4 個探索方向
執行 /opsx:explore,輸入描述:
今期做一個 layout-restructure 變更。目前首頁係按分類平鋪嘅長列表,冇導航結構。需要加入分類導航機制,等用戶可以快速定位到目標分類。
AI 先問咗幾個探索方向,然後畀出咗 4 個候選方案嘅 ASCII 圖:
┌─────────────────────────────────────────────────────────┐
│ 方案 A: 粘性頂部 Tab 導航 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ [全部] [分類A] [分類B] [分類C] [分類D] ... │ │
│ └──────────────────────────────────────────────────┘ │
│ 內容區域(滾動時導航欄固定) │
│ │
│ 優點:直觀,點擊即達 │
│ 缺點:分類多時擁擠,侵佔垂直空間 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 方案 B: 左側迷你導航(桌面端)/ 抽屜式(移動端) │
│ │
│ ┌────┐ ┌──────────────────────────────────────────┐ │
│ │ A │ │ │ │
│ │ B │ │ 內容區 │ │
│ │ C │ │ │ │
│ │ D │ │ │ │
│ └────┘ └──────────────────────────────────────────┘ │
│ │
│ 優點:不佔內容區空間 │
│ 缺點:移動端交互成本高 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 方案 C: 錨點式快速跳轉 │
│ │
│ 頁面內滾動時出現浮動導航: │
│ │
│ ● [🔝]│
│ ● [🔝]│
│ │
│ 點擊跳轉,滾動時淡入淡出 │
│ │
│ 優點:侵入性最小 │
│ 缺點:需要內容區有清晰錨點 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 方案 D: 分類標題吸頂 + 滾動進度 │
│ │
│ 滾動時當前分類標題固定在頂部: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 當前分類:████████ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ 優點:上下文感強,不佔額外空間 │
│ 缺點:失去全局概覽 │
└─────────────────────────────────────────────────────────┘
我答咗項目背景:
目前 4 個分類(文本處理、數據轉換、開發工具、內容創作)、5 個工具,單級分類,卡片網格佈局,數據靜態(catalog.ts)。用戶主要係揾特定分類。
揀方案 A(Tab 導航)。4 個分類啱啱好用,側邊欄太重。未來超過 8 個先考慮轉。
需要「全部」Tab 做默認狀態,同而家效果一致。
Tab 放喺首頁內容區頂部,唔改 TopNav(嗰個係全局導航)。
唔需要獨立 URL(分類篩選係臨時操作,唔關 SEO 事)。
單級分類,短期內唔會有二級。
第 2 輪:3 個實現細節
AI 追問咗三個實現細節。
Tab 切換行為?
| 過濾顯示 | |
| 滾動定位 |
方案 A 適合內容少、靜態嘅場景,DOM 簡單。方案 B 適合內容多、首次只加載首屏 lazy 嘅場景。
滾動狀態? 切到「文本處理」→ 碌返頂部,定係保持當前滾動位置?
Tab 欄位置? AI 畫咗一個結構圖確認 Tab 喺內容區入面嘅位置:
┌──────────────────────────────────────────────────────┐
│ TopNav (全局) │
├──────────────────────────────────────────────────────┤
│ │
│ ┌─ Content Area ─────────────────────────────────┐ │
│ │ [全部] [文本處理] [數據轉換] [開發工具] [內容創作] │ │
│ │ ───────────────────────────────────────────── │ │
│ │ │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │卡片1 │ │卡片2 │ │卡片3 │ │ │
│ │ └─────┘ └─────┘ └─────┘ │ │
│ │ │ │
│ │ ... │ │
│ └────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────┘
我嘅回答:
Tab 切換行為:揀 A(過濾顯示)。㩒「文本處理」淨係渲染該分類卡片,其他 DOM 移除。切返「全部」恢復全部顯示。
滾動狀態:切換 Tab 時返返頂部。因為切換後內容變咗,保持舊滾動位置冇意思。
Tab 欄位置:唔固定,跟住內容滾動。Tab 欄就喺內容區頂部,唔需要 sticky。目前得 5 個工具,一屏就睇得曬,唔需要吸頂。
AI 畀咗總結確認:
┌──────────────────────────────────────────────────────┐
│ TopNav (全局導航) │
├──────────────────────────────────────────────────────┤
│ │
│ Content Area: │
│ [全部] [文本處理] [數據轉換] [開發工具] [內容創作] │
│ ───────────────────────────────────────────────── │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │卡片1 │ │卡片2 │ │卡片3 │ (過濾顯示) │
│ └─────┘ └─────┘ └─────┘ │
│ │
│ 切換 Tab → 回到頂部 │
│ Tab 欄跟隨內容滾動,不吸頂 │
│ │
└──────────────────────────────────────────────────────┘
已經確認:過濾邏輯 A 方案、切換時 scroll to top、「全部」= 全量其他分類 = 過濾。等細化:活躍 Tab 樣式、切換動畫、工具分佈均勻性。
AI 問:要我而家創建 layout-restructure 變更並生成提案嗎?
我回覆:確認 創建。
3. Propose:5 個工件產出
Explore 最後一輪 AI 自動問係咪創建變更,確認之後 5 個工件一次過生成,唔需要獨立執行 propose 命令。
項目配置(openspec/config.yaml)指定咗 schema: with-review,產出順序固定:proposal → specs → design → review → tasks。
AI 完成之後輸出咗工件清單:
openspec/changes/layout-restructure/proposal.md | |
openspec/changes/layout-restructure/design.md | |
openspec/changes/layout-restructure/specs/category-navigation/spec.md | |
openspec/changes/layout-restructure/review.md | |
openspec/changes/layout-restructure/tasks.md |
核心決策已經記錄:
Tab 導航方案: useState+ 過濾顯示滾動重置:smooth scroll to top Tab 欄樣式:底線高亮,Flexbox 橫向佈局 唔吸頂,唔引入 URL 參數
proposal.md
proposal 嘅核心內容係 Why 同 What Changes。
Why:首頁目前係分類平鋪嘅長列表,用戶要手動滾動揾目標分類,效率低。
What Changes:
首頁內容區頂部加入分類 Tab 欄 Tab 欄包含「全部」Tab + 動態生成嘅各分類 Tab 㩒 Tab 過濾展示對應分類嘅工具卡片 唔新增路由,唔改現有接口
老實講,proposal.md 入面有啲內容唔完全準確——例如佢引用咗 src/pages/home-page/ 呢個唔存在嘅路徑,仲提到 Zustand 做狀態管理(實際用緊 useState)。呢個係 OpenSpec 生成工件時嘅已知限制:proposal 係基於項目結構推斷,唔一定同實際代碼完全對應。核心需求描述係啱嘅,細節以 design.md 同實際代碼為準。
specs.md
specs 用場景化嘅方式定義咗 Tab 導航嘅行為要求。核心場景:
WHEN 用戶打開首頁 → 應該顯示「全部」Tab 處於激活狀態,下面展示所有分類嘅工具卡片。
WHEN 用戶㩒某個分類 Tab → 嗰個 Tab 切換成激活態(底線高亮),下面只展示該分類嘅工具卡片。其他分類嘅 section 唔渲染。
WHEN 用戶再㩒「全部」Tab → 恢復顯示所有分類 section,同默認狀態一致。
WHEN catalog 入面新增分類 → Tab 欄會自動出現新嘅分類 Tab,唔需要手動添加。分類列表由 getTools() 動態提取。
design.md
design.md 有三個設計決策。
決策 1:Tab 狀態管理 — 用 useState 管理當前激活嘅分類。唔用 URL 參數(useSearchParams),理由係分類篩選係臨時操作,用戶唔會分享「篩選到開發工具」嘅連結。URL 保持乾淨。
決策 2:滾動重置 — Tab 切換時執行 window.scrollTo({ top: 0, behavior: 'smooth' })。因為切換後內容完全不同,保持舊滾動位置冇意思。
決策 3:Tab 欄樣式 — Flexbox 橫向佈局,活躍 Tab 用底線或者背景色高亮。具體實現留到 apply 階段決定。
design.md 入面嘅 Non-Goals 部分明確咗今期唔做嘅嘢:唔改 TopNav 同 Layout 結構、唔改 catalog.ts 接口、唔新增路由、唔做側邊欄。
review.md
review 嘅審查結果:
邊界條件嘅 ⚠️ 指出:缺少異常場景處理——空分類時 Tab 係咪隱藏、catalog 數據異常時點樣兜底、得一個分類時仲需唔需要 Tab 欄。呢啲係有價值嘅審查發現,不過今期分類數量固定而且數據嚟自靜態 catalog.ts,風險可控,暫且唔處理。
tasks.md
實際產出 4 個任務組,跟 TDD 流程(先寫失敗測試,再寫實現):
任務組 1:Tab 導航組件測試(TDD RED 階段) — 先寫 Tab 交互測試用例,預計全部失敗。
任務組 2:實現 Tab 導航 UI(TDD GREEN 階段) — 寫實現代碼,等測試由紅變綠。
任務組 3:優化卡片列表渲染 — 提取 ToolCard 子組件複用卡片渲染邏輯。
任務組 4:驗收測試 — 全部測試確認所有功能正常。
每個 task 包含完整嘅測試代碼同實現代碼,跟項目 config.yaml 入面嘅 TDD 規則。
4. Apply:改一個文件解決問題
執行:
/opsx:apply
AI 自動執行咗 12 個 task,完成後輸出報告:
## Implementation Complete
**Change:** layout-restructure
**Schema:** with-review
**Progress:** 12/12 tasks complete ✓
### Commits This Session
- `132cb45` test: add Tab navigation tests to Home
- `de953b9` feat: add Tab navigation to Home page
- `49f533a` feat: complete Tab navigation for Home page
### What Was Built
- Tab 導航欄位於內容區頂部(全部 + 4 個分類 Tab)
- 點擊分類 Tab 過濾顯示該分類工具卡片
- 「全部」Tab 顯示全部分類分組
- Tab 切換時 smooth scroll 到頂部
- 活躍 Tab 使用 `border-b-2 border-accent-500` 高亮
- 提取 `ToolCard` 組件複用
### Test Results
Test Files 6 passed (6)
Tests 30 passed (30)
All tasks complete! You can archive this change with `/opsx:archive`.
實際修改嘅文件
src/app/views/Home.tsx | ||
src/app/views/Home.test.tsx |
只改咗 2 個文件。TopNav.tsx、Layout.tsx、router/index.tsx、catalog.ts 都冇鬱過,符合 Explore 階段嘅決策。
Home.tsx 嘅核心變更
改造前嘅邏輯係 sortedCategories.map() 直接渲染所有分類 section。改造後多咗幾層:
首先,提取咗 ToolCard 子組件,將卡片渲染邏輯獨立出嚟複用:
function ToolCard({ tool }: { tool: ReturnType<typeof getTools>[number] }) {
return (
<Link
to={`/tools/${tool.id}`}
className={`block p-5 rounded-xl border transition-all ${
tool.stage === 'planned' ? 'opacity-60' : 'hover:shadow-md'
}`}
style={{
backgroundColor: 'var(--color-neutral-50)',
borderColor: 'var(--color-neutral-200)',
}}
>
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium" style={{ color: 'var(--color-neutral-900)' }}>
{tool.name}
</h3>
{tool.stage === 'planned' && (
<span className="text-xs px-2 py-0.5 rounded"
style={{ backgroundColor: 'var(--color-neutral-200)', color: 'var(--color-neutral-600)' }}>
Planned
</span>
)}
{tool.stage === 'beta' && (
<span className="text-xs px-2 py-0.5 rounded"
style={{ backgroundColor: 'var(--color-accent-100)', color: 'var(--color-accent-600)' }}>
Beta
</span>
)}
</div>
<p className="text-sm" style={{ color: 'var(--color-neutral-500)' }}>
{tool.description}
</p>
</Link>
);
}
然後喺 Home 組件入面,用 Set 去重獲取分類列表,useState 管理 activeTab:
const tools = getTools();
const categories = [...new Set(tools.map((t) => t.category))].sort();
const [activeTab, setActiveTab] = useState<string>('全部');
const handleTabClick = (tab: string) => {
setActiveTab(tab);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const filteredTools =
activeTab === '全部' ? tools : tools.filter((t) => t.category === activeTab);
Tab 欄用 nav + button 渲染,活躍態用 border-b-2 底線高亮:
<nav className="flex gap-2 border-b border-neutral-200">
<button onClick={() => handleTabClick('全部')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === '全部' ? 'border-b-2 border-accent-500 text-accent-600' : 'text-neutral-500 hover:text-neutral-900'
}`}>全部</button>
{categories.map((cat) => (
<button key={cat} onClick={() => handleTabClick(cat)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === cat ? 'border-b-2 border-accent-500 text-accent-600' : 'text-neutral-500 hover:text-neutral-900'
}`}>{cat}</button>
))}
</nav>
條件渲染分兩種模式:「全部」時按分類分組展示,單分類時扁平網格:
{activeTab === '全部' ? (
categories.map((category) => {
const categoryTools = filteredTools.filter((t) => t.category === category).sort((a, b) => {
const priorityDiff = stagePriority[a.stage] - stagePriority[b.stage];
if (priorityDiff !== 0) return priorityDiff;
return a.name.localeCompare(b.name);
});
return (
<section key={category}>
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--color-neutral-800)' }}>{category}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryTools.map((tool) => (<ToolCard key={tool.id} tool={tool} />))}
</div>
</section>
);
})
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTools.sort((a, b) => {
const priorityDiff = stagePriority[a.stage] - stagePriority[b.stage];
if (priorityDiff !== 0) return priorityDiff;
return a.name.localeCompare(b.name);
}).map((tool) => (<ToolCard key={tool.id} tool={tool} />))}
</div>
)}
同草稿預期嘅差異都幾大。實際實現用咗 activeTab 而非 activeCategory,分類列表用 Set 去重而唔係由 grouped 對象攞,Tab 樣式用底線 border-b-2 而唔係藍色填充背景,仲提取咗 ToolCard 子組件。
值得一提嘅係 TDD 流程今期終於行得啱。apply 過程中測試先 run 咗 6 個 FAIL(RED 階段),實現代碼寫入之後變成 10 PASS(GREEN 階段),最後全部驗收 30 PASS。前 3 期嘅 TDD 流程形同虛設,今期首次出現真實嘅 RED-GREEN 循環。
點解要改咁少?
上期改咗 8 個文件(新建 tokens.css + 7 個修改),今期只改咗 2 個。唔係偷懶,係前幾期嘅基礎設施到位咗。
catalog.ts 已經有 getTools() 同分類數據,唔需要新增接口。design tokens 已經定義曬所有需要嘅顏色變量,唔需要新增 CSS。TopNav 同 Layout 唔需要鬱——分類導航係內容層嘅交互,唔涉及全局佈局。路由都唔需要鬱——唔新增頁面,只係同一個頁面入面嘅篩選。
呢個大概就係「前期投入,後期收益」嘅體現。第 2 期做咗工具註冊中心,第 3 期做咗設計系統,今期加導航就可以只改一個文件。
Apply 完成之後嘅人手抽查
吸取上期 NotFound.tsx 漏改嘅教訓,今次 apply 完成之後手動打開 Home.tsx 確認代碼真係改過。上期嘅根因係 AI 標記咗任務完成但 skip 咗實際寫文件操作——呢個坑踩過一次就夠。

圖 2:首頁內容區頂部分類 Tab 欄 — 「全部」+ 4 個分類動態生成,活躍 Tab 用底線高亮
5. Verify:構建同瀏覽器雙重確認
/opsx:verify 三維度檢查
執行:
/opsx:verify
verify 嘅三維度檢查結果:
## Verification Report: layout-restructure
### Summary
| Dimension | Status |
|--------------|------------------------------|
| Completeness | ✅ All 12 tasks committed |
| Correctness | ✅ All 7 spec scenarios covered |
| Coherence | ✅ Design decisions followed |
verify 檢查咗 spec 覆蓋同 design 遵循,引用咗具體代碼行號(例如 useState<string>('全部') 喺 Home.tsx:56,tools.filter(...) 喺 Home.tsx:64),映射咗 7 個 spec scenario 到具體實現位置。但佢冇做 apply 前後嘅文件 diff 對比。所以除咗 run verify,仲要手動確認關鍵文件確實被修改咗。
構建驗證
npm run build
實際輸出:
> shuge-ai-toolbox@0.0.0 build
> tsc -b && vite build
vite v8.0.12 building client environment for production...
✓ 31 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.46 kB │ gzip: 0.30 kB
dist/assets/index-HNIv7IuC.css 17.34 kB │ gzip: 4.22 kB
dist/assets/index-D0MO6XrT.js 290.38 kB │ gzip: 92.34 kB
✓ built in 105ms
零 error 零 warning。
瀏覽器確認
npm run dev 啓動之後逐項檢查:
首頁加載:Tab 欄正常顯示喺標題區域下面,包含「全部」「內容創作」「開發工具」「數據轉換」「文本處理」5 個 Tab 默認狀態:「全部」Tab 處於激活態(底線高亮),下面按分類分組顯示全部 5 張工具卡片 㩒「開發工具」:頁面只顯示「代碼解釋」1 張卡片(Beta 標籤),其他分類消失 㩒「文本處理」:頁面顯示「文本摘要」同「Markdown 表格」兩張卡片(Markdown 表格帶 Planned 灰色標籤) 㩒「全部」:恢復顯示所有分類,返到默認狀態 Console:冇報錯、冇警告

圖 3:㩒「開發工具」Tab — 底線高亮,只顯示該分類下嘅工具卡片

圖 4:切返「全部」— 恢復顯示所有分類
6. Archive:歸檔
Verify 通過之後執行:
/opsx:archive
實際輸出:
## Archive Complete
**Change:** layout-restructure
**Schema:** with-review
**Archived to:** `openspec/changes/archive/2026-05-16-layout-restructure/`
**Specs:** No delta specs (new capability, no existing main spec to sync)
All artifacts complete. All tasks complete.
吸取上期 archive 假成功嘅教訓,執行之後即刻檢查:
ls openspec/changes/
確認 layout-restructure 目錄已經由 changes 搬咗去 archive 目錄。上期嘅坑係 AI 輸出咗 Archive Complete 但冇執行文件操作——今次親眼見到文件確實搬走咗先放心。
歸檔目錄:openspec/changes/archive/2026-05-16-layout-restructure/,包含 5 個工件嘅完整快照。
至此,layout-restructure 呢個 change 完整行完 5 個步驟。
7. Git 推送
OpenSpec 工作流程本身唔包括代碼提交。但 apply 過程中自動產生咗 3 個 commit:
132cb45test: add Tab navigation tests to Homede953b9feat: add Tab navigation to Home page49f533afeat: complete Tab navigation for Home page
呢 3 個 commit 係 apply 執行過程中按 TDD 階段自動提交嘅(test → feat → feat complete),唔係手動 git commit 嘅。
畀 AI 編程助手指令推送到 GitHub:
將今次嘅改動推送到 GitHub。
AI 執行咗 git push,推送去:https://github.com/shuge-x/shuge-ai-toolbox
8. 回顧:今期學到咗乜
小變更都有完整流程嘅價值
今期改動量唔大——核心只改咗 Home.tsx 一個文件,加咗 Tab 欄同篩選邏輯。但行曬完整嘅 Explore → Propose → Apply → Verify → Archive 流程,都仲有得着。
Explore 階段討論咗四種導航方案:粘性 Tab、左側欄、錨點跳轉、分類標題吸頂。4 個分類用 Tab 啱啱好,側邊欄太重。但呢個判斷係有時機性嘅——如果之後分類增長到 10 個以上,Tab 逼埋一排,可能要重新換成側邊欄。呢個決策記錄喺 proposal.md 入面,之後翻歸檔就會見到當初點解揀 Tab。
Propose 階段 specs.md 用場景化嘅方式定義咗 Tab 嘅行為——㩒乜、顯示乜、切返「全部」恢復乜。比「加個 Tab」呢種模糊描述精確得多。如果唔用 OpenSpec,呢啲行為可能只係腦入面有個大概,到寫代碼嘅時候先臨時決定。
依賴已有基礎設施
今期可以只改一個文件,全靠前幾期搭好嘅基礎設施。catalog.ts 的 getTools() 同分類數據已經準備好,design tokens 嘅 CSS 變量體系可以直接重用,TopNav 同 Layout 唔需要鬱。
講到尾,OpenSpec 嘅結構化流程令每期集中喺一個變更範圍。第 2 期做註冊中心,第 3 期做設計系統,第 4 期做導航,每期唔發散。如果一開始就隨便加功能,而家可能要同時改 catalog、路由、佈局、樣式——嗰啲先真係頭痛。
Tab 導航嘅限制
目前嘅 Tab 方案喺分類少(4-6 個)時體驗唔錯。但有兩個限制要注意。
第一,Tab 唔支援多級分類。如果將來工具多咗需要「文本處理 > 格式轉換」呢種層級結構,Tab 搞唔掂,要換成樹形側邊欄。
第二,Tab 狀態唔喺 URL 入面。用戶篩選咗「開發工具」之後 refresh 頁面,會返返「全部」。Explore 階段就決定唔做 URL 參數,但如果之後需要,加 useSearchParams 改動量唔大。
你做工具平台嘅時候,Tab 同側邊欄會揀邊個?歡迎喺留言區傾嚇。
AI 編程驗證嘅持續性
上期踩咗兩個坑(NotFound.tsx 漏改、archive 假成功),今期吸取咗教訓:apply 後手動抽查關鍵文件,archive 後檢查目錄結構。習慣養成之後,驗證成本唔高,但可以避免上期嗰種「AI 標咗完成但實際冇改」嘅問題。
呢個教訓可以提煉成一句話:artifact 記錄嘅係預期做乜,唔係已經做咗乜。 上期 NotFound.tsx 嘅坑暴露咗 verify 嘅盲區——佢檢查咗 spec 覆蓋同 design 遵循,但冇做 apply 前後嘅文件 diff。今期手動抽查就係喺度填補呢個盲區。
話雖如此,呢種驗證習慣唔限於 OpenSpec 工作流程。任何 AI 編程工具都存在同樣嘅風險——AI 話做咗,唔代表真係做咗。抽查係低成本高回報嘅操作。
9. 下期預告
佈局有咗骨架,導航有咗 Tab,首頁唔再係無限滾動嘅長列表。但頁面視覺上仲有提升空間——圖標系統缺位、交互動效為零、配色細節仲可以打磨。下一期做 UI 視覺優化,令頁面由「功能齊全」變成「好用靚仔」。
好喇,多謝你睇我嘅文章,如果鍾意可以點讚轉發俾需要嘅朋友,我哋下一期再見!敬請期待!
🚩 2026 年「術哥無界」系列實戰文檔 X 篇原創計劃 第 114 篇,OpenSpec 項目實戰「2026」系列第 4 篇
大家好,歡迎來到 術哥無界 | ShugeX | 運維有術。
我是術哥,一名專注於 AI 編程、AI 智能體、Agent Skills、MCP、雲原生、AIOps、Milvus 向量數據庫的技術實踐者與開源佈道者!
Talk is cheap, let's explore。無界探索,有術而行。

圖 1:從單頁長列表到有導航結構的頁面 - 本期要做的事

說明:本文內容基於 OpenSpec(Fission-AI/OpenSpec)v1.3.1 和 React 19 + TypeScript + Tailwind CSS v4 的實際操作記錄整理而成,所有命令和代碼均在 shuge AI Toolbox 項目中實際驗證。文中的配置模板和參數建議僅供參考,實際效果請以你的業務數據和環境測試結果為準。如果有實際使用經驗,歡迎在評論區分享交流。
1. 長列表還能用,但不夠好
第 3 期做完了 UI 重設計。CSS 變量體系從零搭起來了,77 行 tokens.css 定義了完整的品牌色、中性色、字體、間距體系。路由包裹 bug 也修了——點進工具頁,導航欄不會消失了。
打開 npm run dev 看一眼:品牌色(藍色系)生效,卡片樣式統一,Beta 和 Planned 標籤顏色一致。好看是好看。
但用着用着就發現一個問題:所有工具還是按分類平鋪在一個長頁面上。catalog.ts 裏目前 5 個工具、4 個分類,還能接受。但後續每加一個工具,首頁就多一張卡片。到 15 個工具、8 個分類的時候,用戶只能靠滾動找工具。
沒有分類導航,沒有錨點跳轉,沒有篩選。頁面像列表頁,不像工具平台。
這期做佈局改造。change name: layout-restructure,一句話需求:給首頁加上分類導航機制,讓用戶快速定位到目標分類,不用在長列表裏滾來滾去。
完整流程不變:
Explore → Propose → Apply → Verify → Archive
↓ ↓ ↓ ↓ ↓
澄清 生成 按任務 驗證 歸檔
需求 5 工件 執行 檢查 change
2. Explore:選哪種導航方案
上期是視覺變更,這期回到功能變更——不改樣式,改佈局結構。
盤點當前佈局
先看首頁代碼的核心邏輯。第 3 期 apply 後的 src/app/views/Home.tsx,展示核心邏輯,卡片渲染和樣式細節省略:
export default function Home() {
const tools = getTools();
const grouped = tools.reduce<Record<string, typeof tools>>((acc, tool) => {
if (!acc[tool.category]) {
acc[tool.category] = [];
}
acc[tool.category].push(tool);
return acc;
}, {});
const sortedCategories = Object.keys(grouped).sort();
return (
<div className="space-y-12 px-6 py-8">
<div className="text-center py-12">
<h1 className="text-3xl font-bold mb-3"
style={{ color: 'var(--color-neutral-900)' }}>
shuge AI Toolbox
</h1>
<p style={{ color: 'var(--color-neutral-500)' }}>
探索 AI 工具,提升工作效率
</p>
</div>
{sortedCategories.map((category) => {
const categoryTools = grouped[category].sort((a, b) => {
const priorityDiff = stagePriority[a.stage] - stagePriority[b.stage];
if (priorityDiff !== 0) return priorityDiff;
return a.name.localeCompare(b.name);
});
return (
<section key={category}>
<h2 className="text-xl font-semibold mb-4"
style={{ color: 'var(--color-neutral-800)' }}>
{category}
</h2>
{/* 卡片網格 — 每張卡片顯示工具名稱、狀態標籤、描述 */}
</section>
);
})}
</div>
);
}
sortedCategories 按字母排序,map 逐個渲染 section。每個 section 是分類標題 + 工具卡片網格。邏輯清晰,數據流也簡單。但頁面結構是線性的——從上到下一串 section,中間沒有導航機制。
TopNav 呢?逐字引用 src/layout/TopNav.tsx:
<div className="flex gap-4">
<Link
to="/"
className={`px-3 py-1 rounded-md transition-colors ${
isHome ? 'font-bold' : 'hover:opacity-80'
}`}
style={{
color: isHome ? 'var(--color-primary-500)' : 'var(--color-neutral-600)',
}}
>
首頁
</Link>
</div>
導航欄只有一個「首頁」連結。而 catalog.ts 裏明明有 getToolsByCategory() 函數和完整的分類數據,TopNav 完全沒用上。
逐字引用 src/tool-registry/catalog.ts 的數據部分:
const tools: ToolManifest[] = [
{
id: 'text-summary',
name: '文本摘要',
route: '/tools/text-summary',
category: '文本處理',
description: '快速提取長文本的核心觀點',
stage: 'active',
},
{
id: 'json-formatter',
name: 'JSON 格式化',
route: '/tools/json-formatter',
category: '數據轉換',
description: '美化 JSON 數據結構',
stage: 'active',
},
{
id: 'code-explainer',
name: '代碼解釋',
route: '/tools/code-explainer',
category: '開發工具',
description: '解釋代碼片段的功能和邏輯',
stage: 'beta',
},
{
id: 'image-generator',
name: '圖片生成',
route: '/tools/image-generator',
category: '內容創作',
description: '根據描述生成圖片',
stage: 'planned',
},
{
id: 'markdown-table',
name: 'Markdown 表格',
route: '/tools/markdown-table',
category: '文本處理',
description: '將數據轉換為 Markdown 表格',
stage: 'planned',
},
];
4 個分類:文本處理、數據轉換、開發工具、內容創作。5 個工具。後續會持續增長。
四種導航方案
執行 /opsx:explore,輸入描述:
本期做一個 layout-restructure 變更。當前首頁是按分類平鋪的長列表,沒有導航結構。需要加入分類導航機制,讓用戶能快速定位到目標分類。
第 1 輪:4 個方案 + 4 個探索方向
執行 /opsx:explore,輸入描述:
本期做一個 layout-restructure 變更。當前首頁是按分類平鋪的長列表,沒有導航結構。需要加入分類導航機制,讓用戶能快速定位到目標分類。
AI 先問了幾個探索方向,然後給出了 4 個候選方案的 ASCII 圖:
┌─────────────────────────────────────────────────────────┐
│ 方案 A: 粘性頂部 Tab 導航 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ [全部] [分類A] [分類B] [分類C] [分類D] ... │ │
│ └──────────────────────────────────────────────────┘ │
│ 內容區域(滾動時導航欄固定) │
│ │
│ 優點:直觀,點擊即達 │
│ 缺點:分類多時擁擠,侵佔垂直空間 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 方案 B: 左側迷你導航(桌面端)/ 抽屜式(移動端) │
│ │
│ ┌────┐ ┌──────────────────────────────────────────┐ │
│ │ A │ │ │ │
│ │ B │ │ 內容區 │ │
│ │ C │ │ │ │
│ │ D │ │ │ │
│ └────┘ └──────────────────────────────────────────┘ │
│ │
│ 優點:不佔內容區空間 │
│ 缺點:移動端交互成本高 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 方案 C: 錨點式快速跳轉 │
│ │
│ 頁面內滾動時出現浮動導航: │
│ │
│ ● [🔝]│
│ ● [🔝]│
│ │
│ 點擊跳轉,滾動時淡入淡出 │
│ │
│ 優點:侵入性最小 │
│ 缺點:需要內容區有清晰錨點 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 方案 D: 分類標題吸頂 + 滾動進度 │
│ │
│ 滾動時當前分類標題固定在頂部: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 當前分類:████████ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ 優點:上下文感強,不佔額外空間 │
│ 缺點:失去全局概覽 │
└─────────────────────────────────────────────────────────┘
我回答了項目背景:
當前 4 個分類(文本處理、數據轉換、開發工具、內容創作)、5 個工具,單級分類,卡片網格佈局,數據靜態(catalog.ts)。用戶主要找特定分類。
選方案 A(Tab 導航)。4 個分類剛好夠用,側邊欄太重。未來超 8 個再考慮換。
需要「全部」Tab 作為默認狀態,和當前效果一致。
Tab 放在首頁內容區頂部,不改 TopNav(那是全局導航)。
不需要獨立 URL(分類篩選是臨時操作,不涉及 SEO)。
單級分類,近期不會有二級。
第 2 輪:3 個實現細節
AI 追問了三個實現細節。
Tab 切換行為?
| 過濾顯示 | |
| 滾動定位 |
方案 A 適合內容少、靜態的場景,DOM 簡單。方案 B 適合內容多、首次只加載首屏 lazy 的場景。
滾動狀態? 切到「文本處理」→ 滾動到頂部,還是保持當前滾動位置?
Tab 欄位置? AI 畫了一個結構圖確認 Tab 在內容區中的位置:
┌──────────────────────────────────────────────────────┐
│ TopNav (全局) │
├──────────────────────────────────────────────────────┤
│ │
│ ┌─ Content Area ─────────────────────────────────┐ │
│ │ [全部] [文本處理] [數據轉換] [開發工具] [內容創作] │ │
│ │ ───────────────────────────────────────────── │ │
│ │ │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │卡片1 │ │卡片2 │ │卡片3 │ │ │
│ │ └─────┘ └─────┘ └─────┘ │ │
│ │ │ │
│ │ ... │ │
│ └────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────┘
我的回答:
Tab 切換行為:選 A(過濾顯示)。點擊「文本處理」只渲染該分類卡片,其他 DOM 移除。切回「全部」恢復全部顯示。
滾動狀態:切換 Tab 時回到頂部。因為切換後內容變了,保持舊滾動位置沒意義。
Tab 欄位置:不固定,跟隨內容滾動。Tab 欄就在內容區頂部,不需要 sticky。當前只有 5 個工具,一屏就能看完,不需要吸頂。
AI 給了總結確認:
┌──────────────────────────────────────────────────────┐
│ TopNav (全局導航) │
├──────────────────────────────────────────────────────┤
│ │
│ Content Area: │
│ [全部] [文本處理] [數據轉換] [開發工具] [內容創作] │
│ ───────────────────────────────────────────────── │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │卡片1 │ │卡片2 │ │卡片3 │ (過濾顯示) │
│ └─────┘ └─────┘ └─────┘ │
│ │
│ 切換 Tab → 回到頂部 │
│ Tab 欄跟隨內容滾動,不吸頂 │
│ │
└──────────────────────────────────────────────────────┘
已確認:過濾邏輯 A 方案、切換時 scroll to top、「全部」= 全量其他分類 = 過濾。待細化:活躍 Tab 樣式、切換動畫、工具分佈均勻性。
AI 問:要我現在創建 layout-restructure 變更並生成提案嗎?
我回復:確認 創建。
3. Propose:5 個工件產出
Explore 最後一輪 AI 自動詢問是否創建變更,確認後 5 個工件一次生成,不需要單獨執行 propose 命令。
項目配置(openspec/config.yaml)指定了 schema: with-review,產出順序固定:proposal → specs → design → review → tasks。
AI 完成後輸出了工件清單:
openspec/changes/layout-restructure/proposal.md | |
openspec/changes/layout-restructure/design.md | |
openspec/changes/layout-restructure/specs/category-navigation/spec.md | |
openspec/changes/layout-restructure/review.md | |
openspec/changes/layout-restructure/tasks.md |
核心決策已記錄:
Tab 導航方案: useState+ 過濾顯示滾動重置:smooth scroll to top Tab 欄樣式:下劃線高亮,Flexbox 橫向佈局 不吸頂,不引入 URL 參數
proposal.md
proposal 的核心內容是 Why 和 What Changes。
Why:首頁當前為分類平鋪的長列表,用戶需要手動滾動查找目標分類,效率低。
What Changes:
首頁內容區頂部添加分類 Tab 欄 Tab 欄包含「全部」Tab + 動態生成的各分類 Tab 點擊 Tab 過濾展示對應分類的工具卡片 不新增路由,不改現有接口
說實話,proposal.md 裏有些內容不完全準確——比如它引用了 src/pages/home-page/ 這個不存在的路徑,還提到了 Zustand 做狀態管理(實際用的是 useState)。這是 OpenSpec 生成工件時的已知侷限:proposal 基於項目結構推斷,不一定和實際代碼完全對應。核心需求描述是對的,細節以 design.md 和實際代碼為準。
specs.md
specs 用場景化的方式定義了 Tab 導航的行為要求。核心場景:
WHEN 用戶打開首頁 → 應顯示「全部」Tab 處於激活狀態,下方展示所有分類的工具卡片。
WHEN 用戶點擊某個分類 Tab → 該 Tab 切換為激活態(下劃線高亮),下方只展示該分類下的工具卡片。其他分類的 section 不渲染。
WHEN 用戶再次點擊「全部」Tab → 恢復顯示所有分類 section,和默認狀態一致。
WHEN catalog 中新增分類 → Tab 欄自動出現新的分類 Tab,不需要手動添加。分類列表從 getTools() 動態提取。
design.md
design.md 有三個設計決策。
決策 1:Tab 狀態管理 — 用 useState 管理當前激活的分類。不用 URL 參數(useSearchParams),理由是分類篩選是臨時操作,用戶不會分享「篩選到開發工具」的連結。URL 保持乾淨。
決策 2:滾動重置 — Tab 切換時執行 window.scrollTo({ top: 0, behavior: 'smooth' })。因為切換後內容完全不同,保持舊滾動位置沒有意義。
決策 3:Tab 欄樣式 — Flexbox 橫向佈局,活躍 Tab 使用下劃線或背景色高亮。具體實現留到 apply 階段決定。
design.md 中的 Non-Goals 部分明確了本期不做的事:不改 TopNav 和 Layout 結構、不改 catalog.ts 接口、不新增路由、不做側邊欄。
review.md
review 的審查結果:
邊界條件的 ⚠️ 指出:缺少異常場景處理——空分類時 Tab 是否隱藏、catalog 數據異常時如何兜底、僅一個分類時是否還需要 Tab 欄。這些是有價值的審查發現,不過本期分類數量固定且數據來自靜態 catalog.ts,風險可控,先不處理。
tasks.md
實際產出 4 個任務組,遵循 TDD 流程(先寫失敗測試,再寫實現):
任務組 1:Tab 導航組件測試(TDD RED 階段) — 先編寫 Tab 交互測試用例,預期全部失敗。
任務組 2:實現 Tab 導航 UI(TDD GREEN 階段) — 編寫實現代碼,讓測試從紅變綠。
任務組 3:優化卡片列表渲染 — 提取 ToolCard 子組件複用卡片渲染邏輯。
任務組 4:驗收測試 — 全量測試確認所有功能正常。
每個 task 包含完整的測試代碼和實現代碼,遵循項目 config.yaml 中的 TDD 規則。
4. Apply:改一個文件解決問題
執行:
/opsx:apply
AI 自動執行了 12 個 task,完成後輸出報告:
## Implementation Complete
**Change:** layout-restructure
**Schema:** with-review
**Progress:** 12/12 tasks complete ✓
### Commits This Session
- `132cb45` test: add Tab navigation tests to Home
- `de953b9` feat: add Tab navigation to Home page
- `49f533a` feat: complete Tab navigation for Home page
### What Was Built
- Tab 導航欄位於內容區頂部(全部 + 4 個分類 Tab)
- 點擊分類 Tab 過濾顯示該分類工具卡片
- 「全部」Tab 顯示全部分類分組
- Tab 切換時 smooth scroll 到頂部
- 活躍 Tab 使用 `border-b-2 border-accent-500` 高亮
- 提取 `ToolCard` 組件複用
### Test Results
Test Files 6 passed (6)
Tests 30 passed (30)
All tasks complete! You can archive this change with `/opsx:archive`.
實際修改的文件
src/app/views/Home.tsx | ||
src/app/views/Home.test.tsx |
只改了 2 個文件。TopNav.tsx、Layout.tsx、router/index.tsx、catalog.ts 都沒動,符合 Explore 階段的決策。
Home.tsx 的核心變更
改造前的邏輯是 sortedCategories.map() 直接渲染所有分類 section。改造後多了幾層:
首先,提取了 ToolCard 子組件,把卡片渲染邏輯獨立出來複用:
function ToolCard({ tool }: { tool: ReturnType<typeof getTools>[number] }) {
return (
<Link
to={`/tools/${tool.id}`}
className={`block p-5 rounded-xl border transition-all ${
tool.stage === 'planned' ? 'opacity-60' : 'hover:shadow-md'
}`}
style={{
backgroundColor: 'var(--color-neutral-50)',
borderColor: 'var(--color-neutral-200)',
}}
>
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium" style={{ color: 'var(--color-neutral-900)' }}>
{tool.name}
</h3>
{tool.stage === 'planned' && (
<span className="text-xs px-2 py-0.5 rounded"
style={{ backgroundColor: 'var(--color-neutral-200)', color: 'var(--color-neutral-600)' }}>
Planned
</span>
)}
{tool.stage === 'beta' && (
<span className="text-xs px-2 py-0.5 rounded"
style={{ backgroundColor: 'var(--color-accent-100)', color: 'var(--color-accent-600)' }}>
Beta
</span>
)}
</div>
<p className="text-sm" style={{ color: 'var(--color-neutral-500)' }}>
{tool.description}
</p>
</Link>
);
}
然後在 Home 組件中,用 Set 去重獲取分類列表,useState 管理 activeTab:
const tools = getTools();
const categories = [...new Set(tools.map((t) => t.category))].sort();
const [activeTab, setActiveTab] = useState<string>('全部');
const handleTabClick = (tab: string) => {
setActiveTab(tab);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const filteredTools =
activeTab === '全部' ? tools : tools.filter((t) => t.category === activeTab);
Tab 欄用 nav + button 渲染,活躍態用 border-b-2 下劃線高亮:
<nav className="flex gap-2 border-b border-neutral-200">
<button onClick={() => handleTabClick('全部')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === '全部' ? 'border-b-2 border-accent-500 text-accent-600' : 'text-neutral-500 hover:text-neutral-900'
}`}>全部</button>
{categories.map((cat) => (
<button key={cat} onClick={() => handleTabClick(cat)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === cat ? 'border-b-2 border-accent-500 text-accent-600' : 'text-neutral-500 hover:text-neutral-900'
}`}>{cat}</button>
))}
</nav>
條件渲染分兩種模式:「全部」時按分類分組展示,單分類時扁平網格:
{activeTab === '全部' ? (
categories.map((category) => {
const categoryTools = filteredTools.filter((t) => t.category === category).sort((a, b) => {
const priorityDiff = stagePriority[a.stage] - stagePriority[b.stage];
if (priorityDiff !== 0) return priorityDiff;
return a.name.localeCompare(b.name);
});
return (
<section key={category}>
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--color-neutral-800)' }}>{category}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryTools.map((tool) => (<ToolCard key={tool.id} tool={tool} />))}
</div>
</section>
);
})
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTools.sort((a, b) => {
const priorityDiff = stagePriority[a.stage] - stagePriority[b.stage];
if (priorityDiff !== 0) return priorityDiff;
return a.name.localeCompare(b.name);
}).map((tool) => (<ToolCard key={tool.id} tool={tool} />))}
</div>
)}
和草稿預期的差異挺大的。實際實現用的是 activeTab 而非 activeCategory,分類列表用 Set 去重而非從 grouped 對象取,Tab 樣式用下劃線 border-b-2 而非藍色填充背景,還提取了 ToolCard 子組件。
值得一提的是 TDD 流程這期終於走對了。apply 過程中測試先跑了 6 個 FAIL(RED 階段),實現代碼寫入後變成 10 PASS(GREEN 階段),最後全量驗收 30 PASS。前 3 期的 TDD 流程形同虛設,這期首次出現了真實的 RED-GREEN 循環。
為什麼要改這麼少?
上期改了 8 個文件(新建 tokens.css + 7 個修改),這期只改了 2 個。不是偷懶,是前幾期的基礎設施到位了。
catalog.ts 已經有 getTools() 和分類數據,不需要新增接口。design tokens 已經定義了所有需要的顏色變量,不需要新增 CSS。TopNav 和 Layout 不需要動——分類導航是內容層的交互,不涉及全局佈局。路由也不需要動——不新增頁面,只是同一個頁面內的篩選。
這大概就是「前期投入,後期收益」的體現。第 2 期做了工具註冊中心,第 3 期做了設計系統,這期加導航就能只改一個文件。
Apply 完成後的人工抽查
吸取上期 NotFound.tsx 漏改的教訓,這次 apply 完成後手動打開 Home.tsx 確認代碼確實改了。上期的根因是 AI 標記了任務完成但跳過了實際寫文件操作——這個坑踩過一次就夠了。

圖 2:首頁內容區頂部分類 Tab 欄 — 「全部」+ 4 個分類動態生成,活躍 Tab 用下劃線高亮
5. Verify:構建和瀏覽器雙確認
/opsx:verify 三維度檢查
執行:
/opsx:verify
verify 的三維度檢查結果:
## Verification Report: layout-restructure
### Summary
| Dimension | Status |
|--------------|------------------------------|
| Completeness | ✅ All 12 tasks committed |
| Correctness | ✅ All 7 spec scenarios covered |
| Coherence | ✅ Design decisions followed |
verify 檢查了 spec 覆蓋和 design 遵循,引用了具體代碼行號(比如 useState<string>('全部') 在 Home.tsx:56,tools.filter(...) 在 Home.tsx:64),映射了 7 個 spec scenario 到具體實現位置。但它沒有做 apply 前後的文件 diff 對比。所以除了跑 verify,還得手動確認關鍵文件確實被修改了。
構建驗證
npm run build
實際輸出:
> shuge-ai-toolbox@0.0.0 build
> tsc -b && vite build
vite v8.0.12 building client environment for production...
✓ 31 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.46 kB │ gzip: 0.30 kB
dist/assets/index-HNIv7IuC.css 17.34 kB │ gzip: 4.22 kB
dist/assets/index-D0MO6XrT.js 290.38 kB │ gzip: 92.34 kB
✓ built in 105ms
零 error 零 warning。
瀏覽器確認
npm run dev 啓動後逐項檢查:
首頁加載:Tab 欄正常顯示在標題區域下方,包含「全部」「內容創作」「開發工具」「數據轉換」「文本處理」5 個 Tab 默認狀態:「全部」Tab 處於激活態(下劃線高亮),下方按分類分組顯示全部 5 張工具卡片 點擊「開發工具」:頁面只顯示「代碼解釋」1 張卡片(Beta 標籤),其他分類消失 點擊「文本處理」:頁面顯示「文本摘要」和「Markdown 表格」兩張卡片(Markdown 表格帶 Planned 灰色標籤) 點擊「全部」:恢復顯示所有分類,回到默認狀態 Console:無報錯、無警告

圖 3:點擊「開發工具」Tab — 下劃線高亮,只顯示該分類下的工具卡片

圖 4:切回「全部」— 恢復顯示所有分類
6. Archive:歸檔
Verify 通過後執行:
/opsx:archive
實際輸出:
## Archive Complete
**Change:** layout-restructure
**Schema:** with-review
**Archived to:** `openspec/changes/archive/2026-05-16-layout-restructure/`
**Specs:** No delta specs (new capability, no existing main spec to sync)
All artifacts complete. All tasks complete.
吸取上期 archive 假成功的教訓,執行後立刻檢查:
ls openspec/changes/
確認 layout-restructure 目錄已經從 changes 移到了 archive 目錄。上期的坑是 AI 輸出了 Archive Complete 但沒執行文件操作——這次親眼看到文件確實移走了才放心。
歸檔目錄:openspec/changes/archive/2026-05-16-layout-restructure/,包含 5 個工件的完整快照。
至此,layout-restructure 這個 change 完整走完了 5 個步驟。
7. Git 推送
OpenSpec 工作流本身不包含代碼提交。但 apply 過程自動產生了 3 個 commit:
132cb45test: add Tab navigation tests to Homede953b9feat: add Tab navigation to Home page49f533afeat: complete Tab navigation for Home page
這 3 個 commit 是 apply 執行過程中按 TDD 階段自動提交的(test → feat → feat complete),不是手動 git commit 的。
給 AI 編程助手指令推送到 GitHub:
把這次的改動推送到 GitHub。
AI 執行了 git push,推送到:https://github.com/shuge-x/shuge-ai-toolbox
8. 回顧:本期學到了什麼
小變更也有完整流程的價值
這期改動量不大——核心只改了 Home.tsx 一個文件,加了 Tab 欄和篩選邏輯。但走完完整的 Explore → Propose → Apply → Verify → Archive 流程,還是有收穫。
Explore 階段討論了四種導航方案:粘性 Tab、左側欄、錨點跳轉、分類標題吸頂。4 個分類用 Tab 剛好,側邊欄太重。但這個判斷是有時間窗口的——如果後續分類增長到 10 個以上,Tab 擠成一排,可能要重新換成側邊欄。這個決策記錄在 proposal.md 裏,後續翻歸檔就能看到當初為什麼選 Tab。
Propose 階段 specs.md 用場景化的方式定義了 Tab 的行為——點擊什麼、顯示什麼、切回「全部」恢復什麼。比「加個 Tab」這種模糊描述精確得多。如果不用 OpenSpec,這些行為可能只在腦子裏有個大概,到寫代碼的時候再臨時決定。
依賴已有基礎設施
這期能只改一個文件,全靠前幾期搭好的基礎設施。catalog.ts 的 getTools() 和分類數據已經就緒,design tokens 的 CSS 變量體系可以直接複用,TopNav 和 Layout 不需要動。
說到底,OpenSpec 的結構化流程讓每期聚焦一個變更範圍。第 2 期做註冊中心,第 3 期做設計系統,第 4 期做導航,每期不發散。如果一開始就隨便加功能,現在可能要同時改 catalog、路由、佈局、樣式——那才是真的頭疼。
Tab 導航的侷限
當前的 Tab 方案在分類少(4-6 個)時體驗不錯。但有兩個侷限需要注意。
其一,Tab 不支持多級分類。如果未來工具多了需要「文本處理 > 格式轉換」這種層級結構,Tab 搞不定,得換成樹形側邊欄。
其二,Tab 狀態不在 URL 裏。用戶篩選了「開發工具」後刷新頁面,會回到「全部」。Explore 階段就決定不做 URL 參數,但如果後續需要,加 useSearchParams 改動量不大。
你做工具平台的時候,Tab 和側邊欄會選哪個?歡迎在評論區聊聊。
AI 編程驗證的持續性
上期踩了兩個坑(NotFound.tsx 漏改、archive 假成功),這期吸取了教訓:apply 後手動抽查關鍵文件,archive 後檢查目錄結構。習慣養成之後,驗證成本不高,但能避免上期那種「AI 標了完成但實際沒改」的問題。
這個教訓可以提煉成一句話:artifact 記錄的是預期做什麼,不是已經做了什麼。 上期 NotFound.tsx 的坑暴露了 verify 的盲區——它檢查了 spec 覆蓋和 design 遵循,但沒有做 apply 前後的文件 diff。這期手動抽查就是在彌補這個盲區。
話說回來,這種驗證習慣不限於 OpenSpec 工作流。任何 AI 編程工具都存在同樣的風險——AI 說做了,不代表真的做了。抽查是低成本高回報的操作。
9. 下期預告
佈局有了骨架,導航有了 Tab,首頁不再是無限滾動的長列表。但頁面視覺上還有提升空間——圖標系統缺位、交互動效為零、配色細節還可以打磨。下一期做 UI 視覺優化,讓頁面從「功能齊全」變成「好用好看」。
好啦,謝謝你觀看我的文章,如果喜歡可以點贊轉發給需要的朋友,我們下一期再見!敬請期待!