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
+1 -1
View File
@@ -11,4 +11,4 @@ axum = { version = "0.8", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] }
tower-http = { version = "0.6", features = ["cors", "limit", "set-header"] }
+91 -31
View File
@@ -8,8 +8,28 @@ use axum::{
use qr_core::qr::{QrCode, QrConfig, VersionMode};
use qr_core::version::EcLevel;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use tower_http::{
cors::CorsLayer,
limit::RequestBodyLimitLayer,
set_header::SetResponseHeaderLayer,
};
/// GET /api/qr 查询参数
/// POST /api/qr JSON 请求体
#[derive(Deserialize)]
struct QrRequest {
text: String,
#[serde(default = "default_level")]
level: String,
#[serde(default = "default_margin")]
margin: u8,
#[serde(default = "default_size")]
size: u8,
#[serde(default)]
fmt: String,
}
/// GET /api/qr 查询参数(保留向后兼容)
#[derive(Deserialize)]
struct QrParams {
text: String,
@@ -19,7 +39,6 @@ struct QrParams {
margin: u8,
#[serde(default = "default_size")]
size: u8,
/// fmt=svg 返回 SVG,否则返回 PNG
#[serde(default)]
fmt: String,
}
@@ -34,52 +53,66 @@ fn default_size() -> u8 {
8
}
fn parse_level(s: &str) -> Result<EcLevel, String> {
match s.to_uppercase().as_str() {
"L" => Ok(EcLevel::L),
"M" => Ok(EcLevel::M),
"Q" => Ok(EcLevel::Q),
"H" => Ok(EcLevel::H),
_ => Err(format!("无效纠错级别: {},支持 L/M/Q/H", s)),
}
}
/// 首页 HTML(编译期嵌入)
async fn index() -> Html<&'static str> {
Html(include_str!("templates/index.html"))
}
/// QR 码生成 API → PNG 或 SVG
async fn generate_qr(Query(params): Query<QrParams>) -> impl IntoResponse {
let level = match parse_level(&params.level) {
/// 生成 QR 码的核心逻辑
fn generate_qr_inner(text: &str, level_str: &str, margin: u8, size: u8, fmt: &str) -> impl IntoResponse {
let level = match EcLevel::from_str(level_str) {
Ok(l) => l,
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
};
// 验证参数范围
if margin > 20 {
return (StatusCode::BAD_REQUEST, "边距过大(最大 20").into_response();
}
if size < 1 || size > 20 {
return (StatusCode::BAD_REQUEST, "模块大小需在 1-20 之间").into_response();
}
let config = QrConfig {
level,
version: VersionMode::Auto,
margin: params.margin,
margin,
..Default::default()
};
let qr = match QrCode::encode(&params.text, config) {
let qr = match QrCode::encode(text, config) {
Ok(q) => q,
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
Err(_) => {
return (StatusCode::BAD_REQUEST, "QR 编码失败,请检查输入内容").into_response()
}
};
if params.fmt == "svg" {
let svg = qr.to_svg(None);
if fmt == "svg" {
let svg = qr.to_svg(None, None, None);
return ([(header::CONTENT_TYPE, "image/svg+xml")], svg).into_response();
}
let fmt = qr_core::render::image::OutputFormat::from_ext(&params.fmt)
let out_fmt = qr_core::render::image::OutputFormat::from_ext(fmt)
.unwrap_or(qr_core::render::image::OutputFormat::Png);
match qr.to_image_bytes(params.size, None, Some(fmt)) {
Ok(b) => ([(header::CONTENT_TYPE, fmt.mime())], b).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
match qr.to_image_bytes(size, None, Some(out_fmt), None, None) {
Ok(b) => ([(header::CONTENT_TYPE, out_fmt.mime())], b).into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"图片生成失败,请稍后重试",
)
.into_response(),
}
}
/// QR 码生成 API → PNG 或 SVG(GET 查询参数,向后兼容)
async fn generate_qr_get(Query(params): Query<QrParams>) -> impl IntoResponse {
generate_qr_inner(&params.text, &params.level, params.margin, params.size, &params.fmt)
}
/// QR 码生成 API → PNG 或 SVGPOST JSON 请求体,推荐)
async fn generate_qr_post(axum::extract::Json(req): axum::extract::Json<QrRequest>) -> impl IntoResponse {
generate_qr_inner(&req.text, &req.level, req.margin, req.size, &req.fmt)
}
/// 解码结果 JSON 响应
#[derive(Serialize)]
struct DecodeResponse {
@@ -109,25 +142,52 @@ async fn decode_qr(mut multipart: Multipart) -> impl IntoResponse {
)
.into_response();
}
Err(e) => {
return (StatusCode::BAD_REQUEST, e).into_response();
Err(_) => {
return (
StatusCode::BAD_REQUEST,
"解码失败:图片中未检测到有效的 QR 码",
)
.into_response();
}
},
Err(e) => {
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
Err(_) => {
return (
StatusCode::BAD_REQUEST,
"文件上传失败,请重试",
)
.into_response();
}
}
}
}
(StatusCode::BAD_REQUEST, "未找到上传文件(字段名: file").into_response()
(
StatusCode::BAD_REQUEST,
"未找到上传文件(表单字段名应为 'file'",
)
.into_response()
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(index))
.route("/api/qr", get(generate_qr))
.route("/api/decode", post(decode_qr));
.route("/api/qr", get(generate_qr_get).post(generate_qr_post))
.route("/api/decode", post(decode_qr))
// 安全层
.layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)) // 10 MB
.layer(CorsLayer::permissive())
.layer(SetResponseHeaderLayer::if_not_present(
header::X_CONTENT_TYPE_OPTIONS,
header::HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::X_FRAME_OPTIONS,
header::HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::REFERRER_POLICY,
header::HeaderValue::from_static("strict-origin-when-cross-origin"),
));
let addr = "0.0.0.0:3000";
println!("QRGen Web → http://{}", addr);
+70 -27
View File
@@ -167,7 +167,22 @@ function scheduleUpdate() {
timer = setTimeout(doUpdate, 200);
}
function doUpdate() {
// 当前 QR 文本(避免在 URL 中泄露敏感数据)
let currentQrText = '';
// 缓存的预览 blob URL
let previewBlobUrl = '';
async function fetchQrBlob(text, size) {
const resp = await fetch('/api/qr', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, level: cfg.level, margin: cfg.margin, size, fmt: 'png' })
});
if (!resp.ok) throw new Error('生成失败');
return URL.createObjectURL(await resp.blob());
}
async function doUpdate() {
const text = buildQrText();
if (!text) {
document.getElementById('previewLoading').style.display = 'none';
@@ -176,29 +191,32 @@ function doUpdate() {
document.getElementById('btnCopy').disabled = true;
document.getElementById('btnPng').disabled = true;
document.getElementById('btnSvg').disabled = true;
currentPngUrl = '';
if (previewBlobUrl) { URL.revokeObjectURL(previewBlobUrl); previewBlobUrl = ''; }
currentQrText = '';
return;
}
currentQrText = text;
// 预览用 PNG(size=8, 清晰不拉伸),下载用用户选择的 size
const previewUrl = `/api/qr?text=${encodeURIComponent(text)}&level=${cfg.level}&margin=${cfg.margin}&size=8`;
currentPngUrl = `/api/qr?text=${encodeURIComponent(text)}&level=${cfg.level}&margin=${cfg.margin}&size=${cfg.size}`;
const img = document.getElementById('previewImg');
img.onload = () => {
document.getElementById('previewLoading').style.display = 'none';
img.style.display = 'block';
};
img.onerror = () => {
try {
// 预览用 size=8(清晰不拉伸)
previewBlobUrl = await fetchQrBlob(text, 8);
const img = document.getElementById('previewImg');
img.onload = () => {
document.getElementById('previewLoading').style.display = 'none';
img.style.display = 'block';
};
img.src = previewBlobUrl;
document.getElementById('previewInfo').textContent = `${cfg.size}px/模块 · ${cfg.margin}px边距 · ${cfg.level}`;
document.getElementById('btnCopy').disabled = false;
document.getElementById('btnPng').disabled = false;
document.getElementById('btnSvg').disabled = false;
} catch {
document.getElementById('previewLoading').style.display = 'none';
document.getElementById('previewPlaceholder').style.display = 'block';
document.getElementById('previewPlaceholder').textContent = '编码失败';
};
img.src = previewUrl;
document.getElementById('previewInfo').textContent = `${cfg.size}px/模块 · ${cfg.margin}px边距 · ${cfg.level}`;
document.getElementById('btnCopy').disabled = false;
document.getElementById('btnPng').disabled = false;
document.getElementById('btnSvg').disabled = false;
document.getElementById('previewInfo').textContent = '';
currentQrText = '';
}
}
// ── 事件绑定 ──
@@ -239,23 +257,48 @@ document.querySelectorAll('.sidebar-l button').forEach(btn => {
});
// ── 导出按钮 ──
document.getElementById('btnPng').onclick = () => {
if (currentPngUrl) { const a = document.createElement('a'); a.href = currentPngUrl; a.download = 'qrcode.png'; a.click(); }
document.getElementById('btnPng').onclick = async () => {
if (!currentQrText) return;
try {
const blob = await fetchQrBlob(currentQrText, cfg.size);
const a = document.createElement('a'); a.href = blob; a.download = 'qrcode.png'; a.click();
} catch(e) {
const err = document.getElementById('errorBox');
err.innerHTML = '<div class="error">导出 PNG 失败</div>';
setTimeout(()=>err.innerHTML='',2000);
}
};
document.getElementById('btnSvg').onclick = () => {
const text = buildQrText();
if (!text) return;
const url = `/api/qr?text=${encodeURIComponent(text)}&level=${cfg.level}&margin=${cfg.margin}&size=1&fmt=svg`;
const a = document.createElement('a'); a.href = url; a.download = 'qrcode.svg'; a.click();
document.getElementById('btnSvg').onclick = async () => {
if (!currentQrText) return;
try {
const resp = await fetch('/api/qr', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: currentQrText, level: cfg.level, margin: cfg.margin, size: 1, fmt: 'svg' })
});
if (!resp.ok) throw new Error('失败');
const svg = await resp.text();
const blob = new Blob([svg], {type:'image/svg+xml'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'qrcode.svg'; a.click();
} catch(e) {
const err = document.getElementById('errorBox');
err.innerHTML = '<div class="error">导出 SVG 失败</div>';
setTimeout(()=>err.innerHTML='',2000);
}
};
document.getElementById('btnCopy').onclick = async () => {
try {
const resp = await fetch(`/api/qr?text=${encodeURIComponent(buildQrText())}&level=${cfg.level}&margin=${cfg.margin}&size=1&fmt=svg`);
const resp = await fetch('/api/qr', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: currentQrText, level: cfg.level, margin: cfg.margin, size: 1, fmt: 'svg' })
});
if (!resp.ok) throw new Error('失败');
const svg = await resp.text();
await navigator.clipboard.writeText(svg);
} catch(e) {
const err = document.getElementById('errorBox');
err.innerHTML = `<div class="error">复制失败</div>`;
err.innerHTML = '<div class="error">复制失败</div>';
setTimeout(()=>err.innerHTML='',2000);
}
};