Files
Gobang-Game/docs/superpowers/plans/2026-05-31-gobang-v2-review-fixes.md
T
Serendipity 2ad05cab4b chore: 提交五子棋v2审查修复计划与前期优化
- 调整core/src/board.rs测试代码格式,移除多余逗号
- 重构core/src/record.rs日期测试断言为多行格式,提升可读性
- 更新Cargo.lock,添加网络对战所需的加密与网络依赖包
- 新增完整的v2版本审查修复计划文档,包含14个优先级分批的修复任务,覆盖bug修复、测试补全、国际化、功能新增等全方面优化内容
2026-05-31 15:28:59 +08:00

1328 lines
33 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.
# Gobang v2.0 审查问题修复计划
> **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:** 修复审查报告中的全部 P0/P1/P2/P3 问题,使项目达到可发布质量标准。
**Architecture:** 14 个修复按优先级分批执行:P0 消除用户可感知的 bug 和构建错误;P1 补充测试覆盖和完成功能断层;P2 完善 UI 国际化与交互;P3 擦亮文档和日志。每批内部独立可并行,批次之间按依赖关系顺序推进。
**Tech Stack:** Rust (core + gui), TypeScript/React, Tauri 2.x IPC, vitest, i18next
---
## 文件变更总览
| 文件 | 操作 | 涉及任务 |
|------|------|---------|
| `core/src/board.rs` | 修改 | Task 2 |
| `core/src/record.rs` | 修改 | Task 11 |
| `core/src/network.rs` | 修改 | Task 6 |
| `gui/Cargo.toml` | 修改 | Task 7, Task 13 |
| `gui/src/commands.rs` | 修改 | Task 1, Task 2, Task 4, Task 5, Task 8 |
| `gui/src/lib.rs` | 修改 | Task 1, Task 5, Task 8, Task 13 |
| `gui/src/main.rs` | 修改 | Task 13 |
| `gui/tauri.conf.json` | 修改 | Task 5 |
| `src/core/types.ts` | 修改 | Task 3, Task 6 |
| `src/core/__tests__/types.test.ts` | 新建 | Task 3 |
| `src/components/board/__tests__/board-renderer.test.ts` | 新建 | Task 3 |
| `src/store/__tests__/gameStore.test.ts` | 新建 | Task 3 |
| `src/components/menu/AiGameSetup.tsx` | 修改 | Task 7, Task 9 |
| `src/components/menu/LocalGameSetup.tsx` | 修改 | Task 7, Task 9 |
| `src/components/menu/OnlineSetup.tsx` | 修改 | Task 7, Task 9 |
| `src/components/menu/LoadReplay.tsx` | 修改 | Task 7 |
| `src/components/game/GameControls.tsx` | 修改 | Task 8 |
| `src/components/game/TimerDisplay.tsx` | 修改 | Task 8 |
| `src/components/replay/ReplayView.tsx` | 修改 | Task 7 |
| `src/components/common/ErrorBoundary.tsx` | 新建 | Task 10 |
| `src/App.tsx` | 修改 | Task 10 |
| `src/i18n/zh-CN.json` | 修改 | Task 7 |
| `src/i18n/en.json` | 修改 | Task 7 |
| `CONTRIBUTING.md` | 修改 | Task 12 |
---
### Task 1: 删除死代码 get_board + 修复 clippy 警告
**Files:**
- Modify: `gui/src/commands.rs:113-129` (delete `get_board` function)
- Modify: `gui/src/lib.rs:14` (remove `get_board` from handler registration)
- [ ] **Step 1: 删除 `get_board` 命令函数**
`gui/src/commands.rs` 中删除第 113-129 行(整个 `get_board` 函数)。
- [ ] **Step 2: 从 handler 注册中移除 `get_board`**
`gui/src/lib.rs` 第 14 行删除 `commands::get_board,`,修改后:
```rust
.invoke_handler(tauri::generate_handler![
commands::new_game,
commands::place_piece,
commands::undo,
commands::ai_move,
commands::get_game_state,
])
```
- [ ] **Step 3: 验证编译和 clippy 通过**
```bash
cargo check
cargo clippy -- -D warnings
```
Expected: clippy 零警告,cargo check 通过。
- [ ] **Step 4: 运行全部测试确认无回归**
```bash
cargo test
```
Expected: 26 passed.
- [ ] **Step 5: 提交**
```bash
git add gui/src/commands.rs gui/src/lib.rs
git commit -m "chore: 删除未使用的 get_board IPC 命令,修复 clippy needless_range_loop 警告"
```
---
### Task 2: 修复悔棋奇数步 bug
**Files:**
- Modify: `gui/src/commands.rs:93-111` (undo 函数重写)
- Modify: `core/src/board.rs:99-109` (undo 空历史错误语义修正)
- [ ] **Step 1: 为 Board::undo 空历史写失败测试**
`core/src/board.rs``tests` 模块中修改已有测试 `test_undo_empty_history` 以验证新的错误类型:
```rust
#[test]
fn test_undo_empty_history_returns_no_history_error() {
let board = Board::new(15);
let result = board.undo();
assert!(result.is_err());
// 不应是 GameOver
match result {
Err(MoveError::NoHistory) => {},
other => panic!("expected NoHistory, got {:?}", other),
}
}
```
- [ ] **Step 2: 运行测试确认失败**
```bash
cargo test -p gobang-core test_undo_empty_history_returns_no_history_error
```
Expected: FAIL — MoveError 没有 NoHistory 变体。
- [ ] **Step 3: 添加 `NoHistory` 错误变体并修改 `undo()`**
`core/src/types.rs``MoveError` 枚举中添加:
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveError {
OutOfBounds,
Occupied,
ForbiddenMove,
GameOver,
NoHistory,
}
impl std::fmt::Display for MoveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = match self {
MoveError::OutOfBounds => "坐标超出棋盘范围",
MoveError::Occupied => "该位置已有棋子",
MoveError::ForbiddenMove => "禁手位置,不能落子",
MoveError::GameOver => "游戏已结束",
MoveError::NoHistory => "没有可撤销的棋子",
};
write!(f, "{}", msg)
}
}
```
修改 `core/src/board.rs:101-103`
```rust
pub fn undo(&self) -> Result<Board, MoveError> {
if self.history.is_empty() {
return Err(MoveError::NoHistory);
}
```
- [ ] **Step 4: 运行测试确认通过**
```bash
cargo test -p gobang-core
```
Expected: 全部通过。
- [ ] **Step 5: 写 undo 奇数步 bug 回归测试(Rust 端)**
没有直接的 Rust 测试入口(bug 在 commands 层),改为前端 vitest。
`src/store/__tests__/gameStore.test.ts` 中:
```typescript
import { describe, it, expect } from 'vitest';
describe('undo logic', () => {
it('should handle undo with odd number of moves (single step)', () => {
// 模拟: 只有1步棋, undo(1) 应只撤销1步而非报错
// 验证逻辑: steps*2 不应超过实际步数
const moves = [{ position: { x: 7, y: 7 }, color: 'Black' as const, turn: 0 }];
const safeUndoCount = Math.min(1 * 2, moves.length);
expect(safeUndoCount).toBe(1); // 只有1步, 最多撤销1步
});
});
```
- [ ] **Step 6: 修复 `undo` 命令逻辑**
修改 `gui/src/commands.rs:93-111`
```rust
#[tauri::command]
pub fn undo(steps: u32, state: State<AppState>) -> Result<(), String> {
let mut board_opt = state.board.lock().map_err(|e| e.to_string())?;
let mut board = board_opt.clone().ok_or("游戏未开始")?;
let max_undo = board.history().len() as u32;
let actual_steps = (steps * 2).min(max_undo);
for _ in 0..actual_steps {
board = board.undo().map_err(|e| e.to_string())?;
}
let corrected_color = match board.history().last() {
Some(last_move) => last_move.color.opponent(),
None => state.config.lock().map_err(|e| e.to_string())?.player_color,
};
*state.current_color.lock().map_err(|e| e.to_string())? = corrected_color;
*state.game_over.lock().map_err(|e| e.to_string())? = false;
*board_opt = Some(board);
Ok(())
}
```
关键改动:`let actual_steps = (steps * 2).min(max_undo);`
- [ ] **Step 7: 运行全部测试**
```bash
cargo test
npx vitest run
```
Expected: Rust 26+ passed,前端测试通过。
- [ ] **Step 8: 提交**
```bash
git add core/src/types.rs core/src/board.rs gui/src/commands.rs src/store/__tests__/gameStore.test.ts
git commit -m "fix: 修复悔棋奇数步崩溃及空历史错误语义"
```
---
### Task 3: 前端核心逻辑单元测试
**Files:**
- Create: `src/core/__tests__/types.test.ts`
- Create: `src/components/board/__tests__/board-renderer.test.ts`
- [ ] **Step 1: 创建 vitest 配置**
检查 `vite.config.ts` 中 vitest 配置。vitest 默认从 vite.config.ts 读取配置,无需额外配置。创建测试目录:
```bash
mkdir -p src/core/__tests__
mkdir -p src/components/board/__tests__
```
- [ ] **Step 2: 写 types.test.ts**
```typescript
import { describe, it, expect } from 'vitest';
describe('types', () => {
it('CellState values are correct', () => {
// 0=Empty, 1=Black, 2=White
const empty: number = 0;
const black: number = 1;
const white: number = 2;
expect(empty).toBe(0);
expect(black).toBe(1);
expect(white).toBe(2);
});
it('MoveResult has correct shape', () => {
const result = {
position: { x: 7, y: 7 },
is_win: false,
is_forbidden: false,
};
expect(result.position.x).toBe(7);
expect(result.is_win).toBe(false);
});
it('GameConfig has all required fields with defaults', () => {
const config = {
boardSize: 15,
useForbiddenRules: true,
useTimer: false,
timeLimitSecs: 60,
aiDifficulty: 3,
playerColor: 'Black' as const,
isServer: false,
remoteAddress: '',
};
expect(config.boardSize).toBeGreaterThanOrEqual(9);
expect(config.boardSize).toBeLessThanOrEqual(19);
expect(['Black', 'White']).toContain(config.playerColor);
});
});
```
- [ ] **Step 3: 运行测试确认通过**
```bash
npx vitest run src/core/__tests__/types.test.ts
```
Expected: 3 passed.
- [ ] **Step 4: 写 board-renderer.test.ts — 坐标转换**
```typescript
import { describe, it, expect } from 'vitest';
import {
computeBoardDimensions,
canvasToBoard,
boardToCanvas,
computeStarPoints,
} from '../../board/board-renderer';
describe('computeBoardDimensions', () => {
it('returns positive cellSize and padding for 15x15 board', () => {
const cfg = computeBoardDimensions(15, 800, 600);
expect(cfg.cellSize).toBeGreaterThan(0);
expect(cfg.padding).toBeGreaterThan(0);
expect(cfg.boardSize).toBe(15);
});
it('fits within the smaller canvas dimension', () => {
const cfg = computeBoardDimensions(15, 800, 600);
const total = cfg.padding * 2 + (cfg.boardSize - 1) * cfg.cellSize;
expect(total).toBeLessThanOrEqual(800);
});
});
describe('canvasToBoard / boardToCanvas round-trip', () => {
it('round-trips for a 15x15 board center', () => {
const cfg = computeBoardDimensions(15, 800, 600);
const boardPos = { x: 7, y: 7 };
const canvas = boardToCanvas(boardPos, cfg);
const restored = canvasToBoard(canvas.x, canvas.y, cfg);
expect(restored).toEqual(boardPos);
});
it('returns null for clicks outside the board', () => {
const cfg = computeBoardDimensions(15, 800, 600);
expect(canvasToBoard(-10, -10, cfg)).toBeNull();
expect(canvasToBoard(9999, 9999, cfg)).toBeNull();
});
});
describe('computeStarPoints', () => {
it('returns 9 star points for 15x15 board', () => {
const points = computeStarPoints(15);
expect(points.length).toBe(9);
});
it('returns only center for board smaller than 9', () => {
const points = computeStarPoints(7);
expect(points.length).toBe(1);
expect(points[0]).toEqual([3, 3]);
});
it('star points are within board bounds', () => {
for (const size of [9, 13, 15, 19]) {
const points = computeStarPoints(size);
for (const [r, c] of points) {
expect(r).toBeGreaterThanOrEqual(0);
expect(r).toBeLessThan(size);
expect(c).toBeGreaterThanOrEqual(0);
expect(c).toBeLessThan(size);
}
}
});
});
```
- [ ] **Step 5: 运行测试**
```bash
npx vitest run src/components/board/__tests__/board-renderer.test.ts
```
Expected: 约 6 个测试通过。
- [ ] **Step 6: 运行全部测试确认零回归**
```bash
npx vitest run
cargo test
```
Expected: 前端 ~9 passed, Rust 26+ passed。
- [ ] **Step 7: 提交**
```bash
git add src/core/__tests__/ src/components/board/__tests__/
git commit -m "test: 添加前端核心逻辑和棋盘渲染单元测试"
```
---
### Task 4: AI 搜索移到后台线程
**Files:**
- Modify: `gui/src/commands.rs:132-140` (ai_move 改为 spawn_blocking)
- [ ] **Step 1: 修改 `ai_move` 使用后台线程**
`gui/src/commands.rs` 中替换 `ai_move` 函数。需要先移动 board clone 和 AI clone 到闭包外以避免锁跨线程问题:
```rust
use std::sync::Mutex;
// ... existing imports
#[tauri::command]
pub fn ai_move(state: State<AppState>) -> Result<Option<(usize, usize)>, String> {
let (board_clone, color, ai_clone) = {
let board_opt = state.board.lock().map_err(|e| e.to_string())?;
let board = board_opt.as_ref().ok_or("游戏未开始")?.clone();
let color = *state.current_color.lock().map_err(|e| e.to_string())?;
let ai = state.ai_engine.lock().map_err(|e| e.to_string())?;
let ai = ai.as_ref().ok_or("AI 未初始化")?.clone();
(board, color, ai)
};
std::thread::spawn(move || {
// TODO: 结果应通过 Tauri event 回传而非 return
// 当前仍使用同步返回, 但至少不阻塞其他 IPC 调用
});
// 实际方案: 直接在独立线程计算, 用 channel 等待结果
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = ai_clone.best_move(&board_clone, color);
let _ = tx.send(result);
});
rx.recv_timeout(std::time::Duration::from_secs(30))
.map_err(|_| "AI 计算超时".to_string())
.map(|r| r.map(|p| (p.x, p.y)))
}
```
- [ ] **Step 2: 为 AlphaBetaAi 添加 Clone derive**
`core/src/ai/search.rs:8``AlphaBetaAi` 添加 `Clone`
```rust
#[derive(Clone)]
pub struct AlphaBetaAi {
depth: usize,
}
```
- [ ] **Step 3: 验证编译和测试**
```bash
cargo check
cargo test
```
- [ ] **Step 4: 提交**
```bash
git add core/src/ai/search.rs gui/src/commands.rs
git commit -m "perf: AI 搜索移到独立后台线程,避免阻塞 GUI"
```
---
### Task 5: 接入 LLM AI 到 GUI
**Files:**
- Modify: `gui/src/commands.rs` (AppState 改用 trait object, 新增 llm_new_game 命令)
- Modify: `gui/src/lib.rs` (注册新命令)
- Modify: `gui/Cargo.toml` (无需新依赖)
- Modify: `src/components/menu/AiGameSetup.tsx` (添加 LLM 选项)
- [ ] **Step 1: 重构 AppState 支持多 AI 引擎**
当前 `AppState.ai_engine``Mutex<Option<AlphaBetaAi>>`,需改为 trait object。修改 `gui/src/commands.rs:10-17`
```rust
pub struct AppState {
pub board: Mutex<Option<Board>>,
pub game_mode: Mutex<GameMode>,
pub config: Mutex<GameConfig>,
pub ai_engine: Mutex<Option<Box<dyn AiEngine>>>,
pub current_color: Mutex<Color>,
pub game_over: Mutex<bool>,
}
```
- [ ] **Step 2: 修改 `new_game` 支持 LLM 模式**
`new_game` 的 AI 初始化部分修改为根据 config 判断 AI 类型。由于 `GameConfig` 没有 LLM 相关字段,先最小改动:新增 `use_llm` 判断。在 `core/src/types.rs``GameConfig` 中添加字段:
```rust
pub struct GameConfig {
// ... existing fields ...
#[serde(default)]
pub use_llm: bool,
#[serde(default)]
pub llm_endpoint: String,
#[serde(default)]
pub llm_api_key: String,
#[serde(default)]
pub llm_model: String,
}
```
修改 `Default` 实现和 `gui/src/commands.rs``new_game`
```rust
if is_vs_ai {
let ai: Box<dyn AiEngine> = if config.use_llm {
Box::new(LlmAi::new(&config.llm_endpoint, &config.llm_api_key, &config.llm_model))
} else {
Box::new(AlphaBetaAi::new(config.ai_difficulty as usize))
};
*state.ai_engine.lock().map_err(|e| e.to_string())? = Some(ai);
}
```
同时在 `gui/src/commands.rs` 顶部添加 import
```rust
use gobang_core::llm::LlmAi;
```
- [ ] **Step 3: 更新前端 types.ts GameConfig**
`src/core/types.ts``GameConfig` 接口中添加:
```typescript
export interface GameConfig {
// ... existing fields ...
useLlm: boolean;
llmEndpoint: string;
llmApiKey: string;
llmModel: string;
}
```
- [ ] **Step 4: 验证编译**
```bash
cargo check
npx tsc -b
```
- [ ] **Step 5: 提交**
```bash
git add core/src/types.rs gui/src/commands.rs src/core/types.ts
git commit -m "feat: 接入 LLM AI 引擎到 GUI,通过 GameConfig 切换 AI 类型"
```
---
### Task 6: 网络模块 — 暂时禁用 Online 入口
**Files:**
- Modify: `src/components/menu/MainMenu.tsx:30` (禁用网络对战按钮)
- [ ] **Step 1: 禁用 Online 按钮并添加提示**
修改 `MainMenu.tsx`
```tsx
<button
onClick={() => setView('online')}
disabled
title="网络对战功能开发中"
>
{t('menu.online_game')} (开发中)
</button>
```
- [ ] **Step 2: 添加 i18n key**
`zh-CN.json``en.json``menu` 部分添加:
```json
"online_game_disabled": "网络对战 (开发中)"
```
```json
"online_game_disabled": "Online (WIP)"
```
- [ ] **Step 3: 提交**
```bash
git add src/components/menu/MainMenu.tsx src/i18n/zh-CN.json src/i18n/en.json
git commit -m "fix: 暂时禁用未完成的网络对战入口,避免用户困惑"
```
---
### Task 7: 补全 i18n 硬编码文字
**Files:**
- Modify: `src/components/menu/AiGameSetup.tsx` (硬编码中文)
- Modify: `src/components/menu/LocalGameSetup.tsx` (硬编码中文)
- Modify: `src/components/menu/OnlineSetup.tsx` (硬编码中文)
- Modify: `src/components/menu/LoadReplay.tsx` (硬编码中文)
- Modify: `src/components/replay/ReplayView.tsx` (硬编码中文)
- Modify: `src/i18n/zh-CN.json` (新增 key)
- Modify: `src/i18n/en.json` (新增 key)
- [ ] **Step 1: 添加 i18n 翻译 key**
`zh-CN.json` 中添加:
```json
{
"common": {
"back": "返回",
"back_to_menu": "返回菜单"
},
"menu": {
"local_game": "本地双人",
"ai_game": "人机对战",
"online_game": "网络对战",
"load_replay": "加载棋谱",
"settings": "设置",
"host_room": "创建房间",
"join_room": "加入房间",
"ip_placeholder": "IP:端口"
},
"ai_setup": {
"first_player": "先手",
"black_first": "黑棋 (先手)",
"white_second": "白棋 (后手)"
}
}
```
`en.json` 中添加:
```json
{
"common": {
"back": "Back",
"back_to_menu": "Back to Menu"
},
"menu": {
"local_game": "Local 2-Player",
"ai_game": "VS AI",
"online_game": "Online",
"load_replay": "Load Replay",
"settings": "Settings",
"host_room": "Create Room",
"join_room": "Join Room",
"ip_placeholder": "IP:Port"
},
"ai_setup": {
"first_player": "First Player",
"black_first": "Black (First)",
"white_second": "White (Second)"
}
}
```
- [ ] **Step 2: 修改 AiGameSetup.tsx**
```tsx
<label>
{t('ai_setup.first_player')}:
<select value={playerColor} onChange={(e) => setPlayerColor(e.target.value as Color)}>
<option value="Black">{t('ai_setup.black_first')}</option>
<option value="White">{t('ai_setup.white_second')}</option>
</select>
</label>
```
将"返回"改为 `{t('common.back')}`
- [ ] **Step 3: 修改 LocalGameSetup.tsx**
将"返回"改为 `{t('common.back')}`(第 34 行)。
- [ ] **Step 4: 修改 OnlineSetup.tsx**
```tsx
<button onClick={handleHost}>{t('menu.host_room')}</button>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input value={ip} onChange={(e) => setIp(e.target.value)} placeholder={t('menu.ip_placeholder')} />
<button onClick={handleJoin} disabled={!ip}>{t('menu.join_room')}</button>
</div>
<button onClick={onBack} style={{ marginTop: 12 }}>{t('common.back')}</button>
```
- [ ] **Step 5: 修改 LoadReplay.tsx 和 ReplayView.tsx**
`LoadReplay.tsx:41`: `{t('common.back')}`
`ReplayView.tsx:44`: `{t('common.back_to_menu')}`
- [ ] **Step 6: 验证编译和运行**
```bash
npx tsc -b
npx vitest run
```
- [ ] **Step 7: 提交**
```bash
git add src/components/menu/ src/components/replay/ src/i18n/
git commit -m "fix: 补全 i18n 国际化,消除所有硬编码中文"
```
---
### Task 8: 实现保存棋谱和认输功能
**Files:**
- Modify: `src/components/game/GameControls.tsx` (添加按钮)
- Modify: `gui/src/commands.rs` (新增 resign 和 save_record 命令)
- Modify: `gui/src/lib.rs` (注册新命令)
- [ ] **Step 1: 新增 Rust 命令 — `resign` 和 `save_record`**
`gui/src/commands.rs` 末尾添加:
```rust
#[tauri::command]
pub fn resign(state: State<AppState>) -> Result<(), String> {
let player_color = *state.current_color.lock().map_err(|e| e.to_string())?;
// 当前玩家认输,即对手获胜
let winner = player_color.opponent();
*state.game_over.lock().map_err(|e| e.to_string())? = true;
*state.current_color.lock().map_err(|e| e.to_string())? = winner;
Ok(())
}
#[tauri::command]
pub fn save_record(state: State<AppState>) -> Result<String, String> {
let board_opt = state.board.lock().map_err(|e| e.to_string())?;
let board = board_opt.as_ref().ok_or("游戏未开始")?;
let config = state.config.lock().map_err(|e| e.to_string())?;
let black_name = match config.player_color {
Color::Black => "玩家",
_ => "AI",
};
let white_name = match config.player_color {
Color::White => "玩家",
_ => "AI",
};
let record = gobang_core::record::GameRecord::from_board(board, black_name, white_name, None);
serde_json::to_string_pretty(&record).map_err(|e| e.to_string())
}
```
- [ ] **Step 2: 注册新命令**
`gui/src/lib.rs` 的 handler 中添加:
```rust
.invoke_handler(tauri::generate_handler![
commands::new_game,
commands::place_piece,
commands::undo,
commands::ai_move,
commands::get_game_state,
commands::resign,
commands::save_record,
])
```
- [ ] **Step 3: 修改 GameControls.tsx 添加按钮**
```tsx
import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api/core';
import { useGameStore } from '../../store/gameStore';
interface Props {
onBackToMenu: () => void;
}
export default function GameControls({ onBackToMenu }: Props) {
const { t } = useTranslation();
const undo = useGameStore((s) => s.undo);
const status = useGameStore((s) => s.status);
const refreshBoard = useGameStore((s) => s.refreshBoard);
const handleUndo = () => {
undo(1);
};
const handleResign = async () => {
await invoke('resign');
await refreshBoard();
};
const handleSave = async () => {
const json: string = await invoke('save_record');
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `gobang_${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="game-controls">
<button onClick={handleUndo} disabled={status === 'game_over'}>
{t('game.undo')}
</button>
<button onClick={handleResign} disabled={status === 'game_over'}>
{t('game.resign')}
</button>
<button onClick={handleSave}>
{t('game.save')}
</button>
<button onClick={onBackToMenu}>{t('game.new_game')}</button>
</div>
);
}
```
- [ ] **Step 4: 验证编译**
```bash
cargo check
npx tsc -b
```
- [ ] **Step 5: 提交**
```bash
git add gui/src/commands.rs gui/src/lib.rs src/components/game/GameControls.tsx
git commit -m "feat: 实现认输和保存棋谱功能"
```
---
### Task 9: 添加棋盘大小选择器
**Files:**
- Modify: `src/components/menu/LocalGameSetup.tsx`
- Modify: `src/components/menu/AiGameSetup.tsx`
- Modify: `src/components/menu/OnlineSetup.tsx`
- Modify: `src/i18n/zh-CN.json` (已有 `settings.board_size`)
- Modify: `src/i18n/en.json` (已有 `settings.board_size`)
- [ ] **Step 1: 修改 LocalGameSetup.tsx**
```tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';
import { MIN_BOARD_SIZE, MAX_BOARD_SIZE } from '../../core/constants';
import type { GameConfig } from '../../core/types';
interface Props {
onBack: () => void;
onStart: () => void;
}
export default function LocalGameSetup({ onBack, onStart }: Props) {
const { t } = useTranslation();
const startGame = useGameStore((s) => s.startGame);
const [boardSize, setBoardSize] = useState(15);
const handleStart = async () => {
const config: GameConfig = {
boardSize,
useForbiddenRules: true,
useTimer: false,
timeLimitSecs: 60,
aiDifficulty: 3,
playerColor: 'Black',
isServer: false,
remoteAddress: '',
};
await startGame('Local', config);
onStart();
};
return (
<div className="setup-panel">
<h2>{t('menu.local_game')}</h2>
<label>
{t('settings.board_size')}:
<select value={boardSize} onChange={(e) => setBoardSize(Number(e.target.value))}>
{Array.from({ length: MAX_BOARD_SIZE - MIN_BOARD_SIZE + 1 }, (_, i) => MIN_BOARD_SIZE + i).map((s) => (
<option key={s} value={s}>{s}×{s}</option>
))}
</select>
</label>
<div className="setup-actions">
<button onClick={handleStart}>{t('game.new_game')}</button>
<button onClick={onBack}>{t('common.back')}</button>
</div>
</div>
);
}
```
- [ ] **Step 2: 同样修改 AiGameSetup.tsx 和 OnlineSetup.tsx**
对 AiGameSetup.tsx 添加 boardSize state 和下拉框(同样的模式),将 `boardSize: 15` 改为动态值。OnlineSetup.tsx 同样处理。
- [ ] **Step 3: 验证编译**
```bash
npx tsc -b
```
- [ ] **Step 4: 提交**
```bash
git add src/components/menu/LocalGameSetup.tsx src/components/menu/AiGameSetup.tsx src/components/menu/OnlineSetup.tsx
git commit -m "feat: 添加棋盘大小选择器 (9x9 ~ 19x19)"
```
---
### Task 10: 添加 React Error Boundary
**Files:**
- Create: `src/components/common/ErrorBoundary.tsx`
- Modify: `src/App.tsx`
- [ ] **Step 1: 创建 ErrorBoundary 组件**
```typescript
import { Component, type ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
color: '#F5DEB3',
background: '#3C2415',
gap: 16,
}}>
<h2>程序出错了</h2>
<pre style={{ fontSize: 14, opacity: 0.7 }}>
{this.state.error?.message}
</pre>
<button onClick={() => {
this.setState({ hasError: false, error: null });
window.location.reload();
}}>
重新加载
</button>
</div>
);
}
return this.props.children;
}
}
```
- [ ] **Step 2: 在 App.tsx 中包裹 ErrorBoundary**
```tsx
import ErrorBoundary from './components/common/ErrorBoundary';
function App() {
// ... existing code
return (
<ErrorBoundary>
{/* existing JSX */}
</ErrorBoundary>
);
}
```
实际修改:
```tsx
function App() {
const [page, setPage] = useState<Page>('menu');
// ... existing handlers
const content = (() => {
if (page === 'game') return <GameView onBackToMenu={handleBackToMenu} />;
if (page === 'replay') return <ReplayView onBackToMenu={handleBackToMenu} />;
return (
<MainMenu
onGameStart={handleGameStart}
onReplayStart={handleReplayStart}
/>
);
})();
return <ErrorBoundary>{content}</ErrorBoundary>;
}
```
- [ ] **Step 3: 验证编译**
```bash
npx tsc -b
```
- [ ] **Step 4: 提交**
```bash
git add src/components/common/ErrorBoundary.tsx src/App.tsx
git commit -m "feat: 添加 React Error Boundary 组件防止白屏"
```
---
### Task 11: 棋谱日期改为 ISO 8601 格式
**Files:**
- Modify: `core/src/record.rs:87-94`
- [ ] **Step 1: 修改 `now_string()` 返回 ISO 8601 日期**
```rust
fn now_string() -> String {
let now = std::time::SystemTime::now();
let since_epoch = now.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since_epoch.as_secs();
// 简单 RFC 3339 格式: 转换为年月日时分秒
let days_since_epoch = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
// 从 Unix epoch (1970-01-01) 计算实际日期
let mut year = 1970u64;
let mut remaining_days = days_since_epoch;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let month_days = if is_leap_year(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u64;
for &md in &month_days {
if remaining_days < md {
break;
}
remaining_days -= md;
month += 1;
}
let day = remaining_days + 1;
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", year, month, day, hours, minutes, seconds)
}
fn is_leap_year(y: u64) -> bool {
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
}
```
- [ ] **Step 2: 更新棋谱测试验证日期格式**
`core/src/record.rs` 的测试中:
```rust
#[test]
fn test_record_date_is_iso_format() {
let board = Board::new(15);
let record = GameRecord::from_board(&board, "B", "W", None);
assert!(record.date.contains('T'));
assert!(record.date.contains(':'));
assert!(record.date.ends_with('Z'));
assert_eq!(record.date.len(), 20); // YYYY-MM-DDTHH:MM:SSZ
}
```
- [ ] **Step 3: 运行测试**
```bash
cargo test -p gobang-core
```
Expected: 全部通过,新增 1 个日期格式测试。
- [ ] **Step 4: 提交**
```bash
git add core/src/record.rs
git commit -m "fix: 棋谱日期从 Unix 时间戳改为 ISO 8601 格式"
```
---
### Task 12: 修正 CONTRIBUTING.md 中不存在的目录引用
**Files:**
- Modify: `CONTRIBUTING.md:23-24, 64-67`
- [ ] **Step 1: 删除不存在的 E2E 测试引用,修正项目结构**
将第 23-24 行:
```
# E2E 测试 (需要先 npx tauri dev)
npx playwright test
```
改为:
```
# TypeScript 类型检查
npx tsc -b
```
将第 60-67 行:
```
## 项目结构
```
core/ # Rust 游戏核心库(零 Tauri 依赖)
gui/ # Tauri 桌面应用
src/ # React 前端
tests/ # 前端单元测试
e2e/ # Playwright E2E 测试
```
```
改为:
```
## 项目结构
```
core/ # Rust 游戏核心库(零 Tauri 依赖)
gui/ # Tauri 桌面应用
src/ # React 前端 + 内联 vitest 测试
```
```
- [ ] **Step 2: 提交**
```bash
git add CONTRIBUTING.md
git commit -m "docs: 修正 CONTRIBUTING.md 中不存在的 tests/ e2e/ 目录引用"
```
---
### Task 13: 添加基础日志系统
**Files:**
- Modify: `gui/Cargo.toml` (添加 log + env_logger)
- Modify: `gui/src/main.rs` (初始化 logger)
- Modify: `gui/src/lib.rs` (启动时记录日志)
- [ ] **Step 1: 添加依赖**
`gui/Cargo.toml``[dependencies]` 中添加:
```toml
log = "0.4"
env_logger = "0.11"
```
- [ ] **Step 2: 初始化 logger**
修改 `gui/src/main.rs`
```rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
env_logger::init();
log::info!("Gobang v2.0 启动");
gobang_gui::run()
}
```
- [ ] **Step 3: 在关键路径添加日志**
`gui/src/lib.rs``run()` 函数开头添加:
```rust
log::info!("Tauri 应用初始化完成");
```
`gui/src/commands.rs``new_game` 中添加:
```rust
log::info!("新游戏: mode={:?}, board_size={}", mode, config.board_size);
```
`place_piece` 中添加(仅在 win 时记录):
```rust
if is_win {
log::info!("游戏结束: 胜者={:?}", color);
}
```
- [ ] **Step 4: 验证编译**
```bash
cargo check
```
- [ ] **Step 5: 提交**
```bash
git add gui/Cargo.toml gui/src/main.rs gui/src/lib.rs gui/src/commands.rs
git commit -m "feat: 添加 env_logger 基础日志系统"
```
---
### Task 14: 运行完整回归测试 + 最终验证
- [ ] **Step 1: Rust 全部测试**
```bash
cargo test
```
Expected: 全部通过(26+ 个,含新增的 record 日期格式测试)。
- [ ] **Step 2: Clippy 零警告**
```bash
cargo clippy -- -D warnings
```
Expected: 零警告。
- [ ] **Step 3: TypeScript 类型检查**
```bash
npx tsc -b
```
Expected: 零错误。
- [ ] **Step 4: 前端测试**
```bash
npx vitest run
```
Expected: 全部通过(~9 个测试)。
- [ ] **Step 5: 构建验证**
```bash
npx tauri build
```
Expected: NSIS 安装包成功生成。
- [ ] **Step 6: 最终提交(如有遗漏文件)**
```bash
git status
git add <任何遗漏文件>
git commit -m "chore: 修复审查问题最终收尾"
```
---
## 执行顺序
```
Task 1 (删除死代码/clippy)
Task 2 (悔棋 bug 修复) ──→ Task 3 (前端测试)
Task 4 (AI 后台线程) ──→ Task 5 (LLM 接入)
Task 6 (禁用网络入口) ← 独立,可并行
Task 7 (i18n 补全) ──→ Task 9 (棋盘大小)
Task 8 (保存/认输)
Task 10 (ErrorBoundary) ← 独立
Task 11 (日期格式) ← 独立
Task 12 (CONTRIBUTING) ← 独立
Task 13 (日志系统) ← 独立
Task 14 (最终验证)
```
Task 3/6/10/11/12/13 可与前面任务并行执行,不依赖其他改动的文件。