fix: 全面代码审查修复 — 安全/类型/持久化/代码质量 (28项)
🔴 CRITICAL (1): - tauri.conf.json: CSP 从 null 改为最小权限策略 🟠 HIGH (6): - 新建 capabilities/default.json: Tauri v2 权限约束(store/dialog/clipboard) - cli: 路径遍历防护 — 拒绝含 ParentDir 组件的输出路径 - HistoryList: 删除/清空同步持久化到 store,历史点击用 formData 回填 - ExportPanel: 移除 console.warn,getCurrentText any→QrState - useQrEncode: WiFi 密码在历史中脱敏显示(P:***),Store 实例缓存 🟡 MEDIUM (10): - mode.rs: Kanji fallback 从 UTF-8 字节改为 13-bit 零值占位(段内模式一致) - mode.rs: Shift JIS 第二字节跳过 0x7F 空洞,修正行内索引 - mode.rs: encode_numeric/alphanumeric 添加 debug_assert! 前置条件 - mask.rs: best_matrix.unwrap()→expect() 附错误信息 - version.rs: ec_info 仅返回 count>0 的 BlockInfo,EcInfo/BlockInfo 加 Debug+Clone - types/index.ts: HistoryEntry.mode string→ModeType,新增 formData 字段 - qrContext.tsx: 使用缓存 Store 加载历史 🟢 LOW (11): - cargo fmt 全部文件 - svg.rs: String::new()→with_capacity() 预分配 - patterns.rs: encode_format_info 拆分为两行提高可读性 - png.rs: 提取 fill_module() 辅助函数降低嵌套 - ErrorBoundary: 添加 componentDidCatch 错误日志入口 - QrPreview: dangerouslySetInnerHTML→<img>+data URL(安全),loading 状态指示 - galois.rs/version.rs: 5 处 #[allow(clippy::indexing_slicing)]+安全文档 - 新建 utils/qrText.ts: 集中管理 6 种模式的文本构造,消除 ExportPanel/mode 间重复 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
/.claude
|
||||
/.codegraph
|
||||
/target
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
+10
-1
@@ -66,7 +66,16 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
match args.output {
|
||||
Some(path) => {
|
||||
let ext = Path::new(&path)
|
||||
// 防止路径遍历攻击,拒绝包含 ".." 的路径
|
||||
let path_obj = Path::new(&path);
|
||||
if path_obj
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||
{
|
||||
anyhow::bail!("不允许包含 '..' 的路径,请使用当前目录下的文件名");
|
||||
}
|
||||
|
||||
let ext = path_obj
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
|
||||
+14
-9
@@ -1,8 +1,15 @@
|
||||
/// GF(2⁸) Galois 域运算
|
||||
/// 本原多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D
|
||||
/// 生成元 α = 0x02
|
||||
//! GF(2⁸) Galois 域运算
|
||||
//! 本原多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D
|
||||
//! 生成元 α = 0x02
|
||||
//!
|
||||
//! 索引安全性: 本模块内数组索引依赖 GF(2⁸) 数学性质——
|
||||
//! * LOG_TABLE[0..=255] → 查表键为 u8,始终在界内
|
||||
//! * EXP_TABLE[0..=511] → 双倍长度避免取模,所有指数和 ≤508<512
|
||||
//! * 所有索引数学上保证安全,编译器无法静态证明
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
static EXP_TABLE: LazyLock<[u8; 512]> = LazyLock::new(|| {
|
||||
let mut table = [0u8; 512];
|
||||
let mut x = 1u8;
|
||||
@@ -21,6 +28,7 @@ static EXP_TABLE: LazyLock<[u8; 512]> = LazyLock::new(|| {
|
||||
table
|
||||
});
|
||||
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
static LOG_TABLE: LazyLock<[u8; 256]> = LazyLock::new(|| {
|
||||
let mut table = [0u8; 256];
|
||||
let mut x = 1u8;
|
||||
@@ -50,6 +58,7 @@ pub fn sub(a: u8, b: u8) -> u8 {
|
||||
|
||||
/// GF(2⁸) 乘法:a * b
|
||||
#[inline]
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
pub fn mul(a: u8, b: u8) -> u8 {
|
||||
if a == 0 || b == 0 {
|
||||
return 0;
|
||||
@@ -61,6 +70,7 @@ pub fn mul(a: u8, b: u8) -> u8 {
|
||||
|
||||
/// GF(2⁸) 除法:a / b,b == 0 时返回 None
|
||||
#[inline]
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
pub fn div(a: u8, b: u8) -> Option<u8> {
|
||||
if a == 0 {
|
||||
return Some(0);
|
||||
@@ -76,6 +86,7 @@ pub fn div(a: u8, b: u8) -> Option<u8> {
|
||||
|
||||
/// GF(2⁸) 幂运算:base^exp
|
||||
#[inline]
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
pub fn pow(base: u8, exp: usize) -> u8 {
|
||||
if exp == 0 {
|
||||
return 1;
|
||||
@@ -93,11 +104,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_mul_basic() {
|
||||
// 2 * 3 = 6 in GF(2^8)
|
||||
assert_eq!(mul(2, 3), 6);
|
||||
// 0xFF * 0x01 = 0xFF
|
||||
assert_eq!(mul(0xFF, 1), 0xFF);
|
||||
// 任何数乘 0 = 0
|
||||
assert_eq!(mul(0xA5, 0), 0);
|
||||
}
|
||||
|
||||
@@ -167,11 +175,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_pow() {
|
||||
// α^0 = 1
|
||||
assert_eq!(pow(2, 0), 1);
|
||||
// α^1 = 2
|
||||
assert_eq!(pow(2, 1), 2);
|
||||
// α^7 * α^2 = α^9
|
||||
assert_eq!(mul(pow(2, 7), pow(2, 2)), pow(2, 9));
|
||||
}
|
||||
}
|
||||
|
||||
+45
-20
@@ -60,7 +60,12 @@ impl Mode {
|
||||
}
|
||||
|
||||
/// 数字模式编码: 每 3 位数字 → 10 bit
|
||||
/// 调用方应确保 input 仅包含 ASCII 数字字符 (0-9)
|
||||
pub fn encode_numeric(input: &str) -> Vec<bool> {
|
||||
debug_assert!(
|
||||
input.chars().all(is_numeric),
|
||||
"encode_numeric: 输入含非数字字符"
|
||||
);
|
||||
let mut bits = Vec::new();
|
||||
let chars: Vec<u8> = input
|
||||
.chars()
|
||||
@@ -87,7 +92,12 @@ pub fn encode_numeric(input: &str) -> Vec<bool> {
|
||||
const ALPHANUMERIC_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
|
||||
|
||||
/// 字母数字模式编码: 每 2 个字符 → 11 bit
|
||||
/// 调用方应确保 input 仅包含字母数字字符集内的字符
|
||||
pub fn encode_alphanumeric(input: &str) -> Vec<bool> {
|
||||
debug_assert!(
|
||||
input.chars().all(is_alphanumeric),
|
||||
"encode_alphanumeric: 输入含非法字符"
|
||||
);
|
||||
let values: Vec<u8> = input
|
||||
.chars()
|
||||
.filter_map(|c| {
|
||||
@@ -127,7 +137,11 @@ pub fn encode_byte(input: &str) -> Vec<bool> {
|
||||
}
|
||||
|
||||
/// 汉字模式编码 (Shift JIS → 13 bit)
|
||||
/// 对于无法转换为 Shift JIS 的字符,降级为 UTF-8 字节编码
|
||||
/// 对于无法转换为 Shift JIS 的字符,使用全零占位符(避免段内模式混用)
|
||||
///
|
||||
/// 注: segment_text 保证 Kanji 段内字符均通过 is_kanji() 检测,
|
||||
/// 但 unicode_to_shift_jis 目前仅覆盖 U+4E00-U+9FFF (基本 CJK 统一汉字)。
|
||||
/// U+3400-U+4DBF (CJK Ext-A) 和 U+3000-U+303F (CJK 符号) 的映射有待补全。
|
||||
pub fn encode_kanji(input: &str) -> Vec<bool> {
|
||||
let mut bits = Vec::new();
|
||||
for c in input.chars() {
|
||||
@@ -136,14 +150,9 @@ pub fn encode_kanji(input: &str) -> Vec<bool> {
|
||||
bits.push((sjis_val >> i) & 1 == 1);
|
||||
}
|
||||
} else {
|
||||
// 回退到字节模式
|
||||
let mut buf = [0u8; 4];
|
||||
let s = c.encode_utf8(&mut buf);
|
||||
for &byte in s.as_bytes() {
|
||||
for i in (0..8).rev() {
|
||||
bits.push((byte >> i) & 1 == 1);
|
||||
}
|
||||
}
|
||||
// 无法映射的字符,填充 13-bit 零值占位符
|
||||
// 避免模式混用(不插入字节模式编码以保证段内一致性)
|
||||
bits.extend(std::iter::repeat_n(false, 13));
|
||||
}
|
||||
}
|
||||
bits
|
||||
@@ -151,6 +160,10 @@ pub fn encode_kanji(input: &str) -> Vec<bool> {
|
||||
|
||||
/// Unicode → Shift JIS 简化转换
|
||||
/// 覆盖常用 CJK 统一汉字 (U+4E00 ~ U+9FFF)
|
||||
///
|
||||
/// 注意: 此映射采用线性近似公式。实际上 Unicode CJK 与 Shift JIS (JIS X 0208)
|
||||
/// 并非严格线性对应,对于非线性的字符会产生偏差。
|
||||
/// 对于需要精确映射的场景,建议使用完整的 CJK→Shift JIS 映射表。
|
||||
fn unicode_to_shift_jis(c: char) -> Option<u16> {
|
||||
let code = c as u32;
|
||||
// CJK 统一汉字 基本区
|
||||
@@ -163,20 +176,32 @@ fn unicode_to_shift_jis(c: char) -> Option<u16> {
|
||||
|
||||
// Shift JIS 汉字有两段区间: 0x81-0x9F 和 0xE0-0xEF
|
||||
// 中间 0xA0-0xDF 为间隙,需要跳过
|
||||
let (hi, lo) = if hi_offset < 31 {
|
||||
(0x81u16 + hi_offset as u16, 0x40u16 + lo_offset as u16)
|
||||
let hi = if hi_offset < 31 {
|
||||
0x81u16 + hi_offset as u16
|
||||
} else {
|
||||
(
|
||||
0xE0u16 + (hi_offset - 31) as u16,
|
||||
0x40u16 + lo_offset as u16,
|
||||
)
|
||||
0xE0u16 + (hi_offset - 31) as u16
|
||||
};
|
||||
|
||||
// 第二字节有效范围: 0x40-0x7E 和 0x80-0xFC (跳过 0x7F)
|
||||
let lo_base = 0x40u16 + lo_offset as u16;
|
||||
let lo = if lo_base >= 0x7F {
|
||||
lo_base + 1 // 跳过无效的 0x7F
|
||||
} else {
|
||||
lo_base
|
||||
};
|
||||
|
||||
// 行内索引: 0x40..=0x7E → 0..62, 0x80..=0xFC → 63..187
|
||||
let lo_idx = if lo >= 0x80 {
|
||||
lo - 0x41 // 跳过 0x7F 后的偏移
|
||||
} else {
|
||||
lo - 0x40
|
||||
};
|
||||
|
||||
// 映射到 13-bit 码字
|
||||
let val = if (0x81..=0x9Fu16).contains(&hi) {
|
||||
(hi - 0x81) * 0xBC + (lo - 0x40)
|
||||
(hi - 0x81) * 0xBC + lo_idx
|
||||
} else {
|
||||
(hi - 0xC1) * 0xBC + (lo - 0x40)
|
||||
(hi - 0xC1) * 0xBC + lo_idx
|
||||
};
|
||||
return Some(val);
|
||||
}
|
||||
@@ -309,10 +334,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_kanji_mode_fallback() {
|
||||
// 非 CJK 字符会被降级为 UTF-8 字节编码
|
||||
// 非 CJK 字符无法映射到 Shift JIS,填充 13-bit 零值占位符(保持段内模式一致)
|
||||
let bits = encode_kanji("A");
|
||||
// 1 个 ASCII 字符 = 8 bit
|
||||
assert_eq!(bits.len(), 8);
|
||||
// 1 个无法映射的字符 = 13 bit 占位符
|
||||
assert_eq!(bits.len(), 13);
|
||||
}
|
||||
|
||||
fn bits_to_u16(bits: &[bool]) -> u16 {
|
||||
|
||||
@@ -197,7 +197,10 @@ pub fn best_mask(matrix: &Matrix) -> (u8, Matrix) {
|
||||
}
|
||||
}
|
||||
|
||||
(best_idx, best_matrix.unwrap())
|
||||
(
|
||||
best_idx,
|
||||
best_matrix.expect("掩码循环 (0..8) 至少执行一次,best_matrix 必定有值"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -153,8 +153,10 @@ pub fn encode_format_info(ec_bits: u8, mask: u8) -> u16 {
|
||||
}
|
||||
}
|
||||
|
||||
// XOR 掩码 — 注意 ^ 优先级高于 |,必须加括号
|
||||
(((data as u16) << 10) | (val & 0x3FF)) ^ 0x5412
|
||||
// BCH 编码结果: (data << 10) | remainder, 再与掩码 XOR
|
||||
// 注意: ^ 优先级高于 |,必须用括号保护
|
||||
let raw = ((data as u16) << 10) | (val & 0x3FF);
|
||||
raw ^ 0x5412
|
||||
}
|
||||
|
||||
/// 将格式信息写入矩阵(两处镜像放置)
|
||||
|
||||
+19
-10
@@ -1,6 +1,24 @@
|
||||
use crate::qr::QrCode;
|
||||
use image::{ImageBuffer, Luma};
|
||||
|
||||
/// 将单个模块填充到图像缓冲区(module_size × module_size 像素块)
|
||||
fn fill_module(
|
||||
img: &mut ImageBuffer<Luma<u8>, Vec<u8>>,
|
||||
x: u32,
|
||||
y: u32,
|
||||
module_size: u32,
|
||||
is_dark: bool,
|
||||
) {
|
||||
let px_val = if is_dark { 0u8 } else { 255u8 };
|
||||
let x0 = x * module_size;
|
||||
let y0 = y * module_size;
|
||||
for dy in 0..module_size {
|
||||
for dx in 0..module_size {
|
||||
img.put_pixel(x0 + dx, y0 + dy, Luma([px_val]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -20,16 +38,7 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Result<Vec<u8>, image::ImageE
|
||||
false // 白边
|
||||
};
|
||||
|
||||
let px_val = if is_dark { 0u8 } else { 255u8 };
|
||||
for dy in 0..module_size as u32 {
|
||||
for dx in 0..module_size as u32 {
|
||||
img.put_pixel(
|
||||
x * module_size as u32 + dx,
|
||||
y * module_size as u32 + dy,
|
||||
Luma([px_val]),
|
||||
);
|
||||
}
|
||||
}
|
||||
fill_module(&mut img, x, y, module_size as u32, is_dark);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,14 @@ pub fn render_svg(qr: &QrCode) -> String {
|
||||
let margin = qr.margin as u32;
|
||||
let total = matrix_size + 2 * margin;
|
||||
|
||||
let mut svg = String::new();
|
||||
// 预估 SVG 大小: 固定头部 + 每个暗模块约 48 字节
|
||||
let dark_count = qr
|
||||
.modules()
|
||||
.iter()
|
||||
.flat_map(|row| row.iter())
|
||||
.filter(|&&m| m)
|
||||
.count();
|
||||
let mut svg = String::with_capacity(200 + dark_count * 50);
|
||||
svg.push_str(&format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">"#,
|
||||
total, total, total, total
|
||||
|
||||
+21
-11
@@ -82,29 +82,36 @@ impl Version {
|
||||
row.h_g2_data,
|
||||
),
|
||||
};
|
||||
let mut blocks = Vec::new();
|
||||
if g1_blocks > 0 {
|
||||
blocks.push(BlockInfo {
|
||||
count: g1_blocks,
|
||||
data_codewords: g1_data,
|
||||
});
|
||||
}
|
||||
if g2_blocks > 0 {
|
||||
blocks.push(BlockInfo {
|
||||
count: g2_blocks,
|
||||
data_codewords: g2_data,
|
||||
});
|
||||
}
|
||||
|
||||
EcInfo {
|
||||
total_codewords: total,
|
||||
ec_per_block,
|
||||
blocks: vec![
|
||||
BlockInfo {
|
||||
count: g1_blocks,
|
||||
data_codewords: g1_data,
|
||||
},
|
||||
BlockInfo {
|
||||
count: g2_blocks,
|
||||
data_codewords: g2_data,
|
||||
},
|
||||
],
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EcInfo {
|
||||
pub total_codewords: u16,
|
||||
pub ec_per_block: u8,
|
||||
pub blocks: Vec<BlockInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockInfo {
|
||||
pub count: u16,
|
||||
pub data_codewords: u16,
|
||||
@@ -1283,6 +1290,8 @@ fn init_capacity_table() -> [[u16; 4]; 40] {
|
||||
table
|
||||
}
|
||||
|
||||
// SAFETY: version.0 ∈ [1,40] 由 Version::new() 保证; level 是 4 变体枚举
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
pub fn get_data_capacity(version: Version, level: EcLevel) -> u16 {
|
||||
static CAPACITY: OnceLock<[[u16; 4]; 40]> = OnceLock::new();
|
||||
let cap = CAPACITY.get_or_init(init_capacity_table);
|
||||
@@ -1335,9 +1344,10 @@ mod tests {
|
||||
fn test_ec_info_blocks() {
|
||||
let info = Version(1).ec_info(EcLevel::M);
|
||||
// Version 1 M: 1 block × 16 data codewords, 10 ec per block
|
||||
// g2 组 count=0 已被过滤,仅保留 g1
|
||||
assert_eq!(info.total_codewords, 26);
|
||||
assert_eq!(info.ec_per_block, 10);
|
||||
assert_eq!(info.blocks.len(), 2);
|
||||
assert_eq!(info.blocks.len(), 1);
|
||||
assert_eq!(info.blocks[0].count, 1);
|
||||
assert_eq!(info.blocks[0].data_codewords, 16);
|
||||
}
|
||||
|
||||
@@ -44,18 +44,45 @@ fn test_dump_format_info() {
|
||||
|
||||
// 格式信息位置 (按标准顺序 bit14→bit0)
|
||||
let coords = [
|
||||
(0, 8), (1, 8), (2, 8), (3, 8), (4, 8), (5, 8), (7, 8), (8, 8),
|
||||
(8, 7), (8, 5), (8, 4), (8, 3), (8, 2), (8, 1), (8, 0),
|
||||
(0, 8),
|
||||
(1, 8),
|
||||
(2, 8),
|
||||
(3, 8),
|
||||
(4, 8),
|
||||
(5, 8),
|
||||
(7, 8),
|
||||
(8, 8),
|
||||
(8, 7),
|
||||
(8, 5),
|
||||
(8, 4),
|
||||
(8, 3),
|
||||
(8, 2),
|
||||
(8, 1),
|
||||
(8, 0),
|
||||
];
|
||||
|
||||
let mut fmt_bits = 0u16;
|
||||
for (i, &(x, y)) in coords.iter().enumerate() {
|
||||
let bit = if m[y as usize][x as usize] { 1u16 } else { 0u16 };
|
||||
let bit = if m[y as usize][x as usize] {
|
||||
1u16
|
||||
} else {
|
||||
0u16
|
||||
};
|
||||
fmt_bits = (fmt_bits << 1) | bit;
|
||||
print!("{} ", if m[y as usize][x as usize] { '█' } else { '_' });
|
||||
print!(
|
||||
"{} ",
|
||||
if m[y as usize][x as usize] {
|
||||
'█'
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!("读取的格式信息 (原始, 含 XOR mask 0x5412): 0x{:04X}", fmt_bits);
|
||||
println!(
|
||||
"读取的格式信息 (原始, 含 XOR mask 0x5412): 0x{:04X}",
|
||||
fmt_bits
|
||||
);
|
||||
|
||||
// 去掉 XOR mask
|
||||
let unmasked = fmt_bits ^ 0x5412;
|
||||
@@ -63,7 +90,10 @@ fn test_dump_format_info() {
|
||||
let ec_bits = (unmasked >> 13) & 0x03;
|
||||
let mask_bits = (unmasked >> 10) & 0x07;
|
||||
let bch = unmasked & 0x3FF;
|
||||
println!("EC bits: {:02b} 掩码 bits: {:03b} BCH: 0x{:03X}", ec_bits, mask_bits, bch);
|
||||
println!(
|
||||
"EC bits: {:02b} 掩码 bits: {:03b} BCH: 0x{:03X}",
|
||||
ec_bits, mask_bits, bch
|
||||
);
|
||||
|
||||
// 期望值
|
||||
let expected = {
|
||||
@@ -71,7 +101,14 @@ fn test_dump_format_info() {
|
||||
encode_format_info(qr.level.indicator_bits(), qr.mask)
|
||||
};
|
||||
println!("期望的格式信息: 0x{:04X}", expected);
|
||||
println!("匹配: {}", if fmt_bits == expected { "✅" } else { "❌ 不匹配!" });
|
||||
println!(
|
||||
"匹配: {}",
|
||||
if fmt_bits == expected {
|
||||
"✅"
|
||||
} else {
|
||||
"❌ 不匹配!"
|
||||
}
|
||||
);
|
||||
assert_eq!(fmt_bits, expected, "格式信息不匹配!");
|
||||
}
|
||||
#[test]
|
||||
@@ -84,11 +121,30 @@ fn test_finder_patterns_present() {
|
||||
let s = size as u8;
|
||||
let finders: [(u8, u8); 3] = [(0, 0), (s - 7, 0), (0, s - 7)];
|
||||
for (fx, fy) in finders {
|
||||
assert!(m[fy as usize][fx as usize], "定位({},{}): 左上角应为暗", fx, fy);
|
||||
assert!(m[fy as usize][(fx + 6) as usize], "定位({},{}): 右上角应为暗", fx, fy);
|
||||
assert!(m[(fy + 6) as usize][fx as usize], "定位({},{}): 左下角应为暗", fx, fy);
|
||||
assert!(
|
||||
m[fy as usize][fx as usize],
|
||||
"定位({},{}): 左上角应为暗",
|
||||
fx, fy
|
||||
);
|
||||
assert!(
|
||||
m[fy as usize][(fx + 6) as usize],
|
||||
"定位({},{}): 右上角应为暗",
|
||||
fx,
|
||||
fy
|
||||
);
|
||||
assert!(
|
||||
m[(fy + 6) as usize][fx as usize],
|
||||
"定位({},{}): 左下角应为暗",
|
||||
fx,
|
||||
fy
|
||||
);
|
||||
// 内部 3×3 是暗色
|
||||
assert!(m[(fy + 2) as usize][(fx + 2) as usize], "定位({},{}): 中心3×3应为暗", fx, fy);
|
||||
assert!(
|
||||
m[(fy + 2) as usize][(fx + 2) as usize],
|
||||
"定位({},{}): 中心3×3应为暗",
|
||||
fx,
|
||||
fy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,8 +184,10 @@ fn test_format_info_written() {
|
||||
let qr = QrCode::encode("HELLO", QrConfig::default()).unwrap();
|
||||
let m = qr.modules();
|
||||
// 格式信息在定位图案旁,检查几个位置不是全亮
|
||||
assert!(m[8][0] || m[8][1] || m[8][2] || !m[8][0],
|
||||
"格式信息应已写入");
|
||||
assert!(
|
||||
m[8][0] || m[8][1] || m[8][2] || !m[8][0],
|
||||
"格式信息应已写入"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -140,8 +198,10 @@ fn test_svg_valid_structure() {
|
||||
assert!(svg.starts_with("<svg"), "SVG 应以 <svg 开头");
|
||||
assert!(svg.contains("rect"), "SVG 应包含 rect 元素");
|
||||
assert!(svg.contains("fill=\"black\""), "SVG 暗模块应是黑色");
|
||||
assert!(svg.ends_with("</svg>\n") || svg.ends_with("</svg>"),
|
||||
"SVG 应以 </svg> 结尾");
|
||||
assert!(
|
||||
svg.ends_with("</svg>\n") || svg.ends_with("</svg>"),
|
||||
"SVG 应以 </svg> 结尾"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -163,7 +223,10 @@ fn test_qr_structure_dump() {
|
||||
let size = qr.size() as usize;
|
||||
let m = qr.modules();
|
||||
|
||||
println!("\n=== QR Matrix {}x{} v{} mask{} ===", size, size, qr.version.0, qr.mask);
|
||||
println!(
|
||||
"\n=== QR Matrix {}x{} v{} mask{} ===",
|
||||
size, size, qr.version.0, qr.mask
|
||||
);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
print!("{}", if m[y][x] { "##" } else { " " });
|
||||
@@ -174,9 +237,17 @@ fn test_qr_structure_dump() {
|
||||
// 统计
|
||||
let dark: usize = m.iter().flatten().filter(|&&x| x).count();
|
||||
let total = size * size;
|
||||
println!("\n暗/总: {}/{} = {:.1}%", dark, total, dark as f64 / total as f64 * 100.0);
|
||||
println!(
|
||||
"\n暗/总: {}/{} = {:.1}%",
|
||||
dark,
|
||||
total,
|
||||
dark as f64 / total as f64 * 100.0
|
||||
);
|
||||
println!("尺寸: {}×{}", size, size);
|
||||
println!("版本: {} 掩码: {} 纠错: {:?}", qr.version.0, qr.mask, qr.level);
|
||||
println!(
|
||||
"版本: {} 掩码: {} 纠错: {:?}",
|
||||
qr.version.0, qr.mask, qr.level
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "QRGen 默认权限 — 限制前端 IPC 和平台 API 访问",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"store:default",
|
||||
"dialog:default",
|
||||
"clipboard-manager:default"
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,12 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
static getDerivedStateFromError(error: Error) { return { hasError: true, error }; }
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
// 生产环境错误日志记录入口
|
||||
// TODO: 集成遥测服务后将错误上报
|
||||
console.error('QRGen ErrorBoundary 捕获错误:', error.message, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
|
||||
@@ -4,27 +4,18 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
|
||||
function getCurrentText(state: any): string {
|
||||
switch (state.mode) {
|
||||
case 'url': return state.formData.url || '';
|
||||
case 'wifi': return `WIFI:T:${state.formData.encryption || 'WPA'};S:${state.formData.ssid || ''};P:${state.formData.password || ''};;`;
|
||||
case 'vcard': return `BEGIN:VCARD\nVERSION:3.0\nFN:${state.formData.name || ''}\nTEL:${state.formData.phone || ''}\nEMAIL:${state.formData.email || ''}\nORG:${state.formData.company || ''}\nADR:${state.formData.address || ''}\nEND:VCARD`;
|
||||
case 'email': return `mailto:${state.formData.to || ''}?subject=${encodeURIComponent(state.formData.subject || '')}&body=${encodeURIComponent(state.formData.body || '')}`;
|
||||
case 'phone': return `tel:${state.formData.number || ''}`;
|
||||
case 'sms': return `smsto:${state.formData.number || ''}:${state.formData.message || ''}`;
|
||||
default: return state.formData.text || '';
|
||||
}
|
||||
}
|
||||
import type { QrConfig } from '../types';
|
||||
import { buildEncodedText } from '../utils/qrText';
|
||||
|
||||
export default function ExportPanel() {
|
||||
const { state, dispatch } = useQrState();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleCopySvg = async () => {
|
||||
if (state.preview?.svg) {
|
||||
if (!state.preview?.svg) return;
|
||||
try {
|
||||
await writeText(state.preview.svg);
|
||||
}
|
||||
} catch { /* 剪贴板不可用时静默忽略 */ }
|
||||
};
|
||||
|
||||
const handleExportPng = async () => {
|
||||
@@ -38,15 +29,13 @@ export default function ExportPanel() {
|
||||
if (!filePath) { setExporting(false); return; }
|
||||
|
||||
const bytes: number[] = await invoke('export_png', {
|
||||
text: getCurrentText(state),
|
||||
text: buildEncodedText(state.mode, state.formData),
|
||||
level: state.config.level,
|
||||
margin: state.config.margin,
|
||||
moduleSize: state.config.moduleSize,
|
||||
});
|
||||
await writeFile(filePath, new Uint8Array(bytes));
|
||||
} catch (e) {
|
||||
console.warn('导出 PNG 失败:', e);
|
||||
}
|
||||
} catch { /* 导出失败时静默处理,UI 回到就绪状态 */ }
|
||||
setExporting(false);
|
||||
};
|
||||
|
||||
@@ -59,9 +48,7 @@ export default function ExportPanel() {
|
||||
});
|
||||
if (!filePath) return;
|
||||
await writeFile(filePath, new TextEncoder().encode(state.preview.svg));
|
||||
} catch (e) {
|
||||
console.warn('导出 SVG 失败:', e);
|
||||
}
|
||||
} catch { /* 导出失败时静默处理 */ }
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -72,7 +59,7 @@ export default function ExportPanel() {
|
||||
纠错级别
|
||||
<select
|
||||
value={state.config.level}
|
||||
onChange={e => dispatch({ type: 'SET_CONFIG', payload: { level: e.target.value as any } })}
|
||||
onChange={e => dispatch({ type: 'SET_CONFIG', payload: { level: e.target.value as QrConfig['level'] } })}
|
||||
className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
>
|
||||
<option value="L">L — 7%</option>
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { MODE_LABELS, type HistoryEntry } from '../types';
|
||||
import { persistHistory } from '../hooks/useQrEncode';
|
||||
|
||||
export default function HistoryList() {
|
||||
const { state, dispatch } = useQrState();
|
||||
|
||||
const handleClick = (entry: HistoryEntry) => {
|
||||
dispatch({ type: 'SET_MODE', payload: entry.mode as any });
|
||||
try {
|
||||
const formData = JSON.parse(entry.content);
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: formData });
|
||||
} catch {
|
||||
dispatch({ type: 'SET_MODE', payload: entry.mode });
|
||||
// 优先使用存储的 formData 恢复表单,否则回退到纯文本
|
||||
if (entry.formData) {
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: entry.formData });
|
||||
} else {
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: { text: entry.content } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
dispatch({ type: 'REMOVE_HISTORY', payload: id });
|
||||
const updated = state.history.filter(h => h.id !== id);
|
||||
dispatch({ type: 'SET_HISTORY', payload: updated });
|
||||
persistHistory(updated);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
dispatch({ type: 'SET_HISTORY', payload: [] });
|
||||
persistHistory([]);
|
||||
};
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
@@ -53,7 +57,7 @@ export default function HistoryList() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="px-1 py-0.5 rounded text-[10px] font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
{MODE_LABELS[entry.mode as keyof typeof MODE_LABELS] || entry.mode}
|
||||
{MODE_LABELS[entry.mode] || entry.mode}
|
||||
</span>
|
||||
<span className="text-gray-400">{formatTime(entry.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
|
||||
/** 将 SVG 字符串转为安全的 data URL(<img> 标签中浏览器会阻止 SVG 内的脚本执行) */
|
||||
function svgToDataUrl(svg: string): string {
|
||||
const encoded = btoa(unescape(encodeURIComponent(svg)));
|
||||
return `data:image/svg+xml;base64,${encoded}`;
|
||||
}
|
||||
|
||||
export default function QrPreview() {
|
||||
const { state } = useQrState();
|
||||
|
||||
if (!state.preview?.svg) {
|
||||
const svgDataUrl = useMemo(
|
||||
() => (state.preview?.svg ? svgToDataUrl(state.preview.svg) : null),
|
||||
[state.preview?.svg],
|
||||
);
|
||||
|
||||
if (!svgDataUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-gray-400">
|
||||
<div className="w-64 h-64 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl flex items-center justify-center bg-white/50 dark:bg-gray-800/50">
|
||||
{state.loading ? (
|
||||
<span className="text-sm animate-pulse">生成中...</span>
|
||||
) : (
|
||||
<span className="text-sm">输入内容生成 QR 码</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -15,15 +31,16 @@ 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 }}
|
||||
{/* SVG 转为 data URL 通过 <img> 渲染,浏览器在 img 上下文中阻止脚本执行 */}
|
||||
<img
|
||||
src={svgDataUrl}
|
||||
alt="QR 码"
|
||||
className="w-64 h-64 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl p-4 bg-white dark:bg-white qr-preview"
|
||||
/>
|
||||
<div className="flex gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>版本: {state.preview.version}</span>
|
||||
<span>{state.preview.size}×{state.preview.size}</span>
|
||||
<span>掩码: {state.preview.mask}</span>
|
||||
<span>版本: {state.preview!.version}</span>
|
||||
<span>{state.preview!.size}×{state.preview!.size}</span>
|
||||
<span>掩码: {state.preview!.mask}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,19 @@ 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';
|
||||
import type { HistoryEntry, ModeType } from '../types';
|
||||
|
||||
const HISTORY_KEY = 'qr-history';
|
||||
const STORE_FILE = 'history.json';
|
||||
|
||||
/** 缓存的 Store 实例,避免每次编码都重新加载 */
|
||||
let storeCache: Promise<Store> | null = null;
|
||||
function getStore(): Promise<Store> {
|
||||
if (!storeCache) {
|
||||
storeCache = Store.load(STORE_FILE);
|
||||
}
|
||||
return storeCache;
|
||||
}
|
||||
|
||||
interface QrResponse {
|
||||
svg: string;
|
||||
@@ -13,6 +23,36 @@ interface QrResponse {
|
||||
mask: number;
|
||||
}
|
||||
|
||||
/** 对 WiFi 密码进行脱敏处理 */
|
||||
function sanitizeContent(mode: ModeType, content: string): string {
|
||||
if (mode === 'wifi') {
|
||||
return content.replace(/P:[^;]*/, 'P:***');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化整个历史列表到 store
|
||||
* 作为内存状态的唯一持久化出口
|
||||
*/
|
||||
export async function persistHistory(history: HistoryEntry[]): Promise<void> {
|
||||
try {
|
||||
const store = await getStore();
|
||||
await store.set(HISTORY_KEY, history);
|
||||
await store.save();
|
||||
} catch { /* store 不可用时静默忽略 */ }
|
||||
}
|
||||
|
||||
/** 从 store 加载历史记录(应用启动时调用) */
|
||||
export async function loadHistory(): Promise<HistoryEntry[]> {
|
||||
try {
|
||||
const store = await getStore();
|
||||
return (await store.get<HistoryEntry[]>(HISTORY_KEY)) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function useQrEncode() {
|
||||
const { state, dispatch } = useQrState();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -46,28 +86,26 @@ export function useQrEncode() {
|
||||
|
||||
// 保存到历史(内存 + 持久化)
|
||||
const entryId = Date.now().toString();
|
||||
const currentMode = modeRef.current;
|
||||
const entry: HistoryEntry = {
|
||||
id: entryId,
|
||||
mode: modeRef.current,
|
||||
content: text,
|
||||
mode: currentMode,
|
||||
content: sanitizeContent(currentMode, text),
|
||||
timestamp: Date.now(),
|
||||
formData: { ...state.formData },
|
||||
};
|
||||
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) {
|
||||
// 编码失败已在 dispatch SET_PREVIEW(null) 中处理,无需额外日志
|
||||
// 从内存状态持久化(避免 store 读写竞态)
|
||||
// 注意: dispatch ADD_HISTORY 是异步的,这里手动计算最新列表
|
||||
// 确保持久化的数据与内存一致
|
||||
persistHistory([entry, ...state.history].slice(0, 50));
|
||||
} catch {
|
||||
// 编码失败时清空预览
|
||||
dispatch({ type: 'SET_PREVIEW', payload: null });
|
||||
}
|
||||
}, 200);
|
||||
}, [state.config.level, state.config.margin, dispatch]);
|
||||
}, [state.config.level, state.config.margin, state.formData, state.history, dispatch]);
|
||||
|
||||
return { encode };
|
||||
return { encode, persistHistory };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildEmailText } from '../utils/qrText';
|
||||
|
||||
export default function EmailMode() {
|
||||
const { state, dispatch } = useQrState();
|
||||
@@ -8,8 +9,7 @@ export default function EmailMode() {
|
||||
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);
|
||||
encode(buildEmailText(data));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildPhoneText } from '../utils/qrText';
|
||||
|
||||
export default function PhoneMode() {
|
||||
const { state, dispatch } = useQrState();
|
||||
@@ -7,7 +8,7 @@ export default function PhoneMode() {
|
||||
|
||||
const update = (number: string) => {
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: { number } });
|
||||
encode(`tel:${number}`);
|
||||
encode(buildPhoneText({ number }));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildSmsText } from '../utils/qrText';
|
||||
|
||||
export default function SmsMode() {
|
||||
const { state, dispatch } = useQrState();
|
||||
@@ -8,7 +9,7 @@ export default function SmsMode() {
|
||||
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 || ''}`);
|
||||
encode(buildSmsText(data));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildVCardText } from '../utils/qrText';
|
||||
|
||||
const FIELDS = [
|
||||
{ key: 'name', placeholder: '姓名' },
|
||||
@@ -16,8 +17,7 @@ export default function VCardMode() {
|
||||
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);
|
||||
encode(buildVCardText(data));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildWifiText } from '../utils/qrText';
|
||||
|
||||
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) };
|
||||
/** checkbox 的 boolean 值统一转为 'true'/'false' 字符串存入 formData */
|
||||
const update = (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
const wifiText = buildWifiText(
|
||||
data.ssid || '', data.password || '', data.encryption || 'WPA', data.hidden === 'true'
|
||||
);
|
||||
encode(wifiText);
|
||||
encode(buildWifiText(data));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -36,7 +30,7 @@ export default function WifiMode() {
|
||||
</select>
|
||||
<label className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<input type="checkbox" checked={state.formData.hidden === 'true'}
|
||||
onChange={e => update('hidden', e.target.checked)} />
|
||||
onChange={e => update('hidden', e.target.checked ? 'true' : 'false')} />
|
||||
隐藏
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useReducer, useEffect, type ReactNode } from 'react';
|
||||
import { Store } from '@tauri-apps/plugin-store';
|
||||
import type { QrState, QrAction, HistoryEntry } from '../types';
|
||||
import { loadHistory } from '../hooks/useQrEncode';
|
||||
import type { QrState, QrAction } from '../types';
|
||||
|
||||
const initialState: QrState = {
|
||||
mode: 'text',
|
||||
@@ -47,11 +47,8 @@ export function QrProvider({ children }: { children: ReactNode }) {
|
||||
// 启动时从 store 加载持久化的历史记录
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const store = await Store.load('history.json');
|
||||
const history = await store.get<HistoryEntry[]>('qr-history') || [];
|
||||
const history = await loadHistory();
|
||||
dispatch({ type: 'SET_HISTORY', payload: history });
|
||||
} catch { /* store 不可用时忽略 */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ export interface QrPreview {
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
mode: string;
|
||||
mode: ModeType;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
/** 原始表单数据,用于恢复历史记录时回填各模式字段 */
|
||||
formData?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface QrState {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* QR 编码文本构造工具
|
||||
* 集中管理各模式的文本格式,避免 ExportPanel 和各 mode 组件间的重复逻辑
|
||||
*/
|
||||
|
||||
/** 构造 WiFi 连接字符串 */
|
||||
export function buildWifiText(formData: Record<string, string>): string {
|
||||
const ssid = formData.ssid || '';
|
||||
if (!ssid) return '';
|
||||
const encryption = formData.encryption || 'WPA';
|
||||
const password = formData.password || '';
|
||||
// hidden 存储为字符串 'true'/'false',保留 boolean 语义
|
||||
const hidden = formData.hidden === 'true' ? 'H:true;' : '';
|
||||
return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden};`;
|
||||
}
|
||||
|
||||
/** 构造 vCard 字符串 */
|
||||
export function buildVCardText(formData: Record<string, string>): string {
|
||||
const name = formData.name || '';
|
||||
const phone = formData.phone || '';
|
||||
const email = formData.email || '';
|
||||
const company = formData.company || '';
|
||||
const address = formData.address || '';
|
||||
return `BEGIN:VCARD\nVERSION:3.0\nFN:${name}\nTEL:${phone}\nEMAIL:${email}\nORG:${company}\nADR:${address}\nEND:VCARD`;
|
||||
}
|
||||
|
||||
/** 构造 mailto 链接 */
|
||||
export function buildEmailText(formData: Record<string, string>): string {
|
||||
const to = formData.to || '';
|
||||
const subject = encodeURIComponent(formData.subject || '');
|
||||
const body = encodeURIComponent(formData.body || '');
|
||||
return `mailto:${to}?subject=${subject}&body=${body}`;
|
||||
}
|
||||
|
||||
/** 构造电话链接 */
|
||||
export function buildPhoneText(formData: Record<string, string>): string {
|
||||
return `tel:${formData.number || ''}`;
|
||||
}
|
||||
|
||||
/** 构造短信链接 */
|
||||
export function buildSmsText(formData: Record<string, string>): string {
|
||||
return `smsto:${formData.number || ''}:${formData.message || ''}`;
|
||||
}
|
||||
|
||||
/** 从完整 formData 构造当前模式的编码文本(供 ExportPanel 使用) */
|
||||
export function buildEncodedText(mode: string, formData: Record<string, string>): string {
|
||||
switch (mode) {
|
||||
case 'url': return formData.url || '';
|
||||
case 'wifi': return buildWifiText(formData);
|
||||
case 'vcard': return buildVCardText(formData);
|
||||
case 'email': return buildEmailText(formData);
|
||||
case 'phone': return buildPhoneText(formData);
|
||||
case 'sms': return buildSmsText(formData);
|
||||
default: return formData.text || '';
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -82,7 +82,8 @@ fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Resul
|
||||
|
||||
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
|
||||
|
||||
qr.to_png_bytes(module_size).map_err(|e| format!("PNG 导出失败: {}", e))
|
||||
qr.to_png_bytes(module_size)
|
||||
.map_err(|e| format!("PNG 导出失败: {}", e))
|
||||
}
|
||||
|
||||
/// 保存历史记录条目
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:"
|
||||
}
|
||||
},
|
||||
"plugins": {},
|
||||
|
||||
Reference in New Issue
Block a user