refactor: P0-P5 全面架构重构

P1 thiserror 类型化错误:
新增 core/src/error.rs QrError 枚举, 全链 String -> QrError,
包括 EmptyInput/InvalidVersion/DataTooLong/DecodeFail 等 8 种变体

P2 text_builder Tauri 统一:
新增 build_qr_text Tauri command, 删除前端 qrText.ts,
所有 mode 组件改为 invoke 调用 Rust 端构建文本

P3 QrConfig 颜色字段移除:
从 QrConfig/QrCode 移除 fg_color/bg_color,
改为 to_svg/to_image_bytes 参数传递

P4 前端 4 项合并:
Context 拆分为 StateContext+DispatchContext (H10),
新建 useModeForm 通用 hook (M11),
VCardMode grid-cols-2 网格布局 (M13),
persistHistory/loadHistory 迁至 utils/storage.ts (L9)

P5 算法优化:
MaskedView 懒计算替代 8 次 Matrix 克隆 (H9),
encoding_rs 精确 Kanji Shift JIS 映射 (H12)

验证: cargo check+clippy 通过, 81+24+7 全部测试通过
This commit is contained in:
2026-06-21 15:09:10 +08:00
parent 8298cd4c9c
commit cd75141037
46 changed files with 1283 additions and 1028 deletions
+281 -105
View File
@@ -1,3 +1,4 @@
use anyhow::{bail, Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use indicatif::{ProgressBar, ProgressStyle};
@@ -49,19 +50,25 @@ enum Command {
#[derive(clap::Args, Clone)]
struct EncodeOpts {
#[arg(short = 'l', long, default_value = "M")] level: String,
#[arg(short = 'V', long, value_parser = clap::value_parser!(u8).range(1..=40))] version: Option<u8>,
#[arg(short = 's', long, default_value = "4")] size: u8,
#[arg(short = 'm', long, default_value = "4")] margin: u8,
#[arg(short = 'l', long, default_value = "M")]
level: String,
#[arg(short = 'V', long, value_parser = clap::value_parser!(u8).range(1..=40))]
version: Option<u8>,
#[arg(short = 's', long, default_value = "4", value_parser = clap::value_parser!(u8).range(1..=20))]
size: u8,
#[arg(short = 'm', long, default_value = "4")]
margin: u8,
#[arg(long)] fg: Option<String>,
#[arg(long)] bg: Option<String>,
#[arg(long)] logo: Option<String>,
#[arg(short = 'f', long, default_value = "png")] format: String,
#[arg(short = 'f', long, default_value = "png")]
format: String,
#[arg(long)] mode: Option<String>,
// WiFi
#[arg(long)] ssid: Option<String>,
#[arg(long)] password: Option<String>,
#[arg(long, default_value = "WPA")] encryption: String,
#[arg(long, default_value = "WPA")]
encryption: String,
#[arg(long)] hidden: bool,
// vCard
#[arg(long)] name: Option<String>,
@@ -90,31 +97,24 @@ struct EncodeOpts {
#[derive(Deserialize)]
struct BatchEntry {
content: Option<String>, level: Option<String>,
ssid: Option<String>, password: Option<String>, encryption: Option<String>,
#[serde(default)] hidden: Option<bool>,
name: Option<String>, phone: Option<String>, email: Option<String>,
company: Option<String>, address: Option<String>,
to: Option<String>, subject: Option<String>, body: Option<String>,
number: Option<String>, message: Option<String>, url: Option<String>,
}
// ──────────────────── 错误类型 ────────────────────
struct E { msg: String, code: i32 }
impl E {
fn new(m: impl Into<String>, c: i32) -> Self { Self { msg: m.into(), code: c } }
fn exit(&self) -> ! { eprintln!("qrgen: {}", self.msg); process::exit(self.code); }
}
impl From<std::io::Error> for E {
fn from(e: std::io::Error) -> Self { E::new(format!("IO 错误: {e}"), 2) }
}
impl From<image::ImageError> for E {
fn from(e: image::ImageError) -> Self { E::new(format!("图像错误: {e}"), 2) }
}
macro_rules! bail {
($msg:expr, $code:expr) => { return Err(E::new($msg, $code)) };
($fmt:expr, $code:expr, $($arg:tt)*) => { return Err(E::new(format!($fmt, $($arg)*), $code)) };
content: Option<String>,
level: Option<String>,
ssid: Option<String>,
password: Option<String>,
encryption: Option<String>,
#[serde(default)]
hidden: Option<bool>,
name: Option<String>,
phone: Option<String>,
email: Option<String>,
company: Option<String>,
address: Option<String>,
to: Option<String>,
subject: Option<String>,
body: Option<String>,
number: Option<String>,
message: Option<String>,
url: Option<String>,
}
// ──────────────────── 入口 ────────────────────
@@ -131,124 +131,238 @@ fn main() {
Command::Encode { content, output, opts } => cmd_encode(&content, &output, &opts),
Command::Decode { file } => cmd_decode(&file),
};
if let Err(e) = r { e.exit(); }
if let Err(e) = r {
eprintln!("qrgen: {:#}", e);
process::exit(1);
}
}
// ──────────────────── I/O 辅助 ────────────────────
fn stdin_bytes() -> Result<Vec<u8>, E> {
/// 最大 stdin 读取量:10 MB
const STDIN_MAX: u64 = 10 * 1024 * 1024;
fn stdin_bytes() -> Result<Vec<u8>> {
let mut b = Vec::new();
io::stdin().read_to_end(&mut b).map_err(|e| E::new(format!("无法读取 stdin: {e}"), 2))?;
io::stdin()
.take(STDIN_MAX)
.read_to_end(&mut b)
.with_context(|| "无法读取 stdin")?;
Ok(b)
}
fn stdin_text() -> Result<String, E> {
fn stdin_text() -> Result<String> {
let mut s = String::new();
io::stdin().read_to_string(&mut s).map_err(|e| E::new(format!("无法读取 stdin: {e}"), 2))?;
io::stdin()
.take(STDIN_MAX)
.read_to_string(&mut s)
.with_context(|| "无法读取 stdin")?;
let t = s.trim().to_string();
if t.is_empty() { bail!("stdin 为空", 2); }
if t.is_empty() {
bail!("stdin 为空");
}
Ok(t)
}
// ──────────────────── 编码 ────────────────────
fn cmd_encode(content: &str, output: &Option<String>, opts: &EncodeOpts) -> Result<(), E> {
let text = if content == "-" { stdin_text()? } else { content.to_string() };
fn cmd_encode(content: &str, output: &Option<String>, opts: &EncodeOpts) -> Result<()> {
let text = if content == "-" {
stdin_text()?
} else {
content.to_string()
};
let final_text = if let Some(m) = &opts.mode {
build_mode(m, opts, &text)?
} else if let Some(ref bf) = opts.batch {
return do_batch(bf, opts);
} else { text };
} else {
text
};
let level = parse_level(&opts.level)?;
let logo = opts.logo.as_ref().map(std::fs::read).transpose()
.map_err(|e| E::new(format!("无法读取 logo: {e}"), 2))?;
let level: EcLevel = opts.level.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let logo = opts
.logo
.as_ref()
.map(std::fs::read)
.transpose()
.with_context(|| "无法读取 logo")?;
let config = QrConfig {
level, version: opts.version.map(VersionMode::Fixed).unwrap_or(VersionMode::Auto),
margin: opts.margin, fg_color: opts.fg.clone(), bg_color: opts.bg.clone(),
level,
version: opts
.version
.map(VersionMode::Fixed)
.unwrap_or(VersionMode::Auto),
margin: opts.margin,
};
let qr = QrCode::encode(&final_text, config).map_err(|e| E::new(format!("编码失败: {e}"), 1))?;
let qr = QrCode::encode(&final_text, config).map_err(|e| anyhow::anyhow!("编码失败: {e}"))?;
let fg = opts.fg.as_deref();
let bg = opts.bg.as_deref();
match output {
Some(p) => {
check_path(p)?;
let ext = Path::new(p).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
let ext = Path::new(p)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if ext.is_empty() {
eprintln!("警告: 无法从扩展名推断输出格式,回退到 PNG");
}
match ext.as_str() {
"svg" => { std::fs::write(p, qr.to_svg(logo.as_deref()))?; eprintln!("已生成: {p} (版本 {}, SVG)", qr.version.0); }
"svg" => {
std::fs::write(p, qr.to_svg(logo.as_deref(), fg, bg))?;
eprintln!("已生成: {p} (版本 {}, SVG)", qr.version.0);
}
_ => {
let fmt = qr_core::render::image::OutputFormat::from_ext(&ext)
.or_else(|| qr_core::render::image::OutputFormat::from_ext(&opts.format))
.unwrap_or(qr_core::render::image::OutputFormat::Png);
std::fs::write(p, qr.to_image_bytes(opts.size, logo.as_deref(), Some(fmt))?)?;
eprintln!("已生成: {p} (版本 {}, {}×{}, {:?}, {})", qr.version.0, qr.size(), qr.size(), qr.level, fmt.extension());
.unwrap_or_else(|| {
eprintln!("警告: 无法识别扩展名 '{}',回退到 PNG", ext);
qr_core::render::image::OutputFormat::Png
});
std::fs::write(
p,
qr.to_image_bytes(opts.size, logo.as_deref(), Some(fmt), fg, bg)?,
)?;
eprintln!(
"已生成: {p} (版本 {}, {}×{}, {:?}, {})",
qr.version.0,
qr.size(),
qr.size(),
qr.level,
fmt.extension()
);
}
}
}
None => { println!("{}", qr.to_ascii(false)); }
None => {
println!("{}", qr.to_ascii(false));
}
}
Ok(())
}
fn build_mode(mode: &str, opts: &EncodeOpts, fb: &str) -> Result<String, E> {
fn build_mode(mode: &str, opts: &EncodeOpts, fb: &str) -> Result<String> {
match mode {
"wifi" => {
let s = opts.ssid.as_deref().ok_or_else(|| E::new("WiFi 模式需要 --ssid", 1))?;
Ok(text_builder::build_wifi_text(s, opts.password.as_deref().unwrap_or(""), &opts.encryption, opts.hidden))
let s = opts
.ssid
.as_deref()
.ok_or_else(|| anyhow::anyhow!("WiFi 模式需要 --ssid"))?;
Ok(text_builder::build_wifi_text(
s,
opts.password.as_deref().unwrap_or(""),
&opts.encryption,
opts.hidden,
))
}
"vcard" => Ok(text_builder::build_vcard_text(
opts.name.as_deref().unwrap_or(""), opts.phone.as_deref().unwrap_or(""),
opts.email.as_deref().unwrap_or(""), opts.company.as_deref().unwrap_or(""),
opts.address.as_deref().unwrap_or(""), opts.title.as_deref().unwrap_or(""),
opts.vcard_url.as_deref().unwrap_or(""), opts.birthday.as_deref().unwrap_or(""),
opts.note.as_deref().unwrap_or(""), opts.photo.as_deref().unwrap_or(""),
opts.name.as_deref().unwrap_or(""),
opts.phone.as_deref().unwrap_or(""),
opts.email.as_deref().unwrap_or(""),
opts.company.as_deref().unwrap_or(""),
opts.address.as_deref().unwrap_or(""),
opts.title.as_deref().unwrap_or(""),
opts.vcard_url.as_deref().unwrap_or(""),
opts.birthday.as_deref().unwrap_or(""),
opts.note.as_deref().unwrap_or(""),
opts.photo.as_deref().unwrap_or(""),
)),
"email" => {
let t = opts.to.as_deref().ok_or_else(|| E::new("Email 模式需要 --to", 1))?;
Ok(text_builder::build_email_text(t, opts.subject.as_deref().unwrap_or(""), opts.body.as_deref().unwrap_or("")))
let t = opts
.to
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Email 模式需要 --to"))?;
Ok(text_builder::build_email_text(
t,
opts.subject.as_deref().unwrap_or(""),
opts.body.as_deref().unwrap_or(""),
))
}
"phone" => Ok(text_builder::build_phone_text(opts.number.as_deref().ok_or_else(|| E::new("需要 --number", 1))?)),
"phone" => Ok(text_builder::build_phone_text(
opts.number
.as_deref()
.ok_or_else(|| anyhow::anyhow!("需要 --number"))?,
)),
"sms" => Ok(text_builder::build_sms_text(
opts.number.as_deref().ok_or_else(|| E::new("需要 --number", 1))?,
opts.number
.as_deref()
.ok_or_else(|| anyhow::anyhow!("需要 --number"))?,
opts.message.as_deref().unwrap_or(""),
)),
"url" => opts.url.clone().ok_or_else(|| E::new("URL 模式需要 --url", 1)),
"url" => opts
.url
.clone()
.ok_or_else(|| anyhow::anyhow!("URL 模式需要 --url")),
"text" => Ok(fb.to_string()),
_m => bail!("未知模式: {_m},支持 text/url/wifi/vcard/email/phone/sms/batch", 1),
_m => bail!("未知模式: {_m},支持 text/url/wifi/vcard/email/phone/sms/batch"),
}
}
// ──────────────────── 解码 ────────────────────
fn cmd_decode(file: &str) -> Result<(), E> {
let bytes = if file == "-" { stdin_bytes()? } else { std::fs::read(file)? };
let r = qr_core::decoder::decode_image(&bytes).map_err(|e| E::new(format!("解码失败: {e}"), 1))?;
fn cmd_decode(file: &str) -> Result<()> {
if file != "-" {
check_path(file)?;
}
let bytes = if file == "-" {
stdin_bytes()?
} else {
std::fs::read(file)?
};
let r = qr_core::decoder::decode_image(&bytes)
.map_err(|e| anyhow::anyhow!("解码失败: {e}"))?;
println!("{}", r.text);
eprintln!("版本: {} 级别: {:?} 掩码: {} 纠错: {} 码字", r.version, r.level, r.mask, r.errors_corrected);
eprintln!(
"版本: {} 级别: {:?} 掩码: {} 纠错: {} 码字",
r.version, r.level, r.mask, r.errors_corrected
);
Ok(())
}
// ──────────────────── 批量 ────────────────────
fn do_batch(file: &str, opts: &EncodeOpts) -> Result<(), E> {
fn do_batch(file: &str, opts: &EncodeOpts) -> Result<()> {
let input = std::fs::read_to_string(file)
.map_err(|e| E::new(format!("无法读取批量文件 '{file}': {e}"), 2))?;
.with_context(|| format!("无法读取批量文件 '{file}'"))?;
let entries: Vec<BatchEntry> = serde_json::from_str(&input)
.or_else(|_| parse_csv(&input))
.map_err(|e| E::new(format!("解析失败: {e}"), 2))?;
.map_err(|e| anyhow::anyhow!("解析失败: {e}"))?;
let out = opts.output_dir.as_deref().unwrap_or("batch_output");
std::fs::create_dir_all(out).map_err(|e| E::new(format!("无法创建目录 '{out}': {e}"), 2))?;
std::fs::create_dir_all(out)
.with_context(|| format!("无法创建目录 '{out}'"))?;
let total = entries.len();
let pb = ProgressBar::new(total as u64);
pb.set_style(ProgressStyle::default_bar().template("{spinner:.green} [{bar:30.cyan/blue}] {pos}/{len} {msg}").unwrap());
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:30.cyan/blue}] {pos}/{len} {msg}")
.unwrap(),
);
for (i, e) in entries.iter().enumerate() {
let text = batch_text(e)?;
let lvl = e.level.as_deref().map(parse_level).unwrap_or(Ok(EcLevel::M))?;
let cfg = QrConfig { level: lvl, version: VersionMode::Auto, margin: opts.margin, fg_color: opts.fg.clone(), bg_color: opts.bg.clone() };
let qr = QrCode::encode(&text, cfg).map_err(|e| E::new(e, 1))?;
let lvl: EcLevel = e
.level
.as_deref()
.map(|s| s.parse())
.unwrap_or(Ok(EcLevel::M))
.map_err(|e: String| anyhow::anyhow!(e))?;
let cfg = QrConfig {
level: lvl,
version: VersionMode::Auto,
margin: opts.margin,
};
let qr = QrCode::encode(&text, cfg).map_err(|e| anyhow::anyhow!(e))?;
let path = format!("{out}/qr_{:04}.png", i + 1);
std::fs::write(&path, qr.to_png_bytes(opts.size, None).map_err(|e| E::new(format!("{e}"), 2))?)?;
std::fs::write(
&path,
qr.to_png_bytes(opts.size, None)
.map_err(|e| anyhow::anyhow!("{e}"))?,
)?;
pb.set_message(path.clone());
pb.inc(1);
}
@@ -256,55 +370,117 @@ fn do_batch(file: &str, opts: &EncodeOpts) -> Result<(), E> {
Ok(())
}
fn batch_text(e: &BatchEntry) -> Result<String, E> {
if let Some(c) = &e.content { return Ok(c.clone()); }
if let Some(u) = &e.url { return Ok(u.clone()); }
fn batch_text(e: &BatchEntry) -> Result<String> {
if let Some(c) = &e.content {
return Ok(c.clone());
}
if let Some(u) = &e.url {
return Ok(u.clone());
}
if let Some(s) = &e.ssid {
return Ok(text_builder::build_wifi_text(s, e.password.as_deref().unwrap_or(""), e.encryption.as_deref().unwrap_or("WPA"), e.hidden.unwrap_or(false)));
return Ok(text_builder::build_wifi_text(
s,
e.password.as_deref().unwrap_or(""),
e.encryption.as_deref().unwrap_or("WPA"),
e.hidden.unwrap_or(false),
));
}
if let Some(n) = &e.name {
return Ok(text_builder::build_vcard_text(n, e.phone.as_deref().unwrap_or(""), e.email.as_deref().unwrap_or(""), e.company.as_deref().unwrap_or(""), e.address.as_deref().unwrap_or(""), "", "", "", "", ""));
return Ok(text_builder::build_vcard_text(
n,
e.phone.as_deref().unwrap_or(""),
e.email.as_deref().unwrap_or(""),
e.company.as_deref().unwrap_or(""),
e.address.as_deref().unwrap_or(""),
"",
"",
"",
"",
"",
));
}
if let Some(t) = &e.to {
return Ok(text_builder::build_email_text(t, e.subject.as_deref().unwrap_or(""), e.body.as_deref().unwrap_or("")));
return Ok(text_builder::build_email_text(
t,
e.subject.as_deref().unwrap_or(""),
e.body.as_deref().unwrap_or(""),
));
}
if let Some(n) = &e.number {
if let Some(m) = &e.message { return Ok(text_builder::build_sms_text(n, m)); }
if let Some(m) = &e.message {
return Ok(text_builder::build_sms_text(n, m));
}
return Ok(text_builder::build_phone_text(n));
}
bail!("无法识别的条目格式", 1)
bail!("无法识别的条目格式")
}
// ──────────────────── 工具函数 ────────────────────
fn parse_csv(input: &str) -> Result<Vec<BatchEntry>, String> {
let mut lines = input.lines();
let cols: Vec<&str> = lines.next().ok_or("CSV 为空")?.split(',').map(|s| s.trim()).collect();
let cols: Vec<&str> = lines
.next()
.ok_or("CSV 为空")?
.split(',')
.map(|s| s.trim())
.collect();
let mut out = Vec::new();
for line in lines {
if line.trim().is_empty() { continue; }
let v: Vec<String> = line.split(',').map(|s| s.trim().trim_matches('"').to_string()).collect();
let (mut c,mut l,mut ss,mut pw,mut en,mut hi,mut na,mut ph,mut em,mut co,mut ad,mut to_,mut su,mut bo,mut nu,mut ms,mut ur) =
(None,None,None,None,None,None,None,None,None,None,None,None,None,None,None,None,None);
for (i, col) in cols.iter().enumerate() {
let val = v.get(i).cloned();
match *col { "content"=>c=val,"level"=>l=val,"ssid"=>ss=val,"password"=>pw=val,"encryption"=>en=val,"hidden"=>hi=val.map(|x|x=="true"),"name"=>na=val,"phone"=>ph=val,"email"=>em=val,"company"=>co=val,"address"=>ad=val,"to"=>to_=val,"subject"=>su=val,"body"=>bo=val,"number"=>nu=val,"message"=>ms=val,"url"=>ur=val, _=>{} }
if line.trim().is_empty() {
continue;
}
out.push(BatchEntry{content:c,level:l,ssid:ss,password:pw,encryption:en,hidden:hi,name:na,phone:ph,email:em,company:co,address:ad,to:to_,subject:su,body:bo,number:nu,message:ms,url:ur});
let values: Vec<String> = line
.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.collect();
// 使用 HashMap 替代 17 字段元组解构
let mut map = std::collections::HashMap::new();
for (i, col) in cols.iter().enumerate() {
if let Some(val) = values.get(i).cloned() {
map.insert(*col, val);
}
}
let get = |k: &str| map.get(k).cloned();
let hidden = get("hidden").map(|x| x == "true");
out.push(BatchEntry {
content: get("content"),
level: get("level"),
ssid: get("ssid"),
password: get("password"),
encryption: get("encryption"),
hidden,
name: get("name"),
phone: get("phone"),
email: get("email"),
company: get("company"),
address: get("address"),
to: get("to"),
subject: get("subject"),
body: get("body"),
number: get("number"),
message: get("message"),
url: get("url"),
});
}
Ok(out)
}
fn parse_level(s: &str) -> Result<EcLevel, E> {
match s.to_uppercase().as_str() {
"L"=>Ok(EcLevel::L),"M"=>Ok(EcLevel::M),"Q"=>Ok(EcLevel::Q),"H"=>Ok(EcLevel::H),
_ => bail!("无效纠错级别: '{s}',支持 L/M/Q/H", 1),
fn check_path(p: &str) -> Result<()> {
let path = Path::new(p);
// 禁止绝对路径
if path.is_absolute() {
bail!("不允许使用绝对路径: {p}");
}
}
fn check_path(p: &str) -> Result<(), E> {
if Path::new(p).components().any(|c| matches!(c, std::path::Component::ParentDir)) {
bail!("路径不允许包含 '..'", 2);
// 禁止父目录遍历
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
bail!("路径不允许包含 '..': {p}");
}
Ok(())
}