feat: QR 预览 + 导出面板(PNG/SVG/复制) + 文本/URL 模式
This commit is contained in:
@@ -3,6 +3,8 @@ import ModePanel from './components/ModePanel';
|
|||||||
import QrPreview from './components/QrPreview';
|
import QrPreview from './components/QrPreview';
|
||||||
import ExportPanel from './components/ExportPanel';
|
import ExportPanel from './components/ExportPanel';
|
||||||
import HistoryList from './components/HistoryList';
|
import HistoryList from './components/HistoryList';
|
||||||
|
import TextMode from './modes/TextMode';
|
||||||
|
import UrlMode from './modes/UrlMode';
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
return (
|
return (
|
||||||
@@ -39,12 +41,16 @@ function AppLayout() {
|
|||||||
function BottomInput() {
|
function BottomInput() {
|
||||||
const { state } = useQrState();
|
const { state } = useQrState();
|
||||||
|
|
||||||
return (
|
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 className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||||
输入区 — 开发中(当前模式: {state.mode})
|
更多模式即将推出...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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() {
|
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 (
|
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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
|
import { useQrState } from '../store/qrContext';
|
||||||
|
|
||||||
export default function QrPreview() {
|
export default function QrPreview() {
|
||||||
|
const { state } = useQrState();
|
||||||
|
|
||||||
|
if (!state.preview?.svg) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 text-gray-400">
|
<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">
|
<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>
|
<span className="text-sm">输入内容生成 QR 码</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user