Compare commits

..

22 Commits

Author SHA1 Message Date
c72a7fc72e Merge pull request '123' (#9) from dev into main
Reviewed-on: #9
2025-07-05 20:48:55 +08:00
bd25138da0 123
All checks were successful
continuous-integration/drone/pr Build is passing
2025-07-05 20:48:24 +08:00
091e619255 Merge pull request '更新' (#8) from dev into main
Reviewed-on: #8
2025-07-05 20:13:33 +08:00
a581cd7c51 更新
Some checks failed
continuous-integration/drone/pr Build is failing
2025-07-05 20:13:00 +08:00
28072339c6 Merge pull request '文字显示不正常的Bug' (#7) from dev into main
Reviewed-on: #7
2025-06-29 15:01:28 +08:00
8a973db679 文字显示不正常的Bug
All checks were successful
continuous-integration/drone/pr Build is passing
2025-06-29 15:00:17 +08:00
0f26fcb23b Merge pull request '增加印章' (#6) from dev into main
Reviewed-on: #6
2025-06-29 03:02:20 +08:00
a262c52c73 增加印章
All checks were successful
continuous-integration/drone/pr Build is passing
2025-06-29 03:01:56 +08:00
b86feaf329 Merge pull request '改title' (#5) from dev into main
Reviewed-on: #5
2025-06-28 22:56:29 +08:00
803139a1c9 改title
All checks were successful
continuous-integration/drone/pr Build is passing
2025-06-28 22:56:11 +08:00
65d9bd2a39 Merge pull request '改成hash' (#4) from dev into main
Reviewed-on: #4
2025-06-28 22:54:36 +08:00
af8b5f6a8d 改成hash 2025-06-28 22:54:13 +08:00
011e41432c Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2025-06-28 22:50:30 +08:00
27f6a6384a 1
All checks were successful
continuous-integration/drone/pr Build is passing
2025-06-28 22:50:01 +08:00
d73506cf81 Merge pull request 'main' (#2) from main into dev
Reviewed-on: #2
2025-06-28 22:48:18 +08:00
ed82688478 改title 2025-06-28 22:41:36 +08:00
8400dbfab9 工具完成
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-06-28 22:38:49 +08:00
2c668fedd0 1
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-28 17:17:42 +08:00
fe98a5292a 2
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-28 17:15:47 +08:00
fe8cfd1c55 1
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-28 17:11:30 +08:00
16287e959b Merge pull request '测试' (#1) from dev into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #1
2025-06-28 17:00:57 +08:00
5ab97a2926 测试
Some checks failed
continuous-integration/drone/pr Build is failing
2025-06-28 16:58:05 +08:00
69 changed files with 25447 additions and 152 deletions

89
.drone copy.yml Normal file
View File

@ -0,0 +1,89 @@
kind: pipeline # 定义一个管道
type: docker # 当前管道的类型
name: test # 当前管道的名称
steps:
# 第一步:构建项目
- name: 构建项目
image: node:18-alpine
commands:
- rm -rf node_modules
- npm ci
- npm run build
# 第二步上传静态资源到腾讯云COS (使用另一个插件)
- name: 静态资源上传到cos
image: ccr.ccs.tencentyun.com/xiaoqidun/gocos
settings:
secret_id:
from_secret: cos_secret_id
secret_key:
from_secret: cos_secret_key
bucket_url: https://files-1302416092.cos.ap-shanghai.myqcloud.com
source: dist
target: /utils
strip_prefix: dist
# 第三步:部署到服务器
- name: 清除服务器缓存
image: appleboy/drone-ssh
settings:
host:
from_secret: server_host
username:
from_secret: server_username
password:
from_secret: server_password
# 或者使用SSH密钥
# key:
# from_secret: server_ssh_key
port: 22
script:
- rm -rf /www/wwwroot/utils.zguiy.com/*
- mkdir -p /www/wwwroot/utils.zguiy.com/
- chmod 755 /www/wwwroot/utils.zguiy.com/
when:
branch:
- main
- master
- dev
# 第四步:上传构建文件
- name: 上传构建文件
image: appleboy/drone-scp
settings:
host:
from_secret: server_host
username:
from_secret: server_username
password:
from_secret: server_password
# 或者使用SSH密钥
# key:
# from_secret: server_ssh_key
port: 22
source: dist/*
target: /www/wwwroot/utils.zguiy.com/
strip_components: 1
when:
branch:
- main
- master
- dev
# 定义数据卷用于缓存node_modules
volumes:
- name: node_modules_cache
host:
path: /tmp/drone_cache/node_modules
# 触发条件
trigger:
branch:
- main
- master
- dev
event:
- push
- pull_request

84
.drone.yml Normal file
View File

@ -0,0 +1,84 @@
kind: pipeline # 定义一个管道
type: docker # 当前管道的类型
name: test # 当前管道的名称
steps:
# 第一步:构建项目
- name: 构建项目
image: node:18-alpine
commands:
- rm -rf node_modules
- npm ci
- npm run build
# 第二步上传静态资源到腾讯云COS (使用另一个插件)
- name: 静态资源上传到cos
image: ccr.ccs.tencentyun.com/xiaoqidun/gocos
settings:
secret_id:
from_secret: cos_secret_id
secret_key:
from_secret: cos_secret_key
bucket_url: https://files-1302416092.cos.ap-shanghai.myqcloud.com
source: dist
target: /utils
strip_prefix: dist
# 第三步:部署到服务器
- name: 清除服务器缓存
image: appleboy/drone-ssh
settings:
host:
from_secret: server_host
username:
from_secret: server_username
password:
from_secret: server_password
# 或者使用SSH密钥
# key:
# from_secret: server_ssh_key
port: 22
script:
- rm -rf /www/wwwroot/utils.zguiy.com/*
- mkdir -p /www/wwwroot/utils.zguiy.com/
- chmod 755 /www/wwwroot/utils.zguiy.com/
when:
branch:
- main
- master
- dev
# 第四步:上传构建文件
- name: 上传构建文件
image: appleboy/drone-scp
settings:
host:
from_secret: server_host
username:
from_secret: server_username
password:
from_secret: server_password
# 或者使用SSH密钥
# key:
# from_secret: server_ssh_key
port: 22
source: dist/*
target: /www/wwwroot/utils.zguiy.com/
strip_components: 1
when:
branch:
- main
- master
- dev
# 触发条件
trigger:
branch:
- main
- master
- dev
event:
- push
- pull_request

1
.env.development Normal file
View File

@ -0,0 +1 @@
VITE_PUBLIC = /

1
.env.production Normal file
View File

@ -0,0 +1 @@
VITE_PUBLIC = https://cdn.files.zguiy.com/utils/

201
README.md
View File

@ -1,5 +1,200 @@
# Vue 3 + TypeScript + Vite
# Vue 工具集 - Vue Tools Kit
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
这是一个基于 Vue 3 + TypeScript + Tailwind CSS 开发的在线工具集合,移植自 jisuxiang-master 项目。
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
## 特性
- 🚀 Vue 3 + Composition API + `<script setup>` 语法糖
- 📱 响应式设计,支持移动端
- 🎨 深色/浅色主题切换
- 🌐 中英文双语支持
- ⚡ Vite 构建工具,开发体验优秀
- 🎯 TypeScript 类型安全
- 💻 包含 28+ 实用开发工具
## 工具列表
### 常用工具
- JSON 格式化器 - JSON格式化、美化、压缩和验证
- HTTP 测试器 - API接口测试工具
- 时间戳转换器 - 时间戳与日期时间相互转换
- 编码转换器 - Base64、URL编码、Unicode转换
- IP 查询工具 - IP地址归属地查询
### JSON 工具
- JSON 编辑器 - 可视化JSON编辑
- JSON 转换器 - JSON与XML、CSV、YAML互转
### 编码工具
- 正则表达式测试器 - 正则表达式测试和验证
- 加密解密工具 - MD5、SHA、AES、DES等
- URL 编码器 - URL编码解码
- Unicode 转换器 - Unicode与中文互转
- JWT 解码器 - JWT令牌解析
- 进制转换器 - 二进制、八进制、十进制、十六进制转换
### 文本工具
- 字数统计器 - 字符、词数、行数统计
- 文本处理器 - 去空格、去换行等
### 代码工具
- 代码格式化器 - HTML、CSS、JS、SQL格式化
- HTML/Markdown 转换器 - HTML与Markdown互转
- YAML/Properties 转换器 - 配置文件格式转换
### 图片工具
- 图片压缩器 - 在线图片压缩
- 二维码生成器 - 文本转二维码
- Base64图片转换器 - 图片与Base64互转
- 图片水印工具 - 添加文字或图片水印
- ICO图标生成器 - 图片转ICO格式
### 前端工具
- 颜色工具 - 颜色选择器和转换
- CSS渐变生成器 - 可视化CSS渐变生成
### 时间工具
- 日期计算器 - 日期差值计算
- 时区转换器 - 不同时区时间转换
- Cron表达式生成器 - 定时任务表达式生成
## 技术栈
- **前端**: Vue 3, TypeScript, Tailwind CSS, Vite
- **图标**: FontAwesome
- **HTTP客户端**: Axios
- **后端**: Node.js, Express
- **工具库**:
- marked (Markdown解析)
- turndown (HTML转Markdown)
- crypto-js (加密解密)
- qrcode (二维码生成)
- fabric.js (图片处理)
- compressor.js (图片压缩)
## 快速开始
### 安装依赖
```bash
npm install
```
### 开发模式
```bash
# 启动前端开发服务器
npm run dev
# 启动后端API服务器新终端
npm run server
```
访问 `http://localhost:8080` 查看应用
### 生产构建
```bash
# 构建前端
npm run build
# 启动生产服务器包含前端和API
npm run server
```
## 项目结构
```
src/
├── components/ # 通用组件
│ ├── tools/ # 工具组件
│ ├── BackToTop.vue # 返回顶部
│ ├── ThemeToggle.vue # 主题切换
│ └── LanguageToggle.vue # 语言切换
├── composables/ # 组合式API
│ ├── useLanguage.ts # 语言管理
│ └── useTheme.ts # 主题管理
├── config/ # 配置文件
│ ├── tools.ts # 工具配置
│ ├── categories.ts # 分类配置
│ └── i18n/ # 国际化
├── router/ # 路由配置
├── types/ # TypeScript类型
├── utils/ # 工具函数
├── views/ # 页面组件
└── style.css # 全局样式
server/
├── index.js # 服务器入口
└── routes/
└── api.js # API路由
```
## 开发指南
### 添加新工具
1.`src/config/tools.ts` 中添加工具配置
2.`src/config/i18n/` 中添加多语言支持
3.`src/components/tools/` 中创建工具组件
4.`src/views/ToolView.vue` 的映射表中注册组件
### 组件开发规范
- 使用 `<script setup>` 语法糖
- 使用箭头函数
- 使用 Composition API
- 遵循 TypeScript 类型约束
- 使用 Tailwind CSS 进行样式开发
### 主题和样式
项目使用 CSS 变量实现主题切换,主要颜色变量定义在 `src/style.css` 中。
### 国际化
使用自定义的国际化系统,配置文件在 `src/config/i18n/` 目录下。
## API 服务
项目包含 Node.js 后端提供以下API
- `GET /api/ip/:ip?` - IP地址查询
- `GET /api/myip` - 获取当前IP
- `POST /api/proxy` - HTTP代理请求
- `POST /api/markdown/convert` - Markdown转换
- `POST /api/format/code` - 代码格式化
## 部署
### 使用 Node.js
```bash
npm run build
npm run server
```
### 使用 Docker
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3001
CMD ["npm", "run", "server"]
```
## 贡献
欢迎提交 Issue 和 Pull Request
## 许可证
MIT License
## 致谢
本项目移植自 [jisuxiang-master](https://github.com/star7th/jisuxiang),感谢原作者的开源贡献。

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<title>子归云工具箱</title>
</head>
<body>
<div id="app"></div>

4266
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,52 @@
{
"name": "client",
"name": "vue-tools-kit",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"build": "vite build",
"preview": "vite preview",
"server": "node server/index.js"
},
"dependencies": {
"vue": "^3.5.13"
"vue": "^3.5.13",
"vue-router": "^4.4.5",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"crypto-js": "^4.2.0",
"marked": "^15.0.7",
"turndown": "^7.2.0",
"compressorjs": "^1.2.1",
"nanoid": "^5.1.5",
"fabric": "^6.6.4",
"cron-parser": "^5.1.1",
"cronstrue": "^2.59.0",
"iconv-lite": "^0.6.3",
"qrcode": "^1.5.4",
"express": "^4.21.2",
"cors": "^2.8.5",
"axios": "^1.7.9",
"node-fetch": "^2.7.0"
},
"devDependencies": {
"@types/node": "^24.0.3",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"@types/crypto-js": "^4.2.2",
"@types/marked": "^5.0.2",
"@types/turndown": "^5.0.5",
"@types/qrcode": "^1.5.5",
"@types/express": "^5.0.0",
"@types/cors": "^2.8.17",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vue-tsc": "^2.2.8"
"vue-tsc": "^2.2.8",
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38"
}
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

526
public/index.html Normal file
View File

@ -0,0 +1,526 @@
<!DOCTYPE html>
<!--
MIT License
Copyright (c) 2016 Loo Rong Jie (Rong Jie Loo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Special thanks to David King, Hennie Rozengarden, Jeroen Van Antwerpen,
Cor Koomen and other TC/RS Chromies in refining this tool.
-->
<html lang="en">
<head>
<title>Chrome Bookmarks Recovery Tool</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,minimum-scale=1.0">
<meta name="description" content="Tool to recover your lost Chrome bookmarks">
<link rel="canonical" href="https://rongjiecomputer.github.io/chrome/bookmark-recovery/">
<link rel="alternate" hreflang="en" href="https://rongjiecomputer.github.io/chrome/bookmark-recovery/">
<link rel="alternate" hreflang="es" href="https://rongjiecomputer.github.io/chrome/bookmark-recovery/index.es.html">
<meta property="og:type" content="website">
<meta property="og:title" content="Chrome Bookmarks Recovery Tool">
<meta property="og:url" content="https://rongjiecomputer.github.io/chrome/bookmark-recovery/">
<meta property="og:site_name" content="rongjiecomputer.github.io">
<meta property="og:description" content="Tool to recover your lost Chrome bookmarks">
<meta property="og:image" content="https://rongjiecomputer.github.io/chrome/bookmark-recovery/share.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Chrome Bookmarks Recovery Tool">
<meta name="twitter:url" content="https://rongjiecomputer.github.io/chrome/bookmark-recovery/">
<meta name="twitter:description" content="Tool to recover your lost Chrome bookmarks">
<meta name="twitter:image" content="https://rongjiecomputer.github.io/chrome/bookmark-recovery/share.png">
<style>
html, body {
font-family: "Helvetica", Arial, sans-serif;
font-size: 14px;
background-color: #fff;
margin: 0;
padding: 0;
}
header {
background-color: #f5f5f5;
padding: 10px 30px 10px 30px;
border-bottom: 1px solid #e5e5e5;
}
h1 {
font-size: 32px;
font-weight: 400;
}
a {
color: #4285f4;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.github-link {
display: block;
position: absolute;
width: 60px;
height: 60px;
top: 0;
right: 0;
z-index: 100;
}
main {
padding: 30px;
}
p, li {
line-height: 1.5em;
word-break: break-word;
}
#tabs {
height: 48px;
margin-bottom: 32px;
padding: 0;
overflow: hidden;
border-bottom: 1px solid #e0e0e0;
}
#tabs a {
display: inline-block;
float: left;
color: #9e9e9e;
font-weight: 500;
line-height: 47px;
text-transform: uppercase;
padding: 0 10px;
text-decoration: none;
}
#tabs .active {
color: #4285f4;
border-bottom: 2px solid #4285f4;
}
.soln {
display: none;
}
.show {
display: block;
}
code {
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
background-color: #f7f7f7;
}
#block {
max-width: 650px;
margin-bottom: 30px;
padding: 10px;
border: 1px solid #ccc;
}
#drop_zone {
border: 2px dashed #bbb;
border-radius: 5px;
padding: 25px;
text-align: center;
color: #bbb;
}
.is_dragover {
border: 4px dashed #4386fa !important;
color: #4386fa !important;
}
.is_dragover svg {
fill: #4386fa;
}
label {
position: relative;
}
label b {
color: #000;
}
label:hover b {
color: #4386fa;
}
input[type="file"] {
position: absolute;
width: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0;
}
#tooltip {
display: none;
}
#drop_zone svg, #drop_zone span {
display: block;
margin: auto;
}
output {
display: block;
padding: 25px;
text-align: center;
}
output b {
color: red;
}
output ul {
padding: 0;
margin: 0;
list-style-type: none;
text-align: left;
}
output a {
color: #555;
text-decoration: underline;
}
output a[data-disabled] {
color: #ccc;
text-decoration: line-through;
}
.zip {
border-top: 1px solid #e0e0e0;
}
.zip-h {
display: block;
background-color: #fff;
color: #4285f4;
font-size: 14px;
font-weight: normal;
padding: 16px 50px 12px 16px;
margin: 0;
position: relative;
z-index: 1;
width: 100%;
border: 0;
text-align: left;
}
.zip-h:hover {
text-decoration: none;
}
.zip-h:before {
background: no-repeat url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%234285f4' d='M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z'/%3E%3C/svg%3E");
background-size: 24px;
content: '';
margin-top: -12px;
position: absolute;
right: 18px;
top: 50%;
width: 22px;
height: 22px;
transition: transform 0.3s;
}
.zip-content {
display: none;
margin-left: 18px;
padding: 1px 26px 19px 16px;
position: relative;
}
.zip-content:before {
background-color: #e0e0e0;
content: '';
position: absolute;
width: 2px;
height: 100%;
left: 0;
top: -19px;
}
.unzip .zip-h:before {
transform: rotate(-180deg);
}
.unzip .zip-content {
display: block;
}
footer {
background-color: #f5f5f5;
border-top: 1px solid #e5e5e5;
}
footer a {
display: inline-block;
padding: 20px 14px;
color: #999;
}
</style>
</head>
<body>
<header>
<h1>Chrome Bookmarks Recovery Tool</h1>
</header>
<main>
<div id="tabs" role="tablist" aria-label="Instructions">
<a href="#windows" id="windows-tab" role="tab" aria-controls="windows-soln">Windows</a>
<a href="#mac" id="mac-tab" role="tab" aria-controls="mac-soln">Mac</a>
<a href="#linux" id="linux-tab" role="tab" aria-controls="linux-soln">Linux</a>
<a href="#google" id="google-tab" role="tab" aria-controls="google-soln">Google Server</a>
</div>
<div class="soln" id="windows-soln" role="tabpanel" aria-labelledby="windows-tab" aria-hidden="true" tabindex="0">
<p>Here is how to recover your lost bookmarks (Windows):</p>
<ol>
<li>Copy <code>C:\Users\%username%\AppData\Local\Google\Chrome\User Data</code> into File Explorer.</li>
<li>In search bar, type <code>Bookmarks</code>, you will see a list of files named <code>Bookmarks</code> and/or <code>Bookmarks.bak</code>. (<i>Note: If there is more than one user using the same Chrome, bookmarks from other users will be listed too</i>)</li>
<li>Select all the files with mouse and drag them to the block below.</li>
<li>Download all the HTML files.</li>
<li>Open each HTML file with Chrome and determine the HTML file that contains your bookmarks. (<i>Note: The largest file is most likely the correct one</i>)</li>
<li>In your Chrome browser, click the Chrome menu icon and go to Bookmarks &gt; Bookmark Manager.</li>
<li>Click the menu icon beside search bar and click "Import Bookmarks".</li>
<li>Select the HTML file that contains your bookmarks.</li>
<li>Your bookmarks should now be imported back to Chrome.</li>
</ol>
</div>
<div class="soln" id="mac-soln" role="tabpanel" aria-labelledby="mac-tab" aria-hidden="true" tabindex="0">
<p>Here is how to recover your lost bookmarks (Mac):</p>
<ol>
<li>In the Mac menu bar at the top of the screen, click Go.</li>
<li>Select Go to Folder.</li>
<li>Type <code>~/Library/Application Support/Google/Chrome</code> and click Go.</li>
<li>In search bar, type <code>Bookmarks</code>, you will see a list of files named <code>Bookmarks</code> and/or <code>Bookmarks.bak</code>. (<i>Note: If there is more than one user using the same Chrome, bookmarks from other users will be listed too</i>)</li>
<li>Select all the files with mouse and drag them to the block below.</li>
<li>Download all the HTML files.</li>
<li>Open each HTML file with Chrome and determine the HTML file that contains your bookmarks. (<i>Note: The largest file is most likely the correct one</i>)</li>
<li>In your Chrome browser, click the Chrome menu icon and go to Bookmarks &gt; Bookmark Manager.</li>
<li>Click the menu icon beside search bar and click "Import Bookmarks".</li>
<li>Select the HTML file that contains your bookmarks.</li>
<li>Your bookmarks should now be imported back to Chrome.</li>
</ol>
</div>
<div class="soln" id="linux-soln" role="tabpanel" aria-labelledby="linux-tab" aria-hidden="true" tabindex="0">
<p>Here is how to recover your lost bookmarks (Linux):</p>
<ol>
<li>Open your file explorer (<i>Nautilus, Dolphin ...</i>).</li>
<li>Go to <code>~/.config/google-chrome</code> (<i>Use Ctrl+L on Nautilus</i>).</li>
<li>Search for <code>Bookmarks*</code>, you will see a list of files named <code>Bookmarks</code> and/or <code>Bookmarks.bak</code>. (<i>Note: If there is more than one user using the same Chrome, bookmarks from other users will be listed too</i>)</li>
<li>Select all the files with mouse and drag them to the block below.</li>
<li>Download all the HTML files.</li>
<li>Open each HTML file with Chrome and determine the HTML file that contains your bookmarks. (<i>Note: The largest file is most likely the correct one</i>)</li>
<li>In your Chrome browser, click the Chrome menu icon and go to Bookmarks &gt; Bookmark Manager.</li>
<li>Click the menu icon beside search bar and click "Import Bookmarks".</li>
<li>Select the HTML file that contains your bookmarks.</li>
<li>Your bookmarks should now be imported back to Chrome.</li>
</ol>
</div>
<div class="soln" id="google-soln" role="tabpanel" aria-labelledby="google-tab" aria-hidden="true" tabindex="0">
<p>If you are sure that Chrome sync data stored in Google server still have your bookmarks, here is how to retrieve them from Google server:</p>
<ol>
<li>Go to <a href="https://takeout.google.com/settings/takeout" target="_blank">Google Takeout</a> and sign in to the Google Account used for Chrome Sync.</li>
<li>Click the "Select None" button.</li>
<li>Check "Chrome" in product list.</li>
<li>Expand "All Chrome data types".</li>
<li>Click the "Select Chrome data" option.</li>
<li>In the popup, check the "Bookmarks" option and uncheck the rest.</li>
<li>Scroll down to the bottom of the page and click "Next" button.</li>
<li>Click the "Create archive" button.</li>
<li>Download and unzip the archive.</li>
<li>In the unzipped folder, navigate to "Chrome" folder. The HTML file named "Bookmarks" is the one we need.</li>
<li>In your Chrome browser, click the Chrome menu icon and go to Bookmarks &gt; Bookmark Manager.</li>
<li>Click the menu icon beside search bar and click "Import Bookmarks".</li>
<li>Select the HTML file that contains your bookmarks.</li>
<li>Your bookmarks should now be imported back to Chrome.</li>
</ol>
</div>
<form id="block" tabindex="-1">
<div id="drop_zone">
<svg aria-hidden="true" fill="#bbb" width="48" height="48" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/>
</svg>
<label>
<input tabindex="0" type="file" multiple id="input" aria-describedby="tooltip"/>
<b>Choose a file</b> or drag it here
</label>
<div role="tooltip" id="tooltip">Search with the keyword of "Bookmarks" and select all the files found.</div>
</div>
<output id="output" role="alert" aria-live="assertive" aria-hidden="true">The HTML file will be produced here later.</output>
</form>
<h3>Other ways to recover bookmarks</h3>
<div class="zip">
<button id="zip-h-2" class="zip-h" aria-expanded="false" aria-controls="zip-2">Personal backup</button>
<div id="zip-2" role="region" aria-labelledby="zip-h-2" aria-hidden="true" class="zip-content">
<p>In the case when backups of your computer are available, find the bookmarks files in the backups, upload them to this tool and continue with the steps above.</p>
</div>
</div>
<div class="zip">
<button id="zip-h-3" class="zip-h" aria-expanded="false" aria-controls="zip-3">Recover from old devices</button>
<div id="zip-3" role="region" aria-labelledby="zip-h-3" aria-hidden="true" class="zip-content">
<p>Do you have a device (e.g: old laptop) that is signed in to your Google Account and it hasn't been synced since the moment your bookmarks were lost? If so, find the files in this device, upload them to this tool and continue with the steps above.</p>
</div>
</div>
<div class="zip">
<button id="zip-h-4" class="zip-h" aria-expanded="false" aria-controls="zip-4">Tips and tricks</button>
<div id="zip-4" role="region" aria-labelledby="zip-h-4" aria-hidden="true" class="zip-content">
<p>You can use this tool or <i>Export Bookmarks</i> function in Bookmark Manager to generate HTML file that can be used as archive/backup of your bookmarks. Regularly making backup is useful in case your bookmarks get lost again in the future.</p>
</div>
</div>
<p><i>Disclaimer: this is not an official Google product.</i></p>
</main>
<footer>
<a href="https://support.google.com/chrome/">Chrome Help Center</a>
<a href="https://productforums.google.com/forum/#!forum/chrome">Chrome Help Forum</a>
</footer>
<a class="github-link" href="https://github.com/rongjiecomputer/chrome/tree/gh-pages/bookmark-recovery" title="Source on Github">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 60.5 60.5" width="60" height="60">
<polygon fill="#9dacb3" points="60.5,60.5 0,0 60.5,0"/>
<path fill="#f5f5f5" d="M43.1,5.8c-6.6,0-12,5.4-12,12c0,5.3,3.4,9.8,8.2,11.4c0.6,0.1,0.8-0.3,0.8-0.6c0-0.3,0-1,0-2c-3.3,0.7-4-1.6-4-1.6c-0.5-1.4-1.3-1.8-1.3-1.8c-1.1-0.7,0.1-0.7,0.1-0.7c1.2,0.1,1.8,1.2,1.8,1.2c1.1,1.8,2.8,1.3,3.5,1c0.1-0.8,0.4-1.3,0.8-1.6c-2.7-0.3-5.5-1.3-5.5-5.9c0-1.3,0.5-2.4,1.2-3.2c-0.1-0.3-0.5-1.5,0.1-3.2c0,0,1-0.3,3.3,1.2c1-0.3,2-0.4,3-0.4c1,0,2,0.1,3,0.4c2.3-1.6,3.3-1.2,3.3-1.2c0.7,1.7,0.2,2.9,0.1,3.2c0.8,0.8,1.2,1.9,1.2,3.2c0,4.6-2.8,5.6-5.5,5.9c0.4,0.4,0.8,1.1,0.8,2.2c0,1.6,0,2.9,0,3.3c0,0.3,0.2,0.7,0.8,0.6c4.8-1.6,8.2-6.1,8.2-11.4C55.1,11.2,49.7,5.8,43.1,5.8z"/>
</svg>
</a>
<script>
'use strict';
(function() {
var $ = document.querySelector.bind(document);
function Bookmark(raw) {
this.tree = JSON.parse(raw);
this.html = '';
this.count = 0;
this.first = true;
}
function chromeTime2TimeT(time) {
return Math.floor((time - 11644473600000000) / 1000000);
}
Bookmark.prototype.walk = function(node) {
if (node.type === 'folder') {
this.html += '<DT><H3 ADD_DATE="' + chromeTime2TimeT(node.date_added) + '" LAST_MODIFIED="' + chromeTime2TimeT(node.date_modified) + '"';
if (this.first) {
this.html += ' PERSONAL_TOOLBAR_FOLDER="true"';
this.first = false;
}
this.html += '>' + node.name + '</H3>\n';
this.html += '<DL><p>\n'
node.children.forEach(this.walk.bind(this));
this.html += '</DL><p>\n';
} else { // node.type == 'url'
this.html += '<DT><A HREF="' + node.url + '" ADD_DATE="' + chromeTime2TimeT(node.date_added) + '">' + node.name + '</A>\n';
this.count++;
}
}
Bookmark.prototype.parse = function() {
this.html = '<!DOCTYPE NETSCAPE-Bookmark-file-1><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"><TITLE>Bookmarks</TITLE><H1>Bookmarks</H1>\n';
this.html += '<DL><p>\n';
var roots = this.tree.roots;
this.walk(roots.bookmark_bar);
if (roots.other.children.length > 0)
this.walk(roots.other);
if (roots.synced.children.length > 0)
this.walk(roots.synced);
this.html += '<style>dt, dl { padding-left: 12px; }</style>\n';
}
Bookmark.prototype.newLink = function() {
var blob = new Blob([this.html], {type: 'text/plain'});
var a = document.createElement('a');
a.download = 'bookmark_backup.html';
a.href = window.URL.createObjectURL(blob);
a.textContent = 'Download ready (' + this.count + ' bookmarks found)';
a.onclick = function(e) {
if ('disabled' in this.dataset) {
return false;
}
this.textContent = 'Downloaded';
this.dataset.disabled = true;
setTimeout(function() {
window.URL.revokeObjectURL(this.href);
}, 1500);
};
return a;
}
function readFile(file) {
var reader = new FileReader();
var li = document.createElement('li');
li.innerHTML = '<strong>Last updated: ' + (file.lastModifiedDate !== undefined ? file.lastModifiedDate.toLocaleDateString() : 'n/a') + '</strong> (' + file.size + 'B) ';
reader.onloadend = function() {
try {
var bookmark = new Bookmark(reader.result);
bookmark.parse();
li.appendChild(bookmark.newLink());
} catch(e) {
li.innerHTML += '<b>Error! The file is invalid. Try again!</b>';
}
};
reader.readAsText(file);
return li;
}
var input = $('#input');
var output = $('#output');
function handleFile(e) {
var files = e.target.files;
output.textContent = 'Loading ...';
var ul = document.createElement('ul');
for (var i = 0; i < files.length; i++) {
ul.appendChild(readFile(files[i]));
}
output.innerHTML = '';
output.setAttribute('aria-hidden', false);
output.appendChild(ul);
}
input.addEventListener('change', handleFile, false);
function handleFileSelect(e) {
e.stopPropagation();
e.preventDefault();
var files = e.dataTransfer.files;
output.textContent = 'Loading ...';
var ul = document.createElement('ul');
for (var i = 0; i < files.length; i++) {
ul.appendChild(readFile(files[i]));
}
output.innerHTML = '';
output.appendChild(ul);
this.classList.remove('is_dragover');
}
function handleDragOver(e) {
e.stopPropagation();
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
this.classList.add('is_dragover');
}
function handleDragLeave(e) {
e.stopPropagation();
e.preventDefault();
this.classList.remove('is_dragover');
}
var dropZone = document.getElementById('drop_zone');
dropZone.addEventListener('dragover', handleDragOver, false);
dropZone.addEventListener('drop', handleFileSelect, false);
dropZone.addEventListener('dragleave', handleDragLeave, false);
var solns = ["#windows", "#mac", "#linux", "#google"];
function handleHashChange(e) {
var hash = location.hash;
for (var i = 0; i < solns.length; i++) {
var id = solns[i];
var tab = $(id + '-tab');
var soln = $(id + '-soln');
var visible;
if (hash === id) {
tab.classList.add('active');
soln.classList.add('show');
visible = true;
} else {
tab.classList.remove('active');
soln.classList.remove('show');
visible = false;
}
tab.setAttribute('aria-selected', visible);
soln.setAttribute('aria-hidden', !visible);
}
}
window.addEventListener('hashchange', handleHashChange, false);
if (location.hash == '') location.hash = '#windows';
handleHashChange();
function toggleZip(e) {
var visible = this.parentNode.classList.toggle('unzip');
this.setAttribute('aria-expanded', visible);
this.nextElementSibling.setAttribute('aria-hidden', !visible);
}
var zipH = document.querySelectorAll('.zip-h');
for (var i = 0; i < zipH.length; i++) {
zipH[i].addEventListener('click', toggleZip, false);
}
}());
</script>
</body>
</html>

32
server/index.js Normal file
View File

@ -0,0 +1,32 @@
const express = require('express')
const cors = require('cors')
const path = require('path')
const app = express()
const PORT = process.env.PORT || 3001
// 中间件
app.use(cors())
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
// 静态文件服务
app.use(express.static(path.join(__dirname, '../dist')))
// API路由
app.use('/api', require('./routes/api'))
// 健康检查
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
// 处理Vue Router的历史模式
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../dist/index.html'))
})
// 启动服务器
app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`)
})

216
server/routes/api.js Normal file
View File

@ -0,0 +1,216 @@
const express = require('express')
const router = express.Router()
const { promisify } = require('util')
const { exec } = require('child_process')
const execAsync = promisify(exec)
// IP查询API
router.get('/ip/:ip?', async (req, res) => {
try {
const ip = req.params.ip || req.ip || req.connection.remoteAddress
// 如果是IPv6的IPv4映射地址提取IPv4部分
const cleanIp = ip.replace(/^::ffff:/, '')
// 使用免费的IP查询服务
const fetch = require('node-fetch')
const response = await fetch(`http://ip-api.com/json/${cleanIp}?lang=zh-CN`)
const data = await response.json()
if (data.status === 'success') {
res.json({
success: true,
data: {
ip: cleanIp,
country: data.country,
region: data.regionName,
city: data.city,
isp: data.isp,
timezone: data.timezone,
lat: data.lat,
lon: data.lon
}
})
} else {
res.json({
success: false,
message: '无法查询该IP地址信息'
})
}
} catch (error) {
console.error('IP查询错误:', error)
res.status(500).json({
success: false,
message: '服务器内部错误'
})
}
})
// 获取当前IP
router.get('/myip', (req, res) => {
const ip = req.ip || req.connection.remoteAddress
const cleanIp = ip.replace(/^::ffff:/, '')
res.json({
success: true,
data: {
ip: cleanIp
}
})
})
// 代理请求API用于HTTP测试工具
router.post('/proxy', async (req, res) => {
try {
const { url, method = 'GET', headers = {}, body } = req.body
if (!url) {
return res.status(400).json({
success: false,
message: 'URL参数不能为空'
})
}
const fetch = require('node-fetch')
const options = {
method: method.toUpperCase(),
headers: {
'User-Agent': 'Vue-Tools-Kit/1.0.0',
...headers
}
}
if (body && ['POST', 'PUT', 'PATCH'].includes(options.method)) {
options.body = body
}
const startTime = Date.now()
const response = await fetch(url, options)
const endTime = Date.now()
const responseHeaders = {}
for (const [key, value] of response.headers.entries()) {
responseHeaders[key] = value
}
const responseBody = await response.text()
res.json({
success: true,
data: {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
body: responseBody,
responseTime: endTime - startTime,
url: response.url
}
})
} catch (error) {
console.error('代理请求错误:', error)
res.status(500).json({
success: false,
message: error.message || '请求失败'
})
}
})
// Markdown转换API
router.post('/markdown/convert', (req, res) => {
try {
const { content, type } = req.body
if (!content) {
return res.status(400).json({
success: false,
message: '内容不能为空'
})
}
if (type === 'html-to-md') {
// HTML转Markdown
const TurndownService = require('turndown')
const turndownService = new TurndownService()
const markdown = turndownService.turndown(content)
res.json({
success: true,
data: { result: markdown }
})
} else if (type === 'md-to-html') {
// Markdown转HTML
const { marked } = require('marked')
const html = marked(content)
res.json({
success: true,
data: { result: html }
})
} else {
res.status(400).json({
success: false,
message: '不支持的转换类型'
})
}
} catch (error) {
console.error('Markdown转换错误:', error)
res.status(500).json({
success: false,
message: '转换失败: ' + error.message
})
}
})
// 文件格式化API
router.post('/format/code', (req, res) => {
try {
const { content, type } = req.body
if (!content) {
return res.status(400).json({
success: false,
message: '内容不能为空'
})
}
let formatted = content
switch (type) {
case 'json':
try {
const parsed = JSON.parse(content)
formatted = JSON.stringify(parsed, null, 2)
} catch (e) {
throw new Error('JSON格式错误')
}
break
case 'xml':
// 简单的XML格式化
formatted = content
.replace(/></g, '>\n<')
.replace(/^\s*\n/gm, '')
break
default:
throw new Error('不支持的格式化类型')
}
res.json({
success: true,
data: { result: formatted }
})
} catch (error) {
console.error('代码格式化错误:', error)
res.status(500).json({
success: false,
message: error.message || '格式化失败'
})
}
})
module.exports = router

View File

@ -1,30 +1,26 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
<div id="app" class="min-h-screen bg-main text-primary">
<router-view />
</div>
<HelloWorld msg="Vite + Vue" />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useLanguage } from '@/composables/useLanguage'
const { restoreTheme } = useTheme()
const { restoreLanguage } = useLanguage()
onMounted(() => {
// 恢复主题和语言设置
restoreTheme()
restoreLanguage()
})
</script>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
#app {
min-height: 100vh;
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<Transition name="fade">
<button
v-if="isVisible"
@click="scrollToTop"
class="fixed bottom-8 right-8 p-3 bg-primary-500 hover:bg-primary-600 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-300 z-50"
title="返回顶部"
>
<FontAwesomeIcon :icon="['fas', 'arrow-up']" class="w-5 h-5" />
</button>
</Transition>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const isVisible = ref(false)
const checkScroll = () => {
isVisible.value = window.pageYOffset > 300
}
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
})
}
onMounted(() => {
window.addEventListener('scroll', checkScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', checkScroll)
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -1,41 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<div class="relative">
<button
@click="toggleDropdown"
class="flex items-center space-x-1 p-2 rounded-lg bg-card hover:bg-block-hover text-secondary hover:text-primary transition-all duration-200"
>
<span class="text-sm font-medium">{{ language.toUpperCase() }}</span>
<FontAwesomeIcon
:icon="['fas', 'chevron-down']"
:class="['w-3 h-3 transition-transform duration-200', isOpen && 'rotate-180']"
/>
</button>
<div
v-if="isOpen"
class="absolute right-0 mt-2 w-20 bg-card border border-opacity-15 border-primary-500 rounded-lg shadow-lg z-50"
>
<button
@click="() => selectLanguage('zh')"
:class="[
'w-full px-3 py-2 text-left text-sm transition-colors duration-200 rounded-t-lg',
language === 'zh' ? 'bg-primary-500 text-white' : 'text-secondary hover:bg-block-hover hover:text-primary'
]"
>
中文
</button>
<button
@click="() => selectLanguage('en')"
:class="[
'w-full px-3 py-2 text-left text-sm transition-colors duration-200 rounded-b-lg',
language === 'en' ? 'bg-primary-500 text-white' : 'text-secondary hover:bg-block-hover hover:text-primary'
]"
>
EN
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
import type { Language } from '@/types/tools'
const { language, switchLanguage } = useLanguage()
const isOpen = ref(false)
const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
const selectLanguage = (lang: Language) => {
switchLanguage(lang)
isOpen.value = false
}
// 点击外部关闭下拉菜单
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element
if (!target.closest('.relative')) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@ -0,0 +1,68 @@
<template>
<button
@click="toggleTheme"
class="relative w-10 h-10 rounded-full bg-card hover:bg-block-hover text-secondary hover:text-primary transition-all duration-300 shadow-md hover:shadow-lg border border-opacity-20 border-primary-500 hover:border-opacity-40 group"
:title="theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'"
>
<!-- 图标容器 -->
<div class="absolute inset-0 flex items-center justify-center">
<!-- 太阳图标 (浅色模式) -->
<FontAwesomeIcon
:icon="['fas', 'sun']"
:class="[
'w-5 h-5 transition-all duration-300 transform',
theme === 'dark'
? 'opacity-100 scale-100 rotate-0'
: 'opacity-0 scale-50 rotate-180'
]"
/>
<!-- 月亮图标 (深色模式) -->
<FontAwesomeIcon
:icon="['fas', 'moon']"
:class="[
'w-5 h-5 transition-all duration-300 transform absolute',
theme === 'dark'
? 'opacity-0 scale-50 -rotate-180'
: 'opacity-100 scale-100 rotate-0'
]"
/>
</div>
<!-- 悬停时的光晕效果 -->
<div class="absolute inset-0 rounded-full bg-primary-500 opacity-0 group-hover:opacity-10 transition-opacity duration-300"></div>
</button>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { theme, toggleTheme } = useTheme()
</script>
<style scoped>
/* 确保图标切换动画流畅 */
.fa-sun,
.fa-moon {
color: rgb(var(--color-text-secondary));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 悬停时图标颜色变化 */
button:hover .fa-sun,
button:hover .fa-moon {
color: rgb(var(--color-primary));
}
/* 为浅色主题的太阳图标添加特殊效果 */
[data-theme='light'] .fa-sun {
color: rgb(var(--color-warning));
filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.3));
}
/* 为深色主题的月亮图标添加特殊效果 */
[data-theme='dark'] .fa-moon {
color: rgb(var(--color-primary-light));
filter: drop-shadow(0 0 4px rgba(129, 140, 248, 0.3));
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<div class="tool-header mb-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-primary mb-2">{{ title }}</h1>
<p class="text-secondary text-sm max-w-2xl mx-auto">{{ description }}</p>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string
description?: string
}
defineProps<Props>()
</script>
<style scoped>
.tool-header {
padding: 1rem 0;
}
</style>

View File

@ -0,0 +1,402 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="convertBase64ToImage"
:disabled="!base64Input.trim() || isConverting"
class="btn-primary"
>
<FontAwesomeIcon
:icon="isConverting ? ['fas', 'spinner'] : ['fas', 'image']"
:class="['mr-2', isConverting && 'animate-spin']"
/>
{{ t('tools.base64_to_image.base64_to_image') }}
</button>
<button
@click="downloadImage"
:disabled="!imageDataUrl"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
{{ t('tools.base64_to_image.download_image') }}
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.base64_to_image.clear') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Base64 输入区域 -->
<div class="space-y-4">
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.base64_to_image.base64_input') }}</h3>
<textarea
v-model="base64Input"
:placeholder="t('tools.base64_to_image.base64_placeholder')"
class="textarea-field h-40"
@input="handleBase64Change"
/>
<div class="text-sm text-secondary mt-2">
字符数: {{ base64Input.length }}
</div>
</div>
<!-- 图片上传区域 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.base64_to_image.image_to_base64') }}</h3>
<div
@click="triggerFileUpload"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleFileDrop"
:class="[
'border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors text-center',
isDragging ? 'border-primary bg-primary bg-opacity-5' : 'border-tertiary hover:border-primary-light'
]"
>
<FontAwesomeIcon :icon="['fas', 'cloud-upload-alt']" class="text-4xl text-tertiary mb-4" />
<div class="text-secondary">
<p>{{ t('tools.base64_to_image.click_or_drag') }}</p>
<p class="text-sm text-tertiary mt-1">支持 JPG, PNG, GIF, WebP 格式</p>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
@change="handleFileSelect"
>
</div>
</div>
<!-- 预览和结果区域 -->
<div class="space-y-4">
<!-- 图片预览 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.base64_to_image.image_preview') }}</h3>
<div class="flex justify-center items-center min-h-[200px] bg-block rounded-lg">
<div v-if="imageDataUrl" class="text-center max-w-full">
<img
:src="imageDataUrl"
:alt="t('tools.base64_to_image.preview_image')"
class="max-w-full max-h-64 mx-auto rounded border border-primary border-opacity-20"
@load="handleImageLoad"
@error="handleImageError"
>
<div v-if="imageInfo" class="text-sm text-secondary mt-2">
<div>尺寸: {{ imageInfo.width }} × {{ imageInfo.height }}px</div>
<div>大小: {{ imageInfo.size }}</div>
<div>格式: {{ imageInfo.format }}</div>
</div>
</div>
<div v-else-if="isConverting" class="text-center">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<div class="text-secondary">{{ t('tools.base64_to_image.converting') }}</div>
</div>
<div v-else class="text-center">
<FontAwesomeIcon :icon="['fas', 'image']" class="text-6xl text-tertiary mb-4" />
<div class="text-secondary">{{ t('tools.base64_to_image.no_preview') }}</div>
</div>
</div>
</div>
<!-- Base64 输出 -->
<div v-if="base64Output" class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">{{ t('tools.base64_to_image.base64_output') }}</h3>
<button
@click="copyBase64ToClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="复制"
>
<FontAwesomeIcon
:icon="base64Copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="base64Copied && 'text-success'"
/>
</button>
</div>
<textarea
v-model="base64Output"
class="textarea-field h-32"
readonly
/>
<div class="text-sm text-secondary mt-2">
字符数: {{ base64Output.length }}
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const base64Input = ref('')
const base64Output = ref('')
const imageDataUrl = ref('')
const isDragging = ref(false)
const isConverting = ref(false)
const base64Copied = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 文件输入引用
const fileInput = ref<HTMLInputElement>()
// 图片信息
const imageInfo = ref<{
width: number
height: number
size: string
format: string
} | null>(null)
// 将Base64转换为图片
const convertBase64ToImage = async () => {
if (!base64Input.value.trim()) {
showStatus('请输入Base64编码', 'error')
return
}
isConverting.value = true
statusMessage.value = ''
await nextTick()
try {
let base64Data = base64Input.value.trim()
// 如果没有data URI前缀尝试添加
if (!base64Data.startsWith('data:')) {
// 检测图片格式
const firstChar = base64Data.charAt(0)
let mimeType = 'image/png' // 默认
if (firstChar === '/') {
mimeType = 'image/jpeg'
} else if (firstChar === 'R') {
mimeType = 'image/gif'
} else if (firstChar === 'U') {
mimeType = 'image/webp'
}
base64Data = `data:${mimeType};base64,${base64Data}`
}
// 验证Base64格式
const base64Pattern = /^data:image\/(png|jpe?g|gif|webp);base64,/
if (!base64Pattern.test(base64Data)) {
throw new Error('无效的Base64图片格式')
}
imageDataUrl.value = base64Data
showStatus('Base64转换成功', 'success')
} catch (error) {
console.error('Base64转换失败:', error)
showStatus('转换失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
imageDataUrl.value = ''
imageInfo.value = null
} finally {
isConverting.value = false
}
}
// 处理文件选择
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
convertFileToBase64(file)
}
}
// 处理文件拖拽
const handleFileDrop = (event: DragEvent) => {
isDragging.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) {
const file = files[0]
if (file.type.startsWith('image/')) {
convertFileToBase64(file)
} else {
showStatus('请选择图片文件', 'error')
}
}
}
const handleDragOver = () => {
isDragging.value = true
}
const handleDragLeave = () => {
isDragging.value = false
}
// 将文件转换为Base64
const convertFileToBase64 = (file: File) => {
if (!file.type.startsWith('image/')) {
showStatus('请选择图片文件', 'error')
return
}
isConverting.value = true
statusMessage.value = ''
const reader = new FileReader()
reader.onload = (e) => {
try {
const result = e.target?.result as string
base64Output.value = result
imageDataUrl.value = result
// 更新图片信息
imageInfo.value = {
width: 0, // 将在图片加载后更新
height: 0,
size: formatFileSize(file.size),
format: file.type.split('/')[1].toUpperCase()
}
showStatus('图片转Base64成功', 'success')
} catch (error) {
console.error('文件转换失败:', error)
showStatus('文件转换失败', 'error')
} finally {
isConverting.value = false
}
}
reader.onerror = () => {
showStatus('文件读取失败', 'error')
isConverting.value = false
}
reader.readAsDataURL(file)
}
// 触发文件上传
const triggerFileUpload = () => {
fileInput.value?.click()
}
// 下载图片
const downloadImage = () => {
if (!imageDataUrl.value) return
try {
const link = document.createElement('a')
link.download = `base64-image-${Date.now()}.png`
link.href = imageDataUrl.value
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
showStatus('图片下载成功', 'success')
} catch (error) {
console.error('下载失败:', error)
showStatus('下载失败', 'error')
}
}
// 复制Base64到剪贴板
const copyBase64ToClipboard = async () => {
if (!base64Output.value) return
try {
await navigator.clipboard.writeText(base64Output.value)
base64Copied.value = true
showStatus('Base64已复制到剪贴板', 'success')
setTimeout(() => {
base64Copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
showStatus('复制失败', 'error')
}
}
// 清除所有内容
const clearAll = () => {
base64Input.value = ''
base64Output.value = ''
imageDataUrl.value = ''
imageInfo.value = null
statusMessage.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
// 处理Base64输入变化
const handleBase64Change = () => {
if (statusMessage.value) {
statusMessage.value = ''
}
}
// 图片加载完成
const handleImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
if (imageInfo.value) {
imageInfo.value.width = img.naturalWidth
imageInfo.value.height = img.naturalHeight
}
}
// 图片加载错误
const handleImageError = () => {
showStatus('图片加载失败请检查Base64编码是否正确', 'error')
imageDataUrl.value = ''
imageInfo.value = null
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>

View File

@ -0,0 +1,361 @@
<template>
<div class="chrome-bookmark-recovery">
<ToolHeader
:title="t('tools.chrome_bookmark_recovery.title')"
:description="t('tools.chrome_bookmark_recovery.description')"
/>
<!-- Windows操作说明 -->
<div class="instruction-content mb-8">
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
{{ t('tools.chrome_bookmark_recovery.instructions.windows.title') }}
</h3>
<ol class="space-y-2 text-gray-700 dark:text-gray-300">
<li
v-for="(step, index) in getInstructionSteps('windows')"
:key="index"
class="flex items-start"
>
<span class="flex-shrink-0 w-6 h-6 bg-blue-500 text-white text-sm rounded-full flex items-center justify-center mr-3 mt-0.5">
{{ index + 1 }}
</span>
<span class="leading-relaxed">{{ step }}</span>
</li>
</ol>
</div>
<!-- 文件上传区域 -->
<div class="upload-section">
<div
ref="dropZone"
@drop="handleDrop"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
:class="[
'border-2 border-dashed rounded-lg p-8 text-center transition-colors',
isDragOver
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
]"
>
<div class="space-y-4">
<div class="mx-auto w-12 h-12 text-gray-400">
<svg fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/>
</svg>
</div>
<div>
<label class="cursor-pointer">
<input
type="file"
multiple
accept=".bak"
@change="handleFileSelect"
class="hidden"
/>
<span class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium">
选择 Bookmarks.bak 文件
</span>
</label>
<span class="text-gray-500 dark:text-gray-400"> 或拖拽文件到此处</span>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
仅支持 .bak 格式的 Chrome 书签备份文件
</p>
</div>
</div>
<!-- 结果显示 -->
<div v-if="results.length > 0" class="mt-6">
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<h4 class="font-medium mb-4 text-gray-900 dark:text-gray-100">
处理结果
</h4>
<ul class="space-y-3">
<li
v-for="result in results"
:key="result.id"
class="flex items-center justify-between p-3 bg-white dark:bg-gray-700 rounded border"
>
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ result.filename }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
最后修改: {{ result.lastModified }} ({{ result.size }}B)
</div>
<div v-if="result.count > 0" class="text-sm text-green-600 dark:text-green-400">
发现 {{ result.count }} 个书签
</div>
<div v-if="result.error" class="text-sm text-red-600 dark:text-red-400">
文件格式错误或无效请选择正确的 Bookmarks.bak 文件
</div>
</div>
<div class="ml-4">
<button
v-if="!result.error"
@click="downloadBookmark(result)"
:disabled="result.downloaded"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm"
>
{{ result.downloaded ? '已下载' : '下载 HTML' }}
</button>
</div>
</li>
</ul>
</div>
</div>
<!-- 加载状态 -->
<div v-if="isLoading" class="mt-6 text-center">
<div class="text-gray-500 dark:text-gray-400">正在处理文件...</div>
</div>
</div>
<!-- 使用说明 -->
<div class="usage-info mt-12">
<h3 class="text-xl font-semibold mb-6 text-gray-900 dark:text-gray-100">
使用说明
</h3>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg p-4">
<div class="flex items-start space-x-3">
<svg class="w-5 h-5 text-blue-500 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-2">导入恢复的书签</p>
<ol class="list-decimal list-inside space-y-1 ml-4">
<li>下载转换后的 HTML 文件</li>
<li>打开 Chrome 浏览器进入 书签 > 书签管理器</li>
<li>点击右上角菜单三个点选择"导入书签"</li>
<li>选择刚才下载的 HTML 文件</li>
<li>书签将被导入到 Chrome </li>
</ol>
</div>
</div>
</div>
</div>
<!-- 免责声明 -->
<div class="disclaimer mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
免责声明这不是Google的官方产品请在使用前备份重要数据
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
import ToolHeader from '@/components/ToolHeader.vue'
// 语言设置
const { t } = useLanguage()
// 文件上传相关
const dropZone = ref<HTMLElement>()
const isDragOver = ref(false)
const isLoading = ref(false)
const results = ref<Array<{
id: string
filename: string
lastModified: string
size: number
count: number
html: string
error: boolean
downloaded: boolean
}>>([])
// 获取指令步骤
const getInstructionSteps = (tab: string) => {
return t(`tools.chrome_bookmark_recovery.instructions.${tab}.steps`)
}
// Chrome时间转换函数
const chromeTime2TimeT = (time: number): number => {
return Math.floor((time - 11644473600000000) / 1000000)
}
// 书签解析类
class Bookmark {
tree: any
html: string
count: number
first: boolean
constructor(raw: string) {
this.tree = JSON.parse(raw)
this.html = ''
this.count = 0
this.first = true
}
walk = (node: any): void => {
if (node.type === 'folder') {
this.html += `<DT><H3 ADD_DATE="${chromeTime2TimeT(node.date_added)}" LAST_MODIFIED="${chromeTime2TimeT(node.date_modified)}"`
if (this.first) {
this.html += ' PERSONAL_TOOLBAR_FOLDER="true"'
this.first = false
}
this.html += `>${node.name}</H3>\n`
this.html += '<DL><p>\n'
node.children.forEach(this.walk)
this.html += '</DL><p>\n'
} else {
this.html += `<DT><A HREF="${node.url}" ADD_DATE="${chromeTime2TimeT(node.date_added)}">${node.name}</A>\n`
this.count++
}
}
parse = (): void => {
this.html = '<!DOCTYPE NETSCAPE-Bookmark-file-1><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"><TITLE>Bookmarks</TITLE><H1>Bookmarks</H1>\n'
this.html += '<DL><p>\n'
const roots = this.tree.roots
this.walk(roots.bookmark_bar)
if (roots.other.children.length > 0) {
this.walk(roots.other)
}
if (roots.synced.children.length > 0) {
this.walk(roots.synced)
}
this.html += '<style>dt, dl { padding-left: 12px; }</style>\n'
}
}
// 验证文件格式
const isValidBookmarkFile = (filename: string): boolean => {
return filename.toLowerCase().endsWith('.bak') &&
(filename.toLowerCase().includes('bookmark') || filename.toLowerCase() === 'bookmarks.bak')
}
// 处理文件读取
const readFile = (file: File): Promise<any> => {
return new Promise((resolve) => {
const reader = new FileReader()
const result = {
id: Math.random().toString(36).substr(2, 9),
filename: file.name,
lastModified: file.lastModified ? new Date(file.lastModified).toLocaleDateString() : 'n/a',
size: file.size,
count: 0,
html: '',
error: false,
downloaded: false
}
// 验证文件格式
if (!isValidBookmarkFile(file.name)) {
result.error = true
resolve(result)
return
}
reader.onloadend = () => {
try {
const bookmark = new Bookmark(reader.result as string)
bookmark.parse()
result.count = bookmark.count
result.html = bookmark.html
} catch (e) {
result.error = true
}
resolve(result)
}
reader.readAsText(file)
})
}
// 处理文件选择
const handleFileSelect = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files) {
await processFiles(Array.from(target.files))
}
}
// 处理文件拖拽
const handleDrop = async (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
isDragOver.value = false
if (event.dataTransfer?.files) {
await processFiles(Array.from(event.dataTransfer.files))
}
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
isDragOver.value = true
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
isDragOver.value = false
}
// 处理文件列表
const processFiles = async (files: File[]) => {
isLoading.value = true
results.value = []
try {
for (const file of files) {
const result = await readFile(file)
results.value.push(result)
}
} finally {
isLoading.value = false
}
}
// 下载书签
const downloadBookmark = (result: any) => {
const blob = new Blob([result.html], { type: 'text/html' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'chrome_bookmarks_backup.html'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
result.downloaded = true
}
</script>
<style scoped>
.chrome-bookmark-recovery {
max-width: 4xl;
margin: 0 auto;
padding: 1rem;
}
.instruction-content {
min-height: 200px;
}
.upload-section {
margin: 2rem 0;
}
.usage-info {
border-top: 1px solid #e5e7eb;
padding-top: 2rem;
}
@media (max-width: 768px) {
.chrome-bookmark-recovery {
padding: 0.5rem;
}
}
</style>

View File

@ -0,0 +1,565 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="formatCode"
:disabled="!inputCode.trim() || isFormatting"
class="btn-primary"
>
<FontAwesomeIcon
:icon="isFormatting ? ['fas', 'spinner'] : ['fas', 'code']"
:class="['mr-2', isFormatting && 'animate-spin']"
/>
{{ t('tools.code_formatter.format') }}
</button>
<button
@click="minifyCode"
:disabled="!inputCode.trim() || isFormatting"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'compress']" class="mr-2" />
{{ t('tools.code_formatter.minify') }}
</button>
<button
@click="copyToClipboard"
:disabled="!outputCode"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
{{ t('tools.code_formatter.copy') }}
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.code_formatter.clear') }}
</button>
</div>
</div>
<!-- 语言选择和设置 -->
<div class="card p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.code_formatter.language') }}
</label>
<select v-model="selectedLanguage" class="select-field" @change="handleLanguageChange">
<option v-for="lang in supportedLanguages" :key="lang.value" :value="lang.value">
{{ lang.label }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.code_formatter.indent_size') }}
</label>
<select v-model="indentSize" class="select-field">
<option value="2">2 空格</option>
<option value="4">4 空格</option>
<option value="tab">制表符</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.code_formatter.line_width') }}
</label>
<input
v-model="lineWidth"
type="number"
min="80"
max="200"
class="input-field"
placeholder="120"
>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">{{ t('tools.code_formatter.input') }}</h3>
<div class="flex space-x-2">
<button
@click="pasteFromClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="粘贴"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" />
</button>
<button
@click="loadExample"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="加载示例"
>
<FontAwesomeIcon :icon="['fas', 'file-code']" />
</button>
</div>
</div>
<textarea
v-model="inputCode"
:placeholder="getPlaceholder()"
class="textarea-field font-mono text-sm"
style="height: 500px; resize: vertical;"
@input="handleInputChange"
/>
<div class="text-sm text-secondary mt-2">
行数: {{ inputLines }} | 字符数: {{ inputCode.length }}
</div>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">{{ t('tools.code_formatter.output') }}</h3>
<div class="text-sm text-secondary">
{{ outputStats }}
</div>
</div>
<textarea
v-model="outputCode"
:placeholder="t('tools.code_formatter.output_placeholder')"
class="textarea-field font-mono text-sm"
style="height: 500px; resize: vertical;"
readonly
/>
<div v-if="outputCode" class="text-sm text-secondary mt-2">
行数: {{ outputLines }} | 字符数: {{ outputCode.length }}
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const inputCode = ref('')
const outputCode = ref('')
const selectedLanguage = ref('javascript')
const indentSize = ref('2')
const lineWidth = ref(120)
const isFormatting = ref(false)
const copied = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 支持的语言
const supportedLanguages = [
{ value: 'javascript', label: 'JavaScript' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'html', label: 'HTML' },
{ value: 'css', label: 'CSS' },
{ value: 'json', label: 'JSON' },
{ value: 'sql', label: 'SQL' },
{ value: 'xml', label: 'XML' },
{ value: 'yaml', label: 'YAML' },
{ value: 'markdown', label: 'Markdown' }
]
// 计算属性
const inputLines = computed(() => {
return inputCode.value.split('\n').length
})
const outputLines = computed(() => {
return outputCode.value.split('\n').length
})
const outputStats = computed(() => {
if (!outputCode.value) return ''
const originalSize = inputCode.value.length
const formattedSize = outputCode.value.length
const diff = formattedSize - originalSize
const diffText = diff > 0 ? `+${diff}` : diff.toString()
return `${diffText} 字符`
})
// 格式化代码
const formatCode = async () => {
if (!inputCode.value.trim()) {
showStatus('请输入要格式化的代码', 'error')
return
}
isFormatting.value = true
statusMessage.value = ''
try {
let formatted = ''
switch (selectedLanguage.value) {
case 'json':
formatted = formatJSON(inputCode.value)
break
case 'html':
formatted = formatHTML(inputCode.value)
break
case 'css':
formatted = formatCSS(inputCode.value)
break
case 'javascript':
case 'typescript':
formatted = formatJavaScript(inputCode.value)
break
case 'sql':
formatted = formatSQL(inputCode.value)
break
case 'xml':
formatted = formatXML(inputCode.value)
break
default:
formatted = formatGeneric(inputCode.value)
}
outputCode.value = formatted
showStatus('代码格式化成功', 'success')
} catch (error) {
console.error('格式化失败:', error)
showStatus('格式化失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
} finally {
isFormatting.value = false
}
}
// 压缩代码
const minifyCode = () => {
if (!inputCode.value.trim()) {
showStatus('请输入要压缩的代码', 'error')
return
}
try {
let minified = ''
switch (selectedLanguage.value) {
case 'json':
const parsed = JSON.parse(inputCode.value)
minified = JSON.stringify(parsed)
break
case 'css':
minified = minifyCSS(inputCode.value)
break
case 'javascript':
minified = minifyJavaScript(inputCode.value)
break
default:
minified = inputCode.value.replace(/\s+/g, ' ').trim()
}
outputCode.value = minified
showStatus('代码压缩成功', 'success')
} catch (error) {
console.error('压缩失败:', error)
showStatus('压缩失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
}
}
// JSON格式化
const formatJSON = (code: string): string => {
const parsed = JSON.parse(code)
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
return JSON.stringify(parsed, null, indent)
}
// HTML格式化
const formatHTML = (code: string): string => {
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
let level = 0
let formatted = ''
// 简单的HTML格式化逻辑
const lines = code.replace(/></g, '>\n<').split('\n')
for (let line of lines) {
line = line.trim()
if (!line) continue
if (line.startsWith('</')) {
level = Math.max(0, level - 1)
}
formatted += indent.repeat(level) + line + '\n'
if (line.startsWith('<') && !line.startsWith('</') && !line.endsWith('/>') && !line.includes('</')) {
level++
}
}
return formatted.trim()
}
// CSS格式化
const formatCSS = (code: string): string => {
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
return code
.replace(/\s*{\s*/g, ' {\n')
.replace(/;\s*/g, ';\n')
.replace(/\s*}\s*/g, '\n}\n')
.split('\n')
.map(line => {
line = line.trim()
if (!line) return ''
if (line.endsWith('{') || line.endsWith('}')) {
return line
}
return indent + line
})
.filter(line => line !== '')
.join('\n')
}
// JavaScript格式化
const formatJavaScript = (code: string): string => {
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
let level = 0
let formatted = ''
let inString = false
let stringChar = ''
for (let i = 0; i < code.length; i++) {
const char = code[i]
const prevChar = code[i - 1]
if ((char === '"' || char === "'") && prevChar !== '\\') {
if (!inString) {
inString = true
stringChar = char
} else if (char === stringChar) {
inString = false
stringChar = ''
}
}
if (!inString) {
if (char === '{') {
formatted += char + '\n' + indent.repeat(++level)
continue
} else if (char === '}') {
formatted = formatted.trimEnd() + '\n' + indent.repeat(--level) + char
if (code[i + 1] && code[i + 1] !== ';' && code[i + 1] !== ',' && code[i + 1] !== ')') {
formatted += '\n' + indent.repeat(level)
}
continue
} else if (char === ';') {
formatted += char
if (code[i + 1] && code[i + 1] !== '}') {
formatted += '\n' + indent.repeat(level)
}
continue
}
}
formatted += char
}
return formatted.trim()
}
// SQL格式化
const formatSQL = (code: string): string => {
const keywords = ['SELECT', 'FROM', 'WHERE', 'JOIN', 'INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'ORDER BY', 'GROUP BY', 'HAVING', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP']
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
let formatted = code.toUpperCase()
keywords.forEach(keyword => {
const regex = new RegExp(`\\b${keyword}\\b`, 'gi')
formatted = formatted.replace(regex, `\n${keyword}`)
})
return formatted
.split('\n')
.map(line => line.trim())
.filter(line => line !== '')
.join('\n')
}
// XML格式化
const formatXML = (code: string): string => {
// 简单的XML格式化复用HTML格式化逻辑
return formatHTML(code)
}
// 通用格式化
const formatGeneric = (code: string): string => {
// 基本的缩进处理
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
let level = 0
return code.split('\n').map(line => {
line = line.trim()
if (!line) return ''
// 简单的括号缩进
const openBrackets = (line.match(/[{(\[]/g) || []).length
const closeBrackets = (line.match(/[})\]]/g) || []).length
if (closeBrackets > openBrackets) {
level = Math.max(0, level - (closeBrackets - openBrackets))
}
const formatted = indent.repeat(level) + line
if (openBrackets > closeBrackets) {
level += (openBrackets - closeBrackets)
}
return formatted
}).join('\n')
}
// CSS压缩
const minifyCSS = (code: string): string => {
return code
.replace(/\/\*[\s\S]*?\*\//g, '') // 移除注释
.replace(/\s+/g, ' ') // 多个空格替换为单个
.replace(/;\s*}/g, '}') // 移除最后一个分号
.replace(/\s*{\s*/g, '{')
.replace(/;\s*/g, ';')
.replace(/:\s*/g, ':')
.trim()
}
// JavaScript压缩
const minifyJavaScript = (code: string): string => {
return code
.replace(/\/\/.*$/gm, '') // 移除单行注释
.replace(/\/\*[\s\S]*?\*\//g, '') // 移除多行注释
.replace(/\s+/g, ' ') // 多个空格替换为单个
.replace(/\s*([{}();,])\s*/g, '$1') // 移除操作符周围的空格
.trim()
}
// 复制到剪贴板
const copyToClipboard = async () => {
if (!outputCode.value) return
try {
await navigator.clipboard.writeText(outputCode.value)
copied.value = true
showStatus('代码已复制到剪贴板', 'success')
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
showStatus('复制失败', 'error')
}
}
// 从剪贴板粘贴
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
inputCode.value = text
handleInputChange()
} catch (error) {
console.error('粘贴失败:', error)
showStatus('粘贴失败', 'error')
}
}
// 加载示例代码
const loadExample = () => {
const examples: Record<string, string> = {
javascript: `function calculateSum(numbers) {
const result = numbers.reduce((sum, num) => {
return sum + num;
}, 0);
return result;
}
const data = [1, 2, 3, 4, 5];
console.log(calculateSum(data));`,
json: `{"name":"极速箱工具集","version":"1.0.0","tools":[{"id":1,"name":"代码格式化器","active":true},{"id":2,"name":"JSON格式化器","active":true}],"settings":{"theme":"dark","language":"zh-CN"}}`,
html: `<div class="container"><header><h1>标题</h1></header><main><section><p>这是一段文本</p></section></main></div>`,
css: `.container{display:flex;flex-direction:column;}.header{background-color:#333;color:white;padding:20px;}.main{flex:1;padding:20px;}`,
sql: `SELECT u.name, u.email, p.title FROM users u INNER JOIN posts p ON u.id = p.user_id WHERE u.active = 1 ORDER BY p.created_at DESC;`
}
inputCode.value = examples[selectedLanguage.value] || examples.javascript
}
// 清除所有内容
const clearAll = () => {
inputCode.value = ''
outputCode.value = ''
statusMessage.value = ''
}
// 处理输入变化
const handleInputChange = () => {
if (statusMessage.value) {
statusMessage.value = ''
}
}
// 处理语言变化
const handleLanguageChange = () => {
outputCode.value = ''
}
// 获取占位符文本
const getPlaceholder = (): string => {
const placeholders: Record<string, string> = {
javascript: '输入 JavaScript 代码...',
html: '输入 HTML 代码...',
css: '输入 CSS 代码...',
json: '输入 JSON 数据...',
sql: '输入 SQL 语句...'
}
return placeholders[selectedLanguage.value] || '输入代码...'
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
</script>

View File

@ -0,0 +1,461 @@
<template>
<div class="space-y-6">
<!-- 主颜色输入和预览 -->
<div class="card p-6">
<h2 class="text-lg font-medium text-primary mb-4">颜色转换器</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 颜色输入区域 -->
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary font-medium mb-2">选择颜色</label>
<div class="flex items-center gap-3">
<input
v-model="mainColor"
type="color"
class="w-16 h-16 cursor-pointer rounded-md border-2 border-gray-300"
@input="handleColorChange"
/>
<div class="flex-1">
<input
v-model="mainColor"
type="text"
class="input-field w-full"
placeholder="#6366F1"
@input="handleColorChange"
/>
</div>
<button
@click="generateRandomColor"
class="btn-secondary px-3 py-2"
>
<FontAwesomeIcon :icon="['fas', 'random']" />
</button>
</div>
</div>
</div>
<!-- 颜色预览区域 -->
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary font-medium mb-2">颜色预览</label>
<div
class="h-32 rounded-md flex items-center justify-center relative overflow-hidden border"
:style="{ backgroundColor: mainColor }"
>
<div class="bg-black bg-opacity-40 px-4 py-2 rounded-md text-white">
示例文本
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 颜色值显示和复制 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">颜色值</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="(value, format) in colorValues"
:key="format"
class="bg-block rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-medium text-primary">{{ format.toUpperCase() }}</h4>
<button
@click="() => copyColorValue(format)"
class="text-secondary hover:text-primary transition-colors"
>
<FontAwesomeIcon :icon="copiedFormat === format ? ['fas', 'check'] : ['fas', 'copy']" />
</button>
</div>
<code class="text-xs font-mono text-secondary break-all">{{ value }}</code>
</div>
</div>
</div>
<!-- 颜色色阶 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">颜色色阶</h3>
<div class="grid grid-cols-5 md:grid-cols-9 gap-2">
<div
v-for="(shade, index) in colorShades"
:key="index"
class="h-16 rounded-md flex items-center justify-center transition-all duration-200 cursor-pointer hover:transform hover:scale-105 border"
:style="{ backgroundColor: shade }"
@click="() => selectShade(shade)"
>
<span class="text-xs font-mono text-white bg-black bg-opacity-40 px-1 rounded">
{{ shade }}
</span>
</div>
</div>
<p class="text-xs text-tertiary mt-2">点击任意色阶来使用该颜色</p>
</div>
<!-- 和谐色彩 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 互补色 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">互补色</h3>
<div class="grid grid-cols-2 gap-2">
<div
v-for="(color, index) in complementaryColors"
:key="index"
class="h-24 rounded-md flex items-center justify-center transition-transform cursor-pointer hover:scale-105 border"
:style="{ backgroundColor: color }"
@click="() => selectShade(color)"
>
<span class="text-xs font-mono text-white bg-black bg-opacity-40 px-1 rounded">
{{ color }}
</span>
</div>
</div>
</div>
<!-- 邻近色 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">邻近色</h3>
<div class="grid grid-cols-3 gap-2">
<div
v-for="(color, index) in analogousColors"
:key="index"
class="h-24 rounded-md flex items-center justify-center transition-transform cursor-pointer hover:scale-105 border"
:style="{ backgroundColor: color }"
@click="() => selectShade(color)"
>
<span class="text-xs font-mono text-white bg-black bg-opacity-40 px-1 rounded">
{{ color }}
</span>
</div>
</div>
</div>
</div>
<!-- 调色板 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-md font-medium text-primary">我的调色板</h3>
<button
@click="showPaletteInput = !showPaletteInput"
class="btn-secondary text-sm"
>
<FontAwesomeIcon :icon="['fas', 'plus']" class="mr-1" />
添加颜色
</button>
</div>
<!-- 添加新颜色输入 -->
<div v-if="showPaletteInput" class="mb-4 p-4 bg-block rounded-lg">
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-sm text-secondary font-medium mb-2">颜色名称</label>
<input
v-model="newPaletteName"
type="text"
class="input-field w-full"
placeholder="输入颜色名称..."
@keydown.enter="addToPalette"
/>
</div>
<button
@click="addToPalette"
class="btn-primary px-4 py-2"
:disabled="!newPaletteName.trim()"
>
添加
</button>
<button
@click="showPaletteInput = false"
class="btn-secondary px-4 py-2"
>
取消
</button>
</div>
</div>
<!-- 调色板颜色列表 -->
<div class="space-y-2">
<div
v-for="color in palette"
:key="color.id"
class="flex items-center justify-between p-2 rounded-md hover:bg-hover transition-colors"
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-md cursor-pointer border"
:style="{ backgroundColor: color.hex }"
@click="() => selectFromPalette(color.hex)"
/>
<div>
<div class="text-sm text-primary">{{ color.name }}</div>
<div class="text-xs text-secondary font-mono">{{ color.hex }}</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
@click="() => copyColorValue('hex', color.hex)"
class="text-secondary hover:text-primary transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'copy']" />
</button>
<button
@click="() => removeFromPalette(color.id)"
class="text-secondary hover:text-error transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'trash']" />
</button>
</div>
</div>
</div>
<div v-if="palette.length === 0" class="text-center py-8 text-tertiary">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mb-2" />
<div>暂无保存的颜色点击"添加颜色"开始创建调色板</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
// 调色板颜色类型
interface PaletteColor {
id: string
hex: string
name: string
}
// 响应式状态
const mainColor = ref('#6366F1')
const colorShades = ref<string[]>([])
const complementaryColors = ref<string[]>([])
const analogousColors = ref<string[]>([])
const palette = ref<PaletteColor[]>([])
const showPaletteInput = ref(false)
const newPaletteName = ref('')
const copiedFormat = ref<string | null>(null)
// 计算颜色值
const colorValues = computed(() => {
const hex = mainColor.value
const rgb = hexToRgb(hex)
const hsl = rgbToHsl(rgb[0], rgb[1], rgb[2])
return {
hex: hex,
rgb: `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`,
hsl: `hsl(${Math.round(hsl[0])}, ${Math.round(hsl[1] * 100)}%, ${Math.round(hsl[2] * 100)}%)`
}
})
// 颜色转换函数
const hexToRgb = (hex: string): [number, number, number] => {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return [r, g, b]
}
const rgbToHsl = (r: number, g: number, b: number): [number, number, number] => {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const diff = max - min
let h = 0
if (max === min) {
h = 0
} else if (max === r) {
h = ((g - b) / diff + (g < b ? 6 : 0)) * 60
} else if (max === g) {
h = ((b - r) / diff + 2) * 60
} else {
h = ((r - g) / diff + 4) * 60
}
const l = (max + min) / 2
const s = diff === 0 ? 0 : diff / (1 - Math.abs(2 * l - 1))
return [h, s, l]
}
const hslToRgb = (h: number, s: number, l: number): [number, number, number] => {
h /= 360
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
return p
}
if (s === 0) {
return [l * 255, l * 255, l * 255]
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
const r = hue2rgb(p, q, h + 1/3)
const g = hue2rgb(p, q, h)
const b = hue2rgb(p, q, h - 1/3)
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]
}
const rgbToHex = (r: number, g: number, b: number): string => {
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}
// 计算颜色变体
const calculateColorShades = (hexColor: string) => {
const shades: string[] = []
const [r, g, b] = hexToRgb(hexColor)
// 创建9个亮度变体
for (let i = 0.1; i <= 0.9; i += 0.1) {
const factor = i
const newR = Math.round(r * factor)
const newG = Math.round(g * factor)
const newB = Math.round(b * factor)
const newHex = rgbToHex(newR, newG, newB)
shades.push(newHex)
}
colorShades.value = shades.reverse()
}
// 计算互补色和邻近色
const calculateHarmonicColors = (hexColor: string) => {
const [r, g, b] = hexToRgb(hexColor)
const [h, s, l] = rgbToHsl(r, g, b)
// 互补色
const complementaryH = (h + 180) % 360
const complementaryRgb = hslToRgb(complementaryH, s, l)
const complementaryHex = rgbToHex(complementaryRgb[0], complementaryRgb[1], complementaryRgb[2])
complementaryColors.value = [hexColor, complementaryHex]
// 邻近色
const analogous = []
for (let offset of [-30, 0, 30]) {
const analogousH = (h + offset + 360) % 360
const analogousRgb = hslToRgb(analogousH, s, l)
const analogousHex = rgbToHex(analogousRgb[0], analogousRgb[1], analogousRgb[2])
analogous.push(analogousHex)
}
analogousColors.value = analogous
}
// 更新所有颜色计算
const updateColorValues = (color: string) => {
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
return
}
calculateColorShades(color)
calculateHarmonicColors(color)
}
// 事件处理
const handleColorChange = () => {
updateColorValues(mainColor.value)
}
const copyColorValue = async (format: string, customValue?: string) => {
try {
const value = customValue || colorValues.value[format as keyof typeof colorValues.value]
await navigator.clipboard.writeText(value)
copiedFormat.value = format
setTimeout(() => {
copiedFormat.value = null
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
const selectShade = (color: string) => {
mainColor.value = color
updateColorValues(color)
}
const selectFromPalette = (hex: string) => {
mainColor.value = hex
updateColorValues(hex)
}
const generateRandomColor = () => {
const randomColor = `#${Math.floor(Math.random()*16777215).toString(16).padStart(6, '0')}`
mainColor.value = randomColor
updateColorValues(randomColor)
}
// 调色板管理
const addToPalette = () => {
if (!newPaletteName.value.trim()) return
const newColor: PaletteColor = {
id: Date.now().toString(),
hex: mainColor.value,
name: newPaletteName.value.trim()
}
palette.value.push(newColor)
newPaletteName.value = ''
showPaletteInput.value = false
// 保存到本地存储
savePalette()
}
const removeFromPalette = (id: string) => {
palette.value = palette.value.filter(color => color.id !== id)
savePalette()
}
const savePalette = () => {
localStorage.setItem('colorPalette', JSON.stringify(palette.value))
}
const loadPalette = () => {
const saved = localStorage.getItem('colorPalette')
if (saved) {
palette.value = JSON.parse(saved)
} else {
// 默认示例调色板
palette.value = [
{ id: 'primary', hex: '#6366F1', name: '主色' },
{ id: 'secondary', hex: '#8B5CF6', name: '辅助色' },
{ id: 'accent', hex: '#EC4899', name: '强调色' },
{ id: 'dark', hex: '#1E293B', name: '深色' },
{ id: 'light', hex: '#F1F5F9', name: '浅色' }
]
}
}
// 监听主颜色变化
watch(mainColor, (newColor) => {
updateColorValues(newColor)
})
// 初始化
onMounted(() => {
loadPalette()
updateColorValues(mainColor.value)
})
</script>

View File

@ -0,0 +1,621 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="generateCron"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'calendar-alt']" class="mr-2" />
{{ t('tools.cron_generator.generate') }}
</button>
<button
@click="copyCronExpression"
:disabled="!cronExpression"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
{{ t('tools.cron_generator.copy') }}
</button>
<button
@click="resetSettings"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'refresh']" class="mr-2" />
{{ t('tools.cron_generator.reset') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 配置区域 -->
<div class="space-y-4">
<!-- 预设任务 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.presets') }}</h3>
<div class="grid grid-cols-1 gap-2">
<button
v-for="preset in presets"
:key="preset.name"
@click="applyPreset(preset)"
class="text-left p-3 rounded bg-block hover:bg-block-hover text-secondary hover:text-primary transition-colors"
>
<div class="font-medium">{{ preset.name }}</div>
<div class="text-sm text-tertiary">{{ preset.description }}</div>
<div class="text-xs text-tertiary font-mono mt-1">{{ preset.expression }}</div>
</button>
</div>
</div>
<!-- 自定义配置 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.custom_config') }}</h3>
<div class="space-y-4">
<!-- 执行频率类型 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.frequency_type') }}
</label>
<select v-model="frequencyType" class="select-field" @change="handleFrequencyChange">
<option value="minutes">每分钟</option>
<option value="hourly">每小时</option>
<option value="daily">每天</option>
<option value="weekly">每周</option>
<option value="monthly">每月</option>
<option value="yearly">每年</option>
<option value="custom">自定义</option>
</select>
</div>
<!-- 分钟设置 -->
<div v-if="showMinutes">
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.minutes') }} (0-59)
</label>
<div class="grid grid-cols-2 gap-2">
<select v-model="cronConfig.minuteType" class="select-field">
<option value="*">每分钟</option>
<option value="specific">指定分钟</option>
<option value="interval">间隔</option>
</select>
<input
v-if="cronConfig.minuteType !== '*'"
v-model="cronConfig.minuteValue"
type="number"
min="0"
max="59"
class="input-field"
:placeholder="cronConfig.minuteType === 'specific' ? '分钟' : '间隔'"
>
</div>
</div>
<!-- 小时设置 -->
<div v-if="showHours">
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.hours') }} (0-23)
</label>
<div class="grid grid-cols-2 gap-2">
<select v-model="cronConfig.hourType" class="select-field">
<option value="*">每小时</option>
<option value="specific">指定小时</option>
<option value="interval">间隔</option>
</select>
<input
v-if="cronConfig.hourType !== '*'"
v-model="cronConfig.hourValue"
type="number"
min="0"
max="23"
class="input-field"
:placeholder="cronConfig.hourType === 'specific' ? '小时' : '间隔'"
>
</div>
</div>
<!-- 日期设置 -->
<div v-if="showDays">
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.days') }} (1-31)
</label>
<div class="grid grid-cols-2 gap-2">
<select v-model="cronConfig.dayType" class="select-field">
<option value="*">每天</option>
<option value="specific">指定日期</option>
<option value="interval">间隔</option>
</select>
<input
v-if="cronConfig.dayType !== '*'"
v-model="cronConfig.dayValue"
type="number"
min="1"
max="31"
class="input-field"
:placeholder="cronConfig.dayType === 'specific' ? '日期' : '间隔'"
>
</div>
</div>
<!-- 月份设置 -->
<div v-if="showMonths">
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.months') }} (1-12)
</label>
<div class="grid grid-cols-2 gap-2">
<select v-model="cronConfig.monthType" class="select-field">
<option value="*">每月</option>
<option value="specific">指定月份</option>
</select>
<select
v-if="cronConfig.monthType === 'specific'"
v-model="cronConfig.monthValue"
class="select-field"
>
<option v-for="(month, index) in months" :key="index" :value="index + 1">
{{ month }}
</option>
</select>
</div>
</div>
<!-- 星期设置 -->
<div v-if="showWeekdays">
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.weekdays') }} (0-6)
</label>
<div class="grid grid-cols-2 gap-2">
<select v-model="cronConfig.weekdayType" class="select-field">
<option value="*">每天</option>
<option value="specific">指定星期</option>
</select>
<select
v-if="cronConfig.weekdayType === 'specific'"
v-model="cronConfig.weekdayValue"
class="select-field"
>
<option v-for="(day, index) in weekdays" :key="index" :value="index">
{{ day }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- 结果区域 -->
<div class="space-y-4">
<!-- Cron表达式 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.expression') }}</h3>
<div class="bg-block rounded-lg p-4 mb-4">
<div class="font-mono text-lg text-primary text-center">
{{ cronExpression || '* * * * *' }}
</div>
</div>
<!-- 表达式说明 -->
<div class="text-sm text-secondary space-y-1">
<div class="grid grid-cols-5 gap-2 text-center font-medium border-b border-primary border-opacity-20 pb-2">
<div>分钟</div>
<div>小时</div>
<div>日期</div>
<div>月份</div>
<div>星期</div>
</div>
<div class="grid grid-cols-5 gap-2 text-center font-mono">
<div>{{ cronParts.minute }}</div>
<div>{{ cronParts.hour }}</div>
<div>{{ cronParts.day }}</div>
<div>{{ cronParts.month }}</div>
<div>{{ cronParts.weekday }}</div>
</div>
</div>
</div>
<!-- 执行时间描述 -->
<div v-if="cronDescription" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.description') }}</h3>
<div class="text-secondary">
{{ cronDescription }}
</div>
</div>
<!-- 下次执行时间 -->
<div v-if="nextExecutions.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.next_executions') }}</h3>
<div class="space-y-2">
<div
v-for="(execution, index) in nextExecutions"
:key="index"
class="flex justify-between items-center p-2 bg-block rounded"
>
<span class="text-secondary"> {{ index + 1 }} :</span>
<span class="text-primary font-medium">{{ execution }}</span>
</div>
</div>
</div>
<!-- 常用表达式参考 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.reference') }}</h3>
<div class="text-sm space-y-2">
<div class="space-y-1">
<div class="font-medium text-secondary">特殊字符:</div>
<div class="text-tertiary">
<div>* - 任意值</div>
<div>? - 不指定值</div>
<div>- - 范围 (: 1-5)</div>
<div>, - 列表 (: 1,3,5)</div>
<div>/ - 间隔 (: 0/5)</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const frequencyType = ref('daily')
const copied = ref(false)
// Cron配置
const cronConfig = ref({
minuteType: 'specific',
minuteValue: 0,
hourType: 'specific',
hourValue: 0,
dayType: '*',
dayValue: 1,
monthType: '*',
monthValue: 1,
weekdayType: '*',
weekdayValue: 0
})
// 预设任务
const presets = [
{
name: '每分钟执行',
description: '每分钟执行一次',
expression: '* * * * *',
config: { minuteType: '*', hourType: '*', dayType: '*', monthType: '*', weekdayType: '*' }
},
{
name: '每小时执行',
description: '每小时的第0分钟执行',
expression: '0 * * * *',
config: { minuteType: 'specific', minuteValue: 0, hourType: '*', dayType: '*', monthType: '*', weekdayType: '*' }
},
{
name: '每天执行',
description: '每天凌晨执行',
expression: '0 0 * * *',
config: { minuteType: 'specific', minuteValue: 0, hourType: 'specific', hourValue: 0, dayType: '*', monthType: '*', weekdayType: '*' }
},
{
name: '每周执行',
description: '每周日凌晨执行',
expression: '0 0 * * 0',
config: { minuteType: 'specific', minuteValue: 0, hourType: 'specific', hourValue: 0, dayType: '*', monthType: '*', weekdayType: 'specific', weekdayValue: 0 }
},
{
name: '每月执行',
description: '每月1日凌晨执行',
expression: '0 0 1 * *',
config: { minuteType: 'specific', minuteValue: 0, hourType: 'specific', hourValue: 0, dayType: 'specific', dayValue: 1, monthType: '*', weekdayType: '*' }
},
{
name: '工作日执行',
description: '工作日上午9点执行',
expression: '0 9 * * 1-5',
config: { minuteType: 'specific', minuteValue: 0, hourType: 'specific', hourValue: 9, dayType: '*', monthType: '*', weekdayType: 'specific', weekdayValue: '1-5' }
}
]
// 月份和星期名称
const months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
// 显示控制
const showMinutes = computed(() => {
return ['custom', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'].includes(frequencyType.value)
})
const showHours = computed(() => {
return ['custom', 'daily', 'weekly', 'monthly', 'yearly'].includes(frequencyType.value)
})
const showDays = computed(() => {
return ['custom', 'monthly', 'yearly'].includes(frequencyType.value)
})
const showMonths = computed(() => {
return ['custom', 'yearly'].includes(frequencyType.value)
})
const showWeekdays = computed(() => {
return ['custom', 'weekly'].includes(frequencyType.value)
})
// Cron表达式各部分
const cronParts = computed(() => {
const minute = getCronPart('minute')
const hour = getCronPart('hour')
const day = getCronPart('day')
const month = getCronPart('month')
const weekday = getCronPart('weekday')
return { minute, hour, day, month, weekday }
})
// 完整Cron表达式
const cronExpression = computed(() => {
const { minute, hour, day, month, weekday } = cronParts.value
return `${minute} ${hour} ${day} ${month} ${weekday}`
})
// Cron描述
const cronDescription = computed(() => {
return generateDescription()
})
// 下次执行时间
const nextExecutions = ref<string[]>([])
// 获取Cron部分
const getCronPart = (type: string): string => {
const config = cronConfig.value
switch (type) {
case 'minute':
if (config.minuteType === '*') return '*'
if (config.minuteType === 'specific') return config.minuteValue.toString()
if (config.minuteType === 'interval') return `*/${config.minuteValue}`
break
case 'hour':
if (config.hourType === '*') return '*'
if (config.hourType === 'specific') return config.hourValue.toString()
if (config.hourType === 'interval') return `*/${config.hourValue}`
break
case 'day':
if (config.dayType === '*') return '*'
if (config.dayType === 'specific') return config.dayValue.toString()
if (config.dayType === 'interval') return `*/${config.dayValue}`
break
case 'month':
if (config.monthType === '*') return '*'
if (config.monthType === 'specific') return config.monthValue.toString()
break
case 'weekday':
if (config.weekdayType === '*') return '*'
if (config.weekdayType === 'specific') return config.weekdayValue.toString()
break
}
return '*'
}
// 生成描述
const generateDescription = (): string => {
switch (frequencyType.value) {
case 'minutes':
return '每分钟执行'
case 'hourly':
return `每小时的第${cronConfig.value.minuteValue}分钟执行`
case 'daily':
return `每天${String(cronConfig.value.hourValue).padStart(2, '0')}:${String(cronConfig.value.minuteValue).padStart(2, '0')}执行`
case 'weekly':
return `${weekdays[cronConfig.value.weekdayValue]}${String(cronConfig.value.hourValue).padStart(2, '0')}:${String(cronConfig.value.minuteValue).padStart(2, '0')}执行`
case 'monthly':
return `每月${cronConfig.value.dayValue}${String(cronConfig.value.hourValue).padStart(2, '0')}:${String(cronConfig.value.minuteValue).padStart(2, '0')}执行`
case 'yearly':
return `每年${months[cronConfig.value.monthValue - 1]}${cronConfig.value.dayValue}${String(cronConfig.value.hourValue).padStart(2, '0')}:${String(cronConfig.value.minuteValue).padStart(2, '0')}执行`
default:
return '自定义表达式'
}
}
// 计算下次执行时间
const calculateNextExecutions = () => {
const executions: string[] = []
const now = new Date()
// 简化的计算逻辑,实际应用中可以使用 cron-parser 库
for (let i = 0; i < 5; i++) {
const nextTime = new Date(now)
switch (frequencyType.value) {
case 'minutes':
nextTime.setMinutes(now.getMinutes() + i + 1)
break
case 'hourly':
nextTime.setHours(now.getHours() + i + 1)
nextTime.setMinutes(cronConfig.value.minuteValue)
break
case 'daily':
nextTime.setDate(now.getDate() + i + 1)
nextTime.setHours(cronConfig.value.hourValue)
nextTime.setMinutes(cronConfig.value.minuteValue)
break
case 'weekly':
nextTime.setDate(now.getDate() + (i + 1) * 7)
nextTime.setHours(cronConfig.value.hourValue)
nextTime.setMinutes(cronConfig.value.minuteValue)
break
case 'monthly':
nextTime.setMonth(now.getMonth() + i + 1)
nextTime.setDate(cronConfig.value.dayValue)
nextTime.setHours(cronConfig.value.hourValue)
nextTime.setMinutes(cronConfig.value.minuteValue)
break
}
executions.push(nextTime.toLocaleString('zh-CN'))
}
nextExecutions.value = executions
}
// 应用预设
const applyPreset = (preset: any) => {
// 解析预设配置
const parts = preset.expression.split(' ')
// 重置配置
resetSettings()
// 应用预设值
if (parts[0] !== '*') {
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = parseInt(parts[0])
}
if (parts[1] !== '*') {
cronConfig.value.hourType = 'specific'
cronConfig.value.hourValue = parseInt(parts[1])
}
if (parts[2] !== '*') {
cronConfig.value.dayType = 'specific'
cronConfig.value.dayValue = parseInt(parts[2])
}
if (parts[3] !== '*') {
cronConfig.value.monthType = 'specific'
cronConfig.value.monthValue = parseInt(parts[3])
}
if (parts[4] !== '*') {
cronConfig.value.weekdayType = 'specific'
cronConfig.value.weekdayValue = parts[4].includes('-') ? parts[4] : parseInt(parts[4])
}
generateCron()
}
// 处理频率类型变化
const handleFrequencyChange = () => {
// 根据频率类型设置默认值
switch (frequencyType.value) {
case 'minutes':
cronConfig.value.minuteType = '*'
cronConfig.value.hourType = '*'
cronConfig.value.dayType = '*'
cronConfig.value.monthType = '*'
cronConfig.value.weekdayType = '*'
break
case 'hourly':
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = 0
cronConfig.value.hourType = '*'
cronConfig.value.dayType = '*'
cronConfig.value.monthType = '*'
cronConfig.value.weekdayType = '*'
break
case 'daily':
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = 0
cronConfig.value.hourType = 'specific'
cronConfig.value.hourValue = 0
cronConfig.value.dayType = '*'
cronConfig.value.monthType = '*'
cronConfig.value.weekdayType = '*'
break
case 'weekly':
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = 0
cronConfig.value.hourType = 'specific'
cronConfig.value.hourValue = 0
cronConfig.value.dayType = '*'
cronConfig.value.monthType = '*'
cronConfig.value.weekdayType = 'specific'
cronConfig.value.weekdayValue = 0
break
case 'monthly':
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = 0
cronConfig.value.hourType = 'specific'
cronConfig.value.hourValue = 0
cronConfig.value.dayType = 'specific'
cronConfig.value.dayValue = 1
cronConfig.value.monthType = '*'
cronConfig.value.weekdayType = '*'
break
case 'yearly':
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = 0
cronConfig.value.hourType = 'specific'
cronConfig.value.hourValue = 0
cronConfig.value.dayType = 'specific'
cronConfig.value.dayValue = 1
cronConfig.value.monthType = 'specific'
cronConfig.value.monthValue = 1
cronConfig.value.weekdayType = '*'
break
}
generateCron()
}
// 生成Cron表达式
const generateCron = () => {
calculateNextExecutions()
}
// 复制表达式
const copyCronExpression = async () => {
if (!cronExpression.value) return
try {
await navigator.clipboard.writeText(cronExpression.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 重置设置
const resetSettings = () => {
frequencyType.value = 'daily'
cronConfig.value = {
minuteType: 'specific',
minuteValue: 0,
hourType: 'specific',
hourValue: 0,
dayType: '*',
dayValue: 1,
monthType: '*',
monthValue: 1,
weekdayType: '*',
weekdayValue: 0
}
nextExecutions.value = []
}
// 监听配置变化
watch([cronConfig, frequencyType], () => {
generateCron()
}, { deep: true })
// 初始化
generateCron()
</script>

View File

@ -0,0 +1,418 @@
<template>
<div class="space-y-6">
<!-- 算法选择 -->
<div class="card p-4">
<h3 class="text-md font-medium text-primary mb-3">选择加密算法</h3>
<div class="grid grid-cols-2 md:grid-cols-6 gap-2">
<button
v-for="algorithm in algorithms"
:key="algorithm.type"
:class="[
'px-3 py-2 text-sm font-medium rounded transition-all',
activeAlgorithm === algorithm.type
? 'bg-primary-500 text-white shadow-sm'
: 'bg-block text-secondary border hover:bg-hover'
]"
@click="setActiveAlgorithm(algorithm.type)"
>
{{ algorithm.name }}
</button>
</div>
</div>
<!-- 操作模式选择仅对支持编码/解码的算法显示 -->
<div v-if="currentAlgorithm?.isEncodeDecode" class="card p-4">
<h3 class="text-md font-medium text-primary mb-3">操作模式</h3>
<div class="flex gap-2">
<button
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded transition-all',
!isDecoding ? 'bg-primary-500 text-white' : 'bg-block text-secondary border'
]"
@click="isDecoding = false"
>
{{ currentAlgorithm?.type === 'aes' ? '加密' : '编码' }}
</button>
<button
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded transition-all',
isDecoding ? 'bg-primary-500 text-white' : 'bg-block text-secondary border'
]"
@click="isDecoding = true"
>
{{ currentAlgorithm?.type === 'aes' ? '解密' : '解码' }}
</button>
</div>
</div>
<!-- 输入和处理区域 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-md font-medium text-primary">输入内容</h3>
<div class="flex gap-2">
<button @click="loadExample" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'redo']" class="mr-1" />
示例
</button>
<button @click="clearAll" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'eraser']" class="mr-1" />
清空
</button>
</div>
</div>
<div class="space-y-4">
<!-- 密钥输入仅对需要密钥的算法显示 -->
<div v-if="currentAlgorithm?.needsKey">
<label class="block text-sm text-secondary font-medium mb-2">密钥</label>
<input
v-model="secretKey"
type="text"
class="input-field w-full"
placeholder="请输入加密密钥..."
/>
</div>
<!-- 文本输入 -->
<div>
<label class="block text-sm text-secondary font-medium mb-2">
{{ isDecoding && currentAlgorithm?.isEncodeDecode ? '待解密/解码内容' : '待加密/编码内容' }}
</label>
<textarea
v-model="inputText"
class="textarea-field h-36 w-full font-mono resize-y"
:placeholder="getInputPlaceholder()"
/>
</div>
<!-- 处理按钮 -->
<button
@click="processOperation"
class="btn-primary w-full flex items-center justify-center gap-2"
:disabled="!inputText.trim() || (currentAlgorithm?.needsKey && !secretKey.trim())"
>
<FontAwesomeIcon :icon="['fas', 'lock']" />
{{ getProcessButtonText() }}
</button>
</div>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-md font-medium text-primary">输出结果</h3>
<button
v-if="output"
@click="copyToClipboard"
class="btn-secondary text-sm"
:disabled="!output"
>
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<div class="space-y-4">
<!-- 结果显示 -->
<div>
<label class="block text-sm text-secondary font-medium mb-2">
{{ isDecoding && currentAlgorithm?.isEncodeDecode ? '解密/解码结果' : '加密/编码结果' }}
</label>
<textarea
v-model="output"
readonly
class="textarea-field h-36 w-full font-mono resize-y bg-block"
placeholder="结果将在这里显示..."
/>
</div>
<!-- 结果信息 -->
<div v-if="output" class="text-sm text-tertiary">
<div>字符长度: {{ output.length }}</div>
<div v-if="currentAlgorithm?.type !== 'aes' && !isDecoding">
哈希值: {{ currentAlgorithm?.name }}
</div>
</div>
</div>
</div>
</div>
<!-- 错误和成功消息 -->
<div v-if="error" class="p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" class="mr-2" />
{{ error }}
</div>
<div v-if="success" class="p-3 bg-green-900/20 border border-green-700/30 rounded-lg text-green-400">
<FontAwesomeIcon :icon="['fas', 'check']" class="mr-2" />
{{ success }}
</div>
<!-- 算法说明 -->
<div class="card p-4">
<h3 class="text-md font-medium text-primary mb-3">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mr-2" />
算法说明
</h3>
<div class="text-sm text-secondary">
{{ getAlgorithmDescription() }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import CryptoJS from 'crypto-js'
// 加密算法类型
type CryptoType = 'md5' | 'sha1' | 'sha256' | 'sha512' | 'aes' | 'base64'
// 算法配置
interface AlgorithmConfig {
type: CryptoType
name: string
needsKey: boolean
isEncodeDecode: boolean
description: string
}
// 响应式状态
const activeAlgorithm = ref<CryptoType>('md5')
const inputText = ref('')
const secretKey = ref('')
const output = ref('')
const isDecoding = ref(false)
const copied = ref(false)
const error = ref<string | null>(null)
const success = ref<string | null>(null)
// 算法配置
const algorithms: AlgorithmConfig[] = [
{
type: 'md5',
name: 'MD5',
needsKey: false,
isEncodeDecode: false,
description: 'MD5是一种广泛使用的密码散列函数可以产生出一个128位16字节的散列值。常用于文件校验和密码存储。'
},
{
type: 'sha1',
name: 'SHA1',
needsKey: false,
isEncodeDecode: false,
description: 'SHA-1是一种密码散列函数可以产生一个160位20字节的散列值。比MD5更安全但现在也被认为不够安全。'
},
{
type: 'sha256',
name: 'SHA256',
needsKey: false,
isEncodeDecode: false,
description: 'SHA-256是SHA-2家族的一种可以产生一个256位32字节的散列值。目前被认为是安全的哈希算法。'
},
{
type: 'sha512',
name: 'SHA512',
needsKey: false,
isEncodeDecode: false,
description: 'SHA-512是SHA-2家族的一种可以产生一个512位64字节的散列值。比SHA-256更安全计算量也更大。'
},
{
type: 'aes',
name: 'AES',
needsKey: true,
isEncodeDecode: true,
description: 'AES高级加密标准是一种对称加密算法需要相同的密钥进行加密和解密。广泛用于数据保护。'
},
{
type: 'base64',
name: 'Base64',
needsKey: false,
isEncodeDecode: true,
description: 'Base64是一种编码方式常用于在文本环境中传输二进制数据。不是加密算法只是编码转换。'
}
]
// 当前算法配置
const currentAlgorithm = computed(() =>
algorithms.find(algo => algo.type === activeAlgorithm.value)
)
// 获取输入提示文本
const getInputPlaceholder = (): string => {
if (isDecoding.value && currentAlgorithm.value?.isEncodeDecode) {
return currentAlgorithm.value.type === 'aes'
? '请输入要解密的密文...'
: '请输入要解码的内容...'
}
return '请输入要处理的文本内容...'
}
// 获取处理按钮文本
const getProcessButtonText = (): string => {
if (!currentAlgorithm.value) return '处理'
if (currentAlgorithm.value.isEncodeDecode) {
return isDecoding.value
? (currentAlgorithm.value.type === 'aes' ? '解密' : '解码')
: (currentAlgorithm.value.type === 'aes' ? '加密' : '编码')
}
return '生成哈希'
}
// 获取算法描述
const getAlgorithmDescription = (): string => {
return currentAlgorithm.value?.description || ''
}
// 设置活动算法
const setActiveAlgorithm = (type: CryptoType) => {
activeAlgorithm.value = type
output.value = ''
error.value = null
success.value = null
// 如果不支持编码/解码,重置解码状态
if (!currentAlgorithm.value?.isEncodeDecode) {
isDecoding.value = false
}
}
// 处理操作
const processOperation = () => {
error.value = null
success.value = null
output.value = ''
if (!inputText.value.trim()) {
error.value = '请输入要处理的内容'
return
}
if (currentAlgorithm.value?.needsKey && !secretKey.value.trim()) {
error.value = '请输入密钥'
return
}
try {
let result = ''
switch (activeAlgorithm.value) {
case 'md5':
result = CryptoJS.MD5(inputText.value).toString()
break
case 'sha1':
result = CryptoJS.SHA1(inputText.value).toString()
break
case 'sha256':
result = CryptoJS.SHA256(inputText.value).toString()
break
case 'sha512':
result = CryptoJS.SHA512(inputText.value).toString()
break
case 'aes':
if (isDecoding.value) {
// 解密操作
try {
const decrypted = CryptoJS.AES.decrypt(inputText.value, secretKey.value)
result = decrypted.toString(CryptoJS.enc.Utf8)
if (!result) {
throw new Error('解密失败,请检查密文和密钥是否正确')
}
} catch {
throw new Error('解密失败,请检查密文和密钥是否正确')
}
} else {
// 加密操作
result = CryptoJS.AES.encrypt(inputText.value, secretKey.value).toString()
}
break
case 'base64':
if (isDecoding.value) {
// Base64解码
try {
result = CryptoJS.enc.Base64.parse(inputText.value).toString(CryptoJS.enc.Utf8)
} catch {
throw new Error('Base64解码失败请检查输入内容格式')
}
} else {
// Base64编码
result = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(inputText.value))
}
break
}
output.value = result
success.value = isDecoding.value ? '解密/解码成功' : '加密/编码成功'
} catch (err) {
console.error('处理错误:', err)
error.value = `处理失败: ${err instanceof Error ? err.message : '未知错误'}`
}
}
// 复制到剪贴板
const copyToClipboard = async () => {
if (!output.value) return
try {
await navigator.clipboard.writeText(output.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
console.error('复制失败:', err)
error.value = '复制失败'
}
}
// 清空所有内容
const clearAll = () => {
inputText.value = ''
secretKey.value = ''
output.value = ''
error.value = null
success.value = null
}
// 加载示例
const loadExample = () => {
const examples: Record<CryptoType, { input: string; key?: string }> = {
md5: { input: 'Hello, World!' },
sha1: { input: 'Hello, World!' },
sha256: { input: 'Hello, World!' },
sha512: { input: 'Hello, World!' },
aes: { input: 'Hello, World!', key: 'secret-key-12345' },
base64: { input: 'Hello, World!' }
}
const example = examples[activeAlgorithm.value]
inputText.value = example.input
if (example.key) {
secretKey.value = example.key
}
output.value = ''
error.value = null
success.value = null
}
// 清除状态提示的定时器
watch([error, success], () => {
if (error.value || success.value) {
setTimeout(() => {
error.value = null
success.value = null
}, 3000)
}
})
</script>

View File

@ -0,0 +1,332 @@
<template>
<div class="space-y-6">
<!-- 标题和描述 -->
<div class="card p-6">
<h2 class="text-2xl font-bold text-primary mb-4">CSS渐变生成器</h2>
<p class="text-secondary mb-4">创建线性渐变和径向渐变生成CSS代码并实时预览效果</p>
</div>
<!-- 渐变类型选择 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">渐变类型</h3>
<div class="flex gap-3">
<button
@click="gradientType = 'linear'"
:class="gradientType === 'linear' ? 'btn-primary' : 'btn-secondary'"
class="px-4 py-2 rounded-lg"
>
线性渐变
</button>
<button
@click="gradientType = 'radial'"
:class="gradientType === 'radial' ? 'btn-primary' : 'btn-secondary'"
class="px-4 py-2 rounded-lg"
>
径向渐变
</button>
</div>
</div>
<!-- 线性渐变设置 -->
<div v-if="gradientType === 'linear'" class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">角度设置</h3>
<div class="flex items-center gap-4">
<input
v-model.number="linearAngle"
type="range"
min="0"
max="360"
class="flex-1"
/>
<input
v-model.number="linearAngle"
type="number"
min="0"
max="360"
class="input w-20"
/>
<span>°</span>
</div>
</div>
<!-- 径向渐变设置 -->
<div v-if="gradientType === 'radial'" class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">径向渐变设置</h3>
<!-- 形状 -->
<div class="mb-4">
<label class="block text-sm font-medium text-secondary mb-2">形状</label>
<div class="flex gap-3">
<button
v-for="shape in radialShapes"
:key="shape.value"
@click="radialShape = shape.value"
:class="{
'btn-primary': radialShape === shape.value,
'btn-secondary': radialShape !== shape.value
}"
class="px-4 py-2 rounded transition-colors"
>
{{ shape.label }}
</button>
</div>
</div>
<!-- 大小 -->
<div class="mb-4">
<label class="block text-sm font-medium text-secondary mb-2">大小</label>
<div class="grid grid-cols-2 gap-2">
<button
v-for="size in radialSizes"
:key="size.value"
@click="radialSize = size.value"
:class="{
'btn-primary': radialSize === size.value,
'btn-secondary': radialSize !== size.value
}"
class="px-3 py-2 text-sm rounded transition-colors"
>
{{ size.label }}
</button>
</div>
</div>
<!-- 位置 -->
<div class="mb-4">
<label class="block text-sm font-medium text-secondary mb-2">位置</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="position in radialPositions"
:key="position.value"
@click="radialPosition = position.value"
:class="{
'btn-primary': radialPosition === position.value,
'btn-secondary': radialPosition !== position.value
}"
class="px-3 py-2 text-sm rounded transition-colors"
>
{{ position.label }}
</button>
</div>
</div>
</div>
<!-- 颜色设置 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">颜色设置</h3>
<div class="space-y-3 mb-4">
<div v-for="(color, index) in colors" :key="index" class="flex items-center gap-3">
<input
v-model="color.color"
type="color"
class="w-12 h-10 rounded"
/>
<input
v-model="color.color"
type="text"
class="input flex-1"
/>
<input
v-model.number="color.position"
type="number"
min="0"
max="100"
class="input w-20"
/>
<span>%</span>
<button
@click="removeColor(index)"
:disabled="colors.length <= 2"
class="btn-secondary"
>
删除
</button>
</div>
</div>
<button @click="addColor" class="btn-primary">添加颜色</button>
</div>
<!-- 预览 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">预览</h3>
<div
class="w-full h-40 rounded-lg border"
:style="{ background: generatedCSS }"
></div>
</div>
<!-- 生成的CSS -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-primary">生成的CSS</h3>
<button @click="copyCSS" class="btn-secondary">复制</button>
</div>
<textarea
:value="generatedCSS"
readonly
class="input w-full h-20 font-mono"
></textarea>
</div>
<!-- 使用说明 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">使用说明</h3>
<div class="prose text-secondary max-w-none">
<ul class="space-y-2">
<li><strong>线性渐变</strong>沿直线方向的颜色过渡可调整角度和方向</li>
<li><strong>径向渐变</strong>从中心点向外辐射的颜色过渡可调整形状大小和位置</li>
<li><strong>颜色设置</strong>点击颜色块选择颜色调整位置百分比控制渐变分布</li>
<li><strong>预设渐变</strong>提供多种常用渐变效果点击即可应用</li>
<li><strong>实时预览</strong>所有修改都会实时显示在预览区域</li>
<li><strong>代码生成</strong>自动生成标准CSS代码支持复制完整规则或仅background属性</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// 颜色接口
interface ColorStop {
color: string
position: number
}
// 渐变类型
const gradientTypes = [
{ label: '线性渐变', value: 'linear' },
{ label: '径向渐变', value: 'radial' }
]
// 线性渐变方向
const linearDirections = [
{ label: '向右', value: 90 },
{ label: '向左', value: 270 },
{ label: '向下', value: 180 },
{ label: '向上', value: 0 },
{ label: '右下', value: 135 },
{ label: '左上', value: 315 },
{ label: '右上', value: 45 },
{ label: '左下', value: 225 }
]
// 径向渐变形状
const radialShapes = [
{ label: '椭圆', value: 'ellipse' },
{ label: '圆形', value: 'circle' }
]
// 径向渐变大小
const radialSizes = [
{ label: '最近边', value: 'closest-side' },
{ label: '最近角', value: 'closest-corner' },
{ label: '最远边', value: 'farthest-side' },
{ label: '最远角', value: 'farthest-corner' }
]
// 径向渐变位置
const radialPositions = [
{ label: '左上', value: 'top left' },
{ label: '上方', value: 'top' },
{ label: '右上', value: 'top right' },
{ label: '左侧', value: 'left' },
{ label: '中心', value: 'center' },
{ label: '右侧', value: 'right' },
{ label: '左下', value: 'bottom left' },
{ label: '下方', value: 'bottom' },
{ label: '右下', value: 'bottom right' }
]
// 响应式状态
const gradientType = ref('linear')
const linearAngle = ref(90)
const radialShape = ref('ellipse')
const radialSize = ref('farthest-corner')
const radialPosition = ref('center')
const colors = ref<ColorStop[]>([
{ color: '#667eea', position: 0 },
{ color: '#764ba2', position: 100 }
])
// 计算生成的CSS
const generatedCSS = computed(() => {
const colorStops = colors.value
.sort((a, b) => a.position - b.position)
.map(color => `${color.color} ${color.position}%`)
.join(', ')
if (gradientType.value === 'linear') {
return `linear-gradient(${linearAngle.value}deg, ${colorStops})`
} else {
return `radial-gradient(${radialShape.value} ${radialSize.value} at ${radialPosition.value}, ${colorStops})`
}
})
// 添加颜色
const addColor = () => {
const newPosition = colors.value.length > 0
? Math.max(...colors.value.map(c => c.position)) + 10
: 50
colors.value.push({
color: '#000000',
position: Math.min(newPosition, 100)
})
}
// 删除颜色
const removeColor = (index: number) => {
if (colors.value.length > 2) {
colors.value.splice(index, 1)
}
}
// 复制CSS
const copyCSS = async () => {
try {
await navigator.clipboard.writeText(generatedCSS.value)
alert('CSS代码已复制到剪贴板')
} catch (error) {
console.error('复制失败:', error)
alert('复制失败,请手动复制')
}
}
</script>
<style scoped>
.slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, #ddd, #ddd);
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
border: none;
}
.prose ul {
list-style-type: disc;
padding-left: 1.5rem;
}
</style>

View File

@ -0,0 +1,550 @@
<template>
<div class="space-y-6">
<!-- 模式切换 -->
<div class="flex flex-wrap gap-4 justify-between items-center">
<div class="flex items-center bg-card rounded-md p-1 border">
<button
:class="[
'px-4 py-2 text-sm font-medium rounded-l transition-all',
mode === 'diff' ? 'bg-primary-500 text-white shadow-sm' : 'text-secondary hover:text-primary'
]"
@click="mode = 'diff'"
>
日期差值计算
</button>
<button
:class="[
'px-4 py-2 text-sm font-medium rounded-r transition-all',
mode === 'add' ? 'bg-primary-500 text-white shadow-sm' : 'text-secondary hover:text-primary'
]"
@click="mode = 'add'"
>
日期加减计算
</button>
</div>
</div>
<!-- 日期差值计算 -->
<div v-if="mode === 'diff'" class="card p-6">
<h2 class="text-lg font-medium text-primary mb-4">日期差值计算器</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 输入部分 -->
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-secondary mb-2">开始日期</label>
<div class="flex items-center gap-2">
<input
v-model="startDate"
type="datetime-local"
class="input-field flex-1"
@input="calculateDateDiff"
/>
<button
class="btn-secondary text-sm px-3 py-2"
@click="setStartDateToCurrent"
>
当前时间
</button>
</div>
</div>
<div class="flex justify-center">
<button
class="btn-secondary text-sm px-4 py-2"
@click="swapDates"
>
<FontAwesomeIcon :icon="['fas', 'calendar-alt']" class="mr-2" />
交换日期
</button>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">结束日期</label>
<div class="flex items-center gap-2">
<input
v-model="endDate"
type="datetime-local"
class="input-field flex-1"
@input="calculateDateDiff"
/>
<button
class="btn-secondary text-sm px-3 py-2"
@click="setEndDateToCurrent"
>
当前时间
</button>
</div>
</div>
</div>
<!-- 结果部分 -->
<div class="bg-block rounded-md p-4 border">
<h3 class="text-primary font-medium mb-3">计算结果</h3>
<div class="space-y-2">
<div
v-for="(result, key) in diffResults"
:key="key"
class="flex justify-between items-center py-2 border-b border-primary/10 last:border-0"
>
<span class="text-sm text-secondary">{{ getTimeUnitLabel(key) }}</span>
<div class="flex items-center gap-2">
<span class="text-sm text-primary font-semibold">
{{ result }} {{ getTimeUnitName(key) }}
</span>
<button
@click="() => copyToClipboard(result.toString(), key)"
class="text-tertiary hover:text-primary transition-colors"
>
<FontAwesomeIcon :icon="copied === key ? ['fas', 'check'] : ['fas', 'copy']" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 日期加减计算 -->
<div v-if="mode === 'add'" class="card p-6">
<h2 class="text-lg font-medium text-primary mb-4">日期加减计算器</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 输入部分 -->
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-secondary mb-2">基准日期</label>
<div class="flex items-center gap-2">
<input
v-model="baseDate"
type="datetime-local"
class="input-field flex-1"
@input="calculateDateAddition"
/>
<button
class="btn-secondary text-sm px-3 py-2"
@click="setBaseDateToCurrent"
>
当前时间
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">操作类型</label>
<div class="flex gap-2">
<button
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded transition-all',
operation === 'add' ? 'bg-primary text-white' : 'bg-block text-secondary border'
]"
@click="() => setOperation('add')"
>
<FontAwesomeIcon :icon="['fas', 'plus']" class="mr-2" />
加上
</button>
<button
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded transition-all',
operation === 'subtract' ? 'bg-primary text-white' : 'bg-block text-secondary border'
]"
@click="() => setOperation('subtract')"
>
<FontAwesomeIcon :icon="['fas', 'minus']" class="mr-2" />
减去
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">时间数量</label>
<input
v-model.number="timeAmount"
type="number"
min="0"
step="1"
class="input-field w-full"
@input="calculateDateAddition"
/>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">时间单位</label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<button
v-for="unit in timeUnits"
:key="unit.value"
:class="[
'px-3 py-2 text-xs rounded transition-all',
timeUnit === unit.value ? 'bg-primary text-white' : 'btn-secondary'
]"
@click="() => setTimeUnit(unit.value)"
>
{{ unit.label }}
</button>
</div>
</div>
</div>
<!-- 结果部分 -->
<div class="bg-block rounded-md p-4 border">
<h3 class="text-primary font-medium mb-3">计算结果</h3>
<div v-if="addResult" class="space-y-3">
<div class="text-center">
<div class="text-lg text-primary font-bold">{{ addResult }}</div>
<button
@click="() => copyToClipboard(addResult, 'result')"
class="mt-2 btn-secondary text-sm"
>
<FontAwesomeIcon :icon="copied === 'result' ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied === 'result' ? '已复制' : '复制结果' }}
</button>
</div>
<!-- 详细信息 -->
<div class="text-xs text-tertiary text-center">
{{ formatCalculationDescription() }}
</div>
</div>
<div v-else class="text-tertiary text-center py-4">
请填写有效的基准日期和时间数量
</div>
</div>
</div>
</div>
<!-- 快速操作面板 -->
<div class="card p-4">
<h3 class="text-md font-medium text-primary mb-3">快速操作</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<button
v-for="quick in quickOperations"
:key="quick.label"
@click="() => applyQuickOperation(quick)"
class="btn-secondary text-sm"
>
{{ quick.label }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
// 时间单位枚举
enum TimeUnit {
YEARS = 'years',
MONTHS = 'months',
WEEKS = 'weeks',
DAYS = 'days',
HOURS = 'hours',
MINUTES = 'minutes'
}
// 响应式状态
const mode = ref<'diff' | 'add'>('diff')
// 日期差值计算状态
const startDate = ref('')
const endDate = ref('')
const diffResults = ref<Record<string, number>>({})
// 日期加减计算状态
const baseDate = ref('')
const timeAmount = ref(1)
const timeUnit = ref<TimeUnit>(TimeUnit.DAYS)
const operation = ref<'add' | 'subtract'>('add')
const addResult = ref('')
// 复制状态
const copied = ref<string | null>(null)
// 时间单位选项
const timeUnits = [
{ value: TimeUnit.YEARS, label: '年' },
{ value: TimeUnit.MONTHS, label: '月' },
{ value: TimeUnit.WEEKS, label: '周' },
{ value: TimeUnit.DAYS, label: '天' },
{ value: TimeUnit.HOURS, label: '小时' },
{ value: TimeUnit.MINUTES, label: '分钟' }
]
// 快速操作选项
const quickOperations = [
{ label: '距今一周', action: () => setQuickDiff(-7, 'days') },
{ label: '距今一月', action: () => setQuickDiff(-1, 'months') },
{ label: '一周后', action: () => setQuickAdd(7, 'days') },
{ label: '一月后', action: () => setQuickAdd(1, 'months') }
]
// 格式化日期为显示格式
const formatDateForDisplay = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
// 格式化日期为输入框格式
const formatDateForInput = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
// 获取时间单位标签
const getTimeUnitLabel = (key: string): string => {
const labels: Record<string, string> = {
years: '相差年数',
months: '相差月数',
weeks: '相差周数',
days: '相差天数',
hours: '相差小时',
minutes: '相差分钟',
seconds: '相差秒数',
milliseconds: '相差毫秒'
}
return labels[key] || key
}
// 获取时间单位名称
const getTimeUnitName = (key: string): string => {
const names: Record<string, string> = {
years: '年',
months: '月',
weeks: '周',
days: '天',
hours: '小时',
minutes: '分钟',
seconds: '秒',
milliseconds: '毫秒'
}
return names[key] || ''
}
// 计算日期差值
const calculateDateDiff = () => {
if (!startDate.value || !endDate.value) {
diffResults.value = {}
return
}
try {
const startDateTime = new Date(startDate.value)
const endDateTime = new Date(endDate.value)
if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
return
}
// 计算毫秒差值
const diffMs = endDateTime.getTime() - startDateTime.getTime()
// 计算各个单位的差值
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
const diffWeeks = Math.floor(diffDays / 7)
// 计算月份差
let months = (endDateTime.getFullYear() - startDateTime.getFullYear()) * 12
months += endDateTime.getMonth() - startDateTime.getMonth()
// 计算年份差
const diffYears = Math.floor(months / 12)
// 设置结果
diffResults.value = {
years: diffYears,
months: months,
weeks: diffWeeks,
days: diffDays,
hours: diffHours,
minutes: diffMinutes,
seconds: diffSeconds,
milliseconds: diffMs
}
} catch (error) {
console.error('计算错误:', error)
}
}
// 计算日期加减
const calculateDateAddition = () => {
if (!baseDate.value || isNaN(timeAmount.value)) {
addResult.value = ''
return
}
try {
const baseDateTime = new Date(baseDate.value)
if (isNaN(baseDateTime.getTime())) {
return
}
const resultDate = new Date(baseDateTime)
const sign = operation.value === 'add' ? 1 : -1
switch (timeUnit.value) {
case TimeUnit.YEARS:
resultDate.setFullYear(resultDate.getFullYear() + sign * timeAmount.value)
break
case TimeUnit.MONTHS:
resultDate.setMonth(resultDate.getMonth() + sign * timeAmount.value)
break
case TimeUnit.WEEKS:
resultDate.setDate(resultDate.getDate() + sign * timeAmount.value * 7)
break
case TimeUnit.DAYS:
resultDate.setDate(resultDate.getDate() + sign * timeAmount.value)
break
case TimeUnit.HOURS:
resultDate.setHours(resultDate.getHours() + sign * timeAmount.value)
break
case TimeUnit.MINUTES:
resultDate.setMinutes(resultDate.getMinutes() + sign * timeAmount.value)
break
}
// 格式化结果
addResult.value = formatDateForDisplay(resultDate)
} catch (error) {
console.error('计算错误:', error)
}
}
// 格式化计算描述
const formatCalculationDescription = (): string => {
const unitName = timeUnits.find(unit => unit.value === timeUnit.value)?.label || ''
const op = operation.value === 'add' ? '加上' : '减去'
return `${formatDateForDisplay(new Date(baseDate.value))} ${op} ${timeAmount.value} ${unitName}`
}
// 设置开始日期为当前时间
const setStartDateToCurrent = () => {
const now = formatDateForInput(new Date())
startDate.value = now
calculateDateDiff()
}
// 设置结束日期为当前时间
const setEndDateToCurrent = () => {
const now = formatDateForInput(new Date())
endDate.value = now
calculateDateDiff()
}
// 设置基准日期为当前时间
const setBaseDateToCurrent = () => {
const now = formatDateForInput(new Date())
baseDate.value = now
calculateDateAddition()
}
// 交换开始和结束日期
const swapDates = () => {
const temp = startDate.value
startDate.value = endDate.value
endDate.value = temp
calculateDateDiff()
}
// 设置时间单位
const setTimeUnit = (unit: TimeUnit) => {
timeUnit.value = unit
calculateDateAddition()
}
// 设置操作类型
const setOperation = (op: 'add' | 'subtract') => {
operation.value = op
calculateDateAddition()
}
// 复制到剪贴板
const copyToClipboard = async (text: string, type: string) => {
try {
await navigator.clipboard.writeText(text)
copied.value = type
setTimeout(() => {
copied.value = null
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 设置快速日期差值
const setQuickDiff = (amount: number, unit: string) => {
const now = new Date()
const past = new Date(now)
switch (unit) {
case 'days':
past.setDate(past.getDate() + amount)
break
case 'months':
past.setMonth(past.getMonth() + amount)
break
}
startDate.value = formatDateForInput(past)
endDate.value = formatDateForInput(now)
mode.value = 'diff'
calculateDateDiff()
}
// 设置快速日期加减
const setQuickAdd = (amount: number, unit: string) => {
const now = new Date()
baseDate.value = formatDateForInput(now)
timeAmount.value = amount
switch (unit) {
case 'days':
timeUnit.value = TimeUnit.DAYS
break
case 'months':
timeUnit.value = TimeUnit.MONTHS
break
}
operation.value = 'add'
mode.value = 'add'
calculateDateAddition()
}
// 应用快速操作
const applyQuickOperation = (quick: any) => {
quick.action()
}
// 初始化
onMounted(() => {
const now = new Date()
const oneWeekAgo = new Date(now)
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7)
startDate.value = formatDateForInput(oneWeekAgo)
endDate.value = formatDateForInput(now)
baseDate.value = formatDateForInput(now)
// 初始化时计算一次
calculateDateDiff()
calculateDateAddition()
})
</script>

View File

@ -0,0 +1,306 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="() => encode('base64')"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'lock']" class="mr-2" />
{{ t('tools.encoding_converter.base64_encode') }}
</button>
<button
@click="() => decode('base64')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'unlock']" class="mr-2" />
{{ t('tools.encoding_converter.base64_decode') }}
</button>
<button
@click="() => encode('url')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'link']" class="mr-2" />
{{ t('tools.encoding_converter.url_encode') }}
</button>
<button
@click="() => decode('url')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'unlink']" class="mr-2" />
{{ t('tools.encoding_converter.url_decode') }}
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('common.clear') }}
</button>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusMessage.type === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusMessage.type === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage.text }}</span>
</div>
</div>
<!-- 输入输出区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输入</h3>
<div class="flex space-x-2">
<button
@click="pasteFromClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="粘贴"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" />
</button>
</div>
</div>
<textarea
v-model="inputText"
:placeholder="t('tools.encoding_converter.input_placeholder')"
class="textarea-field h-80"
/>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输出</h3>
<div class="flex space-x-2">
<button
@click="copyToClipboard"
:disabled="!outputText"
class="p-2 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50"
title="复制"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="copied && 'text-success'"
/>
</button>
</div>
</div>
<textarea
v-model="outputText"
:placeholder="t('tools.encoding_converter.output_placeholder')"
class="textarea-field h-80"
readonly
/>
</div>
</div>
<!-- 快速转换工具 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Base64 工具 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">Base64 工具</h3>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<button @click="() => encode('base64')" class="btn-secondary text-sm">编码</button>
<button @click="() => decode('base64')" class="btn-secondary text-sm">解码</button>
</div>
<div class="text-xs text-tertiary">
Base64是一种基于64个可打印字符来表示二进制数据的表示方法
</div>
</div>
</div>
<!-- URL 工具 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">URL 编码工具</h3>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<button @click="() => encode('url')" class="btn-secondary text-sm">编码</button>
<button @click="() => decode('url')" class="btn-secondary text-sm">解码</button>
</div>
<div class="text-xs text-tertiary">
URL编码将特殊字符转换为%XX格式用于URL传输
</div>
</div>
</div>
</div>
<!-- 示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">示例</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="font-medium text-secondary mb-2">Base64 示例</h4>
<div class="bg-block p-3 rounded text-sm font-mono">
<div><strong>原文:</strong> Hello World!</div>
<div><strong>编码:</strong> SGVsbG8gV29ybGQh</div>
</div>
</div>
<div>
<h4 class="font-medium text-secondary mb-2">URL 编码示例</h4>
<div class="bg-block p-3 rounded text-sm font-mono">
<div><strong>原文:</strong> hello world!</div>
<div><strong>编码:</strong> hello%20world%21</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const inputText = ref('')
const outputText = ref('')
const copied = ref(false)
const statusMessage = ref<{ type: 'success' | 'error', text: string } | null>(null)
// Base64 编码
const base64Encode = (text: string): string => {
try {
return btoa(unescape(encodeURIComponent(text)))
} catch (error) {
throw new Error('Base64编码失败')
}
}
// Base64 解码
const base64Decode = (text: string): string => {
try {
return decodeURIComponent(escape(atob(text)))
} catch (error) {
throw new Error('Base64解码失败请检查输入格式')
}
}
// URL 编码
const urlEncode = (text: string): string => {
try {
return encodeURIComponent(text)
} catch (error) {
throw new Error('URL编码失败')
}
}
// URL 解码
const urlDecode = (text: string): string => {
try {
return decodeURIComponent(text)
} catch (error) {
throw new Error('URL解码失败请检查输入格式')
}
}
// 编码函数
const encode = (type: 'base64' | 'url') => {
if (!inputText.value.trim()) {
statusMessage.value = { type: 'error', text: '请输入要编码的内容' }
return
}
try {
let result = ''
switch (type) {
case 'base64':
result = base64Encode(inputText.value)
break
case 'url':
result = urlEncode(inputText.value)
break
}
outputText.value = result
statusMessage.value = { type: 'success', text: `${type.toUpperCase()}编码成功` }
} catch (error) {
outputText.value = ''
statusMessage.value = {
type: 'error',
text: error instanceof Error ? error.message : '编码失败'
}
}
}
// 解码函数
const decode = (type: 'base64' | 'url') => {
if (!inputText.value.trim()) {
statusMessage.value = { type: 'error', text: '请输入要解码的内容' }
return
}
try {
let result = ''
switch (type) {
case 'base64':
result = base64Decode(inputText.value)
break
case 'url':
result = urlDecode(inputText.value)
break
}
outputText.value = result
statusMessage.value = { type: 'success', text: `${type.toUpperCase()}解码成功` }
} catch (error) {
outputText.value = ''
statusMessage.value = {
type: 'error',
text: error instanceof Error ? error.message : '解码失败'
}
}
}
// 清除所有内容
const clearAll = () => {
inputText.value = ''
outputText.value = ''
statusMessage.value = null
}
// 从剪贴板粘贴
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
inputText.value = text
} catch (error) {
console.error('粘贴失败:', error)
}
}
// 复制到剪贴板
const copyToClipboard = async () => {
if (!outputText.value) return
try {
await navigator.clipboard.writeText(outputText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
</script>

View File

@ -0,0 +1,939 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="convertContent"
:disabled="!inputContent.trim()"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'exchange-alt']" class="mr-2" />
转换
</button>
<button
@click="copyResult"
:disabled="!outputContent"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
复制结果
</button>
<button
@click="swapDirection"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'sync-alt']" class="mr-2" />
交换方向
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清除
</button>
<button
@click="loadSample"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'file-import']" class="mr-2" />
示例
</button>
<button
@click="downloadResult"
:disabled="!outputContent"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
下载
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="space-y-4">
<!-- 输入格式选择 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">输入格式</h3>
<div class="grid grid-cols-2 gap-2">
<button
@click="setInputFormat('html')"
:class="[
'p-3 rounded-lg text-left transition-colors',
inputFormat === 'html'
? 'bg-primary text-white'
: 'bg-block hover:bg-block-hover text-secondary hover:text-primary'
]"
>
<div class="font-medium">HTML</div>
<div class="text-xs opacity-80">超文本标记语言</div>
</button>
<button
@click="setInputFormat('markdown')"
:class="[
'p-3 rounded-lg text-left transition-colors',
inputFormat === 'markdown'
? 'bg-primary text-white'
: 'bg-block hover:bg-block-hover text-secondary hover:text-primary'
]"
>
<div class="font-medium">Markdown</div>
<div class="text-xs opacity-80">轻量级标记语言</div>
</button>
</div>
</div>
<!-- 输入内容 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输入内容</h3>
<div class="text-sm text-secondary">
{{ inputStats.lines }} | {{ inputStats.chars }} 字符
</div>
</div>
<textarea
v-model="inputContent"
:placeholder="getInputPlaceholder()"
class="textarea-field h-96 font-mono text-sm"
@input="validateInput"
/>
<!-- 验证状态 -->
<div class="mt-2 flex items-center justify-between">
<div
v-if="validationMessage"
:class="[
'text-sm flex items-center space-x-1',
isValid ? 'text-success' : 'text-error'
]"
>
<FontAwesomeIcon
:icon="isValid ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ validationMessage }}</span>
</div>
<div class="flex space-x-2">
<button
@click="formatInput"
:disabled="!inputContent.trim()"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-1" />
格式化
</button>
<button
@click="previewInput"
:disabled="!inputContent.trim()"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'eye']" class="mr-1" />
预览
</button>
</div>
</div>
</div>
<!-- 转换选项 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">转换选项</h3>
<div class="space-y-3">
<!-- HTML转Markdown选项 -->
<div v-if="inputFormat === 'html'">
<div class="text-sm font-medium text-secondary mb-2">HTML转Markdown选项</div>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.preserveLinks"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">保留链接</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.preserveImages"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">保留图片</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.preserveCodeBlocks"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">保留代码块</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.removeEmptyElements"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除空元素</span>
</label>
</div>
</div>
<!-- Markdown转HTML选项 -->
<div v-if="inputFormat === 'markdown'">
<div class="text-sm font-medium text-secondary mb-2">Markdown转HTML选项</div>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.addLineBreaks"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">自动换行</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.sanitizeHtml"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">清理HTML</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.enableCodeHighlight"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">代码高亮</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.openLinksInNewTab"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">链接新窗口打开</span>
</label>
</div>
</div>
<!-- 通用选项 -->
<div>
<div class="text-sm font-medium text-secondary mb-2">通用选项</div>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.prettifyOutput"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">美化输出</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.preserveWhitespace"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">保留空白字符</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 输出区域 -->
<div class="space-y-4">
<!-- 输出格式显示 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">
输出格式: {{ outputFormat === 'html' ? 'HTML' : 'Markdown' }}
</h3>
<div class="p-3 bg-block rounded-lg">
<div class="text-sm text-secondary">
{{ outputFormat === 'html'
? '将转换为HTML格式支持在浏览器中直接显示'
: '将转换为Markdown格式适合文档编写和版本控制'
}}
</div>
</div>
</div>
<!-- 输出内容 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输出内容</h3>
<div class="text-sm text-secondary">
{{ outputStats.lines }} | {{ outputStats.chars }} 字符
</div>
</div>
<textarea
v-model="outputContent"
readonly
placeholder="转换结果将显示在这里..."
class="textarea-field h-96 font-mono text-sm bg-block"
/>
<!-- 输出操作 -->
<div class="mt-2 flex items-center justify-between">
<div class="flex space-x-2">
<button
@click="previewOutput"
:disabled="!outputContent"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'eye']" class="mr-1" />
预览
</button>
<button
@click="validateOutput"
:disabled="!outputContent"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'check-circle']" class="mr-1" />
验证
</button>
</div>
<div v-if="conversionStats" class="text-xs text-secondary">
转换时间: {{ conversionStats.time }}ms
</div>
</div>
</div>
<!-- 预览窗口 -->
<div v-if="previewContent" class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">预览</h3>
<button
@click="closePreview"
class="text-secondary hover:text-primary transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'times']" />
</button>
</div>
<div
class="max-h-80 overflow-auto border border-border rounded-lg p-4 bg-white text-black"
v-html="previewContent"
/>
</div>
<!-- 使用说明 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">使用说明</h3>
<div class="space-y-3 text-sm text-secondary">
<div>
<div class="font-medium">HTML转Markdown:</div>
<ul class="list-disc list-inside text-tertiary ml-2 mt-1">
<li>自动识别HTML标签并转换为Markdown语法</li>
<li>保留文本格式链接图片等元素</li>
<li>移除多余的HTML属性和样式</li>
<li>适合将网页内容转换为文档格式</li>
</ul>
</div>
<div>
<div class="font-medium">Markdown转HTML:</div>
<ul class="list-disc list-inside text-tertiary ml-2 mt-1">
<li>解析Markdown语法并生成HTML</li>
<li>支持标题列表代码块表格等</li>
<li>可添加语法高亮和样式</li>
<li>生成的HTML可直接在浏览器中显示</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const inputContent = ref('')
const outputContent = ref('')
const inputFormat = ref<'html' | 'markdown'>('html')
const outputFormat = computed(() => inputFormat.value === 'html' ? 'markdown' : 'html')
const copied = ref(false)
const isValid = ref(true)
const validationMessage = ref('')
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
const previewContent = ref('')
// 转换选项
const options = reactive({
// HTML转Markdown选项
preserveLinks: true,
preserveImages: true,
preserveCodeBlocks: true,
removeEmptyElements: true,
// Markdown转HTML选项
addLineBreaks: true,
sanitizeHtml: true,
enableCodeHighlight: false,
openLinksInNewTab: true,
// 通用选项
prettifyOutput: true,
preserveWhitespace: false
})
// 转换统计
const conversionStats = ref<{ time: number } | null>(null)
// 计算属性
const inputStats = computed(() => {
const lines = inputContent.value ? inputContent.value.split('\n').length : 0
const chars = inputContent.value.length
return { lines, chars }
})
const outputStats = computed(() => {
const lines = outputContent.value ? outputContent.value.split('\n').length : 0
const chars = outputContent.value.length
return { lines, chars }
})
// 获取输入占位符
const getInputPlaceholder = (): string => {
if (inputFormat.value === 'html') {
return `<!DOCTYPE html>
<html>
<head>
<title>示例页面</title>
</head>
<body>
<h1>这是标题</h1>
<p>这是一个段落,包含<strong>粗体</strong>和<em>斜体</em>文本。</p>
<ul>
<li>列表项1</li>
<li>列表项2</li>
</ul>
</body>
</html>`
} else {
return `# 这是标题
这是一个段落,包含**粗体**和*斜体*文本。
- 列表项1
- 列表项2
\`\`\`javascript
console.log('Hello, World!');
\`\`\`
[链接文本](https://example.com)`
}
}
// 设置输入格式
const setInputFormat = (format: 'html' | 'markdown') => {
inputFormat.value = format
validateInput()
outputContent.value = ''
}
// 验证输入
const validateInput = () => {
if (!inputContent.value.trim()) {
isValid.value = true
validationMessage.value = ''
return
}
try {
if (inputFormat.value === 'html') {
// 简单的HTML验证
const parser = new DOMParser()
const doc = parser.parseFromString(inputContent.value, 'text/html')
const errors = doc.getElementsByTagName('parsererror')
if (errors.length > 0) {
throw new Error('HTML格式错误')
}
} else {
// Markdown基本验证检查常见语法错误
const content = inputContent.value
// 检查代码块是否匹配
const codeBlockMatches = content.match(/```/g)
if (codeBlockMatches && codeBlockMatches.length % 2 !== 0) {
throw new Error('代码块标记不匹配')
}
}
isValid.value = true
validationMessage.value = '格式正确'
} catch (error) {
isValid.value = false
validationMessage.value = error instanceof Error ? error.message : '格式错误'
}
}
// 格式化输入
const formatInput = () => {
if (!inputContent.value.trim()) return
try {
if (inputFormat.value === 'html') {
// 简单的HTML格式化
inputContent.value = formatHtml(inputContent.value)
} else {
// Markdown格式化主要是调整空行和缩进
inputContent.value = formatMarkdown(inputContent.value)
}
showStatus('格式化完成', 'success')
} catch (error) {
showStatus('格式化失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
}
}
// HTML格式化
const formatHtml = (html: string): string => {
return html
.replace(/></g, '>\n<')
.split('\n')
.map(line => line.trim())
.filter(line => line)
.join('\n')
}
// Markdown格式化
const formatMarkdown = (markdown: string): string => {
return markdown
.split('\n')
.map(line => line.trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n') // 合并多个空行
}
// 转换内容
const convertContent = () => {
if (!inputContent.value.trim()) {
showStatus('请输入内容', 'error')
return
}
if (!isValid.value) {
showStatus('请先修复输入格式错误', 'error')
return
}
const startTime = Date.now()
try {
let result: string
if (inputFormat.value === 'html') {
result = htmlToMarkdown(inputContent.value)
} else {
result = markdownToHtml(inputContent.value)
}
outputContent.value = result
const endTime = Date.now()
conversionStats.value = { time: endTime - startTime }
showStatus('转换成功', 'success')
} catch (error) {
showStatus('转换失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
outputContent.value = ''
conversionStats.value = null
}
}
// HTML转Markdown
const htmlToMarkdown = (html: string): string => {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const convertElement = (element: Element): string => {
const tagName = element.tagName.toLowerCase()
const text = element.textContent || ''
switch (tagName) {
case 'h1': return `# ${text}\n\n`
case 'h2': return `## ${text}\n\n`
case 'h3': return `### ${text}\n\n`
case 'h4': return `#### ${text}\n\n`
case 'h5': return `##### ${text}\n\n`
case 'h6': return `###### ${text}\n\n`
case 'p': return `${convertChildren(element)}\n\n`
case 'strong':
case 'b': return `**${text}**`
case 'em':
case 'i': return `*${text}*`
case 'code': return `\`${text}\``
case 'pre':
const codeElement = element.querySelector('code')
const code = codeElement ? codeElement.textContent : text
return `\`\`\`\n${code}\n\`\`\`\n\n`
case 'a':
const href = element.getAttribute('href') || '#'
return `[${text}](${href})`
case 'img':
const src = element.getAttribute('src') || ''
const alt = element.getAttribute('alt') || ''
return `![${alt}](${src})`
case 'ul':
return convertList(element, '-') + '\n'
case 'ol':
return convertList(element, '1.') + '\n'
case 'li':
return convertChildren(element)
case 'blockquote':
return `> ${convertChildren(element)}\n\n`
case 'hr':
return '---\n\n'
case 'br':
return '\n'
default:
return convertChildren(element)
}
}
const convertChildren = (element: Element): string => {
let result = ''
for (const child of element.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE) {
result += convertElement(child as Element)
} else if (child.nodeType === Node.TEXT_NODE) {
result += child.textContent || ''
}
}
return result
}
const convertList = (listElement: Element, marker: string): string => {
let result = ''
const items = listElement.querySelectorAll('li')
items.forEach((item, index) => {
const itemMarker = marker === '1.' ? `${index + 1}.` : marker
result += `${itemMarker} ${convertChildren(item)}\n`
})
return result
}
let markdown = convertElement(doc.body || doc.documentElement)
// 清理多余的空行
if (options.removeEmptyElements) {
markdown = markdown.replace(/\n{3,}/g, '\n\n')
}
return markdown.trim()
}
// Markdown转HTML
const markdownToHtml = (markdown: string): string => {
let html = markdown
// 转换标题
html = html.replace(/^# (.*$)/gm, '<h1>$1</h1>')
html = html.replace(/^## (.*$)/gm, '<h2>$1</h2>')
html = html.replace(/^### (.*$)/gm, '<h3>$1</h3>')
html = html.replace(/^#### (.*$)/gm, '<h4>$1</h4>')
html = html.replace(/^##### (.*$)/gm, '<h5>$1</h5>')
html = html.replace(/^###### (.*$)/gm, '<h6>$1</h6>')
// 转换粗体和斜体
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
// 转换代码
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// 转换代码块
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
// 转换链接
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
const target = options.openLinksInNewTab ? ' target="_blank"' : ''
return `<a href="${url}"${target}>${text}</a>`
})
// 转换图片
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">')
// 转换无序列表
html = html.replace(/^[\s]*[-*+]\s+(.*)$/gm, '<li>$1</li>')
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
// 转换有序列表
html = html.replace(/^\d+\.\s+(.*)$/gm, '<li>$1</li>')
// 转换段落
html = html.split('\n\n').map(paragraph => {
paragraph = paragraph.trim()
if (!paragraph) return ''
// 跳过已经是HTML标签的内容
if (paragraph.startsWith('<') && paragraph.endsWith('>')) {
return paragraph
}
return `<p>${paragraph}</p>`
}).join('\n')
// 转换换行
if (options.addLineBreaks) {
html = html.replace(/\n/g, '<br>')
}
return html
}
// 预览输入
const previewInput = () => {
if (!inputContent.value.trim()) return
try {
if (inputFormat.value === 'html') {
previewContent.value = inputContent.value
} else {
previewContent.value = markdownToHtml(inputContent.value)
}
} catch (error) {
showStatus('预览失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
}
}
// 预览输出
const previewOutput = () => {
if (!outputContent.value.trim()) return
try {
if (outputFormat.value === 'html') {
previewContent.value = outputContent.value
} else {
previewContent.value = markdownToHtml(outputContent.value)
}
} catch (error) {
showStatus('预览失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
}
}
// 关闭预览
const closePreview = () => {
previewContent.value = ''
}
// 验证输出
const validateOutput = () => {
if (!outputContent.value) return
try {
if (outputFormat.value === 'html') {
const parser = new DOMParser()
const doc = parser.parseFromString(outputContent.value, 'text/html')
const errors = doc.getElementsByTagName('parsererror')
if (errors.length > 0) {
throw new Error('HTML格式错误')
}
}
showStatus('输出格式正确', 'success')
} catch (error) {
showStatus('输出格式错误: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
}
}
// 交换转换方向
const swapDirection = () => {
inputFormat.value = inputFormat.value === 'html' ? 'markdown' : 'html'
if (outputContent.value) {
const temp = inputContent.value
inputContent.value = outputContent.value
outputContent.value = temp
validateInput()
}
}
// 复制结果
const copyResult = async () => {
if (!outputContent.value) return
try {
await navigator.clipboard.writeText(outputContent.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
showStatus('复制成功', 'success')
} catch (error) {
showStatus('复制失败', 'error')
}
}
// 下载结果
const downloadResult = () => {
if (!outputContent.value) return
const extension = outputFormat.value === 'html' ? '.html' : '.md'
const filename = `converted_${Date.now()}${extension}`
const blob = new Blob([outputContent.value], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
showStatus('文件下载完成', 'success')
}
// 加载示例
const loadSample = () => {
if (inputFormat.value === 'html') {
inputContent.value = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>示例HTML文档</title>
</head>
<body>
<h1>欢迎使用HTML/Markdown转换器</h1>
<p>这是一个<strong>示例HTML文档</strong>包含了常见的HTML元素。</p>
<h2>功能特性</h2>
<ul>
<li>支持HTML转Markdown</li>
<li>支持Markdown转HTML</li>
<li>保留<em>格式和样式</em></li>
<li>提供<code>实时预览</code>功能</li>
</ul>
<h3>代码示例</h3>
<pre><code>function hello() {
console.log("Hello, World!");
}</code></pre>
<p>访问我们的<a href="https://example.com">官方网站</a>了解更多信息。</p>
<blockquote>
<p>这是一个引用块的示例。</p>
</blockquote>
</body>
</html>`
} else {
inputContent.value = `# 欢迎使用HTML/Markdown转换器
这是一个**示例Markdown文档**包含了常见的Markdown语法。
## 功能特性
- 支持HTML转Markdown
- 支持Markdown转HTML
- 保留*格式和样式*
- 提供\`实时预览\`功能
### 代码示例
\`\`\`javascript
function hello() {
console.log("Hello, World!");
}
\`\`\`
访问我们的[官方网站](https://example.com)了解更多信息。
> 这是一个引用块的示例。
![示例图片](https://via.placeholder.com/300x200)`
}
validateInput()
}
// 清除所有
const clearAll = () => {
inputContent.value = ''
outputContent.value = ''
previewContent.value = ''
validationMessage.value = ''
conversionStats.value = null
statusMessage.value = ''
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 监听选项变化,自动重新转换
watch(() => options, () => {
if (inputContent.value.trim() && outputContent.value) {
convertContent()
}
}, { deep: true })
// 监听输入变化
watch(() => inputContent.value, () => {
validateInput()
})
</script>

View File

@ -0,0 +1,432 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2">
<button
@click="sendRequest"
:disabled="!requestUrl.trim() || isLoading"
class="btn-primary flex items-center space-x-2"
>
<FontAwesomeIcon
:icon="isLoading ? ['fas', 'spinner'] : ['fas', 'paper-plane']"
:class="isLoading && 'animate-spin'"
/>
<span>发送请求</span>
</button>
<button
@click="clearAll"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'trash']" />
<span>清空</span>
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<!-- 左侧请求配置 -->
<div class="space-y-6">
<!-- 请求URL和方法 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-4">请求配置</h3>
<div class="space-y-3">
<div class="flex space-x-2">
<select v-model="requestMethod" class="select-input w-32">
<option v-for="method in httpMethods" :key="method" :value="method">
{{ method }}
</option>
</select>
<input
v-model="requestUrl"
type="url"
placeholder="https://api.example.com/users"
class="input-field flex-1"
@keyup.enter="sendRequest"
>
</div>
<!-- 快速URL -->
<div class="flex flex-wrap gap-2">
<button
v-for="quickUrl in quickUrls"
:key="quickUrl.name"
@click="setQuickUrl(quickUrl.url)"
class="px-3 py-1 text-xs rounded bg-block hover:bg-block-hover text-secondary hover:text-primary transition-colors"
>
{{ quickUrl.name }}
</button>
</div>
</div>
</div>
<!-- 请求头配置 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h4 class="font-medium text-secondary">请求头</h4>
<button @click="addHeader" class="btn-small">
<FontAwesomeIcon :icon="['fas', 'plus']" class="mr-1" />
添加头部
</button>
</div>
<div class="space-y-2">
<div
v-for="(header, index) in requestHeaders"
:key="index"
class="flex space-x-2"
>
<input
v-model="header.key"
type="text"
placeholder="Header Name"
class="input-field flex-1"
>
<input
v-model="header.value"
type="text"
placeholder="Header Value"
class="input-field flex-1"
>
<button
@click="removeHeader(index)"
class="p-2 text-error hover:bg-error/10 rounded transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'times']" />
</button>
</div>
</div>
</div>
<!-- 请求体 -->
<div v-if="['POST', 'PUT', 'PATCH'].includes(requestMethod)" class="card p-4">
<div class="flex items-center justify-between mb-3">
<h4 class="font-medium text-secondary">请求体</h4>
<select v-model="requestBodyType" class="select-input w-32">
<option value="json">JSON</option>
<option value="text">Text</option>
<option value="form">Form</option>
</select>
</div>
<textarea
v-model="requestBody"
:placeholder="getBodyPlaceholder()"
class="textarea-field h-40 font-mono text-sm"
/>
<div v-if="requestBodyType === 'json'" class="flex justify-between items-center mt-2">
<button @click="formatJsonBody" class="btn-small">
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-1" />
格式化
</button>
<div class="text-xs text-secondary">
{{ requestBody.length }} 字符
</div>
</div>
</div>
</div>
<!-- 右侧响应结果 -->
<div class="card p-4 min-h-[600px]">
<h3 class="text-lg font-semibold text-primary mb-4">响应结果</h3>
<div v-if="isLoading" class="flex flex-col items-center justify-center h-96">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<p class="text-secondary">正在发送请求...</p>
</div>
<div v-else-if="lastResponse" class="space-y-4">
<!-- 响应状态栏 -->
<div class="flex items-center justify-between p-3 rounded-lg bg-secondary/10">
<div class="flex items-center space-x-3">
<span :class="[
'px-3 py-1 text-sm font-medium rounded',
getStatusColor(lastResponse.status)
]">
{{ lastResponse.status }} {{ lastResponse.statusText }}
</span>
<span class="text-sm text-secondary">
{{ formatResponseSize(lastResponse.size) }} | {{ lastResponse.time }}ms
</span>
</div>
<button @click="copyResponseContent" class="btn-small">
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<!-- 响应内容 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-secondary">响应内容</span>
<button
v-if="isJsonResponse"
@click="toggleJsonFormat"
class="btn-small"
>
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-1" />
{{ jsonFormatted ? '原始' : '格式化' }}
</button>
</div>
<div class="relative">
<pre class="bg-secondary/10 p-4 rounded-lg text-sm font-mono overflow-auto max-h-96 whitespace-pre-wrap">{{ formattedResponseData }}</pre>
</div>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-96 text-tertiary">
<FontAwesomeIcon :icon="['fas', 'paper-plane']" class="text-6xl mb-4 opacity-50" />
<p class="text-lg">暂无响应</p>
<p class="text-sm">点击发送请求按钮开始测试</p>
</div>
</div>
</div>
<!-- 错误提示 -->
<div v-if="errorMessage" class="card p-4 bg-error/10 border-error/20">
<div class="flex items-center space-x-2 text-error">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" />
<span>{{ errorMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// 响应式状态
const requestUrl = ref('https://jsonplaceholder.typicode.com/posts/1')
const requestMethod = ref('GET')
const requestHeaders = ref([
{ key: 'Content-Type', value: 'application/json' }
])
const requestBody = ref('')
const requestBodyType = ref('json')
const isLoading = ref(false)
const lastResponse = ref(null)
const errorMessage = ref('')
const copied = ref(false)
const jsonFormatted = ref(true)
// HTTP方法列表
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
// 快速URL列表
const quickUrls = [
{ name: 'JSONPlaceholder', url: 'https://jsonplaceholder.typicode.com/posts/1' },
{ name: 'GitHub API', url: 'https://api.github.com/users/octocat' },
{ name: 'HTTPBin', url: 'https://httpbin.org/get' }
]
// 计算属性
const isJsonResponse = computed(() => {
if (!lastResponse.value) return false
const contentType = lastResponse.value.headers['content-type'] || ''
return contentType.includes('application/json') ||
(typeof lastResponse.value.data === 'object' && lastResponse.value.data !== null)
})
const formattedResponseData = computed(() => {
if (!lastResponse.value) return ''
if (isJsonResponse.value && jsonFormatted.value) {
try {
return JSON.stringify(lastResponse.value.data, null, 2)
} catch {
return String(lastResponse.value.data)
}
}
return typeof lastResponse.value.data === 'string'
? lastResponse.value.data
: JSON.stringify(lastResponse.value.data)
})
// 请求头管理
const addHeader = () => {
requestHeaders.value.push({ key: '', value: '' })
}
const removeHeader = (index: number) => {
requestHeaders.value.splice(index, 1)
}
// 工具函数
const getBodyPlaceholder = () => {
switch (requestBodyType.value) {
case 'json':
return '{\n "key": "value"\n}'
default:
return '请输入请求体内容...'
}
}
const formatJsonBody = () => {
try {
const parsed = JSON.parse(requestBody.value)
requestBody.value = JSON.stringify(parsed, null, 2)
} catch (error) {
console.error('JSON格式化失败:', error)
}
}
const setQuickUrl = (url: string) => {
requestUrl.value = url
}
const getStatusColor = (status: number) => {
if (status >= 200 && status < 300) return 'bg-success/20 text-success'
if (status >= 300 && status < 400) return 'bg-warning/20 text-warning'
if (status >= 400 && status < 500) return 'bg-error/20 text-error'
if (status >= 500) return 'bg-error/30 text-error'
return 'bg-secondary/20 text-secondary'
}
const formatResponseSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
// 发送请求
const sendRequest = async () => {
if (!requestUrl.value.trim()) return
isLoading.value = true
errorMessage.value = ''
lastResponse.value = null
try {
const startTime = performance.now()
// 准备请求头
const headers: Record<string, string> = {}
requestHeaders.value.forEach(h => {
if (h.key.trim() && h.value.trim()) {
headers[h.key] = h.value
}
})
// 准备请求体
let body: string | undefined
if (['POST', 'PUT', 'PATCH'].includes(requestMethod.value)) {
body = requestBody.value
}
// 发送请求
const response = await fetch(requestUrl.value, {
method: requestMethod.value,
headers,
body
})
const endTime = performance.now()
const responseTime = Math.round(endTime - startTime)
// 获取响应头
const responseHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => {
responseHeaders[key] = value
})
// 解析响应体
let responseData: any
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
responseData = await response.json()
} else {
responseData = await response.text()
}
// 计算响应大小
const responseText = typeof responseData === 'string' ? responseData : JSON.stringify(responseData)
const responseSize = new Blob([responseText]).size
lastResponse.value = {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
data: responseData,
time: responseTime,
size: responseSize
}
} catch (error) {
errorMessage.value = `请求失败: ${(error as Error).message}`
} finally {
isLoading.value = false
}
}
// 其他功能
const clearAll = () => {
requestUrl.value = 'https://jsonplaceholder.typicode.com/posts/1'
requestMethod.value = 'GET'
requestHeaders.value = [{ key: 'Content-Type', value: 'application/json' }]
requestBody.value = ''
lastResponse.value = null
errorMessage.value = ''
}
const copyResponseContent = async () => {
if (!lastResponse.value) return
try {
const textToCopy = formattedResponseData.value
await navigator.clipboard.writeText(textToCopy)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
const toggleJsonFormat = () => {
jsonFormatted.value = !jsonFormatted.value
}
</script>
<style scoped>
.select-input {
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid rgba(var(--color-primary), 0.2);
background-color: rgb(var(--color-bg-card));
color: rgb(var(--color-text-primary));
outline: none;
transition: all 0.2s ease;
}
.select-input:focus {
border-color: rgb(var(--color-primary));
box-shadow: 0 0 0 2px rgba(var(--color-primary), 0.2);
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.375rem;
background-color: rgb(var(--color-bg-secondary));
color: rgb(var(--color-primary-light));
border: 1px solid rgb(var(--color-primary));
transition: all 0.2s ease;
cursor: pointer;
}
.btn-small:hover {
background-color: rgba(var(--color-primary), 0.1);
border-color: rgb(var(--color-primary-hover));
}
</style>

View File

@ -0,0 +1,502 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="compressImage"
:disabled="!originalImage || isCompressing"
class="btn-primary"
>
<FontAwesomeIcon
:icon="isCompressing ? ['fas', 'spinner'] : ['fas', 'compress']"
:class="['mr-2', isCompressing && 'animate-spin']"
/>
{{ t('tools.image_compressor.compress') }}
</button>
<button
@click="downloadCompressedImage"
:disabled="!compressedImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
{{ t('tools.image_compressor.download') }}
</button>
<button
@click="resetAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.image_compressor.reset') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 上传和设置区域 -->
<div class="space-y-4">
<!-- 图片上传 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.image_compressor.upload_image') }}</h3>
<div
@click="triggerFileUpload"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleFileDrop"
:class="[
'border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors text-center',
isDragging ? 'border-primary bg-primary bg-opacity-5' : 'border-tertiary hover:border-primary-light'
]"
>
<FontAwesomeIcon :icon="['fas', 'cloud-upload-alt']" class="text-4xl text-tertiary mb-4" />
<div class="text-secondary">
<p>{{ t('tools.image_compressor.click_or_drag') }}</p>
<p class="text-sm text-tertiary mt-1">支持 JPG, PNG, WebP 格式最大 10MB</p>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
@change="handleFileSelect"
>
</div>
<!-- 压缩设置 -->
<div v-if="originalImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.image_compressor.compression_settings') }}</h3>
<div class="space-y-4">
<!-- 压缩质量 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.image_compressor.quality') }}: {{ quality }}%
</label>
<input
v-model="quality"
type="range"
min="10"
max="100"
step="5"
class="w-full"
@input="handleQualityChange"
>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>10% (最小)</span>
<span>100% (最佳)</span>
</div>
</div>
<!-- 最大宽度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.image_compressor.max_width') }} (px)
</label>
<input
v-model="maxWidth"
type="number"
min="100"
max="5000"
class="input-field"
:placeholder="originalImageInfo?.width?.toString() || '原始宽度'"
>
</div>
<!-- 最大高度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.image_compressor.max_height') }} (px)
</label>
<input
v-model="maxHeight"
type="number"
min="100"
max="5000"
class="input-field"
:placeholder="originalImageInfo?.height?.toString() || '原始高度'"
>
</div>
<!-- 保持宽高比 -->
<div class="flex items-center space-x-2">
<input
v-model="keepAspectRatio"
type="checkbox"
id="keepAspectRatio"
class="rounded"
>
<label for="keepAspectRatio" class="text-sm text-secondary">
{{ t('tools.image_compressor.keep_aspect_ratio') }}
</label>
</div>
<!-- 输出格式 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.image_compressor.output_format') }}
</label>
<select v-model="outputFormat" class="select-field">
<option value="auto">自动 (保持原格式)</option>
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
<option value="image/webp">WebP</option>
</select>
</div>
</div>
</div>
<!-- 原始图片信息 -->
<div v-if="originalImageInfo" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.image_compressor.original_info') }}</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.size') }}:</span>
<span class="text-primary font-medium">{{ originalImageInfo.size }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.dimensions') }}:</span>
<span class="text-primary font-medium">{{ originalImageInfo.width }} × {{ originalImageInfo.height }}px</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.format') }}:</span>
<span class="text-primary font-medium">{{ originalImageInfo.format }}</span>
</div>
</div>
</div>
</div>
<!-- 预览对比区域 -->
<div class="space-y-4">
<!-- 原始图片预览 -->
<div v-if="originalImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.image_compressor.original_preview') }}</h3>
<div class="bg-block rounded-lg p-4">
<img
:src="originalImage"
alt="原始图片"
class="max-w-full max-h-64 mx-auto rounded border border-primary border-opacity-20"
>
</div>
</div>
<!-- 压缩后预览 -->
<div v-if="compressedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.image_compressor.compressed_preview') }}</h3>
<div class="bg-block rounded-lg p-4">
<img
:src="compressedImage"
alt="压缩后图片"
class="max-w-full max-h-64 mx-auto rounded border border-primary border-opacity-20"
>
</div>
<!-- 压缩结果信息 -->
<div v-if="compressedImageInfo" class="mt-4 space-y-2">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.compressed_size') }}:</span>
<span class="text-primary font-medium">{{ compressedImageInfo.size }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.compression_ratio') }}:</span>
<span class="text-primary font-medium">{{ compressionRatio }}%</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.size_reduction') }}:</span>
<span class="text-success font-medium">{{ sizeReduction }}</span>
</div>
</div>
</div>
<!-- 压缩中状态 -->
<div v-if="isCompressing" class="card p-4">
<div class="text-center py-8">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<div class="text-secondary">{{ t('tools.image_compressor.compressing') }}</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
import Compressor from 'compressorjs'
const { t } = useLanguage()
// 响应式状态
const originalImage = ref('')
const compressedImage = ref('')
const isDragging = ref(false)
const isCompressing = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 压缩设置
const quality = ref(80)
const maxWidth = ref<number | null>(null)
const maxHeight = ref<number | null>(null)
const keepAspectRatio = ref(true)
const outputFormat = ref('auto')
// 文件信息
const originalImageInfo = ref<{
size: string
width: number
height: number
format: string
} | null>(null)
const compressedImageInfo = ref<{
size: string
sizeBytes: number
} | null>(null)
// 文件引用
const fileInput = ref<HTMLInputElement>()
const originalFile = ref<File | null>(null)
// 计算压缩比率
const compressionRatio = computed(() => {
if (!originalImageInfo.value || !compressedImageInfo.value) return '0'
const originalBytes = originalFile.value?.size || 0
const compressedBytes = compressedImageInfo.value.sizeBytes
const ratio = ((compressedBytes / originalBytes) * 100).toFixed(1)
return ratio
})
// 计算减少的大小
const sizeReduction = computed(() => {
if (!originalImageInfo.value || !compressedImageInfo.value) return '0'
const originalBytes = originalFile.value?.size || 0
const compressedBytes = compressedImageInfo.value.sizeBytes
const reduction = originalBytes - compressedBytes
return formatFileSize(reduction)
})
// 处理文件选择
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
loadImageFile(file)
}
}
// 处理文件拖拽
const handleFileDrop = (event: DragEvent) => {
isDragging.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) {
const file = files[0]
if (file.type.startsWith('image/')) {
loadImageFile(file)
} else {
showStatus('请选择图片文件', 'error')
}
}
}
const handleDragOver = () => {
isDragging.value = true
}
const handleDragLeave = () => {
isDragging.value = false
}
// 加载图片文件
const loadImageFile = (file: File) => {
if (!file.type.startsWith('image/')) {
showStatus('请选择图片文件', 'error')
return
}
if (file.size > 10 * 1024 * 1024) { // 10MB
showStatus('文件大小不能超过 10MB', 'error')
return
}
originalFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
const result = e.target?.result as string
originalImage.value = result
// 获取图片信息
const img = new Image()
img.onload = () => {
originalImageInfo.value = {
size: formatFileSize(file.size),
width: img.width,
height: img.height,
format: file.type.split('/')[1].toUpperCase()
}
// 设置默认的最大尺寸
if (!maxWidth.value) maxWidth.value = img.width
if (!maxHeight.value) maxHeight.value = img.height
}
img.src = result
// 清除之前的压缩结果
compressedImage.value = ''
compressedImageInfo.value = null
}
reader.onerror = () => {
showStatus('文件读取失败', 'error')
}
reader.readAsDataURL(file)
}
// 触发文件上传
const triggerFileUpload = () => {
fileInput.value?.click()
}
// 压缩图片
const compressImage = async () => {
if (!originalFile.value) {
showStatus('请先选择图片', 'error')
return
}
isCompressing.value = true
statusMessage.value = ''
try {
await nextTick()
const options: Compressor.Options = {
quality: quality.value / 100,
maxWidth: maxWidth.value || undefined,
maxHeight: maxHeight.value || undefined,
convertTypes: outputFormat.value === 'auto' ? undefined : [outputFormat.value],
convertSize: outputFormat.value === 'auto' ? 5000000 : undefined, // 5MB 以上才转换格式
success: (compressedFile: File) => {
// 创建预览URL
const reader = new FileReader()
reader.onload = (e) => {
compressedImage.value = e.target?.result as string
compressedImageInfo.value = {
size: formatFileSize(compressedFile.size),
sizeBytes: compressedFile.size
}
showStatus('图片压缩成功', 'success')
isCompressing.value = false
}
reader.readAsDataURL(compressedFile)
// 保存压缩后的文件用于下载
compressedFile.name = `compressed_${originalFile.value?.name || 'image'}`
;(window as any).compressedFileForDownload = compressedFile
},
error: (err: Error) => {
console.error('压缩失败:', err)
showStatus('压缩失败: ' + err.message, 'error')
isCompressing.value = false
}
}
new Compressor(originalFile.value, options)
} catch (error) {
console.error('压缩过程出错:', error)
showStatus('压缩过程出错: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
isCompressing.value = false
}
}
// 下载压缩后的图片
const downloadCompressedImage = () => {
const compressedFile = (window as any).compressedFileForDownload as File
if (!compressedFile) {
showStatus('没有可下载的压缩图片', 'error')
return
}
try {
const url = URL.createObjectURL(compressedFile)
const link = document.createElement('a')
link.download = compressedFile.name
link.href = url
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showStatus('图片下载成功', 'success')
} catch (error) {
console.error('下载失败:', error)
showStatus('下载失败', 'error')
}
}
// 重置所有数据
const resetAll = () => {
originalImage.value = ''
compressedImage.value = ''
originalImageInfo.value = null
compressedImageInfo.value = null
originalFile.value = null
maxWidth.value = null
maxHeight.value = null
quality.value = 80
outputFormat.value = 'auto'
statusMessage.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
// 清除下载缓存
delete (window as any).compressedFileForDownload
}
// 处理质量变化
const handleQualityChange = () => {
// 可以实时预览质量变化
// 这里可以添加防抖逻辑来避免频繁压缩
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>

View File

@ -0,0 +1,838 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="convertToIco"
:disabled="!selectedImage"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'magic']" class="mr-2" />
转换为ICO
</button>
<button
@click="downloadIco"
:disabled="!icoData"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
下载ICO
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清除
</button>
<button
@click="loadSample"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'image']" class="mr-2" />
示例图片
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 图片上传区域 -->
<div class="space-y-4">
<!-- 上传区域 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">选择图片</h3>
<!-- 拖拽上传区域 -->
<div
@drop="handleDrop"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@click="selectFile"
:class="[
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
isDragOver
? 'border-primary bg-primary bg-opacity-10'
: 'border-border hover:border-primary hover:bg-block-hover'
]"
>
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
class="hidden"
>
<div class="space-y-3">
<FontAwesomeIcon
:icon="['fas', 'cloud-upload-alt']"
class="text-4xl text-secondary"
/>
<div>
<div class="text-primary font-medium">
点击选择或拖拽图片到此处
</div>
<div class="text-sm text-secondary mt-1">
支持 JPGPNGGIFBMPWebP 格式
</div>
</div>
</div>
</div>
<!-- 支持的格式说明 -->
<div class="mt-4 text-sm text-secondary">
<div class="font-medium mb-2">支持的输入格式:</div>
<div class="grid grid-cols-2 gap-2">
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>JPEG (.jpg, .jpeg)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>PNG (.png)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>GIF (.gif)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>BMP (.bmp)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>WebP (.webp)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>SVG (.svg)</span>
</div>
</div>
</div>
</div>
<!-- 图片信息 -->
<div v-if="selectedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">图片信息</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">文件名:</span>
<span class="text-primary font-medium">{{ imageInfo.name }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">格式:</span>
<span class="text-primary font-medium">{{ imageInfo.type }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">大小:</span>
<span class="text-primary font-medium">{{ formatFileSize(imageInfo.size) }}</span>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">宽度:</span>
<span class="text-primary font-medium">{{ imageInfo.width }}px</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">高度:</span>
<span class="text-primary font-medium">{{ imageInfo.height }}px</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">宽高比:</span>
<span class="text-primary font-medium">{{ imageInfo.aspectRatio }}</span>
</div>
</div>
</div>
</div>
<!-- 转换设置 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">转换设置</h3>
<div class="space-y-4">
<!-- ICO尺寸设置 -->
<div>
<label class="block text-sm text-secondary mb-2">ICO尺寸 (像素)</label>
<div class="grid grid-cols-4 gap-2">
<button
v-for="size in icoSizes"
:key="size"
@click="selectIcoSize(size)"
:class="[
'p-2 text-sm rounded border transition-colors',
selectedSizes.includes(size)
? 'border-primary bg-primary text-white'
: 'border-border hover:border-primary text-secondary'
]"
>
{{ size }}×{{ size }}
</button>
</div>
<div class="text-xs text-tertiary mt-1">
可选择多个尺寸生成多尺寸ICO文件
</div>
</div>
<!-- 自定义尺寸 -->
<div>
<label class="block text-sm text-secondary mb-2">自定义尺寸</label>
<div class="flex items-center space-x-2">
<input
v-model.number="customSize"
type="number"
min="16"
max="256"
class="input-field w-20 text-sm"
placeholder="32"
>
<span class="text-secondary text-sm">像素</span>
<button
@click="addCustomSize"
:disabled="!isValidCustomSize"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'plus']" />
</button>
</div>
</div>
<!-- 图片质量设置 -->
<div>
<label class="block text-sm text-secondary mb-2">
图片质量: {{ quality }}%
</label>
<input
v-model.number="quality"
type="range"
min="10"
max="100"
step="5"
class="w-full"
>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>较小文件</span>
<span>较高质量</span>
</div>
</div>
<!-- 背景颜色设置 -->
<div>
<label class="block text-sm text-secondary mb-2">背景颜色 (透明图片)</label>
<div class="flex items-center space-x-2">
<input
v-model="backgroundColor"
type="color"
class="w-12 h-8 border border-border rounded cursor-pointer"
>
<span class="text-sm text-secondary">{{ backgroundColor }}</span>
<label class="flex items-center space-x-2">
<input
v-model="preserveTransparency"
type="checkbox"
class="form-checkbox"
>
<span class="text-sm text-secondary">保持透明度</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 预览和结果区域 -->
<div class="space-y-4">
<!-- 原图预览 -->
<div v-if="selectedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">原图预览</h3>
<div class="flex justify-center">
<div class="max-w-full max-h-64 overflow-hidden border border-border rounded-lg">
<img
:src="imagePreview"
:alt="imageInfo.name"
class="max-w-full max-h-64 object-contain"
>
</div>
</div>
</div>
<!-- ICO预览 -->
<div v-if="icoPreview" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">ICO预览</h3>
<div class="space-y-4">
<!-- 多尺寸预览 -->
<div class="grid grid-cols-4 gap-4">
<div
v-for="size in selectedSizes"
:key="size"
class="text-center"
>
<div class="border border-border rounded p-2 bg-checkerboard">
<img
:src="icoPreview"
:alt="`ICO ${size}x${size}`"
:style="{ width: size + 'px', height: size + 'px' }"
class="mx-auto object-contain"
>
</div>
<div class="text-xs text-secondary mt-1">{{ size }}×{{ size }}</div>
</div>
</div>
<!-- ICO文件信息 -->
<div v-if="icoInfo" class="bg-block rounded-lg p-3 text-sm">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-secondary">文件大小:</span>
<span class="text-primary font-medium">{{ formatFileSize(icoInfo.size) }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">包含尺寸:</span>
<span class="text-primary font-medium">{{ icoInfo.iconCount }}</span>
</div>
</div>
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-secondary">格式:</span>
<span class="text-primary font-medium">ICO</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">颜色深度:</span>
<span class="text-primary font-medium">32</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 转换历史 -->
<div v-if="conversionHistory.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">转换历史</h3>
<div class="space-y-2 max-h-40 overflow-y-auto">
<div
v-for="(record, index) in conversionHistory"
:key="index"
class="flex items-center justify-between p-2 bg-block rounded text-sm"
>
<div class="flex-1">
<div class="font-medium text-primary">{{ record.filename }}</div>
<div class="text-xs text-tertiary">
{{ record.sizes.join(', ') }} | {{ record.time }}
</div>
</div>
<button
@click="downloadHistoryFile(record)"
class="text-secondary hover:text-primary transition-colors"
title="重新下载"
>
<FontAwesomeIcon :icon="['fas', 'download']" />
</button>
</div>
</div>
</div>
<!-- 使用说明 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">使用说明</h3>
<div class="space-y-3 text-sm text-secondary">
<div>
<div class="font-medium">ICO格式特点:</div>
<ul class="list-disc list-inside text-tertiary ml-2 mt-1">
<li>Windows图标标准格式</li>
<li>支持多尺寸存储在一个文件中</li>
<li>常用尺寸: 16×16, 32×32, 48×48, 256×256</li>
<li>支持透明背景</li>
</ul>
</div>
<div>
<div class="font-medium">转换建议:</div>
<ul class="list-disc list-inside text-tertiary ml-2 mt-1">
<li>使用正方形图片效果最佳</li>
<li>PNG格式可保持透明度</li>
<li>选择多个尺寸以适应不同显示场景</li>
<li>16×16和32×32是Windows系统最常用尺寸</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const selectedImage = ref<File | null>(null)
const imagePreview = ref('')
const icoData = ref<Blob | null>(null)
const icoPreview = ref('')
const isDragOver = ref(false)
const quality = ref(90)
const backgroundColor = ref('#ffffff')
const preserveTransparency = ref(true)
const customSize = ref<number | null>(null)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// DOM引用
const fileInput = ref<HTMLInputElement>()
// 图片信息
const imageInfo = reactive({
name: '',
type: '',
size: 0,
width: 0,
height: 0,
aspectRatio: ''
})
// ICO信息
const icoInfo = reactive({
size: 0,
iconCount: 0
})
// ICO尺寸选项
const icoSizes = [16, 24, 32, 48, 64, 96, 128, 256]
const selectedSizes = ref<number[]>([16, 32, 48])
// 转换历史
const conversionHistory = ref<Array<{
filename: string
sizes: string[]
time: string
data: Blob
}>>([])
// 计算属性
const isValidCustomSize = computed(() => {
return customSize.value &&
customSize.value >= 16 &&
customSize.value <= 256 &&
!selectedSizes.value.includes(customSize.value)
})
// 文件选择
const selectFile = () => {
fileInput.value?.click()
}
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
handleImageFile(file)
}
}
// 拖拽处理
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = true
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) {
handleImageFile(files[0])
}
}
// 处理图片文件
const handleImageFile = async (file: File) => {
// 验证文件类型
if (!file.type.startsWith('image/')) {
showStatus('请选择有效的图片文件', 'error')
return
}
selectedImage.value = file
// 更新图片信息
imageInfo.name = file.name
imageInfo.type = file.type
imageInfo.size = file.size
// 创建预览
const reader = new FileReader()
reader.onload = (e) => {
imagePreview.value = e.target?.result as string
}
reader.readAsDataURL(file)
// 获取图片尺寸
const img = new Image()
img.onload = () => {
imageInfo.width = img.width
imageInfo.height = img.height
imageInfo.aspectRatio = `${(img.width / img.height).toFixed(2)}:1`
// 如果图片不是正方形,给出提示
if (img.width !== img.height) {
showStatus('建议使用正方形图片以获得最佳ICO效果', 'error')
}
}
img.src = URL.createObjectURL(file)
// 清除之前的ICO数据
icoData.value = null
icoPreview.value = ''
}
// ICO尺寸选择
const selectIcoSize = (size: number) => {
const index = selectedSizes.value.indexOf(size)
if (index >= 0) {
selectedSizes.value.splice(index, 1)
} else {
selectedSizes.value.push(size)
}
selectedSizes.value.sort((a, b) => a - b)
}
// 添加自定义尺寸
const addCustomSize = () => {
if (customSize.value && isValidCustomSize.value) {
selectedSizes.value.push(customSize.value)
selectedSizes.value.sort((a, b) => a - b)
customSize.value = null
}
}
// 转换为ICO
const convertToIco = async () => {
if (!selectedImage.value) {
showStatus('请先选择图片', 'error')
return
}
if (selectedSizes.value.length === 0) {
showStatus('请至少选择一个ICO尺寸', 'error')
return
}
try {
showStatus('正在转换中...', 'success')
// 创建canvas来处理图片
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法创建canvas上下文')
}
// 加载原图
const img = new Image()
img.src = imagePreview.value
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
// 生成多尺寸图标数据
const iconData: Array<{ size: number; data: Uint8Array }> = []
for (const size of selectedSizes.value) {
canvas.width = size
canvas.height = size
// 设置背景颜色(如果不保持透明度)
if (!preserveTransparency.value) {
ctx.fillStyle = backgroundColor.value
ctx.fillRect(0, 0, size, size)
}
// 绘制图片
ctx.drawImage(img, 0, 0, size, size)
// 获取图片数据
const imageData = ctx.getImageData(0, 0, size, size)
iconData.push({
size,
data: new Uint8Array(imageData.data)
})
}
// 生成ICO文件数据简化实现
const icoBlob = await createIcoBlob(iconData)
icoData.value = icoBlob
// 创建预览
icoPreview.value = URL.createObjectURL(icoBlob)
// 更新ICO信息
icoInfo.size = icoBlob.size
icoInfo.iconCount = selectedSizes.value.length
// 添加到历史记录
conversionHistory.value.unshift({
filename: selectedImage.value.name.replace(/\.[^/.]+$/, '.ico'),
sizes: selectedSizes.value.map(s => `${s}×${s}`),
time: new Date().toLocaleTimeString(),
data: icoBlob
})
// 保持历史记录不超过10条
if (conversionHistory.value.length > 10) {
conversionHistory.value = conversionHistory.value.slice(0, 10)
}
showStatus('转换成功!', 'success')
} catch (error) {
showStatus('转换失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
icoData.value = null
icoPreview.value = ''
}
}
// 创建ICO文件数据 (简化实现)
const createIcoBlob = async (iconData: Array<{ size: number; data: Uint8Array }>): Promise<Blob> => {
// 这是一个简化的ICO文件格式实现
// 实际应用中建议使用专门的ICO库
const iconCount = iconData.length
const headerSize = 6 + iconCount * 16 // ICO文件头 + 图标目录项
// 计算每个图标的PNG数据
const pngData: Uint8Array[] = []
for (const icon of iconData) {
// 将RGBA数据转换为PNG (这里简化处理实际需要PNG编码)
const canvas = document.createElement('canvas')
canvas.width = icon.size
canvas.height = icon.size
const ctx = canvas.getContext('2d')!
const imageData = new ImageData(
new Uint8ClampedArray(icon.data),
icon.size,
icon.size
)
ctx.putImageData(imageData, 0, 0)
// 获取PNG数据
const dataUrl = canvas.toDataURL('image/png', quality.value / 100)
const base64 = dataUrl.split(',')[1]
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
pngData.push(bytes)
}
// 计算总文件大小
let totalSize = headerSize
for (const png of pngData) {
totalSize += png.length
}
// 构建ICO文件
const icoFile = new Uint8Array(totalSize)
let offset = 0
// ICO文件头
icoFile[0] = 0 // 保留字段
icoFile[1] = 0
icoFile[2] = 1 // 类型: ICO
icoFile[3] = 0
icoFile[4] = iconCount & 0xFF // 图标数量
icoFile[5] = (iconCount >> 8) & 0xFF
offset = 6
// 图标目录项
let dataOffset = headerSize
for (let i = 0; i < iconCount; i++) {
const size = iconData[i].size
const pngSize = pngData[i].length
icoFile[offset] = size === 256 ? 0 : size // 宽度
icoFile[offset + 1] = size === 256 ? 0 : size // 高度
icoFile[offset + 2] = 0 // 颜色数
icoFile[offset + 3] = 0 // 保留
icoFile[offset + 4] = 1 // 颜色平面数
icoFile[offset + 5] = 0
icoFile[offset + 6] = 32 // 位深度
icoFile[offset + 7] = 0
// PNG数据大小
icoFile[offset + 8] = pngSize & 0xFF
icoFile[offset + 9] = (pngSize >> 8) & 0xFF
icoFile[offset + 10] = (pngSize >> 16) & 0xFF
icoFile[offset + 11] = (pngSize >> 24) & 0xFF
// PNG数据偏移
icoFile[offset + 12] = dataOffset & 0xFF
icoFile[offset + 13] = (dataOffset >> 8) & 0xFF
icoFile[offset + 14] = (dataOffset >> 16) & 0xFF
icoFile[offset + 15] = (dataOffset >> 24) & 0xFF
offset += 16
dataOffset += pngSize
}
// 写入PNG数据
for (const png of pngData) {
icoFile.set(png, offset)
offset += png.length
}
return new Blob([icoFile], { type: 'image/x-icon' })
}
// 下载ICO
const downloadIco = () => {
if (!icoData.value || !selectedImage.value) return
const filename = selectedImage.value.name.replace(/\.[^/.]+$/, '.ico')
const url = URL.createObjectURL(icoData.value)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
showStatus('ICO文件下载完成', 'success')
}
// 从历史记录下载
const downloadHistoryFile = (record: any) => {
const url = URL.createObjectURL(record.data)
const link = document.createElement('a')
link.href = url
link.download = record.filename
link.click()
URL.revokeObjectURL(url)
}
// 加载示例图片
const loadSample = () => {
// 创建一个简单的示例图片
const canvas = document.createElement('canvas')
canvas.width = 128
canvas.height = 128
const ctx = canvas.getContext('2d')!
// 绘制渐变背景
const gradient = ctx.createLinearGradient(0, 0, 128, 128)
gradient.addColorStop(0, '#3b82f6')
gradient.addColorStop(1, '#1d4ed8')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 128, 128)
// 绘制图标形状
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 60px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('ICO', 64, 64)
canvas.toBlob((blob) => {
if (blob) {
const file = new File([blob], 'sample.png', { type: 'image/png' })
handleImageFile(file)
}
})
}
// 清除所有
const clearAll = () => {
selectedImage.value = null
imagePreview.value = ''
icoData.value = null
icoPreview.value = ''
selectedSizes.value = [16, 32, 48]
customSize.value = null
statusMessage.value = ''
// 重置图片信息
Object.assign(imageInfo, {
name: '',
type: '',
size: 0,
width: 0,
height: 0,
aspectRatio: ''
})
// 重置ICO信息
Object.assign(icoInfo, {
size: 0,
iconCount: 0
})
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
</script>
<style scoped>
.bg-checkerboard {
background-image:
linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
}
</style>

View File

@ -0,0 +1,773 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="triggerFileInput"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'upload']" class="mr-2" />
上传图片
</button>
<button
@click="addTextWatermark"
:disabled="!originalImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'font']" class="mr-2" />
文字水印
</button>
<button
@click="triggerWatermarkInput"
:disabled="!originalImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'image']" class="mr-2" />
图片水印
</button>
<button
@click="downloadImage"
:disabled="!processedImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
下载图片
</button>
<button
@click="resetImage"
:disabled="!originalImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'undo']" class="mr-2" />
重置
</button>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
@change="handleImageUpload"
>
<input
ref="watermarkInput"
type="file"
accept="image/*"
class="hidden"
@change="handleWatermarkUpload"
>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 设置区域 -->
<div class="space-y-4">
<!-- 文字水印设置 -->
<div v-if="watermarkType === 'text'" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">文字水印设置</h3>
<div class="space-y-4">
<!-- 水印文字 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">水印文字</label>
<input
v-model="textWatermark.text"
type="text"
placeholder="请输入水印文字"
class="input-field"
@input="updateWatermark"
>
</div>
<!-- 字体大小 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
字体大小: {{ textWatermark.fontSize }}px
</label>
<input
v-model.number="textWatermark.fontSize"
type="range"
min="12"
max="120"
step="2"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 字体颜色 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体颜色</label>
<div class="flex space-x-2">
<input
v-model="textWatermark.color"
type="color"
class="w-12 h-8 rounded border border-primary border-opacity-20"
@input="updateWatermark"
>
<input
v-model="textWatermark.color"
type="text"
class="input-field flex-1"
@input="updateWatermark"
>
</div>
</div>
<!-- 透明度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
透明度: {{ Math.round(textWatermark.opacity * 100) }}%
</label>
<input
v-model.number="textWatermark.opacity"
type="range"
min="0.1"
max="1"
step="0.1"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 字体样式 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体样式</label>
<div class="flex space-x-2">
<button
@click="toggleFontStyle('bold')"
:class="['btn-sm', textWatermark.fontWeight === 'bold' ? 'btn-primary' : 'btn-secondary']"
>
<FontAwesomeIcon :icon="['fas', 'bold']" />
</button>
<button
@click="toggleFontStyle('italic')"
:class="['btn-sm', textWatermark.fontStyle === 'italic' ? 'btn-primary' : 'btn-secondary']"
>
<FontAwesomeIcon :icon="['fas', 'italic']" />
</button>
</div>
</div>
<!-- 旋转角度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
旋转角度: {{ textWatermark.rotation }}°
</label>
<input
v-model.number="textWatermark.rotation"
type="range"
min="-45"
max="45"
step="5"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
<!-- 图片水印设置 -->
<div v-if="watermarkType === 'image'" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">图片水印设置</h3>
<div class="space-y-4">
<!-- 水印图片预览 -->
<div v-if="watermarkImage">
<label class="block text-sm font-medium text-secondary mb-2">水印图片</label>
<div class="bg-block rounded-lg p-4">
<img :src="watermarkImage" alt="水印图片" class="max-w-full h-20 object-contain mx-auto">
</div>
</div>
<!-- 缩放比例 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
缩放比例: {{ Math.round(imageWatermark.scale * 100) }}%
</label>
<input
v-model.number="imageWatermark.scale"
type="range"
min="0.1"
max="2"
step="0.1"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 透明度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
透明度: {{ Math.round(imageWatermark.opacity * 100) }}%
</label>
<input
v-model.number="imageWatermark.opacity"
type="range"
min="0.1"
max="1"
step="0.1"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 旋转角度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
旋转角度: {{ imageWatermark.rotation }}°
</label>
<input
v-model.number="imageWatermark.rotation"
type="range"
min="-180"
max="180"
step="15"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
<!-- 位置设置 -->
<div v-if="originalImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">位置设置</h3>
<div class="space-y-4">
<!-- 预设位置 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">快速定位</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="(pos, key) in positions"
:key="key"
@click="setPosition(key)"
:class="['btn-sm text-xs', currentPosition.key === key ? 'btn-primary' : 'btn-secondary']"
>
{{ pos.name }}
</button>
</div>
</div>
<!-- 自定义位置 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
X坐标: {{ currentPosition.x }}px
</label>
<input
v-model.number="currentPosition.x"
type="range"
:min="0"
:max="canvasWidth"
class="w-full"
@input="updateWatermark"
>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">
Y坐标: {{ currentPosition.y }}px
</label>
<input
v-model.number="currentPosition.y"
type="range"
:min="0"
:max="canvasHeight"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
<!-- 批量水印设置 -->
<div v-if="originalImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">批量水印</h3>
<div class="space-y-3">
<label class="flex items-center space-x-2">
<input
v-model="batchSettings.enabled"
type="checkbox"
class="form-checkbox"
@change="updateWatermark"
>
<span class="text-secondary">启用平铺水印</span>
</label>
<div v-if="batchSettings.enabled" class="space-y-3">
<div>
<label class="block text-sm font-medium text-secondary mb-2">
水平间距: {{ batchSettings.spacingX }}px
</label>
<input
v-model.number="batchSettings.spacingX"
type="range"
min="50"
max="300"
step="10"
class="w-full"
@input="updateWatermark"
>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">
垂直间距: {{ batchSettings.spacingY }}px
</label>
<input
v-model.number="batchSettings.spacingY"
type="range"
min="50"
max="300"
step="10"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
</div>
</div>
<!-- 预览区域 -->
<div class="lg:col-span-2 space-y-4">
<!-- 原图预览 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">图片预览</h3>
<div v-if="!originalImage" class="bg-block rounded-lg p-8 text-center">
<FontAwesomeIcon :icon="['fas', 'cloud-upload-alt']" class="text-4xl text-tertiary mb-4" />
<p class="text-secondary mb-4">请上传图片或拖拽图片到此处</p>
<button @click="triggerFileInput" class="btn-primary">
选择图片
</button>
</div>
<div v-else class="relative">
<canvas
ref="previewCanvas"
class="max-w-full border border-primary border-opacity-20 rounded-lg cursor-crosshair"
@click="handleCanvasClick"
/>
<!-- 图片信息 -->
<div class="mt-4 flex justify-between items-center text-sm text-secondary">
<span>{{ imageInfo.width }} × {{ imageInfo.height }}</span>
<span>{{ imageInfo.size }}</span>
<span>{{ imageInfo.format }}</span>
</div>
</div>
</div>
<!-- 处理结果 -->
<div v-if="processedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理结果</h3>
<div class="bg-block rounded-lg p-4">
<img :src="processedImage" alt="处理后的图片" class="max-w-full mx-auto">
</div>
<div class="mt-4 flex justify-between items-center text-sm text-secondary">
<span>质量: {{ outputQuality }}%</span>
<span>大小: {{ outputSize }}</span>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// DOM引用
const fileInput = ref<HTMLInputElement>()
const watermarkInput = ref<HTMLInputElement>()
const previewCanvas = ref<HTMLCanvasElement>()
// 响应式状态
const originalImage = ref('')
const processedImage = ref('')
const watermarkImage = ref('')
const watermarkType = ref<'text' | 'image' | null>(null)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 图片信息
const imageInfo = reactive({
width: 0,
height: 0,
size: '',
format: ''
})
// Canvas尺寸
const canvasWidth = ref(0)
const canvasHeight = ref(0)
// 文字水印设置
const textWatermark = reactive({
text: 'Sample Watermark',
fontSize: 36,
color: '#ffffff',
opacity: 0.7,
fontWeight: 'normal',
fontStyle: 'normal',
rotation: 0
})
// 图片水印设置
const imageWatermark = reactive({
scale: 0.3,
opacity: 0.7,
rotation: 0
})
// 位置设置
const currentPosition = reactive({
x: 0,
y: 0,
key: 'center'
})
// 批量水印设置
const batchSettings = reactive({
enabled: false,
spacingX: 150,
spacingY: 150
})
// 预设位置
const positions = {
'top-left': { name: '左上', x: 0.1, y: 0.1 },
'top-center': { name: '居上', x: 0.5, y: 0.1 },
'top-right': { name: '右上', x: 0.9, y: 0.1 },
'center-left': { name: '居左', x: 0.1, y: 0.5 },
'center': { name: '居中', x: 0.5, y: 0.5 },
'center-right': { name: '居右', x: 0.9, y: 0.5 },
'bottom-left': { name: '左下', x: 0.1, y: 0.9 },
'bottom-center': { name: '居下', x: 0.5, y: 0.9 },
'bottom-right': { name: '右下', x: 0.9, y: 0.9 }
}
// 输出设置
const outputQuality = computed(() => 90)
const outputSize = computed(() => {
if (!processedImage.value) return ''
return '估算大小'
})
// 触发文件选择
const triggerFileInput = () => {
fileInput.value?.click()
}
const triggerWatermarkInput = () => {
watermarkInput.value?.click()
}
// 处理图片上传
const handleImageUpload = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
showStatus('请选择有效的图片文件', 'error')
return
}
const reader = new FileReader()
reader.onload = (e) => {
originalImage.value = e.target?.result as string
loadImageInfo(file)
nextTick(() => {
setupCanvas()
})
}
reader.readAsDataURL(file)
}
// 处理水印图片上传
const handleWatermarkUpload = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
showStatus('请选择有效的图片文件', 'error')
return
}
const reader = new FileReader()
reader.onload = (e) => {
watermarkImage.value = e.target?.result as string
watermarkType.value = 'image'
updateWatermark()
}
reader.readAsDataURL(file)
}
// 加载图片信息
const loadImageInfo = (file: File) => {
const img = new Image()
img.onload = () => {
imageInfo.width = img.width
imageInfo.height = img.height
imageInfo.size = formatFileSize(file.size)
imageInfo.format = file.type.split('/')[1].toUpperCase()
}
img.src = originalImage.value
}
// 设置Canvas
const setupCanvas = () => {
if (!previewCanvas.value || !originalImage.value) return
const img = new Image()
img.onload = () => {
const canvas = previewCanvas.value!
const ctx = canvas.getContext('2d')!
// 设置画布尺寸
const maxWidth = 600
const maxHeight = 400
let { width, height } = img
if (width > maxWidth) {
height = (height * maxWidth) / width
width = maxWidth
}
if (height > maxHeight) {
width = (width * maxHeight) / height
height = maxHeight
}
canvas.width = width
canvas.height = height
canvasWidth.value = width
canvasHeight.value = height
// 绘制原图
ctx.drawImage(img, 0, 0, width, height)
// 设置默认水印位置
setPosition('bottom-right')
}
img.src = originalImage.value
}
// 添加文字水印
const addTextWatermark = () => {
watermarkType.value = 'text'
updateWatermark()
}
// 更新水印
const updateWatermark = () => {
if (!previewCanvas.value || !originalImage.value || !watermarkType.value) return
const canvas = previewCanvas.value
const ctx = canvas.getContext('2d')!
// 重新绘制原图
const img = new Image()
img.onload = () => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
// 绘制水印
if (watermarkType.value === 'text') {
drawTextWatermark(ctx)
} else if (watermarkType.value === 'image' && watermarkImage.value) {
drawImageWatermark(ctx)
}
// 生成处理后的图片
processedImage.value = canvas.toDataURL('image/jpeg', outputQuality.value / 100)
}
img.src = originalImage.value
}
// 绘制文字水印
const drawTextWatermark = (ctx: CanvasRenderingContext2D) => {
ctx.save()
// 设置字体
const fontStyle = textWatermark.fontStyle === 'italic' ? 'italic ' : ''
const fontWeight = textWatermark.fontWeight === 'bold' ? 'bold ' : ''
ctx.font = `${fontStyle}${fontWeight}${textWatermark.fontSize}px Arial`
// 设置颜色和透明度
ctx.fillStyle = textWatermark.color
ctx.globalAlpha = textWatermark.opacity
if (batchSettings.enabled) {
// 平铺水印
for (let x = 0; x < canvasWidth.value; x += batchSettings.spacingX) {
for (let y = 0; y < canvasHeight.value; y += batchSettings.spacingY) {
drawSingleTextWatermark(ctx, x + 50, y + 50)
}
}
} else {
// 单个水印
drawSingleTextWatermark(ctx, currentPosition.x, currentPosition.y)
}
ctx.restore()
}
// 绘制单个文字水印
const drawSingleTextWatermark = (ctx: CanvasRenderingContext2D, x: number, y: number) => {
ctx.save()
ctx.translate(x, y)
ctx.rotate((textWatermark.rotation * Math.PI) / 180)
// 添加阴影效果
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'
ctx.shadowBlur = 3
ctx.shadowOffsetX = 1
ctx.shadowOffsetY = 1
ctx.fillText(textWatermark.text, 0, 0)
ctx.restore()
}
// 绘制图片水印
const drawImageWatermark = (ctx: CanvasRenderingContext2D) => {
const watermarkImg = new Image()
watermarkImg.onload = () => {
ctx.save()
ctx.globalAlpha = imageWatermark.opacity
const scaledWidth = watermarkImg.width * imageWatermark.scale
const scaledHeight = watermarkImg.height * imageWatermark.scale
if (batchSettings.enabled) {
// 平铺水印
for (let x = 0; x < canvasWidth.value; x += batchSettings.spacingX) {
for (let y = 0; y < canvasHeight.value; y += batchSettings.spacingY) {
drawSingleImageWatermark(ctx, watermarkImg, x, y, scaledWidth, scaledHeight)
}
}
} else {
// 单个水印
drawSingleImageWatermark(ctx, watermarkImg, currentPosition.x, currentPosition.y, scaledWidth, scaledHeight)
}
ctx.restore()
}
watermarkImg.src = watermarkImage.value
}
// 绘制单个图片水印
const drawSingleImageWatermark = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, width: number, height: number) => {
ctx.save()
ctx.translate(x, y)
ctx.rotate((imageWatermark.rotation * Math.PI) / 180)
ctx.drawImage(img, -width / 2, -height / 2, width, height)
ctx.restore()
}
// 设置位置
const setPosition = (key: string) => {
const pos = positions[key as keyof typeof positions]
if (pos) {
currentPosition.x = canvasWidth.value * pos.x
currentPosition.y = canvasHeight.value * pos.y
currentPosition.key = key
updateWatermark()
}
}
// 处理Canvas点击
const handleCanvasClick = (event: MouseEvent) => {
if (!previewCanvas.value) return
const rect = previewCanvas.value.getBoundingClientRect()
const scaleX = canvasWidth.value / rect.width
const scaleY = canvasHeight.value / rect.height
currentPosition.x = (event.clientX - rect.left) * scaleX
currentPosition.y = (event.clientY - rect.top) * scaleY
currentPosition.key = 'custom'
updateWatermark()
}
// 切换字体样式
const toggleFontStyle = (style: 'bold' | 'italic') => {
if (style === 'bold') {
textWatermark.fontWeight = textWatermark.fontWeight === 'bold' ? 'normal' : 'bold'
} else {
textWatermark.fontStyle = textWatermark.fontStyle === 'italic' ? 'normal' : 'italic'
}
updateWatermark()
}
// 下载图片
const downloadImage = () => {
if (!processedImage.value) return
const link = document.createElement('a')
link.download = `watermarked-image-${Date.now()}.jpg`
link.href = processedImage.value
link.click()
showStatus('图片下载完成', 'success')
}
// 重置图片
const resetImage = () => {
originalImage.value = ''
processedImage.value = ''
watermarkImage.value = ''
watermarkType.value = null
statusMessage.value = ''
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
</script>

View File

@ -0,0 +1,426 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="queryIP"
:disabled="!ipInput.trim() || isLoading"
class="btn-primary"
>
<FontAwesomeIcon
:icon="isLoading ? ['fas', 'spinner'] : ['fas', 'search']"
:class="['mr-2', isLoading && 'animate-spin']"
/>
{{ t('tools.ip_lookup.query') }}
</button>
<button
@click="getMyIP"
:disabled="isLoading"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'globe']" class="mr-2" />
{{ t('tools.ip_lookup.get_my_ip') }}
</button>
<button
@click="clearResults"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.ip_lookup.clear') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="space-y-4">
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.ip_lookup.ip_input') }}</h3>
<input
v-model="ipInput"
type="text"
:placeholder="t('tools.ip_lookup.placeholder')"
class="input-field"
@keyup.enter="queryIP"
@input="validateIP"
>
<div v-if="ipValidation.message" class="mt-2 text-sm" :class="ipValidation.isValid ? 'text-success' : 'text-error'">
{{ ipValidation.message }}
</div>
</div>
<!-- 常用IP -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.ip_lookup.common_ips') }}</h3>
<div class="space-y-2">
<button
v-for="ip in commonIPs"
:key="ip.ip"
@click="selectCommonIP(ip.ip)"
class="w-full text-left p-2 rounded bg-block hover:bg-block-hover text-secondary hover:text-primary transition-colors"
>
<div class="font-medium">{{ ip.ip }}</div>
<div class="text-sm text-tertiary">{{ ip.description }}</div>
</button>
</div>
</div>
</div>
<!-- 结果区域 -->
<div class="space-y-4">
<!-- 加载状态 -->
<div v-if="isLoading" class="card p-4">
<div class="text-center py-8">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<div class="text-secondary">{{ t('tools.ip_lookup.querying') }}</div>
</div>
</div>
<!-- IP信息结果 -->
<div v-else-if="ipResult" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.ip_lookup.ip_info') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.ip_address') }}:</span>
<span class="text-primary font-medium">{{ ipResult.ip }}</span>
</div>
<div v-if="ipResult.type" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.ip_type') }}:</span>
<span class="text-primary font-medium">{{ ipResult.type }}</span>
</div>
<div v-if="ipResult.country" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.country') }}:</span>
<span class="text-primary font-medium">{{ ipResult.country }}</span>
</div>
<div v-if="ipResult.region" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.region') }}:</span>
<span class="text-primary font-medium">{{ ipResult.region }}</span>
</div>
<div v-if="ipResult.city" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.city') }}:</span>
<span class="text-primary font-medium">{{ ipResult.city }}</span>
</div>
<div v-if="ipResult.isp" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.isp') }}:</span>
<span class="text-primary font-medium">{{ ipResult.isp }}</span>
</div>
<div v-if="ipResult.org" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.organization') }}:</span>
<span class="text-primary font-medium">{{ ipResult.org }}</span>
</div>
<div v-if="ipResult.timezone" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.timezone') }}:</span>
<span class="text-primary font-medium">{{ ipResult.timezone }}</span>
</div>
<div v-if="ipResult.lat && ipResult.lon" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.coordinates') }}:</span>
<span class="text-primary font-medium">{{ ipResult.lat }}, {{ ipResult.lon }}</span>
</div>
</div>
</div>
<!-- 当前IP信息 -->
<div v-if="currentIP" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.ip_lookup.current_ip') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.ip_address') }}:</span>
<span class="text-primary font-medium">{{ currentIP.ip }}</span>
</div>
<div v-if="currentIP.location" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.location') }}:</span>
<span class="text-primary font-medium">{{ currentIP.location }}</span>
</div>
<div v-if="currentIP.isp" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.isp') }}:</span>
<span class="text-primary font-medium">{{ currentIP.isp }}</span>
</div>
</div>
</div>
<!-- IP类型检测 -->
<div v-if="ipInput.trim()" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.ip_lookup.ip_analysis') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.format') }}:</span>
<span class="text-primary font-medium">{{ getIPFormat(ipInput) }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.access_type') }}:</span>
<span class="text-primary font-medium">{{ getIPAccessType(ipInput) }}</span>
</div>
<div v-if="isIPv4(ipInput)" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.class') }}:</span>
<span class="text-primary font-medium">{{ getIPClass(ipInput) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
import { api } from '@/utils/api'
const { t } = useLanguage()
// 响应式状态
const ipInput = ref('')
const isLoading = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// IP验证状态
const ipValidation = ref({
isValid: false,
message: ''
})
// 查询结果
const ipResult = ref<{
ip: string
type?: string
country?: string
region?: string
city?: string
isp?: string
org?: string
timezone?: string
lat?: number
lon?: number
} | null>(null)
// 当前IP信息
const currentIP = ref<{
ip: string
location?: string
isp?: string
} | null>(null)
// 常用IP列表
const commonIPs = [
{ ip: '8.8.8.8', description: 'Google DNS' },
{ ip: '1.1.1.1', description: 'Cloudflare DNS' },
{ ip: '114.114.114.114', description: '114 DNS' },
{ ip: '223.5.5.5', description: '阿里 DNS' },
{ ip: '180.76.76.76', description: '百度 DNS' }
]
// 查询IP信息
const queryIP = async () => {
if (!ipInput.value.trim()) {
showStatus('请输入IP地址', 'error')
return
}
if (!ipValidation.value.isValid) {
showStatus('请输入有效的IP地址', 'error')
return
}
isLoading.value = true
statusMessage.value = ''
try {
// 使用免费的IP查询API
const response = await fetch(`http://ip-api.com/json/${ipInput.value}?lang=zh-CN`)
const data = await response.json()
if (data.status === 'success') {
ipResult.value = {
ip: data.query,
type: isIPv4(data.query) ? 'IPv4' : 'IPv6',
country: data.country,
region: data.regionName,
city: data.city,
isp: data.isp,
org: data.org,
timezone: data.timezone,
lat: data.lat,
lon: data.lon
}
showStatus('IP查询成功', 'success')
} else {
throw new Error(data.message || 'IP查询失败')
}
} catch (error) {
console.error('IP查询失败:', error)
showStatus('IP查询失败: ' + (error instanceof Error ? error.message : '网络错误'), 'error')
ipResult.value = null
} finally {
isLoading.value = false
}
}
// 获取当前IP
const getMyIP = async () => {
isLoading.value = true
statusMessage.value = ''
try {
// 首先尝试获取当前IP
const ipResponse = await fetch('https://api.ipify.org?format=json')
const ipData = await ipResponse.json()
// 然后查询IP详细信息
const detailResponse = await fetch(`http://ip-api.com/json/${ipData.ip}?lang=zh-CN`)
const detailData = await detailResponse.json()
if (detailData.status === 'success') {
currentIP.value = {
ip: ipData.ip,
location: `${detailData.country} ${detailData.regionName} ${detailData.city}`,
isp: detailData.isp
}
// 同时设置到输入框
ipInput.value = ipData.ip
validateIP()
showStatus('当前IP获取成功', 'success')
} else {
throw new Error('获取IP详细信息失败')
}
} catch (error) {
console.error('获取当前IP失败:', error)
showStatus('获取当前IP失败: ' + (error instanceof Error ? error.message : '网络错误'), 'error')
} finally {
isLoading.value = false
}
}
// 选择常用IP
const selectCommonIP = (ip: string) => {
ipInput.value = ip
validateIP()
}
// 清除结果
const clearResults = () => {
ipInput.value = ''
ipResult.value = null
currentIP.value = null
statusMessage.value = ''
ipValidation.value = { isValid: false, message: '' }
}
// 验证IP地址
const validateIP = () => {
const ip = ipInput.value.trim()
if (!ip) {
ipValidation.value = { isValid: false, message: '' }
return
}
if (isIPv4(ip)) {
ipValidation.value = { isValid: true, message: '有效的IPv4地址' }
} else if (isIPv6(ip)) {
ipValidation.value = { isValid: true, message: '有效的IPv6地址' }
} else {
ipValidation.value = { isValid: false, message: '无效的IP地址格式' }
}
}
// 检查是否为IPv4
const isIPv4 = (ip: string): boolean => {
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
return ipv4Regex.test(ip)
}
// 检查是否为IPv6
const isIPv6 = (ip: string): boolean => {
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/
return ipv6Regex.test(ip)
}
// 获取IP格式
const getIPFormat = (ip: string): string => {
if (isIPv4(ip)) return 'IPv4'
if (isIPv6(ip)) return 'IPv6'
return '无效格式'
}
// 获取IP访问类型
const getIPAccessType = (ip: string): string => {
if (!isIPv4(ip)) return '未知'
const parts = ip.split('.').map(Number)
const first = parts[0]
const second = parts[1]
// 私有IP地址
if (first === 10) return '私有网络 (Class A)'
if (first === 172 && second >= 16 && second <= 31) return '私有网络 (Class B)'
if (first === 192 && second === 168) return '私有网络 (Class C)'
if (first === 127) return '本地回环'
if (first === 169 && second === 254) return '链路本地'
return '公网'
}
// 获取IP类别 (仅IPv4)
const getIPClass = (ip: string): string => {
if (!isIPv4(ip)) return ''
const first = parseInt(ip.split('.')[0])
if (first >= 1 && first <= 126) return 'A类 (1-126)'
if (first >= 128 && first <= 191) return 'B类 (128-191)'
if (first >= 192 && first <= 223) return 'C类 (192-223)'
if (first >= 224 && first <= 239) return 'D类 (组播)'
if (first >= 240 && first <= 255) return 'E类 (保留)'
return '未知'
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 组件挂载时获取当前IP
onMounted(() => {
// 可以选择是否自动获取当前IP
// getMyIP()
})
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,843 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="formatJson"
:disabled="!jsonText.trim()"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-2" />
格式化
</button>
<button
@click="compressJson"
:disabled="!jsonText.trim()"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'compress']" class="mr-2" />
压缩
</button>
<button
@click="validateJson"
:disabled="!jsonText.trim()"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'check']" class="mr-2" />
验证
</button>
<button
@click="copyJson"
:disabled="!jsonText.trim()"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
复制
</button>
<button
@click="clearEditor"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清除
</button>
<button
@click="loadSample"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'file-import']" class="mr-2" />
示例
</button>
<div class="ml-auto flex items-center space-x-2">
<label class="text-sm text-secondary">视图:</label>
<select
v-model="viewMode"
class="select-field text-sm"
>
<option value="text">文本编辑</option>
<option value="tree">树形视图</option>
<option value="split">分屏视图</option>
</select>
</div>
</div>
</div>
<div class="grid" :class="viewMode === 'split' ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'" style="gap: 1.5rem;">
<!-- 文本编辑器 -->
<div v-if="viewMode === 'text' || viewMode === 'split'" class="space-y-4">
<!-- 编辑器选项 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">文本编辑器</h3>
<div class="flex flex-wrap gap-4 mb-4">
<div>
<label class="block text-sm text-secondary mb-1">缩进设置</label>
<select v-model="indentSize" class="select-field text-sm">
<option :value="2">2个空格</option>
<option :value="4">4个空格</option>
<option :value="'tab'">制表符</option>
</select>
</div>
<div>
<label class="block text-sm text-secondary mb-1">字体大小</label>
<select v-model="fontSize" class="select-field text-sm">
<option value="12">12px</option>
<option value="14">14px</option>
<option value="16">16px</option>
<option value="18">18px</option>
</select>
</div>
<div class="flex items-center space-x-4">
<label class="flex items-center space-x-2">
<input
v-model="showLineNumbers"
type="checkbox"
class="form-checkbox"
>
<span class="text-sm text-secondary">显示行号</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="wordWrap"
type="checkbox"
class="form-checkbox"
>
<span class="text-sm text-secondary">自动换行</span>
</label>
</div>
</div>
<!-- JSON输入区域 -->
<div class="relative">
<div class="flex items-center justify-between mb-2">
<div class="text-sm text-secondary">
行数: {{ lineCount }} | 字符数: {{ charCount }}
</div>
<div v-if="currentPath" class="text-sm text-tertiary">
当前路径: {{ currentPath }}
</div>
</div>
<div class="relative">
<!-- 行号 -->
<div
v-if="showLineNumbers"
class="absolute left-0 top-0 bottom-0 w-12 bg-block-hover border-r border-border text-xs text-tertiary font-mono flex flex-col z-10"
:style="{ fontSize: fontSize + 'px' }"
>
<div
v-for="n in lineCount"
:key="n"
class="h-6 flex items-center justify-end pr-2"
>
{{ n }}
</div>
</div>
<!-- 编辑器 -->
<textarea
v-model="jsonText"
@input="handleTextInput"
@keydown="handleKeyDown"
@click="updateCursor"
@keyup="updateCursor"
:style="{
fontSize: fontSize + 'px',
paddingLeft: showLineNumbers ? '3rem' : '1rem',
whiteSpace: wordWrap ? 'pre-wrap' : 'pre'
}"
class="textarea-field font-mono resize-none transition-all"
:class="[
'h-96 w-full',
jsonError ? 'border-error' : 'border-border'
]"
placeholder="请输入JSON数据..."
spellcheck="false"
/>
</div>
</div>
<!-- 错误信息 -->
<div v-if="jsonError" class="mt-2 p-3 bg-error bg-opacity-10 border border-error rounded-lg">
<div class="flex items-start space-x-2">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" class="text-error mt-0.5" />
<div>
<div class="font-medium text-error">JSON格式错误</div>
<div class="text-sm text-error opacity-80">{{ jsonError }}</div>
<div v-if="errorLine" class="text-xs text-error opacity-60 mt-1">
{{ errorLine }} | {{ errorColumn }}
</div>
</div>
</div>
</div>
<!-- 验证成功信息 -->
<div v-else-if="validationMessage" class="mt-2 p-3 bg-success bg-opacity-10 border border-success rounded-lg">
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success" />
<span class="text-success">{{ validationMessage }}</span>
</div>
</div>
</div>
</div>
<!-- 树形视图 -->
<div v-if="viewMode === 'tree' || viewMode === 'split'" class="space-y-4">
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">树形视图</h3>
<div class="flex space-x-2">
<button
@click="expandAll"
:disabled="!parsedJson"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'expand']" class="mr-1" />
全部展开
</button>
<button
@click="collapseAll"
:disabled="!parsedJson"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'compress']" class="mr-1" />
全部折叠
</button>
</div>
</div>
<div class="max-h-96 overflow-auto border border-border rounded-lg p-4 bg-block font-mono text-sm">
<JsonTreeNode
v-if="parsedJson !== null"
:data="parsedJson"
:path="[]"
:expanded="expandedNodes"
@toggle="toggleNode"
@select="selectNode"
/>
<div v-else class="text-tertiary text-center py-8">
请输入有效的JSON数据
</div>
</div>
</div>
<!-- JSON路径查询 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">路径查询</h3>
<div class="space-y-3">
<div>
<label class="block text-sm text-secondary mb-1">JSON路径 (支持 . [] 语法)</label>
<div class="flex space-x-2">
<input
v-model="jsonPath"
type="text"
class="input-field flex-1 font-mono text-sm"
placeholder="例如: user.name 或 users[0].email"
@keyup.enter="queryPath"
>
<button
@click="queryPath"
:disabled="!jsonPath.trim() || !parsedJson"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'search']" />
</button>
</div>
</div>
<!-- 查询结果 -->
<div v-if="pathResult !== null" class="p-3 bg-block rounded-lg">
<div class="text-sm text-secondary mb-2">查询结果:</div>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap">{{ pathResult }}</pre>
</div>
<div v-if="pathError" class="p-3 bg-error bg-opacity-10 border border-error rounded-lg">
<div class="text-sm text-error">{{ pathError }}</div>
</div>
</div>
</div>
<!-- JSON统计信息 -->
<div v-if="parsedJson" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">统计信息</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">总键数:</span>
<span class="text-primary font-medium">{{ jsonStats.totalKeys }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">总值数:</span>
<span class="text-primary font-medium">{{ jsonStats.totalValues }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">嵌套深度:</span>
<span class="text-primary font-medium">{{ jsonStats.maxDepth }}</span>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">数组数量:</span>
<span class="text-primary font-medium">{{ jsonStats.arrayCount }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">对象数量:</span>
<span class="text-primary font-medium">{{ jsonStats.objectCount }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">字符串数量:</span>
<span class="text-primary font-medium">{{ jsonStats.stringCount }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 状态栏 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const jsonText = ref('')
const viewMode = ref<'text' | 'tree' | 'split'>('text')
const indentSize = ref<number | string>(2)
const fontSize = ref('14')
const showLineNumbers = ref(true)
const wordWrap = ref(false)
const copied = ref(false)
const jsonError = ref('')
const validationMessage = ref('')
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
const currentPath = ref('')
const jsonPath = ref('')
const pathResult = ref<any>(null)
const pathError = ref('')
const errorLine = ref<number | null>(null)
const errorColumn = ref<number | null>(null)
// 树形视图状态
const expandedNodes = ref<Set<string>>(new Set())
// 计算属性
const lineCount = computed(() => {
return jsonText.value ? jsonText.value.split('\n').length : 1
})
const charCount = computed(() => {
return jsonText.value.length
})
const parsedJson = computed(() => {
if (!jsonText.value.trim()) return null
try {
return JSON.parse(jsonText.value)
} catch {
return null
}
})
const jsonStats = computed(() => {
if (!parsedJson.value) {
return {
totalKeys: 0,
totalValues: 0,
maxDepth: 0,
arrayCount: 0,
objectCount: 0,
stringCount: 0
}
}
const stats = {
totalKeys: 0,
totalValues: 0,
maxDepth: 0,
arrayCount: 0,
objectCount: 0,
stringCount: 0
}
const analyze = (obj: any, depth: number = 0): void => {
stats.maxDepth = Math.max(stats.maxDepth, depth)
if (Array.isArray(obj)) {
stats.arrayCount++
stats.totalValues++
for (const item of obj) {
analyze(item, depth + 1)
}
} else if (typeof obj === 'object' && obj !== null) {
stats.objectCount++
stats.totalValues++
for (const [key, value] of Object.entries(obj)) {
stats.totalKeys++
analyze(value, depth + 1)
}
} else if (typeof obj === 'string') {
stats.stringCount++
stats.totalValues++
} else {
stats.totalValues++
}
}
analyze(parsedJson.value)
return stats
})
// 处理文本输入
const handleTextInput = () => {
validateJson()
pathResult.value = null
pathError.value = ''
}
// 处理键盘事件
const handleKeyDown = (event: KeyboardEvent) => {
// Tab键缩进
if (event.key === 'Tab') {
event.preventDefault()
const textarea = event.target as HTMLTextAreaElement
const start = textarea.selectionStart
const end = textarea.selectionEnd
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(Number(indentSize.value))
if (event.shiftKey) {
// Shift+Tab: 减少缩进
const lines = jsonText.value.split('\n')
const startLine = jsonText.value.substring(0, start).split('\n').length - 1
const endLine = jsonText.value.substring(0, end).split('\n').length - 1
for (let i = startLine; i <= endLine; i++) {
if (lines[i].startsWith(indent)) {
lines[i] = lines[i].substring(indent.length)
}
}
jsonText.value = lines.join('\n')
} else {
// Tab: 增加缩进
const value = jsonText.value
jsonText.value = value.substring(0, start) + indent + value.substring(end)
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + indent.length
})
}
}
// Ctrl+Enter: 格式化
if (event.ctrlKey && event.key === 'Enter') {
event.preventDefault()
formatJson()
}
}
// 更新光标位置
const updateCursor = (event: Event) => {
const textarea = event.target as HTMLTextAreaElement
const cursorPos = textarea.selectionStart
const textBeforeCursor = jsonText.value.substring(0, cursorPos)
const lines = textBeforeCursor.split('\n')
const currentLine = lines.length
const currentCol = lines[lines.length - 1].length + 1
currentPath.value = `${currentLine} 行,第 ${currentCol}`
}
// 验证JSON
const validateJson = () => {
if (!jsonText.value.trim()) {
jsonError.value = ''
validationMessage.value = ''
errorLine.value = null
errorColumn.value = null
return
}
try {
JSON.parse(jsonText.value)
jsonError.value = ''
validationMessage.value = 'JSON格式正确'
errorLine.value = null
errorColumn.value = null
} catch (error) {
if (error instanceof SyntaxError) {
jsonError.value = error.message
// 尝试提取行号和列号
const match = error.message.match(/position (\d+)/)
if (match) {
const position = parseInt(match[1])
const lines = jsonText.value.substring(0, position).split('\n')
errorLine.value = lines.length
errorColumn.value = lines[lines.length - 1].length
}
} else {
jsonError.value = '未知错误'
}
validationMessage.value = ''
}
}
// 格式化JSON
const formatJson = () => {
if (!jsonText.value.trim()) {
showStatus('请输入JSON数据', 'error')
return
}
try {
const parsed = JSON.parse(jsonText.value)
const indent = indentSize.value === 'tab' ? '\t' : Number(indentSize.value)
jsonText.value = JSON.stringify(parsed, null, indent)
showStatus('格式化完成', 'success')
validateJson()
} catch (error) {
showStatus('JSON格式错误无法格式化', 'error')
}
}
// 压缩JSON
const compressJson = () => {
if (!jsonText.value.trim()) {
showStatus('请输入JSON数据', 'error')
return
}
try {
const parsed = JSON.parse(jsonText.value)
jsonText.value = JSON.stringify(parsed)
showStatus('压缩完成', 'success')
validateJson()
} catch (error) {
showStatus('JSON格式错误无法压缩', 'error')
}
}
// 复制JSON
const copyJson = async () => {
if (!jsonText.value.trim()) return
try {
await navigator.clipboard.writeText(jsonText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
showStatus('复制成功', 'success')
} catch (error) {
showStatus('复制失败', 'error')
}
}
// 清除编辑器
const clearEditor = () => {
jsonText.value = ''
jsonError.value = ''
validationMessage.value = ''
currentPath.value = ''
pathResult.value = null
pathError.value = ''
statusMessage.value = ''
}
// 加载示例
const loadSample = () => {
const sample = {
"user": {
"id": 1,
"name": "张三",
"email": "zhangsan@example.com",
"profile": {
"age": 30,
"city": "北京",
"skills": ["JavaScript", "Vue.js", "Node.js"]
}
},
"posts": [
{
"id": 1,
"title": "Vue.js入门指南",
"content": "这是一篇关于Vue.js的入门教程...",
"tags": ["vue", "javascript", "前端"],
"published": true
},
{
"id": 2,
"title": "JSON数据处理技巧",
"content": "本文介绍JSON数据的处理方法...",
"tags": ["json", "数据处理"],
"published": false
}
],
"metadata": {
"version": "1.0.0",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-15T12:30:00Z"
}
}
const indent = indentSize.value === 'tab' ? '\t' : Number(indentSize.value)
jsonText.value = JSON.stringify(sample, null, indent)
validateJson()
}
// 树形视图相关
const toggleNode = (path: string[]) => {
const pathStr = path.join('.')
if (expandedNodes.value.has(pathStr)) {
expandedNodes.value.delete(pathStr)
} else {
expandedNodes.value.add(pathStr)
}
}
const selectNode = (path: string[]) => {
jsonPath.value = path.join('.')
queryPath()
}
const expandAll = () => {
const expand = (obj: any, path: string[] = []): void => {
if (typeof obj === 'object' && obj !== null) {
expandedNodes.value.add(path.join('.'))
for (const key in obj) {
expand(obj[key], [...path, key])
}
}
}
if (parsedJson.value) {
expand(parsedJson.value)
}
}
const collapseAll = () => {
expandedNodes.value.clear()
}
// 路径查询
const queryPath = () => {
if (!jsonPath.value.trim() || !parsedJson.value) {
pathResult.value = null
pathError.value = ''
return
}
try {
const result = getValueByPath(parsedJson.value, jsonPath.value)
pathResult.value = JSON.stringify(result, null, 2)
pathError.value = ''
} catch (error) {
pathResult.value = null
pathError.value = error instanceof Error ? error.message : '路径查询失败'
}
}
// 根据路径获取值
const getValueByPath = (obj: any, path: string): any => {
const keys = path.split(/[.\[\]]+/).filter(key => key)
let current = obj
for (const key of keys) {
if (current === null || current === undefined) {
throw new Error(`路径 "${path}" 中的 "${key}" 不存在`)
}
if (Array.isArray(current)) {
const index = parseInt(key)
if (isNaN(index) || index < 0 || index >= current.length) {
throw new Error(`数组索引 "${key}" 无效`)
}
current = current[index]
} else if (typeof current === 'object') {
if (!(key in current)) {
throw new Error(`属性 "${key}" 不存在`)
}
current = current[key]
} else {
throw new Error(`无法在基本类型上访问属性 "${key}"`)
}
}
return current
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 监听JSON文本变化
watch(() => jsonText.value, () => {
validateJson()
})
</script>
<!-- JSON树形节点组件 -->
<script lang="ts">
export default {
name: 'JsonTreeNode',
props: {
data: {
type: [Object, Array, String, Number, Boolean],
required: true
},
path: {
type: Array as () => string[],
required: true
},
expanded: {
type: Set as () => Set<string>,
required: true
}
},
emits: ['toggle', 'select'],
setup(props, { emit }) {
const isExpanded = computed(() => {
return props.expanded.has(props.path.join('.'))
})
const isObject = computed(() => {
return typeof props.data === 'object' && props.data !== null
})
const isArray = computed(() => {
return Array.isArray(props.data)
})
const dataType = computed(() => {
if (props.data === null) return 'null'
if (Array.isArray(props.data)) return 'array'
return typeof props.data
})
const displayValue = computed(() => {
if (props.data === null) return 'null'
if (typeof props.data === 'string') return `"${props.data}"`
if (typeof props.data === 'boolean') return props.data.toString()
if (typeof props.data === 'number') return props.data.toString()
return ''
})
const toggle = () => {
if (isObject.value) {
emit('toggle', props.path)
}
}
const select = () => {
emit('select', props.path)
}
return {
isExpanded,
isObject,
isArray,
dataType,
displayValue,
toggle,
select
}
},
template: `
<div class="json-node">
<div
class="flex items-center space-x-1 hover:bg-block-hover rounded px-1 cursor-pointer"
@click="select"
>
<button
v-if="isObject"
@click.stop="toggle"
class="w-4 h-4 flex items-center justify-center text-xs text-secondary hover:text-primary"
>
<FontAwesomeIcon
:icon="isExpanded ? ['fas', 'chevron-down'] : ['fas', 'chevron-right']"
/>
</button>
<div v-else class="w-4"></div>
<span
v-if="path.length > 0"
class="text-blue-400 font-medium"
>
{{ path[path.length - 1] }}:
</span>
<span
v-if="!isObject"
:class="{
'text-green-400': dataType === 'string',
'text-blue-400': dataType === 'number',
'text-purple-400': dataType === 'boolean',
'text-gray-400': dataType === 'null'
}"
>
{{ displayValue }}
</span>
<span v-if="isArray" class="text-gray-400">
[{{ data.length }}]
</span>
<span v-else-if="isObject && !isArray" class="text-gray-400">
{{{ Object.keys(data).length }}}
</span>
</div>
<div v-if="isObject && isExpanded" class="ml-4 border-l border-border pl-2 mt-1">
<JsonTreeNode
v-for="(value, key, index) in data"
:key="index"
:data="value"
:path="[...path, key.toString()]"
:expanded="expanded"
@toggle="$emit('toggle', $event)"
@select="$emit('select', $event)"
/>
</div>
</div>
`
}
</script>

View File

@ -0,0 +1,847 @@
<template>
<div class="space-y-6">
<!-- 验证状态显示 -->
<div v-if="validationResult.message || isLoading" class="text-center">
<div v-if="isLoading" class="flex items-center justify-center text-tertiary">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="animate-spin mr-2" />
<span>{{ isLargeJson ? t('tools.json_formatter.processing_large_json') : t('tools.json_formatter.parsing_json') }}</span>
</div>
<div v-else-if="validationResult.message" :class="[
'flex items-center justify-center space-x-2',
validationResult.isValid ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="validationResult.isValid ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ validationResult.message }}</span>
</div>
</div>
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="toggleCompression"
:disabled="isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="isCompressed ? ['fas', 'expand'] : ['fas', 'compress']" />
<span>{{ isCompressed ? t('tools.json_formatter.beautify') : t('tools.json_formatter.compress') }}</span>
</button>
<button
@click="toggleFoldable"
:disabled="isLoading || !jsonOutput"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="isFoldable ? ['fas', 'folder'] : ['fas', 'folder-open']" />
<span>{{ isFoldable ? t('tools.json_formatter.normal_mode') : t('tools.json_formatter.fold_mode') }}</span>
</button>
<button
@click="copyToClipboard"
:disabled="!jsonOutput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" />
<span>{{ copied ? t('common.copySuccess') : t('tools.json_formatter.copy') }}</span>
</button>
<button
@click="clearInput"
:disabled="isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'trash']" />
<span>{{ t('tools.json_formatter.clear') }}</span>
</button>
<button
@click="loadExample"
:disabled="isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'code']" />
<span>{{ t('tools.json_formatter.load_example') }}</span>
</button>
<button
@click="reformat"
:disabled="!jsonInput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="isLoading ? ['fas', 'spinner'] : ['fas', 'sync']" :class="isLoading && 'animate-spin'" />
<span>{{ isLoading ? t('tools.json_formatter.processing') : t('tools.json_formatter.reformat') }}</span>
</button>
<button
@click="openSaveModal"
:disabled="!jsonOutput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'save']" />
<span>{{ t('tools.json_formatter.save') }}</span>
</button>
<button
@click="openHistory"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'history']" />
<span>{{ t('tools.json_formatter.history') }}</span>
</button>
<button
@click="removeSlashes"
:disabled="!jsonInput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'eraser']" />
<span>{{ t('tools.json_formatter.remove_slash') }}</span>
</button>
<button
@click="escapeString"
:disabled="!jsonInput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'code']" />
<span>{{ t('tools.json_formatter.escape_string') }}</span>
</button>
<button
@click="unescapeString"
:disabled="!jsonInput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'undo']" />
<span>{{ t('tools.json_formatter.unescape_string') }}</span>
</button>
<button
v-if="isLoading"
@click="cancelFormatting"
class="btn-primary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'times']" />
<span>{{ t('tools.json_formatter.cancel') }}</span>
</button>
</div>
</div>
<!-- 主内容区 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<label class="text-sm font-medium text-secondary">{{ t('tools.json_formatter.input_json') }}</label>
<div class="text-xs text-tertiary">{{ t('tools.json_formatter.paste_json_here') }}</div>
</div>
<textarea
v-model="jsonInput"
ref="jsonInputRef"
:placeholder="t('tools.json_formatter.paste_json_placeholder')"
class="textarea-field h-96 font-mono text-sm"
:disabled="isLoading"
@input="handleInputChange"
@blur="handleBlur"
@paste="handlePaste"
/>
<div v-if="errorMessage" class="mt-2 text-sm text-error">
{{ errorMessage }}
</div>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<label class="text-sm font-medium text-secondary">{{ t('tools.json_formatter.output') }}</label>
<div class="text-xs text-tertiary">
<span v-if="jsonOutput && !isLoading">{{ jsonOutput.length.toLocaleString() }} {{ t('tools.json_formatter.characters') }}</span>
<span v-if="isLoading" class="flex items-center">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="animate-spin mr-1" />
{{ isLargeJson ? t('tools.json_formatter.processing_large_json') : t('tools.json_formatter.processing') }}
</span>
</div>
</div>
<div class="relative h-96 border border-primary/20 rounded-lg overflow-hidden bg-secondary/5">
<div v-if="isLoading" class="absolute inset-0 flex items-center justify-center bg-secondary/10 backdrop-blur-sm z-10">
<div class="flex flex-col items-center space-y-2">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="animate-spin text-2xl text-primary" />
<span class="text-secondary text-center">
{{ isLargeJson ? t('tools.json_formatter.processing_large_json_message') : t('tools.json_formatter.parsing_json') }}
</span>
<button @click="cancelFormatting" class="mt-3 px-3 py-1.5 text-xs rounded btn-secondary">
{{ t('tools.json_formatter.cancel_processing') }}
</button>
</div>
</div>
<div v-else-if="jsonOutput" class="h-full overflow-auto p-4">
<div v-if="isFoldable && parsedJson" class="json-viewer">
<!-- 可折叠的JSON视图 - 简化版本 -->
<JsonTreeView :data="parsedJson" />
</div>
<pre v-else class="whitespace-pre-wrap text-sm font-mono text-primary leading-relaxed">{{ jsonOutput }}</pre>
</div>
<div v-else class="h-full flex items-center justify-center text-tertiary">
<span>{{ t('tools.json_formatter.output_placeholder') }}</span>
</div>
</div>
</div>
</div>
<!-- JSONPath查询 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.json_formatter.jsonpath_query') }}</h3>
<div class="text-xs text-tertiary mb-3">{{ t('tools.json_formatter.enter_jsonpath') }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="relative">
<FontAwesomeIcon
:icon="['fas', 'search']"
class="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary"
/>
<input
v-model="jsonPath"
ref="jsonPathInputRef"
type="text"
:placeholder="t('tools.json_formatter.jsonpath_placeholder')"
class="input-field pl-10"
:disabled="isLoading || !jsonOutput"
@keyup.enter="queryJsonPath"
/>
</div>
</div>
<div>
<div class="p-3 rounded-lg min-h-[40px] text-sm bg-secondary/10 border border-primary/10">
<pre v-if="pathResult" class="whitespace-pre-wrap text-secondary">{{ pathResult }}</pre>
<span v-else class="text-tertiary">{{ t('tools.json_formatter.query_result_placeholder') }}</span>
</div>
</div>
</div>
</div>
<!-- 历史记录侧边栏 -->
<div v-if="isHistoryOpen" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" @click="closeHistory"></div>
<div class="absolute top-0 right-0 h-full w-96 bg-card border-l border-primary/20 shadow-xl overflow-y-auto">
<div class="p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-primary">{{ t('tools.json_formatter.history') }}</h3>
<button @click="closeHistory" class="p-2 rounded text-secondary hover:text-primary">
<FontAwesomeIcon :icon="['fas', 'times']" />
</button>
</div>
<div v-if="historyItems.length === 0" class="text-center text-tertiary py-8">
<FontAwesomeIcon :icon="['fas', 'history']" class="text-4xl mb-2" />
<p>{{ t('tools.json_formatter.no_history') }}</p>
</div>
<div v-else>
<!-- 收藏的项目 -->
<div v-if="favoriteItems.length > 0" class="mb-6">
<h4 class="text-sm font-medium text-secondary mb-2">{{ t('tools.json_formatter.favorites') }}</h4>
<div v-for="item in favoriteItems" :key="item.id" class="history-item" @click="loadFromHistory(item)">
<div class="flex-1 truncate">
<div class="font-medium truncate text-primary">{{ item.title }}</div>
<div class="text-xs text-tertiary">{{ formatTimestamp(item.timestamp) }}</div>
</div>
<div class="flex items-center space-x-2">
<button @click.stop="toggleFavorite(item.id)" class="text-warning" :title="t('tools.json_formatter.remove_favorite')">
<FontAwesomeIcon :icon="['fas', 'star']" />
</button>
<button @click.stop="startEditingTitle(item)" class="text-tertiary hover:text-primary" :title="t('tools.json_formatter.edit_title')">
<FontAwesomeIcon :icon="['fas', 'edit']" />
</button>
<button @click.stop="deleteHistoryItem(item.id)" class="text-tertiary hover:text-error" :title="t('tools.json_formatter.delete')">
<FontAwesomeIcon :icon="['fas', 'trash']" />
</button>
</div>
</div>
</div>
<!-- 所有历史记录 -->
<h4 class="text-sm font-medium text-secondary mb-2">{{ t('tools.json_formatter.all_history') }}</h4>
<div v-for="item in historyItems" :key="item.id" class="history-item" @click="loadFromHistory(item)">
<div class="flex-1 truncate">
<div class="font-medium truncate text-primary">{{ item.title }}</div>
<div class="text-xs text-tertiary">{{ formatTimestamp(item.timestamp) }}</div>
</div>
<div class="flex items-center space-x-2">
<button
@click.stop="toggleFavorite(item.id)"
:class="item.isFavorite ? 'text-warning' : 'text-tertiary hover:text-warning'"
:title="item.isFavorite ? t('tools.json_formatter.remove_favorite') : t('tools.json_formatter.add_favorite')"
>
<FontAwesomeIcon :icon="['fas', 'star']" />
</button>
<button @click.stop="startEditingTitle(item)" class="text-tertiary hover:text-primary" :title="t('tools.json_formatter.edit_title')">
<FontAwesomeIcon :icon="['fas', 'edit']" />
</button>
<button @click.stop="deleteHistoryItem(item.id)" class="text-tertiary hover:text-error" :title="t('tools.json_formatter.delete')">
<FontAwesomeIcon :icon="['fas', 'trash']" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 保存模态框 -->
<div v-if="isSaveModalOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" @click="closeSaveModal"></div>
<div class="relative bg-card rounded-lg p-6 w-full max-w-md mx-4 border border-primary/20 shadow-xl">
<h3 class="text-lg font-medium text-primary mb-4">
{{ editingItem ? t('tools.json_formatter.edit_saved_json') : t('tools.json_formatter.save_to_history') }}
</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-secondary mb-1">{{ t('tools.json_formatter.modal_title') }}</label>
<input
v-model="savingTitle"
type="text"
:placeholder="t('tools.json_formatter.enter_title')"
class="input-field w-full"
@keyup.enter="saveToHistory"
/>
</div>
<div class="flex justify-end space-x-2">
<button @click="closeSaveModal" class="btn-secondary">
{{ t('tools.json_formatter.cancel') }}
</button>
<button @click="saveToHistory" class="btn-primary" :disabled="!savingTitle.trim()">
{{ editingItem ? t('tools.json_formatter.update') : t('tools.json_formatter.save') }}
</button>
</div>
</div>
</div>
<!-- 使用指南 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.json_formatter.usage_guide') }}</h3>
<ul class="text-sm text-tertiary space-y-1 list-disc pl-5">
<li>{{ t('tools.json_formatter.guide_1') }}</li>
<li>{{ t('tools.json_formatter.guide_2') }}</li>
<li>{{ t('tools.json_formatter.guide_3') }}</li>
<li>{{ t('tools.json_formatter.guide_4') }}</li>
<li>{{ t('tools.json_formatter.guide_5') }}</li>
<li>{{ t('tools.json_formatter.guide_6') }}</li>
<li>{{ t('tools.json_formatter.guide_7') }}</li>
<li>{{ t('tools.json_formatter.guide_8') }}</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 历史记录项目类型
interface JsonHistoryItem {
id: string
title: string
json: string
timestamp: number
isFavorite?: boolean
}
// 响应式状态
const jsonInput = ref('')
const jsonOutput = ref('')
const jsonPath = ref('')
const pathResult = ref('')
const errorMessage = ref('')
const isLoading = ref(false)
const isCompressed = ref(false)
const isFoldable = ref(true)
const copied = ref(false)
const isLargeJson = ref(false)
const validationResult = ref({
isValid: false,
message: ''
})
// 历史记录相关状态
const historyItems = ref<JsonHistoryItem[]>([])
const isHistoryOpen = ref(false)
const isSaveModalOpen = ref(false)
const savingTitle = ref('')
const editingItem = ref<JsonHistoryItem | null>(null)
// refs
const jsonInputRef = ref<HTMLTextAreaElement>()
const jsonPathInputRef = ref<HTMLInputElement>()
// 处理参考,用于取消操作
const processingRef = ref(false)
// 计算属性
const parsedJson = computed(() => {
if (!jsonOutput.value) return null
try {
return JSON.parse(jsonOutput.value)
} catch {
return null
}
})
const favoriteItems = computed(() => {
return historyItems.value.filter(item => item.isFavorite)
})
// 从本地存储加载历史记录
onMounted(() => {
const savedHistory = localStorage.getItem('json_formatter_history')
if (savedHistory) {
try {
historyItems.value = JSON.parse(savedHistory)
} catch (e) {
console.error('加载历史记录失败:', e)
}
}
})
// 保存历史记录到本地存储
const saveHistoryToLocalStorage = () => {
localStorage.setItem('json_formatter_history', JSON.stringify(historyItems.value))
}
// 格式化JSON
const formatJson = (json: string, compress = false) => {
if (!json.trim()) {
jsonOutput.value = ''
validationResult.value = { isValid: false, message: '' }
return
}
// 检查JSON大小
const isLarge = json.length > 100000
isLargeJson.value = isLarge
// 设置加载状态
isLoading.value = true
processingRef.value = true
// 使用setTimeout确保UI更新
setTimeout(() => {
if (!processingRef.value) return // 检查是否被取消
try {
// 处理可能的JS对象文本
const processedJson = json
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":')
.replace(/'/g, '"')
let parsed
try {
parsed = JSON.parse(processedJson)
} catch (e) {
// 尝试使用Function构造器处理JS对象
try {
parsed = new Function('return ' + json)()
} catch {
throw e
}
}
// 根据模式输出不同格式
let formattedJson
if (compress) {
formattedJson = JSON.stringify(parsed)
} else {
formattedJson = JSON.stringify(parsed, null, 2)
}
if (!processingRef.value) return // 再次检查是否被取消
// 设置输出
jsonOutput.value = formattedJson
// 计算大小
const sizeKB = (formattedJson.length / 1024).toFixed(1)
const largeJsonMessage = t('tools.json_formatter.large_json_processed').replace('{size}', sizeKB)
validationResult.value = {
isValid: true,
message: isLarge ? largeJsonMessage : t('tools.json_formatter.json_valid')
}
errorMessage.value = ''
// 如果有JSONPath查询执行查询
if (jsonPath.value) {
queryJsonPath()
}
} catch (error) {
if (!processingRef.value) return
if (error instanceof Error) {
errorMessage.value = error.message
validationResult.value = { isValid: false, message: t('tools.json_formatter.json_invalid') }
}
} finally {
isLoading.value = false
processingRef.value = false
}
}, 0)
}
// 切换压缩/美化
const toggleCompression = () => {
isCompressed.value = !isCompressed.value
formatJson(jsonInput.value, isCompressed.value)
}
// 切换折叠功能
const toggleFoldable = () => {
isFoldable.value = !isFoldable.value
}
// 取消格式化操作
const cancelFormatting = () => {
processingRef.value = false
isLoading.value = false
}
// 移除JSON中的转义斜杠
const removeSlashes = () => {
if (!jsonInput.value) return
try {
const processed = jsonInput.value.replace(/\\\//g, '/')
if (processed === jsonInput.value) {
console.log('没有检测到需要替换的内容')
return
}
jsonInput.value = processed
setTimeout(() => formatJson(processed, isCompressed.value), 100)
} catch (error) {
console.error('移除斜杠处理失败:', error)
}
}
// 字符串转义
const escapeString = () => {
if (!jsonInput.value) return
try {
const processed = jsonInput.value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/\f/g, '\\f')
.replace(/\b/g, '\\b')
if (processed === jsonInput.value) {
console.log('没有检测到需要转义的内容')
return
}
jsonInput.value = processed
} catch (error) {
console.error('字符串转义处理失败:', error)
}
}
// 字符串反转义
const unescapeString = () => {
if (!jsonInput.value) return
try {
const processed = jsonInput.value
.replace(/\\"/g, '"')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\f/g, '\f')
.replace(/\\b/g, '\b')
.replace(/\\\\/g, '\\')
if (processed === jsonInput.value) {
console.log('没有检测到需要反转义的内容')
return
}
jsonInput.value = processed
} catch (error) {
console.error('字符串反转义处理失败:', error)
}
}
// 复制结果到剪贴板
const copyToClipboard = async () => {
if (!jsonOutput.value) return
try {
await navigator.clipboard.writeText(jsonOutput.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 清空输入
const clearInput = () => {
if (isLoading.value) {
cancelFormatting()
}
jsonInput.value = ''
jsonOutput.value = ''
errorMessage.value = ''
validationResult.value = { isValid: false, message: '' }
pathResult.value = ''
nextTick(() => {
jsonInputRef.value?.focus()
})
}
// 处理输入变化
const handleInputChange = () => {
if (validationResult.value.message) {
validationResult.value = { isValid: false, message: '' }
errorMessage.value = ''
}
}
// 处理失焦事件
const handleBlur = () => {
if (jsonInput.value && !isLoading.value) {
formatJson(jsonInput.value, isCompressed.value)
}
}
// 处理粘贴事件
const handlePaste = async (e: Event) => {
const clipboardEvent = e as ClipboardEvent
const pastedText = clipboardEvent.clipboardData?.getData('text')
if (pastedText && pastedText.trim().length > 0) {
if (isLoading.value) {
cancelFormatting()
}
jsonInput.value = pastedText
isLoading.value = true
processingRef.value = true
setTimeout(() => formatJson(pastedText, isCompressed.value), 100)
}
}
// 路径查询变化
const queryJsonPath = () => {
if (!jsonPath.value || !jsonOutput.value) {
pathResult.value = ''
return
}
try {
const parsed = JSON.parse(jsonOutput.value)
const result = getValueByPath(parsed, jsonPath.value)
if (typeof result === 'object' && result !== null) {
pathResult.value = JSON.stringify(result, null, 2)
} else {
pathResult.value = String(result)
}
} catch (error) {
pathResult.value = `查询错误: ${error instanceof Error ? error.message : '未知错误'}`
}
}
// 通过路径获取值的辅助函数
const getValueByPath = (obj: any, path: string): any => {
const segments = path
.replace(/\[(\w+)\]/g, '.$1')
.replace(/^\./, '')
.split('.')
let result = obj
for (const segment of segments) {
if (typeof result === 'object' && result !== null && segment in result) {
result = result[segment]
} else {
throw new Error(`路径 '${path}' 不存在`)
}
}
return result
}
// 加载示例JSON
const loadExample = () => {
if (isLoading.value) {
cancelFormatting()
}
const example = {
name: "极速箱",
version: "1.0.0",
description: "高效开发工具集成平台",
author: {
name: "JiSuXiang开发团队",
email: "support@jisuxiang.com"
},
features: [
"JSON格式化与验证",
"时间戳转换",
"编码转换工具",
"正则表达式测试"
],
statistics: {
tools: 24,
users: 100000,
rating: 4.9
},
isOpenSource: true,
lastUpdate: "2024-12-01T08:00:00Z"
}
const exampleJson = JSON.stringify(example)
jsonInput.value = exampleJson
formatJson(exampleJson, isCompressed.value)
}
// 重新格式化
const reformat = () => {
if (isLoading.value) {
cancelFormatting()
}
formatJson(jsonInput.value, isCompressed.value)
}
// 历史记录相关函数
const openSaveModal = () => {
savingTitle.value = `JSON ${new Date().toLocaleString()}`
isSaveModalOpen.value = true
}
const closeSaveModal = () => {
isSaveModalOpen.value = false
editingItem.value = null
savingTitle.value = ''
}
const openHistory = () => {
isHistoryOpen.value = true
}
const closeHistory = () => {
isHistoryOpen.value = false
}
const saveToHistory = () => {
if (!jsonOutput.value || !savingTitle.value.trim()) return
if (editingItem.value) {
// 更新现有项目
const updatedItem = {
...editingItem.value,
title: savingTitle.value,
json: jsonOutput.value,
timestamp: Date.now()
}
const index = historyItems.value.findIndex(item => item.id === editingItem.value!.id)
if (index !== -1) {
historyItems.value[index] = updatedItem
}
} else {
// 创建新项目
const newItem: JsonHistoryItem = {
id: Date.now().toString(),
title: savingTitle.value,
json: jsonOutput.value,
timestamp: Date.now()
}
historyItems.value.unshift(newItem)
}
saveHistoryToLocalStorage()
closeSaveModal()
}
const loadFromHistory = (item: JsonHistoryItem) => {
jsonInput.value = item.json
formatJson(item.json, isCompressed.value)
closeHistory()
}
const deleteHistoryItem = (id: string) => {
historyItems.value = historyItems.value.filter(item => item.id !== id)
saveHistoryToLocalStorage()
}
const startEditingTitle = (item: JsonHistoryItem) => {
editingItem.value = item
savingTitle.value = item.title
isSaveModalOpen.value = true
}
const toggleFavorite = (id: string) => {
const item = historyItems.value.find(item => item.id === id)
if (item) {
item.isFavorite = !item.isFavorite
saveHistoryToLocalStorage()
}
}
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString()
}
// 简化的JSON树形视图组件
const JsonTreeView = ({ data }: { data: any }) => {
// 这里应该是一个简化的树形视图实现
// 为了保持代码简洁我们暂时返回普通的JSON字符串
return JSON.stringify(data, null, 2)
}
</script>
<style scoped>
.json-viewer {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.95rem;
line-height: 1.6;
}
.history-item {
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid;
border-color: rgba(var(--color-primary), 0.15);
background-color: rgba(var(--color-bg-secondary), 0.5);
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.history-item:hover {
border-color: rgba(var(--color-primary), 0.4);
background-color: rgba(var(--color-bg-secondary), 0.8);
}
</style>

View File

@ -0,0 +1,525 @@
<template>
<div class="space-y-6">
<!-- JWT 输入区域 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-primary">JWT 解码器</h2>
<div class="flex gap-2">
<button @click="loadExample" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'sync']" class="mr-1" />
示例
</button>
<button @click="clearAll" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'eraser']" class="mr-1" />
清空
</button>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary font-medium mb-2">JWT Token</label>
<textarea
v-model="jwtToken"
class="textarea-field h-24 w-full resize-none font-mono text-sm"
placeholder="粘贴你的 JWT token 到这里..."
/>
</div>
<!-- 错误和成功消息 -->
<div v-if="error" class="p-3 bg-red-900/20 border border-red-700/30 text-error rounded-lg">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" class="mr-2" />
{{ error }}
</div>
<div v-if="success" class="p-3 bg-green-900/20 border border-green-700/30 text-green-400 rounded-lg">
<FontAwesomeIcon :icon="['fas', 'check']" class="mr-2" />
{{ success }}
</div>
</div>
</div>
<!-- JWT 信息展示 -->
<div v-if="decodedJwt" class="space-y-6">
<!-- Token 状态信息 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">Token 状态</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- 过期状态 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">过期状态</div>
<div :class="getExpirationStatusClass()">
{{ getExpirationStatusText() }}
</div>
<div v-if="decodedJwt.expiresIn" class="text-xs text-tertiary mt-1">
剩余: {{ decodedJwt.expiresIn }}
</div>
</div>
<!-- 算法 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">签名算法</div>
<div class="text-sm font-medium text-primary">
{{ decodedJwt.header.alg }}
</div>
</div>
<!-- 类型 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">Token 类型</div>
<div class="text-sm font-medium text-primary">
{{ decodedJwt.header.typ }}
</div>
</div>
</div>
</div>
<!-- Token 可视化 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-md font-medium text-primary">Token 结构</h3>
<button
@click="showTokenParts = !showTokenParts"
class="btn-secondary text-sm"
>
{{ showTokenParts ? '隐藏结构' : '显示结构' }}
</button>
</div>
<div v-if="showTokenParts" class="space-y-3">
<div class="text-xs text-tertiary">JWT由三部分组成"."分隔</div>
<div class="grid grid-cols-1 gap-2 font-mono text-xs">
<div class="flex items-center gap-2">
<span class="bg-blue-500 text-white px-2 py-1 rounded">Header</span>
<span class="text-secondary break-all">{{ getTokenParts().header }}</span>
</div>
<div class="flex items-center gap-2">
<span class="bg-purple-500 text-white px-2 py-1 rounded">Payload</span>
<span class="text-secondary break-all">{{ getTokenParts().payload }}</span>
</div>
<div class="flex items-center gap-2">
<span class="bg-green-500 text-white px-2 py-1 rounded">Signature</span>
<span class="text-secondary break-all">{{ getTokenParts().signature }}</span>
</div>
</div>
</div>
</div>
<!-- JWT 内容标签页 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex border-b border-gray-200">
<button
v-for="tab in tabs"
:key="tab"
:class="[
'px-4 py-2 font-medium text-sm transition-colors',
activeTab === tab
? 'text-primary border-b-2 border-primary'
: 'text-tertiary hover:text-secondary'
]"
@click="activeTab = tab"
>
{{ getTabLabel(tab) }}
</button>
</div>
<button
@click="copyToClipboard"
class="btn-secondary text-sm"
>
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<!-- 内容显示 -->
<div class="bg-block border border-gray-200 rounded-lg">
<pre class="h-80 p-4 overflow-auto text-sm font-mono whitespace-pre-wrap">{{ getCurrentTabContent() }}</pre>
</div>
</div>
<!-- Payload 关键信息 -->
<div v-if="activeTab === 'payload'" class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">Payload 关键信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 标准声明 -->
<div v-if="hasStandardClaims()" class="space-y-3">
<h4 class="text-sm font-medium text-secondary">标准声明</h4>
<div v-if="decodedJwt.payload.sub" class="bg-block rounded-lg p-3">
<div class="text-xs text-tertiary">Subject (sub)</div>
<div class="text-sm text-primary font-mono">{{ decodedJwt.payload.sub }}</div>
</div>
<div v-if="decodedJwt.payload.iat" class="bg-block rounded-lg p-3">
<div class="text-xs text-tertiary">Issued At (iat)</div>
<div class="text-sm text-primary">{{ formatTimestamp(decodedJwt.payload.iat) }}</div>
</div>
<div v-if="decodedJwt.payload.exp" class="bg-block rounded-lg p-3">
<div class="text-xs text-tertiary">Expiration (exp)</div>
<div class="text-sm text-primary">{{ formatTimestamp(decodedJwt.payload.exp) }}</div>
</div>
</div>
<!-- 自定义声明 -->
<div class="space-y-3">
<h4 class="text-sm font-medium text-secondary">自定义声明</h4>
<div class="space-y-2">
<div
v-for="[key, value] in getCustomClaims()"
:key="key"
class="bg-block rounded-lg p-3"
>
<div class="text-xs text-tertiary">{{ key }}</div>
<div class="text-sm text-primary font-mono break-all">
{{ formatValue(value) }}
</div>
</div>
</div>
<div v-if="getCustomClaims().length === 0" class="text-sm text-tertiary text-center py-4">
暂无自定义声明
</div>
</div>
</div>
</div>
</div>
<!-- 使用说明 -->
<div v-if="!decodedJwt" class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mr-2" />
使用说明
</h3>
<div class="text-sm text-secondary space-y-2">
<p> JWTJSON Web Token是一种开放标准RFC 7519用于在各方之间安全地传输信息</p>
<p> JWT由三部分组成Header头部Payload载荷和Signature签名</p>
<p> 这个工具只解码JWT内容不验证签名的有效性</p>
<p> 请不要在这里输入包含敏感信息的生产环境JWT</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
// JWT 结构接口
interface JwtPayload {
exp?: number
iat?: number
sub?: string
[key: string]: unknown
}
interface JwtHeader {
alg: string
typ: string
[key: string]: unknown
}
interface DecodedJwt {
header: JwtHeader
payload: JwtPayload
signature: string
isValid: boolean
expirationStatus: 'valid' | 'expired' | 'not-set'
expiresIn?: string
}
// 响应式状态
const jwtToken = ref('')
const decodedJwt = ref<DecodedJwt | null>(null)
const activeTab = ref<'header' | 'payload' | 'signature'>('payload')
const error = ref('')
const success = ref('')
const copied = ref(false)
const showTokenParts = ref(false)
// 标签页选项
const tabs: ('header' | 'payload' | 'signature')[] = ['header', 'payload', 'signature']
// 标准JWT声明字段
const standardClaims = ['iss', 'sub', 'aud', 'exp', 'nbf', 'iat', 'jti']
// 获取标签页标签
const getTabLabel = (tab: 'header' | 'payload' | 'signature'): string => {
const labels = {
header: 'Header (头部)',
payload: 'Payload (载荷)',
signature: 'Signature (签名)'
}
return labels[tab]
}
// 获取当前标签页内容
const getCurrentTabContent = (): string => {
if (!decodedJwt.value) return ''
switch (activeTab.value) {
case 'header':
return JSON.stringify(decodedJwt.value.header, null, 2)
case 'payload':
return JSON.stringify(decodedJwt.value.payload, null, 2)
case 'signature':
return decodedJwt.value.signature
default:
return ''
}
}
// 获取过期状态样式类
const getExpirationStatusClass = (): string => {
if (!decodedJwt.value) return ''
const status = decodedJwt.value.expirationStatus
if (status === 'valid') return 'px-2 py-1 rounded-md text-xs bg-green-900/20 text-green-400'
if (status === 'expired') return 'px-2 py-1 rounded-md text-xs bg-red-900/20 text-error'
return 'px-2 py-1 rounded-md text-xs bg-gray-500/20 text-tertiary'
}
// 获取过期状态文本
const getExpirationStatusText = (): string => {
if (!decodedJwt.value) return ''
const status = decodedJwt.value.expirationStatus
if (status === 'valid') return '有效'
if (status === 'expired') return '已过期'
return '未设置过期时间'
}
// 检查是否有标准声明
const hasStandardClaims = (): boolean => {
if (!decodedJwt.value?.payload) return false
return standardClaims.some(claim =>
decodedJwt.value?.payload[claim] !== undefined
)
}
// 获取自定义声明
const getCustomClaims = (): [string, unknown][] => {
if (!decodedJwt.value?.payload) return []
return Object.entries(decodedJwt.value.payload).filter(
([key]) => !standardClaims.includes(key)
)
}
// 获取Token各部分
const getTokenParts = () => {
if (!jwtToken.value) return { header: '', payload: '', signature: '' }
const parts = jwtToken.value.split('.')
return {
header: parts[0] || '',
payload: parts[1] || '',
signature: parts[2] || ''
}
}
// 格式化时间戳
const formatTimestamp = (timestamp: number): string => {
const date = new Date(timestamp * 1000)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 格式化值
const formatValue = (value: unknown): string => {
if (typeof value === 'string') return value
if (typeof value === 'number') return value.toString()
if (typeof value === 'boolean') return value.toString()
return JSON.stringify(value)
}
// Base64 URL解码
const base64UrlDecode = (str: string): string => {
// 替换URL安全Base64字符为标准Base64
let base64 = str.replace(/-/g, '+').replace(/_/g, '/')
// 添加填充字符
while (base64.length % 4) {
base64 += '='
}
try {
// 解码
return decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
)
} catch {
throw new Error('无效的Base64编码')
}
}
// 计算剩余时间
const getTimeRemaining = (expirationDate: Date): string => {
const now = new Date()
const diff = expirationDate.getTime() - now.getTime()
if (diff <= 0) {
return '已过期'
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
let timeStr = ''
if (days > 0) timeStr += `${days}`
if (hours > 0 || days > 0) timeStr += `${hours}小时 `
if (minutes > 0 || hours > 0 || days > 0) timeStr += `${minutes}分钟 `
timeStr += `${seconds}`
return timeStr
}
// 解码JWT令牌
const decodeJwt = (token: string): DecodedJwt => {
if (!token) {
throw new Error('Token为空')
}
const parts = token.split('.')
if (parts.length !== 3) {
throw new Error('无效的JWT格式应包含三个部分')
}
try {
// 解码header和payload
const header = JSON.parse(base64UrlDecode(parts[0])) as JwtHeader
const payload = JSON.parse(base64UrlDecode(parts[1])) as JwtPayload
const signature = parts[2]
// 计算过期状态
let expirationStatus: 'valid' | 'expired' | 'not-set' = 'not-set'
let expiresIn: string | undefined
if (payload.exp) {
const expiration = new Date(payload.exp * 1000)
const now = new Date()
if (expiration > now) {
expirationStatus = 'valid'
expiresIn = getTimeRemaining(expiration)
} else {
expirationStatus = 'expired'
}
}
// 简单验证
const isValid = parts.length === 3 && !!parts[2]
return {
header,
payload,
signature,
isValid,
expirationStatus,
expiresIn
}
} catch {
throw new Error('解析失败无效的JWT内容')
}
}
// 复制当前标签内容到剪贴板
const copyToClipboard = async () => {
if (!decodedJwt.value) return
try {
const content = getCurrentTabContent()
await navigator.clipboard.writeText(content)
copied.value = true
success.value = '复制成功'
setTimeout(() => {
copied.value = false
success.value = ''
}, 2000)
} catch (err) {
console.error('复制失败:', err)
error.value = '复制失败'
}
}
// 清空所有内容
const clearAll = () => {
jwtToken.value = ''
decodedJwt.value = null
error.value = ''
success.value = ''
copied.value = false
showTokenParts.value = false
}
// 加载示例JWT
const loadExample = () => {
// 创建一个示例JWT不包含敏感信息
const header = { alg: 'HS256', typ: 'JWT' }
const payload = {
sub: 'user123',
name: 'John Doe',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600, // 1小时后过期
role: 'admin'
}
// 简单的Base64URL编码
const base64UrlEncode = (obj: object): string => {
return btoa(JSON.stringify(obj))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
const encodedHeader = base64UrlEncode(header)
const encodedPayload = base64UrlEncode(payload)
const signature = 'example-signature-not-real'
jwtToken.value = `${encodedHeader}.${encodedPayload}.${signature}`
success.value = '示例JWT已加载'
setTimeout(() => {
success.value = ''
}, 2000)
}
// 监听JWT变化并解析
watch(jwtToken, (newToken) => {
if (!newToken.trim()) {
decodedJwt.value = null
error.value = ''
return
}
try {
const result = decodeJwt(newToken)
decodedJwt.value = result
error.value = ''
} catch (err) {
if (err instanceof Error) {
error.value = err.message
} else {
error.value = '解析错误'
}
decodedJwt.value = null
}
})
</script>

View File

@ -0,0 +1,464 @@
<template>
<div class="space-y-6">
<!-- 进制选择 -->
<div class="card p-6">
<h2 class="text-lg font-medium text-primary mb-4">数字进制转换器</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 源进制选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">从进制</label>
<div class="grid grid-cols-2 gap-2 mb-3">
<button
v-for="base in baseOptions"
:key="'from-' + base.id"
:class="[
'px-3 py-2 text-sm rounded transition-all',
fromBase === base.id ? 'bg-primary-500 text-white' : 'btn-secondary'
]"
@click="setFromBase(base.id)"
>
{{ base.name }}
</button>
</div>
<!-- 自定义进制输入 -->
<div v-if="fromBase === 'custom'" class="flex items-center gap-2">
<span class="text-sm text-secondary">自定义进制:</span>
<input
v-model.number="customFromBase"
type="number"
min="2"
max="36"
class="w-20 p-1 bg-block border border-primary/20 rounded text-center input-field"
/>
</div>
</div>
<!-- 目标进制选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">到进制</label>
<div class="grid grid-cols-2 gap-2 mb-3">
<button
v-for="base in baseOptions"
:key="'to-' + base.id"
:class="[
'px-3 py-2 text-sm rounded transition-all',
toBase === base.id ? 'bg-primary-500 text-white' : 'btn-secondary'
]"
@click="setToBase(base.id)"
>
{{ base.name }}
</button>
</div>
<!-- 自定义进制输入 -->
<div v-if="toBase === 'custom'" class="flex items-center gap-2">
<span class="text-sm text-secondary">自定义进制:</span>
<input
v-model.number="customToBase"
type="number"
min="2"
max="36"
class="w-20 p-1 bg-block border border-primary/20 rounded text-center input-field"
/>
</div>
</div>
</div>
</div>
<!-- 输入和输出区域 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-md font-medium text-primary">输入数字</h3>
<div class="flex gap-2">
<button @click="loadExample" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'sync-alt']" class="mr-1" />
示例
</button>
<button @click="clearAll" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'eraser']" class="mr-1" />
清空
</button>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary font-medium mb-2">
{{ getBaseLabel(getCurrentFromBase()) }} 数字
</label>
<textarea
v-model="inputValue"
class="textarea-field h-32 w-full resize-y font-mono"
:placeholder="getInputPlaceholder()"
/>
</div>
<!-- 错误消息 -->
<div v-if="error" class="p-3 bg-red-900/20 border border-red-700/30 text-error rounded-lg">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" class="mr-2" />
{{ error }}
</div>
</div>
</div>
<!-- 输出区域 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-md font-medium text-primary">转换结果</h3>
<button
v-if="outputValue"
@click="copyToClipboard"
class="btn-secondary text-sm"
>
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary font-medium mb-2">
{{ getBaseLabel(getCurrentToBase()) }} 数字
</label>
<textarea
v-model="outputValue"
readonly
class="textarea-field h-32 w-full resize-y font-mono bg-block"
placeholder="转换结果将在这里显示..."
/>
</div>
<!-- 转换信息 -->
<div v-if="outputValue" class="text-sm text-tertiary">
<div>十进制值: {{ getDecimalValue() }}</div>
<div>字符长度: {{ outputValue.length }}</div>
</div>
</div>
</div>
</div>
<!-- 高级选项 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-md font-medium text-primary">高级选项</h3>
<button
@click="showAdvancedOptions = !showAdvancedOptions"
class="btn-secondary text-sm"
>
<FontAwesomeIcon :icon="['fas', 'cog']" class="mr-1" />
{{ showAdvancedOptions ? '隐藏选项' : '显示选项' }}
</button>
</div>
<div v-if="showAdvancedOptions" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<label class="flex items-center">
<input
v-model="useUppercase"
type="checkbox"
class="w-4 h-4 text-primary bg-block rounded border-primary/20 focus:ring-primary focus:ring-opacity-25"
/>
<span class="ml-2 text-sm text-secondary">使用大写字母</span>
</label>
<label class="flex items-center">
<input
v-model="addPrefix"
type="checkbox"
class="w-4 h-4 text-primary bg-block rounded border-primary/20 focus:ring-primary focus:ring-opacity-25"
/>
<span class="ml-2 text-sm text-secondary">添加进制前缀</span>
</label>
<label class="flex items-center">
<input
v-model="groupDigits"
type="checkbox"
class="w-4 h-4 text-primary bg-block rounded border-primary/20 focus:ring-primary focus:ring-opacity-25"
/>
<span class="ml-2 text-sm text-secondary">数字分组</span>
</label>
</div>
</div>
<!-- 快速转换面板 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">快速转换</h3>
<div v-if="inputValue && !error" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div
v-for="quickBase in quickBases"
:key="quickBase.id"
class="bg-block rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-medium text-secondary">{{ quickBase.name }}</h4>
<button
@click="() => copyQuickResult(quickBase.id)"
class="text-tertiary hover:text-primary transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'copy']" />
</button>
</div>
<code class="text-sm text-primary font-mono break-all">
{{ getQuickConversion(quickBase.id) }}
</code>
</div>
</div>
<div v-else class="text-center py-8 text-tertiary">
请输入有效数字以查看快速转换结果
</div>
</div>
<!-- 使用说明 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mr-2" />
使用说明
</h3>
<div class="text-sm text-secondary space-y-2">
<p> 支持 2-36 进制之间的任意转换</p>
<p> 可以输入带前缀的数字 0x, 0b, 0o</p>
<p> 支持大写/小写字母添加前缀数字分组等格式选项</p>
<p> 十六进制以上的进制使用字母 A-Z 表示 10-35</p>
<p> 输入时可以使用空格或下划线分隔系统会自动忽略</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
// 响应式状态
const inputValue = ref('')
const outputValue = ref('')
const fromBase = ref('10')
const toBase = ref('2')
const customFromBase = ref(10)
const customToBase = ref(2)
const error = ref('')
const copied = ref(false)
const showAdvancedOptions = ref(false)
const useUppercase = ref(true)
const addPrefix = ref(false)
const groupDigits = ref(false)
// 进制选项
const baseOptions = [
{ id: '2', name: '二进制' },
{ id: '8', name: '八进制' },
{ id: '10', name: '十进制' },
{ id: '16', name: '十六进制' },
{ id: 'custom', name: '自定义' }
]
// 快速转换进制
const quickBases = [
{ id: '2', name: '二进制' },
{ id: '8', name: '八进制' },
{ id: '10', name: '十进制' },
{ id: '16', name: '十六进制' }
]
// 获取当前源进制
const getCurrentFromBase = (): number => {
return fromBase.value === 'custom' ? customFromBase.value : parseInt(fromBase.value)
}
// 获取当前目标进制
const getCurrentToBase = (): number => {
return toBase.value === 'custom' ? customToBase.value : parseInt(toBase.value)
}
// 获取进制标签
const getBaseLabel = (base: number): string => {
const labels: Record<number, string> = {
2: '二进制',
8: '八进制',
10: '十进制',
16: '十六进制'
}
return labels[base] || `${base}进制`
}
// 获取输入提示
const getInputPlaceholder = (): string => {
const base = getCurrentFromBase()
if (base === 2) return '例如: 1010, 0b1010'
if (base === 8) return '例如: 755, 0o755'
if (base === 10) return '例如: 42, 255'
if (base === 16) return '例如: FF, 0xFF'
return `请输入${base}进制数字...`
}
// 获取十进制值
const getDecimalValue = (): string => {
if (!inputValue.value.trim() || error.value) return '-'
try {
const cleanValue = inputValue.value.replace(/^0[bxo]|[\s_]/gi, '')
const decimal = parseInt(cleanValue, getCurrentFromBase())
return isNaN(decimal) ? '-' : decimal.toString()
} catch {
return '-'
}
}
// 进制转换函数
const convertBase = (value: string, from: number, to: number): string => {
// 验证进制范围
if (from < 2 || from > 36 || to < 2 || to > 36) {
throw new Error('进制范围必须在 2-36 之间')
}
// 移除输入中可能存在的前缀和格式化字符
const cleanValue = value.replace(/^0[bxo]|[\s_]/gi, '')
// 转换为十进制
let decimalValue
try {
decimalValue = parseInt(cleanValue, from)
if (isNaN(decimalValue)) {
throw new Error()
}
} catch {
throw new Error('输入的数字格式无效')
}
// 转换为目标进制
let result = decimalValue.toString(to)
// 大写字母
if (useUppercase.value && to > 10) {
result = result.toUpperCase()
}
// 添加前缀
if (addPrefix.value) {
if (to === 2) result = '0b' + result
else if (to === 8) result = '0o' + result
else if (to === 16) result = '0x' + result
}
// 数字分组
if (groupDigits.value) {
const prefix = result.match(/^0[bxo]/i)?.[0] || ''
const digits = result.replace(/^0[bxo]/i, '')
let grouped = ''
if (to === 2) {
// 二进制每8位分组
grouped = digits.match(/.{1,8}/g)?.join('_') || digits
} else if (to === 16) {
// 十六进制每4位分组
grouped = digits.match(/.{1,4}/g)?.join('_') || digits
} else {
// 其他进制每4位分组
grouped = digits.match(/.{1,4}/g)?.join('_') || digits
}
result = prefix + grouped
}
return result
}
// 获取快速转换结果
const getQuickConversion = (baseId: string): string => {
if (!inputValue.value.trim() || error.value) return '-'
try {
return convertBase(inputValue.value, getCurrentFromBase(), parseInt(baseId))
} catch {
return '错误'
}
}
// 设置源进制
const setFromBase = (base: string) => {
fromBase.value = base
}
// 设置目标进制
const setToBase = (base: string) => {
toBase.value = base
}
// 复制输出内容
const copyToClipboard = async () => {
if (!outputValue.value) return
try {
await navigator.clipboard.writeText(outputValue.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
console.error('复制失败:', err)
error.value = '复制失败'
}
}
// 复制快速转换结果
const copyQuickResult = async (baseId: string) => {
const result = getQuickConversion(baseId)
if (result === '-' || result === '错误') return
try {
await navigator.clipboard.writeText(result)
} catch (err) {
console.error('复制失败:', err)
}
}
// 清空所有内容
const clearAll = () => {
inputValue.value = ''
outputValue.value = ''
error.value = ''
}
// 加载示例
const loadExample = () => {
const examples: Record<number, string> = {
2: '1010',
8: '755',
10: '42',
16: 'FF'
}
const currentFromBase = getCurrentFromBase()
const example = examples[currentFromBase] || examples[10]
inputValue.value = example
}
// 监听输入变化并自动转换
watch([inputValue, fromBase, toBase, customFromBase, customToBase, useUppercase, addPrefix, groupDigits], () => {
if (inputValue.value.trim() === '') {
outputValue.value = ''
error.value = ''
return
}
try {
const result = convertBase(inputValue.value, getCurrentFromBase(), getCurrentToBase())
outputValue.value = result
error.value = ''
} catch (err) {
if (err instanceof Error) {
error.value = err.message
} else {
error.value = '转换错误'
}
outputValue.value = ''
}
})
</script>

View File

@ -0,0 +1,289 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="generateQRCode"
:disabled="!qrText.trim() || isGenerating"
class="btn-primary"
>
<FontAwesomeIcon
:icon="isGenerating ? ['fas', 'spinner'] : ['fas', 'qrcode']"
:class="['mr-2', isGenerating && 'animate-spin']"
/>
{{ t('tools.qrcode_generator.generate') }}
</button>
<button
@click="downloadQRCode"
:disabled="!qrCodeDataUrl"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
{{ t('tools.qrcode_generator.download') }}
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.qrcode_generator.clear') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入配置区域 -->
<div class="space-y-6">
<!-- 文本输入 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.qrcode_generator.text_input') }}</h3>
<textarea
v-model="qrText"
:placeholder="t('tools.qrcode_generator.placeholder')"
class="textarea-field h-32"
@input="handleTextChange"
/>
<div class="text-sm text-secondary mt-2">
字符数: {{ qrText.length }}
</div>
</div>
<!-- 配置选项 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.qrcode_generator.settings') }}</h3>
<div class="space-y-4">
<!-- 尺寸 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.qrcode_generator.size') }}: {{ qrSize }}px
</label>
<input
v-model="qrSize"
type="range"
min="100"
max="800"
step="50"
class="w-full"
>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>100px</span>
<span>800px</span>
</div>
</div>
<!-- 容错级别 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.qrcode_generator.error_level') }}
</label>
<select v-model="errorLevel" class="select-field">
<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>
</div>
<!-- 前景色 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.qrcode_generator.foreground_color') }}
</label>
<div class="flex space-x-2">
<input
v-model="foregroundColor"
type="color"
class="w-12 h-10 rounded border border-primary border-opacity-20"
>
<input
v-model="foregroundColor"
type="text"
class="input-field flex-1"
placeholder="#000000"
>
</div>
</div>
<!-- 背景色 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.qrcode_generator.background_color') }}
</label>
<div class="flex space-x-2">
<input
v-model="backgroundColor"
type="color"
class="w-12 h-10 rounded border border-primary border-opacity-20"
>
<input
v-model="backgroundColor"
type="text"
class="input-field flex-1"
placeholder="#FFFFFF"
>
</div>
</div>
</div>
</div>
</div>
<!-- 预览区域 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.qrcode_generator.preview') }}</h3>
<div class="flex justify-center items-center min-h-[300px]">
<div v-if="qrCodeDataUrl" class="text-center">
<img
:src="qrCodeDataUrl"
:alt="t('tools.qrcode_generator.qr_code')"
class="mx-auto mb-4 rounded border border-primary border-opacity-20"
:style="{ maxWidth: '100%', height: 'auto' }"
>
<div class="text-sm text-secondary">
{{ qrSize }}x{{ qrSize }}px
</div>
</div>
<div v-else-if="isGenerating" class="text-center">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<div class="text-secondary">{{ t('tools.qrcode_generator.generating') }}</div>
</div>
<div v-else class="text-center">
<FontAwesomeIcon :icon="['fas', 'qrcode']" class="text-6xl text-tertiary mb-4" />
<div class="text-secondary">{{ t('tools.qrcode_generator.no_preview') }}</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
import QRCode from 'qrcode'
const { t } = useLanguage()
// 响应式状态
const qrText = ref('')
const qrSize = ref(300)
const errorLevel = ref('M')
const foregroundColor = ref('#000000')
const backgroundColor = ref('#FFFFFF')
const qrCodeDataUrl = ref('')
const isGenerating = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 生成二维码
const generateQRCode = async () => {
if (!qrText.value.trim()) {
showStatus('请输入要生成二维码的内容', 'error')
return
}
isGenerating.value = true
statusMessage.value = ''
try {
const options = {
width: qrSize.value,
height: qrSize.value,
errorCorrectionLevel: errorLevel.value as 'L' | 'M' | 'Q' | 'H',
color: {
dark: foregroundColor.value,
light: backgroundColor.value
},
margin: 2
}
const dataUrl = await QRCode.toDataURL(qrText.value, options)
qrCodeDataUrl.value = dataUrl
showStatus('二维码生成成功', 'success')
} catch (error) {
console.error('生成二维码失败:', error)
showStatus('生成二维码失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
qrCodeDataUrl.value = ''
} finally {
isGenerating.value = false
}
}
// 下载二维码
const downloadQRCode = () => {
if (!qrCodeDataUrl.value) return
try {
const link = document.createElement('a')
link.download = `qrcode-${Date.now()}.png`
link.href = qrCodeDataUrl.value
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
showStatus('二维码下载成功', 'success')
} catch (error) {
console.error('下载失败:', error)
showStatus('下载失败', 'error')
}
}
// 清除所有内容
const clearAll = () => {
qrText.value = ''
qrCodeDataUrl.value = ''
statusMessage.value = ''
}
// 处理文本变化
const handleTextChange = () => {
// 文本变化时清除状态
if (statusMessage.value) {
statusMessage.value = ''
}
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 防抖重新生成
let regenerateTimer: number | null = null
const debouncedRegenerate = () => {
if (regenerateTimer) {
clearTimeout(regenerateTimer)
}
regenerateTimer = window.setTimeout(() => {
if (qrCodeDataUrl.value && qrText.value.trim()) {
generateQRCode()
}
}, 500)
}
// 监听配置变化,自动重新生成
watch([qrSize, errorLevel, foregroundColor, backgroundColor], debouncedRegenerate)
</script>

View File

@ -0,0 +1,404 @@
<template>
<div class="space-y-6">
<!-- 主内容区 -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- 左侧面板 - 常用示例和选项 -->
<div class="lg:col-span-1 space-y-6">
<!-- 常用示例 -->
<div class="card p-4">
<h2 class="text-md font-medium text-primary mb-4">常用示例</h2>
<div class="space-y-2">
<button
v-for="(example, index) in examples"
:key="index"
class="text-left w-full px-3 py-2 rounded-md text-sm text-secondary hover:bg-hover transition-colors"
@click="() => applyExample(example)"
>
{{ example.name }}
</button>
</div>
</div>
<!-- 正则选项 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-md font-medium text-primary">选项</h2>
<button
class="text-tertiary hover:text-error transition-colors text-sm"
@click="clearAll"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-1" />
清空
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary mb-2">标志位</label>
<div class="flex flex-wrap gap-2">
<button
:class="flags.includes('g') ? 'btn-primary text-xs' : 'btn-secondary text-xs'"
@click="() => toggleFlag('g')"
>
g (全局)
</button>
<button
:class="flags.includes('i') ? 'btn-primary text-xs' : 'btn-secondary text-xs'"
@click="() => toggleFlag('i')"
>
i (忽略大小写)
</button>
<button
:class="flags.includes('m') ? 'btn-primary text-xs' : 'btn-secondary text-xs'"
@click="() => toggleFlag('m')"
>
m (多行)
</button>
<button
:class="flags.includes('s') ? 'btn-primary text-xs' : 'btn-secondary text-xs'"
@click="() => toggleFlag('s')"
>
s (单行)
</button>
</div>
</div>
<div>
<label class="flex items-center text-sm text-secondary">
<input
v-model="showGroups"
type="checkbox"
class="mr-2"
/>
显示捕获组
</label>
</div>
</div>
</div>
</div>
<!-- 右侧面板 - 测试区域 -->
<div class="lg:col-span-3 space-y-6">
<!-- 正则表达式输入 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-md font-medium text-primary">正则表达式</h2>
<button
class="text-tertiary hover:text-primary transition-colors text-sm"
@click="copyRegex"
:disabled="!regexString"
>
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<div class="relative">
<div class="absolute left-3 top-[13px] text-tertiary">/</div>
<input
v-model="regexString"
type="text"
placeholder="输入正则表达式..."
class="input-field pl-7 pr-14"
/>
<div class="absolute right-14 top-[13px] text-tertiary">/</div>
<input
v-model="flags"
type="text"
placeholder="flags"
class="absolute right-3 top-[13px] w-8 bg-transparent border-none outline-none text-tertiary"
/>
</div>
<div v-if="regexError" class="mt-2 text-sm text-error flex items-center gap-2">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" />
<span>{{ regexError }}</span>
</div>
</div>
<!-- 测试输入 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-md font-medium text-primary">测试文本</h2>
<div class="text-sm text-secondary">
字符数: <span class="text-primary">{{ testString.length }}</span>
</div>
</div>
<textarea
v-model="testString"
placeholder="输入要测试的文本..."
class="textarea-field min-h-[150px] w-full resize-y"
/>
</div>
<!-- 匹配结果 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-md font-medium text-primary">匹配结果</h2>
<div class="text-sm text-secondary">
匹配数量: <span class="text-primary">{{ matchCount }}</span>
</div>
</div>
<div v-if="testString" class="space-y-4">
<!-- 高亮显示的匹配文本 -->
<div class="bg-block rounded-md p-4 whitespace-pre-wrap font-mono text-sm">
<div v-if="matchCount > 0">
<div class="mb-3 text-tertiary text-xs flex items-center justify-between">
<span>
找到 <span class="text-primary font-medium">{{ matchCount }}</span> 个匹配项
</span>
<span class="text-tertiary text-xs">
原文长度: {{ testString.length }} 字符
</span>
</div>
<!-- 使用 v-html 显示高亮结果但要确保安全 -->
<div v-html="highlightedText" class="break-all"></div>
</div>
<span v-else class="text-tertiary">无匹配项</span>
</div>
<!-- 捕获组详情 -->
<div v-if="showGroups && matchCount > 0">
<h3 class="text-sm font-medium text-primary mb-2">捕获组详情</h3>
<div class="space-y-2">
<div
v-for="(match, index) in matches"
:key="index"
class="bg-block rounded-md p-3"
>
<div class="text-xs text-tertiary mb-2">
匹配 #{{ index + 1 }} (位置: {{ match.index }})
</div>
<div class="space-y-1">
<div class="flex items-start gap-2">
<span class="text-xs text-tertiary min-w-[40px]">完整:</span>
<code class="text-sm text-primary-600 bg-primary-100 px-1 rounded break-all">
{{ match[0] || '' }}
</code>
</div>
<div
v-for="group in Math.max(0, match.length - 1)"
:key="group"
class="flex items-start gap-2"
>
<span class="text-xs text-tertiary min-w-[40px]"> {{ group }}:</span>
<code class="text-sm text-primary-600 bg-primary-100 px-1 rounded break-all">
{{ match[group] || '(空)' }}
</code>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex items-center justify-center p-4 text-tertiary">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mr-2" />
请输入测试文本
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
// 定义匹配结果类型
interface MatchResult extends RegExpExecArray {
index: number
}
// 响应式状态
const regexString = ref('')
const flags = ref('g')
const testString = ref('')
const matches = ref<MatchResult[]>([])
const matchCount = ref(0)
const showGroups = ref(true)
const regexError = ref<string | null>(null)
const copied = ref(false)
// 常用正则表达式示例
const examples = [
{
name: '邮箱地址',
pattern: '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}',
flags: 'g',
testText: 'test@example.com, invalid-email, another.email@domain.co.uk'
},
{
name: '手机号码',
pattern: '1[3-9]\\d{9}',
flags: 'g',
testText: '我的手机号是13812345678她的是15987654321座机010-12345678'
},
{
name: 'URL地址',
pattern: 'https?://[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)',
flags: 'g',
testText: '访问 https://www.example.com 或 http://test.org/path?query=1'
},
{
name: 'IP地址',
pattern: '\\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\b',
flags: 'g',
testText: '服务器IP: 192.168.1.1, 公网IP: 8.8.8.8, 错误格式: 999.999.999.999'
},
{
name: '中文字符',
pattern: '[\\u4e00-\\u9fa5]',
flags: 'g',
testText: 'Hello 世界! This is 中文 mixed with English.'
}
]
// HTML转义函数
const escapeHtml = (text: string): string => {
if (!text) return ''
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// 计算高亮文本
const highlightedText = computed(() => {
if (!testString.value || matchCount.value === 0) {
return escapeHtml(testString.value)
}
let result = ''
let lastIndex = 0
// 按索引顺序排序匹配项
const sortedMatches = [...matches.value].sort((a, b) => a.index - b.index)
// 遍历每个匹配项
sortedMatches.forEach(match => {
// 添加匹配前的文本
result += escapeHtml(testString.value.substring(lastIndex, match.index))
// 添加高亮的匹配内容
result += `<span style="background-color:rgba(var(--color-primary), 0.3); color:rgb(var(--color-primary)); font-weight:bold; padding:0 4px; border-radius:3px;">${escapeHtml(match[0])}</span>`
// 更新lastIndex
lastIndex = match.index + match[0].length
})
// 添加最后一个匹配后的文本
if (lastIndex < testString.value.length) {
result += escapeHtml(testString.value.substring(lastIndex))
}
return result
})
// 测试正则表达式
const testRegex = () => {
if (!regexString.value || !testString.value) {
matches.value = []
matchCount.value = 0
regexError.value = null
return
}
try {
// 验证正则表达式是否有效
new RegExp(regexString.value, flags.value)
regexError.value = null
if (flags.value.includes('g')) {
// 获取所有匹配
const allMatches: MatchResult[] = []
let match: RegExpExecArray | null
const regexWithGroups = new RegExp(regexString.value, flags.value)
// 收集所有匹配和捕获组
while ((match = regexWithGroups.exec(testString.value)) !== null) {
allMatches.push(match as MatchResult)
// 防止无限循环如果匹配长度为0手动增加索引
if (match.index === regexWithGroups.lastIndex) {
regexWithGroups.lastIndex++
}
}
matches.value = allMatches
matchCount.value = allMatches.length
} else {
// 单次匹配模式
const regexWithoutG = new RegExp(regexString.value, flags.value.replace('g', ''))
const execMatch = regexWithoutG.exec(testString.value)
if (execMatch) {
matches.value = [execMatch as MatchResult]
matchCount.value = 1
} else {
matches.value = []
matchCount.value = 0
}
}
} catch (error) {
console.error('正则表达式错误:', error)
regexError.value = (error as Error).message
matches.value = []
matchCount.value = 0
}
}
// 复制正则表达式
const copyRegex = async () => {
if (!regexString.value) return
try {
const regexText = `/${regexString.value}/${flags.value}`
await navigator.clipboard.writeText(regexText)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 应用示例
const applyExample = (example: { pattern: string; flags: string; testText: string }) => {
regexString.value = example.pattern
flags.value = example.flags
testString.value = example.testText
}
// 清空所有内容
const clearAll = () => {
regexString.value = ''
flags.value = 'g'
testString.value = ''
matches.value = []
matchCount.value = 0
regexError.value = null
}
// 切换标志位
const toggleFlag = (flag: string) => {
if (flags.value.includes(flag)) {
flags.value = flags.value.replace(flag, '')
} else {
flags.value = flags.value + flag
}
}
// 监听输入变化,自动测试
watch([regexString, flags, testString, showGroups], () => {
testRegex()
}, { immediate: true })
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,299 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="clearText"
:disabled="!text.trim()"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.text_counter.clear') }}
</button>
<button
@click="pasteFromClipboard"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" class="mr-2" />
{{ t('tools.text_counter.paste') }}
</button>
<button
@click="loadSample"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'file-alt']" class="mr-2" />
{{ t('tools.text_counter.sample') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 文本输入区域 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.text_counter.text_input') }}</h3>
<textarea
v-model="text"
:placeholder="t('tools.text_counter.placeholder')"
class="textarea-field"
style="height: 400px; resize: vertical;"
@input="handleTextChange"
/>
</div>
<!-- 统计结果区域 -->
<div class="space-y-4">
<!-- 基础统计 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.text_counter.basic_stats') }}</h3>
<div class="grid grid-cols-2 gap-4">
<div class="stat-item">
<div class="stat-value">{{ stats.characters }}</div>
<div class="stat-label">{{ t('tools.text_counter.characters') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.charactersNoSpaces }}</div>
<div class="stat-label">{{ t('tools.text_counter.characters_no_spaces') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.words }}</div>
<div class="stat-label">{{ t('tools.text_counter.words') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.lines }}</div>
<div class="stat-label">{{ t('tools.text_counter.lines') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.paragraphs }}</div>
<div class="stat-label">{{ t('tools.text_counter.paragraphs') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.sentences }}</div>
<div class="stat-label">{{ t('tools.text_counter.sentences') }}</div>
</div>
</div>
</div>
<!-- 字符类型统计 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.text_counter.character_types') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.letters') }}:</span>
<span class="text-primary font-medium">{{ stats.letters }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.numbers') }}:</span>
<span class="text-primary font-medium">{{ stats.numbers }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.spaces') }}:</span>
<span class="text-primary font-medium">{{ stats.spaces }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.punctuation') }}:</span>
<span class="text-primary font-medium">{{ stats.punctuation }}</span>
</div>
</div>
</div>
<!-- 阅读时间估算 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.text_counter.reading_time') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.slow_reading') }} (200 WPM):</span>
<span class="text-primary font-medium">{{ readingTime.slow }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.normal_reading') }} (250 WPM):</span>
<span class="text-primary font-medium">{{ readingTime.normal }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.fast_reading') }} (300 WPM):</span>
<span class="text-primary font-medium">{{ readingTime.fast }}</span>
</div>
</div>
</div>
<!-- 最常用单词 -->
<div v-if="topWords.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.text_counter.top_words') }}</h3>
<div class="space-y-2">
<div
v-for="(word, index) in topWords.slice(0, 10)"
:key="word.word"
class="flex justify-between items-center"
>
<span class="text-secondary">
{{ index + 1 }}. {{ word.word }}
</span>
<span class="text-primary font-medium">{{ word.count }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const text = ref('')
// 基础统计计算
const stats = computed(() => {
const content = text.value
// 字符数
const characters = content.length
// 不含空格的字符数
const charactersNoSpaces = content.replace(/\s/g, '').length
// 单词数
const words = content.trim() === '' ? 0 : content.trim().split(/\s+/).length
// 行数
const lines = content === '' ? 0 : content.split('\n').length
// 段落数
const paragraphs = content.trim() === '' ? 0 :
content.trim().split(/\n\s*\n/).filter(p => p.trim() !== '').length
// 句子数
const sentences = content.trim() === '' ? 0 :
content.split(/[.!?]+/).filter(s => s.trim() !== '').length
// 字母数
const letters = (content.match(/[a-zA-Z\u4e00-\u9fa5]/g) || []).length
// 数字数
const numbers = (content.match(/\d/g) || []).length
// 空格数
const spaces = (content.match(/\s/g) || []).length
// 标点符号数
const punctuation = (content.match(/[^\w\s\u4e00-\u9fa5]/g) || []).length
return {
characters,
charactersNoSpaces,
words,
lines,
paragraphs,
sentences,
letters,
numbers,
spaces,
punctuation
}
})
// 阅读时间估算
const readingTime = computed(() => {
const words = stats.value.words
const formatTime = (minutes: number) => {
if (minutes < 1) {
return '< 1 分钟'
} else if (minutes < 60) {
return `${Math.round(minutes)} 分钟`
} else {
const hours = Math.floor(minutes / 60)
const mins = Math.round(minutes % 60)
return `${hours} 小时 ${mins} 分钟`
}
}
return {
slow: formatTime(words / 200),
normal: formatTime(words / 250),
fast: formatTime(words / 300)
}
})
// 最常用单词
const topWords = computed(() => {
if (!text.value.trim()) return []
// 提取单词并统计频率
const words = text.value
.toLowerCase()
.replace(/[^\w\s\u4e00-\u9fa5]/g, '')
.split(/\s+/)
.filter(word => word.length > 2) // 过滤掉过短的单词
const wordCount = new Map<string, number>()
words.forEach(word => {
wordCount.set(word, (wordCount.get(word) || 0) + 1)
})
// 转换为数组并排序
return Array.from(wordCount.entries())
.map(([word, count]) => ({ word, count }))
.sort((a, b) => b.count - a.count)
})
// 清除文本
const clearText = () => {
text.value = ''
}
// 从剪贴板粘贴
const pasteFromClipboard = async () => {
try {
const clipText = await navigator.clipboard.readText()
text.value = clipText
} catch (error) {
console.error('粘贴失败:', error)
}
}
// 加载示例文本
const loadSample = () => {
text.value = `这是一个文本统计工具的示例文本。
它可以帮助您统计文本的各种信息,包括字符数、单词数、行数等。
这个工具支持中文和英文文本的统计。它会计算:
- 总字符数和不含空格的字符数
- 单词数和行数
- 段落数和句子数
- 不同类型字符的统计
- 预估的阅读时间
您可以将任何文本粘贴到输入框中,工具会实时更新统计结果。
这对于写作、编辑和内容创作非常有用。
This is a bilingual text counter tool. It supports both Chinese and English text analysis.
The tool provides comprehensive statistics including character count, word count, reading time estimation, and more.`
}
// 处理文本变化
const handleTextChange = () => {
// 可以在这里添加实时处理逻辑
}
</script>
<style scoped>
.stat-item {
@apply text-center p-3 bg-block rounded-lg;
}
.stat-value {
@apply text-2xl font-bold text-primary;
}
.stat-label {
@apply text-sm text-secondary mt-1;
}
</style>

View File

@ -0,0 +1,711 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="processText"
:disabled="!inputText.trim()"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'magic']" class="mr-2" />
处理文本
</button>
<button
@click="copyResult"
:disabled="!outputText"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
复制结果
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清除
</button>
<button
@click="swapContent"
:disabled="!outputText"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'exchange-alt']" class="mr-2" />
交换内容
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="space-y-4">
<!-- 输入文本 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">输入文本</h3>
<textarea
v-model="inputText"
placeholder="请输入需要处理的文本..."
class="textarea-field h-80 font-mono text-sm"
@input="updateStats"
/>
<!-- 输入统计 -->
<div class="flex justify-between items-center mt-2 text-sm text-secondary">
<span>{{ inputStats.chars }} 字符 | {{ inputStats.lines }} </span>
<span>{{ inputStats.spaces }} 空格 | {{ inputStats.tabs }} 制表符</span>
</div>
</div>
<!-- 处理选项 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理选项</h3>
<div class="space-y-4">
<!-- 空格处理 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">空格处理</label>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.removeLeadingSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除行首空格</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.removeTrailingSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除行尾空格</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.removeAllSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除所有空格</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.collapseSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">合并连续空格</span>
</label>
</div>
</div>
<!-- 制表符处理 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">制表符处理</label>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.removeTabs"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除制表符</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.tabsToSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">制表符转空格</span>
</label>
<div v-if="options.tabsToSpaces" class="ml-6">
<label class="block text-xs text-tertiary mb-1">空格数量</label>
<input
v-model.number="options.tabSize"
type="number"
min="1"
max="8"
class="input-field w-20 text-sm"
>
</div>
</div>
</div>
<!-- 换行处理 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">换行处理</label>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.removeEmptyLines"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除空行</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.collapseEmptyLines"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">合并连续空行</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.normalizeLineEndings"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">统一换行符</span>
</label>
</div>
</div>
<!-- 其他选项 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">其他选项</label>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.removeNonBreakingSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除不间断空格 (&nbsp;)</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.removeZeroWidthSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除零宽空格</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.trimLines"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">修剪每行首尾空白</span>
</label>
</div>
</div>
</div>
</div>
<!-- 快速预设 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">快速预设</h3>
<div class="grid grid-cols-1 gap-2">
<button
v-for="preset in presets"
:key="preset.name"
@click="applyPreset(preset)"
class="text-left p-3 rounded bg-block hover:bg-block-hover text-secondary hover:text-primary transition-colors"
>
<div class="font-medium">{{ preset.name }}</div>
<div class="text-sm text-tertiary">{{ preset.description }}</div>
</button>
</div>
</div>
</div>
<!-- 输出区域 -->
<div class="space-y-4">
<!-- 输出文本 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理结果</h3>
<textarea
v-model="outputText"
readonly
placeholder="处理结果将显示在这里..."
class="textarea-field h-80 font-mono text-sm bg-block"
/>
<!-- 输出统计 -->
<div class="flex justify-between items-center mt-2 text-sm text-secondary">
<span>{{ outputStats.chars }} 字符 | {{ outputStats.lines }} </span>
<span>{{ outputStats.spaces }} 空格 | {{ outputStats.tabs }} 制表符</span>
</div>
</div>
<!-- 处理统计 -->
<div v-if="processStats" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理统计</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-secondary">字符变化:</span>
<span :class="processStats.charsDiff >= 0 ? 'text-error' : 'text-success'">
{{ processStats.charsDiff > 0 ? '+' : '' }}{{ processStats.charsDiff }}
</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">行数变化:</span>
<span :class="processStats.linesDiff >= 0 ? 'text-error' : 'text-success'">
{{ processStats.linesDiff > 0 ? '+' : '' }}{{ processStats.linesDiff }}
</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">空格移除:</span>
<span class="text-success">{{ processStats.spacesRemoved }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">制表符移除:</span>
<span class="text-success">{{ processStats.tabsRemoved }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">空行移除:</span>
<span class="text-success">{{ processStats.emptyLinesRemoved }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">压缩率:</span>
<span class="text-primary">{{ processStats.compressionRatio }}%</span>
</div>
</div>
</div>
<!-- 字符分析 -->
<div v-if="charAnalysis" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">字符分析</h3>
<div class="space-y-3">
<!-- 可见字符 -->
<div>
<div class="text-sm font-medium text-secondary mb-1">可见字符分布</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="flex justify-between">
<span class="text-tertiary">字母:</span>
<span class="text-primary">{{ charAnalysis.letters }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">数字:</span>
<span class="text-primary">{{ charAnalysis.numbers }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">标点:</span>
<span class="text-primary">{{ charAnalysis.punctuation }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">符号:</span>
<span class="text-primary">{{ charAnalysis.symbols }}</span>
</div>
</div>
</div>
<!-- 空白字符 -->
<div>
<div class="text-sm font-medium text-secondary mb-1">空白字符</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="flex justify-between">
<span class="text-tertiary">普通空格:</span>
<span class="text-primary">{{ charAnalysis.spaces }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">制表符:</span>
<span class="text-primary">{{ charAnalysis.tabs }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">换行符:</span>
<span class="text-primary">{{ charAnalysis.newlines }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">不间断空格:</span>
<span class="text-primary">{{ charAnalysis.nbspaces }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 处理日志 -->
<div v-if="processLog.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理日志</h3>
<div class="space-y-1 max-h-40 overflow-y-auto">
<div
v-for="(log, index) in processLog"
:key="index"
class="text-sm text-secondary"
>
{{ log }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const inputText = ref('')
const outputText = ref('')
const copied = ref(false)
const processLog = ref<string[]>([])
// 处理选项
const options = ref({
removeLeadingSpaces: false,
removeTrailingSpaces: false,
removeAllSpaces: false,
collapseSpaces: false,
removeTabs: false,
tabsToSpaces: false,
tabSize: 4,
removeEmptyLines: false,
collapseEmptyLines: false,
normalizeLineEndings: false,
removeNonBreakingSpaces: false,
removeZeroWidthSpaces: false,
trimLines: false
})
// 预设配置
const presets = [
{
name: '基本清理',
description: '移除行首尾空格,合并连续空格',
options: {
removeLeadingSpaces: false,
removeTrailingSpaces: true,
trimLines: true,
collapseSpaces: true,
collapseEmptyLines: true
}
},
{
name: '代码格式化',
description: '制表符转空格,统一缩进',
options: {
tabsToSpaces: true,
tabSize: 4,
normalizeLineEndings: true,
removeTrailingSpaces: true
}
},
{
name: '完全清理',
description: '移除所有不必要的空白字符',
options: {
removeAllSpaces: true,
removeTabs: true,
removeEmptyLines: true,
removeNonBreakingSpaces: true,
removeZeroWidthSpaces: true
}
},
{
name: '最小化',
description: '压缩到最小体积',
options: {
removeAllSpaces: true,
removeTabs: true,
removeEmptyLines: true,
removeNonBreakingSpaces: true,
removeZeroWidthSpaces: true,
normalizeLineEndings: true
}
}
]
// 计算统计信息
const inputStats = computed(() => {
return calculateStats(inputText.value)
})
const outputStats = computed(() => {
return calculateStats(outputText.value)
})
const processStats = computed(() => {
if (!outputText.value) return null
const input = inputStats.value
const output = outputStats.value
const charsDiff = output.chars - input.chars
const linesDiff = output.lines - input.lines
const spacesRemoved = input.spaces - output.spaces
const tabsRemoved = input.tabs - output.tabs
const emptyLinesRemoved = Math.max(0, input.emptyLines - output.emptyLines)
const compressionRatio = input.chars > 0
? Math.round((Math.abs(charsDiff) / input.chars) * 100)
: 0
return {
charsDiff,
linesDiff,
spacesRemoved,
tabsRemoved,
emptyLinesRemoved,
compressionRatio
}
})
const charAnalysis = computed(() => {
if (!inputText.value) return null
const text = inputText.value
let letters = 0, numbers = 0, punctuation = 0, symbols = 0
let spaces = 0, tabs = 0, newlines = 0, nbspaces = 0
for (const char of text) {
if (/[a-zA-Z\u4e00-\u9fff]/.test(char)) {
letters++
} else if (/\d/.test(char)) {
numbers++
} else if (/[.,;:!?]/.test(char)) {
punctuation++
} else if (/[^\s\w]/.test(char)) {
symbols++
} else if (char === ' ') {
spaces++
} else if (char === '\t') {
tabs++
} else if (char === '\n' || char === '\r') {
newlines++
} else if (char === '\u00A0') {
nbspaces++
}
}
return {
letters,
numbers,
punctuation,
symbols,
spaces,
tabs,
newlines,
nbspaces
}
})
// 计算文本统计
const calculateStats = (text: string) => {
const chars = text.length
const lines = text ? text.split('\n').length : 0
const spaces = (text.match(/ /g) || []).length
const tabs = (text.match(/\t/g) || []).length
const emptyLines = text ? text.split('\n').filter(line => line.trim() === '').length : 0
return { chars, lines, spaces, tabs, emptyLines }
}
// 处理文本
const processText = () => {
if (!inputText.value.trim()) return
let result = inputText.value
processLog.value = []
// 移除零宽空格
if (options.value.removeZeroWidthSpaces) {
const before = result.length
result = result.replace(/[\u200B-\u200D\uFEFF]/g, '')
const removed = before - result.length
if (removed > 0) {
processLog.value.push(`移除了 ${removed} 个零宽空格`)
}
}
// 移除不间断空格
if (options.value.removeNonBreakingSpaces) {
const before = (result.match(/\u00A0/g) || []).length
result = result.replace(/\u00A0/g, ' ')
if (before > 0) {
processLog.value.push(`转换了 ${before} 个不间断空格`)
}
}
// 制表符转空格
if (options.value.tabsToSpaces) {
const before = (result.match(/\t/g) || []).length
const spaces = ' '.repeat(options.value.tabSize)
result = result.replace(/\t/g, spaces)
if (before > 0) {
processLog.value.push(`转换了 ${before} 个制表符为空格`)
}
}
// 移除制表符
if (options.value.removeTabs && !options.value.tabsToSpaces) {
const before = (result.match(/\t/g) || []).length
result = result.replace(/\t/g, '')
if (before > 0) {
processLog.value.push(`移除了 ${before} 个制表符`)
}
}
// 统一换行符
if (options.value.normalizeLineEndings) {
result = result.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
processLog.value.push('统一了换行符为 LF')
}
// 按行处理
let lines = result.split('\n')
// 移除行首空格
if (options.value.removeLeadingSpaces) {
const before = lines.join('').length
lines = lines.map(line => line.replace(/^[ \t]+/, ''))
const removed = before - lines.join('').length
if (removed > 0) {
processLog.value.push(`移除了 ${removed} 个行首空白字符`)
}
}
// 移除行尾空格
if (options.value.removeTrailingSpaces) {
const before = lines.join('').length
lines = lines.map(line => line.replace(/[ \t]+$/, ''))
const removed = before - lines.join('').length
if (removed > 0) {
processLog.value.push(`移除了 ${removed} 个行尾空白字符`)
}
}
// 修剪每行首尾空白
if (options.value.trimLines) {
const before = lines.join('').length
lines = lines.map(line => line.trim())
const removed = before - lines.join('').length
if (removed > 0) {
processLog.value.push(`修剪了行首尾空白,移除 ${removed} 个字符`)
}
}
// 移除空行
if (options.value.removeEmptyLines) {
const before = lines.length
lines = lines.filter(line => line.trim() !== '')
const removed = before - lines.length
if (removed > 0) {
processLog.value.push(`移除了 ${removed} 个空行`)
}
}
// 合并连续空行
if (options.value.collapseEmptyLines && !options.value.removeEmptyLines) {
const before = lines.length
const collapsed: string[] = []
let lastWasEmpty = false
for (const line of lines) {
const isEmpty = line.trim() === ''
if (!isEmpty || !lastWasEmpty) {
collapsed.push(line)
}
lastWasEmpty = isEmpty
}
lines = collapsed
const removed = before - lines.length
if (removed > 0) {
processLog.value.push(`合并了连续空行,移除 ${removed}`)
}
}
result = lines.join('\n')
// 移除所有空格
if (options.value.removeAllSpaces) {
const before = (result.match(/ /g) || []).length
result = result.replace(/ /g, '')
if (before > 0) {
processLog.value.push(`移除了所有 ${before} 个空格`)
}
}
// 合并连续空格
if (options.value.collapseSpaces && !options.value.removeAllSpaces) {
const before = result.length
result = result.replace(/ +/g, ' ')
const removed = before - result.length
if (removed > 0) {
processLog.value.push(`合并连续空格,移除 ${removed} 个字符`)
}
}
outputText.value = result
if (processLog.value.length === 0) {
processLog.value.push('没有需要处理的内容')
}
}
// 应用预设
const applyPreset = (preset: any) => {
// 重置所有选项
Object.keys(options.value).forEach(key => {
options.value[key as keyof typeof options.value] = false
})
// 应用预设选项
Object.assign(options.value, preset.options)
// 如果有输入文本,立即处理
if (inputText.value.trim()) {
processText()
}
}
// 复制结果
const copyResult = async () => {
if (!outputText.value) return
try {
await navigator.clipboard.writeText(outputText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 交换内容
const swapContent = () => {
const temp = inputText.value
inputText.value = outputText.value
outputText.value = temp
}
// 清除所有
const clearAll = () => {
inputText.value = ''
outputText.value = ''
processLog.value = []
}
// 更新统计
const updateStats = () => {
// 自动更新统计信息
}
// 监听输入变化,自动处理
watch(() => options.value, () => {
if (inputText.value.trim()) {
processText()
}
}, { deep: true })
</script>

View File

@ -0,0 +1,256 @@
<template>
<div class="space-y-6">
<!-- 当前时间戳 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.timestamp_converter.current_timestamp') }}</h3>
<div class="space-y-3">
<div class="flex items-center space-x-4">
<span class="text-secondary">当前时间戳:</span>
<span class="text-primary font-mono text-lg">{{ currentTimestamp }}</span>
<button
@click="copyTimestamp"
class="btn-secondary px-3 py-1 text-sm"
>
<FontAwesomeIcon :icon="['fas', 'copy']" class="mr-1" />
{{ t('tools.timestamp_converter.copy_timestamp') }}
</button>
</div>
<div class="flex items-center space-x-4">
<span class="text-secondary">当前日期:</span>
<span class="text-primary">{{ currentDateTime }}</span>
<button
@click="copyDateTime"
class="btn-secondary px-3 py-1 text-sm"
>
<FontAwesomeIcon :icon="['fas', 'copy']" class="mr-1" />
{{ t('tools.timestamp_converter.copy_date') }}
</button>
</div>
</div>
</div>
<!-- 转换工具 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 时间戳转日期 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.timestamp_converter.timestamp_to_date') }}</h3>
<div class="space-y-3">
<input
v-model="timestampInput"
type="text"
:placeholder="t('tools.timestamp_converter.timestamp_placeholder')"
class="input-field"
@input="convertTimestampToDate"
>
<button
@click="useCurrentTimestamp"
class="btn-secondary"
>
使用当前时间戳
</button>
<div v-if="timestampResult" class="space-y-2">
<label class="block text-sm font-medium text-secondary">转换结果:</label>
<div class="bg-block p-3 rounded font-mono text-sm">
<div><strong>本地时间:</strong> {{ timestampResult.local }}</div>
<div><strong>UTC时间:</strong> {{ timestampResult.utc }}</div>
<div><strong>ISO格式:</strong> {{ timestampResult.iso }}</div>
</div>
</div>
</div>
</div>
<!-- 日期转时间戳 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.timestamp_converter.date_to_timestamp') }}</h3>
<div class="space-y-3">
<input
v-model="dateInput"
type="datetime-local"
class="input-field"
@input="convertDateToTimestamp"
>
<button
@click="useCurrentDate"
class="btn-secondary"
>
使用当前时间
</button>
<div v-if="dateResult" class="space-y-2">
<label class="block text-sm font-medium text-secondary">转换结果:</label>
<div class="bg-block p-3 rounded font-mono text-sm">
<div><strong>时间戳():</strong> {{ dateResult.seconds }}</div>
<div><strong>时间戳(毫秒):</strong> {{ dateResult.milliseconds }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 快速转换 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">快速转换</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<button
v-for="quick in quickOptions"
:key="quick.label"
@click="() => applyQuickTimestamp(quick.timestamp)"
class="btn-secondary text-sm"
>
{{ quick.label }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const currentTimestamp = ref(0)
const currentDateTime = ref('')
const timestampInput = ref('')
const dateInput = ref('')
const timestampResult = ref<any>(null)
const dateResult = ref<any>(null)
// 更新当前时间戳
const updateCurrentTime = () => {
const now = new Date()
currentTimestamp.value = Math.floor(now.getTime() / 1000)
currentDateTime.value = now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 时间戳转日期
const convertTimestampToDate = () => {
const input = timestampInput.value.trim()
if (!input) {
timestampResult.value = null
return
}
try {
let timestamp = parseInt(input)
// 判断是秒还是毫秒
if (timestamp.toString().length === 10) {
timestamp *= 1000
}
const date = new Date(timestamp)
if (isNaN(date.getTime())) {
timestampResult.value = { error: '无效的时间戳' }
return
}
timestampResult.value = {
local: date.toLocaleString('zh-CN'),
utc: date.toUTCString(),
iso: date.toISOString()
}
} catch (error) {
timestampResult.value = { error: '转换失败' }
}
}
// 日期转时间戳
const convertDateToTimestamp = () => {
if (!dateInput.value) {
dateResult.value = null
return
}
try {
const date = new Date(dateInput.value)
const timestamp = date.getTime()
if (isNaN(timestamp)) {
dateResult.value = { error: '无效的日期' }
return
}
dateResult.value = {
seconds: Math.floor(timestamp / 1000),
milliseconds: timestamp
}
} catch (error) {
dateResult.value = { error: '转换失败' }
}
}
// 使用当前时间戳
const useCurrentTimestamp = () => {
timestampInput.value = currentTimestamp.value.toString()
convertTimestampToDate()
}
// 使用当前日期
const useCurrentDate = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
dateInput.value = `${year}-${month}-${day}T${hours}:${minutes}`
convertDateToTimestamp()
}
// 复制时间戳
const copyTimestamp = async () => {
try {
await navigator.clipboard.writeText(currentTimestamp.value.toString())
} catch (error) {
console.error('复制失败:', error)
}
}
// 复制日期时间
const copyDateTime = async () => {
try {
await navigator.clipboard.writeText(currentDateTime.value)
} catch (error) {
console.error('复制失败:', error)
}
}
// 快速选项
const quickOptions = [
{ label: '1小时前', timestamp: () => Math.floor(Date.now() / 1000) - 3600 },
{ label: '1天前', timestamp: () => Math.floor(Date.now() / 1000) - 86400 },
{ label: '1周前', timestamp: () => Math.floor(Date.now() / 1000) - 604800 },
{ label: '1月前', timestamp: () => Math.floor(Date.now() / 1000) - 2592000 }
]
// 应用快速时间戳
const applyQuickTimestamp = (timestampFn: () => number) => {
timestampInput.value = timestampFn().toString()
convertTimestampToDate()
}
// 定时器
let timer: NodeJS.Timeout
onMounted(() => {
updateCurrentTime()
timer = setInterval(updateCurrentTime, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>

View File

@ -0,0 +1,654 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="updateCurrentTime"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'sync']" class="mr-2" />
刷新时间
</button>
<button
@click="copyResult"
:disabled="!selectedTime"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
复制时间
</button>
<button
@click="resetToNow"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'clock']" class="mr-2" />
当前时间
</button>
<button
@click="addCustomTimezone"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'plus']" class="mr-2" />
添加时区
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 输入区域 -->
<div class="space-y-4">
<!-- 时间输入 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">输入时间</h3>
<div class="space-y-4">
<!-- 日期时间输入 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">选择日期时间</label>
<input
v-model="inputDateTime"
type="datetime-local"
class="input-field"
@change="convertTimezones"
>
</div>
<!-- 源时区 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">源时区</label>
<select v-model="sourceTimezone" class="select-field" @change="convertTimezones">
<option v-for="timezone in commonTimezones" :key="timezone.value" :value="timezone.value">
{{ timezone.label }}
</option>
</select>
</div>
<!-- 快速时间选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">快速选择</label>
<div class="grid grid-cols-2 gap-2">
<button
v-for="quickTime in quickTimes"
:key="quickTime.label"
@click="setQuickTime(quickTime)"
class="btn-sm btn-secondary text-xs"
>
{{ quickTime.label }}
</button>
</div>
</div>
</div>
</div>
<!-- 常用时区 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">常用时区</h3>
<div class="space-y-2">
<div
v-for="timezone in commonTimezones.slice(0, 8)"
:key="timezone.value"
class="flex items-center justify-between p-2 bg-block rounded"
>
<div>
<div class="font-medium text-primary">{{ timezone.name }}</div>
<div class="text-sm text-secondary">{{ timezone.value }}</div>
</div>
<div class="text-sm text-primary font-mono">
{{ getTimezoneTime(timezone.value) }}
</div>
</div>
</div>
</div>
<!-- 时区偏移计算 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">时差计算</h3>
<div class="space-y-3">
<div class="flex space-x-2">
<select v-model="timezone1" class="select-field flex-1">
<option v-for="timezone in commonTimezones" :key="timezone.value" :value="timezone.value">
{{ timezone.name }}
</option>
</select>
<span class="self-center text-secondary">vs</span>
<select v-model="timezone2" class="select-field flex-1">
<option v-for="timezone in commonTimezones" :key="timezone.value" :value="timezone.value">
{{ timezone.name }}
</option>
</select>
</div>
<div class="bg-block rounded p-3 text-center">
<div class="text-sm text-secondary">时差</div>
<div class="text-lg font-medium text-primary">{{ getTimeDifference() }}</div>
</div>
</div>
</div>
</div>
<!-- 结果区域 -->
<div class="lg:col-span-2 space-y-4">
<!-- 世界时钟 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">世界时钟</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="timezone in displayTimezones"
:key="timezone.value"
class="bg-block rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<div>
<div class="font-medium text-primary">{{ timezone.name }}</div>
<div class="text-xs text-tertiary">{{ timezone.value }}</div>
</div>
<button
@click="removeTimezone(timezone.value)"
class="text-error hover:bg-error hover:bg-opacity-10 p-1 rounded"
>
<FontAwesomeIcon :icon="['fas', 'times']" />
</button>
</div>
<div class="text-center">
<div class="text-2xl font-mono font-bold text-primary mb-1">
{{ getTimezoneTime(timezone.value, 'HH:mm:ss') }}
</div>
<div class="text-sm text-secondary">
{{ getTimezoneTime(timezone.value, 'yyyy-MM-dd EEEE') }}
</div>
<div class="text-xs text-tertiary mt-1">
UTC{{ getTimezoneOffset(timezone.value) }}
</div>
</div>
</div>
</div>
</div>
<!-- 转换结果 -->
<div v-if="conversionResults.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">转换结果</h3>
<div class="space-y-3">
<div
v-for="result in conversionResults"
:key="result.timezone"
class="flex items-center justify-between p-3 bg-block rounded-lg"
>
<div class="flex-1">
<div class="font-medium text-primary">{{ result.name }}</div>
<div class="text-sm text-secondary">{{ result.timezone }}</div>
</div>
<div class="text-right">
<div class="text-lg font-mono text-primary">{{ result.time }}</div>
<div class="text-xs text-secondary">{{ result.date }}</div>
</div>
<button
@click="copySpecificTime(result)"
class="ml-3 p-2 text-secondary hover:text-primary transition-colors"
:title="'复制 ' + result.name + ' 时间'"
>
<FontAwesomeIcon :icon="['fas', 'copy']" />
</button>
</div>
</div>
</div>
<!-- 时区信息 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">时区信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- UTC时间 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">协调世界时 (UTC)</div>
<div class="text-xl font-mono text-primary">{{ utcTime }}</div>
<div class="text-xs text-tertiary mt-1">Coordinated Universal Time</div>
</div>
<!-- 本地时间 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">本地时间</div>
<div class="text-xl font-mono text-primary">{{ localTime }}</div>
<div class="text-xs text-tertiary mt-1">{{ localTimezone }}</div>
</div>
<!-- Unix时间戳 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">Unix时间戳</div>
<div class="text-lg font-mono text-primary">{{ unixTimestamp }}</div>
<div class="text-xs text-tertiary mt-1"> / 毫秒</div>
</div>
<!-- ISO 8601 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">ISO 8601</div>
<div class="text-sm font-mono text-primary break-all">{{ isoTime }}</div>
<div class="text-xs text-tertiary mt-1">国际标准时间格式</div>
</div>
</div>
</div>
<!-- 日程助手 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">会议时间建议</h3>
<div class="space-y-3">
<div class="flex space-x-2">
<select v-model="meetingTimezone1" class="select-field flex-1">
<option v-for="timezone in commonTimezones" :key="timezone.value" :value="timezone.value">
{{ timezone.name }}
</option>
</select>
<select v-model="meetingTimezone2" class="select-field flex-1">
<option v-for="timezone in commonTimezones" :key="timezone.value" :value="timezone.value">
{{ timezone.name }}
</option>
</select>
</div>
<div class="bg-block rounded p-4">
<div class="text-sm text-secondary mb-2">最佳会议时间段 (工作时间 9:00-18:00)</div>
<div class="space-y-2">
<div
v-for="suggestion in getMeetingSuggestions()"
:key="suggestion.time"
class="flex justify-between items-center text-sm"
>
<span class="text-primary">{{ suggestion.time }}</span>
<span class="text-secondary">{{ suggestion.zones }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const inputDateTime = ref('')
const sourceTimezone = ref('Asia/Shanghai')
const selectedTime = ref('')
const copied = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
const currentTime = ref(new Date())
// 时区比较
const timezone1 = ref('Asia/Shanghai')
const timezone2 = ref('America/New_York')
// 会议时间建议
const meetingTimezone1 = ref('Asia/Shanghai')
const meetingTimezone2 = ref('America/New_York')
// 显示的时区列表
const displayTimezones = ref([
{ name: '北京', value: 'Asia/Shanghai' },
{ name: '纽约', value: 'America/New_York' },
{ name: '伦敦', value: 'Europe/London' },
{ name: '东京', value: 'Asia/Tokyo' }
])
// 转换结果
const conversionResults = ref<Array<{
name: string
timezone: string
time: string
date: string
fullTime: string
}>>([])
// 常用时区
const commonTimezones = [
{ name: '北京 (CST)', value: 'Asia/Shanghai', label: 'Asia/Shanghai (UTC+8)' },
{ name: '东京 (JST)', value: 'Asia/Tokyo', label: 'Asia/Tokyo (UTC+9)' },
{ name: '首尔 (KST)', value: 'Asia/Seoul', label: 'Asia/Seoul (UTC+9)' },
{ name: '新加坡 (SGT)', value: 'Asia/Singapore', label: 'Asia/Singapore (UTC+8)' },
{ name: '香港 (HKT)', value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (UTC+8)' },
{ name: '悉尼 (AEDT)', value: 'Australia/Sydney', label: 'Australia/Sydney (UTC+11)' },
{ name: '伦敦 (GMT)', value: 'Europe/London', label: 'Europe/London (UTC+0)' },
{ name: '巴黎 (CET)', value: 'Europe/Paris', label: 'Europe/Paris (UTC+1)' },
{ name: '莫斯科 (MSK)', value: 'Europe/Moscow', label: 'Europe/Moscow (UTC+3)' },
{ name: '纽约 (EST)', value: 'America/New_York', label: 'America/New_York (UTC-5)' },
{ name: '洛杉矶 (PST)', value: 'America/Los_Angeles', label: 'America/Los_Angeles (UTC-8)' },
{ name: '芝加哥 (CST)', value: 'America/Chicago', label: 'America/Chicago (UTC-6)' },
{ name: '丹佛 (MST)', value: 'America/Denver', label: 'America/Denver (UTC-7)' },
{ name: 'UTC', value: 'UTC', label: 'UTC (UTC+0)' }
]
// 快速时间选择
const quickTimes = [
{ label: '现在', offset: 0 },
{ label: '1小时后', offset: 1 },
{ label: '明天此时', offset: 24 },
{ label: '下周此时', offset: 24 * 7 }
]
// 计算属性
const utcTime = computed(() => {
return formatTime(currentTime.value, 'UTC', 'yyyy-MM-dd HH:mm:ss')
})
const localTime = computed(() => {
return formatTime(currentTime.value, Intl.DateTimeFormat().resolvedOptions().timeZone, 'yyyy-MM-dd HH:mm:ss')
})
const localTimezone = computed(() => {
return Intl.DateTimeFormat().resolvedOptions().timeZone
})
const unixTimestamp = computed(() => {
const seconds = Math.floor(currentTime.value.getTime() / 1000)
const milliseconds = currentTime.value.getTime()
return `${seconds} / ${milliseconds}`
})
const isoTime = computed(() => {
return currentTime.value.toISOString()
})
// 定时器
let timeInterval: number | undefined
// 格式化时间
const formatTime = (date: Date, timezone: string, format: string): string => {
try {
const options: Intl.DateTimeFormatOptions = {
timeZone: timezone
}
if (format.includes('yyyy')) {
options.year = 'numeric'
}
if (format.includes('MM')) {
options.month = '2-digit'
}
if (format.includes('dd')) {
options.day = '2-digit'
}
if (format.includes('HH')) {
options.hour = '2-digit'
options.hour12 = false
}
if (format.includes('mm')) {
options.minute = '2-digit'
}
if (format.includes('ss')) {
options.second = '2-digit'
}
if (format.includes('EEEE')) {
options.weekday = 'long'
}
const formatter = new Intl.DateTimeFormat('zh-CN', options)
if (format === 'HH:mm:ss') {
return date.toLocaleTimeString('zh-CN', {
timeZone: timezone,
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
} else if (format === 'yyyy-MM-dd EEEE') {
return date.toLocaleDateString('zh-CN', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'long'
})
} else {
return formatter.format(date)
}
} catch (error) {
return date.toISOString()
}
}
// 获取时区时间
const getTimezoneTime = (timezone: string, format: string = 'yyyy-MM-dd HH:mm:ss'): string => {
return formatTime(currentTime.value, timezone, format)
}
// 获取时区偏移
const getTimezoneOffset = (timezone: string): string => {
try {
const date = new Date()
const utc = date.getTime() + (date.getTimezoneOffset() * 60000)
const targetTime = new Date(utc + getTimezoneOffsetMinutes(timezone) * 60000)
const offset = getTimezoneOffsetMinutes(timezone) / 60
return offset >= 0 ? `+${offset}` : `${offset}`
} catch {
return '+0'
}
}
// 获取时区偏移分钟数
const getTimezoneOffsetMinutes = (timezone: string): number => {
try {
const date = new Date()
const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }))
const targetDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }))
return (targetDate.getTime() - utcDate.getTime()) / (1000 * 60)
} catch {
return 0
}
}
// 获取时差
const getTimeDifference = (): string => {
const offset1 = getTimezoneOffsetMinutes(timezone1.value)
const offset2 = getTimezoneOffsetMinutes(timezone2.value)
const diffMinutes = Math.abs(offset1 - offset2)
const hours = Math.floor(diffMinutes / 60)
const minutes = diffMinutes % 60
if (hours === 0) {
return `${minutes} 分钟`
} else if (minutes === 0) {
return `${hours} 小时`
} else {
return `${hours} 小时 ${minutes} 分钟`
}
}
// 获取会议建议
const getMeetingSuggestions = (): Array<{ time: string; zones: string }> => {
const suggestions = []
for (let hour = 9; hour <= 18; hour++) {
const time1 = `${hour.toString().padStart(2, '0')}:00`
const date = new Date()
date.setHours(hour, 0, 0, 0)
const time2 = formatTime(date, meetingTimezone2.value, 'HH:mm')
const hour2 = parseInt(time2.split(':')[0])
if (hour2 >= 9 && hour2 <= 18) {
suggestions.push({
time: `${time1} - ${time2}`,
zones: `${getTimezoneName(meetingTimezone1.value)} - ${getTimezoneName(meetingTimezone2.value)}`
})
}
}
return suggestions.slice(0, 3)
}
// 获取时区名称
const getTimezoneName = (timezone: string): string => {
const found = commonTimezones.find(tz => tz.value === timezone)
return found ? found.name : timezone
}
// 设置快速时间
const setQuickTime = (quickTime: any) => {
const date = new Date()
date.setHours(date.getHours() + quickTime.offset)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
inputDateTime.value = `${year}-${month}-${day}T${hours}:${minutes}`
convertTimezones()
}
// 转换时区
const convertTimezones = () => {
if (!inputDateTime.value) return
const inputDate = new Date(inputDateTime.value)
if (isNaN(inputDate.getTime())) return
conversionResults.value = displayTimezones.value.map(timezone => {
const time = formatTime(inputDate, timezone.value, 'HH:mm:ss')
const date = formatTime(inputDate, timezone.value, 'yyyy-MM-dd EEEE')
const fullTime = formatTime(inputDate, timezone.value, 'yyyy-MM-dd HH:mm:ss')
return {
name: timezone.name,
timezone: timezone.value,
time,
date,
fullTime
}
})
}
// 添加自定义时区
const addCustomTimezone = () => {
const timezone = prompt('请输入时区标识符 (如: Asia/Shanghai):')
if (!timezone) return
try {
// 验证时区是否有效
formatTime(new Date(), timezone, 'HH:mm:ss')
const name = prompt('请输入时区显示名称:', timezone) || timezone
displayTimezones.value.push({
name,
value: timezone
})
convertTimezones()
showStatus('时区添加成功', 'success')
} catch (error) {
showStatus('无效的时区标识符', 'error')
}
}
// 移除时区
const removeTimezone = (timezone: string) => {
displayTimezones.value = displayTimezones.value.filter(tz => tz.value !== timezone)
convertTimezones()
}
// 更新当前时间
const updateCurrentTime = () => {
currentTime.value = new Date()
}
// 重置到现在
const resetToNow = () => {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hours = now.getHours().toString().padStart(2, '0')
const minutes = now.getMinutes().toString().padStart(2, '0')
inputDateTime.value = `${year}-${month}-${day}T${hours}:${minutes}`
convertTimezones()
}
// 复制结果
const copyResult = async () => {
if (!selectedTime.value) return
try {
await navigator.clipboard.writeText(selectedTime.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
showStatus('复制失败', 'error')
}
}
// 复制特定时间
const copySpecificTime = async (result: any) => {
try {
await navigator.clipboard.writeText(result.fullTime)
showStatus(`已复制 ${result.name} 时间`, 'success')
} catch (error) {
showStatus('复制失败', 'error')
}
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 组件挂载
onMounted(() => {
resetToNow()
updateCurrentTime()
// 每秒更新时间
timeInterval = setInterval(() => {
updateCurrentTime()
}, 1000)
})
// 组件卸载
onUnmounted(() => {
if (timeInterval) {
clearInterval(timeInterval)
}
})
</script>

View File

@ -0,0 +1,323 @@
<template>
<div class="space-y-6">
<!-- 转换工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="() => convertTo('unicode')"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'arrow-right']" class="mr-2" />
转为Unicode编码
</button>
<button
@click="() => convertTo('text')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'arrow-left']" class="mr-2" />
解码为文本
</button>
<button
@click="() => convertTo('hex')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'hashtag']" class="mr-2" />
转为16进制
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清空
</button>
</div>
</div>
<!-- 输入输出区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输入</h3>
<button
@click="pasteFromClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="粘贴"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" />
</button>
</div>
<textarea
v-model="inputText"
placeholder="输入要转换的文本或Unicode编码..."
class="textarea-field h-80"
/>
<div class="mt-3 text-sm text-tertiary">
<p>字符数量: {{ inputText.length }}</p>
</div>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输出</h3>
<button
@click="copyToClipboard"
:disabled="!outputText"
class="p-2 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50"
title="复制"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="copied && 'text-success'"
/>
</button>
</div>
<textarea
v-model="outputText"
placeholder="转换结果将显示在这里..."
class="textarea-field h-80"
readonly
/>
</div>
</div>
<!-- 字符信息 -->
<div v-if="charInfo.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">字符详细信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="(char, index) in charInfo"
:key="index"
class="bg-block p-3 rounded"
>
<div class="text-center space-y-2">
<div class="text-2xl font-bold text-primary">{{ char.char }}</div>
<div class="text-sm text-secondary space-y-1">
<div><strong>Unicode:</strong> {{ char.unicode }}</div>
<div><strong>UTF-8:</strong> {{ char.utf8 }}</div>
<div><strong>十进制:</strong> {{ char.decimal }}</div>
<div><strong>十六进制:</strong> {{ char.hex }}</div>
<div v-if="char.description"><strong>描述:</strong> {{ char.description }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 快速转换示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">快速转换示例</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="font-medium text-secondary mb-2">常用字符</h4>
<div class="space-y-2">
<button
v-for="example in examples"
:key="example.text"
@click="() => useExample(example.text)"
class="block w-full text-left p-2 bg-block hover:bg-hover rounded text-sm"
>
<span class="font-mono">{{ example.text }}</span>
<span class="text-secondary ml-2">{{ example.unicode }}</span>
</button>
</div>
</div>
<div>
<h4 class="font-medium text-secondary mb-2">转换格式说明</h4>
<div class="space-y-2 text-sm">
<div class="bg-block p-3 rounded">
<div><strong>Unicode编码格式:</strong></div>
<div class="font-mono text-xs">
\\u4E2D (JavaScript格式)<br>
U+4E2D (标准格式)<br>
&#20013; (HTML实体)
</div>
</div>
<div class="bg-block p-3 rounded">
<div><strong>支持的输入格式:</strong></div>
<div class="text-xs">
普通文本<br>
Unicode编码序列<br>
十六进制编码
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
// 响应式状态
const inputText = ref('')
const outputText = ref('')
const copied = ref(false)
const charInfo = ref<Array<{
char: string
unicode: string
utf8: string
decimal: number
hex: string
description?: string
}>>([])
// 示例数据
const examples = [
{ text: '中', unicode: '\\u4E2D' },
{ text: '文', unicode: '\\u6587' },
{ text: '😀', unicode: '\\uD83D\\uDE00' },
{ text: '©', unicode: '\\u00A9' },
{ text: '™', unicode: '\\u2122' },
{ text: '€', unicode: '\\u20AC' }
]
// 文本转Unicode
const textToUnicode = (text: string): string => {
return text.split('').map(char => {
const code = char.charCodeAt(0)
if (code > 127) {
return '\\u' + code.toString(16).toUpperCase().padStart(4, '0')
}
return char
}).join('')
}
// Unicode转文本
const unicodeToText = (unicode: string): string => {
try {
// 处理不同格式的Unicode编码
let processedUnicode = unicode
.replace(/\\u([0-9a-fA-F]{4})/g, '\\u$1')
.replace(/U\+([0-9a-fA-F]{4})/g, '\\u$1')
.replace(/&#(\d+);/g, (match, dec) => {
const hex = parseInt(dec).toString(16).toUpperCase().padStart(4, '0')
return '\\u' + hex
})
return JSON.parse('"' + processedUnicode + '"')
} catch (error) {
throw new Error('Unicode格式错误')
}
}
// 文本转十六进制
const textToHex = (text: string): string => {
return text.split('').map(char => {
const code = char.charCodeAt(0)
return '0x' + code.toString(16).toUpperCase().padStart(4, '0')
}).join(' ')
}
// 转换函数
const convertTo = (type: 'unicode' | 'text' | 'hex') => {
if (!inputText.value.trim()) return
try {
switch (type) {
case 'unicode':
outputText.value = textToUnicode(inputText.value)
break
case 'text':
outputText.value = unicodeToText(inputText.value)
break
case 'hex':
outputText.value = textToHex(inputText.value)
break
}
} catch (error) {
outputText.value = '转换失败: ' + (error instanceof Error ? error.message : '未知错误')
}
}
// 分析字符信息
const analyzeCharacters = (text: string) => {
if (!text || text.length > 20) {
charInfo.value = []
return
}
charInfo.value = text.split('').map(char => {
const code = char.charCodeAt(0)
const unicode = '\\u' + code.toString(16).toUpperCase().padStart(4, '0')
// UTF-8 编码
const utf8Bytes = new TextEncoder().encode(char)
const utf8 = Array.from(utf8Bytes).map(b => '0x' + b.toString(16).toUpperCase()).join(' ')
return {
char,
unicode,
utf8,
decimal: code,
hex: '0x' + code.toString(16).toUpperCase(),
description: getCharDescription(char, code)
}
})
}
// 获取字符描述
const getCharDescription = (char: string, code: number): string | undefined => {
if (code >= 0x4E00 && code <= 0x9FFF) return 'CJK统一汉字'
if (code >= 0x3040 && code <= 0x309F) return '平假名'
if (code >= 0x30A0 && code <= 0x30FF) return '片假名'
if (code >= 0x1F600 && code <= 0x1F64F) return 'Emoji表情'
if (code >= 0x0020 && code <= 0x007F) return 'ASCII字符'
if (code >= 0x00A0 && code <= 0x00FF) return 'Latin-1补充'
return undefined
}
// 使用示例
const useExample = (text: string) => {
inputText.value = text
convertTo('unicode')
}
// 清空所有内容
const clearAll = () => {
inputText.value = ''
outputText.value = ''
charInfo.value = []
}
// 粘贴功能
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
inputText.value = text
} catch (error) {
console.error('粘贴失败:', error)
}
}
// 复制功能
const copyToClipboard = async () => {
if (!outputText.value) return
try {
await navigator.clipboard.writeText(outputText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 监听输入变化,自动分析字符
watch(inputText, (newValue) => {
analyzeCharacters(newValue)
}, { immediate: true })
</script>

View File

@ -0,0 +1,341 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="() => convert('encode')"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'lock']" class="mr-2" />
URL编码
</button>
<button
@click="() => convert('decode')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'unlock']" class="mr-2" />
URL解码
</button>
<button
@click="() => convert('component-encode')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'compress']" class="mr-2" />
组件编码
</button>
<button
@click="() => convert('component-decode')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'expand']" class="mr-2" />
组件解码
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清空
</button>
</div>
</div>
<!-- 输入输出区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输入</h3>
<button
@click="pasteFromClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="粘贴"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" />
</button>
</div>
<textarea
v-model="inputText"
placeholder="输入要编码或解码的URL或文本..."
class="textarea-field h-80"
/>
<div class="mt-3 text-sm text-tertiary">
<p>字符数量: {{ inputText.length }}</p>
</div>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输出</h3>
<button
@click="copyToClipboard"
:disabled="!outputText"
class="p-2 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50"
title="复制"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="copied && 'text-success'"
/>
</button>
</div>
<textarea
v-model="outputText"
placeholder="转换结果将显示在这里..."
class="textarea-field h-80"
readonly
/>
</div>
</div>
<!-- URL 分析 -->
<div v-if="urlParts" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">URL 分析</h3>
<div class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-if="urlParts.protocol" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">协议</div>
<div class="font-mono text-sm">{{ urlParts.protocol }}</div>
</div>
<div v-if="urlParts.hostname" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">主机名</div>
<div class="font-mono text-sm">{{ urlParts.hostname }}</div>
</div>
<div v-if="urlParts.port" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">端口</div>
<div class="font-mono text-sm">{{ urlParts.port }}</div>
</div>
<div v-if="urlParts.pathname" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">路径</div>
<div class="font-mono text-sm">{{ urlParts.pathname }}</div>
</div>
<div v-if="urlParts.search" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">查询参数</div>
<div class="font-mono text-sm">{{ urlParts.search }}</div>
</div>
<div v-if="urlParts.hash" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">锚点</div>
<div class="font-mono text-sm">{{ urlParts.hash }}</div>
</div>
</div>
<div v-if="queryParams.length > 0" class="mt-4">
<h4 class="text-lg font-semibold text-primary mb-2">查询参数详情</h4>
<div class="space-y-2">
<div
v-for="(param, index) in queryParams"
:key="index"
class="bg-block p-3 rounded flex justify-between"
>
<div class="font-mono text-sm">
<span class="text-primary">{{ param.key }}</span>
<span class="text-secondary mx-2">=</span>
<span class="text-secondary">{{ param.value }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 编码对照表 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">常用字符编码对照表</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div
v-for="char in commonChars"
:key="char.original"
class="bg-block p-3 rounded text-center"
>
<div class="text-lg font-bold text-primary">{{ char.original }}</div>
<div class="text-sm text-secondary font-mono">{{ char.encoded }}</div>
</div>
</div>
</div>
<!-- 快速示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">快速示例</h3>
<div class="space-y-3">
<div
v-for="example in examples"
:key="example.name"
class="bg-block p-3 rounded"
>
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-secondary">{{ example.name }}</h4>
<button
@click="() => useExample(example.url)"
class="btn-secondary text-sm"
>
使用此示例
</button>
</div>
<div class="font-mono text-sm break-all">{{ example.url }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
// 响应式状态
const inputText = ref('')
const outputText = ref('')
const copied = ref(false)
const urlParts = ref<any>(null)
const queryParams = ref<Array<{ key: string, value: string }>>([])
// 常用字符编码对照
const commonChars = [
{ original: ' ', encoded: '%20' },
{ original: '!', encoded: '%21' },
{ original: '#', encoded: '%23' },
{ original: '$', encoded: '%24' },
{ original: '&', encoded: '%26' },
{ original: "'", encoded: '%27' },
{ original: '(', encoded: '%28' },
{ original: ')', encoded: '%29' },
{ original: '+', encoded: '%2B' },
{ original: ',', encoded: '%2C' },
{ original: '/', encoded: '%2F' },
{ original: ':', encoded: '%3A' },
{ original: ';', encoded: '%3B' },
{ original: '=', encoded: '%3D' },
{ original: '?', encoded: '%3F' },
{ original: '@', encoded: '%40' }
]
// 示例URL
const examples = [
{
name: 'Google搜索',
url: 'https://www.google.com/search?q=URL编码&hl=zh-CN'
},
{
name: '包含中文的URL',
url: 'https://example.com/用户/信息?姓名=张三&年龄=25'
},
{
name: '包含特殊字符',
url: 'https://api.example.com/data?filter=name eq "John Doe"&sort=created_at desc'
},
{
name: '已编码的URL',
url: 'https://example.com/%E7%94%A8%E6%88%B7?name=%E5%BC%A0%E4%B8%89'
}
]
// 转换函数
const convert = (type: 'encode' | 'decode' | 'component-encode' | 'component-decode') => {
if (!inputText.value.trim()) return
try {
switch (type) {
case 'encode':
outputText.value = encodeURI(inputText.value)
break
case 'decode':
outputText.value = decodeURI(inputText.value)
break
case 'component-encode':
outputText.value = encodeURIComponent(inputText.value)
break
case 'component-decode':
outputText.value = decodeURIComponent(inputText.value)
break
}
} catch (error) {
outputText.value = '转换失败: ' + (error instanceof Error ? error.message : '未知错误')
}
}
// 分析URL结构
const analyzeURL = (url: string) => {
try {
const urlObj = new URL(url)
urlParts.value = {
protocol: urlObj.protocol,
hostname: urlObj.hostname,
port: urlObj.port || '默认端口',
pathname: urlObj.pathname,
search: urlObj.search,
hash: urlObj.hash
}
// 解析查询参数
queryParams.value = []
urlObj.searchParams.forEach((value, key) => {
queryParams.value.push({ key, value })
})
} catch (error) {
urlParts.value = null
queryParams.value = []
}
}
// 使用示例
const useExample = (url: string) => {
inputText.value = url
}
// 清空所有内容
const clearAll = () => {
inputText.value = ''
outputText.value = ''
urlParts.value = null
queryParams.value = []
}
// 粘贴功能
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
inputText.value = text
} catch (error) {
console.error('粘贴失败:', error)
}
}
// 复制功能
const copyToClipboard = async () => {
if (!outputText.value) return
try {
await navigator.clipboard.writeText(outputText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 监听输入变化分析URL
watch(inputText, (newValue) => {
if (newValue.trim()) {
analyzeURL(newValue)
} else {
urlParts.value = null
queryParams.value = []
}
}, { immediate: true })
</script>

View File

@ -0,0 +1,489 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="() => convert('yml-to-properties')"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'arrow-right']" class="mr-2" />
YML Properties
</button>
<button
@click="() => convert('properties-to-yml')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'arrow-left']" class="mr-2" />
Properties YML
</button>
<button
@click="() => convert('yml-to-json')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-2" />
YML JSON
</button>
<button
@click="() => convert('json-to-yml')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'file-code']" class="mr-2" />
JSON YML
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清空
</button>
</div>
</div>
<!-- 输入输出区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输入</h3>
<div class="flex space-x-2">
<select
v-model="inputFormat"
class="input-field text-sm py-1 px-2"
>
<option value="yml">YAML/YML</option>
<option value="properties">Properties</option>
<option value="json">JSON</option>
</select>
<button
@click="pasteFromClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="粘贴"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" />
</button>
</div>
</div>
<textarea
v-model="inputText"
:placeholder="getInputPlaceholder()"
class="textarea-field h-80 font-mono text-sm"
/>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输出</h3>
<div class="flex space-x-2">
<select
v-model="outputFormat"
class="input-field text-sm py-1 px-2"
>
<option value="yml">YAML/YML</option>
<option value="properties">Properties</option>
<option value="json">JSON</option>
</select>
<button
@click="copyToClipboard"
:disabled="!outputText"
class="p-2 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50"
title="复制"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="copied && 'text-success'"
/>
</button>
</div>
</div>
<textarea
v-model="outputText"
placeholder="转换结果将显示在这里..."
class="textarea-field h-80 font-mono text-sm"
readonly
/>
</div>
</div>
<!-- 状态信息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusMessage.type === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusMessage.type === 'success' ? ['fas', 'check'] : ['fas', 'exclamation-triangle']"
/>
<span>{{ statusMessage.text }}</span>
</div>
</div>
<!-- 示例 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- YAML 示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">YAML 示例</h3>
<button
@click="() => useExample(yamlExample)"
class="btn-secondary mb-3 w-full"
>
使用此示例
</button>
<pre class="bg-block p-3 rounded text-xs overflow-x-auto"><code>{{ yamlExample }}</code></pre>
</div>
<!-- Properties 示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">Properties 示例</h3>
<button
@click="() => useExample(propertiesExample)"
class="btn-secondary mb-3 w-full"
>
使用此示例
</button>
<pre class="bg-block p-3 rounded text-xs overflow-x-auto"><code>{{ propertiesExample }}</code></pre>
</div>
<!-- JSON 示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">JSON 示例</h3>
<button
@click="() => useExample(jsonExample)"
class="btn-secondary mb-3 w-full"
>
使用此示例
</button>
<pre class="bg-block p-3 rounded text-xs overflow-x-auto"><code>{{ jsonExample }}</code></pre>
</div>
</div>
<!-- 格式说明 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">格式说明</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-block p-3 rounded">
<h4 class="font-medium text-secondary mb-2">YAML/YML</h4>
<ul class="text-sm space-y-1">
<li> 使用缩进表示层级关系</li>
<li> 支持列表和对象结构</li>
<li> 可读性强常用于配置文件</li>
<li> 不支持注释的转换</li>
</ul>
</div>
<div class="bg-block p-3 rounded">
<h4 class="font-medium text-secondary mb-2">Properties</h4>
<ul class="text-sm space-y-1">
<li> 键值对格式 key=value</li>
<li> 使用点号表示层级</li>
<li> Java 项目常用配置格式</li>
<li> 扁平化结构</li>
</ul>
</div>
<div class="bg-block p-3 rounded">
<h4 class="font-medium text-secondary mb-2">JSON</h4>
<ul class="text-sm space-y-1">
<li> JavaScript 对象标记法</li>
<li> 支持复杂数据结构</li>
<li> API 和数据交换常用格式</li>
<li> 严格的语法规则</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
// 响应式状态
const inputText = ref('')
const outputText = ref('')
const inputFormat = ref('yml')
const outputFormat = ref('properties')
const copied = ref(false)
const statusMessage = ref<{ type: 'success' | 'error', text: string } | null>(null)
// 示例数据
const yamlExample = `server:
port: 8080
host: localhost
database:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: secret
logging:
level:
root: INFO
com.example: DEBUG`
const propertiesExample = `server.port=8080
server.host=localhost
database.url=jdbc:mysql://localhost:3306/mydb
database.username=root
database.password=secret
logging.level.root=INFO
logging.level.com.example=DEBUG`
const jsonExample = `{
"server": {
"port": 8080,
"host": "localhost"
},
"database": {
"url": "jdbc:mysql://localhost:3306/mydb",
"username": "root",
"password": "secret"
},
"logging": {
"level": {
"root": "INFO",
"com.example": "DEBUG"
}
}
}`
// 获取输入提示
const getInputPlaceholder = (): string => {
switch (inputFormat.value) {
case 'yml':
return '输入YAML格式的配置...'
case 'properties':
return '输入Properties格式的配置...'
case 'json':
return '输入JSON格式的配置...'
default:
return '输入要转换的内容...'
}
}
// 简单的 YAML 解析器
const parseYaml = (yamlStr: string): any => {
const lines = yamlStr.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))
const result: any = {}
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.includes(':')) continue
const [key, ...valueParts] = trimmed.split(':')
const value = valueParts.join(':').trim()
const keys = key.trim().split('.')
let current = result
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {}
}
current = current[keys[i]]
}
current[keys[keys.length - 1]] = value || ''
}
return result
}
// 简单的 YAML 生成器
const generateYaml = (obj: any, indent: number = 0): string => {
const spaces = ' '.repeat(indent)
let result = ''
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'object' && value !== null) {
result += `${spaces}${key}:\n`
result += generateYaml(value, indent + 1)
} else {
result += `${spaces}${key}: ${value}\n`
}
}
return result
}
// 对象转 Properties
const objectToProperties = (obj: any, prefix: string = ''): string => {
let result = ''
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (typeof value === 'object' && value !== null) {
result += objectToProperties(value, fullKey)
} else {
result += `${fullKey}=${value}\n`
}
}
return result
}
// Properties 转对象
const propertiesToObject = (propertiesStr: string): any => {
const result: any = {}
const lines = propertiesStr.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))
for (const line of lines) {
const [key, ...valueParts] = line.split('=')
if (!key || valueParts.length === 0) continue
const value = valueParts.join('=').trim()
const keys = key.trim().split('.')
let current = result
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {}
}
current = current[keys[i]]
}
current[keys[keys.length - 1]] = value
}
return result
}
// 转换函数
const convert = (type: string) => {
if (!inputText.value.trim()) {
statusMessage.value = { type: 'error', text: '请输入要转换的内容' }
return
}
try {
let result = ''
switch (type) {
case 'yml-to-properties': {
const obj = parseYaml(inputText.value)
result = objectToProperties(obj)
break
}
case 'properties-to-yml': {
const obj = propertiesToObject(inputText.value)
result = generateYaml(obj)
break
}
case 'yml-to-json': {
const obj = parseYaml(inputText.value)
result = JSON.stringify(obj, null, 2)
break
}
case 'json-to-yml': {
const obj = JSON.parse(inputText.value)
result = generateYaml(obj)
break
}
default:
throw new Error('不支持的转换类型')
}
outputText.value = result
statusMessage.value = { type: 'success', text: '转换成功' }
} catch (error) {
outputText.value = ''
statusMessage.value = {
type: 'error',
text: '转换失败: ' + (error instanceof Error ? error.message : '未知错误')
}
}
}
// 自动转换
const autoConvert = () => {
if (!inputText.value.trim()) {
outputText.value = ''
return
}
const conversionMap: Record<string, string> = {
'yml-properties': 'yml-to-properties',
'yml-json': 'yml-to-json',
'properties-yml': 'properties-to-yml',
'properties-json': 'properties-to-yml',
'json-yml': 'json-to-yml',
'json-properties': 'json-to-yml'
}
const key = `${inputFormat.value}-${outputFormat.value}`
const conversionType = conversionMap[key]
if (conversionType) {
convert(conversionType)
}
}
// 使用示例
const useExample = (example: string) => {
inputText.value = example
autoConvert()
}
// 清空内容
const clearAll = () => {
inputText.value = ''
outputText.value = ''
statusMessage.value = null
}
// 粘贴功能
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
inputText.value = text
autoConvert()
} catch (error) {
console.error('粘贴失败:', error)
}
}
// 复制功能
const copyToClipboard = async () => {
if (!outputText.value) return
try {
await navigator.clipboard.writeText(outputText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 监听格式变化,自动转换
watch([inputFormat, outputFormat], () => {
if (inputText.value.trim()) {
autoConvert()
}
})
// 监听输入变化,自动转换
watch(inputText, () => {
if (inputText.value.trim()) {
autoConvert()
} else {
outputText.value = ''
statusMessage.value = null
}
}, { immediate: true })
</script>

View File

@ -0,0 +1,53 @@
import { ref, computed } from 'vue'
import type { Language } from '@/types/tools'
import { locales } from '@/config/i18n'
// 全局语言状态
const currentLanguage = ref<Language>('zh')
export const useLanguage = () => {
// 获取当前语言的翻译对象
const translation = computed(() => locales[currentLanguage.value])
// 翻译函数
const t = (key: string, params?: Record<string, string>) => {
const keys = key.split('.')
let value: any = translation.value
for (const k of keys) {
value = value?.[k]
}
let result = value || key
// 替换参数
if (params && typeof result === 'string') {
Object.keys(params).forEach(param => {
result = result.replace(`{${param}}`, params[param])
})
}
return result
}
// 切换语言
const switchLanguage = (lang: Language) => {
currentLanguage.value = lang
localStorage.setItem('preferred-language', lang)
}
// 从本地存储恢复语言
const restoreLanguage = () => {
const saved = localStorage.getItem('preferred-language') as Language
if (saved && (saved === 'zh' || saved === 'en')) {
currentLanguage.value = saved
}
}
return {
language: currentLanguage,
t,
switchLanguage,
restoreLanguage
}
}

View File

@ -0,0 +1,55 @@
import { ref, watch } from 'vue'
type Theme = 'light' | 'dark'
// 全局主题状态
const currentTheme = ref<Theme>('light')
export const useTheme = () => {
// 切换主题
const toggleTheme = () => {
currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light'
}
// 设置主题
const setTheme = (theme: Theme) => {
currentTheme.value = theme
}
// 从本地存储恢复主题
const restoreTheme = () => {
const saved = localStorage.getItem('preferred-theme') as Theme
if (saved && (saved === 'light' || saved === 'dark')) {
currentTheme.value = saved
} else {
// 检测系统主题偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
currentTheme.value = prefersDark ? 'dark' : 'light'
}
}
// 应用主题到DOM
const applyTheme = (theme: Theme) => {
const root = document.documentElement
root.setAttribute('data-theme', theme)
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
// 监听主题变化
watch(currentTheme, (newTheme) => {
applyTheme(newTheme)
localStorage.setItem('preferred-theme', newTheme)
}, { immediate: true })
return {
theme: currentTheme,
toggleTheme,
setTheme,
restoreTheme,
applyTheme
}
}

18
src/config/categories.ts Normal file
View File

@ -0,0 +1,18 @@
import type { Category } from '@/types/tools'
// 定义工具类别
const categories: Category[] = [
{ code: "all", active: true },
{ code: "common", active: false },
{ code: "json", active: false },
{ code: "encoding", active: false },
{ code: "network", active: false },
{ code: "datetime", active: false },
{ code: "code", active: false },
{ code: "text", active: false },
{ code: "image", active: false },
{ code: "frontend", active: false },
{ code: "file", active: false },
]
export default categories

350
src/config/i18n/en.ts Normal file
View File

@ -0,0 +1,350 @@
export default {
common: {
home: 'Home',
tools: 'Tools',
categories: 'Categories',
search: 'Search',
clear: 'Clear',
copy: 'Copy',
copied: 'Copied',
paste: 'Paste',
save: 'Save',
load: 'Load',
download: 'Download',
upload: 'Upload',
format: 'Format',
compress: 'Compress',
expand: 'Expand',
validate: 'Validate',
convert: 'Convert',
generate: 'Generate',
example: 'Example',
history: 'History',
favorites: 'Favorites',
settings: 'Settings',
about: 'About',
close: 'Close',
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
edit: 'Edit',
back: 'Back',
next: 'Next',
previous: 'Previous',
loading: 'Loading...',
error: 'Error',
success: 'Success',
warning: 'Warning',
info: 'Info',
placeholder: 'Please enter content...',
noData: 'No data',
retry: 'Retry',
refresh: 'Refresh',
reset: 'Reset',
submit: 'Submit'
},
categories: {
all: 'All',
common: 'Common',
json: 'JSON',
encoding: 'Encoding',
network: 'Network',
datetime: 'DateTime',
code: 'Code',
text: 'Text',
image: 'Image',
frontend: 'Frontend',
file: 'File'
},
tools: {
json_formatter: {
title: 'JSON Formatter',
description: 'JSON formatting, beautification, compression and validation tool',
placeholder_input: 'Please enter JSON content...',
placeholder_output: 'Formatted result will be displayed here...',
format: 'Format',
compress: 'Compress',
validate: 'Validate',
clear: 'Clear',
copy: 'Copy Result',
example: 'Load Example',
json_valid: 'JSON is valid',
json_invalid: 'JSON is invalid',
large_json_processed: 'Large JSON file processed ({size}KB)',
processing: 'Processing...',
cancel: 'Cancel Processing'
},
timestamp_converter: {
title: 'Timestamp Converter',
description: 'Timestamp and datetime conversion tool',
current_timestamp: 'Current Timestamp',
timestamp_to_date: 'Timestamp to Date',
date_to_timestamp: 'Date to Timestamp',
timestamp_placeholder: 'Please enter timestamp...',
date_placeholder: 'Select date time...',
convert: 'Convert',
copy_timestamp: 'Copy Timestamp',
copy_date: 'Copy Date'
},
encoding_converter: {
title: 'Encoding Converter',
description: 'Base64, URL encoding, Unicode and other encoding conversion tools',
input_placeholder: 'Please enter content to encode/decode...',
output_placeholder: 'Conversion result will be displayed here...',
base64_encode: 'Base64 Encode',
base64_decode: 'Base64 Decode',
url_encode: 'URL Encode',
url_decode: 'URL Decode',
unicode_encode: 'Unicode Encode',
unicode_decode: 'Unicode Decode'
},
chrome_bookmark_recovery: {
title: 'Chrome Bookmark Recovery',
description: 'Chrome browser bookmark file recovery tool',
upload_file: 'Upload Bookmark File',
file_analysis: 'File Analysis',
bookmark_list: 'Bookmark List',
export_bookmarks: 'Export Bookmarks',
recovery_success: 'Recovery Successful',
file_format_error: 'File Format Error',
instructions: {
windows: {
title: 'Windows System Instructions',
steps: [
'Copy C:\\Users\\%username%\\AppData\\Local\\Google\\Chrome\\User Data into File Explorer',
'In search bar, type Bookmarks, you will see a list of files named Bookmarks and/or Bookmarks.bak',
'Note: If there is more than one user using the same Chrome, bookmarks from other users will be listed too',
'Select all the files with mouse and drag them to the upload area below',
'Download all the converted HTML files',
'Open each HTML file with Chrome and determine the HTML file that contains your bookmarks (Note: The largest file is most likely the correct one)',
'In your Chrome browser, click the Chrome menu icon and go to Bookmarks > Bookmark Manager',
'Click the menu icon beside search bar and click "Import Bookmarks"',
'Select the HTML file that contains your bookmarks',
'Your bookmarks should now be imported back to Chrome'
]
}
}
},
ip_lookup: {
title: 'IP Lookup',
description: 'IP address geolocation query and type analysis tool',
ip_address: 'IP Address',
lookup: 'Lookup',
query: 'Query',
get_my_ip: 'Get My IP',
clear: 'Clear',
ip_input: 'IP Input',
placeholder: 'Please enter IP address...',
common_ips: 'Common IPs',
querying: 'Querying...',
ip_info: 'IP Information',
location: 'Location',
isp: 'ISP',
organization: 'Organization',
country: 'Country',
region: 'Region',
city: 'City',
timezone: 'Timezone',
coordinates: 'Coordinates',
ip_type: 'IP Type',
public_ip: 'Public IP',
private_ip: 'Private IP',
current_ip: 'Current IP',
ip_analysis: 'IP Analysis',
format: 'Format',
access_type: 'Access Type',
class: 'IP Class'
},
seal_generator: {
title: 'Seal Generator',
description: 'Electronic seal creation tool, supports company and personal seals',
basic_settings: 'Basic Settings',
text_settings: 'Text Settings',
border_settings: 'Border Settings',
seal_type: 'Seal Type',
company_round: 'Company Round Seal',
company_oval: 'Company Oval Seal',
personal_square: 'Personal Square Seal',
personal_round: 'Personal Round Seal',
seal_size: 'Seal Size',
seal_color: 'Seal Color',
background_color: 'Background Color',
main_text: 'Main Text',
center_text: 'Center Text',
header_text: 'Header Text',
sub_text: 'Sub Text',
font_size: 'Font Size',
main_font_size: 'Main Font Size',
center_font_size: 'Center Font Size',
outer_border_width: 'Outer Border Width',
inner_border_width: 'Inner Border Width',
show_star: 'Show Star',
generate_seal: 'Generate Seal',
download_seal: 'Download Seal',
clear_all: 'Clear All',
seal_preview: 'Seal Preview',
company_name_placeholder: 'Company Name',
personal_name_placeholder: 'Name',
center_text_placeholder: 'Seal',
header_text_placeholder: 'e.g., Head Office, Branch',
sub_text_placeholder: 'e.g., Co., Ltd., Corp.',
preview_tip: 'Please fill in seal information and click generate',
usage_instructions: 'Usage Instructions',
company_seal_features: 'Company Seal Features',
personal_seal_features: 'Personal Seal Features',
important_notice: 'Important Notice',
legal_notice: 'The seals generated by this tool are for learning and testing purposes only. Do not use for illegal purposes. Official company seals need to be registered with relevant authorities.'
},
text_counter: {
title: 'Text Counter',
description: 'Word count, character count, line count and other text analysis tools',
clear: 'Clear',
paste: 'Paste',
sample: 'Sample',
text_input: 'Text Input',
placeholder: 'Please enter or paste text to analyze...',
basic_stats: 'Basic Statistics',
characters: 'Characters',
characters_no_spaces: 'Characters (No Spaces)',
words: 'Words',
lines: 'Lines',
paragraphs: 'Paragraphs',
sentences: 'Sentences',
character_types: 'Character Types',
letters: 'Letters',
numbers: 'Numbers',
spaces: 'Spaces',
punctuation: 'Punctuation',
reading_time: 'Reading Time',
slow_reading: 'Slow Reading',
normal_reading: 'Normal Reading',
fast_reading: 'Fast Reading',
top_words: 'Top Words',
character_count: 'Character Count',
word_count: 'Word Count',
line_count: 'Line Count',
paragraph_count: 'Paragraph Count',
sentence_count: 'Sentence Count',
reading_speed: 'Reading Speed',
fast: 'Fast',
normal: 'Normal',
slow: 'Slow',
most_used_words: 'Most Used Words'
},
image_compressor: {
title: 'Image Compressor',
description: 'Image compression and quality adjustment tool',
compress: 'Compress',
download: 'Download',
reset: 'Reset',
upload_image: 'Upload Image',
click_or_drag: 'Click or drag to upload image',
compression_settings: 'Compression Settings',
quality: 'Quality',
max_width: 'Max Width',
max_height: 'Max Height',
keep_aspect_ratio: 'Keep Aspect Ratio',
output_format: 'Output Format',
original_info: 'Original Info',
size: 'File Size',
dimensions: 'Dimensions',
format: 'Format',
original_preview: 'Original Preview',
compressed_preview: 'Compressed Preview',
compressed_size: 'Compressed Size',
compression_ratio: 'Compression Ratio',
size_reduction: 'Size Reduction',
compressing: 'Compressing...',
width: 'Width',
height: 'Height',
original_size: 'Original Size',
maintain_aspect_ratio: 'Maintain Aspect Ratio',
download_result: 'Download Result'
},
qrcode_generator: {
title: 'QR Code Generator',
description: 'Text to QR code generation tool with customizable styles',
generate: 'Generate',
download: 'Download',
clear: 'Clear',
text_input: 'Text Input',
placeholder: 'Please enter content to generate QR code...',
settings: 'Settings',
size: 'Size',
qr_size: 'QR Code Size',
error_level: 'Error Level',
foreground_color: 'Foreground Color',
background_color: 'Background Color',
preview: 'Preview',
qr_code: 'QR Code',
generating: 'Generating...',
no_preview: 'Please enter content and generate QR code',
download_qr: 'Download QR Code',
qr_preview: 'QR Code Preview'
},
code_formatter: {
title: 'Code Formatter',
description: 'Code beautification, formatting and minification tool',
format: 'Format',
minify: 'Minify',
copy: 'Copy',
clear: 'Clear',
language: 'Language',
indent_size: 'Indent Size',
line_width: 'Line Width',
input: 'Input',
output: 'Output',
output_placeholder: 'Formatted result will be displayed here...',
copy_result: 'Copy Result',
clear_all: 'Clear All',
input_placeholder: 'Please enter code...',
line_count: 'Line Count',
char_count: 'Character Count'
},
base64_to_image: {
title: 'Base64 to Image',
description: 'Base64 and image bidirectional conversion tool',
base64_to_image: 'Base64 to Image',
download_image: 'Download Image',
clear: 'Clear',
base64_input: 'Base64 Input',
base64_placeholder: 'Please enter Base64 encoding...',
image_to_base64: 'Image to Base64',
click_or_drag: 'Click or drag to upload image',
image_preview: 'Image Preview',
preview_image: 'Preview Image',
converting: 'Converting...',
no_preview: 'Please enter Base64 or upload image',
base64_output: 'Base64 Output',
upload_image: 'Upload Image',
image_info: 'Image Info',
copy_base64: 'Copy Base64'
},
yml_properties_converter: {
title: 'YML/Properties Converter',
description: 'YML and Properties configuration file format converter',
yml_to_properties: 'YML to Properties',
properties_to_yml: 'Properties to YML',
yml_to_json: 'YML to JSON',
json_to_yml: 'JSON to YML',
clear: 'Clear',
input: 'Input',
output: 'Output',
paste: 'Paste',
copy: 'Copy',
input_format: 'Input Format',
output_format: 'Output Format',
input_placeholder_yml: 'Enter YAML content...',
input_placeholder_properties: 'Enter Properties content...',
input_placeholder_json: 'Enter JSON content...',
output_placeholder: 'Conversion result will be displayed here...',
use_example: 'Use This Example',
format_description: 'Format Description',
yml_example: 'YAML Example',
properties_example: 'Properties Example',
json_example: 'JSON Example',
convert: 'Convert',
conversion_result: 'Conversion Result'
}
}
}

23
src/config/i18n/index.ts Normal file
View File

@ -0,0 +1,23 @@
import zh from './zh'
import en from './en'
import type { Language } from '@/types/tools'
export const locales = {
zh,
en
}
export const getTranslation = (lang: Language = 'zh') => locales[lang]
export const t = (key: string, lang: Language = 'zh') => {
const keys = key.split('.')
let value: any = locales[lang]
for (const k of keys) {
value = value?.[k]
}
return value || key
}
export default locales

708
src/config/i18n/zh.ts Normal file
View File

@ -0,0 +1,708 @@
export default {
common: {
home: '首页',
tools: '工具箱',
categories: '分类',
search: '搜索',
clear: '清除',
copy: '复制',
copied: '已复制',
paste: '粘贴',
save: '保存',
load: '加载',
download: '下载',
upload: '上传',
format: '格式化',
compress: '压缩',
expand: '展开',
validate: '验证',
convert: '转换',
generate: '生成',
example: '示例',
history: '历史',
favorites: '收藏',
settings: '设置',
about: '关于',
close: '关闭',
cancel: '取消',
confirm: '确认',
delete: '删除',
edit: '编辑',
back: '返回',
next: '下一步',
previous: '上一步',
loading: '加载中...',
error: '错误',
success: '成功',
warning: '警告',
info: '信息',
placeholder: '请输入内容...',
noData: '暂无数据',
retry: '重试',
refresh: '刷新',
reset: '重置',
submit: '提交',
input: '输入',
output: '输出',
result: '结果',
preview: '预览',
send: '发送',
add: '添加',
remove: '移除'
},
categories: {
all: '全部',
common: '常用',
json: 'JSON',
encoding: '编码',
network: '网络',
datetime: '时间',
code: '代码',
text: '文本',
image: '图像',
frontend: '前端',
file: '文件'
},
tools: {
json_formatter: {
title: 'JSON 格式化',
description: 'JSON格式化、美化、压缩和验证工具',
input_json: '输入JSON',
output: '输出',
paste_json_here: '粘贴JSON到这里',
paste_json_placeholder: '请粘贴或输入JSON内容...',
output_placeholder: '格式化结果将显示在这里',
characters: '字符',
processing: '处理中...',
processing_large_json: '处理大型JSON中...',
processing_large_json_message: '正在处理大型JSON文件请稍候...',
parsing_json: '解析JSON中...',
cancel_processing: '取消处理',
// 工具栏按钮
beautify: '美化',
compress: '压缩',
copy: '复制',
clear: '清除',
load_example: '加载示例',
reformat: '重新格式化',
save: '保存',
history: '历史记录',
remove_slash: '移除斜杠',
escape_string: '字符串转义',
unescape_string: '字符串反转义',
cancel: '取消',
// 模式切换
normal_mode: '普通模式',
fold_mode: '折叠模式',
// 验证状态
json_valid: 'JSON格式正确',
json_invalid: 'JSON格式错误',
large_json_processed: '大型JSON已处理 ({size}KB)',
// JSONPath查询
jsonpath_query: 'JSONPath查询',
enter_jsonpath: '输入JSONPath表达式查询JSON数据中的特定值',
jsonpath_placeholder: '例如: user.profile.name 或 data[0].id',
query_result_placeholder: '查询结果将显示在这里',
// 历史记录
no_history: '暂无历史记录',
favorites: '收藏夹',
all_history: '全部历史记录',
add_favorite: '添加到收藏',
remove_favorite: '取消收藏',
edit_title: '编辑标题',
delete: '删除',
// 保存模态框
save_to_history: '保存到历史记录',
edit_saved_json: '编辑已保存的JSON',
modal_title: '标题',
enter_title: '请输入标题',
update: '更新',
// 使用指南
usage_guide: '使用指南',
guide_1: '在左侧输入框中粘贴或输入JSON内容',
guide_2: '使用工具栏按钮进行格式化、压缩、验证等操作',
guide_3: '支持大型JSON文件处理会显示处理进度',
guide_4: '使用JSONPath查询功能快速定位JSON中的特定数据',
guide_5: '可以保存常用的JSON到历史记录并设置收藏',
guide_6: '支持字符串转义/反转义,处理特殊字符',
guide_7: '支持移除JSON中的转义斜杠',
guide_8: '提供折叠模式和普通模式两种视图方式'
},
http_tester: {
title: 'HTTP测试',
description: 'API接口测试、请求响应分析工具',
send_request: '发送请求',
clear: '清除',
save: '保存',
request_config: '请求配置',
headers: '请求头',
request_body: '请求体',
response: '响应结果',
status: '状态',
response_time: '响应时间',
response_size: '响应大小',
response_headers: '响应头',
response_body: '响应体',
add_header: '添加请求头',
add_param: '添加参数',
common_headers: '常用请求头',
body_type: '请求体类型',
format_json: '格式化JSON',
network_type: '网络类型',
public_network: '公网',
local_network: '本地/局域网',
mixed_content_warning: '混合内容警告',
mixed_content_description: '当前页面使用HTTPS请求HTTP接口可能被浏览器阻止',
request_headers: '请求头',
request_history: '请求历史',
hide: '隐藏',
show: '显示',
clear_history: '清空历史',
clear_history_confirm: '确定要清空所有历史记录吗?',
no_history: '暂无历史记录',
sending_request: '正在发送请求...',
response_content: '响应内容',
raw: '原始',
format: '格式化',
request_info: '请求信息',
request_details: '请求详情',
method: '方法',
url: 'URL',
response_stats: '响应统计',
response_result: '响应结果',
content_type: '内容类型',
no_response: '暂无响应',
send_request_hint: '点击发送请求按钮开始测试',
generate_doc: '生成文档',
api_documentation: 'API接口文档',
need_advanced: '需要更强大的API测试工具',
characters: '字符',
copy: '复制'
},
timestamp_converter: {
title: '时间戳转换',
description: '时间戳与日期时间相互转换工具',
current_timestamp: '当前时间戳',
timestamp_to_date: '时间戳转日期',
date_to_timestamp: '日期转时间戳',
timestamp_placeholder: '请输入时间戳...',
date_placeholder: '选择日期时间...',
convert: '转换',
copy_timestamp: '复制时间戳',
copy_date: '复制日期',
unix_timestamp: 'Unix时间戳',
formatted_date: '格式化日期',
iso_format: 'ISO格式',
custom_format: '自定义格式'
},
encoding_converter: {
title: '编码转换',
description: 'Base64、URL编码、Unicode等编码转换工具',
input_placeholder: '请输入要编码/解码的内容...',
output_placeholder: '转换结果将显示在这里...',
base64_encode: 'Base64编码',
base64_decode: 'Base64解码',
url_encode: 'URL编码',
url_decode: 'URL解码',
unicode_encode: 'Unicode编码',
unicode_decode: 'Unicode解码',
html_encode: 'HTML编码',
html_decode: 'HTML解码'
},
regex_tester: {
title: '正则表达式测试',
description: '正则表达式匹配测试、语法验证工具',
pattern: '正则表达式',
test_text: '测试文本',
flags: '标志',
global: '全局匹配',
case_insensitive: '忽略大小写',
multiline: '多行模式',
test: '测试',
match_result: '匹配结果',
no_matches: '无匹配结果',
matches_found: '找到 {count} 个匹配',
pattern_placeholder: '请输入正则表达式...',
text_placeholder: '请输入测试文本...'
},
crypto_tools: {
title: '加密解密',
description: 'MD5、SHA、AES、DES等加密解密工具',
hash_type: '哈希类型',
encrypt: '加密',
decrypt: '解密',
key: '密钥',
iv: '初始向量',
mode: '模式',
padding: '填充',
input_text: '输入文本',
output_text: '输出文本',
key_placeholder: '请输入密钥...',
text_placeholder: '请输入要加密/解密的文本...'
},
color_tools: {
title: '颜色工具',
description: 'RGB、HEX、HSL颜色转换调色板',
color_picker: '颜色选择器',
hex: 'HEX',
rgb: 'RGB',
hsl: 'HSL',
hsv: 'HSV',
cmyk: 'CMYK',
color_palette: '调色板',
copy_color: '复制颜色值',
random_color: '随机颜色',
color_harmony: '配色方案'
},
code_formatter: {
title: '代码格式化',
description: '代码美化、格式化、压缩工具',
format: '格式化',
minify: '压缩',
copy: '复制',
clear: '清除',
language: '语言',
indent_size: '缩进大小',
line_width: '行宽',
input: '输入',
output: '输出',
output_placeholder: '格式化结果将显示在这里...',
copy_result: '复制结果',
clear_all: '清除全部',
input_placeholder: '请输入代码...',
line_count: '行数',
char_count: '字符数'
},
json_editor: {
title: 'JSON编辑器',
description: '可视化JSON编辑、树形视图工具',
tree_view: '树形视图',
text_view: '文本视图',
expand_all: '展开全部',
collapse_all: '折叠全部',
search_key: '搜索键',
add_property: '添加属性',
delete_property: '删除属性',
edit_value: '编辑值',
save_changes: '保存更改'
},
json_converter: {
title: 'JSON转换',
description: 'JSON与XML、YAML、CSV等格式互转',
source_format: '源格式',
target_format: '目标格式',
convert: '转换',
conversion_options: '转换选项',
pretty_print: '美化输出',
include_header: '包含头部',
custom_delimiter: '自定义分隔符'
},
url_encoder: {
title: 'URL编码',
description: 'URL编码解码、网址转换工具',
encode: '编码',
decode: '解码',
component_encode: '组件编码',
component_decode: '组件解码',
input_url: '输入URL',
encoded_url: '编码后URL',
decode_url: '解码后URL'
},
unicode_converter: {
title: 'Unicode转换',
description: 'Unicode与中文字符互转工具',
to_unicode: '转为Unicode',
from_unicode: '从Unicode转换',
chinese_text: '中文文本',
unicode_text: 'Unicode文本',
escape_format: '转义格式',
format_js: 'JavaScript格式',
format_java: 'Java格式',
format_python: 'Python格式'
},
jwt_decoder: {
title: 'JWT解码',
description: 'JWT令牌解析验证工具',
jwt_token: 'JWT令牌',
header: '头部',
payload: '载荷',
signature: '签名',
decoded_header: '解码头部',
decoded_payload: '解码载荷',
token_info: '令牌信息',
algorithm: '算法',
issued_at: '签发时间',
expires_at: '过期时间',
verify_signature: '验证签名'
},
ip_lookup: {
title: 'IP查询',
description: 'IP地址归属地查询、类型分析工具',
ip_address: 'IP地址',
lookup: '查询',
query: '查询',
get_my_ip: '获取我的IP',
clear: '清除',
ip_input: 'IP输入',
placeholder: '请输入IP地址...',
common_ips: '常用IP',
querying: '查询中...',
ip_info: 'IP信息',
location: '位置信息',
isp: '运营商',
organization: '组织',
country: '国家',
region: '省份',
city: '城市',
timezone: '时区',
coordinates: '坐标',
ip_type: 'IP类型',
public_ip: '公网IP',
private_ip: '私网IP',
current_ip: '当前IP',
ip_analysis: 'IP分析',
format: '格式',
access_type: '访问类型',
class: 'IP类别'
},
date_calculator: {
title: '日期计算',
description: '日期差值、天数计算工具',
start_date: '开始日期',
end_date: '结束日期',
calculate: '计算',
date_diff: '日期差值',
days: '天',
hours: '小时',
minutes: '分钟',
seconds: '秒',
add_time: '添加时间',
subtract_time: '减去时间',
time_unit: '时间单位'
},
timezone_converter: {
title: '时区转换',
description: '全球时区时间转换工具',
source_timezone: '源时区',
target_timezone: '目标时区',
source_time: '源时间',
converted_time: '转换后时间',
convert: '转换',
common_timezones: '常用时区',
utc_time: 'UTC时间',
local_time: '本地时间'
},
text_counter: {
title: '文本统计',
description: '字数、词数、行数等统计分析工具',
clear: '清除',
paste: '粘贴',
sample: '示例',
text_input: '文本输入',
placeholder: '请输入或粘贴需要统计的文本...',
basic_stats: '基础统计',
characters: '字符数',
characters_no_spaces: '字符数(不含空格)',
words: '词数',
lines: '行数',
paragraphs: '段落数',
sentences: '句子数',
character_types: '字符类型',
letters: '字母',
numbers: '数字',
spaces: '空格',
punctuation: '标点符号',
reading_time: '阅读时间',
slow_reading: '慢速阅读',
normal_reading: '正常阅读',
fast_reading: '快速阅读',
top_words: '最常用词汇',
character_count: '字符数',
word_count: '词数',
line_count: '行数',
paragraph_count: '段落数',
sentence_count: '句子数',
reading_speed: '阅读速度',
fast: '快速',
normal: '正常',
slow: '慢速',
most_used_words: '最常用词汇'
},
text_space_stripper: {
title: '文本空格清理',
description: '空格、换行符、制表符处理工具',
process: '处理',
processing_options: '处理选项',
remove_leading: '移除行首空格',
remove_trailing: '移除行尾空格',
remove_all_spaces: '移除所有空格',
collapse_spaces: '合并连续空格',
remove_tabs: '移除制表符',
tabs_to_spaces: '制表符转空格',
remove_empty_lines: '移除空行',
collapse_empty_lines: '合并空行',
normalize_line_endings: '统一换行符',
preset_configs: '预设配置',
apply_preset: '应用预设',
processing_log: '处理日志',
statistics: '统计信息'
},
html_markdown_converter: {
title: 'HTML/Markdown转换',
description: 'HTML与Markdown文档格式双向转换',
html_to_markdown: 'HTML转Markdown',
markdown_to_html: 'Markdown转HTML',
convert: '转换',
preview: '预览',
html_input: 'HTML输入',
markdown_input: 'Markdown输入',
conversion_options: '转换选项',
preserve_whitespace: '保留空格',
gfm_mode: 'GitHub模式'
},
image_compressor: {
title: '图片压缩',
description: '图片压缩优化、质量调整工具',
compress: '压缩',
download: '下载',
reset: '重置',
upload_image: '上传图片',
click_or_drag: '点击或拖拽上传图片',
compression_settings: '压缩设置',
quality: '质量',
max_width: '最大宽度',
max_height: '最大高度',
keep_aspect_ratio: '保持宽高比',
output_format: '输出格式',
original_info: '原图信息',
size: '文件大小',
dimensions: '尺寸',
format: '格式',
original_preview: '原图预览',
compressed_preview: '压缩预览',
compressed_size: '压缩后大小',
compression_ratio: '压缩比',
size_reduction: '减少大小',
compressing: '压缩中...',
width: '宽度',
height: '高度',
original_size: '原始大小',
maintain_aspect_ratio: '保持宽高比',
download_result: '下载结果'
},
qrcode_generator: {
title: '二维码生成',
description: '文本转二维码、自定义样式工具',
generate: '生成',
download: '下载',
clear: '清除',
text_input: '文本输入',
placeholder: '请输入要生成二维码的内容...',
settings: '设置选项',
size: '尺寸',
qr_size: '二维码大小',
error_level: '容错级别',
foreground_color: '前景色',
background_color: '背景色',
preview: '预览',
qr_code: '二维码',
generating: '生成中...',
no_preview: '请输入内容并生成二维码',
download_qr: '下载二维码',
qr_preview: '二维码预览'
},
css_gradient_generator: {
title: 'CSS渐变生成',
description: '线性/径向渐变代码生成工具',
gradient_type: '渐变类型',
linear_gradient: '线性渐变',
radial_gradient: '径向渐变',
angle: '角度',
direction: '方向',
color_stops: '颜色停止点',
add_color: '添加颜色',
remove_color: '删除颜色',
position: '位置',
css_code: 'CSS代码',
copy_css: '复制CSS',
preview: '预览'
},
number_base_converter: {
title: '进制转换',
description: '二进制、八进制、十六进制转换工具',
decimal: '十进制',
binary: '二进制',
octal: '八进制',
hexadecimal: '十六进制',
convert: '转换',
input_number: '输入数字',
conversion_result: '转换结果',
base_system: '进制系统'
},
yml_properties_converter: {
title: 'YML/Properties转换',
description: 'YML与Properties配置文件格式互转',
yml_to_properties: 'YML转Properties',
properties_to_yml: 'Properties转YML',
yml_to_json: 'YML转JSON',
json_to_yml: 'JSON转YML',
clear: '清空',
input: '输入',
output: '输出',
paste: '粘贴',
copy: '复制',
input_format: '输入格式',
output_format: '输出格式',
input_placeholder_yml: '请输入YAML内容...',
input_placeholder_properties: '请输入Properties内容...',
input_placeholder_json: '请输入JSON内容...',
output_placeholder: '转换结果将显示在这里...',
use_example: '使用此示例',
format_description: '格式说明',
yml_example: 'YAML 示例',
properties_example: 'Properties 示例',
json_example: 'JSON 示例',
convert: '转换',
yml_input: 'YML输入',
properties_input: 'Properties输入',
conversion_result: '转换结果'
},
base64_to_image: {
title: 'Base64转图片',
description: 'Base64与图片双向转换工具',
base64_to_image: 'Base64转图片',
download_image: '下载图片',
clear: '清除',
base64_input: 'Base64输入',
base64_placeholder: '请输入Base64编码...',
image_to_base64: '图片转Base64',
click_or_drag: '点击或拖拽上传图片',
image_preview: '图片预览',
preview_image: '预览图片',
converting: '转换中...',
no_preview: '请输入Base64或上传图片',
base64_output: 'Base64输出',
upload_image: '上传图片',
image_info: '图片信息',
copy_base64: '复制Base64'
},
image_watermark: {
title: '图片水印',
description: '文字/图片水印添加工具',
upload_image: '上传图片',
watermark_type: '水印类型',
text_watermark: '文字水印',
image_watermark: '图片水印',
watermark_text: '水印文字',
position: '位置',
opacity: '透明度',
font_size: '字体大小',
color: '颜色',
add_watermark: '添加水印',
download_result: '下载结果'
},
image_to_ico: {
title: '图片转ICO',
description: '多种格式转ICO图标工具',
upload_image: '上传图片',
ico_sizes: 'ICO尺寸',
select_sizes: '选择尺寸',
generate_ico: '生成ICO',
download_ico: '下载ICO',
multi_size: '多尺寸',
single_size: '单尺寸',
custom_size: '自定义尺寸'
},
cron_generator: {
title: 'Cron表达式生成',
description: '定时任务表达式配置工具',
cron_expression: 'Cron表达式',
preset_expressions: '预设表达式',
custom_expression: '自定义表达式',
minute: '分钟',
hour: '小时',
day: '日',
month: '月',
weekday: '星期',
year: '年',
next_run_times: '下次执行时间',
expression_description: '表达式说明',
generate: '生成',
validate: '验证'
},
chrome_bookmark_recovery: {
title: 'Chrome书签恢复',
description: 'Chrome浏览器书签文件恢复工具',
upload_file: '上传书签文件',
file_analysis: '文件分析',
bookmark_list: '书签列表',
export_bookmarks: '导出书签',
recovery_success: '恢复成功',
file_format_error: '文件格式错误',
instructions: {
windows: {
title: 'Windows 系统操作说明',
steps: [
'复制路径 C:\\Users\\%username%\\AppData\\Local\\Google\\Chrome\\User Data 到文件资源管理器',
'在搜索栏中输入 Bookmarks你将看到名为 Bookmarks 和/或 Bookmarks.bak 的文件列表',
'如果同一台电脑上有多个用户使用 Chrome其他用户的书签文件也会被列出',
'选择所有文件并将它们拖拽到下方的上传区域',
'下载所有转换后的 HTML 文件',
'用 Chrome 打开每个 HTML 文件,确定哪个文件包含你的书签(通常最大的文件是正确的)',
'在 Chrome 浏览器中,点击菜单图标,进入 书签 > 书签管理器',
'点击搜索栏旁边的菜单图标,选择"导入书签"',
'选择包含你书签的 HTML 文件',
'你的书签现在应该已经导入到 Chrome 中了'
]
}
}
},
seal_generator: {
title: '印章生成器',
description: '电子印章制作工具,支持公司印章和个人印章',
basic_settings: '基础参数',
text_settings: '文字设置',
border_settings: '边框设置',
seal_type: '印章样式',
company_round: '公司圆章',
company_oval: '公司椭圆章',
personal_square: '个人方章',
personal_round: '个人圆章',
seal_size: '印章大小',
seal_color: '印章颜色',
background_color: '背景颜色',
main_text: '主文字',
center_text: '中心文字',
header_text: '抬头文字',
sub_text: '副文字',
font_size: '字体大小',
main_font_size: '主文字大小',
center_font_size: '中心文字大小',
outer_border_width: '外边线宽度',
inner_border_width: '内边线宽度',
show_star: '显示五角星',
generate_seal: '生成印章',
download_seal: '下载印章',
clear_all: '清空',
seal_preview: '印章预览',
company_name_placeholder: '公司名称',
personal_name_placeholder: '姓名',
center_text_placeholder: '印',
header_text_placeholder: '例:总公司、分公司',
sub_text_placeholder: '例:有限公司、股份有限公司',
preview_tip: '请填写印章信息并点击生成',
usage_instructions: '使用说明',
company_seal_features: '公司印章功能',
personal_seal_features: '个人印章功能',
important_notice: '重要提示',
legal_notice: '本工具生成的印章仅供学习和测试使用,请勿用于非法用途。正式的公司印章需要到相关部门进行备案注册。'
}
}
}

187
src/config/tools.ts Normal file
View File

@ -0,0 +1,187 @@
import {
faCode, faExchangeAlt, faClock, faGlobe, faLink, faLock,
faImage, faCogs, faFileCode, faKey, faFont,
faCalendarAlt, faPalette, faEdit, faRuler, faNetworkWired,
faEraser, faCalculator, faFileAlt, faBookmark, faStamp
} from '@fortawesome/free-solid-svg-icons'
import type { Tool } from '@/types/tools'
// 定义工具列表
const tools: Tool[] = [
// {
// code: 'json_formatter',
// icon: faCode,
// category: ['common', 'json'],
// keywords: ['json', 'json格式化', '格式化', '美化', '压缩', '校验', 'formatter', 'validator', 'gshjson', 'gshjson', 'jsonxg', 'jxg']
// },
{
code: 'http_tester',
icon: faGlobe,
category: ['common','network'],
keywords: ['http测试', 'api测试', '接口测试', '请求测试', 'http', 'api', 'request', 'jiekou', 'qingqiu', 'ceshi', 'postman']
},
{
code: 'timestamp_converter',
icon: faClock,
category: ['common', 'datetime'],
keywords: ['时间戳', '时间', '日期', 'timestamp', 'time', 'date', 'unix时间', 'datetime', 'shijian', 'sjc', 'sj', 'riqi', 'rq']
},
{
code: 'encoding_converter',
icon: faExchangeAlt,
category: ['common', 'encoding'],
keywords: ['编码', '解码', 'base64', 'url编码', 'unicode', '编码转换', 'encoding', 'decoding', 'bianma', 'jiema', 'bm', 'jm']
},
{
code: 'regex_tester',
icon: faKey,
category: ['text'],
keywords: ['正则', '正则表达式', 'regex', '表达式', '正则测试', 'regular expression', 'zhengze', 'zz']
},
{
code: 'crypto_tools',
icon: faLock,
category: ['encoding'],
keywords: ['加密', '解密', '哈希', 'md5', 'sha', 'aes', 'des', '加密工具', '解密工具', 'encrypt', 'decrypt', 'hash', 'jiami', 'jiemi', 'jm']
},
{
code: 'color_tools',
icon: faPalette,
category: ['frontend'],
keywords: ['颜色', '调色板', '颜色转换', '颜色选择', 'color', 'rgb', 'hex', 'hsv', 'hsl', 'yanse', 'ys', 'colors']
},
{
code: 'code_formatter',
icon: faFileCode,
category: ['code'],
keywords: ['代码格式化', '代码美化', '格式化', '美化', 'code', 'formatter', 'beautify', 'html', 'css', 'js', 'sql', 'daima', 'dm', 'geshi']
},
{
code: 'json_editor',
icon: faEdit,
category: ['common', 'json'],
keywords: ['json编辑器', 'json修改', '编辑器', 'json', 'editor', 'jsonbj', 'json编辑', 'bianji', 'bj']
},
{
code: 'json_converter',
icon: faExchangeAlt,
category: ['json'],
keywords: ['json转换', 'json2xml', 'json2csv', 'json2yaml', 'xml转json', 'csv转json', 'yaml转json', 'jsonzh', 'json互转', 'zhuanhuan', 'zh']
},
{
code: 'url_encoder',
icon: faLink,
category: ['encoding'],
keywords: ['url编码', 'url解码', 'url转换', '网址编码', 'urlencode', 'urldecode', 'url编解码', 'wangzhi', 'wz']
},
{
code: 'unicode_converter',
icon: faFont,
category: ['encoding'],
keywords: ['unicode', '中文', '编码转换', 'unicode转中文', '中文转unicode', 'zhongwen', 'zw', 'unicode编码', 'unicode解码']
},
{
code: 'jwt_decoder',
icon: faLock,
category: ['encoding'],
keywords: ['jwt', 'token', 'jwt解析', 'jwt验证', 'json web token', '令牌', 'token解析', 'jwtjx', 'lingpai']
},
{
code: 'ip_lookup',
icon: faNetworkWired,
category: ['common','network'],
keywords: ['ip查询', 'ip地址', 'ip归属地', '地址查询', 'ip', 'ip归属', 'dizhi', 'dz', 'ip地址查询', 'ipdz']
},
{
code: 'date_calculator',
icon: faCalendarAlt,
category: ['datetime'],
keywords: ['日期计算', '日期差值', '计算器', '日期', 'date', 'calculator', 'date差值', '天数计算', 'riqi', 'rq', 'jsq']
},
{
code: 'timezone_converter',
icon: faClock,
category: ['datetime'],
keywords: ['时区', '时区转换', '时间转换', '时区计算', 'timezone', 'time zone', 'shiqu', 'sq', 'GMT', 'UTC']
},
{
code: 'text_counter',
icon: faRuler,
category: ['text'],
keywords: ['字数统计', '字符统计', '字数', '词数', '行数', '计数', 'word count', 'character count', 'zishu', 'zs', 'tongji', 'tj']
},
{
code: 'text_space_stripper',
icon: faEraser,
category: ['text'],
keywords: ['去空格', '去换行', '去空白', '字符串处理', '文本处理', '空格删除', '换行符', 'trim', 'strip', 'space', 'whitespace', 'newline']
},
{
code: 'html_markdown_converter',
icon: faFileCode,
category: ['code', 'text'],
keywords: ['html', 'markdown', 'md', 'html转markdown', 'markdown转html', '文档转换', 'html2md', 'md2html', 'wenzhang']
},
{
code: 'image_compressor',
icon: faImage,
category: ['common', 'image'],
keywords: ['图片压缩', '压缩图片', '图片', '压缩', 'image', 'compress', 'compressor', 'tupian', 'tp', 'yasuo', 'ys']
},
{
code: 'qrcode_generator',
icon: faImage,
category: ['common','image'],
keywords: ['二维码', '二维码生成', 'qrcode', 'qr码', '扫码', '二维码生成器', 'erweima', 'ewm', 'saoma', 'sm']
},
{
code: 'css_gradient_generator',
icon: faCogs,
category: ['frontend'],
keywords: ['css渐变', '渐变', '渐变背景', 'css', 'gradient', '背景', 'css生成器', 'jianbian', 'jb', 'beijing', 'bj']
},
{
code: 'number_base_converter',
icon: faCalculator,
category: ['encoding'],
keywords: ['进制转换', '进制', '二进制', '八进制', '十进制', '十六进制', '二进制转换', '十六进制转换', 'binary', 'hex', 'decimal', 'octal', 'base conversion', 'jinzhi', 'jzzh', 'jz']
},
{
code: 'yml_properties_converter',
icon: faFileAlt,
category: ['code'],
keywords: ['yml', 'yaml', 'properties', 'yml转properties', 'properties转yml', '配置文件', '转换工具', 'yml互转properties', 'yaml互转properties', '配置转换', 'peizhi', 'pz', 'zhuanhuan', 'zh']
},
{
code: 'base64_to_image',
icon: faImage,
category: ['image', 'encoding'],
keywords: ['base64', 'base64转图片', '图片转base64', '图片', '解码', '编码', '转换', 'base64 to image', 'image to base64', 'image decoder', 'image encoder', 'tupian', 'tp', 'jm', 'bm', 'zhuanhuan', 'zh', 'base64转换']
},
{
code: 'image_watermark',
icon: faImage,
category: ['image'],
keywords: ['图片水印', '水印', '水印添加', '图片', '文字水印', '图片水印', 'watermark', 'image watermark', 'tupian shuiyin', 'tpshuiyin', 'shuiyin', 'sy', 'tp']
},
{
code: 'image_to_ico',
icon: faImage,
category: ['image'],
keywords: ['图标', 'ico', '图片转ico', 'icon', '图标生成', '图标转换', 'favicon', '网站图标', 'tubiao', 'tb', 'zhuanicon', 'icon转换', 'icon生成']
},
{
code: 'chrome_bookmark_recovery',
icon: faBookmark,
category: ['common', 'file'],
keywords: ['chrome书签', '书签恢复', '书签导入', '书签', 'chrome', 'bookmark', '书签备份', '书签导出', 'shuqian', 'sq', 'huifu', 'hf', 'chrome书签恢复', 'chromehuifu']
},
{
code: 'seal_generator',
icon: faStamp,
category: ['common', 'image'],
keywords: ['印章', '印章生成', '电子印章', '公司印章', '个人印章', '印章制作', 'seal', 'stamp', 'yinzhang', 'yz', 'yinzhangshengcheng', 'yzsc', 'gongsiyz', 'gerenyinzhang', 'seal generator']
}
]
export default tools

View File

@ -1,5 +1,52 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'
createApp(App).mount('#app')
// FontAwesome 配置
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { config } from '@fortawesome/fontawesome-svg-core'
import '@fortawesome/fontawesome-svg-core/styles.css'
// 导入所有图标
import {
faCode, faExchangeAlt, faClock, faGlobe, faLink, faLock,
faImage, faCogs, faFileCode, faKey, faFont,
faCalendarAlt, faPalette, faEdit, faRuler, faNetworkWired,
faEraser, faCalculator, faFileAlt, faStar, faSearch,
faTimes, faChevronDown, faBook, faCloud, faBell,
faExternalLinkAlt, faCopy, faCheck, faCompress, faExpand,
faTrash, faSync, faFolderOpen, faFolder, faSpinner,
faSave, faHistory, faEdit as faEditIcon, faTrashAlt,
faSun, faMoon, faHome, faArrowLeft, faArrowUp, faClipboard,
faStamp, faDownload, faEye, faInfoCircle, faExclamationTriangle
} from '@fortawesome/free-solid-svg-icons'
import { faStar as farStar } from '@fortawesome/free-regular-svg-icons'
import { faGithub } from '@fortawesome/free-brands-svg-icons'
// 阻止Font Awesome自动插入CSS避免闪烁
config.autoAddCss = false
// 添加图标到库
library.add(
faCode, faExchangeAlt, faClock, faGlobe, faLink, faLock,
faImage, faCogs, faFileCode, faKey, faFont,
faCalendarAlt, faPalette, faEdit, faRuler, faNetworkWired,
faEraser, faCalculator, faFileAlt, faStar, faSearch,
faTimes, faChevronDown, faBook, faCloud, faBell,
faExternalLinkAlt, faCopy, faCheck, faCompress, faExpand,
faTrash, faSync, faFolderOpen, faFolder, faSpinner,
faSave, faHistory, faEditIcon, faTrashAlt,
faSun, faMoon, faHome, faArrowLeft, faArrowUp, faClipboard,
faStamp, faDownload, faEye, faInfoCircle, faExclamationTriangle,
farStar, faGithub
)
const app = createApp(App)
app.use(router)
app.component('FontAwesomeIcon', FontAwesomeIcon)
app.mount('#app')

26
src/router/index.ts Normal file
View File

@ -0,0 +1,26 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/tools/:toolCode',
name: 'tool',
component: () => import('@/views/ToolView.vue'),
props: true
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue')
}
]
})
export default router

View File

@ -1,79 +1,334 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* FontAwesome 图标预加载优化 */
.fontawesome-i2svg-active body .icon-container .icon {
opacity: 1;
transition: opacity 0.2s ease-in;
}
.fontawesome-i2svg-pending body .icon-container .icon {
opacity: 0;
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 主题色 */
--color-primary: 99, 102, 241; /* #6366F1 - 深邃紫色 */
--color-primary-hover: 139, 92, 246; /* #8B5CF6 - 紫罗兰 */
--color-primary-light: 129, 140, 248; /* #818CF8 - 亮紫色 */
/* 背景色系 */
--color-bg-main: 18, 24, 39; /* #121827 - 深炭黑 */
--color-bg-card: 30, 41, 59; /* #1E293B - 暗灰蓝 */
--color-bg-secondary: 45, 55, 72; /* #2D3748 - 深靛蓝 */
/* 文字颜色 */
--color-text-primary: 241, 245, 249; /* #F1F5F9 - 银月白 */
--color-text-secondary: 203, 213, 225; /* #CBD5E1 - 淡灰蓝 */
--color-text-tertiary: 148, 163, 184; /* #94A3B8 - 冷钢灰 */
/* 点缀色 */
--color-success: 16, 185, 129; /* #10B981 - 暗翠绿 */
--color-warning: 245, 158, 11; /* #F59E0B - 暗琥珀 */
--color-error: 239, 68, 68; /* #EF4444 - 暗珊瑚 */
/* 块级元素背景色 */
--color-block: 38, 53, 72; /* #263548 - 深灰蓝色块 */
--color-block-strong: 45, 55, 72; /* #2D3748 - 更深的块色 */
--color-block-hover: 61, 74, 92; /* #3D4A5C - 悬停态块色 */
/* 边框和阴影 */
--color-border: rgba(99, 102, 241, 0.15);
--color-shadow: rgba(0, 0, 0, 0.25);
--purple-glow: rgba(99, 102, 241, 0.15);
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
/* 浅色主题变量 */
[data-theme='light'] {
/* 主题色保持不变,以保持品牌一致性 */
--color-primary: 99, 102, 241; /* #6366F1 - 深邃紫色 */
--color-primary-hover: 139, 92, 246; /* #8B5CF6 - 紫罗兰 */
--color-primary-light: 124, 58, 237; /* #7C3AED - 更深的紫色,提高对比度 */
/* 背景色系 */
--color-bg-main: 249, 250, 251; /* #F9FAFB - 浅灰背景 */
--color-bg-card: 255, 255, 255; /* #FFFFFF - 纯白卡片 */
--color-bg-secondary: 243, 244, 246; /* #F3F4F6 - 次级灰背景 */
/* 文字颜色 */
--color-text-primary: 17, 24, 39; /* #111827 - 近黑色 */
--color-text-secondary: 55, 65, 81; /* #374151 - 深灰色 */
--color-text-tertiary: 107, 114, 128; /* #6B7280 - 中灰色 */
/* 点缀色保持不变 */
--color-success: 16, 185, 129; /* #10B981 - 暗翠绿 */
--color-warning: 245, 158, 11; /* #F59E0B - 暗琥珀 */
--color-error: 239, 68, 68; /* #EF4444 - 暗珊瑚 */
/* 块级元素背景色 */
--color-block: 241, 245, 249; /* #F1F5F9 - 浅灰色块 */
--color-block-strong: 226, 232, 240; /* #E2E8F0 - 更强调的块色 */
--color-block-hover: 203, 213, 225; /* #CBD5E1 - 悬停态块色 */
/* 边框和阴影 */
--color-border: rgba(99, 102, 241, 0.2);
--color-shadow: rgba(0, 0, 0, 0.1);
--purple-glow: rgba(99, 102, 241, 0.2);
}
/* 全局样式 */
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
color: rgb(var(--color-text-primary));
background: rgb(var(--color-bg-main));
min-height: 100vh;
transition: color 0.3s ease, background-color 0.3s ease;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
::-webkit-scrollbar-track {
background: rgba(var(--color-bg-secondary), 0.3);
}
.card {
padding: 2em;
::-webkit-scrollbar-thumb {
background: rgba(var(--color-primary), 0.4);
border-radius: 4px;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
::-webkit-scrollbar-thumb:hover {
background: rgba(var(--color-primary-hover), 0.6);
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
/* Firefox滚动条样式 */
* {
scrollbar-width: thin;
scrollbar-color: rgba(var(--color-primary), 0.4) rgba(var(--color-bg-secondary), 0.3);
}
/* 主题通用样式类 */
@layer utilities {
/* 文本颜色类 */
.text-primary {
color: rgb(var(--color-text-primary));
}
a:hover {
color: #747bff;
.text-secondary {
color: rgb(var(--color-text-secondary));
}
button {
background-color: #f9f9f9;
.text-tertiary {
color: rgb(var(--color-text-tertiary));
}
.text-purple {
color: rgb(var(--color-primary-light));
}
.text-error {
color: rgb(var(--color-error));
}
.text-success {
color: rgb(var(--color-success));
}
.text-warning {
color: rgb(var(--color-warning));
}
/* 背景色类 */
.bg-main {
background-color: rgb(var(--color-bg-main));
}
.bg-card {
background-color: rgb(var(--color-bg-card));
}
.bg-secondary {
background-color: rgb(var(--color-bg-secondary));
}
.bg-block {
background-color: rgb(var(--color-block));
}
.bg-block-strong {
background-color: rgb(var(--color-block-strong));
}
.bg-block-hover {
background-color: rgb(var(--color-block-hover));
}
.bg-purple-glow {
background-color: var(--purple-glow);
}
}
/* 通用组件样式 */
@layer components {
/* 卡片基础样式 */
.card {
@apply rounded-lg border shadow-lg transition-all duration-300;
background-color: rgb(var(--color-bg-card));
border-color: rgba(var(--color-primary), 0.15);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* 卡片悬停效果 */
.card:hover {
border-color: rgba(var(--color-primary), 0.3);
background-color: color-mix(in srgb, rgb(var(--color-bg-card)) 95%, rgb(var(--color-primary)) 5%);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
/* 主按钮样式 */
.btn-primary {
@apply bg-gradient-to-r text-white rounded-md px-4 py-2 font-medium transition-all duration-200;
background-image: linear-gradient(to right, rgb(var(--color-primary)), rgb(var(--color-primary-hover)));
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(var(--color-primary), 0.3);
}
/* 次按钮样式 */
.btn-secondary {
@apply rounded-md px-4 py-2 font-medium transition-all duration-200 border;
background-color: rgb(var(--color-bg-secondary));
color: rgb(var(--color-primary-light));
border-color: rgb(var(--color-primary));
}
.btn-secondary:hover {
background-color: rgba(var(--color-primary), 0.1);
border-color: rgb(var(--color-primary-hover));
}
/* 输入框样式 */
.input-field {
@apply w-full px-3 py-2 border rounded-md transition-all duration-200 focus:outline-none focus:ring-2;
background-color: rgb(var(--color-bg-card));
border-color: rgba(var(--color-primary), 0.2);
color: rgb(var(--color-text-primary));
}
.input-field:focus {
border-color: rgb(var(--color-primary));
box-shadow: 0 0 0 2px rgba(var(--color-primary), 0.2);
}
/* 文本区域样式 */
.textarea-field {
@apply w-full px-3 py-2 border rounded-md transition-all duration-200 focus:outline-none focus:ring-2 resize-none;
background-color: rgb(var(--color-bg-card));
border-color: rgba(var(--color-primary), 0.2);
color: rgb(var(--color-text-primary));
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.textarea-field:focus {
border-color: rgb(var(--color-primary));
box-shadow: 0 0 0 2px rgba(var(--color-primary), 0.2);
}
/* 选择框样式 */
.select-field {
@apply w-full px-3 py-2 border rounded-md transition-all duration-200 focus:outline-none focus:ring-2;
background-color: rgb(var(--color-bg-card));
border-color: rgba(var(--color-primary), 0.2);
color: rgb(var(--color-text-primary));
}
.select-field:focus {
border-color: rgb(var(--color-primary));
box-shadow: 0 0 0 2px rgba(var(--color-primary), 0.2);
}
/* 小按钮样式 */
.btn-sm {
@apply px-2 py-1 text-sm rounded transition-all duration-200;
}
/* 复选框样式 */
.form-checkbox {
@apply w-4 h-4 rounded border-2 transition-all duration-200;
border-color: rgba(var(--color-primary), 0.3);
background-color: rgb(var(--color-bg-card));
}
.form-checkbox:checked {
background-color: rgb(var(--color-primary));
border-color: rgb(var(--color-primary));
}
/* 边框颜色类 */
.border-border {
border-color: rgba(var(--color-primary), 0.2);
}
.border-primary {
border-color: rgb(var(--color-primary));
}
.border-error {
border-color: rgb(var(--color-error));
}
.border-success {
border-color: rgb(var(--color-success));
}
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scaleIn {
animation: scaleIn 0.3s ease-out;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

65
src/types/tools.ts Normal file
View File

@ -0,0 +1,65 @@
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
/**
* 工具类型接口
*/
export interface Tool {
/** 工具唯一代码 */
code: string
/** 工具图标 */
icon: IconDefinition
/** 工具标题 - 可选,现在使用多语言文件 */
title?: string
/** 工具所属分类 */
category: string[]
/** 工具描述 - 可选,现在使用多语言文件 */
description?: string
/** 搜索关键词,包括各种可能的中英文搜索词 */
keywords: string[]
}
/**
* 分类类型接口
*/
export interface Category {
/** 分类唯一代码 */
code: string
/** 分类名称 - 可选,现在使用多语言文件 */
name?: string
/** 是否激活 */
active?: boolean
}
/**
* 语言类型
*/
export type Language = 'zh' | 'en'
/**
* 多语言文本类型
*/
export interface LocalizedText {
zh: string
en: string
}
/**
* API响应类型
*/
export interface ApiResponse<T = any> {
success: boolean
data?: T
message?: string
error?: string
}
/**
* 历史记录条目类型
*/
export interface HistoryItem {
id: string
title: string
content: string
timestamp: number
isFavorite?: boolean
}

76
src/utils/api.ts Normal file
View File

@ -0,0 +1,76 @@
import axios from 'axios'
import type { ApiResponse } from '@/types/tools'
// 创建axios实例
const api = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
const message = error.response?.data?.message || error.message || '请求失败'
return Promise.reject(new Error(message))
}
)
// API方法
export const apiClient = {
// IP查询
lookupIp: (ip?: string): Promise<ApiResponse> => {
const endpoint = ip ? `/ip/${ip}` : '/myip'
return api.get(endpoint)
},
// 获取当前IP
getMyIp: (): Promise<ApiResponse> => {
return api.get('/myip')
},
// HTTP代理请求
proxyRequest: (data: {
url: string
method?: string
headers?: Record<string, string>
body?: string
}): Promise<ApiResponse> => {
return api.post('/proxy', data)
},
// Markdown转换
convertMarkdown: (data: {
content: string
type: 'html-to-md' | 'md-to-html'
}): Promise<ApiResponse> => {
return api.post('/markdown/convert', data)
},
// 代码格式化
formatCode: (data: {
content: string
type: string
}): Promise<ApiResponse> => {
return api.post('/format/code', data)
}
}
// 同时提供命名导出和默认导出
export { api }
export default api

248
src/views/HomeView.vue Normal file
View File

@ -0,0 +1,248 @@
<template>
<div class="min-h-screen bg-main">
<!-- 导航栏 -->
<nav class="fixed top-0 left-0 w-full bg-card border-b border-opacity-15 border-primary-500 shadow-lg" style="z-index: 999;">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo和标题 -->
<div class="flex items-center space-x-3">
<div class="text-2xl font-bold text-purple">
子归云
</div>
<div class="text-sm text-secondary hidden sm:block">
{{ t('common.tools') }}
</div>
</div>
<!-- 主题和语言切换 -->
<div class="flex items-center space-x-2">
<ThemeToggle />
<LanguageToggle />
</div>
</div>
</div>
</nav>
<!-- 主内容区 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pt-24">
<!-- 搜索栏 -->
<div class="mb-8">
<div class="relative max-w-md mx-auto">
<input
v-model="searchTerm"
type="text"
:placeholder="t('common.search') + '...'"
class="input-field pl-10 text-center"
@keyup.enter="searchTools"
>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<FontAwesomeIcon :icon="['fas', 'search']" class="text-tertiary" />
</div>
<button
v-if="searchTerm"
@click="clearSearch"
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<FontAwesomeIcon :icon="['fas', 'times']" class="text-tertiary hover:text-primary transition-colors" />
</button>
</div>
</div>
<!-- 分类过滤 -->
<div class="mb-8">
<div class="flex flex-wrap justify-center gap-2">
<button
v-for="category in categories"
:key="category.code"
@click="() => setActiveCategory(category.code)"
:class="[
'px-4 py-2 rounded-full text-sm font-medium transition-all duration-200',
activeCategory === category.code
? 'bg-primary-500 text-white shadow-lg'
: 'bg-card text-secondary hover:bg-block-hover hover:text-primary'
]"
>
{{ t(`categories.${category.code}`) }}
</button>
</div>
</div>
<!-- 工具网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div
v-for="tool in filteredTools"
:key="tool.code"
@click="() => navigateToTool(tool.code)"
class="card p-6 cursor-pointer group animate-fadeIn"
>
<div class="flex items-center justify-between mb-4">
<div class="icon-container">
<FontAwesomeIcon
:icon="tool.icon"
class="text-2xl text-primary-light group-hover:text-primary transition-colors duration-200"
/>
</div>
<button
@click.stop="() => toggleFavorite(tool.code)"
class="text-tertiary hover:text-warning transition-colors"
>
<FontAwesomeIcon
:icon="favoriteTools.includes(tool.code) ? ['fas', 'star'] : ['far', 'star']"
:class="favoriteTools.includes(tool.code) ? 'text-warning' : ''"
/>
</button>
</div>
<h3 class="text-lg font-semibold text-primary mb-2">
{{ getToolTitle(tool.code) }}
</h3>
<p class="text-sm text-secondary line-clamp-2">
{{ getToolDescription(tool.code) }}
</p>
<div class="mt-4 flex flex-wrap gap-1">
<span
v-for="cat in tool.category.slice(0, 2)"
:key="cat"
class="px-2 py-1 text-xs rounded bg-block text-tertiary"
>
{{ t(`categories.${cat}`) }}
</span>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-if="filteredTools.length === 0"
class="text-center py-16"
>
<FontAwesomeIcon :icon="['fas', 'search']" class="text-6xl text-tertiary mb-4" />
<h3 class="text-xl font-semibold text-secondary mb-2">
{{ t('common.noData') }}
</h3>
<p class="text-tertiary">
尝试调整搜索条件或选择其他分类
</p>
</div>
</main>
<!-- 返回顶部按钮 -->
<BackToTop />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useLanguage } from '@/composables/useLanguage'
import tools from '@/config/tools'
import categories from '@/config/categories'
import type { Tool } from '@/types/tools'
// 组件导入
import ThemeToggle from '@/components/ThemeToggle.vue'
import LanguageToggle from '@/components/LanguageToggle.vue'
import BackToTop from '@/components/BackToTop.vue'
const router = useRouter()
const { t } = useLanguage()
// 响应式状态
const searchTerm = ref('')
const activeCategory = ref('all')
const favoriteTools = ref<string[]>([])
// 设置激活分类
const setActiveCategory = (categoryCode: string) => {
activeCategory.value = categoryCode
localStorage.setItem('lastActiveCategory', categoryCode)
}
// 过滤工具
const filteredTools = computed(() => {
let filtered = tools
// 分类过滤
if (activeCategory.value !== 'all') {
filtered = filtered.filter(tool => tool.category.includes(activeCategory.value))
}
// 搜索过滤
if (searchTerm.value.trim()) {
const searchLower = searchTerm.value.toLowerCase().trim()
filtered = filtered.filter(tool => {
const title = getToolTitle(tool.code).toLowerCase()
const description = getToolDescription(tool.code).toLowerCase()
const keywords = tool.keywords.join(' ').toLowerCase()
return title.includes(searchLower) ||
description.includes(searchLower) ||
keywords.includes(searchLower)
})
}
return filtered
})
// 获取工具标题
const getToolTitle = (toolCode: string) => {
return t(`tools.${toolCode}.title`) || toolCode
}
// 获取工具描述
const getToolDescription = (toolCode: string) => {
return t(`tools.${toolCode}.description`) || ''
}
// 导航到工具页面
const navigateToTool = (toolCode: string) => {
router.push(`/tools/${toolCode}`)
}
// 搜索工具
const searchTools = () => {
// 搜索逻辑已经在computed中处理
}
// 清除搜索
const clearSearch = () => {
searchTerm.value = ''
}
// 切换收藏
const toggleFavorite = (toolCode: string) => {
const index = favoriteTools.value.indexOf(toolCode)
if (index > -1) {
favoriteTools.value.splice(index, 1)
} else {
favoriteTools.value.push(toolCode)
}
localStorage.setItem('favoriteTools', JSON.stringify(favoriteTools.value))
}
// 从本地存储恢复数据
onMounted(() => {
// 恢复收藏工具
const saved = localStorage.getItem('favoriteTools')
if (saved) {
favoriteTools.value = JSON.parse(saved)
}
// 恢复激活分类
const savedCategory = localStorage.getItem('lastActiveCategory')
if (savedCategory) {
activeCategory.value = savedCategory
}
})
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,41 @@
<template>
<div class="min-h-screen bg-main flex items-center justify-center">
<div class="text-center px-4">
<div class="text-8xl text-primary mb-4">404</div>
<h1 class="text-3xl font-bold text-primary mb-4">页面未找到</h1>
<p class="text-lg text-secondary mb-8">
抱歉您访问的页面不存在或已被移除
</p>
<div class="space-x-4">
<button
@click="goHome"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'home']" class="mr-2" />
返回首页
</button>
<button
@click="goBack"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'arrow-left']" class="mr-2" />
返回上页
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push('/')
}
const goBack = () => {
history.back()
}
</script>

198
src/views/ToolView.vue Normal file
View File

@ -0,0 +1,198 @@
<template>
<div class="min-h-screen bg-main">
<!-- 导航栏 -->
<nav class="fixed top-0 left-0 w-full bg-card border-b border-opacity-15 border-primary-500 shadow-lg" style="z-index: 999;">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- 返回按钮和标题 -->
<div class="flex items-center space-x-4">
<button
@click="goBack"
class="p-2 rounded-lg bg-card hover:bg-block-hover text-secondary hover:text-primary transition-all duration-200"
title="返回首页"
>
<FontAwesomeIcon :icon="['fas', 'arrow-left']" class="w-5 h-5" />
</button>
<div class="flex items-center space-x-3">
<div v-if="currentTool" class="icon-container">
<FontAwesomeIcon
:icon="currentTool.icon"
class="text-2xl text-primary-light"
/>
</div>
<div>
<h1 class="text-xl font-bold text-primary">
{{ toolTitle }}
</h1>
<p class="text-sm text-secondary hidden sm:block">
{{ toolDescription }}
</p>
</div>
</div>
</div>
<!-- 主题和语言切换 -->
<div class="flex items-center space-x-2">
<ThemeToggle />
<LanguageToggle />
</div>
</div>
</div>
</nav>
<!-- 工具内容区 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pt-24">
<div v-if="isLoading" class="text-center py-16">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<p class="text-secondary">{{ t('common.loading') }}</p>
</div>
<div v-else-if="toolNotFound" class="text-center py-16">
<FontAwesomeIcon :icon="['fas', 'search']" class="text-6xl text-tertiary mb-4" />
<h2 class="text-2xl font-semibold text-secondary mb-2">工具未找到</h2>
<p class="text-tertiary mb-6">您访问的工具不存在或暂未实现</p>
<button
@click="goBack"
class="btn-primary"
>
返回首页
</button>
</div>
<!-- 动态工具组件 -->
<component
v-else-if="ToolComponent"
:is="ToolComponent"
class="animate-fadeIn"
/>
</main>
<!-- 返回顶部按钮 -->
<BackToTop />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, defineAsyncComponent, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useLanguage } from '@/composables/useLanguage'
import tools from '@/config/tools'
import type { Tool } from '@/types/tools'
// 组件导入
import ThemeToggle from '@/components/ThemeToggle.vue'
import LanguageToggle from '@/components/LanguageToggle.vue'
import BackToTop from '@/components/BackToTop.vue'
interface Props {
toolCode: string
}
const props = defineProps<Props>()
const router = useRouter()
const { t } = useLanguage()
// 响应式状态
const isLoading = ref(true)
const toolNotFound = ref(false)
const ToolComponent = ref<any>(null)
// 当前工具信息
const currentTool = computed((): Tool | undefined => {
return tools.find(tool => tool.code === props.toolCode)
})
// 工具标题
const toolTitle = computed(() => {
if (!currentTool.value) return '未知工具'
return t(`tools.${currentTool.value.code}.title`) || currentTool.value.code
})
// 工具描述
const toolDescription = computed(() => {
if (!currentTool.value) return ''
return t(`tools.${currentTool.value.code}.description`) || ''
})
// 返回首页
const goBack = () => {
router.push('/')
}
// 工具组件映射表
const toolComponentMap: Record<string, () => Promise<any>> = {
json_formatter: () => import('@/components/tools/JsonFormatter.vue'),
timestamp_converter: () => import('@/components/tools/TimestampConverter.vue'),
encoding_converter: () => import('@/components/tools/EncodingConverter.vue'),
regex_tester: () => import('@/components/tools/RegexTester.vue'),
crypto_tools: () => import('@/components/tools/CryptoTools.vue'),
color_tools: () => import('@/components/tools/ColorTools.vue'),
code_formatter: () => import('@/components/tools/CodeFormatter.vue'),
json_editor: () => import('@/components/tools/JsonEditor.vue'),
json_converter: () => import('@/components/tools/JsonConverter.vue'),
url_encoder: () => import('@/components/tools/UrlEncoder.vue'),
unicode_converter: () => import('@/components/tools/UnicodeConverter.vue'),
jwt_decoder: () => import('@/components/tools/JwtDecoder.vue'),
ip_lookup: () => import('@/components/tools/IpLookup.vue'),
date_calculator: () => import('@/components/tools/DateCalculator.vue'),
timezone_converter: () => import('@/components/tools/TimezoneConverter.vue'),
text_counter: () => import('@/components/tools/TextCounter.vue'),
text_space_stripper: () => import('@/components/tools/TextSpaceStripper.vue'),
html_markdown_converter: () => import('@/components/tools/HtmlMarkdownConverter.vue'),
image_compressor: () => import('@/components/tools/ImageCompressor.vue'),
qrcode_generator: () => import('@/components/tools/QrcodeGenerator.vue'),
css_gradient_generator: () => import('@/components/tools/CssGradientGenerator.vue'),
number_base_converter: () => import('@/components/tools/NumberBaseConverter.vue'),
yml_properties_converter: () => import('@/components/tools/YmlPropertiesConverter.vue'),
base64_to_image: () => import('@/components/tools/Base64ToImage.vue'),
image_watermark: () => import('@/components/tools/ImageWatermark.vue'),
image_to_ico: () => import('@/components/tools/ImageToIco.vue'),
cron_generator: () => import('@/components/tools/CronGenerator.vue'),
http_tester: () => import('@/components/tools/HttpTester.vue'),
chrome_bookmark_recovery: () => import('@/components/tools/ChromeBookmarkRecovery.vue'),
seal_generator: () => import('@/components/tools/SealGenerator.vue')
}
// 加载工具组件
const loadToolComponent = async () => {
try {
isLoading.value = true
toolNotFound.value = false
// 检查工具是否存在
if (!currentTool.value) {
toolNotFound.value = true
isLoading.value = false
return
}
// 检查组件是否存在
const componentLoader = toolComponentMap[props.toolCode]
if (!componentLoader) {
toolNotFound.value = true
isLoading.value = false
return
}
// 动态加载组件
const component = defineAsyncComponent(componentLoader)
ToolComponent.value = component
isLoading.value = false
} catch (error) {
console.error('加载工具组件失败:', error)
toolNotFound.value = true
isLoading.value = false
}
}
onMounted(() => {
loadToolComponent()
})
// 监听路由变化
watch(() => props.toolCode, () => {
loadToolComponent()
})
</script>

42
tailwind.config.js Normal file
View File

@ -0,0 +1,42 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
primary: {
50: '#f0f0ff',
100: '#e5e7ff',
200: '#cdcfff',
300: '#a5a9ff',
400: '#8185ff',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
},
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'scale-in': 'scaleIn 0.3s ease-out',
'pulse-slow': 'pulse 3s ease-in-out infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
},
},
},
plugins: [],
}

290
test-seal.html Normal file
View File

@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>印章生成器测试</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 20px;
}
.form-group {
display: flex;
flex-direction: column;
}
label {
margin-bottom: 5px;
font-weight: bold;
}
input, select, button {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
background: #6366f1;
color: white;
cursor: pointer;
border: none;
}
button:hover {
background: #5855eb;
}
.preview {
text-align: center;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
}
canvas {
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div class="container">
<h1>🖃 印章生成器测试</h1>
<div class="controls">
<div class="form-group">
<label for="sealType">印章类型</label>
<select id="sealType">
<option value="company_round">公司圆章</option>
<option value="company_oval">公司椭圆章</option>
<option value="personal_square">个人方章</option>
<option value="personal_round">个人圆章</option>
</select>
</div>
<div class="form-group">
<label for="mainText">主文字</label>
<input type="text" id="mainText" placeholder="输入公司名称或姓名" value="子归云科技">
</div>
<div class="form-group">
<label for="centerText">中心文字</label>
<input type="text" id="centerText" placeholder="印" value="印" maxlength="2">
</div>
<div class="form-group">
<label for="sealSize">印章大小</label>
<input type="range" id="sealSize" min="200" max="600" value="300">
<span id="sizeValue">300px</span>
</div>
<div class="form-group">
<label for="sealColor">印章颜色</label>
<input type="color" id="sealColor" value="#FF0000">
</div>
<div class="form-group">
<button onclick="generateSeal()">🎨 生成印章</button>
</div>
</div>
<div class="preview">
<h3>印章预览</h3>
<canvas id="sealCanvas" width="300" height="300"></canvas>
<br><br>
<button onclick="downloadSeal()" style="display:none;" id="downloadBtn">📥 下载印章</button>
</div>
</div>
<script>
const canvas = document.getElementById('sealCanvas');
const ctx = canvas.getContext('2d');
const sizeSlider = document.getElementById('sealSize');
const sizeValue = document.getElementById('sizeValue');
// 更新大小显示
sizeSlider.addEventListener('input', function() {
sizeValue.textContent = this.value + 'px';
canvas.width = this.value;
canvas.height = this.value;
});
function generateSeal() {
const sealType = document.getElementById('sealType').value;
const mainText = document.getElementById('mainText').value;
const centerText = document.getElementById('centerText').value;
const sealSize = parseInt(document.getElementById('sealSize').value);
const sealColor = document.getElementById('sealColor').value;
if (!mainText.trim()) {
alert('请输入主文字!');
return;
}
// 清空画布
ctx.clearRect(0, 0, sealSize, sealSize);
// 设置背景
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, sealSize, sealSize);
// 设置印章颜色
ctx.strokeStyle = sealColor;
ctx.fillStyle = sealColor;
const centerX = sealSize / 2;
const centerY = sealSize / 2;
if (sealType.startsWith('company')) {
drawCompanySeal(centerX, centerY, sealSize, mainText, centerText, sealColor);
} else {
drawPersonalSeal(centerX, centerY, sealSize, mainText, sealColor);
}
document.getElementById('downloadBtn').style.display = 'inline-block';
}
function drawCompanySeal(centerX, centerY, sealSize, mainText, centerText, sealColor) {
const radius = sealSize * 0.4;
// 绘制外边框
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
ctx.stroke();
// 绘制内边框
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(centerX, centerY, radius - 15, 0, 2 * Math.PI);
ctx.stroke();
// 绘制弧形主文字
drawCurvedText(mainText, centerX, centerY, radius - 25, 24);
// 绘制五角星
drawStar(centerX, centerY - radius * 0.1, 15);
// 绘制中心文字
if (centerText.trim()) {
ctx.font = 'bold 40px SimSun, serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(centerText, centerX, centerY + radius * 0.2);
}
}
function drawPersonalSeal(centerX, centerY, sealSize, mainText, sealColor) {
const size = sealSize * 0.8;
const sealType = document.getElementById('sealType').value;
// 绘制边框
ctx.lineWidth = 3;
ctx.beginPath();
if (sealType === 'personal_round') {
ctx.arc(centerX, centerY, size / 2, 0, 2 * Math.PI);
} else {
const halfSize = size / 2;
ctx.rect(centerX - halfSize, centerY - halfSize, size, size);
}
ctx.stroke();
// 绘制文字
const text = mainText.trim();
const textLength = text.length;
const fontSize = 32;
ctx.font = `bold ${fontSize}px SimSun, serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (textLength === 2) {
// 两字:上下排列
ctx.fillText(text[0], centerX, centerY - fontSize * 0.6);
ctx.fillText(text[1], centerX, centerY + fontSize * 0.6);
} else if (textLength === 3) {
// 三字:品字形
ctx.fillText(text[0], centerX, centerY - fontSize * 0.8);
ctx.fillText(text[1], centerX - fontSize * 0.7, centerY + fontSize * 0.4);
ctx.fillText(text[2], centerX + fontSize * 0.7, centerY + fontSize * 0.4);
} else if (textLength === 4) {
// 四字:田字格
ctx.fillText(text[0], centerX - fontSize * 0.6, centerY - fontSize * 0.6);
ctx.fillText(text[1], centerX + fontSize * 0.6, centerY - fontSize * 0.6);
ctx.fillText(text[2], centerX - fontSize * 0.6, centerY + fontSize * 0.6);
ctx.fillText(text[3], centerX + fontSize * 0.6, centerY + fontSize * 0.6);
} else {
// 其他情况:单行
ctx.fillText(text, centerX, centerY);
}
}
function drawCurvedText(text, centerX, centerY, radius, fontSize) {
ctx.font = `${fontSize}px SimSun, serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const angleStep = (Math.PI * 1.4) / text.length;
const startOffset = -Math.PI * 0.7;
for (let i = 0; i < text.length; i++) {
const angle = startOffset + i * angleStep;
const x = centerX + Math.cos(angle) * radius;
const y = centerY + Math.sin(angle) * radius;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle + Math.PI / 2);
ctx.fillText(text[i], 0, 0);
ctx.restore();
}
}
function drawStar(centerX, centerY, radius) {
const spikes = 5;
const outerRadius = radius;
const innerRadius = radius * 0.4;
ctx.beginPath();
for (let i = 0; i < spikes * 2; i++) {
const angle = (i * Math.PI) / spikes - Math.PI / 2;
const r = i % 2 === 0 ? outerRadius : innerRadius;
const x = centerX + Math.cos(angle) * r;
const y = centerY + Math.sin(angle) * r;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fill();
}
function downloadSeal() {
const link = document.createElement('a');
link.download = `seal-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
// 初始生成一个示例印章
setTimeout(() => {
generateSeal();
}, 100);
</script>
</body>
</html>

View File

@ -2,12 +2,17 @@
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},

View File

@ -1,7 +1,18 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"script/*": ["src/script/*"]
},
}
}

View File

@ -17,7 +17,6 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},

View File

@ -1,7 +1,47 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
base: env.VITE_PUBLIC || './', //使用环境变量设置资源路径,默认为相对路径
server: {
host: true,
port: 8080, //vite项目启动时自定义端口
open: true,
proxy: {
// 正则表达式写法
'^/api': {
target: 'http://192.168.3.13:3000/api', // 后端服务实际地址
changeOrigin: true, //开启代理
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
plugins: [
vue(),
],
resolve: {
//别名
alias: {
'@': resolve(__dirname, './src'),
components: resolve(__dirname, './src/components'),
script: resolve(__dirname, './src/script'),
utils: resolve(__dirname, './src/utils'),
stores: resolve(__dirname, './src/stores')
}
},
build: {
assetsDir: 'static', //打包后的公共文件夹名
target: 'es2015',
cssTarget: ['chrome61'],
chunkSizeWarningLimit: 5000
}
}
})