feat(router): 新增多个工具分类和路由配置
All checks were successful
Build and Deploy (tool.xpcool.com) / build-and-deploy (push) Successful in 1m1s

- 添加 TerminalIcon, GitBranchIcon, ApiIcon, BrushIcon, SoundIcon,
Table1Icon, LogoGithubIcon, ContrastIcon, Edit2Icon, ChartBarIcon,
EarthIcon, GitMergeIcon, SettingIcon, CurrencyExchangeIcon, MobileIcon,
WifiIcon, RocketIcon 等图标导入
- 新增 dev, design, health, study, travel, fun, online 七个工具分类
- 添加 SQL格式化、文本对比、JSON转代码、渐变生成器、占位图生成、
ASCII艺术、摩斯电码、正则可视化、Markdown表格、API构建器、
Gitignore生成器、代码转图片、颜色命名器、缩进转换、代码统计、
Hello World大全、JSON对比、环境变量解析、汇率换算、快递查询、
手机号归属地、IP信息查询、网络测试、网络测速等多个工具路由配置
- 新增 .env.example 配置文件模板
- 创建 docs/api-contract.md 后端接口契约文档
- 新增 api-builder 和 ascii-art 工具页面组件
- 更新 agents.md 添加接手必读指引和维护约定
- 创建 CHANGELOG.md 变更日志文档和工作日志记录
This commit is contained in:
夏犀麟 2026-08-24 22:11:12 +08:00
parent 8ee1f71b41
commit 954b9e9d8a
33 changed files with 6438 additions and 1 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
# 复制为 .env 并填写你的后端地址后生效(无需提交到 git
# 后端接口契约见 docs/api-contract.md
VITE_API_BASE=https://api.xpcool.com

View File

@ -0,0 +1,64 @@
# 2026-08-24 工作日志
## 工具箱新增「有趣的分类与工具」批次
为 tool.xpcool.com 新增 3 个分类 + 9 个工具31 → 40 个),全部通过 `pnpm type-check`
### 新增分类src/router/tools.ts
- `dev` 开发进阶 / `design` 创意设计 / `fun` 趣味娱乐
### 新增工具views/ 下各自目录)
- 开发进阶sql-format手写分词+关键词换行格式化器、text-diffLCS 行级 diff折叠相同行、json-to-codeJSON→TS interface/Go struct 含 json tag/Python dataclass嵌套递归建类型表
- 创意设计gradient-generatorlinear/radial/conic + 12 组预设 + 随机、placeholder-imagecanvas 占位图 + SVG 代码、ascii-art亮度感知加权灰度 → 字符,支持反色)
- 趣味娱乐morse-codepinyin-pro 中文转拼音编码 + Web Audio 电报声播放、lucky-wheelcanvas 转盘 + ease-out 动画 + 指针判定、slogan-generator5 类文案库随机)
### 要点备忘
- TDesign 图标名需先在 node_modules/tdesign-icons-vue-next/esm/components/ 确认存在PaletteIcon 不存在,只有 Palette1Iconascii-art 用了 ViewModuleIcon
- 创意库设计文档docs/tool-ideas.md含未来 60+ 工具想法清单)
- 页面样式约定scoped 内自建 .page-wrap/.form-actions/.code-block复用公共组件 CopyButton/ErrorAlert/HistoryPanel/UploadDrop
## 第二批:分类重构 + 后端预留机制 + 编程工具全系40 → 58 个)
### 分类重构9 分类)
- 在 dev/design/fun 基础上新增 health健康生活/ study学习办公/ travel旅行出行/ online在线服务·后端预留
### 后端预留机制(用户正在准备后端服务)
- `src/api/client.ts`:统一 API 客户端VITE_API_BASE 配置,未配置返回「后端未接入」,统一响应 `{code,message,data}`
- `docs/api-contract.md`6 个接口契约weather/exchange-rate/express-query/stock-quote/phone-locate/ip-info
- 6 个预留页面UI 完整、字段按契约类型化,后端上线即用;`.env.example` 提供 VITE_API_BASE 模板
### 编程工具 12 个dev 分类)
- code-minifyJS/CSS/HTML 压缩美化,手写 tokenizer、regex-visual正则结构树解析器、markdown-table、api-builderfetch/curl 生成、gitignore-gen、code-to-imagecanvas 高亮卡片、color-namer148 色表欧氏距离匹配、indent-convert、code-stats30+ 语言注释规则状态机、hello-world33 语言、json-diff递归路径级 diff、env-parse
### 坑
- ⚠️ Vue 模板 attribute 内嵌字符串不能写 `\"`HTML 解析器把 " 当属性结束符)→ 字符串常量移到 script 定义
- json-diff 的 `k in b` 需先把 b 断言为 Record<string, unknown>,否则 TS2638/TS18048
### 剩余批次(创意库台账见 docs/tool-ideas.md 第三部分)
- 批次 A 健康 12 / B 学习 10 / C 旅行 6 / D 设计补全 9 / E 趣味补全 14
## 第三批:网络测试工具 + 上下文无缝切换机制58 → 59
### 网络测试online 分类)
- `network-test`HTTP 检测走前端直连CORS 受限时提示Ping / TCP / DNS 走后端 `POST /api/tools/network-test`(契约已追加到 api-contract.md 第 7 节)
- 图标 WifiIcon
### 上下文无缝切换机制(核心需求:切 CodeBuddy 账号后新 AI 能无缝接手)
- 关键洞察:**用户级数据skills/memory随账号走项目内文件不随账号走** → 状态必须存在项目内 docs/ 下
- `docs/PROJECT_STATE.md`:项目状态与交接文档(单点真相)——全貌/架构机制/待办批次/后端契约索引/技术坑/接手清单,含维护规则
- `docs/CHANGELOG.md`:变更日志(倒序),每次请求/更改必追加
- `AGENTS.md` 顶部新增「⚡ 接手必读」段落:新账号 AI 按顺序读 PROJECT_STATE → CHANGELOG → api-contract
- 约定:此后每次实质变更必须同步更新这 3 份文档 + 工作日志(已在 AGENTS.md 写明)
## 第四批网络测速工具59 → 60
- `speed-test`onlineRocketIcon下载测速流式读 body 计时,后端/公网 cloudflare/自定义 URL 三模式5-50MB实时进度+峰值、上传测速随机数据上传后端计时优先1-10MB、延迟测试5/10 次 RTT + 抖动)
- 契约第 8 节下载返回随机二进制流Content-Length + Cache-Control: no-store 建议),上传返回 {bytes, duration_ms, speed_mbps}
- client.ts 导出 BASE_URL 供直接拼流式地址
## 第五批:删除 5 个工具60 → 55
- 按用户要求删除code-minify、lucky-wheel、slogan-generator、weather、stock-quote页面目录 + tools.ts 条目 + 契约节)
- tools.ts 顺带清理无引用图标Star/Smile/Edit/CloudyDayChartIcon 因 word-count 保留)
- ⚠️ 坑:项目内 rm -rf 被沙箱 safe-deletegenie-trash拦截失败 → 改用 mv 到 $TMPDIR 实现移除dist 同理)
- 文档全部同步CHANGELOG 第五批 / PROJECT_STATE55 个、dev 14、fun 1、online 6、契约 7 个)/ tool-ideas / api-contract7 接口)/ 本日志

View File

@ -2,6 +2,14 @@
本文档面向后续接手维护/扩展本项目的 AI 编码代理Agent与开发者说明架构约定、关键机制、开发流程与常见坑。动手改代码前请先读完本文。 本文档面向后续接手维护/扩展本项目的 AI 编码代理Agent与开发者说明架构约定、关键机制、开发流程与常见坑。动手改代码前请先读完本文。
> ## ⚡ 接手必读(任何 AI 账号 / 开发者接手时,按顺序先读这三份)
>
> 1. **`docs/PROJECT_STATE.md`** — 项目状态与交接文档单点真相当前全貌、9 分类 59 个工具清单、待办批次、技术坑、接手清单
> 2. **`docs/CHANGELOG.md`** — 变更日志:每次请求/变更都会追加记录(倒序),先看最近的改动
> 3. **`docs/api-contract.md`** — 后端接口契约:「在线服务」分类工具的后端契约,后端按此实现即可直接对接
>
> **维护约定**每次对本项目做实质变更必须同步更新这三份文档CHANGELOG 追加记录 / PROJECT_STATE 刷新状态 / 涉及后端则更新契约),并把 `docs/PROJECT_STATE.md` 第 7 节的接手清单走一遍。
## 1. 项目定位 ## 1. 项目定位
- 名称tool.xpcool.com在线工具箱 - 名称tool.xpcool.com在线工具箱

74
docs/CHANGELOG.md Normal file
View File

@ -0,0 +1,74 @@
# CHANGELOG — 项目变更日志
> 记录每次请求/变更,**倒序排列(最新在上)**。
> 与 `docs/PROJECT_STATE.md` 配合使用:本文件回答「改了什么」,状态文档回答「现在是什么样」。
---
## 2026-08-24 第五批:删除 5 个工具60 → 55
### 🗑 删除工具(页面目录 + 注册条目 + 相关契约全部清除)
- `code-minify` 代码压缩美化dev
- `lucky-wheel` 抽奖转盘fun
- `slogan-generator` 文案生成器fun
- `weather` 天气预报online—— api-contract 第 1 节一并删除
- `stock-quote` 股票行情online—— api-contract 第 4 节一并删除
### 🧹 清理
- tools.ts 删除对应条目及无引用图标StarIcon / SmileIcon / EditIcon / CloudyDayIconChartIcon 因 word-count 仍使用而保留)
- api-contract.md 接口一览更新为 7 个exchange-rate / express-query / phone-locate / ip-info / network-test / speedtest-download / speedtest-upload
- 删除方式备忘:项目内 `rm` 触发沙箱 trash 保护genie-trash 失败),改用 `mv` 到系统临时目录(`$TMPDIR/tool-deleted-*`)实现移除
## 2026-08-24 第四批网络测速工具59 → 60
### 🆕 新增工具
- `speed-test` 网络测速online 分类,图标 RocketIcon
- 下载测速:流式读取计时(后端测速源 / 公网源 / 自定义 URL 三模式实时进度与峰值速度5/10/25/50MB
- 上传测速:生成随机数据上传,后端计时优先(`POST /api/tools/speedtest/upload`1/5/10MB
- 延迟测试5/10 次 RTT 采样,平均/最小/最大/抖动 + 等级
- 契约新增第 8 节:下载测速返回随机二进制流(带 Content-Length、上传测速返回 `{bytes, duration_ms, speed_mbps}`
- `src/api/client.ts` 导出 `BASE_URL`(测速工具直接拼流式地址)
## 2026-08-24 第三批:网络测试工具 + 上下文无缝切换机制
### 🆕 新增工具
- `network-test` 网络测试online 分类HTTP 站点检测(前端直连,测 DNS/TTFB/状态/大小)+ Ping / TCP 端口 / DNS 解析(后端模式),后端契约见 api-contract.md 第 7 节
### 🏗 机制
- 新增 `docs/PROJECT_STATE.md`:项目状态与交接文档(单点真相),新账号 AI 接手必读
- 新增 `docs/CHANGELOG.md`:本文件,每次变更必记录
- `AGENTS.md` 顶部新增「接手必读」指引,指向上述两份文档与 api-contract.md —— 实现跨 CodeBuddy 账号无缝接续
- 约定:任何后续变更必须同步更新 CHANGELOG.md / PROJECT_STATE.md / 工作日志
## 2026-08-24 第二批:分类重构 + 后端预留机制 + 编程工具全系40 → 58
### 🏗 分类
- 分类重构为 9 个:新增 `health 健康生活` / `study 学习办公` / `travel 旅行出行` / `online 在线服务(后端预留)`
### 🆕 后端预留6
- `src/api/client.ts` 统一 API 客户端VITE_API_BASE 配置,未配置降级提示,统一响应 `{code,message,data}`
- `docs/api-contract.md` 接口契约文档weather / exchange-rate / express-query / stock-quote / phone-locate / ip-info
- 6 个预留页面骨架UI 完整、字段按契约类型化),`.env.example` 提供配置模板
### 🆕 编程工具12
- code-minifyJS/CSS/HTML 压缩美化、regex-visual正则结构树、markdown-table、api-builderfetch/curl、gitignore-gen、code-to-imagecanvas 高亮卡片、color-namer148 色表、indent-convert、code-stats30+ 语言注释规则、hello-world33 语言、json-diff路径级、env-parse
### 🐛 修复
- Vue 模板 attribute 内 `\"` 导致编译错误 → 字符串常量移到 script
- json-diff `k in b` 类型收窄 → 断言为 Record
## 2026-08-24 第一批趣味分类与工具31 → 40
### 🏗 分类
- 新增 `dev 开发进阶` / `design 创意设计` / `fun 趣味娱乐`
### 🆕 工具9
- devsql-format手写分词 SQL 格式化、text-diffLCS 行级 diff、json-to-codeJSON → TS/Go/Python
- designgradient-generatorlinear/radial/conic + 12 预设、placeholder-imagePNG/SVG 占位图、ascii-art图片转字符画
- funmorse-code中文走 pinyin-pro + Web Audio 电报声、lucky-wheelcanvas 转盘动画、slogan-generator5 类文案库)
### 📄 文档
- 新增 `docs/tool-ideas.md` 工具创意库(现状 + 未来创意 + 进度台账)
### 🐛 修复
- `PaletteIcon` 不存在 → 改用 `ViewModuleIcon`(图标引用前需在 node_modules 确认存在)

74
docs/PROJECT_STATE.md Normal file
View File

@ -0,0 +1,74 @@
# PROJECT_STATE.md — 项目状态与交接文档
> **本文件是项目的「单点真相」Source of Truth任何 AI 账号 / 开发者接手项目时,请先读本文件,再读 `CHANGELOG.md``docs/api-contract.md`,即可无缝接续工作。**
>
> **维护规则(重要)**:每次对本项目做任何实质变更(新增/修改工具、改架构、改契约、修坑),必须同步更新:
> 1. `docs/CHANGELOG.md` —— 追加一条变更记录(倒序,最新在上)
> 2. 本文档 —— 更新工具清单、状态、待办与技术约定
> 3. `.workbuddy/memory/YYYY-MM-DD.md` —— 工作日志
---
## 1. 项目快照
- **名称**tool.xpcool.com在线工具箱
- **形态**:纯前端 SPA无后端、无 mock。计算与文件处理在浏览器本地完成「在线服务」分类的工具依赖自建后端见第 5 节)。
- **技术栈**Vite 6 + Vue 3`<script setup>`+ Pinia + TypeScriptstrict+ UnoCSS + TDesign Vue Next + pnpm**禁止换 npm/yarn**
- **常用命令**`pnpm dev`(开发)/ `pnpm build`vue-tsc + vite 构建)/ `pnpm type-check`
- **客户端**PC + 移动端 H5 响应式(`<768px` 侧边栏变抽屉
- **构建注意**`pnpm build` 前若 dist 目录文件过多会触发安全删除保护(阈值 50 个/次),先 `mv dist dist.bak-<ts>` 再构建,构建后把备份移走。
## 2. 当前全貌(截至 2026-08-24共 9 分类 55 个工具)
| 分类 | 数量 | 说明 | 工具path |
| --- | --- | --- | --- |
| work 工作效率 | 18 | 开发编程、办公文本与文件处理 | image-compress / json-format / timestamp / base64 / jwt-parse / cron / radix-convert / hash / url-parse / translate / ocr / uuid / regex-test / case-convert / color-convert / yaml-json / word-count / zh-convert |
| dev 开发进阶 | 14 | 格式化、对比、转换与代码工具 | sql-format / text-diff / json-to-code / regex-visual / markdown-table / api-builder / gitignore-gen / code-to-image / color-namer / indent-convert / code-stats / hello-world / json-diff / env-parse |
| design 创意设计 | 3 | 渐变、占位图与字符画 | gradient-generator / placeholder-image / ascii-art |
| life 生活助手 | 13 | 日常计算、查询与决策 | image-cropper / qrcode / idcard / address-parse / unit-convert / rmb-upper / date-calc / password-gen / bmi / mortgage / bill-split / random-decision / pomodoro |
| health 健康生活 | 0 | 睡眠、饮食与身体指标 | ⬜ 待实现(批次 A |
| study 学习办公 | 0 | 拼音标注、记忆卡片与模板 | ⬜ 待实现(批次 B |
| travel 旅行出行 | 0 | 出行清单、时差与距离 | ⬜ 待实现(批次 C |
| fun 趣味娱乐 | 1 | 摩斯电码等小玩具 | morse-code |
| online 在线服务 | 6 | 依赖后端接口 | exchange-rate / express-query / phone-locate / ip-info / network-test / speed-test |
## 3. 架构与关键机制(新增工具前必读)
- **工具注册表** `src/router/tools.ts` 是全站唯一数据源:分类 `toolCategories` + 工具 `tools` 数组,侧边导航/首页/路由全部自动生成。新增工具 = 建页面 + 加一条配置path/name/desc/icon/category/component 懒加载),**禁止手改路由文件**。
- **历史记录**`useHistory(key)`key 约定等于路由 pathlocalStorage 持久化(`tool-history:<key>`),上限 10 条。
- **主题**`stores/theme.ts`,自定义样式一律用 `var(--td-*)` 令牌,禁止硬编码颜色。
- **公共组件**`CopyButton` / `DownloadButton` / `ErrorAlert` / `HistoryPanel` / `UploadDrop`,新页面必须复用。
- **页面样式**`.page-wrap / .page-title / .page-desc / .tool-section / .result-area` 全局已定义;`.form-actions / .form-row / .code-block` 各页面 scoped 自建。
- **后端请求**:统一走 `src/api/client.ts``api.get/post`),响应契约 `{ code, message, data }``VITE_API_BASE` 未配置时返回「后端未接入」,页面据此降级。
- **网络测试双模式**`network-test` 的 HTTP 检测走前端直连(受 CORS 限制Ping / TCP / DNS 走 `POST /api/tools/network-test`(契约见第 5 节)。
- **网络测速**`speed-test` 下载测速走流式 fetch`GET /api/tools/speedtest/download`,三模式:后端/公网/自定义),上传测速走 `POST /api/tools/speedtest/upload`(后端计时优先),延迟测试前端多次采样。
## 4. 待办(按批推进,见 docs/tool-ideas.md 第三部分)
- **批次 A 健康生活12**:睡眠周期计算 / 喝水提醒 / 体脂率估算 / 卡路里估算 / 预产期计算 / 房贷提前还款 / 生理周期记录 / 运动配速计算 / BMI 对比表 / 热量平衡 / 理想体重 / 步数时长换算
- **批次 B 学习办公10**汉字拼音标注pinyin-pro 已依赖)/ 单词记忆卡 / 随机口算题 / 乘法表练习 / 请假条生成器 / 会议纪要模板 / 邮件模板 / 公文格式助手 / 学习计划表 / 名言摘抄本
- **批次 C 旅行出行6**:行李清单 / 机票折扣速算 / 签证材料清单 / 地图距离估算Haversine/ 时差换算 / 小费计算器
- **批次 D 创意设计补全9**:阴影生成器 / 圆角生成器 / 调色板生成 / 颜色对比度检查 / 像素画编辑器 / 纹理生成器 / 文字效果生成 / Emoji 放大镜 / 图片九宫格切图
- **批次 E 趣味娱乐补全14**:加密暗号机 / Emoji 密文 / 藏头诗生成 / 绕口令生成器 / 塔罗牌占卜 / 星座运势 / 名字评分 / 电子木鱼 / 猜成语小游戏 / 音效合成器 / 节日倒计时 / 情侣默契问答 / 名言警句生成 / 骰子塔
## 5. 后端契约(用户正在开发后端服务)
- 契约文档:`docs/api-contract.md`7 个接口exchange-rate / express-query / phone-locate / ip-info / network-test / speedtest-download / speedtest-upload
- 前端已按契约类型化,后端按文档实现即可直接对接,**前端零改动**。
- 接入方式:复制 `.env.example``.env`,填 `VITE_API_BASE`
## 6. 技术约定与坑(跨会话必须记住)
- **TDesign 图标**:引用前先在 `node_modules/tdesign-icons-vue-next/esm/components/` 确认存在(如 `PaletteIcon` 不存在,只有 `Palette1Icon`)。
- **Vue 模板 attribute**:内嵌字符串不能直接写 `\"`HTML 解析器会把 `"` 当属性结束符导致编译错误),字符串常量移到 `<script>` 定义。
- **`in` 运算符**`k in obj` 中 obj 需先断言为 `Record<string, unknown>`,否则 strict 模式 TS2638/TS18048。
- **构建安全删除保护**`pnpm build` 清理 dist 会触发沙箱批量删除拦截(>50 文件),先 `mv dist dist.bak-<ts>` 再构建。
- **大整数**:进制转换等工具禁用 `parseInt/Number`,用 BigInt 逐位解析(现有 radix-convert 已实现)。
- **暗黑模式**:所有颜色用 `--td-*` 令牌canvas 绘制注意主题切换(尽量用令牌色或中性色)。
## 7. 给下一个 AI 的接手清单
1. 读本文件(状态全貌)→ `CHANGELOG.md`(最近的变更)→ `docs/api-contract.md`(后端契约)
2. 运行 `pnpm type-check` 确认基线
3. 如需继续实现工具:从第 4 节待办批次挑一批,按 `docs/tool-ideas.md` 的工具描述实现
4. 完成后:更新 CHANGELOG.md + 本文件 + `.workbuddy/memory/YYYY-MM-DD.md`

181
docs/api-contract.md Normal file
View File

@ -0,0 +1,181 @@
# 后端接口契约 · tool.xpcool.com
本文档定义工具箱「在线服务」分类所有工具的后端接口规范。
前端已通过 `src/api/client.ts` 统一请求,**后端按本文档实现接口即可直接接入**,前端无需改动。
## 通用约定
- **Base URL**:前端通过环境变量 `VITE_API_BASE` 配置(如 `https://api.xpcool.com`),未配置时页面显示「后端未接入」。
- **统一响应格式**(所有接口):
```json
{ "code": 0, "message": "ok", "data": { ... } }
```
- `code = 0` 成功;非 0 失败,`message` 为可读错误信息(前端直接展示给用户)。
- **编码**UTF-8GET 参数走 Query StringPOST 走 JSON Body无需鉴权公开工具站
- **CORS**:需要允许前端域名跨域(`Access-Control-Allow-Origin: *`)。
- 建议实现限流(如单 IP 每分钟 60 次),防止免费接口被刷。
## 接口一览
| # | 接口 | 说明 |
| --- | --- | --- |
| 1 | `GET /api/tools/exchange-rate` | 汇率换算 |
| 2 | `GET /api/tools/express-query` | 快递查询 |
| 3 | `GET /api/tools/phone-locate` | 手机号归属地 |
| 4 | `GET /api/tools/ip-info` | IP 信息查询 |
| 5 | `POST /api/tools/network-test` | 网络测试ping / tcp / dns |
| 6 | `GET /api/tools/speedtest/download` | 下载测速(返回随机二进制流) |
| 7 | `POST /api/tools/speedtest/upload` | 上传测速(接收二进制流) |
---
## 1. 汇率换算 `GET /api/tools/exchange-rate`
**参数**`from`(源货币代码,如 CNY、`to`(目标货币代码,如 USD、`amount`(金额,可选,默认 1
**响应 data**
```json
{
"from": "CNY",
"to": "USD",
"rate": 0.1398,
"amount": 100,
"result": 13.98,
"updated_at": "2026-08-24 17:00"
}
```
## 3. 快递查询 `GET /api/tools/express-query`
**参数**`no`(快递单号)
**响应 data**
```json
{
"no": "SF1234567890",
"company": "顺丰速运",
"status": "在途",
"traces": [
{ "time": "2026-08-24 15:32", "desc": "快件已到达【深圳中转场】" },
{ "time": "2026-08-24 09:10", "desc": "快件已从【杭州】发出" }
]
}
```
## 4. 手机号归属地 `GET /api/tools/phone-locate`
**参数**`phone`11 位手机号)
**响应 data**
```json
{
"phone": "13800138000",
"area": "北京",
"carrier": "中国移动",
"postcode": "100000",
"updated_at": "2026-08-24 17:00"
}
```
## 5. IP 信息查询 `GET /api/tools/ip-info`
**参数**`ip`IPv4可选不传则查询请求方 IP
**响应 data**
```json
{
"ip": "1.2.3.4",
"country": "中国",
"province": "广东",
"city": "深圳",
"isp": "电信",
"updated_at": "2026-08-24 17:00"
}
```
## 6. 网络测试 `POST /api/tools/network-test`
**请求体**
```json
{ "type": "ping", "host": "baidu.com", "port": null, "timeout": 5000 }
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| type | 是 | `ping` / `tcp` / `dns` |
| host | 是 | 主机名或 IP |
| port | tcp 必填 | 目标端口1-65535其他类型传 null |
| timeout | 否 | 超时毫秒,默认 5000 |
**响应 data**
```json
{
"type": "ping",
"host": "baidu.com",
"port": null,
"reachable": true,
"latency_ms": 23.5,
"detail": { "min_ms": 20.1, "avg_ms": 23.5, "max_ms": 28.3, "loss_pct": 0 }
}
```
各类型 `detail` 字段约定:
| type | detail 字段 | 说明 |
| --- | --- | --- |
| ping | `min_ms` / `avg_ms` / `max_ms` / `loss_pct` | 3 次探测的最小/平均/最大延迟ms与丢包率0-100 |
| tcp | `tcp_connect_ms` | TCP 握手耗时ms |
| dns | `dns_resolve_ms` / `ip` | 解析耗时与解析出的 IP |
`reachable=false``latency_ms` 传 0`detail` 传 `{ "error": "错误描述" }`
## 7. 网络测速
### 下载测速 `GET /api/tools/speedtest/download?size=10485760`
- 返回 **200 + `application/octet-stream` 随机字节流**(不是 JSON响应头需带 `Content-Length`(前端据此显示进度)。
- `size` 为请求的字节数(建议支持 5MB ~ 50MB
- 前端流式读取并按耗时计算下载 Mbps无需后端返回额外数据。
### 上传测速 `POST /api/tools/speedtest/upload`
- 请求体:`application/octet-stream` 原始二进制(前端生成随机数据上传)。
- 响应 JSON
```json
{ "code": 0, "message": "ok", "data": { "bytes": 5242880, "duration_ms": 812, "speed_mbps": 51.6 } }
```
| 字段 | 说明 |
| --- | --- |
| bytes | 实际接收字节数 |
| duration_ms | 接收耗时ms |
| speed_mbps | 后端计算的上传速度Mbps |
> 建议:下载测速时每次生成新的随机数据(`crypto/rand`),避免被缓存/CDN 命中导致测速失真;响应头 `Cache-Control: no-store`
---
## 错误示例
```json
{ "code": 40001, "message": "城市不存在,请检查输入", "data": null }
```
| code | 含义 |
| --- | --- |
| 40001 | 参数不合法(如城市不存在、单号格式错误) |
| 50001 | 上游数据源异常 |
| 50002 | 频率限制,请稍后重试 |
## 前端接入说明
1. 复制 `.env.example``.env`,填写 `VITE_API_BASE=https://api.xpcool.com`
2. 重启 `pnpm dev`,在线服务分类的工具即可请求
3. 后端实现时对照各接口的响应 `data` 字段,字段名保持一致即可(前端已按契约类型化)

133
docs/tool-ideas.md Normal file
View File

@ -0,0 +1,133 @@
# 工具创意库 · tool.xpcool.com
> 本文档是工具箱的「创意清单」与实现进度台账。
> 所有想法均遵循项目原则:**纯前端、无后端、数据不出浏览器**
> 必须联网的数据类工具统一走「在线服务」分类,由后端接口提供(契约见 `docs/api-contract.md`)。
## 一、现状概览
| 分类 | 数量 | 说明 | 状态 |
| --- | --- | --- | --- |
| 工作效率work | 18 | 开发编程、办公文本与文件处理 | ✅ |
| 开发进阶dev | 14 | 格式化、对比、转换与代码工具 | ✅ |
| 创意设计design | 3 | 渐变、占位图与字符画 | 🚧 补全中 |
| 生活助手life | 13 | 日常计算、查询与决策 | ✅ |
| 健康生活health | 0 | 睡眠、饮食与身体指标计算 | ⬜ 待实现 |
| 学习办公study | 0 | 拼音标注、记忆卡片与文档模板 | ⬜ 待实现 |
| 旅行出行travel | 0 | 出行清单、时差与距离计算 | ⬜ 待实现 |
| 趣味娱乐fun | 1 | 摩斯电码等小玩具 | 🚧 补全中 |
| 在线服务online | 6 | 依赖后端接口,后端接入中 | 🚧 后端预留 |
合计 **9 个分类 55 个工具**(在线服务 6 个为后端预留骨架)。
## 二、已实现清单(✅)
### 开发进阶 · 编程工具11 个2026-08-24 落地)
| 工具 | 说明 | 难度 |
| --- | --- | --- |
| 正则可视化 ✅ | 正则解析为结构树(分组/量词/断言/字符类)+ 匹配测试 | ★★★ |
| Markdown 表格 ✅ | 粘贴分隔文本 → Markdown / HTML 表格 | ★ |
| API 请求构造器 ✅ | 可视化拼接请求,生成 fetch 与 curl 代码 | ★ |
| .gitignore 生成器 ✅ | 按技术栈勾选生成忽略规则 | ★ |
| 代码转图片 ✅ | 代码渲染成卡片图片,语法高亮 + 深浅主题 | ★★ |
| 颜色命名器 ✅ | 内置 148 色表,色值匹配最近颜色名 | ★ |
| 缩进转换器 ✅ | 空格 / Tab 缩进互转 | ★ |
| 代码统计 ✅ | 总行 / 代码行 / 注释行 / 空白行30+ 语言注释规则 | ★★ |
| Hello World 大全 ✅ | 33 种语言入门代码速查 | ★ |
| JSON 对比 ✅ | 逐键 diff新增 / 删除 / 修改),路径级展示 | ★★ |
| 环境变量解析 ✅ | .env ↔ JSON / 键值对双向转换 | ★ |
### 在线服务 · 后端预留6 个,🚧 骨架已就绪,待后端按契约接入)
| 工具 | 接口 | 状态 |
| --- | --- | --- |
| 汇率换算 🚧 | `GET /api/tools/exchange-rate` | 页面就绪,等后端 |
| 快递查询 🚧 | `GET /api/tools/express-query` | 页面就绪,等后端 |
| 手机号归属地 🚧 | `GET /api/tools/phone-locate` | 页面就绪,等后端 |
| IP 信息查询 🚧 | `GET /api/tools/ip-info` | 页面就绪,等后端 |
| 网络测试 🚧 | `POST /api/tools/network-test` | HTTP 检测已可用Ping/TCP/DNS 等后端 |
| 网络测速 🚧 | `GET/POST /api/tools/speedtest/*` | 下载/延迟可用(公网源),上传等后端 |
> 前端通过 `src/api/client.ts` 统一请求,`.env` 配置 `VITE_API_BASE` 即可接入;
> 接口字段契约见 `docs/api-contract.md`
## 三、待实现批次(⬜)
### 批次 A健康生活health12 个)
| 工具 | 说明 | 难度 |
| --- | --- | --- |
| 睡眠周期计算 | 按入睡时间推荐起床时刻90 分钟周期) | ★ |
| 喝水提醒 | 按体重算每日饮水量 + 分布时间 | ★ |
| 体脂率估算 | 性别/围度公式估算体脂率 | ★ |
| 卡路里估算 | 常见食物热量查询(内置表) | ★ |
| 预产期计算 | 末次月经推算预产期与孕周 | ★ |
| 房贷提前还款 | 提前还贷金额与节省利息测算 | ★★ |
| 生理周期记录 | 周期推算(本地存储) | ★★ |
| 运动配速计算 | 距离/时间 → 配速换算 | ★ |
| BMI 对比表 | 各标准 BMI 分级对照(已有 BMI可增强 | ★ |
| 热量平衡计算 | 基础代谢 + 活动系数 → 每日所需热量 | ★★ |
| 理想体重 | 多种理想体重公式对照 | ★ |
| 步数时长换算 | 步数 → 距离 / 消耗热量 | ★ |
### 批次 B学习办公study10 个)
| 工具 | 说明 | 难度 |
| --- | --- | --- |
| 汉字拼音标注 | 基于 pinyin-pro 给整段中文标拼音 | ★ |
| 单词记忆卡 | 内置常用词表,简易间隔重复 | ★★ |
| 随机口算题 | 四则运算题批量生成(可打印) | ★ |
| 乘法表练习 | 9×9 乘法表随机练习计时 | ★ |
| 请假条生成器 | 选模板填信息生成请假条文本 | ★ |
| 会议纪要模板 | 常用会议纪要结构模板 | ★ |
| 邮件模板 | 商务邮件常用句型模板 | ★ |
| 公文格式助手 | 标题/正文/落款常用格式速查 | ★ |
| 学习计划表 | 按天生成复习/学习计划 | ★ |
| 名言摘抄本 | 分类名言收藏(本地存储) | ★ |
### 批次 C旅行出行travel6 个)
| 工具 | 说明 | 难度 |
| --- | --- | --- |
| 行李清单 | 出行场景勾选清单(可勾选保存) | ★ |
| 机票折扣速算 | 全价/折扣率 → 折后价 | ★ |
| 签证材料清单 | 常见目的地材料清单 | ★ |
| 地图距离估算 | 经纬度两点距离Haversine | ★ |
| 时差换算 | 两地时差与会议时间推荐 | ★ |
| 小费计算器 | 金额 + 比例 → 小费与总额 | ★ |
### 批次 D创意设计补全design9 个)
| 工具 | 说明 | 难度 |
| --- | --- | --- |
| 阴影生成器 | box-shadow 可视化调参 + 预设 | ★ |
| 圆角生成器 | border-radius 四角独立调节 | ★ |
| 调色板生成 | 基于主色的互补/三角/近似色板 | ★ |
| 颜色对比度检查 | WCAG 对比度评级 | ★ |
| 像素画编辑器 | 网格点画,导出 PNG | ★★ |
| 纹理生成器 | 条纹/网格/点阵/噪点 CSS 背景 | ★★ |
| 文字效果生成 | 立体字/渐变字/描边字 CSS 输出 | ★★ |
| Emoji 放大镜 | 超大 emoji 预览 + 关键词搜索 | ★ |
| 图片九宫格切图 | 一张图切成 3×3 网格导出 | ★★ |
### 批次 E趣味娱乐补全fun14 个)
| 工具 | 说明 | 难度 |
| --- | --- | --- |
| 加密暗号机 | 凯撒/维吉尼亚/栅栏密码编解码 | ★ |
| Emoji 密文 | 文本 → emoji 象形编码 | ★ |
| 藏头诗生成 | 内置意象词库拼接生成 | ★★ |
| 绕口令生成器 | 内置素材库随机组合 | ★ |
| 塔罗牌占卜 | 本地随机抽牌 + 牌意说明 | ★ |
| 星座运势 | 按生日算星座 + 本地随机运势 | ★ |
| 名字评分 | 按笔画/谐音/字义趣味评分 | ★★ |
| 电子木鱼 | 点击敲木鱼,功德 +1Web Audio | ★ |
| 猜成语小游戏 | 内置成语库,猜中计数 | ★★ |
| 音效合成器 | Web Audio 合成提示音并导出 | ★★ |
| 节日倒计时 | 距离下个节日天数(内置列表) | ★ |
| 情侣默契问答 | 内置题目库双人问答计分 | ★★ |
| 名言警句生成 | 分类名言随机 + 作者标注 | ★ |
| 骰子塔 | 1-6 骰子多枚投掷动画 | ★ |
## 四、新增工具三步走
1. 新建 `src/views/<tool-name>/<ToolName>View.vue`,复用公共组件
`CopyButton` / `DownloadButton` / `ErrorAlert` / `HistoryPanel` / `UploadDrop`
2. 在 `src/router/tools.ts``tools` 数组追加一条配置
3. 新分类在 `toolCategories` 追加 `{ key, label, desc }`,导航与首页自动渲染
> 详细约定见 `AGENTS.md`;后端接口契约见 `docs/api-contract.md`

88
src/api/client.ts Normal file
View File

@ -0,0 +1,88 @@
/**
* API 线
*
* VITE_API_BASE https://api.xpcool.com
* .env vite.config.ts define
*
*
* docs/api-contract.md
* { "code": 0, "message": "ok", "data": { ... } }
* code === 0 0 message
*/
export interface ApiResult<T = unknown> {
ok: boolean
data: T | null
message: string
/** HTTP 状态码0 表示未接入/未发出请求,-1 表示网络错误 */
status: number
}
const BASE_URL: string = (import.meta.env.VITE_API_BASE as string | undefined) ?? ''
/** 后端是否已配置接入地址 */
export const backendReady = BASE_URL !== ''
/** 后端 Base URL在线服务工具直接拼接下载/上传地址时使用) */
export { BASE_URL }
const OFFLINE_MSG = '后端服务尚未接入,请先在 .env 配置 VITE_API_BASE并按 docs/api-contract.md 实现接口'
interface Envelope<T> {
code: number
message?: string
data?: T
}
async function request<T>(
method: 'GET' | 'POST',
path: string,
params?: Record<string, string | number | undefined>,
body?: unknown,
): Promise<ApiResult<T>> {
if (!backendReady) {
return { ok: false, data: null, message: OFFLINE_MSG, status: 0 }
}
try {
const url = new URL(path, BASE_URL.endsWith('/') ? BASE_URL : BASE_URL + '/')
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== '') url.searchParams.append(k, String(v))
}
}
const res = await fetch(url, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
let json: Envelope<T> | null = null
try {
json = (await res.json()) as Envelope<T>
} catch {
// 响应非 JSON
}
if (!res.ok) {
return {
ok: false,
data: null,
message: json?.message || `请求失败HTTP ${res.status}`,
status: res.status,
}
}
if (json && json.code === 0) {
return { ok: true, data: (json.data ?? null) as T | null, message: json.message || 'ok', status: res.status }
}
return { ok: false, data: null, message: json?.message || '接口返回异常', status: res.status }
} catch {
return { ok: false, data: null, message: '网络错误:无法连接后端服务', status: -1 }
}
}
export const api = {
get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<ApiResult<T>> {
return request<T>('GET', path, params)
},
post<T>(path: string, body?: unknown): Promise<ApiResult<T>> {
return request<T>('POST', path, undefined, body)
},
}

View File

@ -29,6 +29,25 @@ import {
UsergroupIcon, UsergroupIcon,
GiftIcon, GiftIcon,
AlarmIcon, AlarmIcon,
TerminalIcon,
GitBranchIcon,
ApiIcon,
BrushIcon,
SoundIcon,
ViewModuleIcon,
SearchIcon,
Table1Icon,
LogoGithubIcon,
ContrastIcon,
Edit2Icon,
ChartBarIcon,
EarthIcon,
GitMergeIcon,
SettingIcon,
CurrencyExchangeIcon,
MobileIcon,
WifiIcon,
RocketIcon,
} from 'tdesign-icons-vue-next' } from 'tdesign-icons-vue-next'
/** /**
@ -56,7 +75,14 @@ export interface ToolCategory {
export const toolCategories: ToolCategory[] = [ export const toolCategories: ToolCategory[] = [
{ key: 'work', label: '工作效率', desc: '开发编程、办公文本与文件处理' }, { key: 'work', label: '工作效率', desc: '开发编程、办公文本与文件处理' },
{ key: 'life', label: '生活助手', desc: '日常生活中的计算、查询与决策小工具' }, { key: 'dev', label: '开发进阶', desc: '面向开发者的格式化、转换与对比工具' },
{ key: 'design', label: '创意设计', desc: '生成渐变、占位图与字符画,灵感随手可得' },
{ key: 'life', label: '生活助手', desc: '日常计算、查询与决策小工具' },
{ key: 'health', label: '健康生活', desc: '睡眠、饮食与身体指标计算' },
{ key: 'study', label: '学习办公', desc: '拼音标注、记忆卡片与文档模板' },
{ key: 'travel', label: '旅行出行', desc: '出行清单、时差与距离计算' },
{ key: 'fun', label: '趣味娱乐', desc: '摩斯电码、抽奖转盘等好玩的小玩具' },
{ key: 'online', label: '在线服务', desc: '依赖后端接口的工具,后端接入中' },
] ]
export interface ToolItem { export interface ToolItem {
@ -326,6 +352,208 @@ export const tools: ToolItem[] = [
category: 'life', category: 'life',
component: () => import('@/views/pomodoro/PomodoroView.vue'), component: () => import('@/views/pomodoro/PomodoroView.vue'),
}, },
/* ==================== 开发进阶 ==================== */
{
path: 'sql-format',
name: 'SQL 格式化',
desc: 'SQL 语句美化:关键词换行、子句缩进,实时预览',
icon: TerminalIcon,
category: 'dev',
component: () => import('@/views/sql-format/SqlFormatView.vue'),
},
{
path: 'text-diff',
name: '文本对比',
desc: '逐行对比两段文本,增删高亮一目了然',
icon: GitBranchIcon,
category: 'dev',
component: () => import('@/views/text-diff/TextDiffView.vue'),
},
{
path: 'json-to-code',
name: 'JSON 转代码',
desc: 'JSON 一键生成 TypeScript 接口、Go 结构体与 Python 类',
icon: ApiIcon,
category: 'dev',
component: () => import('@/views/json-to-code/JsonToCodeView.vue'),
},
/* ==================== 创意设计 ==================== */
{
path: 'gradient-generator',
name: '渐变生成器',
desc: '可视化调出 CSS 渐变,复制即用,内置流行配色预设',
icon: BrushIcon,
category: 'design',
component: () => import('@/views/gradient-generator/GradientGeneratorView.vue'),
},
{
path: 'placeholder-image',
name: '占位图生成',
desc: '自定义尺寸与配色的占位图PNG 下载 / SVG 代码复制',
icon: ImageIcon,
category: 'design',
component: () => import('@/views/placeholder-image/PlaceholderImageView.vue'),
},
{
path: 'ascii-art',
name: '图片转字符画',
desc: '把图片转成 ASCII 字符画,可调密度与反转,酷炫复古',
icon: ViewModuleIcon,
category: 'design',
component: () => import('@/views/ascii-art/AsciiArtView.vue'),
},
/* ==================== 趣味娱乐 ==================== */
{
path: 'morse-code',
name: '摩斯电码',
desc: '中英文与摩斯码互转,还能播放滴滴答答的电报声',
icon: SoundIcon,
category: 'fun',
component: () => import('@/views/morse-code/MorseCodeView.vue'),
},
/* ==================== 开发进阶(编程工具) ==================== */
{
path: 'regex-visual',
name: '正则可视化',
desc: '把正则解析成结构树,附匹配测试',
icon: SearchIcon,
category: 'dev',
component: () => import('@/views/regex-visual/RegexVisualView.vue'),
},
{
path: 'markdown-table',
name: 'Markdown 表格',
desc: '粘贴数据一键生成 Markdown / HTML 表格',
icon: Table1Icon,
category: 'dev',
component: () => import('@/views/markdown-table/MarkdownTableView.vue'),
},
{
path: 'api-builder',
name: 'API 请求构造器',
desc: '可视化拼接请求,生成 fetch 与 curl 代码',
icon: ApiIcon,
category: 'dev',
component: () => import('@/views/api-builder/ApiBuilderView.vue'),
},
{
path: 'gitignore-gen',
name: '.gitignore 生成器',
desc: '按技术栈勾选生成忽略规则',
icon: LogoGithubIcon,
category: 'dev',
component: () => import('@/views/gitignore-gen/GitignoreGenView.vue'),
},
{
path: 'code-to-image',
name: '代码转图片',
desc: '代码渲染成卡片图片,支持语法高亮',
icon: ImageIcon,
category: 'dev',
component: () => import('@/views/code-to-image/CodeToImageView.vue'),
},
{
path: 'color-namer',
name: '颜色命名器',
desc: '色值匹配最接近的 CSS 颜色名称',
icon: ContrastIcon,
category: 'dev',
component: () => import('@/views/color-namer/ColorNamerView.vue'),
},
{
path: 'indent-convert',
name: '缩进转换器',
desc: '空格与 Tab 缩进互转2/4/8 空格切换',
icon: Edit2Icon,
category: 'dev',
component: () => import('@/views/indent-convert/IndentConvertView.vue'),
},
{
path: 'code-stats',
name: '代码统计',
desc: '统计总行、代码行、注释行与空白行',
icon: ChartBarIcon,
category: 'dev',
component: () => import('@/views/code-stats/CodeStatsView.vue'),
},
{
path: 'hello-world',
name: 'Hello World 大全',
desc: '30+ 编程语言的入门代码速查',
icon: EarthIcon,
category: 'dev',
component: () => import('@/views/hello-world/HelloWorldView.vue'),
},
{
path: 'json-diff',
name: 'JSON 对比',
desc: '逐键对比两个 JSON 的新增、删除与修改',
icon: GitMergeIcon,
category: 'dev',
component: () => import('@/views/json-diff/JsonDiffView.vue'),
},
{
path: 'env-parse',
name: '环境变量解析',
desc: '.env 与 JSON / 键值对双向转换',
icon: SettingIcon,
category: 'dev',
component: () => import('@/views/env-parse/EnvParseView.vue'),
},
/* ==================== 在线服务(后端预留) ==================== */
{
path: 'exchange-rate',
name: '汇率换算',
desc: '主流货币实时汇率换算(后端接入中)',
icon: CurrencyExchangeIcon,
category: 'online',
component: () => import('@/views/exchange-rate/ExchangeRateView.vue'),
},
{
path: 'express-query',
name: '快递查询',
desc: '物流轨迹一键查询(后端接入中)',
icon: LocationIcon,
category: 'online',
component: () => import('@/views/express-query/ExpressQueryView.vue'),
},
{
path: 'phone-locate',
name: '手机号归属地',
desc: '归属地与运营商查询(后端接入中)',
icon: MobileIcon,
category: 'online',
component: () => import('@/views/phone-locate/PhoneLocateView.vue'),
},
{
path: 'ip-info',
name: 'IP 信息查询',
desc: 'IP 归属地与运营商查询(后端接入中)',
icon: InternetIcon,
category: 'online',
component: () => import('@/views/ip-info/IpInfoView.vue'),
},
{
path: 'network-test',
name: '网络测试',
desc: '站点可达性检测HTTP / Ping / TCP / DNS',
icon: WifiIcon,
category: 'online',
component: () => import('@/views/network-test/NetworkTestView.vue'),
},
{
path: 'speed-test',
name: '网络测速',
desc: '宽带下载/上传速度与延迟测试',
icon: RocketIcon,
category: 'online',
component: () => import('@/views/speed-test/SpeedTestView.vue'),
},
] ]
/** 工具默认分类:未填写 category 时归入第一个分类 */ /** 工具默认分类:未填写 category 时归入第一个分类 */

View File

@ -0,0 +1,173 @@
<template>
<div class="page-wrap">
<h2 class="page-title">API 请求构造器</h2>
<p class="page-desc">可视化拼接 HTTP 请求实时生成 fetch 代码与 curl 命令方便调试与分享</p>
<t-card class="tool-section">
<div class="form-row">
<t-select v-model="method" :options="METHODS" style="width: 110px" />
<t-input v-model="url" placeholder="https://api.example.com/users" class="url-input" />
</div>
<t-divider>Query 参数</t-divider>
<div v-for="(p, i) in queryParams" :key="'q' + i" class="kv-row">
<t-input v-model="p.key" placeholder="key" class="kv-key" />
<span class="kv-eq">=</span>
<t-input v-model="p.value" placeholder="value" class="kv-val" @enter="addQuery" />
<t-button variant="text" size="small" theme="danger" @click="queryParams.splice(i, 1)">
<template #icon><DeleteIcon /></template>
</t-button>
</div>
<t-button variant="outline" size="small" @click="addQuery">+ 添加参数</t-button>
<t-divider>Header</t-divider>
<div v-for="(h, i) in headers" :key="'h' + i" class="kv-row">
<t-input v-model="h.key" placeholder="Header 名" class="kv-key" />
<span class="kv-eq">:</span>
<t-input v-model="h.value" placeholder="值" class="kv-val" @enter="addHeader" />
<t-button variant="text" size="small" theme="danger" @click="headers.splice(i, 1)">
<template #icon><DeleteIcon /></template>
</t-button>
</div>
<t-button variant="outline" size="small" @click="addHeader">+ 添加 Header</t-button>
<template v-if="hasBody">
<t-divider>请求体JSON</t-divider>
<t-textarea v-model="body" :autosize="{ minRows: 4, maxRows: 12 }" placeholder='{"name":"张三"}' />
</template>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card class="tool-section" title="fetch 代码">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="fetchCode" />
</div>
<pre class="code-block">{{ fetchCode || '填写请求信息后自动生成' }}</pre>
</t-card>
<t-card class="tool-section" title="curl 命令">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="curlCode" />
</div>
<pre class="code-block">{{ curlCode || '填写请求信息后自动生成' }}</pre>
</t-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { DeleteIcon } from 'tdesign-icons-vue-next'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
interface Kv {
key: string
value: string
}
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'].map((m) => ({ label: m, value: m }))
const method = ref('GET')
const url = ref('https://api.example.com/users')
const queryParams = ref<Kv[]>([{ key: 'page', value: '1' }])
const headers = ref<Kv[]>([{ key: 'Content-Type', value: 'application/json' }])
const body = ref('{\n "name": "张三",\n "age": 28\n}')
const error = ref('')
function addQuery() {
queryParams.value.push({ key: '', value: '' })
}
function addHeader() {
headers.value.push({ key: '', value: '' })
}
const hasBody = computed(() => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.value))
const fullUrl = computed(() => {
const params = queryParams.value.filter((p) => p.key.trim())
if (!params.length) return url.value
const qs = params
.map((p) => `${encodeURIComponent(p.key.trim())}=${encodeURIComponent(p.value)}`)
.join('&')
return url.value + (url.value.includes('?') ? '&' : '?') + qs
})
const validHeaders = computed(() => headers.value.filter((h) => h.key.trim()))
const fetchCode = computed(() => {
if (!url.value.trim()) return ''
const lines: string[] = []
lines.push(`fetch(${JSON.stringify(fullUrl.value)}, {`)
if (method.value !== 'GET') lines.push(` method: '${method.value}',`)
const hdrs = validHeaders.value
if (hdrs.length) {
lines.push(` headers: {`)
for (const h of hdrs) lines.push(` '${h.key.trim()}': '${h.value}',`)
lines.push(` },`)
}
if (hasBody.value && body.value.trim()) {
lines.push(` body: JSON.stringify(${body.value.trim()}),`)
}
lines.push(`})`)
if (!hasBody.value || body.value.trim()) {
lines.push(` .then((res) => res.json())`)
lines.push(` .then((data) => console.log(data));`)
}
return lines.join('\n')
})
const curlCode = computed(() => {
if (!url.value.trim()) return ''
const parts: string[] = [`curl -X ${method.value} ${JSON.stringify(fullUrl.value)}`]
for (const h of validHeaders.value) parts.push(` -H '${h.key.trim()}: ${h.value}'`)
if (hasBody.value && body.value.trim()) parts.push(` -d '${body.value.trim()}'`)
return parts.join(' \\\n')
})
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-row {
display: flex;
gap: 8px;
}
.url-input {
flex: 1;
}
.kv-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.kv-key {
width: 200px;
}
.kv-val {
flex: 1;
}
.kv-eq {
color: var(--td-text-color-placeholder);
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.code-block {
margin: 12px 0 0;
padding: 12px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,218 @@
<template>
<div class="page-wrap">
<h2 class="page-title">图片转字符画</h2>
<p class="page-desc">把照片变成复古 ASCII 字符画按亮度映射字符可调密度与反转一键复制或下载</p>
<t-card class="tool-section" title="上传图片">
<UploadDrop v-model:files="files" accept="image/*" tip="选择一张图片,生成字符画" />
</t-card>
<t-card class="tool-section" title="生成选项">
<div class="form-row">
<span class="form-label">宽度</span>
<t-slider v-model="cols" :min="40" :max="160" :step="10" class="slider" />
<span class="slider-val">{{ cols }} 字符</span>
</div>
<div class="form-row">
<span class="form-label">密度</span>
<t-radio-group v-model="charset" variant="default-filled">
<t-radio-button value="fine">精细</t-radio-button>
<t-radio-button value="standard">标准</t-radio-button>
<t-radio-button value="bold">简洁</t-radio-button>
</t-radio-group>
</div>
<div class="form-row">
<span class="form-label">高度</span>
<t-slider v-model="heightRatio" :min="40" :max="70" :step="5" class="slider" />
<span class="slider-val">{{ heightRatio }}%</span>
</div>
<div class="form-row">
<t-checkbox v-model="invert">反色浅色区域用密集字符</t-checkbox>
</div>
<div class="form-actions">
<t-button theme="primary" :disabled="!files.length" @click="generate">生成字符画</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="result" class="tool-section" title="字符画结果">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="result" />
<t-button variant="outline" @click="downloadTxt">
<template #icon><DownloadIcon /></template>
下载 .txt
</t-button>
<span class="size-tip">{{ result.length }} 字符</span>
</div>
<pre class="ascii-block">{{ result }}</pre>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import UploadDrop from '@/components/common/UploadDrop.vue'
import { useHistory } from '@/composables/useHistory'
import { downloadText } from '@/composables/useDownload'
interface ArtParams {
cols: number
charset: string
heightRatio: number
invert: boolean
}
const history = useHistory('ascii-art')
const files = ref<File[]>([])
const cols = ref(100)
const charset = ref<'fine' | 'standard' | 'bold'>('fine')
const heightRatio = ref(50)
const invert = ref(false)
const result = ref('')
const error = ref('')
/** 字符集按亮度从"密集"到"稀疏"排列 */
const CHARSETS: Record<string, string> = {
fine: '@%#*+=-:. ',
standard: '@%#*+=:. ',
bold: '#*=- ',
}
function generate() {
error.value = ''
result.value = ''
const file = files.value[0]
if (!file) {
error.value = '请先选择一张图片'
return
}
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
try {
result.value = renderAscii(img)
history.add(JSON.stringify({ cols: cols.value, charset: charset.value, heightRatio: heightRatio.value, invert: invert.value, name: file.name }))
} catch (e) {
error.value = `生成失败:${e instanceof Error ? e.message : '未知错误'}`
} finally {
URL.revokeObjectURL(url)
}
}
img.onerror = () => {
URL.revokeObjectURL(url)
error.value = '图片加载失败,请换一张试试'
}
img.src = url
}
function renderAscii(img: HTMLImageElement): string {
const c = Math.min(160, Math.max(40, Math.round(cols.value)))
// 2
const rows = Math.max(10, Math.round(c * (img.height / img.width) * (heightRatio.value / 100)))
const canvas = document.createElement('canvas')
canvas.width = c
canvas.height = rows
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) throw new Error('无法创建画布')
ctx.drawImage(img, 0, 0, c, rows)
const { data } = ctx.getImageData(0, 0, c, rows)
const chars = CHARSETS[charset.value] || CHARSETS.fine
const lines: string[] = []
for (let y = 0; y < rows; y++) {
let line = ''
for (let x = 0; x < c; x++) {
const i = (y * c + x) * 4
// RGB
const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
const v = invert.value ? 255 - gray : gray
const idx = Math.min(chars.length - 1, Math.floor((v / 256) * chars.length))
line += chars[idx]
}
lines.push(line)
}
return lines.join('\n')
}
function downloadTxt() {
if (!result.value) return
downloadText(result.value, `ascii-art-${Date.now()}.txt`)
}
function onHistorySelect(text: string) {
try {
const h = JSON.parse(text) as Partial<ArtParams> | null
if (!h) return
if (typeof h.cols === 'number') cols.value = h.cols
if (h.charset === 'fine' || h.charset === 'standard' || h.charset === 'bold') charset.value = h.charset
if (typeof h.heightRatio === 'number') heightRatio.value = h.heightRatio
if (typeof h.invert === 'boolean') invert.value = h.invert
//
result.value = ''
} catch {
//
}
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.slider {
flex: 1;
max-width: 420px;
}
.slider-val {
min-width: 64px;
font-size: 13px;
color: var(--td-text-color-secondary);
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.size-tip {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.ascii-block {
margin: 12px 0 0;
padding: 12px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, 'Courier New', Consolas, monospace;
font-size: 11px;
line-height: 1.1;
white-space: pre;
overflow-x: auto;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,239 @@
<template>
<div class="page-wrap">
<h2 class="page-title">代码统计</h2>
<p class="page-desc">统计代码的总行数代码行注释行与空白行支持常见编程语言可上传文件或粘贴代码</p>
<t-card class="tool-section" title="代码来源">
<UploadDrop v-model:files="files" accept=".js,.ts,.jsx,.tsx,.vue,.py,.go,.java,.c,.cpp,.h,.cs,.rb,.php,.sh,.sql,.css,.scss,.html,.json,.yml,.yaml,.md,.kt,.swift,.rs,.lua,.pl,.r,.m,.xml,.toml,.ini,.txt" tip="上传一个代码文件(自动识别语言)" />
<p class="or">或直接粘贴代码</p>
<t-textarea v-model="code" :autosize="{ minRows: 6, maxRows: 14 }" placeholder="粘贴代码" />
<div class="form-row">
<span class="form-label">语言</span>
<t-select v-model="lang" :options="LANGS" style="width: 160px" />
</div>
<div class="form-actions">
<t-button theme="primary" @click="analyze">开始统计</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="stats" class="tool-section" title="统计结果">
<div class="stat-grid">
<div class="stat-card">
<b>{{ stats.total }}</b>
<span>总行数</span>
</div>
<div class="stat-card">
<b>{{ stats.code }}</b>
<span>代码行</span>
</div>
<div class="stat-card">
<b>{{ stats.comment }}</b>
<span>注释行</span>
</div>
<div class="stat-card">
<b>{{ stats.blank }}</b>
<span>空白行</span>
</div>
</div>
<p class="file-info" v-if="fileName">文件{{ fileName }}语言{{ lang }}</p>
</t-card>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import UploadDrop from '@/components/common/UploadDrop.vue'
const files = ref<File[]>([])
const code = ref('')
const lang = ref('javascript')
const error = ref('')
const stats = ref<{ total: number; code: number; comment: number; blank: number } | null>(null)
const fileName = ref('')
const LANGS = [
'javascript', 'typescript', 'python', 'go', 'java', 'c', 'cpp', 'csharp', 'ruby',
'php', 'shell', 'sql', 'css', 'scss', 'html', 'json', 'yaml', 'markdown',
'kotlin', 'swift', 'rust', 'lua', 'xml', 'ini', 'plaintext',
].map((l) => ({ label: l, value: l }))
/** 各语言的注释规则 */
const COMMENT_RULES: Record<string, { line?: string[]; block?: Array<[string, string]> }> = {
javascript: { line: ['//'], block: [['/*', '*/']] },
typescript: { line: ['//'], block: [['/*', '*/']] },
python: { line: ['#'], block: [['"""', '"""'], ["'''", "'''"]] },
go: { line: ['//'], block: [['/*', '*/']] },
java: { line: ['//'], block: [['/*', '*/']] },
c: { line: ['//'], block: [['/*', '*/']] },
cpp: { line: ['//'], block: [['/*', '*/']] },
csharp: { line: ['//'], block: [['/*', '*/']] },
ruby: { line: ['#'], block: [['=begin', '=end']] },
php: { line: ['//', '#'], block: [['/*', '*/']] },
shell: { line: ['#'] },
sql: { line: ['--'], block: [['/*', '*/']] },
css: { block: [['/*', '*/']] },
scss: { line: ['//'], block: [['/*', '*/']] },
html: { block: [['<!--', '-->']] },
json: {},
yaml: { line: ['#'] },
markdown: {},
kotlin: { line: ['//'], block: [['/*', '*/']] },
swift: { line: ['//'], block: [['/*', '*/']] },
rust: { line: ['//'], block: [['/*', '*/']] },
lua: { line: ['--'], block: [['--[[', ']]']] },
xml: { block: [['<!--', '-->']] },
ini: { line: [';', '#'] },
plaintext: {},
}
/** 根据文件名猜测语言 */
function detectLang(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() ?? ''
const map: Record<string, string> = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', tsx: 'typescript', vue: 'typescript',
py: 'python', go: 'go', java: 'java', c: 'c', h: 'c',
cpp: 'cpp', hpp: 'cpp', cc: 'cpp', cs: 'csharp', rb: 'ruby',
php: 'php', sh: 'shell', bash: 'shell', sql: 'sql', css: 'css',
scss: 'scss', html: 'html', htm: 'html', json: 'json',
yml: 'yaml', yaml: 'yaml', md: 'markdown', kt: 'kotlin',
swift: 'swift', rs: 'rust', lua: 'lua', xml: 'xml', ini: 'ini',
txt: 'plaintext',
}
return map[ext] ?? 'plaintext'
}
function analyze() {
error.value = ''
stats.value = null
const text = code.value || (files.value[0] ? '' : '')
if (code.value.trim()) {
run(text, lang.value)
} else if (files.value[0]) {
const f = files.value[0]
fileName.value = f.name
lang.value = detectLang(f.name)
const reader = new FileReader()
reader.onload = () => run(String(reader.result ?? ''), lang.value)
reader.onerror = () => (error.value = '文件读取失败')
reader.readAsText(f)
} else {
error.value = '请上传文件或粘贴代码'
}
}
function run(text: string, language: string) {
const rules = COMMENT_RULES[language] ?? {}
const lineComments = rules.line ?? []
const blockComments = rules.block ?? []
const lines = text.split('\n')
let codeLines = 0
let commentLines = 0
let blankLines = 0
let inBlock = false
for (const raw of lines) {
const line = raw.trim()
if (line === '') {
blankLines++
continue
}
//
if (inBlock) {
commentLines++
for (const [start, end] of blockComments) {
if (line.includes(end)) {
inBlock = false
break
}
}
continue
}
//
const isLineComment = lineComments.some((c) => line.startsWith(c))
const blockStart = blockComments.find(([s]) => line.startsWith(s))
if (isLineComment || blockStart) {
commentLines++
if (blockStart) {
const [s, e] = blockStart
if (!line.includes(e, s.length)) inBlock = true
}
continue
}
//
const blockMid = blockComments.find(([s]) => line.includes(s))
if (blockMid) {
codeLines++
const [s, e] = blockMid
if (!line.includes(e, line.indexOf(s) + s.length)) inBlock = true
continue
}
codeLines++
}
stats.value = { total: lines.length, code: codeLines, comment: commentLines, blank: blankLines }
}
watch(files, () => {
if (files.value[0]) {
const f = files.value[0]
lang.value = detectLang(f.name)
fileName.value = f.name
}
})
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.or {
margin: 16px 0 8px;
font-size: 13px;
color: var(--td-text-color-secondary);
}
.form-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.form-label {
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.form-actions {
margin-top: 16px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
}
.stat-card b {
font-size: 28px;
color: var(--td-brand-color);
}
.stat-card span {
font-size: 13px;
color: var(--td-text-color-secondary);
}
.file-info {
margin: 12px 0 0;
font-size: 13px;
color: var(--td-text-color-placeholder);
}
</style>

View File

@ -0,0 +1,308 @@
<template>
<div class="page-wrap">
<h2 class="page-title">代码转图片</h2>
<p class="page-desc">把代码渲染成漂亮的代码卡片图片支持语法高亮适合分享到社交媒体</p>
<t-card class="tool-section" title="代码">
<t-textarea
v-model="code"
:autosize="{ minRows: 8, maxRows: 20 }"
placeholder="粘贴代码"
/>
<div class="form-row">
<span class="form-label">语言</span>
<t-select v-model="lang" :options="LANGS" style="width: 130px" />
<span class="form-label">主题</span>
<t-radio-group v-model="theme" variant="default-filled">
<t-radio-button value="dark">暗色</t-radio-button>
<t-radio-button value="light">浅色</t-radio-button>
</t-radio-group>
<span class="form-label">宽度</span>
<t-slider v-model="width" :min="600" :max="1200" :step="50" class="width-slider" />
<span class="slider-val">{{ width }}px</span>
</div>
<div class="form-row">
<span class="form-label">文件名</span>
<t-input v-model="filename" placeholder="可选,显示在卡片顶部" class="file-input" />
</div>
<div class="form-actions">
<t-button theme="primary" @click="render">生成图片</t-button>
<t-button variant="outline" @click="loadSample">示例</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="rendered" class="tool-section" title="预览">
<div class="canvas-wrap">
<canvas ref="canvasRef" class="code-canvas" />
</div>
<div class="form-actions">
<t-button theme="primary" @click="downloadPng">
<template #icon><DownloadIcon /></template>
下载 PNG
</t-button>
</div>
</t-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { downloadDataUrl } from '@/composables/useDownload'
const LANGS = ['javascript', 'typescript', 'python', 'go', 'html', 'css', 'json', 'sql'].map((l) => ({
label: l,
value: l,
}))
const code = ref('')
const lang = ref('javascript')
const theme = ref<'dark' | 'light'>('dark')
const width = ref(800)
const filename = ref('demo.js')
const error = ref('')
const rendered = ref(false)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const SAMPLE = `// 示例代码
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
for (let i = 0; i < 10; i++) {
console.log(fibonacci(i));
}`
interface ThemeColors {
bg: string
titlebar: string
text: string
comment: string
string: string
keyword: string
number: string
punctuation: string
}
const THEMES: Record<string, ThemeColors> = {
dark: {
bg: '#1e1e2e',
titlebar: '#262637',
text: '#e4e4ef',
comment: '#6a9955',
string: '#ce9178',
keyword: '#569cd6',
number: '#b5cea8',
punctuation: '#8f93a2',
},
light: {
bg: '#ffffff',
titlebar: '#f4f4f7',
text: '#383a42',
comment: '#a0a1a7',
string: '#50a14f',
keyword: '#a626a4',
number: '#986801',
punctuation: '#9d9d9d',
},
}
const KEYWORDS: Record<string, string[]> = {
javascript: ['function', 'const', 'let', 'var', 'return', 'if', 'else', 'for', 'while', 'new', 'class', 'import', 'export', 'from', 'default', 'async', 'await', 'try', 'catch', 'throw', 'typeof', 'instanceof', 'switch', 'case', 'break', 'continue', 'true', 'false', 'null', 'undefined'],
typescript: ['interface', 'type', 'enum', 'implements', 'extends', 'readonly', 'public', 'private', 'protected', 'function', 'const', 'let', 'var', 'return', 'if', 'else', 'for', 'while', 'new', 'class', 'import', 'export', 'from', 'default', 'async', 'await', 'try', 'catch', 'throw', 'typeof', 'switch', 'case', 'break', 'continue', 'true', 'false', 'null', 'undefined'],
python: ['def', 'class', 'return', 'if', 'elif', 'else', 'for', 'while', 'import', 'from', 'as', 'with', 'try', 'except', 'finally', 'raise', 'lambda', 'pass', 'break', 'continue', 'global', 'nonlocal', 'yield', 'in', 'not', 'and', 'or', 'is', 'None', 'True', 'False'],
go: ['func', 'package', 'import', 'return', 'if', 'else', 'for', 'range', 'switch', 'case', 'break', 'continue', 'var', 'const', 'type', 'struct', 'interface', 'map', 'chan', 'go', 'defer', 'select', 'fallthrough', 'default'],
html: ['<!DOCTYPE', 'html', 'head', 'body', 'div', 'span', 'class', 'id', 'style', 'script', 'link', 'meta', 'title', 'table', 'tr', 'td', 'th', 'ul', 'li', 'a', 'img', 'input', 'button', 'form', 'p', 'h1', 'h2', 'h3'],
css: ['color', 'background', 'margin', 'padding', 'border', 'display', 'flex', 'position', 'width', 'height', 'font', 'text', 'align', 'justify', 'content', 'overflow', 'z-index', 'transition', 'transform', 'opacity', 'cursor'],
json: [],
sql: ['SELECT', 'FROM', 'WHERE', 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP', 'BY', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'UNION', 'ALL', 'AND', 'OR', 'NOT', 'AS', 'CREATE', 'TABLE', 'ALTER', 'DROP', 'INDEX', 'VIEW', 'PRIMARY', 'KEY', 'DISTINCT'],
}
interface Segment {
text: string
color: string
}
/** 行内简单语法高亮:注释 / 字符串 / 数字 / 关键字 / 普通文本 */
function highlightLine(line: string, c: ThemeColors): Segment[] {
const segs: Segment[] = []
let i = 0
const n = line.length
while (i < n) {
//
const commentMatch = line.slice(i).match(/^\/\/.*|^#.*|^<!--.*|^\/\*.*$/)
if (commentMatch) {
segs.push({ text: line.slice(i), color: c.comment })
break
}
//
const strMatch = line.slice(i).match(/^(["'`])(?:\\.|(?!\1).)*\1/)
if (strMatch) {
segs.push({ text: strMatch[0], color: c.string })
i += strMatch[0].length
continue
}
//
const numMatch = line.slice(i).match(/^\d+(?:\.\d+)?/)
if (numMatch) {
segs.push({ text: numMatch[0], color: c.number })
i += numMatch[0].length
continue
}
// /
const wordMatch = line.slice(i).match(/^[A-Za-z_$][A-Za-z0-9_$]*/)
if (wordMatch) {
const word = wordMatch[0]
const keywords = KEYWORDS[lang.value] ?? []
const isKw =
keywords.includes(word) || (lang.value === 'sql' && keywords.includes(word.toUpperCase()))
segs.push({ text: word, color: isKw ? c.keyword : c.text })
i += word.length
continue
}
// /
const ch = line[i]
segs.push({ text: ch, color: /[<>{}()[\]"'`,.;:=+\-*/%&|!?]/.test(ch) ? c.punctuation : c.text })
i++
}
return segs
}
function render() {
error.value = ''
rendered.value = false
const canvas = canvasRef.value
if (!canvas) return
const lines = code.value.split('\n')
if (!lines.length || (lines.length === 1 && lines[0] === '')) {
error.value = '请输入代码'
return
}
const c = THEMES[theme.value]
const dpr = window.devicePixelRatio || 1
const W = Math.min(1200, Math.max(400, width.value))
const fontSize = 14
const lineHeight = 24
const padX = 24
const titleBarH = filename.value ? 40 : 0
const H = titleBarH + padX + lines.length * lineHeight + 16
canvas.width = W * dpr
canvas.height = H * dpr
canvas.style.width = W + 'px'
canvas.style.height = H + 'px'
const ctx = canvas.getContext('2d')!
ctx.scale(dpr, dpr)
//
ctx.fillStyle = c.bg
roundRect(ctx, 0, 0, W, H, 12)
ctx.fill()
//
if (titleBarH) {
ctx.fillStyle = c.titlebar
ctx.fillRect(0, 0, W, titleBarH)
ctx.fillStyle = c.bg
roundRect(ctx, 0, 0, W, 12, 12)
ctx.fill()
//
for (const [x, color] of [[18, '#ff5f57'], [38, '#febc2e'], [58, '#28c840']] as const) {
ctx.beginPath()
ctx.arc(x, 20, 5, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
}
ctx.fillStyle = c.comment
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(filename.value, W / 2, 20)
}
//
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
ctx.font = `14px 'Consolas', 'Courier New', monospace`
lines.forEach((line, idx) => {
const y = titleBarH + padX / 2 + idx * lineHeight
let x = padX
for (const seg of highlightLine(line, c)) {
ctx.fillStyle = seg.color
ctx.fillText(seg.text, x, y)
x += ctx.measureText(seg.text).width
}
})
rendered.value = true
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
}
function downloadPng() {
const canvas = canvasRef.value
if (canvas) downloadDataUrl(canvas.toDataURL('image/png'), (filename.value || 'code') + '.png')
}
function loadSample() {
code.value = SAMPLE
render()
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.form-label {
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.width-slider {
flex: 1;
min-width: 160px;
max-width: 300px;
}
.slider-val {
font-size: 13px;
color: var(--td-text-color-secondary);
}
.file-input {
width: 240px;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.canvas-wrap {
overflow-x: auto;
}
.code-canvas {
max-width: 100%;
height: auto;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.18);
}
</style>

View File

@ -0,0 +1,244 @@
<template>
<div class="page-wrap">
<h2 class="page-title">颜色命名器</h2>
<p class="page-desc">输入任意色值匹配最接近的 CSS 颜色名称也可以浏览内置色板取色</p>
<t-card class="tool-section" title="查询色值">
<div class="form-row">
<t-input v-model="input" placeholder="#ff6347 或 rgb(255, 99, 71)" class="color-input" @enter="match" />
<input type="color" :value="validHex" class="picker" @input="onPicker" />
<t-button theme="primary" @click="match">匹配</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="matched" class="tool-section" title="匹配结果">
<div class="match-row">
<div class="swatch" :style="{ background: matched.hex }" />
<div class="match-info">
<div class="match-name">{{ matched.name }}</div>
<div class="match-hex">{{ matched.hex }} · 距离 {{ matched.distance }}</div>
</div>
<div class="match-actions">
<CopyButton :text="matched.name" label="复制名称" />
<CopyButton :text="matched.hex" label="复制色值" />
</div>
</div>
</t-card>
<t-card class="tool-section" title="内置色板(点击复制色值)">
<div class="palette">
<div
v-for="c in COLORS"
:key="c.name"
class="palette-item"
:title="`${c.name} ${c.hex}`"
@click="copySwatch(c.hex)"
>
<div class="palette-swatch" :style="{ background: c.hex }" />
<span class="palette-name">{{ c.name }}</span>
</div>
</div>
</t-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import { copyText } from '@/composables/useClipboard'
const input = ref('#ff6347')
const error = ref('')
interface ColorEntry {
name: string
hex: string
}
const COLORS: ColorEntry[] = [
['aliceblue', 'f0f8ff'], ['antiquewhite', 'faebd7'], ['aqua', '00ffff'], ['aquamarine', '7fffd4'],
['azure', 'f0ffff'], ['beige', 'f5f5dc'], ['bisque', 'ffe4c4'], ['black', '000000'],
['blanchedalmond', 'ffebcd'], ['blue', '0000ff'], ['blueviolet', '8a2be2'], ['brown', 'a52a2a'],
['burlywood', 'deb887'], ['cadetblue', '5f9ea0'], ['chartreuse', '7fff00'], ['chocolate', 'd2691e'],
['coral', 'ff7f50'], ['cornflowerblue', '6495ed'], ['cornsilk', 'fff8dc'], ['crimson', 'dc143c'],
['cyan', '00ffff'], ['darkblue', '00008b'], ['darkcyan', '008b8b'], ['darkgoldenrod', 'b8860b'],
['darkgray', 'a9a9a9'], ['darkgreen', '006400'], ['darkkhaki', 'bdb76b'], ['darkmagenta', '8b008b'],
['darkolivegreen', '556b2f'], ['darkorange', 'ff8c00'], ['darkorchid', '9932cc'], ['darkred', '8b0000'],
['darksalmon', 'e9967a'], ['darkseagreen', '8fbc8f'], ['darkslateblue', '483d8b'], ['darkslategray', '2f4f4f'],
['darkturquoise', '00ced1'], ['darkviolet', '9400d3'], ['deeppink', 'ff1493'], ['deepskyblue', '00bfff'],
['dimgray', '696969'], ['dodgerblue', '1e90ff'], ['firebrick', 'b22222'], ['floralwhite', 'fffaf0'],
['forestgreen', '228b22'], ['fuchsia', 'ff00ff'], ['gainsboro', 'dcdcdc'], ['ghostwhite', 'f8f8ff'],
['gold', 'ffd700'], ['goldenrod', 'daa520'], ['gray', '808080'], ['green', '008000'],
['greenyellow', 'adff2f'], ['honeydew', 'f0fff0'], ['hotpink', 'ff69b4'], ['indianred', 'cd5c5c'],
['indigo', '4b0082'], ['ivory', 'fffff0'], ['khaki', 'f0e68c'], ['lavender', 'e6e6fa'],
['lavenderblush', 'fff0f5'], ['lawngreen', '7cfc00'], ['lemonchiffon', 'fffacd'], ['lightblue', 'add8e6'],
['lightcoral', 'f08080'], ['lightcyan', 'e0ffff'], ['lightgoldenrodyellow', 'fafad2'], ['lightgray', 'd3d3d3'],
['lightgreen', '90ee90'], ['lightpink', 'ffb6c1'], ['lightsalmon', 'ffa07a'], ['lightseagreen', '20b2aa'],
['lightskyblue', '87cefa'], ['lightslategray', '778899'], ['lightsteelblue', 'b0c4de'], ['lightyellow', 'ffffe0'],
['lime', '00ff00'], ['limegreen', '32cd32'], ['linen', 'faf0e6'], ['magenta', 'ff00ff'],
['maroon', '800000'], ['mediumaquamarine', '66cdaa'], ['mediumblue', '0000cd'], ['mediumorchid', 'ba55d3'],
['mediumpurple', '9370db'], ['mediumseagreen', '3cb371'], ['mediumslateblue', '7b68ee'], ['mediumspringgreen', '00fa9a'],
['mediumturquoise', '48d1cc'], ['mediumvioletred', 'c71585'], ['midnightblue', '191970'], ['mintcream', 'f5fffa'],
['mistyrose', 'ffe4e1'], ['moccasin', 'ffe4b5'], ['navajowhite', 'ffdead'], ['navy', '000080'],
['oldlace', 'fdf5e6'], ['olive', '808000'], ['olivedrab', '6b8e23'], ['orange', 'ffa500'],
['orangered', 'ff4500'], ['orchid', 'da70d6'], ['palegoldenrod', 'eee8aa'], ['palegreen', '98fb98'],
['paleturquoise', 'afeeee'], ['palevioletred', 'db7093'], ['papayawhip', 'ffefd5'], ['peachpuff', 'ffdab9'],
['peru', 'cd853f'], ['pink', 'ffc0cb'], ['plum', 'dda0dd'], ['powderblue', 'b0e0e6'],
['purple', '800080'], ['rebeccapurple', '663399'], ['red', 'ff0000'], ['rosybrown', 'bc8f8f'],
['royalblue', '4169e1'], ['saddlebrown', '8b4513'], ['salmon', 'fa8072'], ['sandybrown', 'f4a460'],
['seagreen', '2e8b57'], ['seashell', 'fff5ee'], ['sienna', 'a0522d'], ['silver', 'c0c0c0'],
['skyblue', '87ceeb'], ['slateblue', '6a5acd'], ['slategray', '708090'], ['snow', 'fffafa'],
['springgreen', '00ff7f'], ['steelblue', '4682b4'], ['tan', 'd2b48c'], ['teal', '008080'],
['thistle', 'd8bfd8'], ['tomato', 'ff6347'], ['turquoise', '40e0d0'], ['violet', 'ee82ee'],
['wheat', 'f5deb3'], ['white', 'ffffff'], ['whitesmoke', 'f5f5f5'], ['yellow', 'ffff00'],
['yellowgreen', '9acd32'],
].map(([name, hex]) => ({ name, hex: '#' + hex }))
/** 解析 HEX / rgb() / 纯数字三元组,返回 RGB 或 null */
function parseColor(text: string): { r: number; g: number; b: number } | null {
const t = text.trim().toLowerCase()
let m = t.match(/^#?([0-9a-f]{3})$/i)
if (m) {
const s = m[1]
return { r: parseInt(s[0] + s[0], 16), g: parseInt(s[1] + s[1], 16), b: parseInt(s[2] + s[2], 16) }
}
m = t.match(/^#?([0-9a-f]{6})$/i)
if (m) {
const s = m[1]
return { r: parseInt(s.slice(0, 2), 16), g: parseInt(s.slice(2, 4), 16), b: parseInt(s.slice(4, 6), 16) }
}
m = t.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/)
if (m) return { r: +m[1], g: +m[2], b: +m[3] }
m = t.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/)
if (m) return { r: +m[1], g: +m[2], b: +m[3] }
return null
}
const matched = ref<{ name: string; hex: string; distance: number } | null>(null)
const validHex = computed(() => {
const rgb = parseColor(input.value)
if (!rgb) return '#ff6347'
const hex = ((1 << 24) | (rgb.r << 16) | (rgb.g << 8) | rgb.b).toString(16).slice(1)
return '#' + hex
})
function onPicker(e: Event) {
input.value = (e.target as HTMLInputElement).value
match()
}
function match() {
error.value = ''
matched.value = null
const rgb = parseColor(input.value)
if (!rgb) {
error.value = '无法识别的颜色格式,支持 #RGB / #RRGGBB / rgb(r,g,b)'
return
}
let best: ColorEntry | null = null
let bestDist = Infinity
for (const c of COLORS) {
const h = c.hex.slice(1)
const cr = parseInt(h.slice(0, 2), 16)
const cg = parseInt(h.slice(2, 4), 16)
const cb = parseInt(h.slice(4, 6), 16)
const d = (rgb.r - cr) ** 2 + (rgb.g - cg) ** 2 + (rgb.b - cb) ** 2
if (d < bestDist) {
bestDist = d
best = c
}
}
if (best) matched.value = { name: best.name, hex: best.hex, distance: Math.round(Math.sqrt(bestDist)) }
}
function copySwatch(hex: string) {
void copyText(hex, `已复制 ${hex}`)
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-row {
display: flex;
align-items: center;
gap: 8px;
}
.color-input {
flex: 1;
max-width: 320px;
font-family: ui-monospace, Consolas, monospace;
}
.picker {
width: 40px;
height: 32px;
padding: 0;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
}
.match-row {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.swatch {
width: 64px;
height: 64px;
border-radius: 8px;
border: 1px solid var(--td-border-level-2-color);
flex-shrink: 0;
}
.match-info {
flex: 1;
}
.match-name {
font-size: 22px;
font-weight: 700;
color: var(--td-text-color-primary);
}
.match-hex {
font-size: 13px;
color: var(--td-text-color-secondary);
font-family: ui-monospace, Consolas, monospace;
}
.match-actions {
display: flex;
gap: 8px;
}
.palette {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
}
.palette-item {
cursor: pointer;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
overflow: hidden;
transition: transform 0.15s;
}
.palette-item:hover {
transform: translateY(-2px);
}
.palette-swatch {
height: 36px;
}
.palette-name {
display: block;
padding: 4px 6px;
font-size: 12px;
color: var(--td-text-color-secondary);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,167 @@
<template>
<div class="page-wrap">
<h2 class="page-title">环境变量解析</h2>
<p class="page-desc">.env 文本与 JSON / 键值对双向转换支持注释引号与 export 前缀</p>
<t-card class="tool-section">
<div class="form-row">
<t-radio-group v-model="direction" variant="default-filled">
<t-radio-button value="env2json">.env JSON</t-radio-button>
<t-radio-button value="json2env">JSON .env</t-radio-button>
</t-radio-group>
</div>
<t-textarea
v-model="input"
:autosize="{ minRows: 8, maxRows: 16 }"
:placeholder="direction === 'env2json' ? PLACEHOLDER_ENV : PLACEHOLDER_JSON"
style="margin-top: 12px"
/>
<div class="form-actions">
<t-button theme="primary" @click="convert">转换</t-button>
<t-button variant="outline" @click="loadSample">示例</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="result" class="tool-section" title="转换结果">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="result" />
</div>
<pre class="code-block">{{ result }}</pre>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
const history = useHistory('env-parse')
const direction = ref<'env2json' | 'json2env'>('env2json')
const input = ref('')
const result = ref('')
const error = ref('')
const PLACEHOLDER_ENV = '# 数据库配置\nDB_HOST=localhost\nDB_PORT=3306\nAPI_KEY="abc123"\nexport DEBUG=true'
const PLACEHOLDER_JSON = '{\n "DB_HOST": "localhost",\n "DB_PORT": 3306,\n "API_KEY": "abc123"\n}'
/** 解析 .env 文本 → 扁平对象 */
function parseEnv(text: string): Record<string, string> {
const out: Record<string, string> = {}
for (const raw of text.split('\n')) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
// export
const body = line.replace(/^export\s+/, '')
const eq = body.indexOf('=')
if (eq <= 0) continue
const key = body.slice(0, eq).trim()
let value = body.slice(eq + 1).trim()
//
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
// #
const hash = value.search(/\s#/)
if (hash !== -1) value = value.slice(0, hash).trim()
out[key] = value
}
return out
}
/** 对象 → .env 文本 */
function toEnv(obj: Record<string, unknown>): string {
return Object.entries(obj)
.map(([k, v]) => {
if (v === null || v === undefined) return `${k}=`
if (typeof v === 'string') {
return /[\s#"]/.test(v) ? `${k}="${v}"` : `${k}=${v}`
}
return `${k}=${JSON.stringify(v)}`
})
.join('\n')
}
function convert() {
error.value = ''
result.value = ''
const text = input.value.trim()
if (!text) {
error.value = '请输入内容'
return
}
try {
if (direction.value === 'env2json') {
const obj = parseEnv(text)
if (Object.keys(obj).length === 0) {
error.value = '未解析到任何变量,请检查格式'
return
}
result.value = JSON.stringify(obj, null, 2)
} else {
const obj = JSON.parse(text) as Record<string, unknown>
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
error.value = 'JSON 需要是对象类型'
return
}
result.value = toEnv(obj)
}
history.add(text.length > 800 ? text.slice(0, 800) : text)
} catch (e) {
error.value = `转换失败:${e instanceof Error ? e.message : '格式错误'}`
}
}
function loadSample() {
input.value =
direction.value === 'env2json'
? '# 数据库配置\nDB_HOST=localhost\nDB_PORT=3306\nAPI_KEY="abc123"\nexport DEBUG=true'
: '{\n "DB_HOST": "localhost",\n "DB_PORT": 3306,\n "API_KEY": "abc123",\n "DEBUG": true\n}'
convert()
}
function onHistorySelect(text: string) {
input.value = text
convert()
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.code-block {
margin: 12px 0 0;
padding: 12px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,142 @@
<template>
<div class="page-wrap">
<h2 class="page-title">汇率换算</h2>
<p class="page-desc">实时汇率换算支持主流货币汇率由后端接口提供</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端服务尚未接入:在 .env 中配置 VITE_API_BASE 并按 docs/api-contract.md 实现接口后即可使用" />
<t-card class="tool-section" title="换算">
<div class="form-row">
<t-input-number v-model="amount" :min="0" :step="1" class="amount-input" />
<t-select v-model="from" :options="CURRENCIES" class="cur-select" />
<span class="swap-icon"></span>
<t-select v-model="to" :options="CURRENCIES" class="cur-select" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="query">
{{ loading ? '查询中…' : '换算' }}
</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="data" class="tool-section" title="换算结果">
<div class="result-box">
<span class="r-amount">{{ data.amount }} {{ data.from }}</span>
<span class="r-arrow">=</span>
<span class="r-result">{{ data.result }} {{ data.to }}</span>
</div>
<div class="rate-line">1 {{ data.from }} = {{ data.rate }} {{ data.to }}</div>
<p class="w-updated">汇率更新时间{{ data.updated_at }}</p>
</t-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { api, backendReady } from '@/api/client'
interface RateData {
from: string
to: string
rate: number
amount: number
result: number
updated_at: string
}
const CURRENCIES = [
{ label: '人民币 CNY', value: 'CNY' },
{ label: '美元 USD', value: 'USD' },
{ label: '欧元 EUR', value: 'EUR' },
{ label: '英镑 GBP', value: 'GBP' },
{ label: '日元 JPY', value: 'JPY' },
{ label: '港元 HKD', value: 'HKD' },
{ label: '新台币 TWD', value: 'TWD' },
{ label: '韩元 KRW', value: 'KRW' },
{ label: '新加坡元 SGD', value: 'SGD' },
{ label: '澳元 AUD', value: 'AUD' },
{ label: '加元 CAD', value: 'CAD' },
{ label: '泰铢 THB', value: 'THB' },
{ label: '卢布 RUB', value: 'RUB' },
{ label: '印度卢比 INR', value: 'INR' },
]
const amount = ref(100)
const from = ref('CNY')
const to = ref('USD')
const data = ref<RateData | null>(null)
const error = ref('')
const loading = ref(false)
async function query() {
error.value = ''
data.value = null
if (!(amount.value > 0)) {
error.value = '请输入大于 0 的金额'
return
}
loading.value = true
const res = await api.get<RateData>('/api/tools/exchange-rate', {
from: from.value,
to: to.value,
amount: amount.value,
})
loading.value = false
if (res.ok) {
data.value = res.data
} else {
error.value = res.message
}
}
</script>
<style scoped>
.page-wrap {
max-width: 720px;
}
.form-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.amount-input {
width: 150px;
}
.cur-select {
width: 150px;
}
.swap-icon {
color: var(--td-text-color-placeholder);
}
.result-box {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
padding: 16px 0 8px;
}
.r-amount {
font-size: 20px;
color: var(--td-text-color-secondary);
}
.r-arrow {
font-size: 20px;
color: var(--td-text-color-placeholder);
}
.r-result {
font-size: 32px;
font-weight: 700;
color: var(--td-brand-color);
}
.rate-line {
font-size: 13px;
color: var(--td-text-color-secondary);
}
.w-updated {
margin: 8px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
</style>

View File

@ -0,0 +1,145 @@
<template>
<div class="page-wrap">
<h2 class="page-title">快递查询</h2>
<p class="page-desc">输入快递单号查询物流轨迹数据由后端接口提供</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端服务尚未接入:在 .env 中配置 VITE_API_BASE 并按 docs/api-contract.md 实现接口后即可使用" />
<t-card class="tool-section" title="查询单号">
<div class="form-row">
<t-input v-model="no" placeholder="输入快递单号,如 SF1234567890" clearable @enter="query" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="query">
{{ loading ? '查询中…' : '查询' }}
</t-button>
</div>
<p class="hint">支持顺丰圆通中通韵达京东等常见快递由后端识别承运商</p>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="data" class="tool-section" title="物流信息">
<div class="head">
<b>{{ data.company }}</b>
<span class="no">{{ data.no }}</span>
<t-tag theme="primary" variant="light">{{ data.status }}</t-tag>
</div>
<div class="trace-list">
<div v-for="(t, i) in data.traces" :key="i" class="trace-item" :class="{ first: i === 0 }">
<div class="dot" />
<div class="trace-body">
<div class="trace-desc">{{ t.desc }}</div>
<div class="trace-time">{{ t.time }}</div>
</div>
</div>
</div>
</t-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { api, backendReady } from '@/api/client'
interface Trace {
time: string
desc: string
}
interface ExpressData {
no: string
company: string
status: string
traces: Trace[]
}
const no = ref('')
const data = ref<ExpressData | null>(null)
const error = ref('')
const loading = ref(false)
async function query() {
error.value = ''
data.value = null
const noText = no.value.trim()
if (!noText) {
error.value = '请输入快递单号'
return
}
loading.value = true
const res = await api.get<ExpressData>('/api/tools/express-query', { no: noText })
loading.value = false
if (res.ok) {
data.value = res.data
} else {
error.value = res.message
}
}
</script>
<style scoped>
.page-wrap {
max-width: 720px;
}
.form-row {
display: flex;
gap: 8px;
}
.hint {
margin: 12px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.no {
font-family: ui-monospace, Consolas, monospace;
font-size: 13px;
color: var(--td-text-color-secondary);
}
.trace-list {
position: relative;
padding-left: 16px;
}
.trace-list::before {
content: '';
position: absolute;
left: 5px;
top: 8px;
bottom: 8px;
width: 2px;
background: var(--td-border-level-2-color);
}
.trace-item {
position: relative;
display: flex;
gap: 12px;
padding: 6px 0;
}
.dot {
position: absolute;
left: -16px;
top: 10px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--td-border-level-2-color);
}
.trace-item.first .dot {
background: var(--td-brand-color);
box-shadow: 0 0 0 4px var(--td-brand-color-light);
}
.trace-desc {
font-size: 14px;
color: var(--td-text-color-primary);
line-height: 1.5;
}
.trace-time {
font-size: 12px;
color: var(--td-text-color-placeholder);
margin-top: 2px;
}
</style>

View File

@ -0,0 +1,164 @@
<template>
<div class="page-wrap">
<h2 class="page-title">.gitignore 生成器</h2>
<p class="page-desc">按技术栈勾选常用忽略规则一键生成 .gitignore 文件</p>
<t-card class="tool-section" title="选择技术栈">
<div class="check-grid">
<t-checkbox v-for="g in GROUPS" :key="g.key" v-model="selected" :value="g.key" :label="g.label" />
</div>
<div class="form-actions">
<t-button theme="primary" @click="generate">生成 .gitignore</t-button>
<t-button variant="outline" @click="selectAll">全选</t-button>
<t-button variant="outline" @click="clearAll">清空</t-button>
</div>
</t-card>
<t-card v-if="result" class="tool-section" title="生成结果">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="result" />
<t-button variant="outline" @click="download">
<template #icon><DownloadIcon /></template>
下载文件
</t-button>
</div>
<pre class="code-block">{{ result }}</pre>
</t-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import CopyButton from '@/components/common/CopyButton.vue'
import { downloadText } from '@/composables/useDownload'
interface IgnoreGroup {
key: string
label: string
lines: string[]
}
const GROUPS: IgnoreGroup[] = [
{
key: 'node',
label: 'Node.js',
lines: ['node_modules/', 'npm-debug.log*', 'yarn-debug.log*', 'yarn-error.log*', 'pnpm-debug.log*', '.pnpm-store/'],
},
{
key: 'python',
label: 'Python',
lines: ['__pycache__/', '*.py[cod]', '*$py.class', '*.so', '.Python', 'venv/', '.venv/', 'env/', 'pip-log.txt', '.mypy_cache/', '.pytest_cache/', '.ruff_cache/'],
},
{
key: 'go',
label: 'Go',
lines: ['*.exe', '*.exe~', '*.dll', '*.so', '*.dylib', '*.test', '*.out', 'bin/'],
},
{
key: 'java',
label: 'Java',
lines: ['*.class', '*.jar', '*.war', '*.ear', 'target/', '.gradle/', 'build/', '.settings/', '.project', '.classpath'],
},
{
key: 'vue',
label: 'Vue / Vite',
lines: ['node_modules/', 'dist/', 'dist-ssr/', '*.local', '.vite/', '.vscode/*', 'coverage/'],
},
{
key: 'env',
label: '环境变量',
lines: ['.env', '.env.*', '!.env.example', '.env.local', '.env.production', '.env.development'],
},
{
key: 'idea',
label: 'IDEA',
lines: ['.idea/', '*.iml', '*.ipr', '*.iws', '.idea/workspace.xml', '.idea/tasks.xml'],
},
{
key: 'vscode',
label: 'VS Code',
lines: ['.vscode/*', '!.vscode/extensions.json', '!.vscode/settings.json', '!.vscode/tasks.json', '!.vscode/launch.json'],
},
{
key: 'macos',
label: 'macOS',
lines: ['.DS_Store', '.AppleDouble', '.LSOverride', '._*', '.Spotlight-V100', '.Trashes'],
},
{
key: 'windows',
label: 'Windows',
lines: ['Thumbs.db', 'ehthumbs.db', 'Desktop.ini', '$RECYCLE.BIN/'],
},
{
key: 'logs',
label: '日志与临时文件',
lines: ['*.log', 'logs/', 'tmp/', 'temp/', '.cache/', '.tmp/', '*.bak'],
},
{
key: 'misc',
label: '通用',
lines: ['.DS_Store', '*.swp', '*~', '*.orig', '.idea/', '.vscode/', 'coverage/', 'dist/', 'build/'],
},
]
const selected = ref<string[]>(['node', 'env'])
function generate() {
const chosen = GROUPS.filter((g) => selected.value.includes(g.key))
if (!chosen.length) {
result.value = ''
return
}
const sections = chosen.map((g) => `# ${g.label}\n${g.lines.join('\n')}`)
result.value = sections.join('\n\n') + '\n'
}
const result = ref('')
const fileName = computed(() => '.gitignore')
function download() {
if (result.value) downloadText(result.value, fileName.value)
}
function selectAll() {
selected.value = GROUPS.map((g) => g.key)
generate()
}
function clearAll() {
selected.value = []
result.value = ''
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.check-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.code-block {
margin: 12px 0 0;
padding: 12px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,279 @@
<template>
<div class="page-wrap">
<h2 class="page-title">渐变生成器</h2>
<p class="page-desc">可视化调出漂亮的 CSS 渐变背景线性径向锥形三种类型色标自由增删复制即用</p>
<t-card class="tool-section">
<!-- 预览区 -->
<div class="preview-box" :style="{ background: cssCode }">
<span v-if="!stops.length" class="preview-empty">请至少保留两个色标</span>
</div>
<div class="form-actions" style="margin-top: 12px">
<t-radio-group v-model="type" variant="default-filled">
<t-radio-button value="linear">线性 linear</t-radio-button>
<t-radio-button value="radial">径向 radial</t-radio-button>
<t-radio-button value="conic">锥形 conic</t-radio-button>
</t-radio-group>
<template v-if="type !== 'radial'">
<span class="form-label">角度</span>
<t-input-number v-model="angle" :min="0" :max="360" :step="15" class="angle-input" />
<span class="form-label">deg</span>
</template>
<t-button variant="outline" @click="randomize">
<template #icon><RefreshIcon /></template>
随机渐变
</t-button>
<t-button variant="outline" @click="reverse">反转</t-button>
</div>
<!-- 色标编辑 -->
<div class="stops">
<div v-for="(s, i) in stops" :key="i" class="stop-row">
<input type="color" v-model="s.color" class="color-input" />
<span class="stop-hex">{{ s.color.toUpperCase() }}</span>
<span class="form-label">位置</span>
<t-input-number v-model="s.pos" :min="0" :max="100" :step="5" class="pos-input" />
<span class="form-label">%</span>
<div class="stop-actions">
<t-button variant="text" size="small" :disabled="i === 0" @click="move(i, -1)">
<template #icon><ArrowUpIcon /></template>
</t-button>
<t-button variant="text" size="small" :disabled="i === stops.length - 1" @click="move(i, 1)">
<template #icon><ArrowDownIcon /></template>
</t-button>
<t-button variant="text" size="small" theme="danger" :disabled="stops.length <= 2" @click="removeStop(i)">
<template #icon><DeleteIcon /></template>
</t-button>
</div>
</div>
<t-button variant="outline" size="small" @click="addStop">+ 添加色标</t-button>
</div>
<!-- 预设 -->
<div class="presets">
<div
v-for="p in PRESETS"
:key="p.name"
class="preset-chip"
:style="{ background: presetBg(p.colors) }"
:title="p.name"
@click="applyPreset(p.colors)"
/>
</div>
</t-card>
<t-card class="tool-section" title="CSS 代码">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="cssCode" />
</div>
<pre class="code-block">{{ cssCode }}</pre>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ArrowDownIcon, ArrowUpIcon, DeleteIcon, RefreshIcon } from 'tdesign-icons-vue-next'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
interface Stop {
color: string
pos: number
}
const history = useHistory('gradient-generator')
const type = ref<'linear' | 'radial' | 'conic'>('linear')
const angle = ref(135)
const stops = ref<Stop[]>([
{ color: '#667eea', pos: 0 },
{ color: '#764ba2', pos: 100 },
])
const PRESETS: Array<{ name: string; colors: string[] }> = [
{ name: '紫罗兰', colors: ['#667eea', '#764ba2'] },
{ name: '霓虹粉', colors: ['#f953c6', '#b91d73'] },
{ name: '海洋', colors: ['#2193b0', '#6dd5ed'] },
{ name: '日落', colors: ['#ff512f', '#dd2476'] },
{ name: '森林', colors: ['#11998e', '#38ef7d'] },
{ name: '星夜', colors: ['#0f0c29', '#302b63', '#24243e'] },
{ name: '蜜桃', colors: ['#ffecd2', '#fcb69f'] },
{ name: '极光', colors: ['#00c9ff', '#92fe9d'] },
{ name: '黄昏', colors: ['#f83600', '#f9d423'] },
{ name: '冰川', colors: ['#e0eafc', '#cfdef3'] },
{ name: '葡萄汽水', colors: ['#a18cd1', '#fbc2eb'] },
{ name: '柠檬汽水', colors: ['#f6d365', '#fda085'] },
]
/** 随机生成一组颜色与角度 */
function randomize() {
angle.value = Math.floor(Math.random() * 360)
const n = 2 + Math.floor(Math.random() * 2) // 2 3
const newStops: Stop[] = []
for (let i = 0; i < n; i++) {
const hue = Math.floor(Math.random() * 360)
const sat = 60 + Math.floor(Math.random() * 30)
const light = 45 + Math.floor(Math.random() * 20)
newStops.push({ color: `hsl(${hue}, ${sat}%, ${light}%)`, pos: Math.round((i / (n - 1)) * 100) })
}
stops.value = newStops
history.add(JSON.stringify({ type: type.value, angle: angle.value, stops: stops.value }))
}
function reverse() {
stops.value = [...stops.value].reverse().map((s) => ({ ...s, pos: 100 - s.pos }))
}
function addStop() {
const last = stops.value[stops.value.length - 1]
stops.value.push({ color: '#333333', pos: Math.min(100, last.pos + 20) })
}
function removeStop(i: number) {
if (stops.value.length <= 2) return
stops.value.splice(i, 1)
}
function move(i: number, dir: -1 | 1) {
const j = i + dir
if (j < 0 || j >= stops.value.length) return
const arr = [...stops.value]
;[arr[i], arr[j]] = [arr[j], arr[i]]
stops.value = arr
}
function applyPreset(colors: string[]) {
stops.value = colors.map((c, i) => ({ color: c, pos: Math.round((i / (colors.length - 1)) * 100) }))
}
function presetBg(colors: string[]): string {
const pct = colors.map((c, i) => `${c} ${Math.round((i / (colors.length - 1)) * 100)}%`).join(', ')
return `linear-gradient(135deg, ${pct})`
}
const cssCode = computed(() => {
const parts = stops.value.map((s) => `${s.color} ${s.pos}%`).join(', ')
if (type.value === 'linear') return `background: linear-gradient(${angle.value}deg, ${parts});`
if (type.value === 'radial') return `background: radial-gradient(circle at center, ${parts});`
return `background: conic-gradient(from ${angle.value}deg, ${parts});`
})
function onHistorySelect(text: string) {
try {
const h = JSON.parse(text) as { type?: string; angle?: number; stops?: Stop[] } | null
if (!h || !Array.isArray(h.stops) || h.stops.length < 2) return
if (h.type === 'linear' || h.type === 'radial' || h.type === 'conic') type.value = h.type
if (typeof h.angle === 'number') angle.value = h.angle
stops.value = h.stops.map((s) => ({ color: s.color, pos: Number(s.pos) || 0 }))
} catch {
//
}
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.preview-box {
height: 200px;
border-radius: 8px;
border: 1px solid var(--td-border-level-1-color);
display: flex;
align-items: center;
justify-content: center;
}
.preview-empty {
color: var(--td-text-color-placeholder);
font-size: 14px;
}
.form-label {
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.angle-input,
.pos-input {
width: 100px;
}
.stops {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.stop-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
flex-wrap: wrap;
}
.color-input {
width: 36px;
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
}
.stop-hex {
font-family: ui-monospace, Consolas, monospace;
font-size: 13px;
min-width: 66px;
color: var(--td-text-color-primary);
}
.stop-actions {
display: flex;
gap: 2px;
margin-left: auto;
}
.presets {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preset-chip {
width: 44px;
height: 32px;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--td-border-level-1-color);
transition: transform 0.15s;
}
.preset-chip:hover {
transform: scale(1.12);
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.code-block {
margin: 12px 0 0;
padding: 12px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<div class="page-wrap">
<h2 class="page-title">Hello World 大全</h2>
<p class="page-desc">收录 30+ 编程语言的经典入门代码支持搜索与一键复制</p>
<t-card class="tool-section">
<t-input v-model="keyword" placeholder="搜索语言名称或关键词,如 python / go / java" clearable>
<template #prefix-icon><SearchIcon /></template>
</t-input>
</t-card>
<div class="lang-grid">
<t-card v-for="item in filtered" :key="item.name" class="lang-card">
<div class="lang-head">
<b>{{ item.name }}</b>
<t-tag v-if="item.hint" size="small" variant="light" theme="primary">{{ item.hint }}</t-tag>
</div>
<pre class="lang-code">{{ item.code }}</pre>
<div class="lang-actions">
<t-button variant="text" size="small" @click="copyItem(item)">
<template #icon><CopyIcon /></template>
复制
</t-button>
</div>
</t-card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { CopyIcon, SearchIcon } from 'tdesign-icons-vue-next'
import { copyText } from '@/composables/useClipboard'
interface LangItem {
name: string
hint: string
code: string
}
const LANGS: LangItem[] = [
{ name: 'Python', hint: '解释型', code: `print("Hello, World!")` },
{ name: 'JavaScript', hint: '浏览器/Node', code: `console.log("Hello, World!");` },
{ name: 'TypeScript', hint: 'JS 超集', code: `console.log("Hello, World!");` },
{ name: 'Go', hint: '编译型', code: `package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("Hello, World!")\n}` },
{ name: 'Rust', hint: '系统级', code: `fn main() {\n println!("Hello, World!");\n}` },
{ name: 'C', hint: '系统级', code: `#include <stdio.h>\n\nint main() {\n printf("Hello, World!\\n");\n return 0;\n}` },
{ name: 'C++', hint: '系统级', code: `#include <iostream>\n\nint main() {\n std::cout << "Hello, World!" << std::endl;\n return 0;\n}` },
{ name: 'C#', hint: '.NET', code: `using System;\n\nclass Program {\n static void Main() {\n Console.WriteLine("Hello, World!");\n }\n}` },
{ name: 'Java', hint: 'JVM', code: `public class HelloWorld {\n public static void main(String[] args) {\n System.out.println("Hello, World!");\n }\n}` },
{ name: 'Kotlin', hint: 'JVM', code: `fun main() {\n println("Hello, World!")\n}` },
{ name: 'Swift', hint: 'Apple 生态', code: `print("Hello, World!")` },
{ name: 'Ruby', hint: '脚本', code: `puts "Hello, World!"` },
{ name: 'PHP', hint: 'Web', code: `<?php\necho "Hello, World!\\n";` },
{ name: 'Perl', hint: '文本处理', code: `print "Hello, World!\\n";` },
{ name: 'Lua', hint: '嵌入式', code: `print("Hello, World!")` },
{ name: 'R', hint: '统计', code: `cat("Hello, World!\\n")` },
{ name: 'MATLAB', hint: '数学', code: `disp('Hello, World!')` },
{ name: 'Bash', hint: 'Shell', code: `#!/bin/bash\necho "Hello, World!"` },
{ name: 'PowerShell', hint: 'Shell', code: `Write-Host "Hello, World!"` },
{ name: 'SQL', hint: '数据库', code: `SELECT 'Hello, World!' AS greeting;` },
{ name: 'HTML', hint: '标记', code: `<!DOCTYPE html>\n<html>\n<body>\n <h1>Hello, World!</h1>\n</body>\n</html>` },
{ name: 'CSS', hint: '样式', code: `body::before {\n content: "Hello, World!";\n}` },
{ name: 'Scala', hint: 'JVM', code: `object HelloWorld {\n def main(args: Array[String]): Unit = {\n println("Hello, World!")\n }\n}` },
{ name: 'Dart', hint: 'Flutter', code: `void main() {\n print('Hello, World!');\n}` },
{ name: 'Elixir', hint: '函数式', code: `IO.puts("Hello, World!")` },
{ name: 'Haskell', hint: '函数式', code: `main :: IO ()\nmain = putStrLn "Hello, World!"` },
{ name: 'Erlang', hint: '并发', code: `io:format("Hello, World!~n").` },
{ name: 'Clojure', hint: 'Lisp', code: `(println "Hello, World!")` },
{ name: 'Julia', hint: '科学计算', code: `println("Hello, World!")` },
{ name: 'Objective-C', hint: 'Apple 生态', code: `#import <Foundation/Foundation.h>\n\nint main() {\n @autoreleasepool {\n NSLog(@"Hello, World!");\n }\n return 0;\n}` },
{ name: 'Assembly', hint: 'x86', code: `section .data\n msg db 'Hello, World!', 0xa\n\nsection .text\n global _start\n_start:\n mov rax, 1\n mov rdi, 1\n mov rsi, msg\n mov rdx, 13\n syscall\n mov rax, 60\n xor rdi, rdi\n syscall` },
{ name: 'Vim', hint: '编辑器', code: `:echo "Hello, World!"` },
{ name: 'Brainfuck', hint: '极简', code: `++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>.>---.+++++++..+++.>>.<-.<.+++.------.--------.>>+.>++.` },
]
const keyword = ref('')
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return LANGS
return LANGS.filter((l) => l.name.toLowerCase().includes(kw) || l.code.toLowerCase().includes(kw))
})
function copyItem(item: LangItem) {
void copyText(item.code, `已复制 ${item.name} 的 Hello World`)
}
</script>
<style scoped>
.page-wrap {
max-width: 980px;
}
.lang-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.lang-card {
margin: 0;
}
.lang-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.lang-head b {
font-size: 15px;
color: var(--td-text-color-primary);
}
.lang-code {
margin: 0;
padding: 10px 12px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
max-height: 220px;
overflow-y: auto;
color: var(--td-text-color-primary);
}
.lang-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
</style>

View File

@ -0,0 +1,148 @@
<template>
<div class="page-wrap">
<h2 class="page-title">缩进转换器</h2>
<p class="page-desc">把代码缩进在空格与 Tab 之间统一转换2/4/8 空格自由切换</p>
<t-card class="tool-section" title="缩进设置">
<div class="form-row">
<span class="form-label"></span>
<t-select v-model="fromUnit" :options="UNIT_OPTIONS" style="width: 140px" />
<span class="arrow"></span>
<span class="form-label"></span>
<t-select v-model="toUnit" :options="UNIT_OPTIONS" style="width: 140px" />
</div>
<t-textarea v-model="input" :autosize="{ minRows: 8, maxRows: 18 }" placeholder="粘贴代码(行首缩进将被转换)" style="margin-top: 12px" />
<div class="form-actions">
<t-button theme="primary" @click="convert">转换</t-button>
<t-button variant="outline" @click="loadSample">示例</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="result" class="tool-section" title="转换结果">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="result" />
</div>
<pre class="code-block">{{ result }}</pre>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
interface UnitOpt {
label: string
value: string
/** 单个缩进单元的空格数tab 为 null */
size: number | null
}
const UNIT_OPTIONS: UnitOpt[] = [
{ label: 'Tab', value: 'tab', size: null },
{ label: '2 空格', value: '2', size: 2 },
{ label: '4 空格', value: '4', size: 4 },
{ label: '8 空格', value: '8', size: 8 },
]
const history = useHistory('indent-convert')
const fromUnit = ref('4')
const toUnit = ref('tab')
const input = ref('')
const result = ref('')
const error = ref('')
const SAMPLE = `function main() {
const a = 1;
if (a > 0) {
console.log(a);
}
return a;
}`
/** 将行首缩进从 from 单位换算为 to 单位 */
function convert() {
error.value = ''
result.value = ''
if (!input.value.trim()) {
error.value = '请输入代码'
return
}
const from = UNIT_OPTIONS.find((u) => u.value === fromUnit.value)!
const to = UNIT_OPTIONS.find((u) => u.value === toUnit.value)!
const lines = input.value.split('\n')
const out = lines.map((line) => {
const m = line.match(/^[\t ]+/)
if (!m) return line
const indent = m[0]
// tab 4
const spaces = indent.replace(/\t/g, ' ').length
const level = Math.round(spaces / (from.size ?? 4))
const newIndent = to.size === null ? '\t'.repeat(level) : ' '.repeat(to.size * level)
return newIndent + line.slice(indent.length)
})
result.value = out.join('\n')
history.add(input.value.length > 800 ? input.value.slice(0, 800) : input.value)
}
function loadSample() {
input.value = SAMPLE
convert()
}
function onHistorySelect(text: string) {
input.value = text
convert()
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-row {
display: flex;
align-items: center;
gap: 12px;
}
.form-label {
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.arrow {
color: var(--td-text-color-placeholder);
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.code-block {
margin: 12px 0 0;
padding: 12px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,105 @@
<template>
<div class="page-wrap">
<h2 class="page-title">IP 信息查询</h2>
<p class="page-desc">查询 IP 地址的归属地运营商等信息数据由后端接口提供</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端服务尚未接入:在 .env 中配置 VITE_API_BASE 并按 docs/api-contract.md 实现接口后即可使用" />
<t-card class="tool-section" title="查询 IP">
<div class="form-row">
<t-input v-model="ip" placeholder="留空则查询本机 IP" clearable @enter="query" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="query">
{{ loading ? '查询中…' : '查询' }}
</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="data" class="tool-section" title="IP 信息">
<div class="ip-main">{{ data.ip }}</div>
<div class="ip-grid">
<div class="ii"><span>国家/地区</span><b>{{ data.country }}</b></div>
<div class="ii"><span>省份</span><b>{{ data.province || '—' }}</b></div>
<div class="ii"><span>城市</span><b>{{ data.city || '—' }}</b></div>
<div class="ii"><span>运营商</span><b>{{ data.isp || '—' }}</b></div>
</div>
</t-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { api, backendReady } from '@/api/client'
interface IpData {
ip: string
country: string
province: string
city: string
isp: string
updated_at: string
}
const ip = ref('')
const data = ref<IpData | null>(null)
const error = ref('')
const loading = ref(false)
async function query() {
error.value = ''
data.value = null
const ipText = ip.value.trim()
if (ipText && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ipText)) {
error.value = 'IP 格式不正确,请输入 IPv4 地址'
return
}
loading.value = true
const res = await api.get<IpData>('/api/tools/ip-info', ipText ? { ip: ipText } : undefined)
loading.value = false
if (res.ok) {
data.value = res.data
} else {
error.value = res.message
}
}
</script>
<style scoped>
.page-wrap {
max-width: 720px;
}
.form-row {
display: flex;
gap: 8px;
}
.ip-main {
font-family: ui-monospace, Consolas, monospace;
font-size: 26px;
font-weight: 700;
color: var(--td-brand-color);
padding: 8px 0 16px;
}
.ip-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.ii {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
}
.ii span {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.ii b {
font-size: 15px;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,261 @@
<template>
<div class="page-wrap">
<h2 class="page-title">JSON 对比</h2>
<p class="page-desc">逐键对比两个 JSON 的结构差异新增删除与修改的字段一目了然</p>
<t-card class="tool-section">
<div class="diff-inputs">
<div class="diff-col">
<div class="diff-col-title"> JSON</div>
<t-textarea v-model="left" :autosize="{ minRows: 8, maxRows: 16 }" placeholder='{"name":"张三","age":18}' />
</div>
<div class="diff-col">
<div class="diff-col-title"> JSON</div>
<t-textarea v-model="right" :autosize="{ minRows: 8, maxRows: 16 }" placeholder='{"name":"李四","age":18,"vip":true}' />
</div>
</div>
<div class="form-actions">
<t-button theme="primary" @click="run">对比</t-button>
<t-button variant="outline" @click="loadSample">示例</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="changes.length" class="tool-section" title="差异结果">
<div class="diff-stats">
<span class="stat stat-add">+ 新增 {{ stats.add }}</span>
<span class="stat stat-del">- 删除 {{ stats.del }}</span>
<span class="stat stat-chg">~ 修改 {{ stats.chg }}</span>
<CopyButton :text="reportText" label="复制差异报告" />
</div>
<div class="change-list">
<div v-for="(c, i) in changes" :key="i" class="change-item" :class="'c-' + c.type">
<span class="c-mark">{{ mark(c.type) }}</span>
<code class="c-path">{{ c.path }}</code>
<span class="c-detail">{{ detail(c) }}</span>
</div>
</div>
</t-card>
<t-card v-else-if="tried" class="tool-section">
<t-empty description="两个 JSON 完全一致" />
</t-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
type ChangeType = 'added' | 'removed' | 'changed'
interface Change {
type: ChangeType
path: string
old?: unknown
new?: unknown
}
const left = ref('')
const right = ref('')
const error = ref('')
const tried = ref(false)
const changes = ref<Change[]>([])
const stats = computed(() => ({
add: changes.value.filter((c) => c.type === 'added').length,
del: changes.value.filter((c) => c.type === 'removed').length,
chg: changes.value.filter((c) => c.type === 'changed').length,
}))
const SAMPLE_LEFT = `{
"id": 1,
"name": "张三",
"age": 18,
"address": {
"city": "北京",
"zip": "100000"
},
"tags": ["a", "b"]
}`
const SAMPLE_RIGHT = `{
"id": 1,
"name": "李四",
"age": 19,
"vip": true,
"address": {
"city": "上海"
},
"tags": ["a", "c"]
}`
function diffObjects(a: unknown, b: unknown, path: string, out: Change[]) {
//
if (typeof a !== typeof b || a === null || b === null || Array.isArray(a) !== Array.isArray(b)) {
out.push({ type: 'changed', path: path || '(root)', old: a, new: b })
return
}
if (Array.isArray(a) && Array.isArray(b)) {
const n = Math.max(a.length, b.length)
for (let i = 0; i < n; i++) {
const p = `${path}[${i}]`
if (i >= a.length) out.push({ type: 'added', path: p, new: b[i] })
else if (i >= b.length) out.push({ type: 'removed', path: p, old: a[i] })
else diffObjects(a[i], b[i], p, out)
}
return
}
if (typeof a === 'object' && a !== null) {
const aObj = a as Record<string, unknown>
const bObj = b as Record<string, unknown>
const aKeys = Object.keys(aObj)
const bKeys = Object.keys(bObj)
const allKeys = new Set([...aKeys, ...bKeys])
for (const k of allKeys) {
const p = path ? `${path}.${k}` : k
const av = aObj[k]
const bv = bObj[k]
if (!(k in aObj)) out.push({ type: 'added', path: p, new: bv })
else if (!(k in bObj)) out.push({ type: 'removed', path: p, old: av })
else diffObjects(av, bv, p, out)
}
return
}
if (a !== b) out.push({ type: 'changed', path: path || '(root)', old: a, new: b })
}
function run() {
error.value = ''
changes.value = []
tried.value = false
const l = left.value.trim()
const r = right.value.trim()
if (!l || !r) {
error.value = '两侧都需要输入 JSON'
return
}
let lv: unknown
let rv: unknown
try {
lv = JSON.parse(l)
} catch {
error.value = '原 JSON 解析失败,请检查语法'
return
}
try {
rv = JSON.parse(r)
} catch {
error.value = '新 JSON 解析失败,请检查语法'
return
}
diffObjects(lv, rv, '', changes.value)
tried.value = true
}
function mark(t: ChangeType): string {
return t === 'added' ? '+' : t === 'removed' ? '-' : '~'
}
function detail(c: Change): string {
if (c.type === 'added') return `${JSON.stringify(c.new)}`
if (c.type === 'removed') return `${JSON.stringify(c.old)} → 无`
return `${JSON.stringify(c.old)}${JSON.stringify(c.new)}`
}
const reportText = computed(() =>
changes.value
.map((c) => `${mark(c.type)} ${c.path}: ${detail(c)}`)
.join('\n'),
)
function loadSample() {
left.value = SAMPLE_LEFT
right.value = SAMPLE_RIGHT
run()
}
</script>
<style scoped>
.page-wrap {
max-width: 980px;
}
.diff-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.diff-col-title {
font-size: 13px;
color: var(--td-text-color-secondary);
margin-bottom: 6px;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.diff-stats {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.stat {
font-weight: 600;
}
.stat-add {
color: #2a9d4e;
}
.stat-del {
color: #d54941;
}
.stat-chg {
color: #e67e22;
}
.change-list {
display: flex;
flex-direction: column;
gap: 4px;
font-family: ui-monospace, Consolas, monospace;
font-size: 13px;
}
.change-item {
display: flex;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
}
.c-added {
background: rgba(42, 157, 78, 0.12);
}
.c-removed {
background: rgba(213, 73, 65, 0.12);
}
.c-changed {
background: rgba(230, 126, 34, 0.12);
}
.c-mark {
flex-shrink: 0;
font-weight: 700;
}
.c-added .c-mark {
color: #2a9d4e;
}
.c-removed .c-mark {
color: #d54941;
}
.c-changed .c-mark {
color: #e67e22;
}
.c-path {
color: var(--td-text-color-primary);
word-break: break-all;
}
.c-detail {
color: var(--td-text-color-secondary);
word-break: break-all;
}
</style>

View File

@ -0,0 +1,304 @@
<template>
<div class="page-wrap">
<h2 class="page-title">JSON 转代码</h2>
<p class="page-desc">粘贴一段 JSON一键生成 TypeScript 接口Go 结构体或 Python dataclass嵌套对象与数组自动递归生成类型定义</p>
<t-card class="tool-section" title="JSON 数据">
<t-textarea
v-model="input"
:autosize="{ minRows: 8, maxRows: 16 }"
placeholder='{"name":"张三","age":18,"vip":true,"tags":["a","b"],"address":{"city":"北京","zip":"100000"}}'
/>
<div class="form-actions">
<t-button theme="primary" @click="generate">生成代码</t-button>
<t-button variant="outline" @click="loadExample">填入示例</t-button>
<t-radio-group v-model="lang" variant="default-filled">
<t-radio-button value="ts">TypeScript</t-radio-button>
<t-radio-button value="go">Go Struct</t-radio-button>
<t-radio-button value="python">Python</t-radio-button>
</t-radio-group>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="result" class="tool-section" title="生成的代码">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="result" />
</div>
<pre class="code-block">{{ result }}</pre>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
const history = useHistory('json-to-code')
const input = ref('')
const lang = ref<'ts' | 'go' | 'python'>('ts')
const result = ref('')
const error = ref('')
const EXAMPLE = `{
"id": 1001,
"name": "张三",
"email": null,
"vip": true,
"score": 88.5,
"tags": ["开发", "设计"],
"address": {
"city": "北京",
"zip": "100000"
},
"orders": [
{ "id": 1, "amount": 99.9 },
{ "id": 2, "amount": 199 }
]
}`
/* ---------------- 类型推断 ---------------- */
type JType =
| { kind: 'string' | 'boolean' | 'null' | 'any' }
| { kind: 'int' | 'float' }
| { kind: 'array'; elem: JType }
| { kind: 'object'; name: string }
interface FieldInfo {
jsonName: string
type: JType
}
interface TypeDef {
name: string
fields: FieldInfo[]
}
const types: TypeDef[] = []
/** 结构体命名(首字母大写驼峰) */
function pascal(name: string): string {
const parts = name.replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'Root'
return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('')
}
/** 生成唯一类型名:同名且结构相同则复用,否则追加序号 */
function uniqueName(base: string, fields: FieldInfo[]): string {
const existing = types.filter((t) => t.name === base)
if (existing.length === 0) return base
//
const sameStruct = existing.some(
(t) => t.fields.length === fields.length && t.fields.every((f, i) => f.jsonName === fields[i].jsonName),
)
if (sameStruct) return base
let n = 2
while (types.some((t) => t.name === `${base}${n}`)) n++
return `${base}${n}`
}
function inferType(value: unknown, nameHint: string): JType {
if (value === null) return { kind: 'null' }
if (value === undefined) return { kind: 'any' }
if (typeof value === 'string') return { kind: 'string' }
if (typeof value === 'boolean') return { kind: 'boolean' }
if (typeof value === 'number') return { kind: Number.isInteger(value) ? 'int' : 'float' }
if (Array.isArray(value)) {
// null 退 any
const elem = value.find((v) => v !== null)
return { kind: 'array', elem: elem === undefined ? { kind: 'any' } : inferType(elem, nameHint) }
}
if (typeof value === 'object') {
const fields: FieldInfo[] = Object.entries(value as Record<string, unknown>).map(([k, v]) => ({
jsonName: k,
type: inferType(v, k),
}))
const name = uniqueName(pascal(nameHint || 'Root'), fields)
//
types.push({ name, fields })
return { kind: 'object', name }
}
return { kind: 'any' }
}
/* ---------------- 渲染 ---------------- */
const VALID_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
function tsType(t: JType, nullable: boolean): string {
const base =
t.kind === 'string' ? 'string'
: t.kind === 'int' || t.kind === 'float' ? 'number'
: t.kind === 'boolean' ? 'boolean'
: t.kind === 'null' ? 'null'
: t.kind === 'any' ? 'any'
: t.kind === 'array' ? `${tsType(t.elem, false)}[]`
: t.kind === 'object' ? t.name
: 'any'
return nullable ? `${base} | null` : base
}
function goType(t: JType, nullable: boolean): string {
const base =
t.kind === 'string' ? 'string'
: t.kind === 'int' ? 'int64'
: t.kind === 'float' ? 'float64'
: t.kind === 'boolean' ? 'bool'
: t.kind === 'null' || t.kind === 'any' ? 'interface{}'
: t.kind === 'array' ? `[]${goType(t.elem, false)}`
: t.kind === 'object' ? t.name
: 'interface{}'
return nullable && t.kind === 'object' ? `*${base}` : base
}
function pyType(t: JType, nullable: boolean, seenOptional: Set<string>): string {
const base =
t.kind === 'string' ? 'str'
: t.kind === 'int' ? 'int'
: t.kind === 'float' ? 'float'
: t.kind === 'boolean' ? 'bool'
: t.kind === 'null' ? 'Any'
: t.kind === 'any' ? 'Any'
: t.kind === 'array' ? `list[${pyType(t.elem, false, seenOptional)}]`
: t.kind === 'object' ? t.name
: 'Any'
if (nullable && base !== 'Any') {
seenOptional.add(base)
return `Optional[${base}]`
}
return base
}
function renderTs(): string {
const lines: string[] = []
for (const def of types) {
lines.push(`export interface ${def.name} {`)
for (const f of def.fields) {
const key = VALID_IDENT.test(f.jsonName) ? f.jsonName : JSON.stringify(f.jsonName)
lines.push(` ${key}: ${tsType(f.type, f.type.kind === 'null')}`)
}
lines.push('}')
lines.push('')
}
return lines.join('\n').trimEnd()
}
function renderGo(): string {
const lines: string[] = []
for (const def of types) {
lines.push(`type ${def.name} struct {`)
for (const f of def.fields) {
const goName = pascal(f.jsonName) || 'Field'
lines.push(`\t${goName} ${goType(f.type, f.type.kind === 'null')} \`json:"${f.jsonName}"\``)
}
lines.push('}')
lines.push('')
}
return lines.join('\n').trimEnd()
}
function renderPython(): string {
const lines: string[] = []
const seenOptional = new Set<string>()
lines.push('from __future__ import annotations')
lines.push('from typing import Any, Optional')
lines.push('from dataclasses import dataclass')
lines.push('')
for (const def of types) {
lines.push('')
lines.push('@dataclass')
lines.push(`class ${def.name}:`)
if (def.fields.length === 0) {
lines.push(' pass')
continue
}
for (const f of def.fields) {
const key = VALID_IDENT.test(f.jsonName) ? f.jsonName : `"${f.jsonName}"`
lines.push(` ${key}: ${pyType(f.type, f.type.kind === 'null', seenOptional)}`)
}
}
return lines.join('\n').trim()
}
/* ---------------- 页面逻辑 ---------------- */
function generate() {
error.value = ''
result.value = ''
const text = input.value.trim()
if (!text) {
error.value = '请输入 JSON 数据'
return
}
let data: unknown
try {
data = JSON.parse(text)
} catch (e) {
error.value = `JSON 解析失败:${e instanceof Error ? e.message : '语法错误'}`
return
}
types.length = 0
const rootType = inferType(data, 'Root')
if (rootType.kind === 'object') {
result.value = lang.value === 'ts' ? renderTs() : lang.value === 'go' ? renderGo() : renderPython()
} else {
// /
if (lang.value === 'ts') result.value = `export type Root = ${tsType(rootType, false)}`
else if (lang.value === 'go') result.value = `type Root = ${goType(rootType, false)}`
else {
const seen = new Set<string>()
const py = pyType(rootType, false, seen)
result.value = `from typing import Any${seen.size ? ', Optional' : ''}\n\n# 顶层非对象Root 的类型为:\nRoot: ${py} = None # type: ignore[assignment]`
}
}
history.add(text.length > 1000 ? text.slice(0, 1000) : text)
}
function loadExample() {
input.value = EXAMPLE
generate()
}
function onHistorySelect(text: string) {
input.value = text
generate()
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.code-block {
margin: 12px 0 0;
padding: 12px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,209 @@
<template>
<div class="page-wrap">
<h2 class="page-title">Markdown 表格生成器</h2>
<p class="page-desc">粘贴以制表符 / 竖线 / 空格分隔的文本一键生成 Markdown 表格与 HTML 表格代码</p>
<t-card class="tool-section" title="表格数据">
<t-textarea
v-model="input"
:autosize="{ minRows: 6, maxRows: 14 }"
placeholder="每行一条记录,列之间用 Tab、竖线 | 或 2+ 空格分隔&#10;示例:&#10;姓名\t年龄\t城市&#10;张三\t28\t北京&#10;李四\t32\t上海"
/>
<div class="form-row">
<span class="form-label">分隔符</span>
<t-radio-group v-model="sep" variant="default-filled">
<t-radio-button value="auto">自动识别</t-radio-button>
<t-radio-button value="tab">Tab</t-radio-button>
<t-radio-button value="pipe">竖线 |</t-radio-button>
<t-radio-button value="space">2+ 空格</t-radio-button>
</t-radio-group>
<t-checkbox v-model="firstRowHeader">首行作为表头</t-checkbox>
</div>
<div class="form-actions">
<t-button theme="primary" @click="parseInput">解析</t-button>
<t-button variant="outline" @click="loadSample">示例</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="grid.length" class="tool-section" title="表格预览">
<table class="tbl">
<thead v-if="firstRowHeader">
<tr>
<th v-for="(c, i) in grid[0]" :key="i">{{ c }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ri) in bodyRows" :key="ri">
<td v-for="(c, ci) in row" :key="ci">{{ c }}</td>
</tr>
</tbody>
</table>
</t-card>
<t-card v-if="grid.length" class="tool-section" title="Markdown 代码">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="markdownCode" />
</div>
<pre class="code-block">{{ markdownCode }}</pre>
</t-card>
<t-card v-if="grid.length" class="tool-section" title="HTML 代码">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="htmlCode" />
</div>
<pre class="code-block">{{ htmlCode }}</pre>
</t-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import { useHistory } from '@/composables/useHistory'
const history = useHistory('markdown-table')
const input = ref('')
const sep = ref<'auto' | 'tab' | 'pipe' | 'space'>('auto')
const firstRowHeader = ref(true)
const error = ref('')
const grid = ref<string[][]>([])
const SAMPLE = ['姓名\t年龄\t城市', '张三\t28\t北京', '李四\t32\t上海', '王五\t25\t深圳'].join('\n')
function splitRow(line: string): string[] {
if (sep.value === 'tab') return line.split('\t').map((s) => s.trim())
if (sep.value === 'pipe') {
return line
.split('|')
.map((s) => s.trim())
.filter((s, i, arr) => !(i === 0 && s === '') && !(i === arr.length - 1 && s === ''))
}
if (sep.value === 'space') return line.split(/\s{2,}/).map((s) => s.trim())
// Tab线 2+
if (line.includes('\t')) return line.split('\t').map((s) => s.trim())
if (line.includes('|')) {
return line
.split('|')
.map((s) => s.trim())
.filter((s, i, arr) => !(i === 0 && s === '') && !(i === arr.length - 1 && s === ''))
}
return line.split(/\s{2,}/).map((s) => s.trim())
}
function parseInput() {
error.value = ''
grid.value = []
const rows = input.value.split('\n').map((s) => s.replace(/\r$/, '')).filter((s) => s.trim() !== '')
if (rows.length === 0) {
error.value = '请输入表格数据'
return
}
const parsed = rows.map(splitRow)
//
const cols = Math.max(...parsed.map((r) => r.length))
if (cols === 0) {
error.value = '未能识别出任何列,请检查分隔符'
return
}
grid.value = parsed.map((r) => {
const row = [...r]
while (row.length < cols) row.push('')
return row
})
history.add(input.value.length > 800 ? input.value.slice(0, 800) : input.value)
}
const bodyRows = computed(() => (firstRowHeader.value ? grid.value.slice(1) : grid.value))
const markdownCode = computed(() => {
if (!grid.value.length) return ''
const rows = grid.value
const toRow = (cells: string[]) => `| ${cells.map((c) => c.replace(/\|/g, '\\|')).join(' | ')} |`
const lines = [toRow(rows[0])]
if (rows.length > 1) lines.push(`| ${rows[0].map(() => '---').join(' | ')} |`)
for (let i = 1; i < rows.length; i++) lines.push(toRow(rows[i]))
return lines.join('\n')
})
const htmlCode = computed(() => {
if (!grid.value.length) return ''
const rows = grid.value
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const parts: string[] = ['<table>']
parts.push(' <thead>')
parts.push(` <tr>${rows[0].map((c) => `<th>${esc(c)}</th>`).join('')}</tr>`)
parts.push(' </thead>')
if (rows.length > 1) {
parts.push(' <tbody>')
for (let i = 1; i < rows.length; i++) {
parts.push(` <tr>${rows[i].map((c) => `<td>${esc(c)}</td>`).join('')}</tr>`)
}
parts.push(' </tbody>')
}
parts.push('</table>')
return parts.join('\n')
})
function loadSample() {
input.value = SAMPLE
parseInput()
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.form-label {
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.tbl {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.tbl th,
.tbl td {
border: 1px solid var(--td-border-level-2-color);
padding: 8px 12px;
text-align: left;
}
.tbl th {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
font-weight: 600;
}
.code-block {
margin: 12px 0 0;
padding: 12px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,277 @@
<template>
<div class="page-wrap">
<h2 class="page-title">摩斯电码</h2>
<p class="page-desc">英文数字中文与摩斯码互转中文按拼音编码还可以播放滴滴答答的电报声</p>
<t-tabs v-model="tab">
<!-- 编码文本 摩斯 -->
<t-tab-panel value="encode" label="文本 → 摩斯">
<t-card class="tool-section" title="输入文本(支持中文 / 英文 / 数字)">
<t-textarea
v-model="textInput"
:autosize="{ minRows: 4, maxRows: 10 }"
placeholder="例如SOS 或 你好世界"
/>
<div class="form-actions">
<t-button theme="primary" @click="encode">编码</t-button>
<t-button variant="outline" @click="loadSample">示例</t-button>
</div>
</t-card>
<ErrorAlert :message="error1" @close="error1 = ''" />
<t-card v-if="morseOut" class="tool-section" title="摩斯电码">
<div class="morse-display">{{ morseOut }}</div>
<div class="form-actions">
<t-button theme="primary" variant="outline" @click="playMorse">
<template #icon><SoundIcon /></template>
播放电报声
</t-button>
<t-button variant="outline" @click="stopPlay" :disabled="!playing">停止</t-button>
<CopyButton :text="morseOut" />
</div>
</t-card>
</t-tab-panel>
<!-- 解码摩斯 文本 -->
<t-tab-panel value="decode" label="摩斯 → 文本">
<t-card class="tool-section" title="输入摩斯电码">
<t-textarea
v-model="morseInput"
:autosize="{ minRows: 4, maxRows: 10 }"
placeholder="例如:... --- ..."
/>
<div class="form-actions">
<t-button theme="primary" @click="decode">解码</t-button>
</div>
</t-card>
<ErrorAlert :message="error2" @close="error2 = ''" />
<t-card v-if="textOut" class="tool-section" title="解码结果">
<div class="morse-display">{{ textOut }}</div>
<div class="form-actions">
<CopyButton :text="textOut" />
</div>
<p class="note">中文以拼音形式返回摩斯码不含声调与汉字信息英文数字标点可完整还原</p>
</t-card>
</t-tab-panel>
</t-tabs>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { SoundIcon } from 'tdesign-icons-vue-next'
import { pinyin } from 'pinyin-pro'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
const history = useHistory('morse-code')
const tab = ref('encode')
const textInput = ref('')
const morseOut = ref('')
const morseInput = ref('')
const textOut = ref('')
const error1 = ref('')
const error2 = ref('')
/** 标准国际摩斯码表ITU-R M.1677-1 */
const MORSE: Record<string, string> = {
A: '.-', B: '-...', C: '-.-.', D: '-..', E: '.', F: '..-.', G: '--.', H: '....',
I: '..', J: '.---', K: '-.-', L: '.-..', M: '--', N: '-.', O: '---', P: '.--.',
Q: '--.-', R: '.-.', S: '...', T: '-', U: '..-', V: '...-', W: '.--', X: '-..-',
Y: '-.--', Z: '--..',
'0': '-----', '1': '.----', '2': '..---', '3': '...--', '4': '....-', '5': '.....',
'6': '-....', '7': '--...', '8': '---..', '9': '----.',
'.': '.-.-.-', ',': '--..--', '?': '..--..', '!': '-.-.--', "'": '.----.',
'/': '-..-.', '(': '-.--.', ')': '-.--.-', '&': '.-...', ':': '---...',
';': '-.-.-.', '=': '-...-', '+': '.-.-.', '-': '-....-', '_': '..--.-',
'"': '.-..-.', '$': '...-..-', '@': '.--.-.',
}
const REVERSE: Record<string, string> = Object.fromEntries(
Object.entries(MORSE).map(([k, v]) => [v, k]),
)
/* ---------------- 编码 ---------------- */
function encode() {
error1.value = ''
morseOut.value = ''
const text = textInput.value.trim()
if (!text) {
error1.value = '请输入需要编码的文本'
return
}
//
const pinyinArray = pinyin(text, { toneType: 'none', type: 'array' })
const parts: string[] = []
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (ch === ' ' || ch === '\n') {
parts.push('/')
continue
}
if (/[\u4e00-\u9fa5]/.test(ch)) {
//
const py = pinyinArray[i] || ''
const letters = py.toUpperCase()
if (letters) {
const code = letters
.split('')
.map((l) => MORSE[l])
.filter(Boolean)
.join(' ')
if (code) parts.push(code)
}
continue
}
const up = ch.toUpperCase()
const code = MORSE[up]
if (code) parts.push(code)
else parts.push('?') //
}
morseOut.value = parts.join(' / ')
history.add(text.length > 500 ? text.slice(0, 500) : text)
}
/* ---------------- 解码 ---------------- */
function decode() {
error2.value = ''
textOut.value = ''
const code = morseInput.value.trim()
if (!code) {
error2.value = '请输入摩斯电码'
return
}
const words = code.split('/')
const out: string[] = []
for (const word of words) {
const letters = word.trim().split(/\s+/)
let decoded = ''
for (const letter of letters) {
if (!letter) continue
decoded += REVERSE[letter] ?? '?'
}
out.push(decoded)
}
textOut.value = out.join(' ').trim()
history.add(code.length > 500 ? code.slice(0, 500) : code)
}
/* ---------------- 播放电报声 ---------------- */
const playing = ref(false)
let audioCtx: AudioContext | null = null
let stopFlag = false
/** 按摩斯码调度短音/长音/间隔(单元时长 90ms */
function playMorse() {
const code = morseOut.value
if (!code) return
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!Ctor) return
if (!audioCtx) audioCtx = new Ctor()
void audioCtx.resume()
stopFlag = false
playing.value = true
const unit = 0.09
let t = audioCtx.currentTime + 0.15
const beep = (dur: number) => {
if (stopFlag) return
const osc = audioCtx!.createOscillator()
const gain = audioCtx!.createGain()
osc.type = 'sine'
osc.frequency.value = 750
gain.gain.setValueAtTime(0.0001, t)
gain.gain.exponentialRampToValueAtTime(0.5, t + 0.01)
gain.gain.setValueAtTime(0.5, t + dur - 0.01)
gain.gain.exponentialRampToValueAtTime(0.0001, t + dur)
osc.connect(gain).connect(audioCtx!.destination)
osc.start(t)
osc.stop(t + dur + 0.02)
}
for (const ch of code) {
if (stopFlag) break
if (ch === '.') {
beep(unit)
t += unit + unit
} else if (ch === '-') {
beep(unit * 3)
t += unit * 3 + unit
} else if (ch === ' ') {
t += unit * 2
} else if (ch === '/') {
t += unit * 4
}
}
//
window.setTimeout(() => {
if (!stopFlag) playing.value = false
}, (t - audioCtx.currentTime) * 1000 + 200)
}
function stopPlay() {
stopFlag = true
playing.value = false
}
onBeforeUnmount(() => {
stopFlag = true
if (audioCtx) void audioCtx.close()
})
/* ---------------- 其他 ---------------- */
function loadSample() {
textInput.value = '你好世界Hello World!'
encode()
}
function onHistorySelect(text: string) {
if (tab.value === 'encode') {
textInput.value = text
encode()
} else {
morseInput.value = text
decode()
}
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.morse-display {
padding: 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 16px;
line-height: 1.9;
word-break: break-all;
color: var(--td-text-color-primary);
}
.note {
margin: 12px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
</style>

View File

@ -0,0 +1,260 @@
<template>
<div class="page-wrap">
<h2 class="page-title">网络测试</h2>
<p class="page-desc">检测目标站点的可达性与响应耗时HTTP 检测在浏览器本地完成Ping / TCP 端口 / DNS 解析由后端完成</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端未接入HTTP 站点检测仍可直接使用Ping / TCP / DNS 需在 .env 配置 VITE_API_BASE 并按 docs/api-contract.md 实现接口" />
<t-card class="tool-section">
<t-tabs v-model="tab">
<!-- HTTP 检测前端直连 -->
<t-tab-panel value="http" label="HTTP 检测">
<div class="form-row">
<t-input v-model="httpUrl" placeholder="https://example.com" class="url-input" clearable @enter="runHttp" />
<t-button theme="primary" :disabled="httpLoading" @click="runHttp">
{{ httpLoading ? '检测中…' : '开始检测' }}
</t-button>
</div>
<p class="hint">浏览器直连检测测延迟与状态码受浏览器跨域限制不支持跨域读取的站点将提示失败</p>
</t-tab-panel>
<!-- Ping后端 -->
<t-tab-panel value="ping" label="Ping">
<div class="form-row">
<t-input v-model="host" placeholder="主机名,如 baidu.com" class="url-input" clearable @enter="runDiag('ping')" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="runDiag('ping')">
{{ loading ? '测试中…' : 'Ping' }}
</t-button>
</div>
</t-tab-panel>
<!-- TCP 端口后端 -->
<t-tab-panel value="tcp" label="TCP 端口">
<div class="form-row">
<t-input v-model="host" placeholder="主机名或 IP" class="url-input" clearable @enter="runDiag('tcp')" />
<t-input-number v-model="port" :min="1" :max="65535" class="port-input" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="runDiag('tcp')">
{{ loading ? '测试中…' : '连接测试' }}
</t-button>
</div>
</t-tab-panel>
<!-- DNS后端 -->
<t-tab-panel value="dns" label="DNS 解析">
<div class="form-row">
<t-input v-model="host" placeholder="域名,如 baidu.com" class="url-input" clearable @enter="runDiag('dns')" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="runDiag('dns')">
{{ loading ? '解析中…' : '解析' }}
</t-button>
</div>
</t-tab-panel>
</t-tabs>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<!-- HTTP 结果 -->
<t-card v-if="httpResult" class="tool-section" title="HTTP 检测结果">
<div class="res-main">
<div class="res-big" :class="httpResult.ok ? 'ok' : 'bad'">{{ httpResult.ok ? '可达' : '失败' }}</div>
<div class="res-url">{{ httpResult.url }}</div>
</div>
<div class="res-grid">
<div class="ri"><span>状态码</span><b>{{ httpResult.status }}</b></div>
<div class="ri"><span>耗时</span><b>{{ httpResult.ms }} ms</b></div>
<div class="ri"><span>内容大小</span><b>{{ httpResult.size }}</b></div>
<div class="ri"><span>方式</span><b>浏览器直连</b></div>
</div>
</t-card>
<!-- 后端诊断结果 -->
<t-card v-if="diagResult" class="tool-section" title="诊断结果">
<div class="res-main">
<div class="res-big" :class="diagResult.reachable ? 'ok' : 'bad'">
{{ diagResult.reachable ? '可达' : '不可达' }}
</div>
<div class="res-url">{{ diagResult.type.toUpperCase() }} {{ diagResult.host }}{{ diagResult.port ? ':' + diagResult.port : '' }}</div>
</div>
<div class="res-grid">
<div class="ri"><span>延迟</span><b>{{ diagResult.latency_ms }} ms</b></div>
<div v-for="(v, k) in diagResult.detail" :key="k" class="ri">
<span>{{ detailLabel(k) }}</span><b>{{ String(v) }}</b>
</div>
</div>
</t-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { api, backendReady } from '@/api/client'
const tab = ref('http')
const error = ref('')
const httpUrl = ref('https://example.com')
const httpLoading = ref(false)
const httpResult = ref<{ ok: boolean; url: string; status: number | string; ms: number; size: string } | null>(null)
const host = ref('baidu.com')
const port = ref(443)
const loading = ref(false)
const diagResult = ref<{ type: string; host: string; port: number | null; reachable: boolean; latency_ms: number; detail: Record<string, unknown> } | null>(null)
/* ---------------- HTTP 检测(前端直连) ---------------- */
function formatBytes(n: number): string {
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + ' MB'
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'
return n + ' B'
}
async function runHttp() {
error.value = ''
httpResult.value = null
let url = httpUrl.value.trim()
if (!url) {
error.value = '请输入目标 URL'
return
}
if (!/^https?:\/\//i.test(url)) url = 'https://' + url
httpLoading.value = true
const controller = new AbortController()
const timer = window.setTimeout(() => controller.abort(), 10000)
const start = performance.now()
try {
const res = await fetch(url, { mode: 'cors', signal: controller.signal })
const ms = Math.round(performance.now() - start)
const text = await res.text()
httpResult.value = {
ok: res.status >= 200 && res.status < 400,
url: res.url || url,
status: res.status,
ms,
size: formatBytes(text.length),
}
} catch (e) {
const ms = Math.round(performance.now() - start)
httpResult.value = {
ok: false,
url,
status: '—',
ms,
size: '—',
}
error.value =
e instanceof DOMException && e.name === 'AbortError'
? '请求超时10 秒)'
: '无法访问可能是跨域限制CORS、站点不可达或证书问题可尝试 Ping / TCP 模式'
} finally {
window.clearTimeout(timer)
httpLoading.value = false
}
}
/* ---------------- 后端诊断 ---------------- */
type DiagType = 'ping' | 'tcp' | 'dns'
async function runDiag(type: DiagType) {
error.value = ''
diagResult.value = null
const h = host.value.trim()
if (!h) {
error.value = '请输入主机名'
return
}
if (type === 'tcp' && (!Number.isInteger(port.value) || port.value < 1 || port.value > 65535)) {
error.value = '端口需为 1 ~ 65535 的整数'
return
}
loading.value = true
const res = await api.post<{
type: string
host: string
port: number | null
reachable: boolean
latency_ms: number
detail: Record<string, unknown>
}>('/api/tools/network-test', { type, host: h, port: type === 'tcp' ? port.value : null })
loading.value = false
if (res.ok) {
diagResult.value = res.data
} else {
error.value = res.message
}
}
function detailLabel(k: string): string {
const map: Record<string, string> = {
min_ms: '最小延迟', avg_ms: '平均延迟', max_ms: '最大延迟', loss_pct: '丢包率',
tcp_connect_ms: 'TCP 连接', dns_resolve_ms: 'DNS 解析', ip: '解析 IP',
}
return map[k] ?? k
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.url-input {
flex: 1;
min-width: 260px;
}
.port-input {
width: 110px;
}
.hint {
margin: 10px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.res-main {
text-align: center;
padding: 8px 0 16px;
}
.res-big {
font-size: 40px;
font-weight: 700;
}
.res-big.ok {
color: #2a9d4e;
}
.res-big.bad {
color: #d54941;
}
.res-url {
margin-top: 4px;
font-size: 14px;
color: var(--td-text-color-secondary);
word-break: break-all;
}
.res-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.ri {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
}
.ri span {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.ri b {
font-size: 15px;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,111 @@
<template>
<div class="page-wrap">
<h2 class="page-title">手机号归属地</h2>
<p class="page-desc">查询手机号归属地与运营商数据由后端接口提供</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端服务尚未接入:在 .env 中配置 VITE_API_BASE 并按 docs/api-contract.md 实现接口后即可使用" />
<t-card class="tool-section" title="查询号码">
<div class="form-row">
<t-input v-model="phone" placeholder="输入 11 位手机号" maxlength="11" clearable @enter="query" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="query">
{{ loading ? '查询中…' : '查询' }}
</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="data" class="tool-section" title="归属地信息">
<div class="phone-main">
<span class="phone-no">{{ data.phone }}</span>
<t-tag theme="primary" variant="light">{{ data.carrier }}</t-tag>
</div>
<div class="phone-grid">
<div class="pi"><span>归属地</span><b>{{ data.area }}</b></div>
<div class="pi"><span>运营商</span><b>{{ data.carrier }}</b></div>
<div class="pi"><span>邮编</span><b>{{ data.postcode || '—' }}</b></div>
</div>
</t-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { api, backendReady } from '@/api/client'
interface PhoneData {
phone: string
area: string
carrier: string
postcode: string
updated_at: string
}
const phone = ref('')
const data = ref<PhoneData | null>(null)
const error = ref('')
const loading = ref(false)
async function query() {
error.value = ''
data.value = null
const p = phone.value.trim()
if (!/^1\d{10}$/.test(p)) {
error.value = '请输入 11 位有效手机号'
return
}
loading.value = true
const res = await api.get<PhoneData>('/api/tools/phone-locate', { phone: p })
loading.value = false
if (res.ok) {
data.value = res.data
} else {
error.value = res.message
}
}
</script>
<style scoped>
.page-wrap {
max-width: 720px;
}
.form-row {
display: flex;
gap: 8px;
}
.phone-main {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0 16px;
}
.phone-no {
font-family: ui-monospace, Consolas, monospace;
font-size: 28px;
font-weight: 700;
color: var(--td-text-color-primary);
}
.phone-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.pi {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
}
.pi span {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.pi b {
font-size: 15px;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,255 @@
<template>
<div class="page-wrap">
<h2 class="page-title">占位图生成</h2>
<p class="page-desc">前端开发常用的占位图Placeholder自定义尺寸与配色下载 PNG 或复制 SVG 代码纯本地生成</p>
<t-card class="tool-section">
<div class="preview-wrap">
<canvas ref="canvasRef" class="preview-canvas" />
</div>
<div class="grid-form">
<div class="form-item">
<span class="form-label">宽度</span>
<t-input-number v-model="width" :min="1" :max="2000" :step="10" class="num-input" />
</div>
<div class="form-item">
<span class="form-label">高度</span>
<t-input-number v-model="height" :min="1" :max="2000" :step="10" class="num-input" />
</div>
<div class="form-item">
<span class="form-label">背景色</span>
<input type="color" v-model="bgColor" class="color-input" />
<span class="hex-text">{{ bgColor.toUpperCase() }}</span>
</div>
<div class="form-item">
<span class="form-label">文字色</span>
<input type="color" v-model="textColor" class="color-input" />
<span class="hex-text">{{ textColor.toUpperCase() }}</span>
</div>
<div class="form-item full">
<span class="form-label">文字</span>
<t-input v-model="text" placeholder="显示在图片中央的文字(可换行)" class="text-input" />
</div>
<div class="form-item full">
<span class="form-label">字号</span>
<t-slider v-model="fontRatio" :min="5" :max="30" :step="1" class="slider" />
<span class="hex-text">{{ fontRatio }}%</span>
</div>
</div>
<div class="form-actions">
<t-button theme="primary" @click="downloadPng">下载 PNG</t-button>
<CopyButton :text="svgCode" label="复制 SVG 代码" />
</div>
</t-card>
<t-card class="tool-section" title="SVG 代码">
<pre class="code-block">{{ svgCode }}</pre>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
import { downloadDataUrl } from '@/composables/useDownload'
interface PlaceholderParams {
width: number
height: number
bgColor: string
textColor: string
text: string
fontRatio: number
}
const history = useHistory('placeholder-image')
const canvasRef = ref<HTMLCanvasElement | null>(null)
const width = ref(400)
const height = ref(300)
const bgColor = ref('#667eea')
const textColor = ref('#ffffff')
const text = ref('400 x 300')
const fontRatio = ref(12)
const W = () => Math.min(2000, Math.max(1, Math.round(width.value)))
const H = () => Math.min(2000, Math.max(1, Math.round(height.value)))
const FONT_RATIO = () => Math.min(30, Math.max(5, Math.round(fontRatio.value))) / 100
function draw() {
const canvas = canvasRef.value
if (!canvas) return
const w = W()
const h = H()
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.fillStyle = bgColor.value
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = textColor.value
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const fontSize = Math.round(Math.min(w, h) * FONT_RATIO())
ctx.font = `${fontSize}px sans-serif`
const lines = text.value.split('\n')
lines.forEach((line, i) => {
const y = h / 2 + (i - (lines.length - 1) / 2) * fontSize * 1.25
ctx.fillText(line, w / 2, y)
})
}
/** 转义 SVG 文本 */
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
const svgCode = computed(() => {
const w = W()
const h = H()
const fontSize = Math.round(Math.min(w, h) * FONT_RATIO())
const lines = text.value.split('\n')
const tspans = lines
.map(
(line, i) =>
` <tspan x="50%" dy="${i === 0 ? 0 : fontSize * 1.25}">${escapeXml(line) || ' '}</tspan>`,
)
.join('\n')
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`,
` <rect width="100%" height="100%" fill="${bgColor.value}"/>`,
` <text x="50%" y="50%" fill="${textColor.value}" font-size="${fontSize}" text-anchor="middle" dominant-baseline="central" font-family="sans-serif">`,
tspans,
` </text>`,
`</svg>`,
].join('\n')
})
function downloadPng() {
const canvas = canvasRef.value
if (!canvas) return
downloadDataUrl(canvas.toDataURL('image/png'), `placeholder-${W()}x${H()}.png`)
saveHistory()
}
function saveHistory() {
const p: PlaceholderParams = {
width: W(),
height: H(),
bgColor: bgColor.value,
textColor: textColor.value,
text: text.value,
fontRatio: fontRatio.value,
}
history.add(JSON.stringify(p))
}
function onHistorySelect(t: string) {
try {
const p = JSON.parse(t) as PlaceholderParams
if (typeof p.width === 'number') width.value = p.width
if (typeof p.height === 'number') height.value = p.height
if (typeof p.bgColor === 'string') bgColor.value = p.bgColor
if (typeof p.textColor === 'string') textColor.value = p.textColor
if (typeof p.text === 'string') text.value = p.text
if (typeof p.fontRatio === 'number') fontRatio.value = p.fontRatio
draw()
} catch {
//
}
}
watch([width, height, bgColor, textColor, text, fontRatio], draw, { immediate: true })
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.preview-wrap {
display: flex;
justify-content: center;
padding: 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
background: var(--td-bg-color-container);
margin-bottom: 16px;
}
.preview-canvas {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.grid-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px 16px;
}
.form-item {
display: flex;
align-items: center;
gap: 8px;
}
.form-item.full {
grid-column: 1 / -1;
}
.form-label {
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.num-input {
width: 110px;
}
.color-input {
width: 36px;
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
}
.hex-text {
font-family: ui-monospace, Consolas, monospace;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.text-input {
flex: 1;
}
.slider {
flex: 1;
min-width: 120px;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.code-block {
margin: 0;
padding: 12px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,368 @@
<template>
<div class="page-wrap">
<h2 class="page-title">正则可视化</h2>
<p class="page-desc">把正则表达式解析成结构树一眼看懂每个字符的含义附匹配测试</p>
<t-card class="tool-section" title="正则表达式">
<div class="form-row">
<t-input v-model="pattern" placeholder="如:^https?://(www\.)?[\w-]+\.(com|cn)(/\S*)?$" class="pattern-input" @change="parse" />
<t-select v-model="flags" multiple :options="FLAG_OPTIONS" class="flag-select" />
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="nodes.length" class="tool-section" title="结构解析">
<div class="tree">
<div v-for="(n, i) in flat" :key="i" class="tree-node" :style="{ paddingLeft: n.depth * 22 + 'px' }">
<span class="tag" :class="'tag-' + n.type">{{ typeLabel(n.type) }}</span>
<code class="txt">{{ n.text || '(空)' }}</code>
<span class="desc">{{ n.explain }}</span>
</div>
</div>
</t-card>
<t-card class="tool-section" title="匹配测试">
<t-textarea v-model="testText" :autosize="{ minRows: 3, maxRows: 8 }" placeholder="输入测试文本" />
<div v-if="matches.length" class="match-list">
<div v-for="(m, i) in matches" :key="i" class="match-item">
<span class="m-index">#{{ i + 1 }}</span>
<code class="m-text">{{ m }}</code>
</div>
</div>
<p v-else-if="pattern && testText" class="no-match">没有匹配结果</p>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
const history = useHistory('regex-visual')
const pattern = ref('')
const testText = ref('')
const error = ref('')
const flags = ref<string[]>(['g'])
const FLAG_OPTIONS = [
{ label: 'g 全局', value: 'g' },
{ label: 'i 忽略大小写', value: 'i' },
{ label: 'm 多行', value: 'm' },
{ label: 's 点号匹配换行', value: 's' },
{ label: 'u Unicode', value: 'u' },
]
/* ---------------- 正则解析器 ---------------- */
type NodeType =
| 'group' | 'class' | 'quantifier' | 'anchor' | 'escape' | 'literal' | 'any' | 'alternation' | 'flags'
interface RegexNode {
type: NodeType
text: string
explain: string
children?: RegexNode[]
}
interface FlatNode extends RegexNode {
depth: number
}
const NODE_EXPLAIN: Record<string, string> = {
group: '分组',
class: '字符类',
quantifier: '量词',
anchor: '锚点',
escape: '转义',
literal: '字面量',
any: '任意字符',
alternation: '或',
flags: '修饰符',
}
function typeLabel(t: NodeType): string {
return NODE_EXPLAIN[t] ?? t
}
function parseRegex(pattern: string): RegexNode[] {
let pos = 0
const len = pattern.length
const root: RegexNode[] = []
function parseUntilClose(): RegexNode[] {
const nodes: RegexNode[] = []
while (pos < len) {
const c = pattern[pos]
if (c === ')') {
pos++
break
}
let node: RegexNode | null = null
if (c === '(') {
const groupStart = pos
const rest = pattern.slice(pos, pos + 4)
let explain = '捕获分组'
let advance = 1
if (rest.startsWith('(?:')) {
explain = '非捕获分组'
advance = 3
} else if (rest.startsWith('(?=')) {
explain = '正向先行断言'
advance = 3
} else if (rest.startsWith('(?!')) {
explain = '负向先行断言'
advance = 3
} else if (rest.startsWith('(?<=')) {
explain = '正向后行断言'
advance = 4
} else if (rest.startsWith('(?<!')) {
explain = '负向后行断言'
advance = 4
} else if (rest.startsWith('(?<')) {
explain = '具名分组'
advance = 1
}
pos += advance
const children = parseUntilClose()
node = {
type: 'group',
text: pattern.slice(groupStart, pos),
explain,
children,
}
} else if (c === '[') {
const end = pattern.indexOf(']', pos + 1)
const close = end === -1 ? len : end + 1
const body = pattern.slice(pos, close)
node = {
type: 'class',
text: body,
explain: body.startsWith('[^') ? '否定字符类' : '字符类(匹配其中任一字符)',
}
pos = close
} else if (c === '*' || c === '+' || c === '?') {
node = {
type: 'quantifier',
text: c,
explain: c === '*' ? '重复 0 次或多次' : c === '+' ? '重复 1 次或多次' : '重复 0 次或 1 次',
}
pos++
} else if (c === '{') {
const m = pattern.slice(pos).match(/^\{(\d+)(?:,(\d*))?\}/)
if (m) {
const min = m[1]
const max = m[2] === undefined ? min : m[2] === '' ? '∞' : m[2]
node = { type: 'quantifier', text: m[0], explain: `重复 ${min}${max}` }
pos += m[0].length
} else {
node = { type: 'literal', text: c, explain: '字面量 {(非量词)' }
pos++
}
} else if (c === '^' || c === '$') {
node = { type: 'anchor', text: c, explain: c === '^' ? '匹配输入开头' : '匹配输入结尾' }
pos++
} else if (c === '\\') {
const esc = pattern.slice(pos, pos + 2)
const what = esc[1] ?? ''
const map: Record<string, string> = {
d: '数字字符 [0-9]', D: '非数字字符', w: '单词字符 [A-Za-z0-9_]', W: '非单词字符',
s: '空白字符(空格/制表/换行)', S: '非空白字符', b: '单词边界', B: '非单词边界',
n: '换行符', t: '制表符', r: '回车符', '0': '空字符',
}
node = {
type: 'escape',
text: esc,
explain: map[what] ?? `转义字符 ${what === '\\' ? '反斜杠' : `${what}`}`,
}
pos += 2
} else if (c === '.') {
node = { type: 'any', text: '.', explain: flags.value.includes('s') ? '任意字符(含换行)' : '任意字符(不含换行)' }
pos++
} else if (c === '|') {
node = { type: 'alternation', text: '|', explain: '或(两边表达式二选一)' }
pos++
} else {
node = { type: 'literal', text: c, explain: `字面量「${c}` }
pos++
}
nodes.push(node)
}
return nodes
}
try {
const nodes = parseUntilClose()
if (nodes.length === 0 && len === 0) return []
return nodes
} catch {
return []
}
}
function flatten(nodes: RegexNode[], depth: number, out: FlatNode[] = []): FlatNode[] {
for (const n of nodes) {
out.push({ ...n, depth })
if (n.children?.length) flatten(n.children, depth + 1, out)
}
return out
}
const nodes = ref<RegexNode[]>([])
const flat = computed<FlatNode[]>(() => flatten(nodes.value, 0))
function parse() {
error.value = ''
nodes.value = []
const p = pattern.value.trim()
if (!p) return
try {
new RegExp(p, flags.value.join(''))
} catch (e) {
error.value = `正则语法错误:${e instanceof Error ? e.message : '请检查表达式'}`
return
}
nodes.value = parseRegex(p)
history.add(p)
}
const matches = computed<string[]>(() => {
if (!pattern.value || !testText.value) return []
try {
const re = new RegExp(pattern.value, flags.value.join(''))
const out: string[] = []
let m: RegExpExecArray | null
let guard = 0
while ((m = re.exec(testText.value)) !== null && guard < 200) {
out.push(m[0])
guard++
if (!re.global) break
if (m.index === re.lastIndex) re.lastIndex++
}
return out
} catch {
return []
}
})
function onHistorySelect(text: string) {
pattern.value = text
parse()
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pattern-input {
flex: 1;
min-width: 260px;
font-family: ui-monospace, Consolas, monospace;
}
.flag-select {
width: 200px;
}
.tree {
display: flex;
flex-direction: column;
gap: 4px;
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
}
.tree-node:hover {
background: var(--td-bg-color-container-hover);
}
.tag {
flex-shrink: 0;
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
color: #fff;
min-width: 40px;
text-align: center;
}
.tag-group {
background: #667eea;
}
.tag-class {
background: #11998e;
}
.tag-quantifier {
background: #f76b1c;
}
.tag-anchor {
background: #8e44ad;
}
.tag-escape {
background: #c0392b;
}
.tag-literal {
background: #7f8c8d;
}
.tag-any {
background: #2c3e50;
}
.tag-alternation {
background: #e67e22;
}
.txt {
font-family: ui-monospace, Consolas, monospace;
font-size: 13px;
color: var(--td-text-color-primary);
word-break: break-all;
}
.desc {
color: var(--td-text-color-secondary);
font-size: 12px;
}
.match-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.match-item {
display: flex;
gap: 10px;
padding: 6px 10px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
}
.m-index {
color: var(--td-text-color-placeholder);
font-size: 12px;
}
.m-text {
font-family: ui-monospace, Consolas, monospace;
font-size: 13px;
color: var(--td-brand-color);
word-break: break-all;
}
.no-match {
margin: 12px 0 0;
font-size: 13px;
color: var(--td-text-color-placeholder);
}
</style>

View File

@ -0,0 +1,401 @@
<template>
<div class="page-wrap">
<h2 class="page-title">网络测速</h2>
<p class="page-desc">测试宽带下载 / 上传速度与网络延迟后端模式最精确未接入时下载可用公网测速源</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端未接入:下载测速将使用公网源(实验性,依赖其 CORS上传测速需配置 VITE_API_BASE 并按契约实现接口" />
<t-card class="tool-section">
<t-tabs v-model="tab">
<!-- 下载测速 -->
<t-tab-panel value="download" label="下载测速">
<div class="opt-row">
<span class="form-label">测速源</span>
<t-radio-group v-model="dlSource" variant="default-filled">
<t-radio-button value="backend">后端测速源</t-radio-button>
<t-radio-button value="public">公网源实验</t-radio-button>
<t-radio-button value="custom">自定义 URL</t-radio-button>
</t-radio-group>
</div>
<div v-if="dlSource === 'custom'" class="opt-row">
<t-input v-model="customUrl" placeholder="直接下载的测速文件 URL需允许跨域" clearable />
</div>
<div v-else class="opt-row">
<span class="form-label">大小</span>
<t-radio-group v-model="dlSize" variant="default-filled">
<t-radio-button value="5">5 MB</t-radio-button>
<t-radio-button value="10">10 MB</t-radio-button>
<t-radio-button value="25">25 MB</t-radio-button>
<t-radio-button value="50">50 MB</t-radio-button>
</t-radio-group>
</div>
<div class="opt-row">
<t-button theme="primary" :disabled="running" @click="runDownload">开始测速</t-button>
<t-button variant="outline" :disabled="!running" @click="cancel">取消</t-button>
</div>
<div v-if="dlProgress.active" class="progress-box">
<t-progress :percentage="dlProgress.pct" theme="plump" />
<div class="live-speed">
已下载 {{ dlProgress.doneMb }} MB / {{ dlProgress.totalMb }} MB · 当前 {{ dlProgress.speed }} Mbps
</div>
</div>
<div v-if="dlResult" class="result-box">
<div class="speed-main">
<span class="speed-num">{{ dlResult.mbps }}</span>
<span class="speed-unit">Mbps</span>
<t-tag :theme="dlResult.tag.theme" variant="light">{{ dlResult.tag.text }}</t-tag>
</div>
<div class="speed-detail">
<span>数据量 {{ dlResult.bytes }} MB</span>
<span>用时 {{ dlResult.seconds }} s</span>
<span>平均 {{ dlResult.avg }} Mbps</span>
<span>峰值 {{ dlResult.peak }} Mbps</span>
</div>
</div>
</t-tab-panel>
<!-- 上传测速 -->
<t-tab-panel value="upload" label="上传测速">
<div class="opt-row">
<span class="form-label">大小</span>
<t-radio-group v-model="upSize" variant="default-filled">
<t-radio-button value="1">1 MB</t-radio-button>
<t-radio-button value="5">5 MB</t-radio-button>
<t-radio-button value="10">10 MB</t-radio-button>
</t-radio-group>
</div>
<div class="opt-row">
<t-button theme="primary" :disabled="running || !backendReady" @click="runUpload">开始测速</t-button>
<t-button variant="outline" :disabled="!running" @click="cancel">取消</t-button>
</div>
<div v-if="upProgress.active" class="progress-box">
<t-progress :percentage="upProgress.pct" theme="plump" status="success" />
<div class="live-speed">上传中 {{ upProgress.doneMb }} / {{ upProgress.totalMb }} MB</div>
</div>
<div v-if="upResult" class="result-box">
<div class="speed-main">
<span class="speed-num">{{ upResult.mbps }}</span>
<span class="speed-unit">Mbps</span>
<t-tag :theme="upResult.tag.theme" variant="light">{{ upResult.tag.text }}</t-tag>
</div>
<div class="speed-detail">
<span>数据量 {{ upResult.bytes }} MB</span>
<span>用时 {{ upResult.seconds }} s</span>
<span>方式 {{ upResult.mode }}</span>
</div>
</div>
</t-tab-panel>
<!-- 延迟测试 -->
<t-tab-panel value="latency" label="延迟测试">
<div class="opt-row">
<span class="form-label">目标</span>
<t-input v-model="latencyUrl" placeholder="https://example.com留空使用测速源" clearable />
</div>
<div class="opt-row">
<span class="form-label">次数</span>
<t-radio-group v-model="latencyCount" variant="default-filled">
<t-radio-button :value="5">5 </t-radio-button>
<t-radio-button :value="10">10 </t-radio-button>
</t-radio-group>
</div>
<div class="opt-row">
<t-button theme="primary" :disabled="running" @click="runLatency">开始测试</t-button>
</div>
<div v-if="latencyResult" class="result-box">
<div class="speed-main">
<span class="speed-num">{{ latencyResult.avg }}</span>
<span class="speed-unit">ms</span>
<t-tag :theme="latencyResult.tag.theme" variant="light">{{ latencyResult.tag.text }}</t-tag>
</div>
<div class="speed-detail">
<span>最小 {{ latencyResult.min }} ms</span>
<span>最大 {{ latencyResult.max }} ms</span>
<span>抖动 {{ latencyResult.jitter }} ms</span>
</div>
</div>
</t-tab-panel>
</t-tabs>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { backendReady, BASE_URL } from '@/api/client'
const tab = ref('download')
const error = ref('')
const running = ref(false)
/* ---------------- 下载测速 ---------------- */
const dlSource = ref<'backend' | 'public' | 'custom'>('backend')
const dlSize = ref('10')
const customUrl = ref('')
const dlProgress = ref({ active: false, pct: 0, doneMb: 0, totalMb: 0, speed: '0' })
const dlResult = ref<{ mbps: string; bytes: string; seconds: string; avg: string; peak: string; tag: { theme: 'success' | 'warning' | 'danger' | 'primary'; text: string } } | null>(null)
/** 公网测速源(实验性:依赖其 CORS 允许跨域) */
const PUBLIC_SOURCE = 'https://speed.cloudflare.com/__down?bytes='
function dlUrl(bytes: number): string {
if (dlSource.value === 'custom') return customUrl.value.trim()
if (dlSource.value === 'public') return PUBLIC_SOURCE + bytes
return `${BASE_URL}/api/tools/speedtest/download?size=${bytes}`
}
function speedTag(mbps: number) {
if (mbps >= 100) return { theme: 'success' as const, text: '极速' }
if (mbps >= 50) return { theme: 'success' as const, text: '优秀' }
if (mbps >= 20) return { theme: 'primary' as const, text: '良好' }
if (mbps >= 10) return { theme: 'warning' as const, text: '一般' }
return { theme: 'danger' as const, text: '较慢' }
}
async function runDownload() {
error.value = ''
dlResult.value = null
const bytes = Number(dlSize.value) * 1024 * 1024
const url = dlUrl(bytes)
if (!url) {
error.value = '请填写测速文件 URL'
return
}
if (dlSource.value === 'backend' && !backendReady) {
error.value = '后端未接入,请选择公网源或自定义 URL'
return
}
running.value = true
dlProgress.value = { active: true, pct: 0, doneMb: 0, totalMb: dlSource.value === 'custom' ? 0 : Number(dlSize.value), speed: '0' }
const controller = new AbortController()
const timer = window.setTimeout(() => controller.abort(), 60000)
const start = performance.now()
let loaded = 0
let peak = 0
try {
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const total = Number(res.headers.get('content-length')) || bytes
const reader = res.body?.getReader()
if (!reader) throw new Error('无法读取响应流')
const t0 = performance.now()
for (;;) {
const { done, value } = await reader.read()
if (done) break
loaded += value?.length ?? 0
const elapsed = (performance.now() - t0) / 1000
const speed = elapsed > 0 ? (loaded * 8) / 1e6 / elapsed : 0
if (speed > peak) peak = speed
dlProgress.value.pct = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0
dlProgress.value.doneMb = +(loaded / 1024 / 1024).toFixed(2)
dlProgress.value.totalMb = +(total / 1024 / 1024).toFixed(2)
dlProgress.value.speed = speed.toFixed(1)
}
const seconds = (performance.now() - t0) / 1000
const avg = (loaded * 8) / 1e6 / seconds
const mbps = avg.toFixed(1)
dlResult.value = {
mbps,
bytes: +(loaded / 1024 / 1024).toFixed(2) + '',
seconds: seconds.toFixed(2),
avg: avg.toFixed(1),
peak: peak.toFixed(1),
tag: speedTag(avg),
}
} catch (e) {
error.value =
e instanceof DOMException && e.name === 'AbortError'
? '测速超时60 秒),请尝试更小的文件'
: `测速失败:${e instanceof Error ? e.message : '未知错误'}(可能被跨域拦截,可换公网源或后端模式)`
} finally {
window.clearTimeout(timer)
running.value = false
dlProgress.value.active = false
}
}
/* ---------------- 上传测速 ---------------- */
const upSize = ref('5')
const upProgress = ref({ active: false, pct: 0, doneMb: 0, totalMb: 0 })
const upResult = ref<{ mbps: string; bytes: string; seconds: string; mode: string; tag: { theme: 'success' | 'warning' | 'danger' | 'primary'; text: string } } | null>(null)
async function runUpload() {
error.value = ''
upResult.value = null
if (!backendReady) {
error.value = '上传测速需要后端支持,请先配置 VITE_API_BASE'
return
}
const bytes = Number(upSize.value) * 1024 * 1024
running.value = true
upProgress.value = { active: true, pct: 0, doneMb: 0, totalMb: Number(upSize.value) }
const controller = new AbortController()
try {
//
const blob = new Blob([new Uint8Array(bytes)], { type: 'application/octet-stream' })
const start = performance.now()
const res = await fetch(`${BASE_URL}/api/tools/speedtest/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: blob,
signal: controller.signal,
})
upProgress.value.pct = 100
upProgress.value.doneMb = Number(upSize.value)
const seconds = (performance.now() - start) / 1000
const avg = (bytes * 8) / 1e6 / seconds
let mode = '前端计时'
let finalSeconds = seconds
let finalBytes = bytes
if (res.ok) {
try {
const json = (await res.json()) as { code?: number; data?: { bytes?: number; duration_ms?: number; speed_mbps?: number } }
if (json.code === 0 && json.data?.duration_ms) {
mode = '后端计时'
finalSeconds = json.data.duration_ms / 1000
finalBytes = json.data.bytes ?? bytes
}
} catch {
// JSON
}
}
const mbps = (finalBytes * 8) / 1e6 / finalSeconds
upResult.value = {
mbps: mbps.toFixed(1),
bytes: +(finalBytes / 1024 / 1024).toFixed(2) + '',
seconds: finalSeconds.toFixed(2),
mode,
tag: speedTag(mbps),
}
} catch (e) {
error.value = `上传失败:${e instanceof Error ? e.message : '未知错误'}`
} finally {
running.value = false
upProgress.value.active = false
}
}
/* ---------------- 延迟测试 ---------------- */
const latencyUrl = ref('')
const latencyCount = ref(5)
const latencyResult = ref<{ avg: number; min: number; max: number; jitter: number; tag: { theme: 'success' | 'warning' | 'danger' | 'primary'; text: string } } | null>(null)
async function runLatency() {
error.value = ''
latencyResult.value = null
const url = latencyUrl.value.trim() || (backendReady ? `${BASE_URL}/api/tools/speedtest/download?size=1` : PUBLIC_SOURCE + 1024 * 1024)
if (!url) {
error.value = '请输入目标 URL'
return
}
running.value = true
const rtts: number[] = []
const n = latencyCount.value
for (let i = 0; i < n; i++) {
const controller = new AbortController()
const timer = window.setTimeout(() => controller.abort(), 5000)
const t0 = performance.now()
try {
await fetch(url, { method: 'GET', cache: 'no-store', signal: controller.signal })
rtts.push(Math.round(performance.now() - t0))
} catch {
rtts.push(-1) // -1
} finally {
window.clearTimeout(timer)
}
}
const valid = rtts.filter((v) => v >= 0)
if (!valid.length) {
error.value = '所有请求均失败,请检查网络或目标'
running.value = false
return
}
const avg = Math.round(valid.reduce((a, b) => a + b, 0) / valid.length)
const min = Math.min(...valid)
const max = Math.max(...valid)
const jitter = Math.round(valid.reduce((a, b) => a + Math.abs(b - avg), 0) / valid.length)
const tag =
avg <= 50 ? { theme: 'success' as const, text: '极佳' }
: avg <= 100 ? { theme: 'success' as const, text: '优秀' }
: avg <= 200 ? { theme: 'primary' as const, text: '良好' }
: avg <= 400 ? { theme: 'warning' as const, text: '一般' }
: { theme: 'danger' as const, text: '较差' }
latencyResult.value = { avg, min, max, jitter, tag }
running.value = false
}
/* ---------------- 取消 ---------------- */
function cancel() {
// fetch
error.value = '已请求取消,当前请求将超时后中止'
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.opt-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.form-label {
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.progress-box {
margin-top: 16px;
padding: 14px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
background: var(--td-bg-color-container);
}
.live-speed {
margin-top: 8px;
font-size: 13px;
color: var(--td-text-color-secondary);
}
.result-box {
margin-top: 16px;
padding: 20px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 10px;
background:
radial-gradient(circle at 30% 20%, var(--td-brand-color-light), transparent 60%),
var(--td-bg-color-container);
}
.speed-main {
display: flex;
align-items: baseline;
gap: 10px;
}
.speed-num {
font-size: 48px;
font-weight: 700;
color: var(--td-brand-color);
line-height: 1.1;
}
.speed-unit {
font-size: 16px;
color: var(--td-text-color-secondary);
}
.speed-detail {
margin-top: 12px;
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 13px;
color: var(--td-text-color-secondary);
}
</style>

View File

@ -0,0 +1,355 @@
<template>
<div class="page-wrap">
<h2 class="page-title">SQL 格式化</h2>
<p class="page-desc">把挤成一团的 SQL 美化排版顶层子句换行WHERE 条件缩进SELECT 列表逐列对齐全程本地处理</p>
<t-card class="tool-section" title="SQL 语句">
<t-textarea
v-model="input"
:autosize="{ minRows: 8, maxRows: 18 }"
placeholder="粘贴 SQL 语句例如select id,name,age from users where age>18 and city='北京' order by age desc"
/>
<div class="form-actions">
<t-button theme="primary" @click="formatSql">格式化</t-button>
<t-button variant="outline" @click="loadExample">填入示例</t-button>
<t-radio-group v-model="caseMode" variant="default-filled">
<t-radio-button value="upper">关键词大写</t-radio-button>
<t-radio-button value="preserve">保持原样</t-radio-button>
<t-radio-button value="lower">关键词小写</t-radio-button>
</t-radio-group>
<t-select v-model="indentMode" :options="indentOptions" style="width: 120px" />
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="result" class="tool-section" title="格式化结果">
<div class="form-actions" style="margin-top: 0">
<CopyButton :text="result" />
</div>
<pre class="code-block">{{ result }}</pre>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
const history = useHistory('sql-format')
const input = ref('')
const result = ref('')
const error = ref('')
const caseMode = ref<'upper' | 'preserve' | 'lower'>('upper')
const indentMode = ref('4')
const indentOptions = [
{ label: '缩进2 空格', value: '2' },
{ label: '缩进4 空格', value: '4' },
{ label: '缩进Tab', value: 'tab' },
]
const EXAMPLE = `SELECT u.id,u.name,COUNT(o.id) AS order_count,SUM(o.amount) AS total
FROM users u LEFT JOIN orders o ON u.id=o.user_id
WHERE u.age>=18 AND u.city IN ('北京','上海') OR u.vip=1
GROUP BY u.id,u.name HAVING COUNT(o.id)>5
ORDER BY total DESC LIMIT 10 OFFSET 20;`
/* ---------------- 词法分析 ---------------- */
type Tok = { type: 'word' | 'str' | 'comment' | 'punct'; raw: string }
/** 简单分词:识别字符串、注释、数字与标识符/关键词,其余按单字符标点处理 */
function tokenize(sql: string): Tok[] {
const toks: Tok[] = []
let i = 0
const n = sql.length
while (i < n) {
const c = sql[i]
if (/\s/.test(c)) {
i++
continue
}
//
if (c === '-' && sql[i + 1] === '-') {
let j = sql.indexOf('\n', i)
if (j === -1) j = n
toks.push({ type: 'comment', raw: sql.slice(i, j) })
i = j
continue
}
//
if (c === '/' && sql[i + 1] === '*') {
const j = sql.indexOf('*/', i + 2)
const end = j === -1 ? n : j + 2
toks.push({ type: 'comment', raw: sql.slice(i, end) })
i = end
continue
}
// / / ''
if (c === "'" || c === '"' || c === '`') {
let j = i + 1
while (j < n) {
if (sql[j] === c) {
if (sql[j + 1] === c) {
j += 2
continue
}
break
}
j++
}
toks.push({ type: 'str', raw: sql.slice(i, Math.min(j + 1, n)) })
i = Math.min(j + 1, n)
continue
}
//
if (/\d/.test(c)) {
const m = sql.slice(i).match(/^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/)
toks.push({ type: 'word', raw: m ? m[0] : c })
i += m ? m[0].length : 1
continue
}
// / /
const m = sql.slice(i).match(/^[A-Za-z_$#][A-Za-z0-9_$#]*|^[\u4e00-\u9fa5]+/)
if (m) {
toks.push({ type: 'word', raw: m[0] })
i += m[0].length
continue
}
toks.push({ type: 'punct', raw: c })
i++
}
return toks
}
/* ---------------- 格式化 ---------------- */
/** 顶层子句关键词:前导换行 */
const TOP_KEYWORDS = new Set([
'SELECT', 'FROM', 'WHERE', 'GROUP', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET',
'JOIN', 'LEFT', 'RIGHT', 'INNER', 'FULL', 'CROSS', 'OUTER', 'ON',
'UNION', 'VALUES', 'SET', 'INTO', 'UPDATE', 'DELETE', 'INSERT',
'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'WITH', 'RETURNING',
'BEGIN', 'COMMIT', 'ROLLBACK', 'EXPLAIN', 'USE', 'SHOW',
])
/** 组合关键词:前一词 + 后一词 构成顶层子句 */
const PAIR_TAILS: Record<string, string[]> = {
GROUP: ['BY'], ORDER: ['BY'], UNION: ['ALL'], INSERT: ['INTO'],
DELETE: ['FROM'], LEFT: ['JOIN'], RIGHT: ['JOIN'], INNER: ['JOIN'],
FULL: ['JOIN'], CROSS: ['JOIN'], OUTER: ['JOIN'],
CREATE: ['TABLE', 'INDEX', 'VIEW', 'DATABASE', 'SCHEMA', 'TRIGGER', 'PROCEDURE', 'FUNCTION'],
ALTER: ['TABLE', 'INDEX', 'VIEW', 'DATABASE', 'SCHEMA'],
DROP: ['TABLE', 'INDEX', 'VIEW', 'DATABASE', 'SCHEMA', 'TRIGGER', 'PROCEDURE', 'FUNCTION'],
}
/** CASE 结构关键词:额外缩进一层 */
const CASE_KEYWORDS = new Set(['WHEN', 'ELSE'])
const CASE_TOP = new Set(['CASE', 'END'])
function formatSqlText(sql: string, caseMode: string, indentMode: string): string {
const toks = tokenize(sql)
if (toks.length === 0) return ''
const unit = indentMode === 'tab' ? '\t' : ' '.repeat(Number(indentMode) || 4)
const lines: string[] = []
let line = ''
let depth = 0
const parenStack: Array<'call' | 'group'> = []
/** 输出一行(去掉尾部空白后入列) */
function flush() {
if (line.trim().length > 0) lines.push(line.replace(/\s+$/, ''))
line = ''
}
/** 在行首补缩进 */
function indent(level: number) {
if (line === '') line = unit.repeat(level)
}
function pushWord(raw: string) {
if (line === '') {
line = raw
return
}
line += ' ' + raw
}
for (let i = 0; i < toks.length; i++) {
const tok = toks[i]
const isWord = tok.type === 'word'
const up = isWord ? tok.raw.toUpperCase() : ''
//
if (tok.type === 'comment') {
flush()
line = tok.raw
flush()
continue
}
// GROUP BY / LEFT JOIN / CREATE TABLE
const tails = isWord ? PAIR_TAILS[up] : undefined
let pairWord = ''
if (tails && i + 1 < toks.length) {
const next = toks[i + 1]
const nextUp = next.type === 'word' ? next.raw.toUpperCase() : ''
if (tails.includes(nextUp)) {
pairWord = up + ' ' + nextUp
i++
}
}
if (isWord) {
if (pairWord) {
flush()
indent(depth)
line = pairWord
continue
}
if (TOP_KEYWORDS.has(up) || CASE_TOP.has(up)) {
flush()
indent(depth)
line = up
continue
}
if (CASE_KEYWORDS.has(up)) {
flush()
indent(depth + 1)
line = up
continue
}
if (up === 'AND' || up === 'OR') {
flush()
indent(depth + 1)
line = up
continue
}
}
//
if (tok.type === 'punct') {
const p = tok.raw
if (p === '(') {
// SELECT/IN/VALUES/WHERE
const prev = i > 0 ? toks[i - 1] : null
const prevIsKw =
!!prev && prev.type === 'word' && (TOP_KEYWORDS.has(prev.raw.toUpperCase()) || CASE_KEYWORDS.has(prev.raw.toUpperCase()) || ['IN', 'VALUES', 'AND', 'OR', 'NOT', 'BY', 'THEN'].includes(prev.raw.toUpperCase()))
parenStack.push(prevIsKw ? 'group' : 'call')
if (line !== '' && !line.endsWith(' ') && !line.endsWith('(')) line += ' '
line += p
depth++
continue
}
if (p === ')') {
depth = Math.max(0, depth - 1)
parenStack.pop()
line = line.replace(/\s+$/, '') + p
continue
}
if (p === ',') {
//
if (parenStack[parenStack.length - 1] === 'call') {
line = line.replace(/\s+$/, '') + ', '
} else {
line = line.replace(/\s+$/, '') + ','
flush()
indent(depth + 1)
}
continue
}
if (p === '.') {
line = line.replace(/\s+$/, '') + '.'
continue
}
if (p === ';') {
line = line.replace(/\s+$/, '') + ';'
continue
}
//
if (line !== '' && !line.endsWith(' ')) line += ' '
line += p
continue
}
// / /
if (line !== '') line += ' '
line += tok.raw
}
flush()
//
const wordRe = /^[A-Za-z_$#][A-Za-z0-9_$#]*$/
return lines
.map((l) =>
l
.split(' ')
.map((w) => {
if (!wordRe.test(w)) return w
if (caseMode === 'upper') return w.toUpperCase()
if (caseMode === 'lower') return w.toLowerCase()
return w
})
.join(' '),
)
.join('\n')
}
/* ---------------- 页面逻辑 ---------------- */
function formatSql() {
error.value = ''
result.value = ''
const text = input.value.trim()
if (!text) {
error.value = '请输入需要格式化的 SQL 语句'
return
}
result.value = formatSqlText(text, caseMode.value, indentMode.value)
history.add(text.length > 1000 ? text.slice(0, 1000) : text)
}
function loadExample() {
input.value = EXAMPLE
formatSql()
}
function onHistorySelect(text: string) {
input.value = text
formatSql()
}
</script>
<style scoped>
.page-wrap {
max-width: 860px;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.code-block {
margin: 12px 0 0;
padding: 12px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,319 @@
<template>
<div class="page-wrap">
<h2 class="page-title">文本对比</h2>
<p class="page-desc">逐行对比两段文本找出新增删除与修改的行基于最长公共子序列LCS算法纯本地计算</p>
<t-card class="tool-section">
<div class="diff-inputs">
<div class="diff-col">
<div class="diff-col-title">原文本删除侧</div>
<t-textarea
v-model="left"
:autosize="{ minRows: 8, maxRows: 16 }"
placeholder="粘贴第一段文本"
/>
</div>
<div class="diff-col">
<div class="diff-col-title">新文本新增侧</div>
<t-textarea
v-model="right"
:autosize="{ minRows: 8, maxRows: 16 }"
placeholder="粘贴第二段文本"
/>
</div>
</div>
<div class="form-actions">
<t-button theme="primary" @click="runDiff">开始对比</t-button>
<t-button variant="outline" @click="loadExample">填入示例</t-button>
<t-button variant="outline" @click="swap">左右交换</t-button>
<t-checkbox v-model="ignoreCase">忽略大小写</t-checkbox>
<t-checkbox v-model="foldEqual">折叠相同行</t-checkbox>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="rows.length" class="tool-section" title="对比结果">
<div class="diff-stats">
<span class="stat stat-add">+ {{ stats.add }}</span>
<span class="stat stat-del">- {{ stats.del }}</span>
<span class="stat stat-eq">= {{ stats.eq }}</span>
<CopyButton :text="unifiedText" label="复制 diff 文本" />
</div>
<div class="diff-view">
<template v-for="(row, i) in rows" :key="i">
<!-- 折叠标记行 -->
<div v-if="row.type === 'fold'" class="diff-line diff-fold">
中间 {{ row.count }} 行无变化
</div>
<div v-else class="diff-line" :class="'diff-' + row.type">
<span class="diff-mark">{{ mark(row.type) }}</span>
<span class="diff-text">{{ row.text }}</span>
</div>
</template>
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
type DiffType = 'add' | 'del' | 'equal'
interface DiffRow {
type: DiffType | 'fold'
text: string
count?: number
}
const history = useHistory('text-diff')
const left = ref('')
const right = ref('')
const error = ref('')
const ignoreCase = ref(false)
const foldEqual = ref(true)
const rows = ref<DiffRow[]>([])
const rawRows = ref<DiffRow[]>([])
const stats = ref({ add: 0, del: 0, eq: 0 })
const EXAMPLE_LEFT = `function greet(name) {
console.log('Hello, ' + name);
return name;
}
//
const version = 1;`
const EXAMPLE_RIGHT = `function greet(name) {
console.log('Hi, ' + name);
return name.toUpperCase();
}
const version = 2;
const author = 'Alice';`
/** 基于 LCS 的行级 diff返回增删序列 */
function lcsDiff(a: string[], b: string[], ignore: boolean): DiffRow[] {
const na = a.length
const nb = b.length
// DPdp[i * (nb+1) + j] = LCS
const dp = new Int32Array((na + 1) * (nb + 1))
for (let i = na - 1; i >= 0; i--) {
for (let j = nb - 1; j >= 0; j--) {
const ai = ignore ? a[i].toLowerCase() : a[i]
const bj = ignore ? b[j].toLowerCase() : b[j]
dp[i * (nb + 1) + j] =
ai === bj
? dp[(i + 1) * (nb + 1) + j + 1] + 1
: Math.max(dp[(i + 1) * (nb + 1) + j], dp[i * (nb + 1) + j + 1])
}
}
const out: DiffRow[] = []
let i = 0
let j = 0
while (i < na && j < nb) {
const ai = ignore ? a[i].toLowerCase() : a[i]
const bj = ignore ? b[j].toLowerCase() : b[j]
if (ai === bj) {
out.push({ type: 'equal', text: a[i] })
i++
j++
} else if (dp[(i + 1) * (nb + 1) + j] >= dp[i * (nb + 1) + j + 1]) {
out.push({ type: 'del', text: a[i] })
i++
} else {
out.push({ type: 'add', text: b[j] })
j++
}
}
while (i < na) out.push({ type: 'del', text: a[i++] })
while (j < nb) out.push({ type: 'add', text: b[j++] })
return out
}
function runDiff() {
error.value = ''
const a = left.value.split('\n')
const b = right.value.split('\n')
//
while (a.length && a[0] === '') a.shift()
while (a.length && a[a.length - 1] === '') a.pop()
while (b.length && b[0] === '') b.shift()
while (b.length && b[b.length - 1] === '') b.pop()
if (a.length === 0 && b.length === 0) {
error.value = '请至少在一侧输入文本'
rows.value = []
return
}
if (a.length > 800 || b.length > 800) {
error.value = '单侧最多支持 800 行,请截短后重试'
rows.value = []
return
}
const raw = lcsDiff(a, b, ignoreCase.value)
rawRows.value = raw
//
stats.value = { add: 0, del: 0, eq: 0 }
for (const r of raw) {
if (r.type === 'add') stats.value.add++
else if (r.type === 'del') stats.value.del++
else stats.value.eq++
}
// 3
if (foldEqual.value) {
const folded: DiffRow[] = []
let run = 0
for (const r of raw) {
if (r.type === 'equal') {
run++
continue
}
if (run > 0) {
if (run > 3) folded.push({ type: 'fold', text: '', count: run })
else for (let k = 0; k < run; k++) folded.push({ type: 'equal', text: '' })
run = 0
}
folded.push(r)
}
if (run > 0) {
if (run > 3) folded.push({ type: 'fold', text: '', count: run })
else for (let k = 0; k < run; k++) folded.push({ type: 'equal', text: '' })
}
rows.value = folded
} else {
rows.value = raw
}
history.add(JSON.stringify({ left: left.value, right: right.value }))
}
function mark(type: DiffType): string {
return type === 'add' ? '+' : type === 'del' ? '-' : ' '
}
function loadExample() {
left.value = EXAMPLE_LEFT
right.value = EXAMPLE_RIGHT
runDiff()
}
function swap() {
const t = left.value
left.value = right.value
right.value = t
if (rows.value.length) runDiff()
}
const unifiedText = computed(() =>
rawRows.value
.map((r) => {
if (r.type === 'add') return '+ ' + r.text
if (r.type === 'del') return '- ' + r.text
return ' ' + r.text
})
.join('\n'),
)
function onHistorySelect(text: string) {
try {
const h = JSON.parse(text) as { left?: string; right?: string } | null
if (h && typeof h.left === 'string' && typeof h.right === 'string') {
left.value = h.left
right.value = h.right
runDiff()
}
} catch {
//
}
}
</script>
<style scoped>
.page-wrap {
max-width: 980px;
}
.diff-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.diff-col-title {
font-size: 13px;
color: var(--td-text-color-secondary);
margin-bottom: 6px;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.diff-stats {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.stat {
font-weight: 600;
}
.stat-add {
color: #2a9d4e;
}
.stat-del {
color: #d54941;
}
.stat-eq {
color: var(--td-text-color-placeholder);
}
.diff-view {
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.diff-line {
display: flex;
padding: 2px 10px;
}
.diff-mark {
width: 18px;
flex-shrink: 0;
user-select: none;
color: var(--td-text-color-placeholder);
}
.diff-text {
white-space: pre-wrap;
word-break: break-all;
flex: 1;
}
.diff-add {
background: rgba(42, 157, 78, 0.12);
}
.diff-add .diff-mark {
color: #2a9d4e;
}
.diff-del {
background: rgba(213, 73, 65, 0.12);
}
.diff-del .diff-mark {
color: #d54941;
}
.diff-fold {
justify-content: center;
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-container-hover);
}
</style>