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:
@@ -51,7 +51,7 @@ function AppLayout() {
|
||||
</div>
|
||||
|
||||
{/* 底部输入区 */}
|
||||
<div className="h-24 border-t border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl p-3">
|
||||
<div className="h-36 border-t border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl p-3">
|
||||
<BottomInput />
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +59,7 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
function BottomInput() {
|
||||
const { state } = useQrState();
|
||||
const state = useQrState();
|
||||
|
||||
switch (state.mode) {
|
||||
case 'text':
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { QrProvider, useQrState } from '../store/qrContext';
|
||||
import { QrProvider, useQrState, useQrDispatch } from '../store/qrContext';
|
||||
|
||||
describe('QrProvider + useQrState', () => {
|
||||
function useQr() {
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
return { state, dispatch };
|
||||
}
|
||||
|
||||
describe('QrProvider + split context', () => {
|
||||
it('provides default state', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
expect(result.current.state.mode).toBe('text');
|
||||
expect(result.current.state.config.level).toBe('M');
|
||||
expect(result.current.state.config.margin).toBe(4);
|
||||
@@ -23,7 +29,7 @@ describe('QrProvider + useQrState', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
act(() => result.current.dispatch({ type: 'SET_MODE', payload: 'wifi' }));
|
||||
expect(result.current.state.mode).toBe('wifi');
|
||||
});
|
||||
@@ -32,13 +38,8 @@ describe('QrProvider + useQrState', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_FORM_DATA',
|
||||
payload: { text: 'hello' },
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
act(() => result.current.dispatch({ type: 'SET_FORM_DATA', payload: { text: 'hello' } }));
|
||||
expect(result.current.state.formData.text).toBe('hello');
|
||||
});
|
||||
|
||||
@@ -46,13 +47,8 @@ describe('QrProvider + useQrState', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_CONFIG',
|
||||
payload: { level: 'H' },
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
act(() => result.current.dispatch({ type: 'SET_CONFIG', payload: { level: 'H' } }));
|
||||
expect(result.current.state.config.level).toBe('H');
|
||||
expect(result.current.state.config.margin).toBe(4); // unchanged
|
||||
});
|
||||
@@ -61,7 +57,7 @@ describe('QrProvider + useQrState', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_PREVIEW',
|
||||
@@ -76,7 +72,7 @@ describe('QrProvider + useQrState', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QrProvider>{children}</QrProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useQrState(), { wrapper });
|
||||
const { result } = renderHook(() => useQr(), { wrapper });
|
||||
act(() =>
|
||||
result.current.dispatch({
|
||||
type: 'SET_HISTORY',
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* QR 编码文本构造工具 — 单元测试
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildWifiText,
|
||||
buildVCardText,
|
||||
buildEmailText,
|
||||
buildPhoneText,
|
||||
buildSmsText,
|
||||
buildEncodedText,
|
||||
} from '@/utils/qrText';
|
||||
|
||||
describe('buildWifiText', () => {
|
||||
it('构造 WPA WiFi 字符串', () => {
|
||||
const result = buildWifiText({
|
||||
ssid: 'MyWiFi',
|
||||
encryption: 'WPA',
|
||||
password: 'pass123',
|
||||
});
|
||||
expect(result).toBe('WIFI:T:WPA;S:MyWiFi;P:pass123;;');
|
||||
});
|
||||
|
||||
it('空 SSID 返回空字符串', () => {
|
||||
const result = buildWifiText({ ssid: '' });
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('隐藏网络标记正确', () => {
|
||||
const result = buildWifiText({
|
||||
ssid: 'HiddenNet',
|
||||
encryption: 'WPA2',
|
||||
password: 'secret',
|
||||
hidden: 'true',
|
||||
});
|
||||
expect(result).toBe('WIFI:T:WPA2;S:HiddenNet;P:secret;H:true;;');
|
||||
});
|
||||
|
||||
it('默认加密方式为 WPA', () => {
|
||||
const result = buildWifiText({ ssid: 'Test' });
|
||||
expect(result).toContain('T:WPA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildVCardText', () => {
|
||||
it('构造完整 vCard', () => {
|
||||
const result = buildVCardText({
|
||||
name: '张三',
|
||||
phone: '13800138000',
|
||||
email: 'zhangsan@example.com',
|
||||
company: '测试公司',
|
||||
address: '北京市',
|
||||
});
|
||||
expect(result).toContain('BEGIN:VCARD');
|
||||
expect(result).toContain('VERSION:3.0');
|
||||
expect(result).toContain('FN:张三');
|
||||
expect(result).toContain('TEL:13800138000');
|
||||
expect(result).toContain('EMAIL:zhangsan@example.com');
|
||||
expect(result).toContain('END:VCARD');
|
||||
});
|
||||
|
||||
it('空字段产生空值', () => {
|
||||
const result = buildVCardText({});
|
||||
expect(result).toBe('BEGIN:VCARD\nVERSION:3.0\nFN:\nTEL:\nEMAIL:\nORG:\nADR:\nEND:VCARD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEmailText', () => {
|
||||
it('构造 mailto 链接', () => {
|
||||
const result = buildEmailText({
|
||||
to: 'test@example.com',
|
||||
subject: 'Hello',
|
||||
body: 'Test body',
|
||||
});
|
||||
expect(result).toContain('mailto:test@example.com');
|
||||
expect(result).toContain('subject=');
|
||||
expect(result).toContain('body=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPhoneText', () => {
|
||||
it('构造电话链接', () => {
|
||||
expect(buildPhoneText({ number: '13800138000' })).toBe('tel:13800138000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSmsText', () => {
|
||||
it('构造短信链接', () => {
|
||||
const result = buildSmsText({ number: '13800138000', message: 'Hi' });
|
||||
expect(result).toBe('smsto:13800138000:Hi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEncodedText', () => {
|
||||
it('url 模式返回 url 字段', () => {
|
||||
const result = buildEncodedText('url', { url: 'https://example.com' });
|
||||
expect(result).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('wifi 模式委托给 buildWifiText', () => {
|
||||
const result = buildEncodedText('wifi', { ssid: 'Test', encryption: 'WPA' });
|
||||
expect(result).toContain('WIFI:T:WPA;S:Test');
|
||||
});
|
||||
|
||||
it('未知模式返回 text 字段', () => {
|
||||
const result = buildEncodedText('unknown', { text: 'raw text' });
|
||||
expect(result).toBe('raw text');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { open, save } from '@tauri-apps/plugin-dialog';
|
||||
import { readFile, writeFile } from '@tauri-apps/plugin-fs';
|
||||
import type { QrConfig } from '../types';
|
||||
import { buildEncodedText } from '../utils/qrText';
|
||||
|
||||
export default function ExportPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [decodedText, setDecodedText] = useState<string | null>(null);
|
||||
@@ -62,8 +62,12 @@ export default function ExportPanel() {
|
||||
setExporting(false);
|
||||
return;
|
||||
}
|
||||
const qrText = await invoke<string>('build_qr_text', {
|
||||
mode: state.mode,
|
||||
formData: state.formData,
|
||||
});
|
||||
const bytes: number[] = await invoke('export_png', {
|
||||
text: buildEncodedText(state.mode, state.formData),
|
||||
text: qrText,
|
||||
level: state.config.level,
|
||||
margin: state.config.margin,
|
||||
moduleSize: state.config.moduleSize,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { type HistoryEntry } from '../types';
|
||||
import { persistHistory } from '../hooks/useQrEncode';
|
||||
import { persistHistory } from '../utils/storage';
|
||||
|
||||
const MODE_I18N: Record<string, string> = {
|
||||
text: 'mode.text',
|
||||
@@ -15,7 +15,8 @@ const MODE_I18N: Record<string, string> = {
|
||||
|
||||
export default function HistoryList() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
|
||||
const handleClick = (entry: HistoryEntry) => {
|
||||
dispatch({ type: 'SET_MODE', payload: entry.mode });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { MODES } from '../types';
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
@@ -14,7 +14,8 @@ const MODE_LABELS: Record<string, string> = {
|
||||
|
||||
export default function ModePanel() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
|
||||
return (
|
||||
<div className="w-48 border-r border-gray-200 dark:border-gray-800 p-3 flex flex-col gap-1 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
|
||||
@@ -10,23 +10,38 @@ function svgToDataUrl(svg: string): string {
|
||||
return `data:image/svg+xml;base64,${btoa(binStr)}`;
|
||||
}
|
||||
|
||||
export default function QrPreview() {
|
||||
/** QR 码预览组件(纯展示,用 React.memo 避免不必要的重渲染) */
|
||||
const QrPreview = memo(function QrPreview() {
|
||||
const { t } = useTranslation();
|
||||
const { state } = useQrState();
|
||||
const state = useQrState();
|
||||
|
||||
const svgDataUrl = useMemo(
|
||||
() => (state.preview?.svg ? svgToDataUrl(state.preview.svg) : null),
|
||||
[state.preview?.svg],
|
||||
);
|
||||
|
||||
const preview = state.preview;
|
||||
const containerCls = 'w-64 h-64 flex items-center justify-center bg-white rounded-xl shadow-sm';
|
||||
|
||||
// 错误状态
|
||||
if (state.error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-red-400">
|
||||
<div className={`${containerCls} border border-red-200 bg-red-50`}>
|
||||
<span className="text-sm text-center px-4">{state.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!svgDataUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-gray-400">
|
||||
<div className={`${containerCls} border border-gray-200`}>
|
||||
{state.loading ? (
|
||||
<span className="text-sm animate-pulse">{t('preview.loading')}</span>
|
||||
<span className="text-sm animate-pulse" role="status">
|
||||
{t('preview.loading')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm">{t('preview.empty')}</span>
|
||||
)}
|
||||
@@ -41,17 +56,25 @@ export default function QrPreview() {
|
||||
<div className={containerCls}>
|
||||
<img src={svgDataUrl} alt="QR Code" className="w-60 h-60" />
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-gray-400">
|
||||
<span>
|
||||
{t('preview.version')} {state.preview!.version}
|
||||
</span>
|
||||
<span>
|
||||
{state.preview!.size}×{state.preview!.size}
|
||||
</span>
|
||||
<span>
|
||||
{t('preview.mask')} {state.preview!.mask}
|
||||
</span>
|
||||
</div>
|
||||
{preview && (
|
||||
<div
|
||||
className="flex gap-3 text-xs text-gray-400"
|
||||
role="group"
|
||||
aria-label={t('preview.info')}
|
||||
>
|
||||
<span>
|
||||
{t('preview.version')} {preview.version}
|
||||
</span>
|
||||
<span>
|
||||
{preview.size}×{preview.size}
|
||||
</span>
|
||||
<span>
|
||||
{t('preview.mask')} {preview.mask}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default QrPreview;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { useQrEncode } from './useQrEncode';
|
||||
import type { ModeType } from '../types';
|
||||
|
||||
/**
|
||||
* 通用模式表单 hook
|
||||
*
|
||||
* 封装模式组件中重复的 update 模式:
|
||||
* 1. 合并 formData
|
||||
* 2. dispatch SET_FORM_DATA
|
||||
* 3. 调用 Rust text_builder 构建 QR 文本
|
||||
* 4. 触达编码
|
||||
*/
|
||||
export function useModeForm(mode: ModeType) {
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const update = useCallback(
|
||||
async (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
try {
|
||||
const text = await invoke<string>('build_qr_text', { mode, formData: data });
|
||||
encode(text);
|
||||
} catch {
|
||||
/* 错误在 encode 中通过 SET_ERROR dispatch 处理 */
|
||||
}
|
||||
},
|
||||
[mode, state.formData, encode, dispatch],
|
||||
);
|
||||
|
||||
return { formData: state.formData, update };
|
||||
}
|
||||
@@ -1,21 +1,9 @@
|
||||
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 { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { persistHistory } from '../utils/storage';
|
||||
import type { HistoryEntry, ModeType } from '../types';
|
||||
|
||||
const HISTORY_KEY = 'qr-history';
|
||||
const STORE_FILE = 'history.json';
|
||||
|
||||
/** 缓存的 Store 实例,避免每次编码都重新加载 */
|
||||
let storeCache: Promise<Store> | null = null;
|
||||
function getStore(): Promise<Store> {
|
||||
if (!storeCache) {
|
||||
storeCache = Store.load(STORE_FILE);
|
||||
}
|
||||
return storeCache;
|
||||
}
|
||||
|
||||
interface QrResponse {
|
||||
svg: string;
|
||||
version: number;
|
||||
@@ -31,37 +19,19 @@ function sanitizeContent(mode: ModeType, content: string): string {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化整个历史列表到 store
|
||||
* 作为内存状态的唯一持久化出口
|
||||
*/
|
||||
export async function persistHistory(history: HistoryEntry[]): Promise<void> {
|
||||
try {
|
||||
const store = await getStore();
|
||||
await store.set(HISTORY_KEY, history);
|
||||
await store.save();
|
||||
} catch {
|
||||
/* store 不可用时静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 store 加载历史记录(应用启动时调用) */
|
||||
export async function loadHistory(): Promise<HistoryEntry[]> {
|
||||
try {
|
||||
const store = await getStore();
|
||||
return (await store.get<HistoryEntry[]>(HISTORY_KEY)) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function useQrEncode() {
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 用 ref 持有最新值,避免 encode 回调依赖 formData/history
|
||||
const modeRef = useRef(state.mode);
|
||||
modeRef.current = state.mode;
|
||||
const formDataRef = useRef(state.formData);
|
||||
formDataRef.current = state.formData;
|
||||
const historyRef = useRef(state.history);
|
||||
historyRef.current = state.history;
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
@@ -72,10 +42,12 @@ export function useQrEncode() {
|
||||
(text: string) => {
|
||||
if (!text.trim()) {
|
||||
dispatch({ type: 'SET_PREVIEW', payload: null });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(async () => {
|
||||
@@ -87,30 +59,26 @@ export function useQrEncode() {
|
||||
});
|
||||
dispatch({ type: 'SET_PREVIEW', payload: result });
|
||||
|
||||
// 保存到历史(内存 + 持久化)
|
||||
const entryId = Date.now().toString();
|
||||
const currentMode = modeRef.current;
|
||||
const entry: HistoryEntry = {
|
||||
id: entryId,
|
||||
mode: currentMode,
|
||||
content: sanitizeContent(currentMode, text),
|
||||
id: Date.now().toString(),
|
||||
mode: modeRef.current,
|
||||
content: sanitizeContent(modeRef.current, text),
|
||||
timestamp: Date.now(),
|
||||
formData: { ...state.formData },
|
||||
formData: { ...formDataRef.current },
|
||||
};
|
||||
dispatch({ type: 'ADD_HISTORY', payload: entry });
|
||||
|
||||
// 从内存状态持久化(避免 store 读写竞态)
|
||||
// 注意: dispatch ADD_HISTORY 是异步的,这里手动计算最新列表
|
||||
// 确保持久化的数据与内存一致
|
||||
persistHistory([entry, ...state.history].slice(0, 50));
|
||||
} catch {
|
||||
// 编码失败时清空预览
|
||||
persistHistory([entry, ...historyRef.current].slice(0, 50));
|
||||
} catch (e) {
|
||||
dispatch({ type: 'SET_PREVIEW', payload: null });
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: e instanceof Error ? e.message : '编码失败,请检查输入内容',
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
[state.config.level, state.config.margin, state.formData, state.history, dispatch],
|
||||
[state.config.level, state.config.margin, dispatch],
|
||||
);
|
||||
|
||||
return { encode, persistHistory };
|
||||
return { encode };
|
||||
}
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildEmailText } from '../utils/qrText';
|
||||
import { useModeForm } from '../hooks/useModeForm';
|
||||
|
||||
export default function EmailMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const update = (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
encode(buildEmailText(data));
|
||||
};
|
||||
const { formData, update } = useModeForm('email');
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center h-full px-4">
|
||||
<input
|
||||
placeholder={t('email.to')}
|
||||
value={state.formData.to || ''}
|
||||
value={formData.to || ''}
|
||||
onChange={(e) => update('to', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder={t('email.subject')}
|
||||
value={state.formData.subject || ''}
|
||||
value={formData.subject || ''}
|
||||
onChange={(e) => update('subject', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder={t('email.body')}
|
||||
value={state.formData.body || ''}
|
||||
value={formData.body || ''}
|
||||
onChange={(e) => update('body', e.target.value)}
|
||||
className="flex-[2] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildPhoneText } from '../utils/qrText';
|
||||
import { useModeForm } from '../hooks/useModeForm';
|
||||
|
||||
export default function PhoneMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const update = (number: string) => {
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: { number } });
|
||||
encode(buildPhoneText({ number }));
|
||||
};
|
||||
const { formData, update } = useModeForm('phone');
|
||||
|
||||
return (
|
||||
<input
|
||||
placeholder={t('phone.placeholder')}
|
||||
type="tel"
|
||||
value={state.formData.number || ''}
|
||||
onChange={(e) => update(e.target.value)}
|
||||
placeholder={t('phone.placeholder')}
|
||||
value={formData.number || ''}
|
||||
onChange={(e) => update('number', e.target.value)}
|
||||
className="w-full h-full px-4 text-sm bg-transparent outline-none placeholder-gray-400 dark:placeholder-gray-600 focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildSmsText } from '../utils/qrText';
|
||||
import { useModeForm } from '../hooks/useModeForm';
|
||||
|
||||
export default function SmsMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const update = (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
encode(buildSmsText(data));
|
||||
};
|
||||
const { formData, update } = useModeForm('sms');
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center h-full px-4">
|
||||
<input
|
||||
placeholder={t('sms.number')}
|
||||
type="tel"
|
||||
value={state.formData.number || ''}
|
||||
placeholder={t('sms.number')}
|
||||
value={formData.number || ''}
|
||||
onChange={(e) => update('number', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder={t('sms.message')}
|
||||
value={state.formData.message || ''}
|
||||
value={formData.message || ''}
|
||||
onChange={(e) => update('message', e.target.value)}
|
||||
className="flex-[2] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
|
||||
export default function TextMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrState, useQrDispatch } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
|
||||
export default function UrlMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const state = useQrState();
|
||||
const dispatch = useQrDispatch();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const handleChange = (url: string) => {
|
||||
|
||||
@@ -1,41 +1,32 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildVCardText } from '../utils/qrText';
|
||||
import { useModeForm } from '../hooks/useModeForm';
|
||||
|
||||
const FIELDS = [
|
||||
{ key: 'name', i18n: 'vcard.name' },
|
||||
{ key: 'phone', i18n: 'vcard.phone' },
|
||||
{ key: 'email', i18n: 'vcard.email' },
|
||||
{ key: 'company', i18n: 'vcard.company' },
|
||||
{ key: 'title', i18n: 'vcard.title' },
|
||||
{ key: 'address', i18n: 'vcard.address' },
|
||||
{ key: 'vcardUrl', i18n: 'vcard.url' },
|
||||
{ key: 'birthday', i18n: 'vcard.birthday' },
|
||||
{ key: 'note', i18n: 'vcard.note' },
|
||||
{ key: 'photo', i18n: 'vcard.photo' },
|
||||
];
|
||||
'name',
|
||||
'phone',
|
||||
'email',
|
||||
'company',
|
||||
'title',
|
||||
'address',
|
||||
'vcardUrl',
|
||||
'birthday',
|
||||
'note',
|
||||
'photo',
|
||||
] as const;
|
||||
|
||||
export default function VCardMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
const update = (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
encode(buildVCardText(data));
|
||||
};
|
||||
const { formData, update } = useModeForm('vcard');
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center h-full px-4">
|
||||
{FIELDS.map((f) => (
|
||||
<div className="grid grid-cols-2 gap-2 px-4 py-1 h-full overflow-y-auto content-start">
|
||||
{FIELDS.map((key) => (
|
||||
<input
|
||||
key={f.key}
|
||||
placeholder={t(f.i18n)}
|
||||
value={state.formData[f.key] || ''}
|
||||
onChange={(e) => update(f.key, e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
key={key}
|
||||
placeholder={t(`vcard.${key}`)}
|
||||
value={formData[key] || ''}
|
||||
onChange={(e) => update(key, e.target.value)}
|
||||
className="px-2 py-1 rounded-lg border border-gray-200 dark:border-gray-700 text-xs bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,48 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQrState } from '../store/qrContext';
|
||||
import { useQrEncode } from '../hooks/useQrEncode';
|
||||
import { buildWifiText } from '../utils/qrText';
|
||||
import { useModeForm } from '../hooks/useModeForm';
|
||||
|
||||
export default function WifiMode() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch } = useQrState();
|
||||
const { encode } = useQrEncode();
|
||||
|
||||
/** checkbox 的 boolean 值统一转为 'true'/'false' 字符串存入 formData */
|
||||
const update = (field: string, value: string) => {
|
||||
const data = { ...state.formData, [field]: value };
|
||||
dispatch({ type: 'SET_FORM_DATA', payload: data });
|
||||
encode(buildWifiText(data));
|
||||
};
|
||||
const { formData, update } = useModeForm('wifi');
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center h-full px-4">
|
||||
<input
|
||||
placeholder="SSID"
|
||||
value={state.formData.ssid || ''}
|
||||
value={formData.ssid || ''}
|
||||
onChange={(e) => update('ssid', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<input
|
||||
placeholder={t('wifi.password')}
|
||||
type="password"
|
||||
value={state.formData.password || ''}
|
||||
value={formData.password || ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
/>
|
||||
<select
|
||||
value={state.formData.encryption || 'WPA'}
|
||||
value={formData.encryption || 'WPA'}
|
||||
onChange={(e) => update('encryption', e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
>
|
||||
<option value="WPA">WPA/WPA2</option>
|
||||
<option value="WEP">WEP</option>
|
||||
<option value="nopass">无{t('wifi.password')}</option>
|
||||
<option value="nopass">{t('wifi.noPassword')}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.formData.hidden === 'true'}
|
||||
checked={formData.hidden === 'true'}
|
||||
onChange={(e) => update('hidden', e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
{t('wifi.hidden')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useReducer, useEffect, type ReactNode } from 'react';
|
||||
import { loadHistory } from '../hooks/useQrEncode';
|
||||
import { loadHistory } from '../utils/storage';
|
||||
import type { QrState, QrAction } from '../types';
|
||||
|
||||
const initialState: QrState = {
|
||||
@@ -9,20 +9,23 @@ const initialState: QrState = {
|
||||
preview: null,
|
||||
history: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function qrReducer(state: QrState, action: QrAction): QrState {
|
||||
switch (action.type) {
|
||||
case 'SET_MODE':
|
||||
return { ...state, mode: action.payload, formData: {}, preview: null };
|
||||
return { ...state, mode: action.payload, formData: {}, preview: null, error: null };
|
||||
case 'SET_FORM_DATA':
|
||||
return { ...state, formData: action.payload };
|
||||
return { ...state, formData: action.payload, error: null };
|
||||
case 'SET_CONFIG':
|
||||
return { ...state, config: { ...state.config, ...action.payload } };
|
||||
case 'SET_PREVIEW':
|
||||
return { ...state, preview: action.payload, loading: false };
|
||||
case 'SET_LOADING':
|
||||
return { ...state, loading: action.payload };
|
||||
case 'SET_ERROR':
|
||||
return { ...state, error: action.payload };
|
||||
case 'SET_HISTORY':
|
||||
return { ...state, history: action.payload };
|
||||
case 'ADD_HISTORY':
|
||||
@@ -36,15 +39,14 @@ function qrReducer(state: QrState, action: QrAction): QrState {
|
||||
}
|
||||
}
|
||||
|
||||
const QrContext = createContext<{
|
||||
state: QrState;
|
||||
dispatch: React.Dispatch<QrAction>;
|
||||
} | null>(null);
|
||||
/** 只读 state context — 只有 state 变化的组件需要订阅 */
|
||||
const StateContext = createContext<QrState | null>(null);
|
||||
/** 只写 dispatch context — 只需要 dispatch 的组件不因 state 变化而重渲染 */
|
||||
const DispatchContext = createContext<React.Dispatch<QrAction> | null>(null);
|
||||
|
||||
export function QrProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(qrReducer, initialState);
|
||||
|
||||
// 启动时从 store 加载持久化的历史记录
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const history = await loadHistory();
|
||||
@@ -52,11 +54,23 @@ export function QrProvider({ children }: { children: ReactNode }) {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return <QrContext.Provider value={{ state, dispatch }}>{children}</QrContext.Provider>;
|
||||
return (
|
||||
<StateContext.Provider value={state}>
|
||||
<DispatchContext.Provider value={dispatch}>{children}</DispatchContext.Provider>
|
||||
</StateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 订阅 state(需要读取 state 的组件使用) */
|
||||
export function useQrState() {
|
||||
const ctx = useContext(QrContext);
|
||||
const ctx = useContext(StateContext);
|
||||
if (!ctx) throw new Error('useQrState must be inside QrProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** 获取 dispatch(只需要写入的组件使用,不触发 state 重渲染) */
|
||||
export function useQrDispatch() {
|
||||
const ctx = useContext(DispatchContext);
|
||||
if (!ctx) throw new Error('useQrDispatch must be inside QrProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface QrState {
|
||||
preview: QrPreview | null;
|
||||
history: HistoryEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type QrAction =
|
||||
@@ -37,19 +38,10 @@ export type QrAction =
|
||||
| { type: 'SET_CONFIG'; payload: Partial<QrConfig> }
|
||||
| { type: 'SET_PREVIEW'; payload: QrPreview | null }
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'SET_HISTORY'; payload: HistoryEntry[] }
|
||||
| { type: 'ADD_HISTORY'; payload: HistoryEntry }
|
||||
| { type: 'REMOVE_HISTORY'; payload: string }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export const MODE_LABELS: Record<ModeType, string> = {
|
||||
text: '文本',
|
||||
url: 'URL',
|
||||
wifi: 'WiFi',
|
||||
vcard: 'vCard',
|
||||
email: 'Email',
|
||||
phone: '电话',
|
||||
sms: 'SMS',
|
||||
};
|
||||
|
||||
export const MODES: ModeType[] = ['text', 'url', 'wifi', 'vcard', 'email', 'phone', 'sms'];
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* QR 编码文本构造工具
|
||||
* 集中管理各模式的文本格式,避免 ExportPanel 和各 mode 组件间的重复逻辑
|
||||
*/
|
||||
|
||||
/** 构造 WiFi 连接字符串 */
|
||||
export function buildWifiText(formData: Record<string, string>): 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 3.0 字符串(含扩展字段) */
|
||||
export function buildVCardText(formData: Record<string, string>): string {
|
||||
const name = formData.name || '';
|
||||
const phone = formData.phone || '';
|
||||
const email = formData.email || '';
|
||||
const company = formData.company || '';
|
||||
const address = formData.address || '';
|
||||
const title = formData.title || '';
|
||||
const url = formData.vcardUrl || '';
|
||||
const birthday = formData.birthday || '';
|
||||
const note = formData.note || '';
|
||||
const photo = formData.photo || '';
|
||||
let s = `BEGIN:VCARD\nVERSION:3.0\nFN:${name}\nTEL:${phone}\nEMAIL:${email}\nORG:${company}`;
|
||||
if (title) s += `\nTITLE:${title}`;
|
||||
if (address) s += `\nADR:${address}`;
|
||||
if (url) s += `\nURL:${url}`;
|
||||
if (birthday) s += `\nBDAY:${birthday}`;
|
||||
if (note) s += `\nNOTE:${note}`;
|
||||
if (photo) s += `\nPHOTO:${photo}`;
|
||||
return s + '\nEND:VCARD';
|
||||
}
|
||||
|
||||
/** 构造 mailto 链接 */
|
||||
export function buildEmailText(formData: Record<string, string>): 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, string>): string {
|
||||
return `tel:${formData.number || ''}`;
|
||||
}
|
||||
|
||||
/** 构造短信链接 */
|
||||
export function buildSmsText(formData: Record<string, string>): string {
|
||||
return `smsto:${formData.number || ''}:${formData.message || ''}`;
|
||||
}
|
||||
|
||||
/** 从完整 formData 构造当前模式的编码文本(供 ExportPanel 使用) */
|
||||
export function buildEncodedText(mode: string, formData: Record<string, string>): 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 || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Store } from '@tauri-apps/plugin-store';
|
||||
import type { HistoryEntry } from '../types';
|
||||
|
||||
const HISTORY_KEY = 'qr-history';
|
||||
const STORE_FILE = 'history.json';
|
||||
|
||||
/** 缓存的 Store 实例,避免每次编码都重新加载 */
|
||||
let storeCache: Promise<Store> | null = null;
|
||||
function getStore(): Promise<Store> {
|
||||
if (!storeCache) {
|
||||
storeCache = Store.load(STORE_FILE);
|
||||
}
|
||||
return storeCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化整个历史列表到 store
|
||||
* 作为内存状态的唯一持久化出口
|
||||
*/
|
||||
export async function persistHistory(history: HistoryEntry[]): Promise<void> {
|
||||
try {
|
||||
const store = await getStore();
|
||||
await store.set(HISTORY_KEY, history);
|
||||
await store.save();
|
||||
} catch {
|
||||
/* store 不可用时静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 store 加载历史记录(应用启动时调用) */
|
||||
export async function loadHistory(): Promise<HistoryEntry[]> {
|
||||
try {
|
||||
const store = await getStore();
|
||||
return (await store.get<HistoryEntry[]>(HISTORY_KEY)) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user