1662 lines
45 KiB
Markdown
1662 lines
45 KiB
Markdown
# 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
|
||
<!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` |
|