Files
QRGen/docs/superpowers/plans/2026-06-17-qrcode-gui-plan.md

45 KiB
Raw Permalink Blame History

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/ crateTauri 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 commandsencode_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:

[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 追加:

serde = { version = "1", features = ["derive"] }

core/src/version.rs 中给 EcLevelSerialize derive:

use serde::Serialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum EcLevel { ... }

Version 加 derive:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Version(pub u8);
  • Step 3: 验证编译
cd D:\Code\doing_exercises\programs\QRGen && cargo build
  • Step 4: 提交
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

[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
{
  "$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
fn main() {
    tauri_build::build()
}
  • Step 4: 创建 gui/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
    app_lib::run()
}
  • Step 5: 创建 gui/src/lib.rs(占位)
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:

{
  "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:

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:

{
  "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:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>QRGen</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

gui/src-frontend/src/main.tsx:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

gui/src-frontend/src/index.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:

/** @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:

export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};
  • Step 7: 安装前端依赖 + 验证编译
cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm install && cd ..\.. && cargo build -p qrgen-gui
  • Step 8: 提交
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

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<Vec<HistoryEntry>>,
}

/// 编码 QR 码,返回 SVG + 元信息
#[tauri::command]
fn encode_qr(text: String, level: String, margin: u8) -> Result<QrResponse, 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))?;

    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<Vec<u8>, 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<AppState>, 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<AppState>) -> Result<Vec<HistoryEntry>, String> {
    let history = state.history.lock().map_err(|e| e.to_string())?;
    Ok(history.clone())
}

/// 清空历史记录
#[tauri::command]
fn clear_history(state: tauri::State<AppState>) -> 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: 验证编译
cd D:\Code\doing_exercises\programs\QRGen && cargo build -p qrgen-gui
  • Step 3: 提交
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:

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<string, string>;
  config: QrConfig;
  preview: QrPreview | null;
  history: HistoryEntry[];
  loading: boolean;
}

export type QrAction =
  | { type: 'SET_MODE'; payload: ModeType }
  | { type: 'SET_FORM_DATA'; payload: Record<string, string> }
  | { type: 'SET_CONFIG'; payload: Partial<QrConfig> }
  | { 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<ModeType, string> = {
  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:

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<QrAction>;
} | null>(null);

export function QrProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(qrReducer, initialState);
  return <QrContext.Provider value={{ state, dispatch }}>{children}</QrContext.Provider>;
}

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:

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<ReturnType<typeof setTimeout> | 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<QrResponse>('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 编译
cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit
  • Step 5: 提交
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 — 三栏 + 底部布局

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 (
    <div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
      {/* 顶部标题栏 */}
      <div className="h-10 flex items-center px-4 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-b border-gray-200 dark:border-gray-800">
        <span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
          🀫 QRGen
        </span>
      </div>

      {/* 三栏主体 */}
      <div className="flex-1 flex overflow-hidden">
        {/* 左侧模式面板 */}
        <ModePanel />

        {/* 中间预览区 */}
        <div className="flex-1 flex items-center justify-center p-4">
          <QrPreview />
        </div>

        {/* 右侧面板 */}
        <div className="w-56 flex flex-col border-l border-gray-200 dark:border-gray-800 p-3 gap-3 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
          <ExportPanel />
          <div className="flex-1 overflow-hidden">
            <HistoryList />
          </div>
        </div>
      </div>

      {/* 底部输入区 */}
      <div className="h-24 border-t border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl p-3">
        <BottomInput />
      </div>
    </div>
  );
}

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 (
      <input
        type="text"
        placeholder="输入文本内容..."
        value={state.formData.text || ''}
        onChange={e => 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 (
    <div className="flex items-center justify-center h-full text-gray-400 text-sm">
      请在下方表单中填写 {state.mode === 'url' ? 'URL' : state.mode} 信息
    </div>
  );
}

export default function App() {
  return (
    <QrProvider>
      <AppLayout />
    </QrProvider>
  );
}
  • Step 2: ModePanel.tsx
import { useQrState } from '../store/qrContext';
import { MODES, MODE_LABELS, type ModeType } from '../types';

export default function ModePanel() {
  const { state, dispatch } = useQrState();

  return (
    <div className="w-48 border-r border-gray-200 dark:border-gray-800 p-3 flex flex-col gap-1 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
      <div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 px-2">
        编码模式
      </div>
      {MODES.map(mode => (
        <button
          key={mode}
          onClick={() => dispatch({ type: 'SET_MODE', payload: mode })}
          className={`px-3 py-2 rounded-lg text-left text-sm transition-all ${
            state.mode === mode
              ? 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-md shadow-blue-500/20'
              : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
          }`}
        >
          {MODE_LABELS[mode]}
        </button>
      ))}
    </div>
  );
}
  • Step 3: 验证 TypeScript 编译
cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit
  • Step 4: 提交
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

import { useQrState } from '../store/qrContext';

export default function QrPreview() {
  const { state } = useQrState();

  if (!state.preview?.svg) {
    return (
      <div className="flex flex-col items-center justify-center gap-3 text-gray-400">
        <div className="w-48 h-48 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl flex items-center justify-center">
          <span className="text-sm">输入内容生成 QR </span>
        </div>
      </div>
    );
  }

  return (
    <div className="flex flex-col items-center gap-3">
      <div
        className="w-64 h-64 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl p-4 flex items-center justify-center bg-white dark:bg-gray-800"
        dangerouslySetInnerHTML={{ __html: state.preview.svg }}
      />
      <div className="flex gap-3 text-xs text-gray-500">
        <span>版本: {state.preview.version}</span>
        <span>{state.preview.size}×{state.preview.size}</span>
        <span>掩码: {state.preview.mask}</span>
      </div>
    </div>
  );
}
  • Step 2: ExportPanel.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 (
    <div className="flex flex-col gap-2">
      <div className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
        导出选项
      </div>

      {/* 纠错级别 */}
      <label className="text-xs text-gray-600 dark:text-gray-400">
        纠错级别
        <select
          value={state.config.level}
          onChange={e => dispatch({ type: 'SET_CONFIG', payload: { level: e.target.value as any } })}
          className="w-full mt-1 px-2 py-1 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"
        >
          <option value="L">L  7%</option>
          <option value="M">M  15%</option>
          <option value="Q">Q  25%</option>
          <option value="H">H  30%</option>
        </select>
      </label>

      {/* 模块大小 */}
      <label className="text-xs text-gray-600 dark:text-gray-400">
        模块大小: {state.config.moduleSize}px
        <input
          type="range"
          min={2}
          max={20}
          value={state.config.moduleSize}
          onChange={e => dispatch({ type: 'SET_CONFIG', payload: { moduleSize: +e.target.value } })}
          className="w-full mt-1"
        />
      </label>

      {/* 边距 */}
      <label className="text-xs text-gray-600 dark:text-gray-400">
        边距: {state.config.margin}
        <input
          type="range"
          min={1}
          max={10}
          value={state.config.margin}
          onChange={e => dispatch({ type: 'SET_CONFIG', payload: { margin: +e.target.value } })}
          className="w-full mt-1"
        />
      </label>

      {/* 导出按钮 */}
      <button
        onClick={handleCopySvg}
        disabled={!state.preview}
        className="w-full py-2 rounded-lg bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-40 transition-all"
      >
        复制到剪贴板
      </button>
      <button
        onClick={handleExportPng}
        disabled={!state.preview || exporting}
        className="w-full py-2 rounded-lg bg-green-500 text-white text-sm font-medium hover:bg-green-600 disabled:opacity-40 transition-all"
      >
        {exporting ? '导出中...' : '导出 PNG'}
      </button>
    </div>
  );
}

// 各模式文本构建(占位,后续 Task 完善)
function buildModeText(mode: string, data: Record<string, string>): 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 编译
cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit
  • Step 4: 提交
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

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 (
    <textarea
      placeholder="输入任意文本..."
      value={state.formData.text || ''}
      onChange={e => handleChange(e.target.value)}
      rows={3}
      className="w-full h-full resize-none px-4 py-2 text-sm bg-transparent outline-none placeholder-gray-400"
    />
  );
}
  • Step 2: UrlMode.tsx
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';

export default function UrlMode() {
  const { state, dispatch } = useQrState();
  const { encode } = useQrEncode();

  const handleChange = (url: string) => {
    dispatch({ type: 'SET_FORM_DATA', payload: { url } });
    encode(url);
  };

  const handleBlur = () => {
    const url = state.formData.url || '';
    if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
      const corrected = `https://${url}`;
      dispatch({ type: 'SET_FORM_DATA', payload: { url: corrected } });
      encode(corrected);
    }
  };

  return (
    <input
      type="url"
      placeholder="https://example.com"
      value={state.formData.url || ''}
      onChange={e => handleChange(e.target.value)}
      onBlur={handleBlur}
      className="w-full h-full px-4 text-sm bg-transparent outline-none placeholder-gray-400"
    />
  );
}
  • Step 3: 更新 App.tsx 的 BottomInput

把 App.tsx 中的 BottomInput 替换为动态渲染模式组件:

import TextMode from './modes/TextMode';
import UrlMode from './modes/UrlMode';
// ... 其他模式后续添加

function BottomInput() {
  const { state } = useQrState();

  switch (state.mode) {
    case 'text': return <TextMode />;
    case 'url': return <UrlMode />;
    // 后续 Task 添加其他模式
    default: return <TextMode />;
  }
}
  • Step 4: 验证编译
cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit
  • Step 5: 提交
git add -A && git commit -m "feat: 文本模式 + URL 模式表单(含协议自动补全)"

Task 8: WiFi + vCard + Email + 电话 + SMS 全模式

Files:

  • Create: gui/src-frontend/src/modes/WifiMode.tsx

  • Create: gui/src-frontend/src/modes/VCardMode.tsx

  • Create: gui/src-frontend/src/modes/EmailMode.tsx

  • Create: gui/src-frontend/src/modes/PhoneMode.tsx

  • Create: gui/src-frontend/src/modes/SmsMode.tsx

  • Modify: gui/src-frontend/src/App.tsx (import and wire all modes)

  • Step 1: WifiMode.tsx

import { useEffect } from 'react';
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';

export default function WifiMode() {
  const { state, dispatch } = useQrState();
  const { encode } = useQrEncode();

  const buildWifiText = (ssid: string, password: string, encryption: string, hidden: boolean) => {
    if (!ssid) return '';
    return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden ? 'H:true;' : ''};`;
  };

  const update = (field: string, value: string | boolean) => {
    const data = { ...state.formData, [field]: String(value) };
    dispatch({ type: 'SET_FORM_DATA', payload: data });
    const wifiText = buildWifiText(
      data.ssid || '',
      data.password || '',
      data.encryption || 'WPA',
      data.hidden === 'true',
    );
    encode(wifiText);
  };

  return (
    <div className="flex gap-2 items-center h-full px-4">
      <input placeholder="SSID" value={state.formData.ssid || ''}
        onChange={e => update('ssid', e.target.value)}
        className="flex-1 px-3 py-1.5 rounded-lg border text-sm bg-transparent outline-none" />
      <input placeholder="密码" type="password" value={state.formData.password || ''}
        onChange={e => update('password', e.target.value)}
        className="flex-1 px-3 py-1.5 rounded-lg border text-sm bg-transparent outline-none" />
      <select value={state.formData.encryption || 'WPA'}
        onChange={e => update('encryption', e.target.value)}
        className="px-3 py-1.5 rounded-lg border text-sm bg-transparent">
        <option value="WPA">WPA/WPA2</option>
        <option value="WEP">WEP</option>
        <option value="nopass">无密码</option>
      </select>
      <label className="flex items-center gap-1 text-sm text-gray-500">
        <input type="checkbox" checked={state.formData.hidden === 'true'}
          onChange={e => update('hidden', e.target.checked)} />
        隐藏
      </label>
    </div>
  );
}
  • Step 2: VCardMode.tsx
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';

const FIELDS = [
  { key: 'name', placeholder: '姓名', span: 1 },
  { key: 'phone', placeholder: '电话', span: 1 },
  { key: 'email', placeholder: '邮箱', span: 2 },
  { key: 'company', placeholder: '公司', span: 1 },
  { key: 'address', placeholder: '地址', span: 1 },
];

export default function VCardMode() {
  const { state, dispatch } = useQrState();
  const { encode } = useQrEncode();

  const update = (field: string, value: string) => {
    const data = { ...state.formData, [field]: value };
    dispatch({ type: 'SET_FORM_DATA', payload: data });
    const vcard = `BEGIN:VCARD\nVERSION:3.0\nFN:${data.name || ''}\nTEL:${data.phone || ''}\nEMAIL:${data.email || ''}\nORG:${data.company || ''}\nADR:${data.address || ''}\nEND:VCARD`;
    encode(vcard);
  };

  return (
    <div className="flex gap-2 items-center h-full px-4 flex-wrap">
      {FIELDS.map(f => (
        <input key={f.key} placeholder={f.placeholder}
          value={state.formData[f.key] || ''}
          onChange={e => update(f.key, e.target.value)}
          className="px-3 py-1.5 rounded-lg border text-sm bg-transparent outline-none"
          style={{ width: f.span === 2 ? 'calc(40%)' : 'calc(20%)' }} />
      ))}
    </div>
  );
}
  • Step 3: EmailMode.tsx
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';

export default function EmailMode() {
  const { state, dispatch } = useQrState();
  const { encode } = useQrEncode();

  const update = (field: string, value: string) => {
    const data = { ...state.formData, [field]: value };
    dispatch({ type: 'SET_FORM_DATA', payload: data });
    const mailto = `mailto:${data.to || ''}?subject=${encodeURIComponent(data.subject || '')}&body=${encodeURIComponent(data.body || '')}`;
    encode(mailto);
  };

  return (
    <div className="flex gap-2 items-center h-full px-4">
      <input placeholder="收件人" value={state.formData.to || ''}
        onChange={e => update('to', e.target.value)}
        className="flex-1 px-3 py-1.5 rounded-lg border text-sm bg-transparent outline-none" />
      <input placeholder="主题" value={state.formData.subject || ''}
        onChange={e => update('subject', e.target.value)}
        className="flex-1 px-3 py-1.5 rounded-lg border text-sm bg-transparent outline-none" />
      <input placeholder="正文" value={state.formData.body || ''}
        onChange={e => update('body', e.target.value)}
        className="flex-[2] px-3 py-1.5 rounded-lg border text-sm bg-transparent outline-none" />
    </div>
  );
}
  • Step 4: PhoneMode.tsx
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';

export default function PhoneMode() {
  const { state, dispatch } = useQrState();
  const { encode } = useQrEncode();

  const update = (number: string) => {
    dispatch({ type: 'SET_FORM_DATA', payload: { number } });
    encode(`tel:${number}`);
  };

  return (
    <input placeholder="输入电话号码" type="tel" value={state.formData.number || ''}
      onChange={e => update(e.target.value)}
      className="w-full h-full px-4 text-sm bg-transparent outline-none placeholder-gray-400" />
  );
}
  • Step 5: SmsMode.tsx
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';

export default function SmsMode() {
  const { state, dispatch } = useQrState();
  const { encode } = useQrEncode();

  const update = (field: string, value: string) => {
    const data = { ...state.formData, [field]: value };
    dispatch({ type: 'SET_FORM_DATA', payload: data });
    encode(`smsto:${data.number || ''}:${data.message || ''}`);
  };

  return (
    <div className="flex gap-2 items-center h-full px-4">
      <input placeholder="电话号码" type="tel" value={state.formData.number || ''}
        onChange={e => update('number', e.target.value)}
        className="flex-1 px-3 py-1.5 rounded-lg border text-sm bg-transparent outline-none" />
      <input placeholder="短信内容" value={state.formData.message || ''}
        onChange={e => update('message', e.target.value)}
        className="flex-[2] px-3 py-1.5 rounded-lg border text-sm bg-transparent outline-none" />
    </div>
  );
}
  • Step 6: 更新 App.tsx 的 BottomInput 接入全部模式
import WifiMode from './modes/WifiMode';
import VCardMode from './modes/VCardMode';
import EmailMode from './modes/EmailMode';
import PhoneMode from './modes/PhoneMode';
import SmsMode from './modes/SmsMode';

function BottomInput() {
  const { state } = useQrState();
  switch (state.mode) {
    case 'text': return <TextMode />;
    case 'url': return <UrlMode />;
    case 'wifi': return <WifiMode />;
    case 'vcard': return <VCardMode />;
    case 'email': return <EmailMode />;
    case 'phone': return <PhoneMode />;
    case 'sms': return <SmsMode />;
    default: return <TextMode />;
  }
}
  • Step 7: 验证编译
cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit
  • Step 8: 提交
git add -A && git commit -m "feat: WiFi/vCard/Email/电话/SMS 全模式表单"

Task 9: 历史记录面板

Files:

  • Create: gui/src-frontend/src/components/HistoryList.tsx

  • Modify: gui/src-frontend/src/App.tsx (history save on encode)

  • Modify: gui/src-frontend/src/hooks/useQrEncode.ts (add history save)

  • Step 1: HistoryList.tsx

import { useQrState } from '../store/qrContext';
import { MODE_LABELS, type HistoryEntry } from '../types';

export default function HistoryList() {
  const { state, dispatch } = useQrState();

  const handleClick = (entry: HistoryEntry) => {
    // 回填历史记录
    dispatch({ type: 'SET_MODE', payload: entry.mode as any });
    // 尝试解析 formData
    try {
      const formData = JSON.parse(entry.content);
      dispatch({ type: 'SET_FORM_DATA', payload: formData });
    } catch {
      dispatch({ type: 'SET_FORM_DATA', payload: { text: entry.content } });
    }
  };

  const handleDelete = (id: string) => {
    dispatch({ type: 'REMOVE_HISTORY', payload: id });
  };

  const handleClear = () => {
    dispatch({ type: 'SET_HISTORY', payload: [] });
  };

  return (
    <div className="flex flex-col h-full">
      <div className="flex items-center justify-between mb-2">
        <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
          📋 历史记录
        </span>
        {state.history.length > 0 && (
          <button onClick={handleClear}
            className="text-xs text-red-400 hover:text-red-600 transition-colors">
            清空
          </button>
        )}
      </div>

      <div className="flex-1 overflow-y-auto space-y-1">
        {state.history.length === 0 && (
          <p className="text-xs text-gray-400 text-center py-4">暂无记录</p>
        )}
        {state.history.map(entry => (
          <div key={entry.id}
            onClick={() => handleClick(entry)}
            className="group flex items-center justify-between px-2 py-1.5 rounded-lg text-xs cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-all">
            <div className="flex-1 min-w-0">
              <span className="font-medium text-gray-700 dark:text-gray-300 truncate block">
                {MODE_LABELS[entry.mode as keyof typeof MODE_LABELS] || entry.mode}
              </span>
              <span className="text-gray-400 truncate block">
                {new Date(entry.timestamp).toLocaleTimeString()}
              </span>
            </div>
            <button onClick={e => { e.stopPropagation(); handleDelete(entry.id); }}
              className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 ml-1 transition-all">
              ×
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}
  • Step 2: 更新 useQrEncode Hook — 编码时保存历史

useQrEncode.ts 中添加历史记录保存逻辑:

// 在 encode 函数的 setTimeout 回调中,编码成功后:
import { useQrState } from '../store/qrContext';

// 在回调中添加:
const { state, dispatch } = useQrState(); // 已有

// encode 成功回调内:
dispatch({
  type: 'ADD_HISTORY',
  payload: {
    id: Date.now().toString(),
    mode: state.mode,       // 注意:闭包陷阱,需要用最新值
    content: text,
    timestamp: Date.now(),
  },
});

为了处理闭包陷阱,用 ref 追踪最新 mode:

const modeRef = useRef(state.mode);
modeRef.current = state.mode;

// 然后在 dispatch ADD_HISTORY 时用 modeRef.current
  • Step 3: 验证编译
cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit
  • Step 4: 提交
git add -A && git commit -m "feat: 历史记录面板(回填/删除/清空)"

Task 10: 暗色模式 + 视觉打磨

Files:

  • Modify: gui/src-frontend/src/index.css (extend dark mode styles)

  • Modify: 各种 .tsx (确保 dark: 样式完整)

  • Step 1: 全局暗色模式完善

index.css 确保已有这些:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --glass-bg: rgba(255, 255, 255, 0.7);
    --glass-border: rgba(0, 0, 0, 0.08);
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --glass-bg: rgba(17, 24, 39, 0.7);
      --glass-border: rgba(255, 255, 255, 0.06);
    }
  }
}

body {
  @apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
  margin: 0;
  overflow: hidden;
  user-select: none;
}

/* 预览区 SVG 自适应 */
.qr-preview svg {
  width: 100% !important;
  height: 100% !important;
}

/* 自定义滚动条 */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  @apply bg-gray-300 dark:bg-gray-700 rounded-full;
}

/* 输入框聚焦效果 */
input:focus, textarea:focus, select:focus {
  @apply ring-2 ring-blue-500/30 outline-none;
}
  • Step 2: 磨砂玻璃效果统一应用

确保三栏都用了 backdrop-blur-xlbg-white/70 dark:bg-gray-900/70

  • Step 3: 构建验证
cd D:\Code\doing_exercises\programs\QRGen && cargo build -p qrgen-gui
  • Step 4: 提交
git add -A && git commit -m "style: 暗色模式完善 + 磨砂玻璃效果 + 滚动条美化"

Task 11: 图标 + 打包配置 + 最终测试

Files:

  • Create: gui/icons/icon.png (生成一个简单占位图标)

  • Modify: gui/tauri.conf.json (添加图标配置)

  • Step 1: 生成占位图标

用 PowerShell 生成一个简单的 512×512 PNG 占位图标(蓝底白字 QR 图案):

# 暂时用 core 的 PNG 渲染能力生成一个简单图标
# 或者直接放一个占位文件

简化处理:在 tauri.conf.json 中暂时去掉图标引用,用系统默认。

  • Step 2: 更新 tauri.conf.json 加打包配置
{
  "$schema": "...",
  "productName": "QRGen",
  "version": "0.1.0",
  "identifier": "com.liuhangyu.qrgen",
  "build": { ... },
  "app": { ... },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ]
  }
}
  • Step 3: 最终编译检查
cd D:\Code\doing_exercises\programs\QRGen && cargo build
cargo test
  • Step 4: 提交
git add -A && git commit -m "chore: 打包配置 + 最终验证"

实现顺序

Task 1 (workspace+serde)
  → Task 2 (Tauri+React scaffold)
    → Task 3 (Rust commands)
      → Task 4 (types+store+hook)
        → Task 5 (layout+ModePanel)
          → Task 6 (preview+export)
            → Task 7 (text+url modes)
              → Task 8 (wifi+vcard+email+phone+sms)
                → Task 9 (history)
                  → Task 10 (dark mode+polish)
                    → Task 11 (icons+packaging)

验证检查点

任务 验证方式
Task 1-2 cargo build 全 workspace
Task 3 cargo build -p qrgen-gui
Task 4 pnpm tsc --noEmit
Task 5-6 pnpm tsc --noEmit
Task 7-8 pnpm tsc --noEmit
Task 9-10 pnpm tsc --noEmit + cargo build
Task 11 cargo build && cargo test