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