45 KiB
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:
[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 中给 EcLevel 加 Serialize 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-xl 和 bg-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 |