fix: 前端 HIGH/MEDIUM — timer 清理 + 历史持久化 + Error Boundary + console 移除

This commit is contained in:
2026-06-17 09:03:38 +08:00
parent feb5ae709f
commit 91bdf9ecc3
15 changed files with 184 additions and 84 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ fn main() -> anyhow::Result<()> {
match ext.as_str() { match ext.as_str() {
"png" => { "png" => {
let bytes = qr.to_png_bytes(args.size); let bytes = qr.to_png_bytes(args.size)?;
std::fs::write(&path, bytes)?; std::fs::write(&path, bytes)?;
println!( println!(
"已生成: {} (版本 {}, {}×{} 模块, {} 级纠错)", "已生成: {} (版本 {}, {}×{} 模块, {} 级纠错)",
+13 -19
View File
@@ -1,11 +1,9 @@
/// GF(2⁸) Galois 域运算 /// GF(2⁸) Galois 域运算
/// 本原多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D /// 本原多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D
/// 生成元 α = 0x02 /// 生成元 α = 0x02
use std::sync::OnceLock; use std::sync::LazyLock;
fn exp_table() -> &'static [u8; 512] { static EXP_TABLE: LazyLock<[u8; 512]> = LazyLock::new(|| {
static TABLE: OnceLock<[u8; 512]> = OnceLock::new();
TABLE.get_or_init(|| {
let mut table = [0u8; 512]; let mut table = [0u8; 512];
let mut x = 1u8; let mut x = 1u8;
for i in 0..255 { for i in 0..255 {
@@ -21,12 +19,9 @@ fn exp_table() -> &'static [u8; 512] {
table[510] = table[255]; table[510] = table[255];
table[511] = table[256]; table[511] = table[256];
table table
}) });
}
fn log_table() -> &'static [u8; 256] { static LOG_TABLE: LazyLock<[u8; 256]> = LazyLock::new(|| {
static TABLE: OnceLock<[u8; 256]> = OnceLock::new();
TABLE.get_or_init(|| {
let mut table = [0u8; 256]; let mut table = [0u8; 256];
let mut x = 1u8; let mut x = 1u8;
for i in 0..255 { for i in 0..255 {
@@ -39,8 +34,7 @@ fn log_table() -> &'static [u8; 256] {
}; };
} }
table table
}) });
}
/// GF(2⁸) 加法 = 异或 /// GF(2⁸) 加法 = 异或
#[inline] #[inline]
@@ -60,9 +54,9 @@ pub fn mul(a: u8, b: u8) -> u8 {
if a == 0 || b == 0 { if a == 0 || b == 0 {
return 0; return 0;
} }
let log_a = log_table()[a as usize] as usize; let log_a = LOG_TABLE[a as usize] as usize;
let log_b = log_table()[b as usize] as usize; let log_b = LOG_TABLE[b as usize] as usize;
exp_table()[log_a + log_b] EXP_TABLE[log_a + log_b]
} }
/// GF(2⁸) 除法:a / bb == 0 时返回 None /// GF(2⁸) 除法:a / bb == 0 时返回 None
@@ -74,10 +68,10 @@ pub fn div(a: u8, b: u8) -> Option<u8> {
if b == 0 { if b == 0 {
return None; return None;
} }
let log_a = log_table()[a as usize] as usize; let log_a = LOG_TABLE[a as usize] as usize;
let log_b = log_table()[b as usize] as usize; let log_b = LOG_TABLE[b as usize] as usize;
let diff = (log_a + 255 - log_b) % 255; let diff = (log_a + 255 - log_b) % 255;
Some(exp_table()[diff]) Some(EXP_TABLE[diff])
} }
/// GF(2⁸) 幂运算:base^exp /// GF(2⁸) 幂运算:base^exp
@@ -89,8 +83,8 @@ pub fn pow(base: u8, exp: usize) -> u8 {
if base == 0 { if base == 0 {
return 0; return 0;
} }
let log_b = log_table()[base as usize] as usize; let log_b = LOG_TABLE[base as usize] as usize;
exp_table()[(log_b * exp) % 255] EXP_TABLE[(log_b * exp) % 255]
} }
#[cfg(test)] #[cfg(test)]
+33 -2
View File
@@ -163,8 +163,8 @@ fn unicode_to_shift_jis(c: char) -> Option<u16> {
let sjis = ((hi << 8) | lo) as u16; let sjis = ((hi << 8) | lo) as u16;
// 映射到 13-bit 码字(内层 if/else 已区分两个 Shift-JIS 区间) // 映射到 13-bit 码字(内层 if/else 已区分两个 Shift-JIS 区间)
let val = { let val = {
let h = (sjis >> 8); let h = sjis >> 8;
let l = (sjis & 0xFF); let l = sjis & 0xFF;
if (0x81..=0x9F).contains(&h) { if (0x81..=0x9F).contains(&h) {
(h - 0x81) * 0xBC + (l - 0x40) (h - 0x81) * 0xBC + (l - 0x40)
} else { } else {
@@ -277,6 +277,37 @@ mod tests {
assert!(!is_kanji('A')); assert!(!is_kanji('A'));
} }
#[test]
fn test_kanji_encode_basic() {
// 常用汉字 "中文" 应能编码且长度正确
let bits = encode_kanji("中文");
// 2 个汉字,每个 13 bit = 26 bit
assert_eq!(bits.len(), 26);
}
#[test]
fn test_unicode_to_shift_jis_known() {
// 基本汉字应返回 Some
assert!(unicode_to_shift_jis('中').is_some());
assert!(unicode_to_shift_jis('文').is_some());
assert!(unicode_to_shift_jis('你').is_some());
}
#[test]
fn test_unicode_to_shift_jis_ascii_returns_none() {
// ASCII 字符不是汉字
assert!(unicode_to_shift_jis('A').is_none());
assert!(unicode_to_shift_jis('1').is_none());
}
#[test]
fn test_kanji_mode_fallback() {
// 非 CJK 字符会被降级为 UTF-8 字节编码
let bits = encode_kanji("A");
// 1 个 ASCII 字符 = 8 bit
assert_eq!(bits.len(), 8);
}
fn bits_to_u16(bits: &[bool]) -> u16 { fn bits_to_u16(bits: &[bool]) -> u16 {
bits.iter().fold(0, |acc, &b| (acc << 1) | (b as u16)) bits.iter().fold(0, |acc, &b| (acc << 1) | (b as u16))
} }
+4 -4
View File
@@ -185,19 +185,19 @@ fn score_rule4(matrix: &Matrix) -> u32 {
pub fn best_mask(matrix: &Matrix) -> (u8, Matrix) { pub fn best_mask(matrix: &Matrix) -> (u8, Matrix) {
let mut best_idx = 0u8; let mut best_idx = 0u8;
let mut best_score = u32::MAX; let mut best_score = u32::MAX;
let mut best_matrix = matrix.clone(); let mut best_matrix: Option<Matrix> = None;
for i in 0..8u8 { for i in 0..8u8 {
let masked = apply_mask(matrix, i); let masked = apply_mask(matrix, i);
let s = score(&masked); let s = score(&masked);
if s < best_score { if best_matrix.is_none() || s < best_score {
best_score = s; best_score = s;
best_idx = i; best_idx = i;
best_matrix = masked; best_matrix = Some(masked);
} }
} }
(best_idx, best_matrix) (best_idx, best_matrix.unwrap())
} }
#[cfg(test)] #[cfg(test)]
+10 -5
View File
@@ -24,6 +24,15 @@ pub fn place_finder_patterns(matrix: &mut Matrix) {
matrix.reserve(x, y); matrix.reserve(x, y);
} }
} }
// 定位图案分隔符(1 模块宽的白色边框,在 finder 周围)
for i in 0..8u8 {
if fx + 7 < matrix.size {
matrix.reserve(fx + 7, fy + i); // 右侧分隔列
}
if fy + 7 < matrix.size {
matrix.reserve(fx + i, fy + 7); // 底部隔行
}
}
} }
} }
@@ -59,11 +68,7 @@ fn place_single_alignment(matrix: &mut Matrix, cx: u8, cy: u8) {
let y0 = cy - 2; let y0 = cy - 2;
for dy in 0..5u8 { for dy in 0..5u8 {
for dx in 0..5u8 { for dx in 0..5u8 {
let is_dark = match (dx, dy) { let is_dark = matches!((dx, dy), (0, _) | (4, _) | (_, 0) | (_, 4) | (2, 2));
(0, _) | (4, _) | (_, 0) | (_, 4) => true,
(2, 2) => true,
_ => false,
};
let x = x0 + dx; let x = x0 + dx;
let y = y0 + dy; let y = y0 + dy;
matrix.set(x, y, is_dark); matrix.set(x, y, is_dark);
+1 -1
View File
@@ -143,7 +143,7 @@ impl QrCode {
crate::render::ascii::render_ascii(self, invert) crate::render::ascii::render_ascii(self, invert)
} }
pub fn to_png_bytes(&self, module_size: u8) -> Vec<u8> { pub fn to_png_bytes(&self, module_size: u8) -> Result<Vec<u8>, image::ImageError> {
crate::render::png::render_png(self, module_size) crate::render::png::render_png(self, module_size)
} }
} }
+3 -4
View File
@@ -1,7 +1,7 @@
use crate::qr::QrCode; use crate::qr::QrCode;
use image::{ImageBuffer, Luma}; use image::{ImageBuffer, Luma};
pub fn render_png(qr: &QrCode, module_size: u8) -> Vec<u8> { pub fn render_png(qr: &QrCode, module_size: u8) -> Result<Vec<u8>, image::ImageError> {
let matrix_size = qr.size() as u32; let matrix_size = qr.size() as u32;
let margin = qr.margin as u32; let margin = qr.margin as u32;
let total_size = matrix_size + 2 * margin; let total_size = matrix_size + 2 * margin;
@@ -34,7 +34,6 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Vec<u8> {
} }
let mut buf = Vec::new(); let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
.expect("PNG 编码失败"); Ok(buf)
buf
} }
+1 -1
View File
@@ -83,7 +83,7 @@ fn test_ascii_output() {
#[test] #[test]
fn test_png_output() { fn test_png_output() {
let qr = QrCode::encode("TEST", QrConfig::default()).unwrap(); let qr = QrCode::encode("TEST", QrConfig::default()).unwrap();
let png = qr.to_png_bytes(4); let png = qr.to_png_bytes(4).unwrap();
assert!(!png.is_empty()); assert!(!png.is_empty());
// PNG 文件应以 8 字节魔术签名开头 // PNG 文件应以 8 字节魔术签名开头
assert_eq!(&png[..8], &[137, 80, 78, 71, 13, 10, 26, 10]); assert_eq!(&png[..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
+3
View File
@@ -1,4 +1,5 @@
import { QrProvider, useQrState } from './store/qrContext'; import { QrProvider, useQrState } from './store/qrContext';
import ErrorBoundary from './components/ErrorBoundary';
import ModePanel from './components/ModePanel'; import ModePanel from './components/ModePanel';
import QrPreview from './components/QrPreview'; import QrPreview from './components/QrPreview';
import ExportPanel from './components/ExportPanel'; import ExportPanel from './components/ExportPanel';
@@ -60,8 +61,10 @@ function BottomInput() {
export default function App() { export default function App() {
return ( return (
<ErrorBoundary>
<QrProvider> <QrProvider>
<AppLayout /> <AppLayout />
</QrProvider> </QrProvider>
</ErrorBoundary>
); );
} }
@@ -0,0 +1,27 @@
import React, { Component, type ReactNode } from 'react';
interface Props { children: ReactNode; }
interface State { hasError: boolean; error: Error | null; }
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) { return { hasError: true, error }; }
render() {
if (this.state.hasError) {
return (
<div className="h-screen flex flex-col items-center justify-center gap-3 bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400">
<span className="text-4xl"></span>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm max-w-md text-center">{this.state.error?.message}</p>
<button onClick={() => window.location.reload()}
className="px-4 py-2 rounded-lg bg-blue-500 text-white text-sm hover:bg-blue-600 transition-all">
</button>
</div>
);
}
return this.props.children;
}
}
@@ -45,7 +45,7 @@ export default function ExportPanel() {
}); });
await writeFile(filePath, new Uint8Array(bytes)); await writeFile(filePath, new Uint8Array(bytes));
} catch (e) { } catch (e) {
console.error('导出 PNG 失败:', e); console.warn('导出 PNG 失败:', e);
} }
setExporting(false); setExporting(false);
}; };
@@ -60,7 +60,7 @@ export default function ExportPanel() {
if (!filePath) return; if (!filePath) return;
await writeFile(filePath, new TextEncoder().encode(state.preview.svg)); await writeFile(filePath, new TextEncoder().encode(state.preview.svg));
} catch (e) { } catch (e) {
console.error('导出 SVG 失败:', e); console.warn('导出 SVG 失败:', e);
} }
}; };
@@ -15,6 +15,7 @@ export default function QrPreview() {
return ( return (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
{/* SVG 由 Rust 端 qr-core 生成,仅含 <rect> 和固定颜色,无用户文本嵌入 */}
<div <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-white qr-preview" 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-white qr-preview"
dangerouslySetInnerHTML={{ __html: state.preview.svg }} dangerouslySetInnerHTML={{ __html: state.preview.svg }}
+28 -9
View File
@@ -1,6 +1,10 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { Store } from '@tauri-apps/plugin-store';
import { useQrState } from '../store/qrContext'; import { useQrState } from '../store/qrContext';
import type { HistoryEntry } from '../types';
const HISTORY_KEY = 'qr-history';
interface QrResponse { interface QrResponse {
svg: string; svg: string;
@@ -15,6 +19,13 @@ export function useQrEncode() {
const modeRef = useRef(state.mode); const modeRef = useRef(state.mode);
modeRef.current = state.mode; modeRef.current = state.mode;
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const encode = useCallback((text: string) => { const encode = useCallback((text: string) => {
if (!text.trim()) { if (!text.trim()) {
dispatch({ type: 'SET_PREVIEW', payload: null }); dispatch({ type: 'SET_PREVIEW', payload: null });
@@ -33,18 +44,26 @@ export function useQrEncode() {
}); });
dispatch({ type: 'SET_PREVIEW', payload: result }); dispatch({ type: 'SET_PREVIEW', payload: result });
// 保存到历史 // 保存到历史(内存 + 持久化)
dispatch({ const entryId = Date.now().toString();
type: 'ADD_HISTORY', const entry: HistoryEntry = {
payload: { id: entryId,
id: Date.now().toString(),
mode: modeRef.current, mode: modeRef.current,
content: text, content: text,
timestamp: Date.now(), timestamp: Date.now(),
}, };
}); dispatch({ type: 'ADD_HISTORY', payload: entry });
// 持久化到 tauri-plugin-store
try {
const store = await Store.load('history.json');
const current = await store.get<HistoryEntry[]>(HISTORY_KEY) || [];
const updated = [entry, ...current].slice(0, 50);
await store.set(HISTORY_KEY, updated);
await store.save();
} catch { /* store 不可用时静默忽略 */ }
} catch (e) { } catch (e) {
console.error('QR 编码失败:', e); // 编码失败已在 dispatch SET_PREVIEW(null) 中处理,无需额外日志
dispatch({ type: 'SET_PREVIEW', payload: null }); dispatch({ type: 'SET_PREVIEW', payload: null });
} }
}, 200); }, 200);
+15 -2
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useReducer, type ReactNode } from 'react'; import React, { createContext, useContext, useReducer, useEffect, type ReactNode } from 'react';
import type { QrState, QrAction } from '../types'; import { Store } from '@tauri-apps/plugin-store';
import type { QrState, QrAction, HistoryEntry } from '../types';
const initialState: QrState = { const initialState: QrState = {
mode: 'text', mode: 'text',
@@ -42,6 +43,18 @@ const QrContext = createContext<{
export function QrProvider({ children }: { children: ReactNode }) { export function QrProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(qrReducer, initialState); const [state, dispatch] = useReducer(qrReducer, initialState);
// 启动时从 store 加载持久化的历史记录
useEffect(() => {
(async () => {
try {
const store = await Store.load('history.json');
const history = await store.get<HistoryEntry[]>('qr-history') || [];
dispatch({ type: 'SET_HISTORY', payload: history });
} catch { /* store 不可用时忽略 */ }
})();
}, []);
return <QrContext.Provider value={{ state, dispatch }}>{children}</QrContext.Provider>; return <QrContext.Provider value={{ state, dispatch }}>{children}</QrContext.Provider>;
} }
+9 -1
View File
@@ -29,6 +29,10 @@ struct AppState {
/// 编码 QR 码,返回 SVG + 元信息 /// 编码 QR 码,返回 SVG + 元信息
#[tauri::command] #[tauri::command]
fn encode_qr(text: String, level: String, margin: u8) -> Result<QrResponse, String> { fn encode_qr(text: String, level: String, margin: u8) -> Result<QrResponse, String> {
if margin > 100 {
return Err("边距过大(最大 100".into());
}
let ec_level = match level.to_uppercase().as_str() { let ec_level = match level.to_uppercase().as_str() {
"L" => EcLevel::L, "L" => EcLevel::L,
"M" => EcLevel::M, "M" => EcLevel::M,
@@ -58,6 +62,10 @@ fn encode_qr(text: String, level: String, margin: u8) -> Result<QrResponse, Stri
/// 导出 PNG bytes /// 导出 PNG bytes
#[tauri::command] #[tauri::command]
fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Result<Vec<u8>, String> { fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Result<Vec<u8>, String> {
if margin > 100 {
return Err("边距过大(最大 100".into());
}
let ec_level = match level.to_uppercase().as_str() { let ec_level = match level.to_uppercase().as_str() {
"L" => EcLevel::L, "L" => EcLevel::L,
"M" => EcLevel::M, "M" => EcLevel::M,
@@ -74,7 +82,7 @@ fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Resul
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?; let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
Ok(qr.to_png_bytes(module_size)) qr.to_png_bytes(module_size).map_err(|e| format!("PNG 导出失败: {}", e))
} }
/// 保存历史记录条目 /// 保存历史记录条目