chore: 前端工程化 + Git hooks + 对齐 PathEditor 规范

- 新增 .gitattributes(CRLF 统一)+ rust-toolchain.toml
- 新增 Prettier + ESLint + markdownlint 配置
- 新增 Husky Git hooks(pre-commit lint-staged + commit-msg commitlint)
- 新增 vitest 前端测试(12 tests, utils/qrText.ts)
- 新增 @ 路径别名(vite + tsconfig)
- 新增 ROADMAP / SUPPORT / CODEOWNERS / FUNDING / dependabot
- 更新 .gitignore + .editorconfig
- 更新 package.json(新增 lint/format/test 脚本)
- 全项目 prettier 格式化 + eslint 通过
- 更新 CLAUDE.md + README.md
This commit is contained in:
2026-06-19 19:42:13 +08:00
parent ce8063431e
commit c3956f0f36
40 changed files with 4034 additions and 148 deletions
+32
View File
@@ -0,0 +1,32 @@
# Git 行尾符规范化
# 统一 CRLFWindows 原生项目)
# 源码文本文件
*.ts text eol=crlf
*.tsx text eol=crlf
*.js text eol=crlf
*.json text eol=crlf
*.html text eol=crlf
*.css text eol=crlf
*.md text eol=crlf
*.rs text eol=crlf
*.toml text eol=crlf
*.yml text eol=crlf
*.yaml text eol=crlf
*.svg text eol=crlf
*.txt text eol=crlf
*.editorconfig text eol=crlf
*.gitattributes text eol=crlf
*.gitignore text eol=crlf
LICENSE text eol=crlf
# 二进制文件
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.dll binary
*.exe binary
*.nsis binary
+19
View File
@@ -0,0 +1,19 @@
# 代码所有者 — 自动分配 PR 审查
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# 全局所有者
* @LHY0125
# Rust 代码
/core/ @LHY0125
/cli/ @LHY0125
/gui/ @LHY0125
/web/ @LHY0125
/Cargo.toml @LHY0125
/rust-toolchain.toml @LHY0125
# 前端代码
/gui/src-frontend/ @LHY0125
# CI/CD 和配置文件
/.github/ @LHY0125
+4
View File
@@ -0,0 +1,4 @@
# 开源赞助
# 支持 QRGen 的开发
github: LHY0125
+72
View File
@@ -0,0 +1,72 @@
# Dependabot 自动依赖更新配置
# https://docs.github.com/code-security/dependabot/dependabot-version-updates
version: 2
updates:
# npm 前端依赖
- package-ecosystem: "npm"
directory: "/gui/src-frontend"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
versioning-strategy: "auto"
allow:
- dependency-type: "all"
labels:
- "dependencies"
- "javascript"
commit-message:
prefix: "chore(deps)"
prefix-development: "chore(deps-dev)"
open-pull-requests-limit: 5
groups:
react:
patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
tauri:
patterns:
- "@tauri-apps/*"
testing:
patterns:
- "vitest"
- "@testing-library/*"
- "jsdom"
eslint:
patterns:
- "eslint"
- "eslint-plugin-*"
- "typescript-eslint"
- "globals"
- "@eslint/js"
# Cargo Rust 依赖
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
labels:
- "dependencies"
- "rust"
commit-message:
prefix: "chore(deps)"
prefix-development: "chore(deps-dev)"
open-pull-requests-limit: 3
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
labels:
- "dependencies"
- "ci"
commit-message:
prefix: "ci(deps)"
+28 -4
View File
@@ -1,13 +1,37 @@
# AI assistant
/.claude
/.codegraph
CLAUDE.md
# Rust
/target
/test
# Node
node_modules/
dist/
dist-ssr/
*.local
pnpm-lock.yaml
# Coverage
coverage/
*.lcov
# Test artifacts
test-results/
playwright-report/
# IDE
/.vscode
.idea/
*.swp
*.swo
*~
.idea/
*.iml
node_modules/
dist/
.DS_Store
*.suo
# Archive
*.tar.gz
*.zip
*.7z
+1
View File
@@ -0,0 +1 @@
cd gui/src-frontend && npx --no -- commitlint --edit $1
+1
View File
@@ -0,0 +1 @@
cd gui/src-frontend && npx lint-staged
+8
View File
@@ -0,0 +1,8 @@
{
"default": true,
"MD013": false,
"MD033": {
"allowed_elements": ["img", "br", "kbd", "summary", "details", "p"]
},
"MD041": false
}
+9
View File
@@ -0,0 +1,9 @@
node_modules
target
dist
dist-ssr
*.local
pnpm-lock.yaml
coverage
test-results
playwright-report
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "crlf",
"arrowParens": "always",
"bracketSpacing": true
}
+11
View File
@@ -47,6 +47,17 @@ docker build -t qrgen-web -f web/Dockerfile .
# Rust lint
cargo clippy -- -D warnings
# 前端格式
cd gui/src-frontend && pnpm format # prettier 格式化
cd gui/src-frontend && pnpm format:check # prettier 检查
# 前端 lint
cd gui/src-frontend && pnpm lint # eslint
# 前端测试
cd gui/src-frontend && pnpm test # vitest (12 tests)
cd gui/src-frontend && pnpm test:coverage # vitest + 覆盖率
```
## 架构
+17 -2
View File
@@ -13,6 +13,9 @@
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
<img src="https://img.shields.io/badge/tests-58%20passed-brightgreen" alt="tests">
<img src="https://img.shields.io/badge/clippy-clean-brightgreen" alt="clippy">
<img src="https://img.shields.io/badge/prettier-formatted-ff69b4" alt="prettier">
<img src="https://img.shields.io/badge/eslint-checked-4b32c3" alt="eslint">
<img src="https://img.shields.io/badge/vitest-12%20passed-brightgreen" alt="vitest">
</p>
---
@@ -184,14 +187,26 @@ cargo run -p qrgen -- "Hello World"
# Web 开发
cargo run -p qrgen-web # → http://localhost:3000
# 全部测试
cargo test # Rust: 82 tests
# Rust 测试
cargo test # 58 unit
# 前端测试
cd gui/src-frontend && pnpm test # vitest
# 前端类型检查
cd gui/src-frontend && pnpm tsc --noEmit
# Rust lint
cargo clippy -- -D warnings
# 前端代码检查
cd gui/src-frontend && pnpm lint # eslint
cd gui/src-frontend && pnpm format:check # prettier
# Git hooks(提交前自动运行)
# pre-commit: lint-stagedprettier + eslint
# commit-msg: commitlintConventional Commits
```
```
### 技术栈
+46
View File
@@ -0,0 +1,46 @@
# 路线图
QRGen 的未来发展方向。
## v0.2.0 (下一个版本)
- [ ] **CLI 编码模式** — CLI 支持 `--mode wifi` 等子命令,免去手动拼 `WIFI:T:...`
- [ ] **Logo 嵌入** — QR 码中央嵌入自定义图片(Logo/头像)
- [ ] **彩色 QR 码** — 自定义前景色/背景色,渐变色支持
- [ ] **批量生成** — 从 CSV/JSON 批量生成 QR 码
- [ ] **前端测试** — vite + vitest + React Testing Library80% 覆盖率
- [ ] **E2E 测试** — Playwright 端到端测试(编码 → 导出 → 历史)
- [ ] **i18n** — 中英双语界面 (i18next)
## v0.3.0
- [ ] **QR 解码** — 内置 QR 码解码器(从图片识别 QR 内容)
- [ ] **格式扩展** — 支持 BMP/JPEG/WEBP 输出
- [ ] **WiFi 扫码自动连接** — 移动端扫码后一键连接 WiFi
- [ ] **vCard 扩展** — 支持更多字段(照片、社交媒体等)
- [ ] **macOS 桌面应用** — Tauri macOS 构建支持
## v1.0.0 (长期)
- [ ] **跨平台 GUI** — 完整的 Windows + macOS + Linux 桌面应用发布
- [ ] **PWA 支持** — Web 端可安装为 PWA,离线使用
- [ ] **发布到包管理器** — crates.io / winget / Homebrew / Scoop
- [ ] **插件系统** — 第三方编码模式扩展
- [ ] **在线服务** — 公开的 QR 码生成 API 服务(带速率限制)
## 已交付
### v0.1.0
- ✅ ISO/IEC 18004 完整 QR 码生成算法
- ✅ 4 种编码模式(数字/字母/字节/汉字 Shift JIS
- ✅ 40 版本 × 4 纠错级别支持
- ✅ CLI 命令行工具(PNG/SVG/ASCII 输出)
- ✅ GUI 桌面应用(Tauri 2 + React 187 种编码模式)
- ✅ Web 服务(axum + Docker alpine 17.7MB 镜像)
- ✅ 82 个 Rust 测试
- ✅ 社区规范文件(CONTRIBUTING / CODE_OF_CONDUCT / SECURITY
---
欢迎通过 [Issues](https://github.com/LHY0125/QRGen/issues) 提交功能建议!
+71
View File
@@ -0,0 +1,71 @@
# 获取帮助
## 📖 文档
- [README](README.md) — 项目简介、功能列表、安装指南
- [CONTRIBUTING](CONTRIBUTING.md) — 贡献指南
- [CHANGELOG](CHANGELOG.md) — 版本变更记录
- [ROADMAP](ROADMAP.md) — 未来规划
- [SECURITY](SECURITY.md) — 安全政策
## 🐛 报告 Bug
1. 先搜索 [Issues](https://github.com/LHY0125/QRGen/issues) 确认未被报告
2. 使用 **Bug Report** 模板创建新 Issue
3. 提供系统信息(OS 版本、Rust 版本、QRGen 版本)
4. 附上复现步骤和截图
## 💡 功能建议
1. 检查 [ROADMAP](ROADMAP.md) 确认不在已有计划中
2. 使用 **Feature Request** 模板创建新 Issue
3. 描述使用场景和期望行为
## ❓ 常见问题
### CLI 命令找不到?
```bash
qrgen --help
```
确保已通过 `cargo build --release -p qrgen` 编译,可执行文件在 `target/release/qrgen.exe`
### QR 码扫不出来?
1. 确保静区边距足够(推荐 `--margin 4` 或更大)
2. 确保纠错级别适合(H 级可容忍更大遮挡)
3. 检查打印/显示尺寸 — 太小的 QR 码可能无法对焦
### PNG 输出空白?
```bash
# 增大模块尺寸
qrgen "Hello" -o qr.png -s 8
```
默认模块尺寸较小(2px),增大 `-s` 参数可获得更大图片。
### Web 服务怎么后台运行?
```bash
# Docker
docker run -d --name qrgen-web --restart unless-stopped -p 3000:3000 qrgen-web
# 或使用 docker-compose
docker compose up -d
```
### 生成超大 QR 码(版本 40)失败?
```bash
# 降低纠错级别获取更多容量
qrgen "very long text..." -l L -o qr.png
```
版本 40-L 可容纳约 7089 个数字或 4296 个字母。
## 📧 联系
- GitHub Issues: [LHY0125/QRGen](https://github.com/LHY0125/QRGen/issues)
- 安全问题: 参见 [SECURITY.md](SECURITY.md)
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('@commitlint/types').UserConfig} */
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'refactor', 'docs', 'test', 'chore', 'perf', 'ci', 'style', 'revert'],
],
'subject-case': [0],
},
};
+19
View File
@@ -0,0 +1,19 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import globals from 'globals';
export default tseslint.config(
{ ignores: ['**/node_modules/**', '**/target/**', '**/dist/**', '**/dist-ssr/**'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
globals: globals.browser,
},
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
);
+33 -3
View File
@@ -1,13 +1,30 @@
{
"name": "qrgen-frontend",
"name": "qrgen-gui",
"private": true,
"version": "0.1.0",
"type": "module",
"lint-staged": {
"*.{ts,tsx}": [
"prettier --write",
"eslint --fix"
],
"*.{json,md,css,html}": [
"prettier --write"
]
},
"scripts": {
"dev": "vite --port 1420",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "husky"
},
"dependencies": {
"@tauri-apps/api": "^2",
@@ -19,14 +36,27 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@commitlint/cli": "^19",
"@commitlint/config-conventional": "^19",
"@eslint/js": "^9",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6",
"@testing-library/react": "^16",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9",
"globals": "^16",
"husky": "^9",
"jsdom": "^26",
"lint-staged": "^15",
"postcss": "^8.4.49",
"prettier": "^3",
"tailwindcss": "^3.4.16",
"typescript": "^5.6.3",
"vite": "^6.0.3"
"typescript-eslint": "^8",
"vite": "^6.0.3",
"vitest": "^3"
}
}
+3275 -5
View File
File diff suppressed because it is too large Load Diff
+17 -11
View File
@@ -17,9 +17,7 @@ function AppLayout() {
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
{/* 顶部标题栏 */}
<div className="h-10 flex items-center px-4 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-b border-gray-200 dark:border-gray-800">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
🀫 QRGen
</span>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">🀫 QRGen</span>
</div>
{/* 三栏主体 */}
@@ -48,14 +46,22 @@ function BottomInput() {
const { state } = useQrState();
switch (state.mode) {
case 'text': return <TextMode />;
case 'url': return <UrlMode />;
case 'wifi': return <WifiMode />;
case 'vcard': return <VCardMode />;
case 'email': return <EmailMode />;
case 'phone': return <PhoneMode />;
case 'sms': return <SmsMode />;
default: return <TextMode />;
case 'text':
return <TextMode />;
case 'url':
return <UrlMode />;
case 'wifi':
return <WifiMode />;
case 'vcard':
return <VCardMode />;
case 'email':
return <EmailMode />;
case 'phone':
return <PhoneMode />;
case 'sms':
return <SmsMode />;
default:
return <TextMode />;
}
}
@@ -0,0 +1,109 @@
/**
* 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,12 +1,19 @@
import React, { Component, type ReactNode } from 'react';
interface Props { children: ReactNode; }
interface State { hasError: boolean; error: Error | null; }
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) { return { hasError: true, error }; }
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// 生产环境错误日志记录入口
@@ -21,8 +28,10 @@ export default class ErrorBoundary extends Component<Props, State> {
<span className="text-4xl"></span>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm max-w-md text-center">{this.state.error?.message}</p>
<button onClick={() => window.location.reload()}
className="px-4 py-2 rounded-lg bg-blue-500 text-white text-sm hover:bg-blue-600 transition-all">
<button
onClick={() => window.location.reload()}
className="px-4 py-2 rounded-lg bg-blue-500 text-white text-sm hover:bg-blue-600 transition-all"
>
</button>
</div>
+35 -12
View File
@@ -30,7 +30,10 @@ export default function ExportPanel() {
filters: [{ name: 'PNG 图片', extensions: ['png'] }],
defaultPath: 'qrcode.png',
});
if (!filePath) { setExporting(false); return; }
if (!filePath) {
setExporting(false);
return;
}
const bytes: number[] = await invoke('export_png', {
text: buildEncodedText(state.mode, state.formData),
@@ -74,7 +77,12 @@ export default function ExportPanel() {
<select
value={state.config.level}
onChange={e => dispatch({ type: 'SET_CONFIG', payload: { level: e.target.value as QrConfig['level'] } })}
onChange={(e) =>
dispatch({
type: 'SET_CONFIG',
payload: { level: e.target.value as QrConfig['level'] },
})
}
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>
@@ -87,9 +95,13 @@ export default function ExportPanel() {
<label className="text-xs text-gray-600 dark:text-gray-400">
: {state.config.moduleSize}px
<input
type="range" min={2} max={20}
type="range"
min={2}
max={20}
value={state.config.moduleSize}
onChange={e => dispatch({ type: 'SET_CONFIG', payload: { moduleSize: +e.target.value } })}
onChange={(e) =>
dispatch({ type: 'SET_CONFIG', payload: { moduleSize: +e.target.value } })
}
className="w-full mt-1 accent-blue-500"
/>
</label>
@@ -97,23 +109,34 @@ export default function ExportPanel() {
<label className="text-xs text-gray-600 dark:text-gray-400">
: {state.config.margin}
<input
type="range" min={1} max={10}
type="range"
min={1}
max={10}
value={state.config.margin}
onChange={e => dispatch({ type: 'SET_CONFIG', payload: { margin: +e.target.value } })}
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">
<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">
<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">
<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>
@@ -17,7 +17,7 @@ export default function HistoryList() {
const handleDelete = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
const updated = state.history.filter(h => h.id !== id);
const updated = state.history.filter((h) => h.id !== id);
dispatch({ type: 'SET_HISTORY', payload: updated });
persistHistory(updated);
};
@@ -39,8 +39,10 @@ export default function HistoryList() {
📋
</span>
{state.history.length > 0 && (
<button onClick={handleClear}
className="text-xs text-red-400 hover:text-red-600 transition-colors">
<button
onClick={handleClear}
className="text-xs text-red-400 hover:text-red-600 transition-colors"
>
</button>
)}
@@ -50,10 +52,12 @@ export default function HistoryList() {
{state.history.length === 0 && (
<p className="text-xs text-gray-400 text-center py-4"></p>
)}
{state.history.map(entry => (
<div key={entry.id}
{state.history.map((entry) => (
<div
key={entry.id}
onClick={() => handleClick(entry)}
className="group flex items-center justify-between px-2 py-1.5 rounded-lg text-xs cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-all">
className="group flex items-center justify-between px-2 py-1.5 rounded-lg text-xs cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="px-1 py-0.5 rounded text-[10px] font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
@@ -65,8 +69,10 @@ export default function HistoryList() {
{entry.content.length > 20 ? entry.content.slice(0, 20) + '...' : entry.content}
</span>
</div>
<button onClick={(e) => handleDelete(e, entry.id)}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 ml-1 transition-all text-lg leading-none">
<button
onClick={(e) => handleDelete(e, entry.id)}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 ml-1 transition-all text-lg leading-none"
>
×
</button>
</div>
@@ -9,7 +9,7 @@ export default function ModePanel() {
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 px-2">
</div>
{MODES.map(mode => (
{MODES.map((mode) => (
<button
key={mode}
onClick={() => dispatch({ type: 'SET_MODE', payload: mode })}
@@ -17,8 +17,7 @@ export default function QrPreview() {
[state.preview?.svg],
);
const containerCls =
'w-64 h-64 flex items-center justify-center bg-white rounded-xl shadow-sm';
const containerCls = 'w-64 h-64 flex items-center justify-center bg-white rounded-xl shadow-sm';
if (!svgDataUrl) {
return (
@@ -38,15 +37,13 @@ export default function QrPreview() {
<div className="flex flex-col items-center gap-3">
{/* 纯白背景 + 微阴影,无边框/圆角干扰扫描 */}
<div className={containerCls}>
<img
src={svgDataUrl}
alt="QR 码"
className="w-60 h-60"
/>
<img src={svgDataUrl} alt="QR 码" className="w-60 h-60" />
</div>
<div className="flex gap-3 text-xs text-gray-400">
<span> {state.preview!.version}</span>
<span>{state.preview!.size}×{state.preview!.size}</span>
<span>
{state.preview!.size}×{state.preview!.size}
</span>
<span> {state.preview!.mask}</span>
</div>
</div>
+8 -3
View File
@@ -40,7 +40,9 @@ export async function persistHistory(history: HistoryEntry[]): Promise<void> {
const store = await getStore();
await store.set(HISTORY_KEY, history);
await store.save();
} catch { /* store 不可用时静默忽略 */ }
} catch {
/* store 不可用时静默忽略 */
}
}
/** 从 store 加载历史记录(应用启动时调用) */
@@ -66,7 +68,8 @@ export function useQrEncode() {
};
}, []);
const encode = useCallback((text: string) => {
const encode = useCallback(
(text: string) => {
if (!text.trim()) {
dispatch({ type: 'SET_PREVIEW', payload: null });
return;
@@ -105,7 +108,9 @@ export function useQrEncode() {
dispatch({ type: 'SET_PREVIEW', payload: null });
}
}, 200);
}, [state.config.level, state.config.margin, state.formData, state.history, dispatch]);
},
[state.config.level, state.config.margin, state.formData, state.history, dispatch],
);
return { encode, persistHistory };
}
+6 -6
View File
@@ -1,10 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById("root")!).render(
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
</React.StrictMode>,
);
+18 -9
View File
@@ -14,15 +14,24 @@ export default function EmailMode() {
return (
<div className="flex gap-2 items-center h-full px-4">
<input placeholder="收件人" value={state.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="主题" value={state.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="正文" value={state.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" />
<input
placeholder="收件人"
value={state.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="主题"
value={state.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="正文"
value={state.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"
/>
</div>
);
}
+7 -3
View File
@@ -12,8 +12,12 @@ export default function PhoneMode() {
};
return (
<input placeholder="输入电话号码" type="tel" value={state.formData.number || ''}
onChange={e => update(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" />
<input
placeholder="输入电话号码"
type="tel"
value={state.formData.number || ''}
onChange={(e) => update(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"
/>
);
}
+13 -6
View File
@@ -14,12 +14,19 @@ export default function SmsMode() {
return (
<div className="flex gap-2 items-center h-full px-4">
<input placeholder="电话号码" type="tel" value={state.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="短信内容" value={state.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" />
<input
placeholder="电话号码"
type="tel"
value={state.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="短信内容"
value={state.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"
/>
</div>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@ export default function TextMode() {
<textarea
placeholder="输入任意文本..."
value={state.formData.text || ''}
onChange={e => handleChange(e.target.value)}
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"
/>
+1 -1
View File
@@ -24,7 +24,7 @@ export default function UrlMode() {
type="url"
placeholder="https://example.com"
value={state.formData.url || ''}
onChange={e => handleChange(e.target.value)}
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"
/>
+7 -4
View File
@@ -22,11 +22,14 @@ export default function VCardMode() {
return (
<div className="flex gap-2 items-center h-full px-4">
{FIELDS.map(f => (
<input key={f.key} placeholder={f.placeholder}
{FIELDS.map((f) => (
<input
key={f.key}
placeholder={f.placeholder}
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" />
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"
/>
))}
</div>
);
+23 -11
View File
@@ -15,22 +15,34 @@ export default function WifiMode() {
return (
<div className="flex gap-2 items-center h-full px-4">
<input placeholder="SSID" value={state.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="密码" type="password" value={state.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'}
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">
<input
placeholder="SSID"
value={state.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="密码"
type="password"
value={state.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'}
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"></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'}
onChange={e => update('hidden', e.target.checked ? 'true' : 'false')} />
<input
type="checkbox"
checked={state.formData.hidden === 'true'}
onChange={(e) => update('hidden', e.target.checked ? 'true' : 'false')}
/>
</label>
</div>
+1 -1
View File
@@ -28,7 +28,7 @@ function qrReducer(state: QrState, action: QrAction): QrState {
case 'ADD_HISTORY':
return { ...state, history: [action.payload, ...state.history].slice(0, 50) };
case 'REMOVE_HISTORY':
return { ...state, history: state.history.filter(h => h.id !== action.payload) };
return { ...state, history: state.history.filter((h) => h.id !== action.payload) };
case 'RESET':
return { ...initialState, history: state.history };
default:
+14 -7
View File
@@ -45,12 +45,19 @@ export function buildSmsText(formData: Record<string, string>): string {
/** 从完整 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 || '';
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 || '';
}
}
+5 -1
View File
@@ -14,7 +14,11 @@
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+6
View File
@@ -1,11 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: { port: 1420, strictPort: true },
envPrefix: ["VITE_", "TAURI_"],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
build: {
target: "esnext",
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
test: {
environment: 'jsdom',
exclude: ['node_modules/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**'],
exclude: ['src/main.tsx', 'src/vite-env.d.ts'],
thresholds: {
lines: 60,
},
},
},
});
+2
View File
@@ -0,0 +1,2 @@
[toolchain]
channel = "stable-x86_64-pc-windows-gnu"