feat: QR 预览 + 导出面板(PNG/SVG/复制) + 文本/URL 模式

This commit is contained in:
2026-06-17 00:23:23 +08:00
parent 8aacd3bea2
commit 1a4af38bac
5 changed files with 200 additions and 9 deletions
+11 -5
View File
@@ -3,6 +3,8 @@ import ModePanel from './components/ModePanel';
import QrPreview from './components/QrPreview';
import ExportPanel from './components/ExportPanel';
import HistoryList from './components/HistoryList';
import TextMode from './modes/TextMode';
import UrlMode from './modes/UrlMode';
function AppLayout() {
return (
@@ -39,11 +41,15 @@ function AppLayout() {
function BottomInput() {
const { state } = useQrState();
return (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
: {state.mode}
</div>
);
switch (state.mode) {
case 'text': return <TextMode />;
case 'url': return <UrlMode />;
default: return (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
...
</div>
);
}
}
export default function App() {
+112 -1
View File
@@ -1,8 +1,119 @@
import { useState } from 'react';
import { useQrState } from '../store/qrContext';
import { invoke } from '@tauri-apps/api/core';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile } from '@tauri-apps/plugin-fs';
function getCurrentText(state: any): string {
switch (state.mode) {
case 'url': return state.formData.url || '';
case 'wifi': return `WIFI:T:${state.formData.encryption || 'WPA'};S:${state.formData.ssid || ''};P:${state.formData.password || ''};;`;
case 'vcard': return `BEGIN:VCARD\nVERSION:3.0\nFN:${state.formData.name || ''}\nTEL:${state.formData.phone || ''}\nEMAIL:${state.formData.email || ''}\nORG:${state.formData.company || ''}\nADR:${state.formData.address || ''}\nEND:VCARD`;
case 'email': return `mailto:${state.formData.to || ''}?subject=${encodeURIComponent(state.formData.subject || '')}&body=${encodeURIComponent(state.formData.body || '')}`;
case 'phone': return `tel:${state.formData.number || ''}`;
case 'sms': return `smsto:${state.formData.number || ''}:${state.formData.message || ''}`;
default: return state.formData.text || '';
}
}
export default function ExportPanel() {
const { state, dispatch } = useQrState();
const [exporting, setExporting] = useState(false);
const handleCopySvg = async () => {
if (state.preview?.svg) {
await writeText(state.preview.svg);
}
};
const handleExportPng = async () => {
if (!state.preview?.svg) return;
setExporting(true);
try {
const filePath = await save({
filters: [{ name: 'PNG 图片', extensions: ['png'] }],
defaultPath: 'qrcode.png',
});
if (!filePath) { setExporting(false); return; }
const bytes: number[] = await invoke('export_png', {
text: getCurrentText(state),
level: state.config.level,
margin: state.config.margin,
moduleSize: state.config.moduleSize,
});
await writeFile(filePath, new Uint8Array(bytes));
} catch (e) {
console.error('导出 PNG 失败:', e);
}
setExporting(false);
};
const handleExportSvg = async () => {
if (!state.preview?.svg) return;
try {
const filePath = await save({
filters: [{ name: 'SVG 图片', extensions: ['svg'] }],
defaultPath: 'qrcode.svg',
});
if (!filePath) return;
await writeFile(filePath, new TextEncoder().encode(state.preview.svg));
} catch (e) {
console.error('导出 SVG 失败:', e);
}
};
return (
<div className="flex flex-col gap-2">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider"></div>
<div className="text-xs text-gray-400">...</div>
<label className="text-xs text-gray-600 dark:text-gray-400">
<select
value={state.config.level}
onChange={e => dispatch({ type: 'SET_CONFIG', payload: { level: e.target.value as any } })}
className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm outline-none focus:ring-2 focus:ring-blue-500/30"
>
<option value="L">L 7%</option>
<option value="M">M 15%</option>
<option value="Q">Q 25%</option>
<option value="H">H 30%</option>
</select>
</label>
<label className="text-xs text-gray-600 dark:text-gray-400">
: {state.config.moduleSize}px
<input
type="range" min={2} max={20}
value={state.config.moduleSize}
onChange={e => dispatch({ type: 'SET_CONFIG', payload: { moduleSize: +e.target.value } })}
className="w-full mt-1 accent-blue-500"
/>
</label>
<label className="text-xs text-gray-600 dark:text-gray-400">
: {state.config.margin}
<input
type="range" min={1} max={10}
value={state.config.margin}
onChange={e => dispatch({ type: 'SET_CONFIG', payload: { margin: +e.target.value } })}
className="w-full mt-1 accent-blue-500"
/>
</label>
<button onClick={handleCopySvg} disabled={!state.preview}
className="w-full py-2 rounded-lg bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-40 transition-all">
SVG
</button>
<button onClick={handleExportPng} disabled={!state.preview || exporting}
className="w-full py-2 rounded-lg bg-green-500 text-white text-sm font-medium hover:bg-green-600 disabled:opacity-40 transition-all">
{exporting ? '导出中...' : '导出 PNG'}
</button>
<button onClick={handleExportSvg} disabled={!state.preview}
className="w-full py-2 rounded-lg bg-purple-500 text-white text-sm font-medium hover:bg-purple-600 disabled:opacity-40 transition-all">
SVG
</button>
</div>
);
}
+23 -3
View File
@@ -1,8 +1,28 @@
import { useQrState } from '../store/qrContext';
export default function QrPreview() {
const { state } = useQrState();
if (!state.preview?.svg) {
return (
<div className="flex flex-col items-center justify-center gap-3 text-gray-400">
<div className="w-64 h-64 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl flex items-center justify-center bg-white/50 dark:bg-gray-800/50">
<span className="text-sm"> QR </span>
</div>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-3 text-gray-400">
<div className="w-48 h-48 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl flex items-center justify-center">
<span className="text-sm"> QR </span>
<div className="flex flex-col items-center gap-3">
<div
className="w-64 h-64 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl p-4 flex items-center justify-center bg-white dark:bg-white qr-preview"
dangerouslySetInnerHTML={{ __html: state.preview.svg }}
/>
<div className="flex gap-3 text-xs text-gray-500 dark:text-gray-400">
<span>: {state.preview.version}</span>
<span>{state.preview.size}×{state.preview.size}</span>
<span>: {state.preview.mask}</span>
</div>
</div>
);
+22
View File
@@ -0,0 +1,22 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
export default function TextMode() {
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
const handleChange = (text: string) => {
dispatch({ type: 'SET_FORM_DATA', payload: { text } });
encode(text);
};
return (
<textarea
placeholder="输入任意文本..."
value={state.formData.text || ''}
onChange={e => handleChange(e.target.value)}
rows={3}
className="w-full h-full resize-none px-4 py-2 text-sm bg-transparent outline-none placeholder-gray-400 dark:placeholder-gray-600"
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
export default function UrlMode() {
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
const handleChange = (url: string) => {
dispatch({ type: 'SET_FORM_DATA', payload: { url } });
encode(url);
};
const handleBlur = () => {
const url = state.formData.url || '';
if (url && !/^https?:\/\//i.test(url)) {
const corrected = `https://${url}`;
dispatch({ type: 'SET_FORM_DATA', payload: { url: corrected } });
encode(corrected);
}
};
return (
<input
type="url"
placeholder="https://example.com"
value={state.formData.url || ''}
onChange={e => handleChange(e.target.value)}
onBlur={handleBlur}
className="w-full h-full px-4 text-sm bg-transparent outline-none placeholder-gray-400 dark:placeholder-gray-600"
/>
);
}