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

1662 lines
45 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.
# 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`:
```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
<!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`:
```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`:
```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<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: 验证编译**
```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<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`:
```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`:
```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<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 编译**
```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 (
<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**
```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 编译**
```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 (
<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**
```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 编译**
```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 (
<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**
```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` 替换为动态渲染模式组件:
```tsx
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: 验证编译**
```bash
cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit
```
- [ ] **Step 5: 提交**
```bash
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**
```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**
```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**
```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**
```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**
```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 接入全部模式**
```tsx
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: 验证编译**
```bash
cd D:\Code\doing_exercises\programs\QRGen\gui\src-frontend && pnpm tsc --noEmit
```
- [ ] **Step 8: 提交**
```bash
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**
```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` 中添加历史记录保存逻辑:
```typescript
// 在 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:
```typescript
const modeRef = useRef(state.mode);
modeRef.current = state.mode;
// 然后在 dispatch ADD_HISTORY 时用 modeRef.current
```
- [ ] **Step 3: 验证编译**
```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 10: 暗色模式 + 视觉打磨
**Files:**
- Modify: `gui/src-frontend/src/index.css` (extend dark mode styles)
- Modify: 各种 `.tsx` (确保 dark: 样式完整)
- [ ] **Step 1: 全局暗色模式完善**
`index.css` 确保已有这些:
```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-xl``bg-white/70 dark:bg-gray-900/70`
- [ ] **Step 3: 构建验证**
```bash
cd D:\Code\doing_exercises\programs\QRGen && cargo build -p qrgen-gui
```
- [ ] **Step 4: 提交**
```bash
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 图案):
```bash
# 暂时用 core 的 PNG 渲染能力生成一个简单图标
# 或者直接放一个占位文件
```
简化处理:在 `tauri.conf.json` 中暂时去掉图标引用,用系统默认。
- [ ] **Step 2: 更新 tauri.conf.json 加打包配置**
```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: 最终编译检查**
```bash
cd D:\Code\doing_exercises\programs\QRGen && cargo build
cargo test
```
- [ ] **Step 4: 提交**
```bash
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` |