fix: QR 扫描失败 + GUI 导出失败 — PNG margin + separator + fs 插件

🔴 QR 扫描失败根因 (2项):
- render/png: saturating_sub 导致 margin 区域映射到 finder 黑角,
  quiet zone 全黑,扫描器无法定位 QR
- matrix/patterns: 缺少右上 finder 左侧 + 左下 finder 顶部
  隔离带预留,数据模块破坏 finder 检测比率(1:1:3:1:1)

🔴 GUI 导出失败 (2项):
- gui/Cargo.toml + gui/lib.rs: 注册 tauri-plugin-fs 后端插件
  (前端 writeFile 调用缺少 Rust handler)
- capabilities: fs:allow-write-file + $HOME/** 路径 scope
  (ACL 默认不给 fs 写权限,需显式声明)

🔧 其他:
- ExportPanel: 导出失败显示红色错误信息(替代静默吞错)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 11:21:27 +08:00
parent 1e9c94eff9
commit 05b1714628
14 changed files with 7210 additions and 16 deletions
+2
View File
@@ -1,6 +1,8 @@
/.claude
/.codegraph
/target
/test
/.vscode
*.swp
*.swo
*~
+2
View File
@@ -3,6 +3,7 @@
## 0.1.0 (2026-06-17)
### Added
- **核心算法**:完整实现 ISO/IEC 18004 QR 码生成
- GF(2⁸) Galois 域运算(预计算 exp/log 表,0x11D 本原多项式)
- Reed-Solomon 纠错编码(动态生成多项式 + 多项式长除法 + 数据交错)
@@ -29,6 +30,7 @@
- 完整 40 版本 × 四级纠错支持
### Technical
- Cargo workspace 三层架构 (core + cli + gui)
- qr-coreSerde 序列化支持(跨 IPC 传输)
- GUIReact Context + useReducer 状态管理
Generated
+1
View File
@@ -2597,6 +2597,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-store",
]
+13 -3
View File
@@ -24,13 +24,23 @@ pub fn place_finder_patterns(matrix: &mut Matrix) {
matrix.reserve(x, y);
}
}
// 定位图案分隔符(1 模块宽的白色边框,在 finder 周
// 定位图案分隔符(1 模块宽的白色边框,在 finder 周)
for i in 0..8u8 {
// 右侧分隔列(finder 右边缘外侧)
if fx + 7 < matrix.size {
matrix.reserve(fx + 7, fy + i); // 右侧分隔列
matrix.reserve(fx + 7, fy + i);
}
// 底部隔行(finder 下边缘外侧)
if fy + 7 < matrix.size {
matrix.reserve(fx + i, fy + 7); // 底部隔行
matrix.reserve(fx + i, fy + 7);
}
// 左侧分隔列(finder 左边缘外侧,非左边界时有效)
if fx > 0 {
matrix.reserve(fx - 1, fy + i);
}
// 顶部隔行(finder 上边缘外侧,非上边界时有效)
if fy > 0 {
matrix.reserve(fx + i, fy - 1);
}
}
}
+10 -6
View File
@@ -29,13 +29,17 @@ 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 {
let module_x = x.saturating_sub(margin);
let module_y = y.saturating_sub(margin);
let is_dark = if module_x < matrix_size && module_y < matrix_size {
qr.modules()[module_y as usize][module_x as usize]
// 直接比较坐标与 margin 边界,避免 saturating_sub 在边界处回绕到 0
let is_dark = if x >= margin
&& x < margin + matrix_size
&& y >= margin
&& y < margin + matrix_size
{
let mx = (x - margin) as usize;
let my = (y - margin) as usize;
qr.modules()[my][mx]
} else {
false // 白边
false // 白边 (quiet zone)
};
fill_module(&mut img, x, y, module_size as u32, is_dark);
+1
View File
@@ -20,3 +20,4 @@ tauri = { version = "2", features = [] }
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-fs = "2"
+6 -2
View File
@@ -1,12 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "QRGen 默认权限 — 限制前端 IPC 和平台 API 访问",
"description": "QRGen 默认权限",
"windows": ["main"],
"permissions": [
"core:default",
"store:default",
"dialog:default",
"clipboard-manager:default"
"clipboard-manager:default",
{
"identifier": "fs:allow-write-file",
"allow": [{ "path": "$HOME/**" }]
}
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{}
{"default":{"identifier":"default","description":"QRGen 默认权限","local":true,"windows":["main"],"permissions":["core:default","store:default","dialog:default","clipboard-manager:default",{"identifier":"fs:allow-write-file","allow":[{"path":"$HOME/**"}]}]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: false
@@ -10,17 +10,21 @@ import { buildEncodedText } from '../utils/qrText';
export default function ExportPanel() {
const { state, dispatch } = useQrState();
const [exporting, setExporting] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const handleCopySvg = async () => {
if (!state.preview?.svg) return;
try {
await writeText(state.preview.svg);
} catch { /* 剪贴板不可用时静默忽略 */ }
} catch (e) {
setErrorMsg(`复制失败: ${e}`);
}
};
const handleExportPng = async () => {
if (!state.preview?.svg) return;
setExporting(true);
setErrorMsg(null);
try {
const filePath = await save({
filters: [{ name: 'PNG 图片', extensions: ['png'] }],
@@ -35,12 +39,15 @@ export default function ExportPanel() {
moduleSize: state.config.moduleSize,
});
await writeFile(filePath, new Uint8Array(bytes));
} catch { /* 导出失败时静默处理,UI 回到就绪状态 */ }
} catch (e) {
setErrorMsg(`导出 PNG 失败: ${e}`);
}
setExporting(false);
};
const handleExportSvg = async () => {
if (!state.preview?.svg) return;
setErrorMsg(null);
try {
const filePath = await save({
filters: [{ name: 'SVG 图片', extensions: ['svg'] }],
@@ -48,13 +55,21 @@ export default function ExportPanel() {
});
if (!filePath) return;
await writeFile(filePath, new TextEncoder().encode(state.preview.svg));
} catch { /* 导出失败时静默处理 */ }
} catch (e) {
setErrorMsg(`导出 SVG 失败: ${e}`);
}
};
return (
<div className="flex flex-col gap-2">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider"></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">
{errorMsg}
</div>
)}
<label className="text-xs text-gray-600 dark:text-gray-400">
<select
+1
View File
@@ -118,6 +118,7 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_fs::init())
.manage(AppState {
history: Mutex::new(Vec::new()),
})