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