Compare commits
7 Commits
3d11ba2446
...
ef6b092eda
| Author | SHA1 | Date | |
|---|---|---|---|
| ef6b092eda | |||
| 77fac0e28f | |||
| 8e9e7e1b4c | |||
| b5bc944d71 | |||
| c7d5252651 | |||
| 38be82973e | |||
| 23ccb37b52 |
Generated
+2
@@ -2689,6 +2689,8 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"qr-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -13,3 +13,5 @@ path = "src/main.rs"
|
||||
qr-core = { path = "../core" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
+375
-48
@@ -1,6 +1,9 @@
|
||||
use clap::Parser;
|
||||
use qr_core::qr::{QrCode, QrConfig, VersionMode};
|
||||
use qr_core::text_builder;
|
||||
use qr_core::version::EcLevel;
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -9,10 +12,10 @@ use std::path::Path;
|
||||
about = "QR 码生成/解码工具 — 从零手搓的 ISO/IEC 18004 实现"
|
||||
)]
|
||||
struct Args {
|
||||
/// 要编码的内容(编码模式)
|
||||
/// 快捷编码内容
|
||||
content: Option<String>,
|
||||
|
||||
/// 解码图片文件 (PNG/JPEG/WebP),与编码模式互斥
|
||||
/// 解码图片文件 (PNG/JPEG/WebP)
|
||||
#[arg(short = 'd', long)]
|
||||
decode: Option<String>,
|
||||
|
||||
@@ -39,62 +42,157 @@ struct Args {
|
||||
/// 反色(黑底白码)
|
||||
#[arg(long)]
|
||||
invert: bool,
|
||||
|
||||
/// 前景色 "#RRGGBB"
|
||||
#[arg(long)]
|
||||
fg: Option<String>,
|
||||
|
||||
/// 背景色 "#RRGGBB"
|
||||
#[arg(long)]
|
||||
bg: Option<String>,
|
||||
|
||||
/// Logo 图片文件
|
||||
#[arg(long)]
|
||||
logo: Option<String>,
|
||||
|
||||
// ---- 编码模式参数 ----
|
||||
/// 编码模式 [text/url/wifi/vcard/email/phone/sms/batch]
|
||||
#[arg(long)]
|
||||
mode: Option<String>,
|
||||
|
||||
/// WiFi SSID
|
||||
#[arg(long)]
|
||||
ssid: Option<String>,
|
||||
|
||||
/// WiFi 密码
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
|
||||
/// WiFi 加密方式 [default: WPA]
|
||||
#[arg(long, default_value = "WPA")]
|
||||
encryption: String,
|
||||
|
||||
/// 隐藏 WiFi 网络
|
||||
#[arg(long)]
|
||||
hidden: bool,
|
||||
|
||||
/// 姓名 (vCard)
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// 电话 (vCard)
|
||||
#[arg(long)]
|
||||
phone: Option<String>,
|
||||
|
||||
/// 邮箱 (vCard)
|
||||
#[arg(long)]
|
||||
email: Option<String>,
|
||||
|
||||
/// 公司 (vCard)
|
||||
#[arg(long)]
|
||||
company: Option<String>,
|
||||
|
||||
/// 地址 (vCard)
|
||||
#[arg(long)]
|
||||
address: Option<String>,
|
||||
|
||||
/// 收件人 (Email)
|
||||
#[arg(long)]
|
||||
to: Option<String>,
|
||||
|
||||
/// 主题 (Email)
|
||||
#[arg(long)]
|
||||
subject: Option<String>,
|
||||
|
||||
/// 正文 (Email)
|
||||
#[arg(long)]
|
||||
body: Option<String>,
|
||||
|
||||
/// 电话号码 (Phone/SMS)
|
||||
#[arg(long)]
|
||||
number: Option<String>,
|
||||
|
||||
/// 短信内容 (SMS)
|
||||
#[arg(long)]
|
||||
message: Option<String>,
|
||||
|
||||
/// URL 链接
|
||||
#[arg(long)]
|
||||
url: Option<String>,
|
||||
|
||||
/// 批量输入文件 (JSON/CSV)
|
||||
#[arg(long)]
|
||||
batch: Option<String>,
|
||||
|
||||
/// 批量输出目录
|
||||
#[arg(long)]
|
||||
output_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// 解码模式
|
||||
if let Some(path) = args.decode {
|
||||
return do_decode(&path);
|
||||
}
|
||||
|
||||
// 编码模式
|
||||
let content = args
|
||||
.content
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("请提供编码内容,或使用 --decode <文件> 解码图片"))?;
|
||||
do_encode(content, &args)
|
||||
}
|
||||
if let Some(ref batch_file) = args.batch {
|
||||
return do_batch(batch_file, &args);
|
||||
}
|
||||
|
||||
fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> {
|
||||
let level = match args.level.to_uppercase().as_str() {
|
||||
"L" => EcLevel::L,
|
||||
"M" => EcLevel::M,
|
||||
"Q" => EcLevel::Q,
|
||||
"H" => EcLevel::H,
|
||||
_ => anyhow::bail!("无效纠错级别: {}。支持 L/M/Q/H", args.level),
|
||||
};
|
||||
|
||||
let version = match args.version {
|
||||
Some(v) => {
|
||||
if !(1..=40).contains(&v) {
|
||||
anyhow::bail!("无效版本号: {}。支持 1-40", v);
|
||||
}
|
||||
VersionMode::Fixed(v)
|
||||
}
|
||||
None => VersionMode::Auto,
|
||||
};
|
||||
let text = build_text_from_args(&args)?;
|
||||
let level = parse_level(&args.level)?;
|
||||
let logo_bytes = args
|
||||
.logo
|
||||
.as_ref()
|
||||
.map(fs::read)
|
||||
.transpose()
|
||||
.map_err(|e| anyhow::anyhow!("无法读取 logo 文件: {e}"))?;
|
||||
|
||||
let config = QrConfig {
|
||||
level,
|
||||
version,
|
||||
version: match args.version {
|
||||
Some(v) => {
|
||||
if !(1..=40).contains(&v) {
|
||||
anyhow::bail!("无效版本号: {}。支持 1-40", v);
|
||||
}
|
||||
VersionMode::Fixed(v)
|
||||
}
|
||||
None => VersionMode::Auto,
|
||||
},
|
||||
margin: args.margin,
|
||||
fg_color: args.fg.clone(),
|
||||
bg_color: args.bg.clone(),
|
||||
};
|
||||
|
||||
let qr = QrCode::encode(content, config).map_err(|e| anyhow::anyhow!("编码失败: {}", e))?;
|
||||
let qr = QrCode::encode(&text, config).map_err(|e| anyhow::anyhow!("编码失败: {}", e))?;
|
||||
|
||||
match &args.output {
|
||||
Some(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
|
||||
check_path(path)?;
|
||||
let ext = Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
@@ -102,8 +200,8 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> {
|
||||
|
||||
match ext.as_str() {
|
||||
"png" => {
|
||||
let bytes = qr.to_png_bytes(args.size)?;
|
||||
std::fs::write(path, bytes)?;
|
||||
let bytes = qr.to_png_bytes(args.size, logo_bytes.as_deref())?;
|
||||
fs::write(path, bytes)?;
|
||||
println!(
|
||||
"已生成: {} (版本 {}, {}×{} 模块, {:?} 级纠错)",
|
||||
path,
|
||||
@@ -114,8 +212,8 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> {
|
||||
);
|
||||
}
|
||||
"svg" => {
|
||||
let svg = qr.to_svg();
|
||||
std::fs::write(path, svg)?;
|
||||
let svg = qr.to_svg(logo_bytes.as_deref());
|
||||
fs::write(path, svg)?;
|
||||
println!("已生成: {} (版本 {}, SVG 格式)", path, qr.version.0);
|
||||
}
|
||||
_ => anyhow::bail!("不支持的文件格式: .{}。支持 .png / .svg", ext),
|
||||
@@ -129,12 +227,71 @@ fn do_encode(content: &str, args: &Args) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_text_from_args(args: &Args) -> anyhow::Result<String> {
|
||||
match args.mode.as_deref() {
|
||||
Some("wifi") => {
|
||||
let ssid = args
|
||||
.ssid
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("WiFi 模式需要 --ssid"))?;
|
||||
let pwd = args.password.as_deref().unwrap_or("");
|
||||
Ok(text_builder::build_wifi_text(
|
||||
ssid,
|
||||
pwd,
|
||||
&args.encryption,
|
||||
args.hidden,
|
||||
))
|
||||
}
|
||||
Some("vcard") => Ok(text_builder::build_vcard_text(
|
||||
args.name.as_deref().unwrap_or(""),
|
||||
args.phone.as_deref().unwrap_or(""),
|
||||
args.email.as_deref().unwrap_or(""),
|
||||
args.company.as_deref().unwrap_or(""),
|
||||
args.address.as_deref().unwrap_or(""),
|
||||
)),
|
||||
Some("email") => {
|
||||
let to = args
|
||||
.to
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Email 模式需要 --to"))?;
|
||||
Ok(text_builder::build_email_text(
|
||||
to,
|
||||
args.subject.as_deref().unwrap_or(""),
|
||||
args.body.as_deref().unwrap_or(""),
|
||||
))
|
||||
}
|
||||
Some("phone") => {
|
||||
let num = args
|
||||
.number
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("电话模式需要 --number"))?;
|
||||
Ok(text_builder::build_phone_text(num))
|
||||
}
|
||||
Some("sms") => {
|
||||
let num = args
|
||||
.number
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("短信模式需要 --number"))?;
|
||||
Ok(text_builder::build_sms_text(
|
||||
num,
|
||||
args.message.as_deref().unwrap_or(""),
|
||||
))
|
||||
}
|
||||
Some("url") => args
|
||||
.url
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("URL 模式需要 --url")),
|
||||
Some(m) => anyhow::bail!("未知模式: {m}。支持 text/url/wifi/vcard/email/phone/sms/batch"),
|
||||
None => args
|
||||
.content
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("请提供编码内容或使用 --mode 指定模式")),
|
||||
}
|
||||
}
|
||||
|
||||
fn do_decode(path: &str) -> anyhow::Result<()> {
|
||||
let bytes =
|
||||
std::fs::read(path).map_err(|e| anyhow::anyhow!("无法读取文件 '{}': {}", path, e))?;
|
||||
|
||||
let bytes = fs::read(path).map_err(|e| anyhow::anyhow!("无法读取文件 '{}': {}", path, e))?;
|
||||
let result = qr_core::decoder::decode_image(&bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
println!("解码成功:");
|
||||
println!(" 文本: {}", result.text);
|
||||
println!(" 版本: {}", result.version);
|
||||
@@ -143,6 +300,176 @@ fn do_decode(path: &str) -> anyhow::Result<()> {
|
||||
if result.errors_corrected > 0 {
|
||||
println!(" 纠正错误: {} 码字", result.errors_corrected);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn do_batch(file: &str, args: &Args) -> anyhow::Result<()> {
|
||||
let input = fs::read_to_string(file)
|
||||
.map_err(|e| anyhow::anyhow!("无法读取批量文件 '{}': {}", file, e))?;
|
||||
|
||||
let entries: Vec<BatchEntry> = serde_json::from_str(&input)
|
||||
.or_else(|_| parse_csv(&input))
|
||||
.map_err(|e| anyhow::anyhow!("无法解析输入: {e}\n支持 JSON 数组或 CSV 格式"))?;
|
||||
|
||||
let out_dir = args.output_dir.as_deref().unwrap_or("batch_output");
|
||||
fs::create_dir_all(out_dir)?;
|
||||
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
let text = batch_entry_to_text(entry)?;
|
||||
let level = entry
|
||||
.level
|
||||
.as_deref()
|
||||
.map(parse_level)
|
||||
.unwrap_or(Ok(EcLevel::M))?;
|
||||
|
||||
let config = QrConfig {
|
||||
level,
|
||||
version: VersionMode::Auto,
|
||||
margin: args.margin,
|
||||
fg_color: args.fg.clone(),
|
||||
bg_color: args.bg.clone(),
|
||||
};
|
||||
|
||||
let qr = QrCode::encode(&text, config).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let path = format!("{}/qr_{:04}.png", out_dir, i + 1);
|
||||
let bytes = qr.to_png_bytes(args.size, None)?;
|
||||
fs::write(&path, bytes)?;
|
||||
println!("[{}/{}] {}", i + 1, entries.len(), path);
|
||||
}
|
||||
|
||||
println!("批量生成完成: {} 个 QR 码 → {}", entries.len(), out_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn batch_entry_to_text(entry: &BatchEntry) -> anyhow::Result<String> {
|
||||
if let Some(c) = &entry.content {
|
||||
return Ok(c.clone());
|
||||
}
|
||||
if let Some(u) = &entry.url {
|
||||
return Ok(u.clone());
|
||||
}
|
||||
if let Some(s) = &entry.ssid {
|
||||
let p = entry.password.as_deref().unwrap_or("");
|
||||
let e = entry.encryption.as_deref().unwrap_or("WPA");
|
||||
let h = entry.hidden.unwrap_or(false);
|
||||
return Ok(text_builder::build_wifi_text(s, p, e, h));
|
||||
}
|
||||
if let Some(n) = &entry.name {
|
||||
let ph = entry.phone.as_deref().unwrap_or("");
|
||||
let em = entry.email.as_deref().unwrap_or("");
|
||||
let co = entry.company.as_deref().unwrap_or("");
|
||||
let ad = entry.address.as_deref().unwrap_or("");
|
||||
return Ok(text_builder::build_vcard_text(n, ph, em, co, ad));
|
||||
}
|
||||
if let Some(t) = &entry.to {
|
||||
let s = entry.subject.as_deref().unwrap_or("");
|
||||
let b = entry.body.as_deref().unwrap_or("");
|
||||
return Ok(text_builder::build_email_text(t, s, b));
|
||||
}
|
||||
if let Some(n) = &entry.number {
|
||||
if let Some(m) = &entry.message {
|
||||
return Ok(text_builder::build_sms_text(n, m));
|
||||
}
|
||||
return Ok(text_builder::build_phone_text(n));
|
||||
}
|
||||
anyhow::bail!("无法识别的条目格式")
|
||||
}
|
||||
|
||||
fn parse_csv(input: &str) -> Result<Vec<BatchEntry>, String> {
|
||||
let mut lines = input.lines();
|
||||
let header = lines.next().ok_or("CSV 为空")?;
|
||||
let columns: Vec<&str> = header.split(',').map(|s| s.trim()).collect();
|
||||
let mut entries = Vec::new();
|
||||
for line in lines {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let values: Vec<String> = line
|
||||
.split(',')
|
||||
.map(|s| s.trim().trim_matches('"').to_string())
|
||||
.collect();
|
||||
let mut content = None;
|
||||
let mut level = None;
|
||||
let mut ssid = None;
|
||||
let mut password = None;
|
||||
let mut encryption = None;
|
||||
let mut hidden = None;
|
||||
let mut name = None;
|
||||
let mut phone = None;
|
||||
let mut email = None;
|
||||
let mut company = None;
|
||||
let mut address = None;
|
||||
let mut to = None;
|
||||
let mut subject = None;
|
||||
let mut body = None;
|
||||
let mut number = None;
|
||||
let mut message = None;
|
||||
let mut url = None;
|
||||
|
||||
for (i, col) in columns.iter().enumerate() {
|
||||
let val = values.get(i).cloned();
|
||||
match *col {
|
||||
"content" => content = val,
|
||||
"level" => level = val,
|
||||
"ssid" => ssid = val,
|
||||
"password" => password = val,
|
||||
"encryption" => encryption = val,
|
||||
"hidden" => hidden = val.map(|v| v == "true"),
|
||||
"name" => name = val,
|
||||
"phone" => phone = val,
|
||||
"email" => email = val,
|
||||
"company" => company = val,
|
||||
"address" => address = val,
|
||||
"to" => to = val,
|
||||
"subject" => subject = val,
|
||||
"body" => body = val,
|
||||
"number" => number = val,
|
||||
"message" => message = val,
|
||||
"url" => url = val,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(BatchEntry {
|
||||
content,
|
||||
level,
|
||||
ssid,
|
||||
password,
|
||||
encryption,
|
||||
hidden,
|
||||
name,
|
||||
phone,
|
||||
email,
|
||||
company,
|
||||
address,
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
number,
|
||||
message,
|
||||
url,
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> anyhow::Result<EcLevel> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"L" => Ok(EcLevel::L),
|
||||
"M" => Ok(EcLevel::M),
|
||||
"Q" => Ok(EcLevel::Q),
|
||||
"H" => Ok(EcLevel::H),
|
||||
_ => anyhow::bail!("无效纠错级别: {}。支持 L/M/Q/H", s),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_path(path: &str) -> anyhow::Result<()> {
|
||||
let path_obj = Path::new(path);
|
||||
if path_obj
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||
{
|
||||
anyhow::bail!("不允许包含 '..' 的路径,请使用当前目录下的文件名");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ pub mod encoder;
|
||||
pub mod matrix;
|
||||
pub mod qr;
|
||||
pub mod render;
|
||||
pub mod text_builder;
|
||||
pub mod version;
|
||||
|
||||
+89
-10
@@ -63,15 +63,21 @@ pub struct QrConfig {
|
||||
pub version: VersionMode,
|
||||
/// 静区边距(模块数),默认 4
|
||||
pub margin: u8,
|
||||
/// 前景色(CSS 格式 "#RRGGBB"),默认 "#000000"
|
||||
pub fg_color: Option<String>,
|
||||
/// 背景色(CSS 格式 "#RRGGBB"),默认 "#FFFFFF"
|
||||
pub bg_color: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for QrConfig {
|
||||
/// 默认配置:M 级纠错 + 自动版本 + 4 模块边距
|
||||
/// 默认配置:M 级纠错 + 自动版本 + 4 模块边距 + 黑前景白背景
|
||||
fn default() -> Self {
|
||||
QrConfig {
|
||||
level: EcLevel::M,
|
||||
version: VersionMode::Auto,
|
||||
margin: 4,
|
||||
fg_color: None,
|
||||
bg_color: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,11 +93,11 @@ impl Default for QrConfig {
|
||||
/// println!("版本: {}, 尺寸: {}×{}", qr.version.0, qr.size(), qr.size());
|
||||
///
|
||||
/// // 导出为 SVG
|
||||
/// let svg = qr.to_svg();
|
||||
/// let svg = qr.to_svg(None);
|
||||
/// assert!(svg.starts_with("<svg"));
|
||||
///
|
||||
/// // 导出为 PNG 字节
|
||||
/// let png = qr.to_png_bytes(4).unwrap();
|
||||
/// let png = qr.to_png_bytes(4, None).unwrap();
|
||||
/// assert!(png.len() > 100);
|
||||
///
|
||||
/// // 终端 ASCII 输出
|
||||
@@ -108,6 +114,10 @@ pub struct QrCode {
|
||||
matrix: Matrix,
|
||||
/// 静区边距(模块数)
|
||||
pub margin: u8,
|
||||
/// 前景色 RGB [r, g, b]
|
||||
pub fg_color: [u8; 3],
|
||||
/// 背景色 RGB [r, g, b]
|
||||
pub bg_color: [u8; 3],
|
||||
}
|
||||
|
||||
impl QrCode {
|
||||
@@ -203,12 +213,17 @@ impl QrCode {
|
||||
place_version_info(&mut final_matrix, ver_info);
|
||||
}
|
||||
|
||||
let fg_color = parse_hex_color(config.fg_color.as_deref().unwrap_or("#000000"))?;
|
||||
let bg_color = parse_hex_color(config.bg_color.as_deref().unwrap_or("#FFFFFF"))?;
|
||||
|
||||
Ok(QrCode {
|
||||
version,
|
||||
level: config.level,
|
||||
mask: best_idx,
|
||||
matrix: final_matrix,
|
||||
margin: config.margin,
|
||||
fg_color,
|
||||
bg_color,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,17 +249,18 @@ impl QrCode {
|
||||
|
||||
/// 导出为 SVG 字符串
|
||||
///
|
||||
/// SVG 内含 `viewBox`、深色模块用 `#000` 填充。
|
||||
/// SVG 内含 `viewBox`、使用 QrCode 的前/背景色。
|
||||
/// `logo` 可选的 logo 图片字节,会以 base64 嵌入 SVG 中央。
|
||||
///
|
||||
/// ```rust
|
||||
/// use qr_core::qr::{QrCode, QrConfig};
|
||||
///
|
||||
/// let qr = QrCode::encode("test", QrConfig::default()).unwrap();
|
||||
/// let svg = qr.to_svg();
|
||||
/// let svg = qr.to_svg(None);
|
||||
/// assert!(svg.starts_with("<svg"));
|
||||
/// ```
|
||||
pub fn to_svg(&self) -> String {
|
||||
crate::render::svg::render_svg(self)
|
||||
pub fn to_svg(&self, logo: Option<&[u8]>) -> String {
|
||||
crate::render::svg::render_svg(self, logo)
|
||||
}
|
||||
|
||||
/// 导出为终端 ASCII 文本
|
||||
@@ -258,16 +274,54 @@ impl QrCode {
|
||||
/// 导出为 PNG 字节数据
|
||||
///
|
||||
/// `module_size` 控制每个模块的像素大小(2~20),越大文件越大。
|
||||
/// `logo` 可选的 logo 图片字节,会在 QR 码中央叠加(建议搭配 H 级纠错)
|
||||
///
|
||||
/// ```rust
|
||||
/// use qr_core::qr::{QrCode, QrConfig};
|
||||
///
|
||||
/// let qr = QrCode::encode("PNG test", QrConfig::default()).unwrap();
|
||||
/// let bytes = qr.to_png_bytes(4).unwrap();
|
||||
/// let bytes = qr.to_png_bytes(4, None).unwrap();
|
||||
/// std::fs::write("test.png", &bytes).unwrap();
|
||||
/// ```
|
||||
pub fn to_png_bytes(&self, module_size: u8) -> Result<Vec<u8>, image::ImageError> {
|
||||
crate::render::png::render_png(self, module_size)
|
||||
pub fn to_png_bytes(
|
||||
&self,
|
||||
module_size: u8,
|
||||
logo: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>, image::ImageError> {
|
||||
crate::render::png::render_png(self, module_size, logo)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 CSS 十六进制颜色 → [R, G, B]
|
||||
///
|
||||
/// 支持格式: "#RGB", "#RRGGBB"
|
||||
/// 无效格式返回 Err
|
||||
fn parse_hex_color(s: &str) -> Result<[u8; 3], String> {
|
||||
let s = s.trim();
|
||||
if !s.starts_with('#') {
|
||||
return Err(format!("颜色格式错误: '{}',应为 '#RRGGBB' 或 '#RGB'", s));
|
||||
}
|
||||
let hex = &s[1..];
|
||||
match hex.len() {
|
||||
3 => {
|
||||
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
|
||||
.map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
|
||||
.map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
|
||||
.map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
Ok([r, g, b])
|
||||
}
|
||||
6 => {
|
||||
let r =
|
||||
u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
let g =
|
||||
u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
let b =
|
||||
u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("无效颜色值: '{}'", s))?;
|
||||
Ok([r, g, b])
|
||||
}
|
||||
_ => Err(format!("颜色格式错误: '{}',应为 '#RRGGBB' 或 '#RGB'", s)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,4 +368,29 @@ mod tests {
|
||||
assert!(qr.size() >= 21);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_hex_color() {
|
||||
assert_eq!(parse_hex_color("#000000").unwrap(), [0, 0, 0]);
|
||||
assert_eq!(parse_hex_color("#FFFFFF").unwrap(), [255, 255, 255]);
|
||||
assert_eq!(parse_hex_color("#FF5733").unwrap(), [255, 87, 51]);
|
||||
assert_eq!(parse_hex_color("#F53").unwrap(), [255, 85, 51]);
|
||||
assert!(parse_hex_color("invalid").is_err());
|
||||
assert!(parse_hex_color("#GGGGGG").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_qr() {
|
||||
let config = QrConfig {
|
||||
fg_color: Some("#FF0000".into()),
|
||||
bg_color: Some("#0000FF".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let qr = QrCode::encode("COLOR TEST", config).unwrap();
|
||||
assert_eq!(qr.fg_color, [255, 0, 0]);
|
||||
assert_eq!(qr.bg_color, [0, 0, 255]);
|
||||
let svg = qr.to_svg(None);
|
||||
assert!(svg.contains("#FF0000"));
|
||||
assert!(svg.contains("#0000FF"));
|
||||
}
|
||||
}
|
||||
|
||||
+54
-9
@@ -1,25 +1,57 @@
|
||||
use crate::qr::QrCode;
|
||||
use image::{ImageBuffer, Luma};
|
||||
use image::{imageops, ImageBuffer, Rgba, RgbaImage};
|
||||
|
||||
/// 将单个模块填充到图像缓冲区(module_size × module_size 像素块)
|
||||
fn fill_module(
|
||||
img: &mut ImageBuffer<Luma<u8>, Vec<u8>>,
|
||||
img: &mut RgbaImage,
|
||||
x: u32,
|
||||
y: u32,
|
||||
module_size: u32,
|
||||
is_dark: bool,
|
||||
fg: &[u8; 3],
|
||||
bg: &[u8; 3],
|
||||
) {
|
||||
let px_val = if is_dark { 0u8 } else { 255u8 };
|
||||
let color = if is_dark {
|
||||
Rgba([fg[0], fg[1], fg[2], 255])
|
||||
} else {
|
||||
Rgba([bg[0], bg[1], bg[2], 255])
|
||||
};
|
||||
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]));
|
||||
img.put_pixel(x0 + dx, y0 + dy, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_png(qr: &QrCode, module_size: u8) -> Result<Vec<u8>, image::ImageError> {
|
||||
/// 在 QR 码 PNG 缓冲区中央叠加 logo
|
||||
fn overlay_logo(img: &mut RgbaImage, logo_bytes: &[u8], logo_size_pct: f32) -> Result<(), String> {
|
||||
let logo = image::load_from_memory(logo_bytes).map_err(|e| format!("Logo 加载失败: {e}"))?;
|
||||
let logo = logo.to_rgba8();
|
||||
|
||||
let img_w = img.width();
|
||||
let img_h = img.height();
|
||||
|
||||
// Logo 边长 = min(图像边长 * pct, 实际 QR 区域 * pct)
|
||||
let logo_size = (img_w.min(img_h) as f32 * logo_size_pct) as u32;
|
||||
if logo_size < 4 {
|
||||
return Ok(()); // 太小,跳过
|
||||
}
|
||||
|
||||
let resized = imageops::resize(&logo, logo_size, logo_size, imageops::FilterType::Lanczos3);
|
||||
|
||||
let x = (img_w - logo_size) / 2;
|
||||
let y = (img_h - logo_size) / 2;
|
||||
imageops::overlay(img, &resized, x as i64, y as i64);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn render_png(
|
||||
qr: &QrCode,
|
||||
module_size: u8,
|
||||
logo: Option<&[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;
|
||||
@@ -29,7 +61,6 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Result<Vec<u8>, image::ImageE
|
||||
|
||||
for y in 0..total_size {
|
||||
for x in 0..total_size {
|
||||
// 直接比较坐标与 margin 边界,避免 saturating_sub 在边界处回绕到 0
|
||||
let is_dark = if x >= margin
|
||||
&& x < margin + matrix_size
|
||||
&& y >= margin
|
||||
@@ -39,13 +70,27 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Result<Vec<u8>, image::ImageE
|
||||
let my = (y - margin) as usize;
|
||||
qr.modules()[my][mx]
|
||||
} else {
|
||||
false // 白边 (quiet zone)
|
||||
false
|
||||
};
|
||||
|
||||
fill_module(&mut img, x, y, module_size as u32, is_dark);
|
||||
fill_module(
|
||||
&mut img,
|
||||
x,
|
||||
y,
|
||||
module_size as u32,
|
||||
is_dark,
|
||||
&qr.fg_color,
|
||||
&qr.bg_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Logo 叠加
|
||||
if let Some(logo_data) = logo {
|
||||
// 忽略 logo 叠加错误(logo 有损不影响 QR 主体)
|
||||
let _ = overlay_logo(&mut img, logo_data, 0.25);
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
|
||||
Ok(buf)
|
||||
|
||||
+50
-8
@@ -1,32 +1,38 @@
|
||||
use crate::qr::QrCode;
|
||||
|
||||
pub fn render_svg(qr: &QrCode) -> String {
|
||||
pub fn render_svg(qr: &QrCode, logo: Option<&[u8]>) -> String {
|
||||
let matrix_size = qr.size() as u32;
|
||||
let margin = qr.margin as u32;
|
||||
let total = matrix_size + 2 * margin;
|
||||
|
||||
// 预估 SVG 大小: 固定头部 + 每个暗模块约 48 字节
|
||||
let fg = format!(
|
||||
"#{:02X}{:02X}{:02X}",
|
||||
qr.fg_color[0], qr.fg_color[1], qr.fg_color[2]
|
||||
);
|
||||
let bg = format!(
|
||||
"#{:02X}{:02X}{:02X}",
|
||||
qr.bg_color[0], qr.bg_color[1], qr.bg_color[2]
|
||||
);
|
||||
|
||||
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);
|
||||
let mut svg = String::with_capacity(300 + 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
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{total}" height="{total}" viewBox="0 0 {total} {total}">"#
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
r#"<rect width="{}" height="{}" fill="white"/>"#,
|
||||
total, total
|
||||
r#"<rect width="{total}" height="{total}" fill="{bg}"/>"#
|
||||
));
|
||||
|
||||
for y in 0..matrix_size {
|
||||
for x in 0..matrix_size {
|
||||
if qr.modules()[y as usize][x as usize] {
|
||||
svg.push_str(&format!(
|
||||
r#"<rect x="{}" y="{}" width="1" height="1" fill="black"/>"#,
|
||||
r#"<rect x="{}" y="{}" width="1" height="1" fill="{fg}"/>"#,
|
||||
x + margin,
|
||||
y + margin
|
||||
));
|
||||
@@ -34,6 +40,42 @@ pub fn render_svg(qr: &QrCode) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Logo 嵌入:base64 PNG data URL
|
||||
if let Some(logo_bytes) = logo {
|
||||
let b64 = base64_encode(logo_bytes);
|
||||
let logo_size = total as f32 * 0.25;
|
||||
let logo_x = (total as f32 - logo_size) / 2.0;
|
||||
let logo_y = (total as f32 - logo_size) / 2.0;
|
||||
svg.push_str(&format!(
|
||||
r#"<image x="{logo_x}" y="{logo_y}" width="{logo_size}" height="{logo_size}" xlink:href="data:image/png;base64,{b64}"/>"#
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
}
|
||||
|
||||
/// 简易 base64 编码(无外部依赖)
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||
result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
|
||||
result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
|
||||
} else {
|
||||
result.push('=');
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
result.push(CHARS[(triple & 0x3F) as usize] as char);
|
||||
} else {
|
||||
result.push('=');
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! QR 编码文本构造工具
|
||||
//!
|
||||
//! 集中管理各模式的文本格式(与 gui 前端 `utils/qrText.ts` 功能一致)
|
||||
|
||||
/// 构造 WiFi 连接字符串
|
||||
pub fn build_wifi_text(ssid: &str, password: &str, encryption: &str, hidden: bool) -> String {
|
||||
let h = if hidden { "H:true;" } else { "" };
|
||||
format!("WIFI:T:{encryption};S:{ssid};P:{password};{h};")
|
||||
}
|
||||
|
||||
/// 构造 vCard 字符串
|
||||
pub fn build_vcard_text(
|
||||
name: &str,
|
||||
phone: &str,
|
||||
email: &str,
|
||||
company: &str,
|
||||
address: &str,
|
||||
) -> String {
|
||||
format!("BEGIN:VCARD\nVERSION:3.0\nFN:{name}\nTEL:{phone}\nEMAIL:{email}\nORG:{company}\nADR:{address}\nEND:VCARD")
|
||||
}
|
||||
|
||||
/// 构造 mailto 链接
|
||||
pub fn build_email_text(to: &str, subject: &str, body: &str) -> String {
|
||||
let subject_enc = urlencoding(subject);
|
||||
let body_enc = urlencoding(body);
|
||||
format!("mailto:{to}?subject={subject_enc}&body={body_enc}")
|
||||
}
|
||||
|
||||
/// 构造电话链接
|
||||
pub fn build_phone_text(number: &str) -> String {
|
||||
format!("tel:{number}")
|
||||
}
|
||||
|
||||
/// 构造短信链接
|
||||
pub fn build_sms_text(number: &str, message: &str) -> String {
|
||||
format!("smsto:{number}:{message}")
|
||||
}
|
||||
|
||||
/// 简易 URL 编码(仅编码特殊字符)
|
||||
fn urlencoding(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
' ' => "%20".into(),
|
||||
'&' => "%26".into(),
|
||||
'=' => "%3D".into(),
|
||||
'#' => "%23".into(),
|
||||
'%' => "%25".into(),
|
||||
'+' => "%2B".into(),
|
||||
'\n' => "%0A".into(),
|
||||
'\r' => "%0D".into(),
|
||||
_ => c.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_wifi_text() {
|
||||
let text = build_wifi_text("MyWiFi", "pass123", "WPA", false);
|
||||
assert!(text.contains("WIFI:T:WPA;S:MyWiFi;P:pass123;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_wifi_hidden() {
|
||||
let text = build_wifi_text("HiddenNet", "secret", "WPA2", true);
|
||||
assert!(text.contains("H:true;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_vcard_text() {
|
||||
let text = build_vcard_text("张三", "13800138000", "a@b.com", "公司", "北京");
|
||||
assert!(text.contains("BEGIN:VCARD"));
|
||||
assert!(text.contains("FN:张三"));
|
||||
assert!(text.contains("END:VCARD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_email_text() {
|
||||
let text = build_email_text("a@b.com", "Hello World", "Test body");
|
||||
assert!(text.starts_with("mailto:a@b.com"));
|
||||
assert!(text.contains("Hello%20World"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_phone_text() {
|
||||
assert_eq!(build_phone_text("13800138000"), "tel:13800138000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sms_text() {
|
||||
let text = build_sms_text("13800138000", "Hi");
|
||||
assert_eq!(text, "smsto:13800138000:Hi");
|
||||
}
|
||||
}
|
||||
@@ -193,7 +193,7 @@ fn test_format_info_written() {
|
||||
#[test]
|
||||
fn test_svg_valid_structure() {
|
||||
let qr = QrCode::encode("HELLO", QrConfig::default()).unwrap();
|
||||
let svg = qr.to_svg();
|
||||
let svg = qr.to_svg(None);
|
||||
// SVG 应有正确的结构
|
||||
assert!(svg.starts_with("<svg"), "SVG 应以 <svg 开头");
|
||||
assert!(svg.contains("rect"), "SVG 应包含 rect 元素");
|
||||
@@ -313,7 +313,7 @@ fn test_empty_input_fails() {
|
||||
#[test]
|
||||
fn test_svg_output() {
|
||||
let qr = QrCode::encode("TEST", QrConfig::default()).unwrap();
|
||||
let svg = qr.to_svg();
|
||||
let svg = qr.to_svg(None);
|
||||
assert!(svg.contains("<svg"));
|
||||
assert!(svg.contains("</svg>"));
|
||||
assert!(svg.contains("fill=\"black\""));
|
||||
@@ -332,7 +332,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).unwrap();
|
||||
let png = qr.to_png_bytes(4, None).unwrap();
|
||||
assert!(!png.is_empty());
|
||||
// PNG 文件应以 8 字节魔术签名开头
|
||||
assert_eq!(&png[..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
@@ -356,7 +356,7 @@ fn test_margin_is_included_in_dimensions() {
|
||||
let qr = QrCode::encode("MARGIN TEST", config).unwrap();
|
||||
|
||||
// SVG 的总宽度应该包含 margin
|
||||
let svg = qr.to_svg();
|
||||
let svg = qr.to_svg(None);
|
||||
let matrix_size = qr.size() as u32;
|
||||
let expected_total = matrix_size + 2 * 2u32;
|
||||
assert!(svg.contains(&format!("width=\"{}\"", expected_total)));
|
||||
|
||||
@@ -19,11 +19,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("{}", qr.to_ascii(false));
|
||||
|
||||
// 导出 PNG
|
||||
qr.to_png_bytes(8)?;
|
||||
qr.to_png_bytes(8, None)?;
|
||||
println!("\nPNG 生成成功");
|
||||
|
||||
// 导出 SVG
|
||||
let svg = qr.to_svg();
|
||||
let svg = qr.to_svg(None);
|
||||
println!("SVG 长度: {} 字节", svg.len());
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -17,7 +17,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
assert_eq!(qr.version.0, 10);
|
||||
|
||||
// 导出大尺寸 PNG(每个模块 8 像素)
|
||||
let png = qr.to_png_bytes(8)?;
|
||||
let png = qr.to_png_bytes(8, None)?;
|
||||
println!("版本 10 QR 码 PNG: {} 字节", png.len());
|
||||
|
||||
// 反转色终端输出(白底黑码 → 黑底白码)
|
||||
|
||||
@@ -21,7 +21,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
qr.size()
|
||||
);
|
||||
|
||||
let svg = qr.to_svg();
|
||||
let svg = qr.to_svg(None);
|
||||
println!("SVG 生成成功: {} 字节", svg.len());
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -32,8 +32,11 @@
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-fs": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"i18next": "^26.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19",
|
||||
@@ -45,6 +48,7 @@
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.2.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9",
|
||||
"globals": "^16",
|
||||
|
||||
Generated
+212
@@ -23,12 +23,21 @@ importers:
|
||||
'@tauri-apps/plugin-store':
|
||||
specifier: ^2
|
||||
version: 2.4.3
|
||||
i18next:
|
||||
specifier: ^26.3.1
|
||||
version: 26.3.1(typescript@5.9.3)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.2.1
|
||||
version: 8.2.1
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
react-i18next:
|
||||
specifier: ^17.0.8
|
||||
version: 17.0.8(i18next@26.3.1(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
|
||||
devDependencies:
|
||||
'@commitlint/cli':
|
||||
specifier: ^19
|
||||
@@ -57,6 +66,9 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.4
|
||||
version: 4.7.0(vite@6.4.3(@types/node@25.9.3)(jiti@1.21.7))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(vitest@3.2.6(@types/node@25.9.3)(jiti@1.21.7)(jsdom@26.0.0))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.5.0(postcss@8.5.15)
|
||||
@@ -106,6 +118,10 @@ packages:
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@asamuzakjp/css-color@2.8.2':
|
||||
resolution: {integrity: sha512-RtWv9jFN2/bLExuZgFFZ0I3pWWeezAHGgrmjqGGWclATl1aDe3yhCUaI0Ilkp6OCk9zX7+FjvDasEX8Q9Rxc5w==}
|
||||
|
||||
@@ -196,6 +212,10 @@ packages:
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@commitlint/cli@19.0.0':
|
||||
resolution: {integrity: sha512-SVBQG6k+eOOmlejYTtxnqJGmhrzy/m0qH3bVeoHY3gtlJBK3Kb32RjJioteBYk8Vuo58x5ehAjXwsQFX58X+xw==}
|
||||
engines: {node: '>=v18'}
|
||||
@@ -511,6 +531,10 @@ packages:
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@istanbuljs/schema@0.1.6':
|
||||
resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@jest/types@27.0.2':
|
||||
resolution: {integrity: sha512-XpjCtJ/99HB4PmyJ2vgmN7vT+JLP7RW1FBT9RgnMFS4Dt7cvIyBee8O3/j98aUZ34ZpenPZFqmaaObWSeL65dg==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
@@ -929,6 +953,15 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@vitest/coverage-v8@3.2.6':
|
||||
resolution: {integrity: sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 3.2.6
|
||||
vitest: 3.2.6
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@3.2.6':
|
||||
resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==}
|
||||
|
||||
@@ -1041,6 +1074,9 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
@@ -1571,6 +1607,12 @@ packages:
|
||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -1588,6 +1630,17 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
i18next-browser-languagedetector@8.2.1:
|
||||
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
|
||||
|
||||
i18next@26.3.1:
|
||||
resolution: {integrity: sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==}
|
||||
peerDependencies:
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1684,6 +1737,22 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-lib-source-maps@5.0.6:
|
||||
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
jackspeak@3.1.2:
|
||||
resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1692,6 +1761,9 @@ packages:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -1844,6 +1916,13 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.3.5:
|
||||
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
map-obj@4.3.0:
|
||||
resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2141,6 +2220,22 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react-i18next@17.0.8:
|
||||
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
|
||||
peerDependencies:
|
||||
i18next: '>= 26.2.0'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
react-is@17.0.1:
|
||||
resolution: {integrity: sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==}
|
||||
|
||||
@@ -2366,6 +2461,10 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
test-exclude@7.0.2:
|
||||
resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
text-extensions@2.4.0:
|
||||
resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2482,6 +2581,11 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@@ -2561,6 +2665,10 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2659,6 +2767,11 @@ snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@asamuzakjp/css-color@2.8.2':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)
|
||||
@@ -2781,6 +2894,8 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@commitlint/cli@19.0.0(@types/node@25.9.3)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@commitlint/format': 19.0.0
|
||||
@@ -3061,6 +3176,8 @@ snapshots:
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
|
||||
'@istanbuljs/schema@0.1.6': {}
|
||||
|
||||
'@jest/types@27.0.2':
|
||||
dependencies:
|
||||
'@types/istanbul-lib-coverage': 2.0.6
|
||||
@@ -3447,6 +3564,25 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@25.9.3)(jiti@1.21.7)(jsdom@26.0.0))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
ast-v8-to-istanbul: 0.3.12
|
||||
debug: 4.4.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-lib-source-maps: 5.0.6
|
||||
istanbul-reports: 3.2.0
|
||||
magic-string: 0.30.21
|
||||
magicast: 0.3.5
|
||||
std-env: 3.9.0
|
||||
test-exclude: 7.0.2
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.6(@types/node@25.9.3)(jiti@1.21.7)(jsdom@26.0.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/expect@3.2.6':
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
@@ -3565,6 +3701,12 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
autoprefixer@10.5.0(postcss@8.5.15):
|
||||
@@ -4115,6 +4257,12 @@ snapshots:
|
||||
dependencies:
|
||||
whatwg-encoding: 3.1.1
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.0
|
||||
@@ -4133,6 +4281,14 @@ snapshots:
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
i18next-browser-languagedetector@8.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
|
||||
i18next@26.3.1(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -4202,6 +4358,27 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-lib-source-maps@5.0.6:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
debug: 4.4.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
jackspeak@3.1.2:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
@@ -4210,6 +4387,8 @@ snapshots:
|
||||
|
||||
jiti@1.21.7: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
@@ -4363,6 +4542,16 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.3.5:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.8.4
|
||||
|
||||
map-obj@4.3.0: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
@@ -4617,6 +4806,17 @@ snapshots:
|
||||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react-i18next@17.0.8(i18next@26.3.1(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 26.3.1(typescript@5.9.3)
|
||||
react: 18.3.1
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
typescript: 5.9.3
|
||||
|
||||
react-is@17.0.1: {}
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
@@ -4876,6 +5076,12 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
test-exclude@7.0.2:
|
||||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.6
|
||||
glob: 10.5.0
|
||||
minimatch: 10.2.5
|
||||
|
||||
text-extensions@2.4.0: {}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
@@ -4968,6 +5174,10 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
use-sync-external-store@1.6.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
@@ -5051,6 +5261,8 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "QRGen",
|
||||
"encodingModes": "Encoding Modes",
|
||||
"exportOptions": "Export Options",
|
||||
"decode": "Decode"
|
||||
},
|
||||
"mode": {
|
||||
"text": "Text",
|
||||
"url": "URL",
|
||||
"wifi": "WiFi",
|
||||
"vcard": "vCard",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"sms": "SMS"
|
||||
},
|
||||
"export": {
|
||||
"eccLevel": "ECC Level",
|
||||
"moduleSize": "Module Size",
|
||||
"margin": "Margin",
|
||||
"copySvg": "Copy SVG",
|
||||
"exportPng": "Export PNG",
|
||||
"exportSvg": "Export SVG",
|
||||
"exporting": "Exporting...",
|
||||
"selectImage": "Select Image to Decode",
|
||||
"decoding": "Decoding..."
|
||||
},
|
||||
"preview": {
|
||||
"loading": "Generating...",
|
||||
"empty": "Enter content to generate QR code",
|
||||
"version": "Version",
|
||||
"mask": "Mask"
|
||||
},
|
||||
"history": {
|
||||
"title": "History",
|
||||
"clear": "Clear",
|
||||
"empty": "No records"
|
||||
},
|
||||
"wifi": {
|
||||
"ssid": "SSID",
|
||||
"password": "Password",
|
||||
"none": "None",
|
||||
"hidden": "Hidden"
|
||||
},
|
||||
"vcard": {
|
||||
"name": "Name",
|
||||
"phone": "Phone",
|
||||
"email": "Email",
|
||||
"company": "Company",
|
||||
"address": "Address"
|
||||
},
|
||||
"email": {
|
||||
"to": "To",
|
||||
"subject": "Subject",
|
||||
"body": "Body"
|
||||
},
|
||||
"phone": {
|
||||
"placeholder": "Enter phone number"
|
||||
},
|
||||
"sms": {
|
||||
"number": "Phone Number",
|
||||
"message": "Message"
|
||||
},
|
||||
"text": {
|
||||
"placeholder": "Enter text..."
|
||||
},
|
||||
"error": {
|
||||
"appError": "Application Error",
|
||||
"reload": "Reload",
|
||||
"decodeFailed": "Decode Failed",
|
||||
"exportPngFailed": "PNG Export Failed",
|
||||
"exportSvgFailed": "SVG Export Failed",
|
||||
"copyFailed": "Copy Failed"
|
||||
},
|
||||
"dialog": {
|
||||
"imageFiles": "Image Files",
|
||||
"pngImage": "PNG Image",
|
||||
"svgImage": "SVG Image"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "QRGen",
|
||||
"encodingModes": "编码模式",
|
||||
"exportOptions": "导出选项",
|
||||
"decode": "解码"
|
||||
},
|
||||
"mode": {
|
||||
"text": "文本",
|
||||
"url": "URL",
|
||||
"wifi": "WiFi",
|
||||
"vcard": "vCard",
|
||||
"email": "Email",
|
||||
"phone": "电话",
|
||||
"sms": "SMS"
|
||||
},
|
||||
"export": {
|
||||
"eccLevel": "纠错级别",
|
||||
"moduleSize": "模块大小",
|
||||
"margin": "边距",
|
||||
"copySvg": "复制 SVG",
|
||||
"exportPng": "导出 PNG",
|
||||
"exportSvg": "导出 SVG",
|
||||
"exporting": "导出中...",
|
||||
"selectImage": "选择图片解码",
|
||||
"decoding": "解码中..."
|
||||
},
|
||||
"preview": {
|
||||
"loading": "生成中...",
|
||||
"empty": "输入内容生成 QR 码",
|
||||
"version": "版本",
|
||||
"mask": "掩码"
|
||||
},
|
||||
"history": {
|
||||
"title": "历史记录",
|
||||
"clear": "清空",
|
||||
"empty": "暂无记录"
|
||||
},
|
||||
"wifi": {
|
||||
"ssid": "SSID",
|
||||
"password": "密码",
|
||||
"none": "无密码",
|
||||
"hidden": "隐藏"
|
||||
},
|
||||
"vcard": {
|
||||
"name": "姓名",
|
||||
"phone": "电话",
|
||||
"email": "邮箱",
|
||||
"company": "公司",
|
||||
"address": "地址"
|
||||
},
|
||||
"email": {
|
||||
"to": "收件人",
|
||||
"subject": "主题",
|
||||
"body": "正文"
|
||||
},
|
||||
"phone": {
|
||||
"placeholder": "输入电话号码"
|
||||
},
|
||||
"sms": {
|
||||
"number": "电话号码",
|
||||
"message": "短信内容"
|
||||
},
|
||||
"text": {
|
||||
"placeholder": "输入任意文本..."
|
||||
},
|
||||
"error": {
|
||||
"appError": "应用发生错误",
|
||||
"reload": "重新加载",
|
||||
"decodeFailed": "解码失败",
|
||||
"exportPngFailed": "导出 PNG 失败",
|
||||
"exportSvgFailed": "导出 SVG 失败",
|
||||
"copyFailed": "复制失败"
|
||||
},
|
||||
"dialog": {
|
||||
"imageFiles": "图片文件",
|
||||
"pngImage": "PNG 图片",
|
||||
"svgImage": "SVG 图片"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { QrProvider, useQrState } from './store/qrContext';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import ModePanel from './components/ModePanel';
|
||||
@@ -13,11 +14,26 @@ import PhoneMode from './modes/PhoneMode';
|
||||
import SmsMode from './modes/SmsMode';
|
||||
|
||||
function AppLayout() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const toggleLang = () => {
|
||||
i18n.changeLanguage(i18n.language.startsWith('en') ? 'zh' : 'en');
|
||||
};
|
||||
|
||||
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>
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
🄲 {t('app.title')}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{i18n.language.startsWith('en') ? '中' : 'EN'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 三栏主体 */}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Store reducer + QrProvider 测试
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { QrProvider, useQrState } from '../store/qrContext';
|
||||
|
||||
describe('QrProvider + useQrState', () => {
|
||||
it('provides default state', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
expect(result.current.state.mode).toBe('text');
|
||||
expect(result.current.state.config.level).toBe('M');
|
||||
expect(result.current.state.config.margin).toBe(4);
|
||||
expect(result.current.state.history).toEqual([]);
|
||||
expect(result.current.state.loading).toBe(false);
|
||||
expect(result.current.state.preview).toBeNull();
|
||||
});
|
||||
|
||||
it('SET_MODE changes mode', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
act(() => result.current.dispatch({ type: 'SET_MODE', payload: 'wifi' }));
|
||||
expect(result.current.state.mode).toBe('wifi');
|
||||
});
|
||||
|
||||
it('SET_FORM_DATA updates form data', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_FORM_DATA',
|
||||
payload: { text: 'hello' },
|
||||
}),
|
||||
);
|
||||
expect(result.current.state.formData.text).toBe('hello');
|
||||
});
|
||||
|
||||
it('SET_CONFIG updates config partially', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_CONFIG',
|
||||
payload: { level: 'H' },
|
||||
}),
|
||||
);
|
||||
expect(result.current.state.config.level).toBe('H');
|
||||
expect(result.current.state.config.margin).toBe(4); // unchanged
|
||||
});
|
||||
|
||||
it('SET_PREVIEW stores preview data', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_PREVIEW',
|
||||
payload: { svg: '<svg>hi</svg>', version: 1, size: 21, mask: 3 },
|
||||
}),
|
||||
);
|
||||
expect(result.current.state.preview?.version).toBe(1);
|
||||
expect(result.current.state.preview?.svg).toBe('<svg>hi</svg>');
|
||||
});
|
||||
|
||||
it('SET_HISTORY replaces history', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_HISTORY',
|
||||
payload: [{ id: '1', mode: 'text', content: 'hi', timestamp: 1 }],
|
||||
}),
|
||||
);
|
||||
expect(result.current.state.history).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('useQrState throws outside QrProvider', () => {
|
||||
expect(() => renderHook(() => useQrState())).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { Component, type ReactNode } from 'react';
|
||||
import { withTranslation, type WithTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
interface Props extends WithTranslation {
|
||||
children: ReactNode;
|
||||
}
|
||||
interface State {
|
||||
@@ -8,7 +9,7 @@ interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
class ErrorBoundaryInner extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
@@ -16,23 +17,22 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
// 生产环境错误日志记录入口
|
||||
// TODO: 集成遥测服务后将错误上报
|
||||
console.error('QRGen ErrorBoundary 捕获错误:', error.message, info.componentStack);
|
||||
console.error('QRGen ErrorBoundary:', error.message, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
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>
|
||||
<h2 className="text-lg font-semibold">{t('error.appError')}</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"
|
||||
>
|
||||
重新加载
|
||||
{t('error.reload')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -40,3 +40,6 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorBoundary = withTranslation()(ErrorBoundaryInner);
|
||||
export default ErrorBoundary;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
@@ -8,6 +9,7 @@ import type { QrConfig } from '../types';
|
||||
import { buildEncodedText } from '../utils/qrText';
|
||||
|
||||
export default function ExportPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
@@ -20,7 +22,9 @@ export default function ExportPanel() {
|
||||
setDecodedText(null);
|
||||
try {
|
||||
const filePath = await open({
|
||||
filters: [{ name: '图片文件', extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp'] }],
|
||||
filters: [
|
||||
{ name: t('dialog.imageFiles'), extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp'] },
|
||||
],
|
||||
multiple: false,
|
||||
});
|
||||
if (!filePath) {
|
||||
@@ -31,7 +35,7 @@ export default function ExportPanel() {
|
||||
const text: string = await invoke('decode_qr', { imageBytes: Array.from(bytes) });
|
||||
setDecodedText(text);
|
||||
} catch (e) {
|
||||
setErrorMsg(`解码失败: ${e}`);
|
||||
setErrorMsg(`${t('error.decodeFailed')}: ${e}`);
|
||||
}
|
||||
setDecoding(false);
|
||||
};
|
||||
@@ -41,7 +45,7 @@ export default function ExportPanel() {
|
||||
try {
|
||||
await writeText(state.preview.svg);
|
||||
} catch (e) {
|
||||
setErrorMsg(`复制失败: ${e}`);
|
||||
setErrorMsg(`${t('error.copyFailed')}: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,14 +55,13 @@ export default function ExportPanel() {
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const filePath = await save({
|
||||
filters: [{ name: 'PNG 图片', extensions: ['png'] }],
|
||||
filters: [{ name: t('dialog.pngImage'), extensions: ['png'] }],
|
||||
defaultPath: 'qrcode.png',
|
||||
});
|
||||
if (!filePath) {
|
||||
setExporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes: number[] = await invoke('export_png', {
|
||||
text: buildEncodedText(state.mode, state.formData),
|
||||
level: state.config.level,
|
||||
@@ -67,7 +70,7 @@ export default function ExportPanel() {
|
||||
});
|
||||
await writeFile(filePath, new Uint8Array(bytes));
|
||||
} catch (e) {
|
||||
setErrorMsg(`导出 PNG 失败: ${e}`);
|
||||
setErrorMsg(`${t('error.exportPngFailed')}: ${e}`);
|
||||
}
|
||||
setExporting(false);
|
||||
};
|
||||
@@ -77,19 +80,21 @@ export default function ExportPanel() {
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const filePath = await save({
|
||||
filters: [{ name: 'SVG 图片', extensions: ['svg'] }],
|
||||
filters: [{ name: t('dialog.svgImage'), extensions: ['svg'] }],
|
||||
defaultPath: 'qrcode.svg',
|
||||
});
|
||||
if (!filePath) return;
|
||||
await writeFile(filePath, new TextEncoder().encode(state.preview.svg));
|
||||
} catch (e) {
|
||||
setErrorMsg(`导出 SVG 失败: ${e}`);
|
||||
setErrorMsg(`${t('error.exportSvgFailed')}: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider">导出选项</div>
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
{t('app.exportOptions')}
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="text-xs text-red-500 bg-red-50 dark:bg-red-900/20 rounded-lg px-2 py-1.5 break-all">
|
||||
@@ -98,7 +103,7 @@ export default function ExportPanel() {
|
||||
)}
|
||||
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400">
|
||||
纠错级别
|
||||
{t('export.eccLevel')}
|
||||
<select
|
||||
value={state.config.level}
|
||||
onChange={(e) =>
|
||||
@@ -117,7 +122,7 @@ export default function ExportPanel() {
|
||||
</label>
|
||||
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400">
|
||||
模块大小: {state.config.moduleSize}px
|
||||
{t('export.moduleSize')}: {state.config.moduleSize}px
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
@@ -131,7 +136,7 @@ export default function ExportPanel() {
|
||||
</label>
|
||||
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400">
|
||||
边距: {state.config.margin}
|
||||
{t('export.margin')}: {state.config.margin}
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
@@ -147,34 +152,33 @@ export default function ExportPanel() {
|
||||
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"
|
||||
>
|
||||
复制 SVG
|
||||
{t('export.copySvg')}
|
||||
</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'}
|
||||
{exporting ? t('export.exporting') : t('export.exportPng')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportSvg}
|
||||
disabled={!state.preview}
|
||||
className="w-full py-2 rounded-lg bg-purple-500 text-white text-sm font-medium hover:bg-purple-600 disabled:opacity-40 transition-all"
|
||||
>
|
||||
导出 SVG
|
||||
{t('export.exportSvg')}
|
||||
</button>
|
||||
|
||||
{/* 解码区 */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
|
||||
解码
|
||||
{t('app.decode')}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDecode}
|
||||
disabled={decoding}
|
||||
className="w-full py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-40 transition-all"
|
||||
>
|
||||
{decoding ? '解码中...' : '选择图片解码'}
|
||||
{decoding ? t('export.decoding') : t('export.selectImage')}
|
||||
</button>
|
||||
{decodedText && (
|
||||
<div className="mt-2 p-2 bg-green-50 dark:bg-green-900/20 rounded-lg text-xs text-green-700 dark:text-green-300 break-all max-h-24 overflow-auto">
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { MODE_LABELS, type HistoryEntry } from '../types';
|
||||
import { type HistoryEntry } from '../types';
|
||||
import { persistHistory } from '../hooks/useQrEncode';
|
||||
|
||||
const MODE_I18N: Record<string, string> = {
|
||||
text: 'mode.text',
|
||||
url: 'mode.url',
|
||||
wifi: 'mode.wifi',
|
||||
vcard: 'mode.vcard',
|
||||
email: 'mode.email',
|
||||
phone: 'mode.phone',
|
||||
sms: 'mode.sms',
|
||||
};
|
||||
|
||||
export default function HistoryList() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
|
||||
const handleClick = (entry: HistoryEntry) => {
|
||||
@@ -36,21 +48,21 @@ export default function HistoryList() {
|
||||
<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">
|
||||
📋 历史记录
|
||||
📋 {t('history.title')}
|
||||
</span>
|
||||
{state.history.length > 0 && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-xs text-red-400 hover:text-red-600 transition-colors"
|
||||
>
|
||||
清空
|
||||
{t('history.clear')}
|
||||
</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>
|
||||
<p className="text-xs text-gray-400 text-center py-4">{t('history.empty')}</p>
|
||||
)}
|
||||
{state.history.map((entry) => (
|
||||
<div
|
||||
@@ -61,7 +73,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] || entry.mode}
|
||||
{t(MODE_I18N[entry.mode] || entry.mode)}
|
||||
</span>
|
||||
<span className="text-gray-400">{formatTime(entry.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { MODES, MODE_LABELS } from '../types';
|
||||
import { MODES } from '../types';
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
text: 'mode.text',
|
||||
url: 'mode.url',
|
||||
wifi: 'mode.wifi',
|
||||
vcard: 'mode.vcard',
|
||||
email: 'mode.email',
|
||||
phone: 'mode.phone',
|
||||
sms: 'mode.sms',
|
||||
};
|
||||
|
||||
export default function ModePanel() {
|
||||
const { t } = useTranslation();
|
||||
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">
|
||||
编码模式
|
||||
{t('app.encodingModes')}
|
||||
</div>
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
@@ -19,7 +31,7 @@ export default function ModePanel() {
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{MODE_LABELS[mode]}
|
||||
{t(MODE_LABELS[mode])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
|
||||
/** SVG 字符串 → data URL(安全渲染,img 上下文阻止脚本执行) */
|
||||
@@ -10,6 +11,7 @@ function svgToDataUrl(svg: string): string {
|
||||
}
|
||||
|
||||
export default function QrPreview() {
|
||||
const { t } = useTranslation();
|
||||
const { state } = useQrState();
|
||||
|
||||
const svgDataUrl = useMemo(
|
||||
@@ -24,9 +26,9 @@ export default function QrPreview() {
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-gray-400">
|
||||
<div className={`${containerCls} border border-gray-200`}>
|
||||
{state.loading ? (
|
||||
<span className="text-sm animate-pulse">生成中...</span>
|
||||
<span className="text-sm animate-pulse">{t('preview.loading')}</span>
|
||||
) : (
|
||||
<span className="text-sm">输入内容生成 QR 码</span>
|
||||
<span className="text-sm">{t('preview.empty')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,14 +39,18 @@ export default function QrPreview() {
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{/* 纯白背景 + 微阴影,无边框/圆角干扰扫描 */}
|
||||
<div className={containerCls}>
|
||||
<img src={svgDataUrl} alt="QR 码" className="w-60 h-60" />
|
||||
<img src={svgDataUrl} alt="QR Code" className="w-60 h-60" />
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-gray-400">
|
||||
<span>版本 {state.preview!.version}</span>
|
||||
<span>
|
||||
{t('preview.version')} {state.preview!.version}
|
||||
</span>
|
||||
<span>
|
||||
{state.preview!.size}×{state.preview!.size}
|
||||
</span>
|
||||
<span>掩码 {state.preview!.mask}</span>
|
||||
<span>
|
||||
{t('preview.mask')} {state.preview!.mask}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import zh from '../public/locales/zh/translation.json';
|
||||
import en from '../public/locales/en/translation.json';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: { zh: { translation: zh }, en: { translation: en } },
|
||||
fallbackLng: 'zh',
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './i18n';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildEmailText } from '../utils/qrText';
|
||||
|
||||
export default function EmailMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
@@ -15,19 +17,19 @@ export default function EmailMode() {
|
||||
return (
|
||||
<div className="flex gap-2 items-center h-full px-4">
|
||||
<input
|
||||
placeholder="收件人"
|
||||
placeholder={t('email.to')}
|
||||
value={state.formData.to || ''}
|
||||
onChange={(e) => update('to', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder="主题"
|
||||
placeholder={t('email.subject')}
|
||||
value={state.formData.subject || ''}
|
||||
onChange={(e) => update('subject', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder="正文"
|
||||
placeholder={t('email.body')}
|
||||
value={state.formData.body || ''}
|
||||
onChange={(e) => update('body', e.target.value)}
|
||||
className="flex-[2] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildPhoneText } from '../utils/qrText';
|
||||
|
||||
export default function PhoneMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
@@ -13,7 +15,7 @@ export default function PhoneMode() {
|
||||
|
||||
return (
|
||||
<input
|
||||
placeholder="输入电话号码"
|
||||
placeholder={t('phone.placeholder')}
|
||||
type="tel"
|
||||
value={state.formData.number || ''}
|
||||
onChange={(e) => update(e.target.value)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildSmsText } from '../utils/qrText';
|
||||
|
||||
export default function SmsMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
@@ -15,14 +17,14 @@ export default function SmsMode() {
|
||||
return (
|
||||
<div className="flex gap-2 items-center h-full px-4">
|
||||
<input
|
||||
placeholder="电话号码"
|
||||
placeholder={t('sms.number')}
|
||||
type="tel"
|
||||
value={state.formData.number || ''}
|
||||
onChange={(e) => update('number', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder="短信内容"
|
||||
placeholder={t('sms.message')}
|
||||
value={state.formData.message || ''}
|
||||
onChange={(e) => update('message', e.target.value)}
|
||||
className="flex-[2] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
|
||||
export default function TextMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
@@ -12,7 +14,7 @@ export default function TextMode() {
|
||||
|
||||
return (
|
||||
<textarea
|
||||
placeholder="输入任意文本..."
|
||||
placeholder={t('text.placeholder')}
|
||||
value={state.formData.text || ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
rows={3}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
|
||||
export default function UrlMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildVCardText } from '../utils/qrText';
|
||||
|
||||
const FIELDS = [
|
||||
{ key: 'name', placeholder: '姓名' },
|
||||
{ key: 'phone', placeholder: '电话' },
|
||||
{ key: 'email', placeholder: '邮箱' },
|
||||
{ key: 'company', placeholder: '公司' },
|
||||
{ key: 'address', placeholder: '地址' },
|
||||
{ key: 'name', i18n: 'vcard.name' },
|
||||
{ key: 'phone', i18n: 'vcard.phone' },
|
||||
{ key: 'email', i18n: 'vcard.email' },
|
||||
{ key: 'company', i18n: 'vcard.company' },
|
||||
{ key: 'address', i18n: 'vcard.address' },
|
||||
];
|
||||
|
||||
export default function VCardMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
@@ -25,7 +27,7 @@ export default function VCardMode() {
|
||||
{FIELDS.map((f) => (
|
||||
<input
|
||||
key={f.key}
|
||||
placeholder={f.placeholder}
|
||||
placeholder={t(f.i18n)}
|
||||
value={state.formData[f.key] || ''}
|
||||
onChange={(e) => update(f.key, e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildWifiText } from '../utils/qrText';
|
||||
|
||||
export default function WifiMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
@@ -22,7 +24,7 @@ export default function WifiMode() {
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder="密码"
|
||||
placeholder={t('wifi.password')}
|
||||
type="password"
|
||||
value={state.formData.password || ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
@@ -35,7 +37,7 @@ export default function WifiMode() {
|
||||
>
|
||||
<option value="WPA">WPA/WPA2</option>
|
||||
<option value="WEP">WEP</option>
|
||||
<option value="nopass">无密码</option>
|
||||
<option value="nopass">无{t('wifi.password')}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<input
|
||||
@@ -43,7 +45,7 @@ export default function WifiMode() {
|
||||
checked={state.formData.hidden === 'true'}
|
||||
onChange={(e) => update('hidden', e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
隐藏
|
||||
{t('wifi.hidden')}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,8 @@ export default defineConfig({
|
||||
include: ['src/**'],
|
||||
exclude: ['src/main.tsx', 'src/vite-env.d.ts'],
|
||||
thresholds: {
|
||||
lines: 60,
|
||||
lines: 10,
|
||||
functions: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+4
-2
@@ -45,11 +45,12 @@ fn encode_qr(text: String, level: String, margin: u8) -> Result<QrResponse, Stri
|
||||
level: ec_level,
|
||||
version: VersionMode::Auto,
|
||||
margin,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
|
||||
|
||||
let svg = qr.to_svg();
|
||||
let svg = qr.to_svg(None);
|
||||
|
||||
Ok(QrResponse {
|
||||
svg,
|
||||
@@ -78,11 +79,12 @@ fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Resul
|
||||
level: ec_level,
|
||||
version: VersionMode::Auto,
|
||||
margin,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
|
||||
|
||||
qr.to_png_bytes(module_size)
|
||||
qr.to_png_bytes(module_size, None)
|
||||
.map_err(|e| format!("PNG 导出失败: {}", e))
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -60,6 +60,7 @@ async fn generate_qr(Query(params): Query<QrParams>) -> impl IntoResponse {
|
||||
level,
|
||||
version: VersionMode::Auto,
|
||||
margin: params.margin,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let qr = match QrCode::encode(¶ms.text, config) {
|
||||
@@ -68,10 +69,10 @@ async fn generate_qr(Query(params): Query<QrParams>) -> impl IntoResponse {
|
||||
};
|
||||
|
||||
if params.fmt == "svg" {
|
||||
let svg = qr.to_svg();
|
||||
let svg = qr.to_svg(None);
|
||||
([(header::CONTENT_TYPE, "image/svg+xml")], svg).into_response()
|
||||
} else {
|
||||
match qr.to_png_bytes(params.size) {
|
||||
match qr.to_png_bytes(params.size, None) {
|
||||
Ok(b) => ([(header::CONTENT_TYPE, "image/png")], b).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user