38be82973e
- PNG 渲染:RgbaImage 上使用 imageops::overlay 叠加 logo - SVG 渲染:base64 编码 logo 嵌入 <image> 标签 - QrCode::to_png_bytes / to_svg 新增 Option<logo_bytes> 参数 - Logo 默认占 QR 区域 25%,建议配合 H 级纠错使用
137 lines
3.9 KiB
Rust
137 lines
3.9 KiB
Rust
use axum::{
|
||
extract::{Multipart, Query},
|
||
http::{header, StatusCode},
|
||
response::{Html, IntoResponse, Json},
|
||
routing::{get, post},
|
||
Router,
|
||
};
|
||
use qr_core::qr::{QrCode, QrConfig, VersionMode};
|
||
use qr_core::version::EcLevel;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// GET /api/qr 查询参数
|
||
#[derive(Deserialize)]
|
||
struct QrParams {
|
||
text: String,
|
||
#[serde(default = "default_level")]
|
||
level: String,
|
||
#[serde(default = "default_margin")]
|
||
margin: u8,
|
||
#[serde(default = "default_size")]
|
||
size: u8,
|
||
/// fmt=svg 返回 SVG,否则返回 PNG
|
||
#[serde(default)]
|
||
fmt: String,
|
||
}
|
||
|
||
fn default_level() -> String {
|
||
"M".into()
|
||
}
|
||
fn default_margin() -> u8 {
|
||
4
|
||
}
|
||
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) {
|
||
Ok(l) => l,
|
||
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
|
||
};
|
||
|
||
let config = QrConfig {
|
||
level,
|
||
version: VersionMode::Auto,
|
||
margin: params.margin,
|
||
..Default::default()
|
||
};
|
||
|
||
let qr = match QrCode::encode(¶ms.text, config) {
|
||
Ok(q) => q,
|
||
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
|
||
};
|
||
|
||
if params.fmt == "svg" {
|
||
let svg = qr.to_svg(None);
|
||
([(header::CONTENT_TYPE, "image/svg+xml")], svg).into_response()
|
||
} else {
|
||
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(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 解码结果 JSON 响应
|
||
#[derive(Serialize)]
|
||
struct DecodeResponse {
|
||
text: String,
|
||
version: u8,
|
||
level: String,
|
||
mask: u8,
|
||
errors_corrected: usize,
|
||
}
|
||
|
||
/// POST /api/decode — 解码上传的 QR 码图片
|
||
async fn decode_qr(mut multipart: Multipart) -> impl IntoResponse {
|
||
while let Ok(Some(field)) = multipart.next_field().await {
|
||
if field.name() == Some("file") {
|
||
match field.bytes().await {
|
||
Ok(data) => match qr_core::decoder::decode_image(&data) {
|
||
Ok(result) => {
|
||
return (
|
||
StatusCode::OK,
|
||
Json(DecodeResponse {
|
||
text: result.text,
|
||
version: result.version,
|
||
level: format!("{:?}", result.level),
|
||
mask: result.mask,
|
||
errors_corrected: result.errors_corrected,
|
||
}),
|
||
)
|
||
.into_response();
|
||
}
|
||
Err(e) => {
|
||
return (StatusCode::BAD_REQUEST, e).into_response();
|
||
}
|
||
},
|
||
Err(e) => {
|
||
return (StatusCode::BAD_REQUEST, e.to_string()).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));
|
||
|
||
let addr = "0.0.0.0:3000";
|
||
println!("QRGen Web → http://{}", addr);
|
||
|
||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||
axum::serve(listener, app).await.unwrap();
|
||
}
|