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:
@@ -0,0 +1,32 @@
|
||||
# Git 行尾符规范化
|
||||
# 统一 CRLF(Windows 原生项目)
|
||||
|
||||
# 源码文本文件
|
||||
*.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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# 开源赞助
|
||||
# 支持 QRGen 的开发
|
||||
|
||||
github: LHY0125
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
cd gui/src-frontend && npx --no -- commitlint --edit $1
|
||||
@@ -0,0 +1 @@
|
||||
cd gui/src-frontend && npx lint-staged
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD033": {
|
||||
"allowed_elements": ["img", "br", "kbd", "summary", "details", "p"]
|
||||
},
|
||||
"MD041": false
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
target
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
pnpm-lock.yaml
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "crlf",
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
@@ -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 + 覆盖率
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
@@ -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-staged(prettier + eslint)
|
||||
# commit-msg: commitlint(Conventional Commits)
|
||||
```
|
||||
```
|
||||
|
||||
### 技术栈
|
||||
|
||||
+46
@@ -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 Library,80% 覆盖率
|
||||
- [ ] **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 18,7 种编码模式)
|
||||
- ✅ Web 服务(axum + Docker alpine 17.7MB 镜像)
|
||||
- ✅ 82 个 Rust 测试
|
||||
- ✅ 社区规范文件(CONTRIBUTING / CODE_OF_CONDUCT / SECURITY)
|
||||
|
||||
---
|
||||
|
||||
欢迎通过 [Issues](https://github.com/LHY0125/QRGen/issues) 提交功能建议!
|
||||
+71
@@ -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)
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
@@ -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'] }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3275
-5
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "stable-x86_64-pc-windows-gnu"
|
||||
Reference in New Issue
Block a user