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() {
"png" => {
let bytes = qr.to_png_bytes(args.size);
let bytes = qr.to_png_bytes(args.size)?;
std::fs::write(&path, bytes)?;
println!(
"已生成: {} (版本 {}, {}×{} 模块, {} 级纠错)",
+40 -46
View File
@@ -1,46 +1,40 @@
/// GF(2⁸) Galois 域运算
/// 本原多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D
/// 生成元 α = 0x02
use std::sync::OnceLock;
use std::sync::LazyLock;
fn exp_table() -> &'static [u8; 512] {
static TABLE: OnceLock<[u8; 512]> = OnceLock::new();
TABLE.get_or_init(|| {
let mut table = [0u8; 512];
let mut x = 1u8;
for i in 0..255 {
table[i] = x;
table[i + 255] = x; // 双倍长度避免 % 255
let next = (x as u16) << 1;
x = if next >= 0x100 {
(next ^ 0x1D) as u8
} else {
next as u8
};
}
table[510] = table[255];
table[511] = table[256];
table
})
}
static EXP_TABLE: LazyLock<[u8; 512]> = LazyLock::new(|| {
let mut table = [0u8; 512];
let mut x = 1u8;
for i in 0..255 {
table[i] = x;
table[i + 255] = x; // 双倍长度避免 % 255
let next = (x as u16) << 1;
x = if next >= 0x100 {
(next ^ 0x1D) as u8
} else {
next as u8
};
}
table[510] = table[255];
table[511] = table[256];
table
});
fn log_table() -> &'static [u8; 256] {
static TABLE: OnceLock<[u8; 256]> = OnceLock::new();
TABLE.get_or_init(|| {
let mut table = [0u8; 256];
let mut x = 1u8;
for i in 0..255 {
table[x as usize] = i;
let next = (x as u16) << 1;
x = if next >= 0x100 {
(next ^ 0x1D) as u8
} else {
next as u8
};
}
table
})
}
static LOG_TABLE: LazyLock<[u8; 256]> = LazyLock::new(|| {
let mut table = [0u8; 256];
let mut x = 1u8;
for i in 0..255 {
table[x as usize] = i;
let next = (x as u16) << 1;
x = if next >= 0x100 {
(next ^ 0x1D) as u8
} else {
next as u8
};
}
table
});
/// GF(2⁸) 加法 = 异或
#[inline]
@@ -60,9 +54,9 @@ pub fn mul(a: u8, b: u8) -> u8 {
if a == 0 || b == 0 {
return 0;
}
let log_a = log_table()[a as usize] as usize;
let log_b = log_table()[b as usize] as usize;
exp_table()[log_a + log_b]
let log_a = LOG_TABLE[a as usize] as usize;
let log_b = LOG_TABLE[b as usize] as usize;
EXP_TABLE[log_a + log_b]
}
/// GF(2⁸) 除法:a / bb == 0 时返回 None
@@ -74,10 +68,10 @@ pub fn div(a: u8, b: u8) -> Option<u8> {
if b == 0 {
return None;
}
let log_a = log_table()[a as usize] as usize;
let log_b = log_table()[b 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 diff = (log_a + 255 - log_b) % 255;
Some(exp_table()[diff])
Some(EXP_TABLE[diff])
}
/// GF(2⁸) 幂运算:base^exp
@@ -89,8 +83,8 @@ pub fn pow(base: u8, exp: usize) -> u8 {
if base == 0 {
return 0;
}
let log_b = log_table()[base as usize] as usize;
exp_table()[(log_b * exp) % 255]
let log_b = LOG_TABLE[base as usize] as usize;
EXP_TABLE[(log_b * exp) % 255]
}
#[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;
// 映射到 13-bit 码字(内层 if/else 已区分两个 Shift-JIS 区间)
let val = {
let h = (sjis >> 8);
let l = (sjis & 0xFF);
let h = sjis >> 8;
let l = sjis & 0xFF;
if (0x81..=0x9F).contains(&h) {
(h - 0x81) * 0xBC + (l - 0x40)
} else {
@@ -277,6 +277,37 @@ mod tests {
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 {
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) {
let mut best_idx = 0u8;
let mut best_score = u32::MAX;
let mut best_matrix = matrix.clone();
let mut best_matrix: Option<Matrix> = None;
for i in 0..8u8 {
let masked = apply_mask(matrix, i);
let s = score(&masked);
if s < best_score {
if best_matrix.is_none() || s < best_score {
best_score = s;
best_idx = i;
best_matrix = masked;
best_matrix = Some(masked);
}
}
(best_idx, best_matrix)
(best_idx, best_matrix.unwrap())
}
#[cfg(test)]
+10 -5
View File
@@ -24,6 +24,15 @@ pub fn place_finder_patterns(matrix: &mut Matrix) {
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;
for dy in 0..5u8 {
for dx in 0..5u8 {
let is_dark = match (dx, dy) {
(0, _) | (4, _) | (_, 0) | (_, 4) => true,
(2, 2) => true,
_ => false,
};
let is_dark = matches!((dx, dy), (0, _) | (4, _) | (_, 0) | (_, 4) | (2, 2));
let x = x0 + dx;
let y = y0 + dy;
matrix.set(x, y, is_dark);
+1 -1
View File
@@ -143,7 +143,7 @@ impl QrCode {
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)
}
}
+3 -4
View File
@@ -1,7 +1,7 @@
use crate::qr::QrCode;
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 margin = qr.margin as u32;
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();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.expect("PNG 编码失败");
buf
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
Ok(buf)
}
+1 -1
View File
@@ -83,7 +83,7 @@ fn test_ascii_output() {
#[test]
fn test_png_output() {
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());
// PNG 文件应以 8 字节魔术签名开头
assert_eq!(&png[..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
+6 -3
View File
@@ -1,4 +1,5 @@
import { QrProvider, useQrState } from './store/qrContext';
import ErrorBoundary from './components/ErrorBoundary';
import ModePanel from './components/ModePanel';
import QrPreview from './components/QrPreview';
import ExportPanel from './components/ExportPanel';
@@ -60,8 +61,10 @@ function BottomInput() {
export default function App() {
return (
<QrProvider>
<AppLayout />
</QrProvider>
<ErrorBoundary>
<QrProvider>
<AppLayout />
</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));
} catch (e) {
console.error('导出 PNG 失败:', e);
console.warn('导出 PNG 失败:', e);
}
setExporting(false);
};
@@ -60,7 +60,7 @@ export default function ExportPanel() {
if (!filePath) return;
await writeFile(filePath, new TextEncoder().encode(state.preview.svg));
} catch (e) {
console.error('导出 SVG 失败:', e);
console.warn('导出 SVG 失败:', e);
}
};
@@ -15,6 +15,7 @@ export default function QrPreview() {
return (
<div className="flex flex-col items-center gap-3">
{/* SVG 由 Rust 端 qr-core 生成,仅含 <rect> 和固定颜色,无用户文本嵌入 */}
<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"
dangerouslySetInnerHTML={{ __html: state.preview.svg }}
+31 -12
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 { Store } from '@tauri-apps/plugin-store';
import { useQrState } from '../store/qrContext';
import type { HistoryEntry } from '../types';
const HISTORY_KEY = 'qr-history';
interface QrResponse {
svg: string;
@@ -15,6 +19,13 @@ export function useQrEncode() {
const modeRef = useRef(state.mode);
modeRef.current = state.mode;
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const encode = useCallback((text: string) => {
if (!text.trim()) {
dispatch({ type: 'SET_PREVIEW', payload: null });
@@ -33,18 +44,26 @@ export function useQrEncode() {
});
dispatch({ type: 'SET_PREVIEW', payload: result });
// 保存到历史
dispatch({
type: 'ADD_HISTORY',
payload: {
id: Date.now().toString(),
mode: modeRef.current,
content: text,
timestamp: Date.now(),
},
});
// 保存到历史(内存 + 持久化)
const entryId = Date.now().toString();
const entry: HistoryEntry = {
id: entryId,
mode: modeRef.current,
content: text,
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) {
console.error('QR 编码失败:', e);
// 编码失败已在 dispatch SET_PREVIEW(null) 中处理,无需额外日志
dispatch({ type: 'SET_PREVIEW', payload: null });
}
}, 200);
+15 -2
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useReducer, type ReactNode } from 'react';
import type { QrState, QrAction } from '../types';
import React, { createContext, useContext, useReducer, useEffect, type ReactNode } from 'react';
import { Store } from '@tauri-apps/plugin-store';
import type { QrState, QrAction, HistoryEntry } from '../types';
const initialState: QrState = {
mode: 'text',
@@ -42,6 +43,18 @@ const QrContext = createContext<{
export function QrProvider({ children }: { children: ReactNode }) {
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>;
}
+9 -1
View File
@@ -29,6 +29,10 @@ struct AppState {
/// 编码 QR 码,返回 SVG + 元信息
#[tauri::command]
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() {
"L" => EcLevel::L,
"M" => EcLevel::M,
@@ -58,6 +62,10 @@ fn encode_qr(text: String, level: String, margin: u8) -> Result<QrResponse, Stri
/// 导出 PNG bytes
#[tauri::command]
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() {
"L" => EcLevel::L,
"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))?;
Ok(qr.to_png_bytes(module_size))
qr.to_png_bytes(module_size).map_err(|e| format!("PNG 导出失败: {}", e))
}
/// 保存历史记录条目