# 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 (