- {MODE_LABELS[entry.mode as keyof typeof MODE_LABELS] || entry.mode}
+ {MODE_LABELS[entry.mode] || entry.mode}
{formatTime(entry.timestamp)}
diff --git a/gui/src-frontend/src/components/QrPreview.tsx b/gui/src-frontend/src/components/QrPreview.tsx
index 7e48be2..36360f2 100644
--- a/gui/src-frontend/src/components/QrPreview.tsx
+++ b/gui/src-frontend/src/components/QrPreview.tsx
@@ -1,13 +1,29 @@
+import { useMemo } from 'react';
import { useQrState } from '../store/qrContext';
+/** 将 SVG 字符串转为安全的 data URL(
![]()
标签中浏览器会阻止 SVG 内的脚本执行) */
+function svgToDataUrl(svg: string): string {
+ const encoded = btoa(unescape(encodeURIComponent(svg)));
+ return `data:image/svg+xml;base64,${encoded}`;
+}
+
export default function QrPreview() {
const { state } = useQrState();
- if (!state.preview?.svg) {
+ const svgDataUrl = useMemo(
+ () => (state.preview?.svg ? svgToDataUrl(state.preview.svg) : null),
+ [state.preview?.svg],
+ );
+
+ if (!svgDataUrl) {
return (
- 输入内容生成 QR 码
+ {state.loading ? (
+ 生成中...
+ ) : (
+ 输入内容生成 QR 码
+ )}
);
@@ -15,15 +31,16 @@ export default function QrPreview() {
return (
- {/* SVG 由 Rust 端 qr-core 生成,仅含
和固定颜色,无用户文本嵌入 */}
- 渲染,浏览器在 img 上下文中阻止脚本执行 */}
+
- 版本: {state.preview.version}
- {state.preview.size}×{state.preview.size}
- 掩码: {state.preview.mask}
+ 版本: {state.preview!.version}
+ {state.preview!.size}×{state.preview!.size}
+ 掩码: {state.preview!.mask}
);
diff --git a/gui/src-frontend/src/hooks/useQrEncode.ts b/gui/src-frontend/src/hooks/useQrEncode.ts
index f460152..ab9a4c9 100644
--- a/gui/src-frontend/src/hooks/useQrEncode.ts
+++ b/gui/src-frontend/src/hooks/useQrEncode.ts
@@ -2,9 +2,19 @@ import { useCallback, useRef, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Store } from '@tauri-apps/plugin-store';
import { useQrState } from '../store/qrContext';
-import type { HistoryEntry } from '../types';
+import type { HistoryEntry, ModeType } from '../types';
const HISTORY_KEY = 'qr-history';
+const STORE_FILE = 'history.json';
+
+/** 缓存的 Store 实例,避免每次编码都重新加载 */
+let storeCache: Promise | null = null;
+function getStore(): Promise {
+ if (!storeCache) {
+ storeCache = Store.load(STORE_FILE);
+ }
+ return storeCache;
+}
interface QrResponse {
svg: string;
@@ -13,6 +23,36 @@ interface QrResponse {
mask: number;
}
+/** 对 WiFi 密码进行脱敏处理 */
+function sanitizeContent(mode: ModeType, content: string): string {
+ if (mode === 'wifi') {
+ return content.replace(/P:[^;]*/, 'P:***');
+ }
+ return content;
+}
+
+/**
+ * 持久化整个历史列表到 store
+ * 作为内存状态的唯一持久化出口
+ */
+export async function persistHistory(history: HistoryEntry[]): Promise {
+ try {
+ const store = await getStore();
+ await store.set(HISTORY_KEY, history);
+ await store.save();
+ } catch { /* store 不可用时静默忽略 */ }
+}
+
+/** 从 store 加载历史记录(应用启动时调用) */
+export async function loadHistory(): Promise {
+ try {
+ const store = await getStore();
+ return (await store.get(HISTORY_KEY)) || [];
+ } catch {
+ return [];
+ }
+}
+
export function useQrEncode() {
const { state, dispatch } = useQrState();
const timerRef = useRef | null>(null);
@@ -46,28 +86,26 @@ export function useQrEncode() {
// 保存到历史(内存 + 持久化)
const entryId = Date.now().toString();
+ const currentMode = modeRef.current;
const entry: HistoryEntry = {
id: entryId,
- mode: modeRef.current,
- content: text,
+ mode: currentMode,
+ content: sanitizeContent(currentMode, text),
timestamp: Date.now(),
+ formData: { ...state.formData },
};
dispatch({ type: 'ADD_HISTORY', payload: entry });
- // 持久化到 tauri-plugin-store
- try {
- const store = await Store.load('history.json');
- const current = await store.get(HISTORY_KEY) || [];
- const updated = [entry, ...current].slice(0, 50);
- await store.set(HISTORY_KEY, updated);
- await store.save();
- } catch { /* store 不可用时静默忽略 */ }
- } catch (e) {
- // 编码失败已在 dispatch SET_PREVIEW(null) 中处理,无需额外日志
+ // 从内存状态持久化(避免 store 读写竞态)
+ // 注意: dispatch ADD_HISTORY 是异步的,这里手动计算最新列表
+ // 确保持久化的数据与内存一致
+ persistHistory([entry, ...state.history].slice(0, 50));
+ } catch {
+ // 编码失败时清空预览
dispatch({ type: 'SET_PREVIEW', payload: null });
}
}, 200);
- }, [state.config.level, state.config.margin, dispatch]);
+ }, [state.config.level, state.config.margin, state.formData, state.history, dispatch]);
- return { encode };
+ return { encode, persistHistory };
}
diff --git a/gui/src-frontend/src/modes/EmailMode.tsx b/gui/src-frontend/src/modes/EmailMode.tsx
index ef66f88..b4991eb 100644
--- a/gui/src-frontend/src/modes/EmailMode.tsx
+++ b/gui/src-frontend/src/modes/EmailMode.tsx
@@ -1,5 +1,6 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
+import { buildEmailText } from '../utils/qrText';
export default function EmailMode() {
const { state, dispatch } = useQrState();
@@ -8,8 +9,7 @@ export default function EmailMode() {
const update = (field: string, value: string) => {
const data = { ...state.formData, [field]: value };
dispatch({ type: 'SET_FORM_DATA', payload: data });
- const mailto = `mailto:${data.to || ''}?subject=${encodeURIComponent(data.subject || '')}&body=${encodeURIComponent(data.body || '')}`;
- encode(mailto);
+ encode(buildEmailText(data));
};
return (
diff --git a/gui/src-frontend/src/modes/PhoneMode.tsx b/gui/src-frontend/src/modes/PhoneMode.tsx
index 91dca0a..cf0f76b 100644
--- a/gui/src-frontend/src/modes/PhoneMode.tsx
+++ b/gui/src-frontend/src/modes/PhoneMode.tsx
@@ -1,5 +1,6 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
+import { buildPhoneText } from '../utils/qrText';
export default function PhoneMode() {
const { state, dispatch } = useQrState();
@@ -7,7 +8,7 @@ export default function PhoneMode() {
const update = (number: string) => {
dispatch({ type: 'SET_FORM_DATA', payload: { number } });
- encode(`tel:${number}`);
+ encode(buildPhoneText({ number }));
};
return (
diff --git a/gui/src-frontend/src/modes/SmsMode.tsx b/gui/src-frontend/src/modes/SmsMode.tsx
index 9775c0f..134e875 100644
--- a/gui/src-frontend/src/modes/SmsMode.tsx
+++ b/gui/src-frontend/src/modes/SmsMode.tsx
@@ -1,5 +1,6 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
+import { buildSmsText } from '../utils/qrText';
export default function SmsMode() {
const { state, dispatch } = useQrState();
@@ -8,7 +9,7 @@ export default function SmsMode() {
const update = (field: string, value: string) => {
const data = { ...state.formData, [field]: value };
dispatch({ type: 'SET_FORM_DATA', payload: data });
- encode(`smsto:${data.number || ''}:${data.message || ''}`);
+ encode(buildSmsText(data));
};
return (
diff --git a/gui/src-frontend/src/modes/VCardMode.tsx b/gui/src-frontend/src/modes/VCardMode.tsx
index e09ab14..b915c72 100644
--- a/gui/src-frontend/src/modes/VCardMode.tsx
+++ b/gui/src-frontend/src/modes/VCardMode.tsx
@@ -1,5 +1,6 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
+import { buildVCardText } from '../utils/qrText';
const FIELDS = [
{ key: 'name', placeholder: '姓名' },
@@ -16,8 +17,7 @@ export default function VCardMode() {
const update = (field: string, value: string) => {
const data = { ...state.formData, [field]: value };
dispatch({ type: 'SET_FORM_DATA', payload: data });
- const vcard = `BEGIN:VCARD\nVERSION:3.0\nFN:${data.name || ''}\nTEL:${data.phone || ''}\nEMAIL:${data.email || ''}\nORG:${data.company || ''}\nADR:${data.address || ''}\nEND:VCARD`;
- encode(vcard);
+ encode(buildVCardText(data));
};
return (
diff --git a/gui/src-frontend/src/modes/WifiMode.tsx b/gui/src-frontend/src/modes/WifiMode.tsx
index 82efda5..6a027e9 100644
--- a/gui/src-frontend/src/modes/WifiMode.tsx
+++ b/gui/src-frontend/src/modes/WifiMode.tsx
@@ -1,22 +1,16 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
+import { buildWifiText } from '../utils/qrText';
export default function WifiMode() {
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
- const buildWifiText = (ssid: string, password: string, encryption: string, hidden: boolean) => {
- if (!ssid) return '';
- return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden ? 'H:true;' : ''};`;
- };
-
- const update = (field: string, value: string | boolean) => {
- const data = { ...state.formData, [field]: String(value) };
+ /** checkbox 的 boolean 值统一转为 'true'/'false' 字符串存入 formData */
+ const update = (field: string, value: string) => {
+ const data = { ...state.formData, [field]: value };
dispatch({ type: 'SET_FORM_DATA', payload: data });
- const wifiText = buildWifiText(
- data.ssid || '', data.password || '', data.encryption || 'WPA', data.hidden === 'true'
- );
- encode(wifiText);
+ encode(buildWifiText(data));
};
return (
@@ -36,7 +30,7 @@ export default function WifiMode() {
diff --git a/gui/src-frontend/src/store/qrContext.tsx b/gui/src-frontend/src/store/qrContext.tsx
index d8a035f..d79156e 100644
--- a/gui/src-frontend/src/store/qrContext.tsx
+++ b/gui/src-frontend/src/store/qrContext.tsx
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useReducer, useEffect, type ReactNode } from 'react';
-import { Store } from '@tauri-apps/plugin-store';
-import type { QrState, QrAction, HistoryEntry } from '../types';
+import { loadHistory } from '../hooks/useQrEncode';
+import type { QrState, QrAction } from '../types';
const initialState: QrState = {
mode: 'text',
@@ -47,11 +47,8 @@ export function QrProvider({ children }: { children: ReactNode }) {
// 启动时从 store 加载持久化的历史记录
useEffect(() => {
(async () => {
- try {
- const store = await Store.load('history.json');
- const history = await store.get
('qr-history') || [];
- dispatch({ type: 'SET_HISTORY', payload: history });
- } catch { /* store 不可用时忽略 */ }
+ const history = await loadHistory();
+ dispatch({ type: 'SET_HISTORY', payload: history });
})();
}, []);
diff --git a/gui/src-frontend/src/types/index.ts b/gui/src-frontend/src/types/index.ts
index 6aa0df9..c6a4069 100644
--- a/gui/src-frontend/src/types/index.ts
+++ b/gui/src-frontend/src/types/index.ts
@@ -15,9 +15,11 @@ export interface QrPreview {
export interface HistoryEntry {
id: string;
- mode: string;
+ mode: ModeType;
content: string;
timestamp: number;
+ /** 原始表单数据,用于恢复历史记录时回填各模式字段 */
+ formData?: Record;
}
export interface QrState {
diff --git a/gui/src-frontend/src/utils/qrText.ts b/gui/src-frontend/src/utils/qrText.ts
new file mode 100644
index 0000000..ffd80f3
--- /dev/null
+++ b/gui/src-frontend/src/utils/qrText.ts
@@ -0,0 +1,56 @@
+/**
+ * QR 编码文本构造工具
+ * 集中管理各模式的文本格式,避免 ExportPanel 和各 mode 组件间的重复逻辑
+ */
+
+/** 构造 WiFi 连接字符串 */
+export function buildWifiText(formData: Record): string {
+ const ssid = formData.ssid || '';
+ if (!ssid) return '';
+ const encryption = formData.encryption || 'WPA';
+ const password = formData.password || '';
+ // hidden 存储为字符串 'true'/'false',保留 boolean 语义
+ const hidden = formData.hidden === 'true' ? 'H:true;' : '';
+ return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden};`;
+}
+
+/** 构造 vCard 字符串 */
+export function buildVCardText(formData: Record): string {
+ const name = formData.name || '';
+ const phone = formData.phone || '';
+ const email = formData.email || '';
+ const company = formData.company || '';
+ const address = formData.address || '';
+ return `BEGIN:VCARD\nVERSION:3.0\nFN:${name}\nTEL:${phone}\nEMAIL:${email}\nORG:${company}\nADR:${address}\nEND:VCARD`;
+}
+
+/** 构造 mailto 链接 */
+export function buildEmailText(formData: Record): string {
+ const to = formData.to || '';
+ const subject = encodeURIComponent(formData.subject || '');
+ const body = encodeURIComponent(formData.body || '');
+ return `mailto:${to}?subject=${subject}&body=${body}`;
+}
+
+/** 构造电话链接 */
+export function buildPhoneText(formData: Record): string {
+ return `tel:${formData.number || ''}`;
+}
+
+/** 构造短信链接 */
+export function buildSmsText(formData: Record): string {
+ return `smsto:${formData.number || ''}:${formData.message || ''}`;
+}
+
+/** 从完整 formData 构造当前模式的编码文本(供 ExportPanel 使用) */
+export function buildEncodedText(mode: string, formData: Record): string {
+ switch (mode) {
+ case 'url': return formData.url || '';
+ case 'wifi': return buildWifiText(formData);
+ case 'vcard': return buildVCardText(formData);
+ case 'email': return buildEmailText(formData);
+ case 'phone': return buildPhoneText(formData);
+ case 'sms': return buildSmsText(formData);
+ default: return formData.text || '';
+ }
+}
diff --git a/gui/src/lib.rs b/gui/src/lib.rs
index 4d11877..3d94d2d 100644
--- a/gui/src/lib.rs
+++ b/gui/src/lib.rs
@@ -82,7 +82,8 @@ fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Resul
let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?;
- qr.to_png_bytes(module_size).map_err(|e| format!("PNG 导出失败: {}", e))
+ qr.to_png_bytes(module_size)
+ .map_err(|e| format!("PNG 导出失败: {}", e))
}
/// 保存历史记录条目
diff --git a/gui/tauri.conf.json b/gui/tauri.conf.json
index 193b7f1..c819675 100644
--- a/gui/tauri.conf.json
+++ b/gui/tauri.conf.json
@@ -23,7 +23,7 @@
}
],
"security": {
- "csp": null
+ "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:"
}
},
"plugins": {},