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:
+1
-1
@@ -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
@@ -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(¶ms.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(¶ms.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(¶ms.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(¶ms.text, ¶ms.level, params.margin, params.size, ¶ms.fmt)
|
||||
}
|
||||
|
||||
/// QR 码生成 API → PNG 或 SVG(POST 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user