diff --git a/docs/superpowers/plans/2026-06-17-qrcode-gui-plan.md b/docs/superpowers/plans/2026-06-17-qrcode-gui-plan.md new file mode 100644 index 0000000..c0a8c57 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-qrcode-gui-plan.md @@ -0,0 +1,1661 @@ +# QRGen GUI 版 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为 QRGen 添加 Tauri 2 + React 桌面 GUI,支持 7 种编码模式、实时预览、多格式导出和历史记录 + +**Architecture:** 在现有 workspace 添加 `gui/` crate,Tauri 2 (Rust 后端, thin wrapper over qr-core) + React 18 + TypeScript + TailwindCSS (前端) + +**Tech Stack:** Tauri 2.6, React 18, TypeScript, TailwindCSS 4, Vite, pnpm + +--- + +## 文件清单 + +### 修改已有文件 +| 文件 | 变更 | +|------|------| +| `Cargo.toml` | workspace members 加 `"gui"` | +| `core/Cargo.toml` | 加 `serde` derive 依赖 | + +### 新增文件 +| 文件 | 职责 | +|------|------| +| `gui/Cargo.toml` | Tauri crate 配置 | +| `gui/tauri.conf.json` | Tauri 窗口/权限配置 | +| `gui/build.rs` | Tauri 构建脚本 | +| `gui/src/main.rs` | Tauri 入口 | +| `gui/src/lib.rs` | Tauri commands(encode_qr, export, history CRUD) | +| `gui/src-frontend/package.json` | 前端依赖 | +| `gui/src-frontend/vite.config.ts` | Vite 构建配置 | +| `gui/src-frontend/tsconfig.json` | TypeScript 配置 | +| `gui/src-frontend/index.html` | HTML 入口 | +| `gui/src-frontend/src/main.tsx` | React 入口 | +| `gui/src-frontend/src/index.css` | TailwindCSS + 全局样式 | +| `gui/src-frontend/src/App.tsx` | 主布局(三栏+底部输入) | +| `gui/src-frontend/src/types/index.ts` | TypeScript 类型定义 | +| `gui/src-frontend/src/hooks/useQrEncode.ts` | 编码 hook(防抖+Tauri调用) | +| `gui/src-frontend/src/store/qrContext.tsx` | React Context + useReducer | +| `gui/src-frontend/src/store/history.ts` | 历史记录持久化(tauri-plugin-store) | +| `gui/src-frontend/src/components/ModePanel.tsx` | 左侧模式面板 | +| `gui/src-frontend/src/components/QrPreview.tsx` | 中间预览区 | +| `gui/src-frontend/src/components/ExportPanel.tsx` | 右侧导出面板 | +| `gui/src-frontend/src/components/HistoryList.tsx` | 历史记录列表 | +| `gui/src-frontend/src/modes/TextMode.tsx` | 文本模式表单 | +| `gui/src-frontend/src/modes/UrlMode.tsx` | URL 模式表单 | +| `gui/src-frontend/src/modes/WifiMode.tsx` | WiFi 模式表单 | +| `gui/src-frontend/src/modes/VCardMode.tsx` | vCard 模式表单 | +| `gui/src-frontend/src/modes/EmailMode.tsx` | Email 模式表单 | +| `gui/src-frontend/src/modes/PhoneMode.tsx` | 电话模式表单 | +| `gui/src-frontend/src/modes/SmsMode.tsx` | SMS 模式表单 | + +--- + +### Task 1: 准备 qr-core + Workspace + +**Files:** +- Modify: `Cargo.toml` (workspace root) +- Modify: `core/Cargo.toml` + +- [ ] **Step 1: 更新 workspace members** + +`Cargo.toml`: +```toml +[workspace] +resolver = "2" +members = ["core", "cli", "gui"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["刘航宇"] +``` + +- [ ] **Step 2: 给 qr-core 添加 serde 支持** + +`core/Cargo.toml` 追加: +```toml +serde = { version = "1", features = ["derive"] } +``` + +在 `core/src/version.rs` 中给 `EcLevel` 加 `Serialize` derive: +```rust +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum EcLevel { ... } +``` + +给 `Version` 加 derive: +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Version(pub u8); +``` + +- [ ] **Step 3: 验证编译** + +```bash +cd D:\Code\doing_exercises\programs\QRGen && cargo build +``` + +- [ ] **Step 4: 提交** + +```bash +git add -A && git commit -m "chore: workspace 加 gui 成员,qr-core 加 serde" +``` + +--- + +### Task 2: Tauri 2 + React 脚手架 + +**Files:** +- Create: `gui/Cargo.toml` +- Create: `gui/tauri.conf.json` +- Create: `gui/build.rs` +- Create: `gui/src/main.rs` +- Create: `gui/src/lib.rs` (stub) +- Create: `gui/src-frontend/package.json` +- Create: `gui/src-frontend/vite.config.ts` +- Create: `gui/src-frontend/tsconfig.json` +- Create: `gui/src-frontend/index.html` +- Create: `gui/src-frontend/src/main.tsx` +- Create: `gui/src-frontend/src/index.css` +- Create: `gui/src-frontend/src/App.tsx` (stub) + +- [ ] **Step 1: 创建 gui/Cargo.toml** + +```toml +[package] +name = "qrgen-gui" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +name = "app_lib" +crate-type = ["staticlib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +qr-core = { path = "../core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri = { version = "2", features = [] } +tauri-plugin-store = "2" +tauri-plugin-dialog = "2" +tauri-plugin-clipboard-manager = "2" +``` + +- [ ] **Step 2: 创建 gui/tauri.conf.json** + +```json +{ + "$schema": "https://raw.githubusercontent.com/nicehash/tauri/main/crates/tauri-cli/config.schema.json", + "productName": "QRGen", + "version": "0.1.0", + "identifier": "com.liuhangyu.qrgen", + "build": { + "frontendDist": "../src-frontend/dist", + "devUrl": "http://localhost:1420", + "beforeDevCommand": "cd src-frontend && pnpm dev", + "beforeBuildCommand": "cd src-frontend && pnpm build" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "QRGen - QR 码生成器", + "width": 900, + "height": 650, + "minWidth": 900, + "minHeight": 650, + "resizable": true, + "decorations": true + } + ], + "security": { + "csp": null + } + }, + "plugins": { + "store": {} + } +} +``` + +- [ ] **Step 3: 创建 gui/build.rs** + +```rust +fn main() { + tauri_build::build() +} +``` + +- [ ] **Step 4: 创建 gui/src/main.rs** + +```rust +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + app_lib::run() +} +``` + +- [ ] **Step 5: 创建 gui/src/lib.rs(占位)** + +```rust +use tauri::Manager; + +#[tauri::command] +fn greet(name: &str) -> String { + format!("你好, {}! QRGen GUI 已就绪。", name) +} + +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .invoke_handler(tauri::generate_handler![greet]) + .run(tauri::generate_context!()) + .expect("启动 QRGen GUI 失败"); +} +``` + +- [ ] **Step 6: 创建前端工程文件** + +`gui/src-frontend/package.json`: +```json +{ + "name": "qrgen-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --port 1420", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-store": "^2", + "@tauri-apps/plugin-clipboard-manager": "^2", + "@tauri-apps/plugin-dialog": "^2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } +} +``` + +`gui/src-frontend/vite.config.ts`: +```typescript +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + clearScreen: false, + server: { port: 1420, strictPort: true }, + envPrefix: ["VITE_", "TAURI_"], + build: { target: "esnext", minify: !process.env.TAURI_DEBUG ? "esbuild" : false, sourcemap: !!process.env.TAURI_DEBUG } +}); +``` + +`gui/src-frontend/tsconfig.json`: +```json +{ + "compilerOptions": { + "target": "ES2021", + "useDefineForClassFields": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} +``` + +`gui/src-frontend/index.html`: +```html + + + + + + QRGen + + +
+ + + +``` + +`gui/src-frontend/src/main.tsx`: +```tsx +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); +``` + +`gui/src-frontend/src/index.css`: +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100; + margin: 0; + overflow: hidden; + user-select: none; +} + +#root { + height: 100vh; + display: flex; + flex-direction: column; +} +``` + +`gui/src-frontend/tailwind.config.js`: +```js +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + darkMode: "media", + theme: { extend: {} }, + plugins: [], +}; +``` + +`gui/src-frontend/postcss.config.js`: +```js +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; +``` + +- [ ] **Step 7: 安装前端依赖 + 验证编译** + +```bash +cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm install && cd ..\.. && cargo build -p qrgen-gui +``` + +- [ ] **Step 8: 提交** + +```bash +git add -A && git commit -m "feat: Tauri 2 + React 脚手架 — gui crate" +``` + +--- + +### Task 3: Rust 后端 — Tauri Commands + +**Files:** +- Modify: `gui/src/lib.rs` (replace stub with full commands) + +- [ ] **Step 1: 实现完整的 lib.rs** + +```rust +use qr_core::qr::{QrCode, QrConfig, VersionMode}; +use qr_core::version::EcLevel; +use serde::{Deserialize, Serialize}; +use tauri::Manager; +use std::sync::Mutex; + +/// QR 编码响应 +#[derive(Debug, Serialize, Clone)] +struct QrResponse { + svg: String, + version: u8, + size: u8, + mask: u8, +} + +/// 历史记录条目 +#[derive(Debug, Serialize, Deserialize, Clone)] +struct HistoryEntry { + id: String, + mode: String, + content: String, + timestamp: u64, +} + +/// 应用状态(内存中的历史记录) +struct AppState { + history: Mutex>, +} + +/// 编码 QR 码,返回 SVG + 元信息 +#[tauri::command] +fn encode_qr(text: String, level: String, margin: u8) -> Result { + let ec_level = match level.to_uppercase().as_str() { + "L" => EcLevel::L, + "M" => EcLevel::M, + "Q" => EcLevel::Q, + "H" => EcLevel::H, + _ => return Err(format!("无效纠错级别: {}", level)), + }; + + let config = QrConfig { + level: ec_level, + version: VersionMode::Auto, + margin, + }; + + let qr = QrCode::encode(&text, config) + .map_err(|e| format!("编码失败: {}", e))?; + + let svg = qr.to_svg(); + + Ok(QrResponse { + svg, + version: qr.version.0, + size: qr.size(), + mask: qr.mask, + }) +} + +/// 导出 PNG bytes (base64 encoded) +#[tauri::command] +fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Result, String> { + let ec_level = match level.to_uppercase().as_str() { + "L" => EcLevel::L, + "M" => EcLevel::M, + "Q" => EcLevel::Q, + "H" => EcLevel::H, + _ => return Err(format!("无效纠错级别: {}", level)), + }; + + let config = QrConfig { + level: ec_level, + version: VersionMode::Auto, + margin, + }; + + let qr = QrCode::encode(&text, config) + .map_err(|e| format!("编码失败: {}", e))?; + + Ok(qr.to_png_bytes(module_size)) +} + +/// 添加历史记录 +#[tauri::command] +fn save_history(state: tauri::State, entry: HistoryEntry) -> Result<(), String> { + let mut history = state.history.lock().map_err(|e| e.to_string())?; + history.push(entry); + if history.len() > 50 { + history.remove(0); + } + Ok(()) +} + +/// 加载历史记录 +#[tauri::command] +fn load_history(state: tauri::State) -> Result, String> { + let history = state.history.lock().map_err(|e| e.to_string())?; + Ok(history.clone()) +} + +/// 清空历史记录 +#[tauri::command] +fn clear_history(state: tauri::State) -> Result<(), String> { + let mut history = state.history.lock().map_err(|e| e.to_string())?; + history.clear(); + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .manage(AppState { + history: Mutex::new(Vec::new()), + }) + .invoke_handler(tauri::generate_handler![ + encode_qr, + export_png, + save_history, + load_history, + clear_history, + ]) + .run(tauri::generate_context!()) + .expect("启动 QRGen GUI 失败"); +} +``` + +- [ ] **Step 2: 验证编译** + +```bash +cd D:\Code\doing_exercises\programs\QRGen && cargo build -p qrgen-gui +``` + +- [ ] **Step 3: 提交** + +```bash +git add -A && git commit -m "feat: Tauri commands — encode/export/history CRUD" +``` + +--- + +### Task 4: React 前端 — 类型、状态、Hook + +**Files:** +- Create: `gui/src-frontend/src/types/index.ts` +- Create: `gui/src-frontend/src/store/qrContext.tsx` +- Create: `gui/src-frontend/src/hooks/useQrEncode.ts` + +- [ ] **Step 1: 类型定义** + +`gui/src-frontend/src/types/index.ts`: +```typescript +export type ModeType = 'text' | 'url' | 'wifi' | 'vcard' | 'email' | 'phone' | 'sms'; + +export interface QrConfig { + level: 'L' | 'M' | 'Q' | 'H'; + margin: number; + moduleSize: number; +} + +export interface QrPreview { + svg: string | null; + version: number; + size: number; + mask: number; +} + +export interface HistoryEntry { + id: string; + mode: string; + content: string; + timestamp: number; +} + +export interface QrState { + mode: ModeType; + formData: Record; + config: QrConfig; + preview: QrPreview | null; + history: HistoryEntry[]; + loading: boolean; +} + +export type QrAction = + | { type: 'SET_MODE'; payload: ModeType } + | { type: 'SET_FORM_DATA'; payload: Record } + | { type: 'SET_CONFIG'; payload: Partial } + | { type: 'SET_PREVIEW'; payload: QrPreview | null } + | { type: 'SET_LOADING'; payload: boolean } + | { type: 'SET_HISTORY'; payload: HistoryEntry[] } + | { type: 'ADD_HISTORY'; payload: HistoryEntry } + | { type: 'REMOVE_HISTORY'; payload: string } + | { type: 'RESET' }; + +export const MODE_LABELS: Record = { + text: '文本', + url: 'URL', + wifi: 'WiFi', + vcard: 'vCard', + email: 'Email', + phone: '电话', + sms: 'SMS', +}; + +export const MODES: ModeType[] = ['text', 'url', 'wifi', 'vcard', 'email', 'phone', 'sms']; +``` + +- [ ] **Step 2: Context + Reducer** + +`gui/src-frontend/src/store/qrContext.tsx`: +```tsx +import React, { createContext, useContext, useReducer, ReactNode } from 'react'; +import type { QrState, QrAction, ModeType } from '../types'; + +const initialState: QrState = { + mode: 'text', + formData: {}, + config: { level: 'M', margin: 4, moduleSize: 8 }, + preview: null, + history: [], + loading: false, +}; + +function qrReducer(state: QrState, action: QrAction): QrState { + switch (action.type) { + case 'SET_MODE': + return { ...state, mode: action.payload, formData: {}, preview: null }; + case 'SET_FORM_DATA': + return { ...state, formData: action.payload }; + case 'SET_CONFIG': + return { ...state, config: { ...state.config, ...action.payload } }; + case 'SET_PREVIEW': + return { ...state, preview: action.payload, loading: false }; + case 'SET_LOADING': + return { ...state, loading: action.payload }; + case 'SET_HISTORY': + return { ...state, history: action.payload }; + case 'ADD_HISTORY': + return { ...state, history: [action.payload, ...state.history].slice(0, 50) }; + case 'REMOVE_HISTORY': + return { ...state, history: state.history.filter(h => h.id !== action.payload) }; + case 'RESET': + return { ...initialState, history: state.history }; + default: + return state; + } +} + +const QrContext = createContext<{ + state: QrState; + dispatch: React.Dispatch; +} | null>(null); + +export function QrProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(qrReducer, initialState); + return {children}; +} + +export function useQrState() { + const ctx = useContext(QrContext); + if (!ctx) throw new Error('useQrState must be inside QrProvider'); + return ctx; +} +``` + +- [ ] **Step 3: 编码 Hook** + +`gui/src-frontend/src/hooks/useQrEncode.ts`: +```typescript +import { useCallback, useRef } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useQrState } from '../store/qrContext'; + +interface QrResponse { + svg: string; + version: number; + size: number; + mask: number; +} + +export function useQrEncode() { + const { dispatch } = useQrState(); + const timerRef = useRef | null>(null); + + const encode = useCallback((text: string) => { + if (!text.trim()) { + dispatch({ type: 'SET_PREVIEW', payload: null }); + return; + } + + dispatch({ type: 'SET_LOADING', payload: true }); + + // 200ms 防抖 + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(async () => { + try { + const result = await invoke('encode_qr', { + text, + level: 'M', + margin: 4, + }); + dispatch({ + type: 'SET_PREVIEW', + payload: { + svg: result.svg, + version: result.version, + size: result.size, + mask: result.mask, + }, + }); + } catch (e) { + console.error('QR 编码失败:', e); + dispatch({ type: 'SET_PREVIEW', payload: null }); + } + }, 200); + }, [dispatch]); + + return { encode }; +} +``` + +- [ ] **Step 4: 验证 TypeScript 编译** + +```bash +cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit +``` + +- [ ] **Step 5: 提交** + +```bash +git add -A && git commit -m "feat: 类型定义 + Context/Reducer + 编码 Hook" +``` + +--- + +### Task 5: 主布局 + 模式面板 + +**Files:** +- Modify: `gui/src-frontend/src/App.tsx` (replace stub) +- Create: `gui/src-frontend/src/components/ModePanel.tsx` + +- [ ] **Step 1: App.tsx — 三栏 + 底部布局** + +```tsx +import { QrProvider, useQrState } from './store/qrContext'; +import ModePanel from './components/ModePanel'; +import QrPreview from './components/QrPreview'; +import ExportPanel from './components/ExportPanel'; +import HistoryList from './components/HistoryList'; + +function AppLayout() { + const { state } = useQrState(); + + return ( +
+ {/* 顶部标题栏 */} +
+ + 🀫 QRGen + +
+ + {/* 三栏主体 */} +
+ {/* 左侧模式面板 */} + + + {/* 中间预览区 */} +
+ +
+ + {/* 右侧面板 */} +
+ +
+ +
+
+
+ + {/* 底部输入区 */} +
+ +
+
+ ); +} + +function BottomInput() { + const { state, dispatch } = useQrState(); + const { encode } = useQrEncode(); // 延迟 import 待完成 + + const handleInput = (text: string) => { + dispatch({ type: 'SET_FORM_DATA', payload: { text } }); + encode(text); + }; + + if (state.mode === 'text') { + return ( + handleInput(e.target.value)} + className="w-full h-full px-4 text-lg bg-transparent outline-none placeholder-gray-400 dark:placeholder-gray-600" + /> + ); + } + + // 其他模式:显示"请在下方表单中输入" + return ( +
+ 请在下方表单中填写 {state.mode === 'url' ? 'URL' : state.mode} 信息 +
+ ); +} + +export default function App() { + return ( + + + + ); +} +``` + +- [ ] **Step 2: ModePanel.tsx** + +```tsx +import { useQrState } from '../store/qrContext'; +import { MODES, MODE_LABELS, type ModeType } from '../types'; + +export default function ModePanel() { + const { state, dispatch } = useQrState(); + + return ( +
+
+ 编码模式 +
+ {MODES.map(mode => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 3: 验证 TypeScript 编译** + +```bash +cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit +``` + +- [ ] **Step 4: 提交** + +```bash +git add -A && git commit -m "feat: 主布局 + 左侧模式面板" +``` + +--- + +### Task 6: 预览区 + 导出面板 + +**Files:** +- Create: `gui/src-frontend/src/components/QrPreview.tsx` +- Create: `gui/src-frontend/src/components/ExportPanel.tsx` + +- [ ] **Step 1: QrPreview.tsx** + +```tsx +import { useQrState } from '../store/qrContext'; + +export default function QrPreview() { + const { state } = useQrState(); + + if (!state.preview?.svg) { + return ( +
+
+ 输入内容生成 QR 码 +
+
+ ); + } + + return ( +
+
+
+ 版本: {state.preview.version} + {state.preview.size}×{state.preview.size} + 掩码: {state.preview.mask} +
+
+ ); +} +``` + +- [ ] **Step 2: ExportPanel.tsx** + +```tsx +import { useState } from 'react'; +import { useQrState } from '../store/qrContext'; +import { writeText } from '@tauri-apps/plugin-clipboard-manager'; +import { save } from '@tauri-apps/plugin-dialog'; +import { writeFile } from '@tauri-apps/plugin-fs'; +import { invoke } from '@tauri-apps/api/core'; + +export default function ExportPanel() { + const { state, dispatch } = useQrState(); + const [exporting, setExporting] = useState(false); + + const handleExportPng = async () => { + if (!state.preview?.svg) return; + setExporting(true); + try { + const filePath = await save({ + filters: [{ name: 'PNG 图片', extensions: ['png'] }], + defaultPath: 'qrcode.png', + }); + if (!filePath) return; + + const bytes: number[] = await invoke('export_png', { + text: getCurrentText(), + level: state.config.level, + margin: state.config.margin, + moduleSize: state.config.moduleSize, + }); + await writeFile(filePath, new Uint8Array(bytes)); + } catch (e) { + console.error('导出 PNG 失败:', e); + } + setExporting(false); + }; + + const handleCopySvg = async () => { + if (state.preview?.svg) { + await writeText(state.preview.svg); + } + }; + + const getCurrentText = () => { + if (state.mode === 'text') return state.formData.text || ''; + return buildModeText(state.mode, state.formData); + }; + + return ( +
+
+ 导出选项 +
+ + {/* 纠错级别 */} + + + {/* 模块大小 */} + + + {/* 边距 */} + + + {/* 导出按钮 */} + + +
+ ); +} + +// 各模式文本构建(占位,后续 Task 完善) +function buildModeText(mode: string, data: Record): string { + switch (mode) { + case 'url': return data.url || ''; + case 'wifi': return `WIFI:T:${data.encryption || 'WPA'};S:${data.ssid || ''};P:${data.password || ''};;`; + case 'vcard': return `BEGIN:VCARD\nVERSION:3.0\nFN:${data.name || ''}\nTEL:${data.phone || ''}\nEMAIL:${data.email || ''}\nORG:${data.company || ''}\nADR:${data.address || ''}\nEND:VCARD`; + case 'email': return `mailto:${data.to || ''}?subject=${encodeURIComponent(data.subject || '')}&body=${encodeURIComponent(data.body || '')}`; + case 'phone': return `tel:${data.number || ''}`; + case 'sms': return `smsto:${data.number || ''}:${data.message || ''}`; + default: return data.text || ''; + } +} +``` + +- [ ] **Step 3: 验证 TypeScript 编译** + +```bash +cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit +``` + +- [ ] **Step 4: 提交** + +```bash +git add -A && git commit -m "feat: QR 预览区 + 导出面板(PNG/复制)" +``` + +--- + +### Task 7: 文本模式 + URL 模式表单 + +**Files:** +- Create: `gui/src-frontend/src/modes/TextMode.tsx` +- Create: `gui/src-frontend/src/modes/UrlMode.tsx` +- Modify: `gui/src-frontend/src/App.tsx` (update BottomInput to render mode forms) + +- [ ] **Step 1: TextMode.tsx** + +```tsx +import { useQrState } from '../store/qrContext'; +import { useQrEncode } from '../hooks/useQrEncode'; + +export default function TextMode() { + const { state, dispatch } = useQrState(); + const { encode } = useQrEncode(); + + const handleChange = (text: string) => { + dispatch({ type: 'SET_FORM_DATA', payload: { text } }); + encode(text); + }; + + return ( +