Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd38aeaf38 | ||
|
|
af233e1628 | ||
|
|
8ba67edc18 | ||
|
|
e1e3ab7b23 | ||
|
|
00013ee112 | ||
|
|
e65da3ac8f | ||
|
|
b9369df4d6 | ||
|
|
b2911f01b8 | ||
|
|
f48dded468 | ||
|
|
1ff576c704 | ||
|
|
954b9e9d8a | ||
|
|
8ee1f71b41 | ||
|
|
42d4de46ba | ||
| 833720b7bf | |||
| 2fd700fb16 | |||
| b89b6a1ebe | |||
| 475610e941 |
1
.ci-trigger
Normal file
1
.ci-trigger
Normal file
@ -0,0 +1 @@
|
|||||||
|
ci trigger 20260822T062156Z
|
||||||
7
.env.example
Normal file
7
.env.example
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# 复制为 .env 并填写你的后端地址后生效(无需提交到 git)
|
||||||
|
# 后端接口契约见 docs/api-contract.md
|
||||||
|
#
|
||||||
|
# 默认值(代码内回落):https://service.xpcool.com
|
||||||
|
# 即:不创建 .env 也能正常调用已上线的 open/tools 接口。
|
||||||
|
# 仅在需要指向本地/测试后端时才覆盖它。
|
||||||
|
VITE_API_BASE=https://service.xpcool.com
|
||||||
64
.workbuddy/memory/2026-08-24.md
Normal file
64
.workbuddy/memory/2026-08-24.md
Normal 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-diff(LCS 行级 diff,折叠相同行)、json-to-code(JSON→TS interface/Go struct 含 json tag/Python dataclass,嵌套递归建类型表)
|
||||||
|
- 创意设计:gradient-generator(linear/radial/conic + 12 组预设 + 随机)、placeholder-image(canvas 占位图 + SVG 代码)、ascii-art(亮度感知加权灰度 → 字符,支持反色)
|
||||||
|
- 趣味娱乐:morse-code(pinyin-pro 中文转拼音编码 + Web Audio 电报声播放)、lucky-wheel(canvas 转盘 + ease-out 动画 + 指针判定)、slogan-generator(5 类文案库随机)
|
||||||
|
|
||||||
|
### 要点备忘
|
||||||
|
- TDesign 图标名需先在 node_modules/tdesign-icons-vue-next/esm/components/ 确认存在(PaletteIcon 不存在,只有 Palette1Icon;ascii-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-minify(JS/CSS/HTML 压缩美化,手写 tokenizer)、regex-visual(正则结构树解析器)、markdown-table、api-builder(fetch/curl 生成)、gitignore-gen、code-to-image(canvas 高亮卡片)、color-namer(148 色表欧氏距离匹配)、indent-convert、code-stats(30+ 语言注释规则状态机)、hello-world(33 语言)、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`(online,RocketIcon):下载测速(流式读 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/CloudyDay;ChartIcon 因 word-count 保留)
|
||||||
|
- ⚠️ 坑:项目内 rm -rf 被沙箱 safe-delete(genie-trash)拦截失败 → 改用 mv 到 $TMPDIR 实现移除(dist 同理)
|
||||||
|
- 文档全部同步:CHANGELOG 第五批 / PROJECT_STATE(55 个、dev 14、fun 1、online 6、契约 7 个)/ tool-ideas / api-contract(7 接口)/ 本日志
|
||||||
21
.workbuddy/memory/CHANGELOG.md
Normal file
21
.workbuddy/memory/CHANGELOG.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# tool.xpcool.com 变更记录
|
||||||
|
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
||||||
|
|
||||||
|
2026-09-14 | FIX | 修复主题失效的头号坑:TDesign 浅/深色变量声明在 :root[theme-mode='light'|'dark'](权重 0,2,0),我们此前用 html[theme-mode='dark'](0,1,1),生产构建下被 UI 库深色变量反向覆盖(卡片退回 #242424、输入框描边退回 #ddd)。tokens.scss 与 tdesign-theme.scss 全部改用 :root[theme-mode=...];另记录验证方法:切主题后 getComputedStyle(body).backgroundColor 可能命中计算值缓存,需用探针元素或刷新后再读
|
||||||
|
|
||||||
|
2026-09-14 | CHG | 界面改版第二版「清晰蓝 · 卡片工作台」(解决「字体/下拉/控件太小」+ 补上 UI 库主题控制):① tokens.scss:主色青玉 #0c7c59 → 宝石蓝 #2563eb、中性色调改蓝灰(浅 f4f6fb/fff/0f1729,深 0b0f18/131926/e9eef8)、圆角放大一档(xs5/sm7/md10/lg12/xl16/2xl22)、字号整体上移一档(正文 14→16px、最小 13px、标题阶梯同步)、密度收敛(space-9 80→68、版心 1120→1200、顶栏 64→68、侧栏 248→268)、卡片由「无框发丝线」改回「白底 + 细描边」;② tdesign-theme.scss:品牌/中性/语义色阶换蓝体系 + 深色反向中性色阶,新增「UI 库尺度控制」段(--td-comp-size-m 32→40 即默认输入框/按钮/下拉高度、xl 40→48、xs/xxs/xxxs 24/20/16→28/24/18、--td-font-size-body-medium 14→16px、body-small 12→13px、title 14/16/18→16/18/20px、行高成对放宽、paddingTB/LR 放宽一档),控件尺度只改变量不再写死高度;③ 27 个 .vue 里残留的 11/12px 硬编码字号提升为 var(--tk-text-xs);④ 外壳与组件跟随:MainLayout(顶栏 68、命令入口 44、内容留白 32/48/72、抽屉 300)、SideNav(筛选框 42、导航项 padding 9/12、图标 19)、HomeView(hero 52、搜索底线 60、索引行 20、服务端卡片 38px 图标)、CommandPalette(搜索行 64、结果项 58、kbd 22)、UploadDrop(缩略图 56、删除热区 36)、AppBrand 36、ThemeSwitch/LocaleSwitch 40、HistoryPanel 条目 48;⑤ index.html 的 theme-color 与 unocss 的 panel/code-block/hint-text 同步。构建通过(vue-tsc + vite,22.86s)
|
||||||
|
|
||||||
|
2026-09-14 | CHG | 界面改版为「更松弛的编辑感」(design-taste-frontend 技能,DESIGN_VARIANCE 6 / MOTION 3 / DENSITY 3):① tokens.scss 新增发丝线 --tk-hairline、排版令牌(--tk-leading-*/-track-*、--tk-text-5xl、--tk-font-display、--tk-space-9=80px)、顶栏高 60→64,并移除未用的 --tk-track-caps;② 全局排版:page-title 改 clamp(26,3vw,34)+640 字重、page-desc 15px/1.75、tool-section 间距 16→32、滚动条 10→8px、reveal 位移 14→10px;③ 容器去框化:t-card / UnoCSS panel 改 2xl 圆角 + hairline 描边 + 大内边距(.t-card .t-card__body 24/32,窄屏收回),品牌图标去掉全站唯一渐变改实色方块;④ 首页重写:删掉 eyebrow 与重复的 hero 链接,标题 58px 左对齐 + 底线式搜索框(聚焦加粗为 2px),事实条去边框,58 个工具改为「发丝线索引行」(图标常态中性灰、hover 才上主色);⑤ 外壳:侧栏去底色与右侧竖线(hover 改用 --tk-surface 统一「抬起」方向)、筛选框改底线式、内容区留白 28→40/48/88、页脚改左右分列 + hairline;⑥ HistoryPanel 改用 --tk-* 令牌与发丝线分行(此前有硬编码 --td-* 与 6px 圆角);⑦ 清理 4 份语言包中已失效的 home.eyebrow/ctaPrimary/ctaSecondary。构建通过(vue-tsc + vite,32.17s)
|
||||||
|
|
||||||
|
2026-09-14 | CHG | 视觉重设计 + 中英日韩四语言 + 三态昼夜主题 + 服务端接口对接(一大批):① 新增 tokens.scss(--tk-* 设计令牌:青玉 Jade 单一强调色、圆角/间距/字号/z-index/动效规范、按语言切换字体栈)+ tdesign-theme.scss(--tk-* → --td-* 桥接,含完整冷调灰阶),60+ 未改动工具页零成本继承新配色;② vue-i18n 11.4.10 + zh-CN/en-US/ja-JP/ko-KR 四份完整语言包 + helpers 中文兜底;③ MainLayout/SideNav/HomeView 重写、新增 CommandPalette(⌘K)/LocaleSwitch(显示 ZH/EN/JA/KO 不用国旗)/ThemeSwitch(三态);④ 新增 openTools.ts 对接 service.xpcool.com /api/service/open/tools 的 5 个接口(实测全 200),改造 ip-info/uuid/hash/timestamp 并新增 random-string/api-status 两工具(均本机+服务端双模式,默认本机);⑤ 修复空分类渲染缺陷(分类 9→6)、panel 快捷类缺内边距、IpInfoView 漏 onBeforeUnmount;⑥ 新增 src/config.ts 集中站点常量,页脚补备案号 黔ICP备2026003933号;⑦ 重写 docs/api-contract.md(旧契约 /api/tools/* 与实际不符)。构建通过(vue-tsc + vite,33.88s)
|
||||||
|
|
||||||
|
2026-09-01 | CHG | 全站接入图片预览(TDesign ImageViewer):新增共享 AppImageViewer 组件 + useImageViewer 组合式函数;接入 9 处——UploadDrop 缩略图、Base64 还原图、图片裁剪结果、图片转 ICO 预览(多尺寸成组)、OCR 待识别图、二维码结果、图片压缩(原直连 t-image-viewer 重构为共享组件)、代码转图片画布、占位图画布;全局样式新增 .img-previewable
|
||||||
|
|
||||||
|
2026-09-01 | CHG | 图片转 ICO 结果区增强:逐尺寸单独 ICO 下载(buildIco 新增 singles 字段,复用条目数据零额外编码)+ 多尺寸一键打包 ZIP 下载(jszip);另修复 pnpm wrapper 中 node 版本路径(22.22.2 → 22.22.2-2)
|
||||||
|
|
||||||
|
2026-08-31 | CHG | 新增「图片转 ICO」工具:src/utils/ico.ts(纯前端自实现 PNG/BMP DIB/ICO 编码 + 中位切分量化 + 自适应滤波 + 体积上限自动降级),src/views/image-to-ico/ImageToIcoView.vue(尺寸组合/填充/编码方式/量化/体积限制/预览下载/历史),路由注册于 src/router/tools.ts;Pillow 独立解码验证通过
|
||||||
|
|
||||||
|
2026-08-26 | CFG | CHANGELOG 纳入 git 管理(.gitignore 放行 .workbuddy/memory/CHANGELOG.md),中央工作空间迁移至 ../workbuddy.xpcool.com(相对路径索引)
|
||||||
|
|
||||||
|
2026-08-26 | CHG | 同步三铁律(中文注释/做记录/中文优先)至 AGENTS.md「统一工作约定」小节
|
||||||
|
2026-08-26 | CFG | 建立统一变更记录机制(CHANGELOG),接入 xpcool.com 中央索引
|
||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- 27 个实用工具:图片压缩/裁剪、二维码生成解析、JSON 格式化、时间戳转换、Base64 编解码、JWT 解析、Cron 表达式、进制转换、哈希计算、身份证解析、地址解析、URL 解析、翻译、图片 OCR 识别、UUID 生成、正则测试、命名转换、色值转换、单位换算、YAML ↔ JSON、字数统计、人民币大写、简繁转换、日期计算、密码生成、HTTP 状态码速查
|
- 27 个实用工具:图片压缩/裁剪、图片转 ICO、二维码生成解析、JSON 格式化、时间戳转换、Base64 编解码、JWT 解析、Cron 表达式、进制转换、哈希计算、身份证解析、地址解析、URL 解析、翻译、图片 OCR 识别、UUID 生成、正则测试、命名转换、色值转换、单位换算、YAML ↔ JSON、字数统计、人民币大写、简繁转换、日期计算、密码生成、HTTP 状态码速查
|
||||||
- 左侧固定导航 + 中间内容区;PC Web 与移动端 H5 响应式适配(移动端为抽屉导航)
|
- 左侧固定导航 + 中间内容区;PC Web 与移动端 H5 响应式适配(移动端为抽屉导航)
|
||||||
- 暗黑 / 浅色主题切换,选择持久化,刷新不丢失
|
- 暗黑 / 浅色主题切换,选择持久化,刷新不丢失
|
||||||
- 每个工具独立路由、独立本地历史记录(localStorage)
|
- 每个工具独立路由、独立本地历史记录(localStorage)
|
||||||
@ -56,6 +56,7 @@ pnpm preview
|
|||||||
| `/` | 首页(工具卡片导航) |
|
| `/` | 首页(工具卡片导航) |
|
||||||
| `/image-compress` | 图片压缩 |
|
| `/image-compress` | 图片压缩 |
|
||||||
| `/image-cropper` | 图片裁剪 |
|
| `/image-cropper` | 图片裁剪 |
|
||||||
|
| `/image-to-ico` | 图片转 ICO |
|
||||||
| `/qrcode` | 二维码生成 / 解析 |
|
| `/qrcode` | 二维码生成 / 解析 |
|
||||||
| `/json-format` | JSON 格式化 |
|
| `/json-format` | JSON 格式化 |
|
||||||
| `/timestamp` | 时间戳转换 |
|
| `/timestamp` | 时间戳转换 |
|
||||||
|
|||||||
42
agents.md
42
agents.md
@ -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(在线工具箱)
|
||||||
@ -47,17 +55,20 @@ export interface ToolItem {
|
|||||||
name: string // 工具名(菜单/卡片/页面标题)
|
name: string // 工具名(菜单/卡片/页面标题)
|
||||||
desc: string // 一句话描述
|
desc: string // 一句话描述
|
||||||
icon: Component // TDesign 图标组件
|
icon: Component // TDesign 图标组件
|
||||||
|
category?: string // 分类 key(对应 toolCategories),缺省归入第一个分类
|
||||||
component: () => Promise<Component> // 懒加载组件
|
component: () => Promise<Component> // 懒加载组件
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**分类机制**:`toolCategories` 数组定义分类(目前 `work` 工作效率、`life` 生活助手),`groupedTools()` 按分类分组返回工具,侧边导航(`SideNav.vue`)与首页分区(`HomeView.vue`)均消费该函数。**新增分类零改动**:在 `toolCategories` 追加一条 `{ key/label/desc }`,再把对应工具的 `category` 指向新 key 即可。
|
||||||
|
|
||||||
**新增一个工具的完整步骤:**
|
**新增一个工具的完整步骤:**
|
||||||
|
|
||||||
1. 新建 `src/views/<tool-name>/<ToolName>View.vue`,参考现有页面结构(标题 → 表单卡片 → 错误提示 → 结果卡片 → 历史面板)。
|
1. 新建 `src/views/<tool-name>/<ToolName>View.vue`,参考现有页面结构(标题 → 表单卡片 → 错误提示 → 结果卡片 → 历史面板)。
|
||||||
2. 在 `tools.ts` 的 `tools` 数组末尾追加一条配置,`component` 使用 `() => import('@/views/<tool-name>/<ToolName>View.vue')`。
|
2. 在 `tools.ts` 的 `tools` 数组对应分类分区末尾追加一条配置,`component` 使用 `() => import('@/views/<tool-name>/<ToolName>View.vue')`,并填写 `category`。
|
||||||
3. 完成——菜单、首页卡片、路由、文档标题自动生效。可运行 `pnpm type-check` 验证。
|
3. 完成——菜单、首页卡片、路由、文档标题自动生效。可运行 `pnpm type-check` 验证。
|
||||||
|
|
||||||
**注意:** `path` 必须与页面内 `useHistory('xxx')` 的 key 一致(历史记录约定),并避免与现有 27 个 path 重复。
|
**注意:** `path` 必须与页面内 `useHistory('xxx')` 的 key 一致(历史记录约定),并避免与现有工具的 path 重复。
|
||||||
|
|
||||||
### 3.2 历史记录约定
|
### 3.2 历史记录约定
|
||||||
|
|
||||||
@ -66,11 +77,19 @@ export interface ToolItem {
|
|||||||
- 文本类工具:`text` 存输入原文,点击历史回填输入框。
|
- 文本类工具:`text` 存输入原文,点击历史回填输入框。
|
||||||
- 文件类工具(图片压缩/裁剪等):文件本体**不要**存入 localStorage(会撑爆配额),改为存「文件名 | 关键参数」形式的摘要,点击历史只恢复参数。
|
- 文件类工具(图片压缩/裁剪等):文件本体**不要**存入 localStorage(会撑爆配额),改为存「文件名 | 关键参数」形式的摘要,点击历史只恢复参数。
|
||||||
|
|
||||||
### 3.3 主题机制
|
### 3.3 主题机制(色彩 + UI 库尺度两套变量)
|
||||||
|
|
||||||
- TDesign 通过 `document.documentElement.setAttribute('theme-mode', 'dark')` 切换暗黑模式。
|
- TDesign 通过 `document.documentElement.setAttribute('theme-mode', 'dark')` 切换暗黑模式。
|
||||||
- `stores/theme.ts` 负责切换 + 持久化(key:`tool-theme`);`index.html` 内联脚本在挂载前预置属性防闪烁;`main.ts` 再同步一次。
|
- `stores/theme.ts` 负责切换 + 持久化(key:`tool-theme`);`index.html` 内联脚本在挂载前预置属性防闪烁;`main.ts` 再同步一次。
|
||||||
- **自定义样式一律使用 `var(--td-*)` 令牌**(如 `--td-bg-color-page`、`--td-text-color-primary`、`--td-brand-color`),禁止硬编码颜色,否则暗黑模式会破版。
|
- **两套变量各管一段**:
|
||||||
|
- `src/assets/styles/tokens.scss` 定义 `--tk-*`(配色 / 圆角 / 间距 / **字号** / 动效),是全站唯一真相源;
|
||||||
|
- `src/assets/styles/tdesign-theme.scss` 把 `--tk-*` 映射为 `--td-*`,并**额外接管 UI 库自身的尺度**:
|
||||||
|
`--td-comp-size-*`(控件高度,默认控件 40px)、`--td-comp-paddingTB/LR-*`、`--td-font-size-*`、
|
||||||
|
`--td-line-height-*`。放大按钮 / 输入框 / 下拉请改这里,**不要给单个组件写死 height**。
|
||||||
|
- **自定义样式一律使用 `var(--tk-*)`(或 `var(--td-*)`)令牌**,禁止硬编码颜色与字号,否则暗黑模式会破版。
|
||||||
|
- **头号坑**:主题变量必须声明在 `:root[theme-mode='light'|'dark']` 里,**不能写 `html[theme-mode='dark']`** ——
|
||||||
|
TDesign 自己用的是前者(权重 0,2,0 且后加载者胜),写成后者(0,1,1)会被 UI 库的深色变量盖掉,
|
||||||
|
表现为「深色下组件退回 UI 库自带灰(卡片 #242424、描边 #ddd)」。
|
||||||
|
|
||||||
### 3.4 公共组件
|
### 3.4 公共组件
|
||||||
|
|
||||||
@ -115,6 +134,11 @@ export interface ToolItem {
|
|||||||
| 日期计算 | `views/date-calc` | 原生 Date(本地时间构造) | 三个 tab:差值/推算/年龄生肖。`parseDate` 用 `new Date(y, m-1, d)` + 回读校验防时区偏移与 2 月 30 类非法日期;月/年推算先归 1 号再 setMonth/setFullYear,溢出钳制到目标月最后一天;生肖 `((year-4)%12+12)%12` 正模 |
|
| 日期计算 | `views/date-calc` | 原生 Date(本地时间构造) | 三个 tab:差值/推算/年龄生肖。`parseDate` 用 `new Date(y, m-1, d)` + 回读校验防时区偏移与 2 月 30 类非法日期;月/年推算先归 1 号再 setMonth/setFullYear,溢出钳制到目标月最后一天;生肖 `((year-4)%12+12)%12` 正模 |
|
||||||
| 密码生成 | `views/password-gen` | `crypto.getRandomValues` + 拒绝采样 | 每种选中字符集至少出现一次 + Fisher-Yates 洗牌;t-slider 数值用外部 `<span>` 显示(label 不能传函数);强度 = 长度×log2(池大小) 分四档 |
|
| 密码生成 | `views/password-gen` | `crypto.getRandomValues` + 拒绝采样 | 每种选中字符集至少出现一次 + Fisher-Yates 洗牌;t-slider 数值用外部 `<span>` 显示(label 不能传函数);强度 = 长度×log2(池大小) 分四档 |
|
||||||
| HTTP 状态码 | `views/http-status` | 内置静态数据(56 条,1xx-5xx) | 搜索(code/短语/中文)+ 分类筛选;历史存搜索词 |
|
| HTTP 状态码 | `views/http-status` | 内置静态数据(56 条,1xx-5xx) | 搜索(code/短语/中文)+ 分类筛选;历史存搜索词 |
|
||||||
|
| BMI 计算器 | `views/bmi` | 纯 TS 计算 | 中国成人标准四级分类(<18.5/24/28);健康体重范围按 18.5~23.9 反推;历史存 `身高|体重` |
|
||||||
|
| 房贷计算器 | `views/mortgage` | 纯 TS 金融公式 | 等额本息/等额本金一次算出并对比;等额本息 `M = P·r·(1+r)^n / ((1+r)^n − 1)`,等额本金总利息等差求和 `(n+1)·P·r/2`;利率为 0 需特判;历史存 JSON 快照 |
|
||||||
|
| 账单拆分 | `views/bill-split` | 纯 TS 贪心结算 | 每人已付 → 人均 → 净差额;欠款方升序/收款方降序配对生成转账方案;最多 20 人;历史存 `[[名字,金额],...]` |
|
||||||
|
| 随机决定 | `views/random-decision` | `crypto.getRandomValues` 拒绝采样 | 选项抽签 + 随机数两个 tab;`secureRandInt` 拒绝采样避免取模偏斜;不重复模式小范围洗牌/大范围 Set;历史存 JSON 快照 |
|
||||||
|
| 番茄钟 | `views/pomodoro` | 原生定时器 + Web Audio | 基于目标时间戳计时(`endTime` + 250ms tick,后台节流不漂移);阶段结束自动切换并计轮;提示音用 AudioContext 880Hz 振荡器;`onBeforeUnmount` 清理定时器;历史存 `focus=25 | rest=5` |
|
||||||
|
|
||||||
## 5. OCR 特殊说明(重要)
|
## 5. OCR 特殊说明(重要)
|
||||||
|
|
||||||
@ -177,4 +201,12 @@ pnpm preview # 预览 dist
|
|||||||
- **PWA / 离线**:可接入 `vite-plugin-pwa`,配合第 5 节 OCR 离线方案实现完全离线工具站。
|
- **PWA / 离线**:可接入 `vite-plugin-pwa`,配合第 5 节 OCR 离线方案实现完全离线工具站。
|
||||||
- **国际化**:文案目前硬编码中文,若需 i18n 建议 vue-i18n,工具注册表的 `name/desc` 改为 key。
|
- **国际化**:文案目前硬编码中文,若需 i18n 建议 vue-i18n,工具注册表的 `name/desc` 改为 key。
|
||||||
- **体积优化**:TDesign 目前全量注册(约 1.4MB gzip 373KB),若追求极致可改按需引入(unplugin-vue-components + TDesignResolver)。
|
- **体积优化**:TDesign 目前全量注册(约 1.4MB gzip 373KB),若追求极致可改按需引入(unplugin-vue-components + TDesignResolver)。
|
||||||
- **更多工具**:图片格式互转(canvas)、Markdown 预览(markdown-it)、SQL 格式化(sql-formatter)、CSS 渐变生成器、番茄钟、BMI/贷款计算器等均可按现有模式快速复制。
|
- **更多工具**:图片格式互转(canvas)、Markdown 预览(markdown-it)、SQL 格式化(sql-formatter)、CSS 渐变生成器、农历/节假日查询等均可按现有模式快速复制;新增分类见 3.1 节。
|
||||||
|
|
||||||
|
## 统一工作约定(xpcool.com 中央规则)
|
||||||
|
|
||||||
|
> 与中央 `../workbuddy.xpcool.com/.workbuddy/memory/MEMORY.md` 保持一致;本项目变更流水在 `.workbuddy/memory/CHANGELOG.md`。
|
||||||
|
|
||||||
|
1. **中文注释**:写/改代码时,在应有处(函数、复杂逻辑、配置项、非显然分支)加中文注释。
|
||||||
|
2. **做记录**:每次请求/变更/修复/文档动作,都在本项目 `.workbuddy/memory/CHANGELOG.md` 顶部追加一条,格式 `YYYY-MM-DD | 类型 | 一句话摘要`(类型:REQ/CHG/FIX/DOC/CFG/DEP)。
|
||||||
|
3. **中文优先**:与用户的思考、输出、交流,能中文尽量中文。
|
||||||
|
|||||||
262
docs/CHANGELOG.md
Normal file
262
docs/CHANGELOG.md
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
# CHANGELOG — 项目变更日志
|
||||||
|
|
||||||
|
> 记录每次请求/变更,**倒序排列(最新在上)**。
|
||||||
|
> 与 `docs/PROJECT_STATE.md` 配合使用:本文件回答「改了什么」,状态文档回答「现在是什么样」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-14 第八批:修「字太小」+ 换风格为「清晰蓝 · 卡片工作台」+ 补上 UI 库主题控制
|
||||||
|
|
||||||
|
> 用户反馈:① 界面字体、下拉与功能控件都太小;② 再换一种风格;③ UI 库(TDesign)的主题此前没有真正接上,
|
||||||
|
> 需要用主题变量控制组件的色彩与风格。
|
||||||
|
> 三者一并处理:换主色与密度(风格)→ 放大令牌与控件(太小)→ 用 `--td-comp-*` / `--td-font-*` 接管 TDesign 尺度(主题控制)。
|
||||||
|
> 构建通过(`vue-tsc --noEmit` + `vite build`,22.86s)。
|
||||||
|
|
||||||
|
### ⚠️ 本轮挖出的头号坑:`theme-mode` 选择器权重
|
||||||
|
TDesign 的浅/深色变量声明在 `:root[theme-mode='light'|'dark']`(权重 0,2,0),
|
||||||
|
而上一版我们写的是 `html[theme-mode='dark']`(0,1,1)—— 生产构建里即便我们的 CSS 排在 vendor CSS 之后,
|
||||||
|
**仍会被 UI 库的深色变量盖掉**。表现:深色下卡片/输入框用回 TDesign 自带灰(卡片 `#242424`、描边 `#ddd`)。
|
||||||
|
**修复:tokens.scss 与 tdesign-theme.scss 全部改用 `:root[theme-mode=...]`(与 TDesign 同级权重 + 后加载者胜)。**
|
||||||
|
(验证提醒:切完主题别只看 `getComputedStyle(body).backgroundColor`,它可能命中计算值缓存,需用探针元素或刷新后再读。)
|
||||||
|
|
||||||
|
### 🎨 风格:青玉编辑感 → 宝石蓝 · 卡片工作台
|
||||||
|
- 主色:青玉 `#0c7c59` → 宝石蓝 `#2563eb`(与白底 5.17:1 过 AA;深色下按钮底同色 + 白字,链接另用 `#7aa8ff`)。
|
||||||
|
- 中性色:青灰 → 蓝灰(浅 `#f4f6fb / #ffffff / #0f1729`,深 `#0b0f18 / #131926 / #e9eef8`);
|
||||||
|
品牌/中性/语义三级色板与 `index.html` 的 `theme-color` 同步更新。
|
||||||
|
- 形状:圆角整体放大一档(xs5 / sm7 / md10 / lg12 / xl16 / 2xl22);
|
||||||
|
卡片由「无框发丝线」改回「白底 + 细描边」,重新有边界感(仅列表条目分隔线仍用 `--tk-hairline`)。
|
||||||
|
- 密度:`--tk-space-9` 80→68、版心 1120→1200、顶栏 64→68、侧栏 248→268。
|
||||||
|
|
||||||
|
### 🔠 字号整体放大一档(tokens.scss)
|
||||||
|
- 旧 → 新:`12→13 / 13→14 / 14→16 / 15→17 / 17→19 / 20→22 / 26→28 / 34→36 / 44→46 / 58→52`。
|
||||||
|
- **正文基准 16px**,全站最小字号 13px(`--tk-text-xs`)。
|
||||||
|
- **工具页里残留的 11px/12px 硬编码字号一次性提升为 `var(--tk-text-xs)`**(27 个 `.vue` 文件)。
|
||||||
|
|
||||||
|
### 🧩 UI 库主题控制(tdesign-theme.scss 新增第 ③ 段,本轮核心)
|
||||||
|
TDesign 组件样式完全建立在 `--td-*` 变量之上(例如 `.t-input.t-size-l { height: var(--td-comp-size-xl) }`),
|
||||||
|
所以「控件太小」的正解是改变量,而不是逐个组件写 CSS:
|
||||||
|
|
||||||
|
| 变量分组 | TDesign 默认 | 本版 | 影响面 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `--td-comp-size-m`(默认控件) | 32px | **40px** | 输入框 / 按钮 / 下拉 / 日期选择器 |
|
||||||
|
| `--td-comp-size-xl`(large) | 40px | 48px | large 尺寸控件 |
|
||||||
|
| `--td-comp-size-xs / xxs / xxxs` | 24 / 20 / 16px | 28 / 24 / 18px | 小按钮、标签、复选框 |
|
||||||
|
| `--td-font-size-body-medium` | 14px | **16px** | 控件文字(与正文同号) |
|
||||||
|
| `--td-font-size-body-small` | 12px | 13px | 小标签、说明 |
|
||||||
|
| `--td-font-size-title-*` | 14/16/18px | 16/18/20px | 卡片标题、分组标题 |
|
||||||
|
| `--td-line-height-body-*` | 20/22/24px | 22/24/26px | **必须与字号成对改**,否则大字号被行高挤住 |
|
||||||
|
| `--td-comp-paddingTB / LR-*` | size 阶梯 | 放宽一档 | 控件内边距,避免大控件里文字贴边 |
|
||||||
|
|
||||||
|
- 只覆盖 `--td-comp-*`,**不动 `--td-size-*` 原子刻度**(图标/徽标/间距都在用它)。
|
||||||
|
- 深色中性色阶(反向,1 最深 → 14 最浅)补进 `:root[theme-mode='dark']`,避免组件在深色下退回 UI 库冷灰。
|
||||||
|
- 下拉:`--td-comp-size-m` 放大后 `.t-select-option` / `.t-dropdown__item` 行高自动跟随,仅补一条字号保险。
|
||||||
|
|
||||||
|
### 🧱 外壳、首页与公共组件(跟随新尺度)
|
||||||
|
- `MainLayout`:顶栏 68px、命令面板入口 44px、图标按钮 40px、内容区留白 32/48/72、移动端抽屉宽 300。
|
||||||
|
- `SideNav`:筛选框 42px、导航项 padding 9/12(行高约 43px)、图标 19px、活动指示条 3×18px。
|
||||||
|
- `HomeView`:hero 标题 52px、搜索底线 60px、工具索引行 padding 20px、服务端卡片图标 38px + 轻投影。
|
||||||
|
- 公共组件:`CommandPalette`(搜索行 64px、结果项 58px、kbd 22px)、`UploadDrop`(拖拽区加大、
|
||||||
|
缩略图 56px、删除按钮热区 36px)、`AppBrand`(标记 36px)、`ThemeSwitch` / `LocaleSwitch`(40px 高)、
|
||||||
|
`HistoryPanel`(条目最小高 48px、操作按钮热区撑大)。
|
||||||
|
- `unocss.config.ts` 的 `panel`(改实描边 + 更大内边距)、`code-block`(14px)、`hint-text`(13px)跟随放大。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-14 第七批:界面改版 →「更松弛的编辑感」
|
||||||
|
|
||||||
|
> 用户反馈对现有主题界面仍不满意,经方向选择后定为**编辑感(Editorial)**:
|
||||||
|
> 加大留白与字号反差,卡片几乎无边框,靠排版与呼吸感建立层次。
|
||||||
|
> 依据 `design-taste-frontend` 技能执行,旋钮 `DESIGN_VARIANCE 6` / `MOTION_INTENSITY 3` / `VISUAL_DENSITY 3`。
|
||||||
|
> 构建通过(`vue-tsc --noEmit` + `vite build`,32.17s)。
|
||||||
|
|
||||||
|
### 🎯 本轮解决的三个真实问题
|
||||||
|
1. **首页 hero 超标**:eyebrow + 标题 + 描述 + 搜索框 + 文字链接共 5 个文字元素(超过 hero 上限 4),
|
||||||
|
且 eyebrow 是模板化开场 → **删掉 eyebrow 与重复的 hero 链接**(服务端专区入口已在下方,同意图不出现两次)。
|
||||||
|
2. **58 张卡片长得一模一样**:全部 14px 圆角 + 1px 描边 + 绿底图标,重复得没有节奏
|
||||||
|
→ 改为**发丝线索引行**(像书末索引),图标常态中性灰、hover 才上主色。
|
||||||
|
3. **层次全靠边框与底纹堆出来**,排版本身承担的信息层级偏弱
|
||||||
|
→ 标题 58px / 说明 17px 拉开反差,分区之间改用 80px 留白而非横线。
|
||||||
|
|
||||||
|
### 🧱 令牌层(tokens.scss)
|
||||||
|
- 新增 `--tk-hairline`(浅 `#e7ecea` / 深 `#232b29`):内容容器与条目分隔线用它;
|
||||||
|
**功能控件仍用 `--tk-border`**,两类不要互换。
|
||||||
|
- 新增排版令牌:`--tk-text-5xl`(58px)、`--tk-leading-display/title/body`、
|
||||||
|
`--tk-track-display/title`、`--tk-font-display`。其中展示字体显式等于系统字体族 ——
|
||||||
|
中文环境下可用的展示字体就是系统字体本身,引入网络 CJK 展示字体要多付 2MB+ 首屏成本。
|
||||||
|
- `--tk-space-9`(80px) 专用于分区之间;`--tk-header-h` 60 → 64。
|
||||||
|
- 移除未使用的 `--tk-track-caps`(不在 CJK 场景下用宽字距)。
|
||||||
|
|
||||||
|
### 📐 全局排版与容器(index.scss / unocss.config.ts / tdesign-theme.scss)
|
||||||
|
- `.page-title` → `clamp(26px, 3vw, 34px)` + 字重 640;`.page-desc` → 15px / 1.75 / `max-width: 62ch`;
|
||||||
|
`.tool-section` 间距 16 → 32px;新增 `.section-heading` 与 `.rule`。
|
||||||
|
- `t-card` / UnoCSS `panel`:**圆角 → 2xl、描边 → 发丝线、内边距放大一档**(`p-5 md:p-7`、
|
||||||
|
`.t-card .t-card__body` 24/32,窄屏在 `@media (max-width: 767px)` 收回)。
|
||||||
|
- 滚动条 10 → 8px;`.reveal` 位移 14 → 10px(编辑感入场要「不动声色」)。
|
||||||
|
|
||||||
|
### 🏠 首页重写(HomeView.vue)
|
||||||
|
- 抬头区:58px 左对齐标题 + 一句说明 + **底线式搜索框**(聚焦时底线加粗为 2px,
|
||||||
|
用 `box-shadow` 叠加避免 `border-width` 变化引起 1px 位移;输入框自身不画焦点环)。
|
||||||
|
- 事实条去掉全部边框与底纹,数字直接摊在纸面上。
|
||||||
|
- 服务端专区保留青玉底纹(全站唯一),是分区层级的锚点。
|
||||||
|
- 分类目录 = 标题 + 数量注记 + 说明 + 发丝线索引网格(列间距 52px,避免两栏读串行)。
|
||||||
|
|
||||||
|
### 🧭 外壳(MainLayout / SideNav / AppBrand)
|
||||||
|
- 侧栏**去掉底色块与右侧竖线**,与正文落在同一张纸面上;hover 改用 `--tk-surface`
|
||||||
|
(浅色下更亮、深色下也更亮,两种主题都是同一种「抬起」方向)。
|
||||||
|
- 侧栏筛选框改为底线式;内容区留白 28/28/56 → **40/48/88**;页脚改左右分列 + 发丝线。
|
||||||
|
- 品牌图标**去掉全站唯一渐变**,改实色小方块(渐变会把视线从标题上拽走),
|
||||||
|
品牌辨识度改由字重与字距承担。
|
||||||
|
|
||||||
|
### ♻️ 顺带修复
|
||||||
|
- `HistoryPanel.vue` 此前混用硬编码的 `--td-*` 与写死的 `border-radius: 6px`,
|
||||||
|
违反「样式一律用 `--tk-*`」的硬规则,且每条历史都套一个描边盒子;已改为 `--tk-*` 令牌 + 发丝线分行。
|
||||||
|
- 清理 4 份语言包中已失效的 `home.eyebrow` / `home.ctaPrimary` / `home.ctaSecondary`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-14 第六批:视觉重设计 + 中英日韩四语言 + 昼夜主题 + 服务端接口对接
|
||||||
|
|
||||||
|
> 依据 `design-taste-frontend` 技能(BRIEF INFERENCE / 单一强调色 / 形状一致性 / 反 AI-slop 清单)重建全站视觉体系,
|
||||||
|
> 并一次性补齐多语言、主题切换与 5 个已上线服务端接口的对接。构建通过(`vue-tsc --noEmit` + `vite build`,33.88s)。
|
||||||
|
|
||||||
|
### 🎨 视觉体系重建(令牌 → TDesign 桥接,60+ 页面自动生效)
|
||||||
|
- 新增 `src/assets/styles/tokens.scss`:全站唯一视觉真相源。冷调石板灰中性色阶 + **青玉 Jade 单一强调色**
|
||||||
|
(浅色 `#0c7c59` 对比 5.19:1 / 深色 `#3fc79b` 对比 8.85:1,均过 WCAG AA);统一圆角规范
|
||||||
|
(xs4/sm6/md8/lg10/xl14/2xl20/pill999)、8px 间距节奏、字号阶梯、z-index 分层规范、动效曲线与时长;
|
||||||
|
按语言切换的字体栈(`html[lang^='zh'|'ja'|'ko'|'en']`,注意必须前缀匹配,`lang` 写的是完整标签)。
|
||||||
|
- 新增 `src/assets/styles/tdesign-theme.scss`:把 `--tk-*` 映射到 `--td-*`,重定义品牌色阶、语义色阶、
|
||||||
|
**完整冷调灰阶**(`--td-gray-color-1..14`,浅深共用)、圆角、阴影、滚动条。**收益:60+ 未改动的工具页
|
||||||
|
零成本继承新配色与圆角**,这是本次能低风险铺开全站的关键。
|
||||||
|
- 重写 `index.scss`:每页只过渡颜色(不碰 transform/布局)、统一 `:focus-visible` 焦点环、
|
||||||
|
`.skip-to-content`、滚动条 `background-clip: content-box`、标题字重 650 + `letter-spacing:-0.02em`、
|
||||||
|
`prefers-reduced-motion` 降级。
|
||||||
|
- 新增 `src/directives/reveal.ts`:`v-reveal` 滚动进入动效(全局单例 IntersectionObserver,传数字做错峰)。
|
||||||
|
- `unocss.config.ts` shortcuts 全部改为令牌驱动,`panel` 补上内边距(此前 ApiStatus/RandomString/Hash 等
|
||||||
|
新建页出现内容贴边)——注意页面 scoped 样式(`.block[data-v-*]`)权重高于该快捷类。
|
||||||
|
|
||||||
|
### 🧭 布局外壳重构
|
||||||
|
- `MainLayout.vue` 完全重写:毛玻璃 sticky 顶栏(`backdrop-filter: blur(14px) saturate(180%)`)+ 三区布局,
|
||||||
|
品牌区改用 TDesign `AppIcon` 而非手绘 SVG,全站**唯一**一处渐变(青玉 → `#2fb98d`)。
|
||||||
|
- 新增 `CommandPalette.vue` 命令面板(⌘K / Ctrl+K,按 UA 显示对应提示):Teleport 到 body、
|
||||||
|
扁平结果列表 + 分类标签、↑↓/Enter/Esc 键盘导航、鼠标 hover 同步高亮、打开时锁 body 滚动。
|
||||||
|
- `SideNav.vue` 重写:内联筛选框、可折叠分组(`tool-nav-collapsed` 持久化)、活动态青玉底纹 + 左侧 2px
|
||||||
|
指示条、`nav-dot` 标记需联网工具。
|
||||||
|
- 新增 `LocaleSwitch.vue`(显示 `ZH/EN/JA/KO` **而非国旗**,避免语言与国家绑定歧义)、
|
||||||
|
`ThemeSwitch.vue`(三态,图标显示实际生效的 `mode`、菜单勾选用户偏好 `preference`)。
|
||||||
|
|
||||||
|
### 🌏 多语言(中/英/日/韩)
|
||||||
|
- 引入 vue-i18n 11.4.10(`legacy: false` + `globalInjection: true`),基线语言 zh-CN。
|
||||||
|
- 新增 `src/i18n/index.ts`(`SUPPORTED_LOCALES` / `normalizeLocale()` / `resolveInitialLocale()` /
|
||||||
|
`applyLocaleToDocument()` / `setAppLocale()`)、`src/i18n/helpers.ts`(`toolName/toolDesc/categoryLabel/tOr`
|
||||||
|
等**带中文兜底**的取词助手)、`src/stores/locale.ts`。
|
||||||
|
- 四份完整语言包 `src/i18n/locales/{zh-CN,en-US,ja-JP,ko-KR}.ts`:app / nav / palette / theme / locale /
|
||||||
|
home / category / **tool(58 个工具名与描述全译)** / page / pending / common。
|
||||||
|
- 新增 `src/components/common/BackendPending.vue`:用中性信息态(非 `t-alert` 错误色)如实说明
|
||||||
|
「前端已就绪、后端接口尚未上线」,**不伪装成可用**。
|
||||||
|
- `index.html` 重写防闪烁内联脚本(同步执行,解析主题 `auto` + 语言归一化),与 theme store、i18n index
|
||||||
|
**三处逻辑必须同步**;`theme-mode` 属性 + 明暗两套 `theme-color` meta。
|
||||||
|
|
||||||
|
### 🔌 服务端接口对接(service.xpcool.com `/api/service/open/tools/*`)
|
||||||
|
- 新增 `src/api/openTools.ts`:接口元数据(`OPEN_TOOL_ENDPOINTS`,供「接口状态」页探测)与调用函数
|
||||||
|
共用同一份路径定义,避免改路径忘了改探测列表。5 个接口实测线上 **全部 HTTP 200**。
|
||||||
|
- `src/api/client.ts` 重写:`BASE_URL` 回落到 `https://service.xpcool.com`(**不建 .env 也能用**);
|
||||||
|
新增 AbortController 超时(10s)、`describeStatus()` 状态码中文翻译(404 → 「接口尚未上线」)、
|
||||||
|
区分超时与网络不可达。
|
||||||
|
- 改造 6 个页面接入真实后端(均为「本机 / 服务端」双模式,**默认本机、不自动打网络请求**):
|
||||||
|
- `ip-info`:服务端识别的来源 IP + 内网判定,另附本机环境(时区/语言/平台/屏幕/UA)。
|
||||||
|
**后端不返回归属地,界面如实说明,不做前端猜测。**
|
||||||
|
- `uuid`:本机 RFC 4122 v4 / 服务端 GoFrame guid,服务端模式并发 n 次(接口一次只返一个)。
|
||||||
|
- `random-string`(新工具):本机 Web Crypto(已注明取模偏置量级约 2^-26)/ 服务端 `crypto/rand`,
|
||||||
|
长度 1-128,服务端模式数量上限压到 10。
|
||||||
|
- `api-status`(新工具):**串行**探测 5 个接口(并发会干扰延迟读数),
|
||||||
|
`performance.now()` 计时、500ms 封顶刻度条、状态点承载真实语义、窄屏表格转卡片。
|
||||||
|
- `hash`:新增「同时用服务端计算 MD5」开关(默认关闭——算哈希是高频纯本地动作,
|
||||||
|
不该每次点击都多一次网络往返),做**两端口径对比**,比对统一折小写。
|
||||||
|
- `timestamp`:新增服务端时钟卡片,偏移量基于 Unix 秒(**与时区无关**)并补偿半个往返耗时,
|
||||||
|
误差上界 `rtt/2`;偏差 > 2s 提示本机时钟可能不准。
|
||||||
|
- 未实现接口的工具页(exchange-rate / express-query / phone-locate / network-test / speed-test)
|
||||||
|
统一改用 `<BackendPending />`,去掉「后端未就绪就禁用按钮」的旧逻辑。
|
||||||
|
|
||||||
|
### 🧹 修复
|
||||||
|
- **空分类渲染缺陷**:health / study / travel 三个分类无任何工具,却仍渲染出空区块与空标题。
|
||||||
|
现分类精简为 6 个(work / dev / design / life / fun / online),`groupedTools()` 过滤空分组。
|
||||||
|
- `SideNav.vue` 在 `<script setup>` 末尾 `import`(ESM 提升但不规范)、`IpInfoView.vue` 漏 `onBeforeUnmount`
|
||||||
|
导入、`HomeView.vue` 从 `@/api/openTools` 错误导入 `SUPPORTED_LOCALES` —— 均已修正。
|
||||||
|
|
||||||
|
### 📄 备案号
|
||||||
|
- 新增 `src/config.ts` 集中管理站点级常量,页脚展示 `黔ICP备2026003933号` 并链接工信部核验入口
|
||||||
|
(备案号单列一行——它是监管信息,不与产品承诺混在同一组)。
|
||||||
|
- ⚠️ **该值必须与主站 `xpcool.com` 的 `src/config.ts` 的 `SITE.icp` 保持一致,换号时两处一起改。**
|
||||||
|
|
||||||
|
### 📚 文档
|
||||||
|
- `docs/api-contract.md` **按线上实际部署重写**:旧版是一份 `/api/tools/*` 的待实现清单,与实际不符。
|
||||||
|
现明确区分「已实现 5 个」(含请求/响应字段、错误语义、前端封装函数名)与「待实现 5 个」。
|
||||||
|
- `.env.example` 补充默认基址说明;`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 / CloudyDayIcon;ChartIcon 因 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-minify(JS/CSS/HTML 压缩美化)、regex-visual(正则结构树)、markdown-table、api-builder(fetch/curl)、gitignore-gen、code-to-image(canvas 高亮卡片)、color-namer(148 色表)、indent-convert、code-stats(30+ 语言注释规则)、hello-world(33 语言)、json-diff(路径级)、env-parse
|
||||||
|
|
||||||
|
### 🐛 修复
|
||||||
|
- Vue 模板 attribute 内 `\"` 导致编译错误 → 字符串常量移到 script
|
||||||
|
- json-diff `k in b` 类型收窄 → 断言为 Record
|
||||||
|
|
||||||
|
## 2026-08-24 第一批:趣味分类与工具(31 → 40)
|
||||||
|
|
||||||
|
### 🏗 分类
|
||||||
|
- 新增 `dev 开发进阶` / `design 创意设计` / `fun 趣味娱乐`
|
||||||
|
|
||||||
|
### 🆕 工具(9)
|
||||||
|
- dev:sql-format(手写分词 SQL 格式化)、text-diff(LCS 行级 diff)、json-to-code(JSON → TS/Go/Python)
|
||||||
|
- design:gradient-generator(linear/radial/conic + 12 预设)、placeholder-image(PNG/SVG 占位图)、ascii-art(图片转字符画)
|
||||||
|
- fun:morse-code(中文走 pinyin-pro + Web Audio 电报声)、lucky-wheel(canvas 转盘动画)、slogan-generator(5 类文案库)
|
||||||
|
|
||||||
|
### 📄 文档
|
||||||
|
- 新增 `docs/tool-ideas.md` 工具创意库(现状 + 未来创意 + 进度台账)
|
||||||
|
|
||||||
|
### 🐛 修复
|
||||||
|
- `PaletteIcon` 不存在 → 改用 `ViewModuleIcon`(图标引用前需在 node_modules 确认存在)
|
||||||
190
docs/PROJECT_STATE.md
Normal file
190
docs/PROJECT_STATE.md
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
# 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 + TypeScript(strict)+ UnoCSS + TDesign Vue Next + **vue-i18n 11** + pnpm(**禁止换 npm/yarn**)
|
||||||
|
- **国际化**:中(基线 zh-CN)/ 英 / 日 / 韩 四语言,详见第 3 节
|
||||||
|
- **主题**:浅色 / 深色 / 跟随系统 三态
|
||||||
|
- **常用命令**:`pnpm dev`(开发)/ `pnpm build`(vue-tsc + vite 构建)/ `pnpm type-check`
|
||||||
|
- **客户端**:PC + 移动端 H5 响应式(`<768px` 侧边栏变抽屉)
|
||||||
|
- **构建注意**:`pnpm build` 前若 dist 目录文件过多会触发安全删除保护(阈值 50 个/次),先 `mv dist dist.bak-<ts>` 再构建,构建后把备份移走。
|
||||||
|
|
||||||
|
## 2. 当前全貌(截至 2026-09-14,共 6 分类 58 个工具)
|
||||||
|
|
||||||
|
> ⚠️ **分类在本次改造中从 9 个精简为 6 个**:原 health / study / travel 三个分类下没有任何工具,
|
||||||
|
> 却仍然渲染出空区块与空标题,属既有缺陷。现 `groupedTools()` 会过滤空分组,分类与工具一一对应。
|
||||||
|
> 第 4 节待办里的「批次 A/B/C」若要推进,需先重新加入对应分类定义。
|
||||||
|
|
||||||
|
| 分类 | 数量 | 说明 | 工具(path) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| work 工作效率 | 19 | 开发编程、办公文本与文件处理 | image-compress / image-to-ico / 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 |
|
||||||
|
| fun 趣味娱乐 | 1 | 摩斯电码等小玩具 | morse-code |
|
||||||
|
| online 服务端工具 | 8 | 依赖后端接口,**全部标记 `needsNetwork`** | api-status / random-string / ip-info / network-test / speed-test / exchange-rate / express-query / phone-locate |
|
||||||
|
|
||||||
|
> 说明:
|
||||||
|
> - `online` 分类 8 个工具中,`ip-info`(客户端 IP)、`random-string`(随机字符串)、`api-status`(连通性自检)已真正对接后端。
|
||||||
|
> - 另有 3 个**不属于 online 分类**的工具也接入了服务端能力,以「本机 / 服务端」双模式呈现:
|
||||||
|
> `hash`(服务端 MD5 两端口径对比)、`timestamp`(服务端时钟 + 时钟偏移)、`uuid`(服务端唯一 ID)。
|
||||||
|
> 它们默认走本机计算,不自动打网络请求。
|
||||||
|
> - 剩余 5 个后端接口尚未实现,页面用 `BackendPending` 如实标注。
|
||||||
|
|
||||||
|
## 3. 架构与关键机制(新增工具前必读)
|
||||||
|
|
||||||
|
### 3.1 工具注册
|
||||||
|
- **工具注册表** `src/router/tools.ts` 是全站唯一数据源:分类 `toolCategories` + 工具 `tools` 数组,侧边导航/首页/路由/命令面板全部自动生成。新增工具 = 建页面 + 加一条配置(path/name/desc/icon/category/component 懒加载),**禁止手改路由文件**。
|
||||||
|
- `ToolItem.needsNetwork` 标记需联网工具(侧栏圆点 + 首页服务端工具专区)。
|
||||||
|
- `groupedTools()` / `networkTools()` / `searchTools()` 供首页、服务端专区、命令面板使用。
|
||||||
|
|
||||||
|
### 3.2 视觉体系(令牌 → TDesign 桥接 + UI 库主题控制)
|
||||||
|
- **唯一真相源**:`src/assets/styles/tokens.scss` 定义全部 `--tk-*` 令牌(颜色/圆角/间距/字号/z-index/动效/字体栈)。
|
||||||
|
- **桥接层 + UI 库主题控制**:`src/assets/styles/tdesign-theme.scss` 除了把 `--tk-*` 映射到 `--td-*`,
|
||||||
|
还接管两件「UI 库自身」的事:
|
||||||
|
1. **组件尺度**:`--td-comp-size-*`(控件高度阶梯)、`--td-comp-paddingTB/LR-*`、`--td-font-size-*`、
|
||||||
|
`--td-line-height-*`。**TDesign 的输入框 / 按钮 / 下拉 / 日期选择器高度全部取自这些变量**
|
||||||
|
(例如 `.t-input.t-size-l { height: var(--td-comp-size-xl) }`),所以放大控件只需改这一处,
|
||||||
|
**不要再给单个组件写死高度或字号**。
|
||||||
|
2. **色阶**:品牌 / 中性 / 语义的 10 级色板。注意深色下的中性色阶是**反向**的(1 最深 → 14 最浅),
|
||||||
|
且必须显式覆盖,否则组件会退回 TDesign 自带的冷灰。
|
||||||
|
**改主题 = 改 tokens.scss(配色/字号/圆角/间距)+ tdesign-theme.scss(UI 库尺度与色阶)两处**,
|
||||||
|
60+ 个未改动的工具页自动继承 —— 这是低风险全站改造的关键机制。
|
||||||
|
- **硬规则**:自定义样式一律引用 `--tk-*`(或 `--td-*`),**禁止硬编码色值/圆角/阴影/字号**,否则昼夜切换会花掉。
|
||||||
|
- **形状一致性**:全站只用 tokens 里那一套圆角(xs5/sm7/md10/lg12/xl16/2xl22/pill999)。
|
||||||
|
- **单一强调色**:宝石蓝 `#2563eb`。**全站已无任何渐变**(品牌图标为实色圆角方块)。
|
||||||
|
强调色只出现在需要「指向」的地方:hover 的条目、活动导航项、服务端专区底纹、正文链接。
|
||||||
|
- **设计方向(2026-09-14 第二次改版):清晰蓝 · 卡片工作台**。四条硬规则:
|
||||||
|
1. **字号整体放大一档**:正文 16px 起、说明文字不低于 13px(上一版 12/13/14 的密度在 1080p 屏上偏小);
|
||||||
|
2. **控件大于内容**:输入框 / 按钮 / 下拉默认 40px 高、16px 文字,可点区域永远比正文更「实」;
|
||||||
|
3. **容器有边界**:卡片用「白底 + 细描边(`--tk-border`)」与页面底(`--tk-bg`)拉开层次,不再追求无框纸面;
|
||||||
|
4. **主色单一**:全站只有一个强调色。
|
||||||
|
- **发丝线 `--tk-hairline`**:只用于**列表条目之间的分隔线**(历史列表、首页索引行);
|
||||||
|
内容容器边界与功能控件(输入框/按钮/下拉)一律用 `--tk-border`。
|
||||||
|
- **留白节奏**:分区之间用 `--tk-space-9`(68px)分隔,**不画横线** —— 留白本身就是分隔符。
|
||||||
|
窄屏按断点阶梯式收回(`≤1279px` → `≤1023px` → `≤767px`),不要一档切到底。
|
||||||
|
- **排版令牌必须成对使用**:大字号配负字距 + 紧行高(`--tk-track-display/-title` +
|
||||||
|
`--tk-leading-display/-body`),小字号配正字距 + 松行高。混用(大字号配松行高)会让层级立刻垮掉。
|
||||||
|
- **首页是目录,不是卡片墙**:58 个工具用「索引行」呈现(发丝线分隔),不做 58 个描边盒子;
|
||||||
|
图标常态中性灰,主色只在 hover 时出现(58 个图标全染主色会把主色的含义稀释掉);
|
||||||
|
仅「服务端工具专区」用整块底纹 + 卡片与其它分区区分。
|
||||||
|
- **动效档位**:克制(上浮淡入 + 颜色过渡),`prefers-reduced-motion` 下全部降为 0ms。
|
||||||
|
- `v-reveal` 指令(`src/directives/reveal.ts`)做滚动进入,传数字错峰。
|
||||||
|
|
||||||
|
### 3.3 国际化
|
||||||
|
- 基础设施 `src/i18n/index.ts`:`SUPPORTED_LOCALES` / `normalizeLocale()` / `resolveInitialLocale()` /
|
||||||
|
`applyLocaleToDocument()` / `setAppLocale()`;`src/i18n/helpers.ts` 提供**带中文兜底**的取词助手
|
||||||
|
(`toolName/toolDesc/categoryLabel/tOr`),缺词时回落中文而不是显示 key。
|
||||||
|
- 语言包 `src/i18n/locales/{zh-CN,en-US,ja-JP,ko-KR}.ts`,zh-CN 为基线。命名空间:
|
||||||
|
`app / nav / palette / theme / locale / home / category / tool / page / pending / common`。
|
||||||
|
- **新增页面必须补齐四语言的 `page.<path>.*`**,只补中文会导致切到其他语言时显示中文。
|
||||||
|
- 组件内取词统一用 `const { t } = useI18n()`;非组件上下文用 helpers。
|
||||||
|
- **防闪烁**:`index.html` 内联同步脚本、`stores/theme.ts`、`src/i18n/index.ts` **三处逻辑必须同步修改**。
|
||||||
|
|
||||||
|
### 3.4 主题
|
||||||
|
- `stores/theme.ts`:**preference(用户偏好 light/dark/auto)与 mode(实际生效 light/dark)分离**。
|
||||||
|
三态切换、localStorage 持久化(`tool-theme`)、监听 `prefers-color-scheme` 变化。
|
||||||
|
- `html[theme-mode='light'|'dark']` 驱动整套 CSS 变量。
|
||||||
|
|
||||||
|
### 3.5 后端请求
|
||||||
|
- 统一走 `src/api/client.ts`(`api.post/get`),响应契约 `{ code, message, data }`;
|
||||||
|
`BASE_URL` 回落到 `https://service.xpcool.com`,**不建 `.env` 也能用**;默认超时 10s(AbortController)。
|
||||||
|
- 已实现的 5 个接口封装在 `src/api/openTools.ts`,元数据 `OPEN_TOOL_ENDPOINTS` 同时供「接口状态」页探测。
|
||||||
|
|
||||||
|
### 3.6 其他约定
|
||||||
|
- **历史记录**:`useHistory(key)`,key 约定等于路由 path,localStorage 持久化(`tool-history:<key>`),上限 10 条。
|
||||||
|
- **公共组件**:`CopyButton` / `DownloadButton` / `ErrorAlert` / `HistoryPanel` / `UploadDrop` / `AppImageViewer` / `BackendPending`,新页面必须复用。
|
||||||
|
- **页面样式**:`.page-wrap / .page-title / .page-desc / .tool-section / .form-label / .form-actions / .mono-num / .hint-text / .panel / .code-block` 全局已定义,优先复用。
|
||||||
|
- **站点级常量**:`src/config.ts`(目前是备案号 `SITE.icp` 与核验地址)。备案号必须与主站 `xpcool.com` 的 `src/config.ts` 保持一致。
|
||||||
|
|
||||||
|
## 4. 待办(按批推进,见 docs/tool-ideas.md 第三部分)
|
||||||
|
|
||||||
|
- **批次 A 健康生活(12)**:睡眠周期计算 / 喝水提醒 / 体脂率估算 / 卡路里估算 / 预产期计算 / 房贷提前还款 / 生理周期记录 / 运动配速计算 / BMI 对比表 / 热量平衡 / 理想体重 / 步数时长换算
|
||||||
|
- **批次 B 学习办公(10)**:汉字拼音标注(pinyin-pro 已依赖)/ 单词记忆卡 / 随机口算题 / 乘法表练习 / 请假条生成器 / 会议纪要模板 / 邮件模板 / 公文格式助手 / 学习计划表 / 名言摘抄本
|
||||||
|
- **批次 C 旅行出行(6)**:行李清单 / 机票折扣速算 / 签证材料清单 / 地图距离估算(Haversine)/ 时差换算 / 小费计算器
|
||||||
|
- **批次 D 创意设计补全(9)**:阴影生成器 / 圆角生成器 / 调色板生成 / 颜色对比度检查 / 像素画编辑器 / 纹理生成器 / 文字效果生成 / Emoji 放大镜 / 图片九宫格切图
|
||||||
|
- **批次 E 趣味娱乐补全(14)**:加密暗号机 / Emoji 密文 / 藏头诗生成 / 绕口令生成器 / 塔罗牌占卜 / 星座运势 / 名字评分 / 电子木鱼 / 猜成语小游戏 / 音效合成器 / 节日倒计时 / 情侣默契问答 / 名言警句生成 / 骰子塔
|
||||||
|
|
||||||
|
## 5. 后端契约(service.xpcool.com)
|
||||||
|
|
||||||
|
- **契约文档**:`docs/api-contract.md`(2026-09-14 **已按线上实际部署重写**,旧版 `/api/tools/*` 是待实现清单,与实际不符)。
|
||||||
|
- **实际前缀**:`/api/service/open/tools/*`,**一律 POST、URL 不带参数、入参全走 JSON body**(本项目 2026-08-27 起的强制接口规范)。
|
||||||
|
- **已实现且实测可用(5 个)**:`ip` / `time` / `uuid` / `md5` / `random`(2026-09-14 `curl` 直连全部 HTTP 200)。
|
||||||
|
- **未实现(5 个)**:exchange-rate / express-query / phone-locate / network-test / speed-test。
|
||||||
|
对应页面用 `BackendPending` 组件如实说明现状,后端实现后改开关即可生效。
|
||||||
|
- **接入方式**:`BASE_URL` 已回落到 `https://service.xpcool.com`,**不建 `.env` 也能正常调用**;
|
||||||
|
仅在需要指向本地/测试后端时才覆盖 `VITE_API_BASE`。
|
||||||
|
- **新增接口的完整流程**:① 在 `src/api/openTools.ts` 补 `OpenEndpoint` 元数据 + 调用函数
|
||||||
|
(元数据与路径共用一份定义,「接口状态」页与首页计数会自动同步);② 在工具页接入;
|
||||||
|
③ 补四语言 `page.*` 文案;④ 本文件与 `api-contract.md` 同步更新。
|
||||||
|
|
||||||
|
## 6. 技术约定与坑(跨会话必须记住)
|
||||||
|
|
||||||
|
### 6.1 通用
|
||||||
|
- **TDesign 图标**:引用前先在 `node_modules/tdesign-icons-vue-next/esm/components/` 确认存在(如 `PaletteIcon` 不存在,只有 `Palette1Icon`;`GlobeIcon` / `BulbIcon` / `FontIcon` 也**不存在**,可用 `TranslateIcon` / `ServerIcon` / `TextboxIcon`)。
|
||||||
|
- **Vue 模板 attribute**:内嵌字符串不能直接写 `\"`(HTML 解析器会把 `"` 当属性结束符导致编译错误),字符串常量移到 `<script>` 定义。
|
||||||
|
- **`in` 运算符**:`k in obj` 中 obj 需先断言为 `Record<string, unknown>`,否则 strict 模式 TS2638/TS18048。
|
||||||
|
- **构建安全删除保护**:`pnpm build` 清理 dist 会触发沙箱批量删除拦截(>50 文件),先 `mv dist dist.bak-<ts>` 再构建。
|
||||||
|
- **备份目录同样删不掉**:`dist.bak-*` 的递归删除也会被 bulk guard 拦(`SAFE_DELETE_BULK_GUARD_ERROR`),
|
||||||
|
因此多次构建会堆积若干 `dist.bak-*`,属正常现象;需要清理时逐个目录删,或交由人手动处理。
|
||||||
|
- **大整数**:进制转换等工具禁用 `parseInt/Number`,用 BigInt 逐位解析(现有 radix-convert 已实现)。
|
||||||
|
- **pnpm 调用方式**:`corepack` 的 `pnpm` shell wrapper 在 Git Bash 下会把 `/c/...` 误算成 `E:\c\...`
|
||||||
|
(报 `Cannot find module 'E:\c\...\corepack\dist\pnpm.js'`)。绕过 wrapper,显式调用:
|
||||||
|
```bash
|
||||||
|
"C:/Users/xxl/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" \
|
||||||
|
"C:/Users/xxl/.workbuddy/binaries/node/versions/22.22.2-3/node_modules/corepack/dist/pnpm.js" build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 样式与主题
|
||||||
|
- **禁止硬编码色值**:一律用 `--tk-*`(自定义样式)或 `--td-*`(TDesign 组件覆盖)。
|
||||||
|
- **UnoCSS `panel` 快捷类自带内边距**(`p-5 md:p-7`,编辑感改版后放大一档)且描边为发丝线。
|
||||||
|
页面 scoped 样式(如 `.block[data-v-*]`)权重更高,需要单独覆盖 padding 时照常写即可。
|
||||||
|
- **卡片内边距的覆盖写法**:`.t-card .t-card__body`(两个类)比 TDesign 自身的 `.t-card__body` 高一级,
|
||||||
|
能稳定覆盖;只写一个类会被 TDesign 的规则压掉。窄屏需在 `@media (max-width: 767px)` 里收回。
|
||||||
|
- **`--td-radius-*` 刻意不跟随主题**(浅深共用),改圆角请改 `tokens.scss` 的 `--tk-radius-*`。
|
||||||
|
- **canvas 绘制**:注意主题切换,尽量用令牌色或中性色,不要写死黑白。
|
||||||
|
- **头号坑:`theme-mode` 选择器必须写 `:root[theme-mode='dark']`,不能写 `html[theme-mode='dark']`**。
|
||||||
|
TDesign 自己的浅/深色变量声明用的是 `:root[theme-mode=...]`(权重 0,2,0),
|
||||||
|
而 `html[theme-mode=...]` 只有 (0,1,1) —— **生产构建里我们的 CSS 虽然排在 vendor CSS 之后,仍会被 UI 库盖掉**。
|
||||||
|
表现:深色下组件用回 TDesign 自带灰(卡片 `#242424`、描边 `#ddd`),只有少量手写规则生效。
|
||||||
|
新增主题变量时请放进 tokens.scss / tdesign-theme.scss 里那两个 `:root[theme-mode=...]` 块。
|
||||||
|
- **验证主题是否真的生效**:切完 `theme-mode` 后**不要**只看 `getComputedStyle(document.body).backgroundColor`,
|
||||||
|
它可能命中浏览器的计算值缓存、读出「上一次主题」的颜色。可靠做法:插入探针元素读
|
||||||
|
(`div.style.backgroundColor = 'var(--tk-bg)'` 后读 computed),或**刷新页面后再读**。
|
||||||
|
- **控件尺度统一由 `--td-comp-size-*` 控制**:放大/缩小按钮、输入框、下拉请改 `tdesign-theme.scss` 的尺度段,
|
||||||
|
不要给单个组件写死 `height`(会与 UI 库其它组件脱节)。
|
||||||
|
|
||||||
|
### 6.3 国际化
|
||||||
|
- **`html` 的 `lang` 属性写的是完整标签**(`ja-JP` / `ko-KR`),因此 `tokens.scss` 里的字体栈选择器
|
||||||
|
必须用前缀匹配 `html[lang^='ja']`,**不能用等值匹配**,否则不生效。
|
||||||
|
- **防闪烁三处同步**:`index.html` 内联脚本、`stores/theme.ts`、`src/i18n/index.ts` 三处逻辑必须一起改。
|
||||||
|
- **取词助手带中文兜底**:`src/i18n/helpers.ts` 在缺词时回落中文而不是显示 key,因此漏补翻译不会显示成
|
||||||
|
`page.xxx.yyy`,但会静默显示中文 —— **新增页面仍必须补齐四语言**。
|
||||||
|
|
||||||
|
## 7. 给下一个 AI 的接手清单
|
||||||
|
|
||||||
|
1. 读本文件(状态全貌)→ `.workbuddy/memory/CHANGELOG.md`(最近的变更)→ `docs/api-contract.md`(后端契约)
|
||||||
|
2. 运行 `pnpm build` 确认基线(含 `vue-tsc --noEmit` 类型检查)
|
||||||
|
3. 如需继续实现工具:从第 4 节待办批次挑一批,按 `docs/tool-ideas.md` 的工具描述实现;
|
||||||
|
⚠️ 批次 A/B/C 需先在 `toolCategories` 里重新加回 health / study / travel 分类定义
|
||||||
|
4. 完成后:更新 `.workbuddy/memory/CHANGELOG.md` + 本文件 + `.workbuddy/memory/YYYY-MM-DD.md`
|
||||||
|
|
||||||
|
## 8. 已知未完成项(2026-09-14 遗留)
|
||||||
|
|
||||||
|
- **存量工具页的页内文案仍是中文硬编码**。本次已完成多语言的页面:全站外壳(导航/首页/分类/工具名与描述/
|
||||||
|
主题/语言切换/待接入提示)+ 新建与改造的 6 个页面(ip-info / uuid / random-string / api-status / hash / timestamp)。
|
||||||
|
其余约 50 个存量工具页的**页内标签文字**(如按钮名、字段名)尚未迁移到 i18n,
|
||||||
|
切换语言时会出现「外壳英文、页内中文」的混排。机制已就绪(helpers 有中文兜底),补 key 即可,属机械工作。
|
||||||
|
- 汇率/快递/手机号归属地/网络测试/网络测速 5 个工具等待后端接口实现。
|
||||||
|
- home.xpcool.com 子站尚未展示备案号(tool 站已于本次补齐)。
|
||||||
157
docs/api-contract.md
Normal file
157
docs/api-contract.md
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# 后端接口契约 · tool.xpcool.com
|
||||||
|
|
||||||
|
后端为 `service.xpcool.com`(GoFrame v2)。前端统一走 `src/api/client.ts`,
|
||||||
|
已实现的接口封装在 `src/api/openTools.ts`。
|
||||||
|
|
||||||
|
> ⚠️ 本文件曾经是一份"待实现清单"(`/api/tools/*`)。2026-09-14 已按**线上实际部署**重写:
|
||||||
|
> 落地在 `/api/service/open/tools/*` 的 5 个接口**已经上线可用**,其余仍是待实现。
|
||||||
|
|
||||||
|
## 通用约定
|
||||||
|
|
||||||
|
- **Base URL**:环境变量 `VITE_API_BASE`,默认 `https://service.xpcool.com`(见 `.env.example`)。
|
||||||
|
未配置时前端仍可用默认值,不会白屏。
|
||||||
|
- **统一响应格式**(GoFrame `MiddlewareHandlerResponse` 信封):
|
||||||
|
```json
|
||||||
|
{ "code": 0, "message": "OK", "data": { ... } }
|
||||||
|
```
|
||||||
|
- `code = 0` 成功;非 0 失败,`message` 为可读错误信息(前端直接展示给用户)。
|
||||||
|
- **方法**:**一律 POST**,URL 不带任何参数(查询参数、路径参数都禁止),入参全部走 JSON Body。
|
||||||
|
这是本项目 2026-08-27 起的强制接口设计规范。
|
||||||
|
- **鉴权**:无(公开工具站)。
|
||||||
|
- **CORS**:后端 `CORS` 中间件已开启,允许跨域。
|
||||||
|
- **超时**:前端 `DEFAULT_TIMEOUT = 10000ms`,超时与网络不可达在 `describeStatus()` 中区分提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、已实现接口(5 个)
|
||||||
|
|
||||||
|
前缀:`/api/service/open/tools`
|
||||||
|
|
||||||
|
| # | 路径 | 说明 | 前端封装 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | `POST /api/service/open/tools/ip` | 客户端 IP + 内网判定 | `fetchClientIp()` |
|
||||||
|
| 2 | `POST /api/service/open/tools/time` | 服务端当前时间 | `fetchServerTime()` |
|
||||||
|
| 3 | `POST /api/service/open/tools/uuid` | 唯一 ID | `fetchUuid(short)` |
|
||||||
|
| 4 | `POST /api/service/open/tools/md5` | 文本 MD5 | `fetchMd5(text)` |
|
||||||
|
| 5 | `POST /api/service/open/tools/random` | 随机字符串 | `fetchRandomString(length, type)` |
|
||||||
|
|
||||||
|
后端源码位置:`api/open/tools/<name>/<name>.go` + `internal/controller/open/<name>.go`。
|
||||||
|
路由挂载:`internal/cmd/cmd.go` 中 `s.Group("/api/service/open", ...)`。
|
||||||
|
|
||||||
|
### 1. `POST /tools/ip`
|
||||||
|
|
||||||
|
请求体:`{}`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "message": "OK", "data": { "ip": "1.2.3.4", "internal": false } }
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ip | string | 服务端看到的来源 IP |
|
||||||
|
| internal | bool | 是否为内网/私网地址 |
|
||||||
|
|
||||||
|
> 注意:**不返回归属地**(国家/省/市/ISP)。前端「IP 信息查询」页对此如实说明,不做猜测。
|
||||||
|
> 若要补归属地,需后端接入离线 IP 库后新增字段,前端预留位置。
|
||||||
|
|
||||||
|
### 2. `POST /tools/time`
|
||||||
|
|
||||||
|
请求体:`{}`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "OK",
|
||||||
|
"data": { "timestamp": 1757788000, "dateTime": "2026-09-14 01:45:00", "date": "2026-09-14" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| timestamp | int | Unix 秒 |
|
||||||
|
| dateTime | string | `2006-01-02 15:04:05`(时区取决于服务端配置,前端不推断) |
|
||||||
|
| date | string | `2006-01-02` |
|
||||||
|
|
||||||
|
前端用它做两件事:显示服务端时间、估算本机时钟偏移。
|
||||||
|
偏移量基于 `timestamp`(Unix 秒,与时区无关),并补偿半个往返耗时,误差上界为 `rtt/2`。
|
||||||
|
|
||||||
|
### 3. `POST /tools/uuid`
|
||||||
|
|
||||||
|
请求体:`{ "short": false }`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "message": "OK", "data": { "uuid": "a1b2c3d4e5f6..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| short | bool | true 返回 8 位短码;false 返回 32 位 ID |
|
||||||
|
|
||||||
|
前端服务端模式下一律**并发**调用 n 次(接口一次只返回一个),因此数量上限压到 10。
|
||||||
|
|
||||||
|
### 4. `POST /tools/md5`
|
||||||
|
|
||||||
|
请求体:`{ "text": "ping" }`(`text` 非空,后端有校验)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "message": "OK", "data": { "md5": "b6f3a1..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
用于「哈希计算」页的**两端口径对比**:同一段文本前端(spark-md5)与后端各算一次。
|
||||||
|
比对统一折成小写,大小写只是展示层选择。
|
||||||
|
|
||||||
|
### 5. `POST /tools/random`
|
||||||
|
|
||||||
|
请求体:`{ "length": 8, "type": "alnum" }`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "message": "OK", "data": { "value": "aB3xK9Qz" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| length | int | `1 - 128`(`v:"min:1|max:128"`,前端常量 `RANDOM_MAX_LENGTH = 128`) |
|
||||||
|
| type | string | `alnum` \| `digits` \| `letters`(`v:"in:alnum,digits,letters"`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、待实现接口
|
||||||
|
|
||||||
|
以下工具在「在线服务」分类中,但后端尚无对应接口。
|
||||||
|
前端已用 `BackendPending` 组件如实标注「接口尚未上线」,**不会伪装成可用**;
|
||||||
|
后端实现后把对应页面的 `BACKEND_*_READY` 常量改为 `true`(或去掉开关)即可自动生效。
|
||||||
|
|
||||||
|
| 工具 | 建议路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 汇率换算 | `POST /api/service/open/tools/exchange-rate` | `{ from, to, amount }` |
|
||||||
|
| 快递查询 | `POST /api/service/open/tools/express-query` | `{ no }` |
|
||||||
|
| 手机号归属地 | `POST /api/service/open/tools/phone-locate` | `{ phone }` |
|
||||||
|
| 网络测试 | `POST /api/service/open/tools/network-test` | `{ type: ping\|tcp\|dns, host, port, timeout }` |
|
||||||
|
| 网络测速 | `POST /api/service/open/tools/speedtest/*` | 下载返回随机二进制流,上传接收二进制流 |
|
||||||
|
|
||||||
|
上述字段设计沿用旧契约(见 git 历史中的 2026-08-24 版本),仅路径前缀从 `/api/tools/*` 迁到
|
||||||
|
`/api/service/open/tools/*`,并统一改为 POST + 纯 body 传参。
|
||||||
|
|
||||||
|
## 三、连通性自检
|
||||||
|
|
||||||
|
前端内置「服务端接口状态」页(`/api-status`),逐个探测上面 5 个已实现接口的
|
||||||
|
可用性与响应耗时。接口清单来自 `OPEN_TOOL_ENDPOINTS`,**新增接口只需在 `openTools.ts`
|
||||||
|
补一条元数据**,「接口状态」页与首页的接口计数会自动同步。
|
||||||
|
|
||||||
|
2026-09-14 实测(`curl` 直连线上):
|
||||||
|
|
||||||
|
| 接口 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/tools/ip` | HTTP 200 |
|
||||||
|
| `/tools/time` | HTTP 200 |
|
||||||
|
| `/tools/uuid` | HTTP 200 |
|
||||||
|
| `/tools/md5` | HTTP 200 |
|
||||||
|
| `/tools/random` | HTTP 200 |
|
||||||
|
|
||||||
|
## 四、前端接入说明
|
||||||
|
|
||||||
|
1. 复制 `.env.example` 为 `.env`,按需覆盖 `VITE_API_BASE`(不填也能跑,默认线上地址)。
|
||||||
|
2. `pnpm dev` / `pnpm build`。
|
||||||
|
3. 新增后端接口时:
|
||||||
|
- 在 `src/api/openTools.ts` 补 `OpenEndpoint` 元数据 + 调用函数;
|
||||||
|
- 在对应工具页接入,并补 `src/i18n/locales/*.ts` 的 `page.*` 文案(**四语言都要补**)。
|
||||||
133
docs/tool-ideas.md
Normal file
133
docs/tool-ideas.md
Normal 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:健康生活(health,12 个)
|
||||||
|
| 工具 | 说明 | 难度 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 睡眠周期计算 | 按入睡时间推荐起床时刻(90 分钟周期) | ★ |
|
||||||
|
| 喝水提醒 | 按体重算每日饮水量 + 分布时间 | ★ |
|
||||||
|
| 体脂率估算 | 性别/围度公式估算体脂率 | ★ |
|
||||||
|
| 卡路里估算 | 常见食物热量查询(内置表) | ★ |
|
||||||
|
| 预产期计算 | 末次月经推算预产期与孕周 | ★ |
|
||||||
|
| 房贷提前还款 | 提前还贷金额与节省利息测算 | ★★ |
|
||||||
|
| 生理周期记录 | 周期推算(本地存储) | ★★ |
|
||||||
|
| 运动配速计算 | 距离/时间 → 配速换算 | ★ |
|
||||||
|
| BMI 对比表 | 各标准 BMI 分级对照(已有 BMI,可增强) | ★ |
|
||||||
|
| 热量平衡计算 | 基础代谢 + 活动系数 → 每日所需热量 | ★★ |
|
||||||
|
| 理想体重 | 多种理想体重公式对照 | ★ |
|
||||||
|
| 步数时长换算 | 步数 → 距离 / 消耗热量 | ★ |
|
||||||
|
|
||||||
|
### 批次 B:学习办公(study,10 个)
|
||||||
|
| 工具 | 说明 | 难度 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 汉字拼音标注 | 基于 pinyin-pro 给整段中文标拼音 | ★ |
|
||||||
|
| 单词记忆卡 | 内置常用词表,简易间隔重复 | ★★ |
|
||||||
|
| 随机口算题 | 四则运算题批量生成(可打印) | ★ |
|
||||||
|
| 乘法表练习 | 9×9 乘法表随机练习计时 | ★ |
|
||||||
|
| 请假条生成器 | 选模板填信息生成请假条文本 | ★ |
|
||||||
|
| 会议纪要模板 | 常用会议纪要结构模板 | ★ |
|
||||||
|
| 邮件模板 | 商务邮件常用句型模板 | ★ |
|
||||||
|
| 公文格式助手 | 标题/正文/落款常用格式速查 | ★ |
|
||||||
|
| 学习计划表 | 按天生成复习/学习计划 | ★ |
|
||||||
|
| 名言摘抄本 | 分类名言收藏(本地存储) | ★ |
|
||||||
|
|
||||||
|
### 批次 C:旅行出行(travel,6 个)
|
||||||
|
| 工具 | 说明 | 难度 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 行李清单 | 出行场景勾选清单(可勾选保存) | ★ |
|
||||||
|
| 机票折扣速算 | 全价/折扣率 → 折后价 | ★ |
|
||||||
|
| 签证材料清单 | 常见目的地材料清单 | ★ |
|
||||||
|
| 地图距离估算 | 经纬度两点距离(Haversine) | ★ |
|
||||||
|
| 时差换算 | 两地时差与会议时间推荐 | ★ |
|
||||||
|
| 小费计算器 | 金额 + 比例 → 小费与总额 | ★ |
|
||||||
|
|
||||||
|
### 批次 D:创意设计补全(design,9 个)
|
||||||
|
| 工具 | 说明 | 难度 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 阴影生成器 | box-shadow 可视化调参 + 预设 | ★ |
|
||||||
|
| 圆角生成器 | border-radius 四角独立调节 | ★ |
|
||||||
|
| 调色板生成 | 基于主色的互补/三角/近似色板 | ★ |
|
||||||
|
| 颜色对比度检查 | WCAG 对比度评级 | ★ |
|
||||||
|
| 像素画编辑器 | 网格点画,导出 PNG | ★★ |
|
||||||
|
| 纹理生成器 | 条纹/网格/点阵/噪点 CSS 背景 | ★★ |
|
||||||
|
| 文字效果生成 | 立体字/渐变字/描边字 CSS 输出 | ★★ |
|
||||||
|
| Emoji 放大镜 | 超大 emoji 预览 + 关键词搜索 | ★ |
|
||||||
|
| 图片九宫格切图 | 一张图切成 3×3 网格导出 | ★★ |
|
||||||
|
|
||||||
|
### 批次 E:趣味娱乐补全(fun,14 个)
|
||||||
|
| 工具 | 说明 | 难度 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 加密暗号机 | 凯撒/维吉尼亚/栅栏密码编解码 | ★ |
|
||||||
|
| Emoji 密文 | 文本 → emoji 象形编码 | ★ |
|
||||||
|
| 藏头诗生成 | 内置意象词库拼接生成 | ★★ |
|
||||||
|
| 绕口令生成器 | 内置素材库随机组合 | ★ |
|
||||||
|
| 塔罗牌占卜 | 本地随机抽牌 + 牌意说明 | ★ |
|
||||||
|
| 星座运势 | 按生日算星座 + 本地随机运势 | ★ |
|
||||||
|
| 名字评分 | 按笔画/谐音/字义趣味评分 | ★★ |
|
||||||
|
| 电子木鱼 | 点击敲木鱼,功德 +1(Web 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`。
|
||||||
52
index.html
52
index.html
@ -1,18 +1,60 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" theme-mode="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="纯前端在线工具箱:图片压缩/裁剪、二维码、JSON 格式化、时间戳、Base64、JWT、Cron、进制转换、哈希、身份证、地址、URL 解析、OCR 等 14 个实用工具,所有数据仅在浏览器本地处理。"
|
content="纯前端在线工具箱:图片压缩与裁剪、二维码、JSON 格式化、时间戳、Base64、JWT、Cron、进制转换、哈希、身份证解析、OCR 等 56 个实用工具,支持中英日韩四种语言与日间夜间主题,数据全部在浏览器本地处理。"
|
||||||
/>
|
/>
|
||||||
|
<meta name="theme-color" content="#f4f6fb" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#0b0f18" media="(prefers-color-scheme: dark)" />
|
||||||
<title>在线工具箱 · Tool</title>
|
<title>在线工具箱 · Tool</title>
|
||||||
<!-- 防闪烁:应用挂载前先应用本地保存的主题(TDesign 依赖 html[theme-mode] 属性) -->
|
<!--
|
||||||
|
防闪烁(FOUC)内联脚本:必须在样式与模块加载之前同步执行。
|
||||||
|
1) 主题:TDesign 依赖 html[theme-mode] 属性,"auto" 或不合法值按系统偏好解析,
|
||||||
|
解析结果回写 theme-mode,保证首屏就是正确配色。
|
||||||
|
2) 语言:把 html[lang] 先设对,字体栈(见 tokens.scss)才不会先按中文渲染再跳变。
|
||||||
|
这段脚本与 src/stores/theme.ts、src/i18n/index.ts 中的读取逻辑保持一致,
|
||||||
|
改动存储 key 或取值规则时三处必须同步。
|
||||||
|
-->
|
||||||
<script>
|
<script>
|
||||||
;(function () {
|
;(function () {
|
||||||
var theme = localStorage.getItem('tool-theme') || 'light'
|
var SUPPORTED = ['zh-CN', 'en-US', 'ja-JP', 'ko-KR']
|
||||||
document.documentElement.setAttribute('theme-mode', theme)
|
|
||||||
|
function readStorage(key) {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key)
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 主题 ----
|
||||||
|
var pref = readStorage('tool-theme')
|
||||||
|
var dark = false
|
||||||
|
try {
|
||||||
|
dark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
} catch (e) {
|
||||||
|
dark = false
|
||||||
|
}
|
||||||
|
var mode = pref === 'light' || pref === 'dark' ? pref : dark ? 'dark' : 'light'
|
||||||
|
document.documentElement.setAttribute('theme-mode', mode)
|
||||||
|
|
||||||
|
// ---- 语言 ----
|
||||||
|
var locale = readStorage('tool-locale')
|
||||||
|
if (SUPPORTED.indexOf(locale) === -1) {
|
||||||
|
var nav = (navigator.language || 'zh-CN').toLowerCase()
|
||||||
|
var primary = nav.split('-')[0]
|
||||||
|
locale = 'zh-CN'
|
||||||
|
for (var i = 0; i < SUPPORTED.length; i++) {
|
||||||
|
if (SUPPORTED[i].toLowerCase().split('-')[0] === primary) {
|
||||||
|
locale = SUPPORTED[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.documentElement.setAttribute('lang', locale)
|
||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -29,6 +29,7 @@
|
|||||||
"tesseract.js": "^5.1.1",
|
"tesseract.js": "^5.1.1",
|
||||||
"tesseract.js-core": "^5.1.0",
|
"tesseract.js-core": "^5.1.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
"vue-i18n": "^11.4.10",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
@ -59,6 +59,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.41(typescript@5.7.3)
|
version: 3.5.41(typescript@5.7.3)
|
||||||
|
vue-i18n:
|
||||||
|
specifier: ^11.4.10
|
||||||
|
version: 11.4.10(vue@3.5.41(typescript@5.7.3))
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.5.0
|
specifier: ^4.5.0
|
||||||
version: 4.6.4(vue@3.5.41(typescript@5.7.3))
|
version: 4.6.4(vue@3.5.41(typescript@5.7.3))
|
||||||
@ -442,6 +445,22 @@ packages:
|
|||||||
'@iconify/utils@2.3.0':
|
'@iconify/utils@2.3.0':
|
||||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||||
|
|
||||||
|
'@intlify/core-base@11.4.10':
|
||||||
|
resolution: {integrity: sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
|
'@intlify/devtools-types@11.4.10':
|
||||||
|
resolution: {integrity: sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
|
'@intlify/message-compiler@11.4.10':
|
||||||
|
resolution: {integrity: sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
|
'@intlify/shared@11.4.10':
|
||||||
|
resolution: {integrity: sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@ -460,7 +479,6 @@ packages:
|
|||||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@parcel/watcher-android-arm64@2.6.0':
|
'@parcel/watcher-android-arm64@2.6.0':
|
||||||
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
|
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
|
||||||
@ -491,42 +509,36 @@ packages:
|
|||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm-musl@2.6.0':
|
'@parcel/watcher-linux-arm-musl@2.6.0':
|
||||||
resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
|
resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm64-glibc@2.6.0':
|
'@parcel/watcher-linux-arm64-glibc@2.6.0':
|
||||||
resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
|
resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm64-musl@2.6.0':
|
'@parcel/watcher-linux-arm64-musl@2.6.0':
|
||||||
resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
|
resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@parcel/watcher-linux-x64-glibc@2.6.0':
|
'@parcel/watcher-linux-x64-glibc@2.6.0':
|
||||||
resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
|
resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@parcel/watcher-linux-x64-musl@2.6.0':
|
'@parcel/watcher-linux-x64-musl@2.6.0':
|
||||||
resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
|
resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@parcel/watcher-win32-arm64@2.6.0':
|
'@parcel/watcher-win32-arm64@2.6.0':
|
||||||
resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
|
resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
|
||||||
@ -593,79 +605,66 @@ packages:
|
|||||||
resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==}
|
resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.62.5':
|
'@rollup/rollup-linux-arm-musleabihf@4.62.5':
|
||||||
resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==}
|
resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.62.5':
|
'@rollup/rollup-linux-arm64-gnu@4.62.5':
|
||||||
resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==}
|
resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.62.5':
|
'@rollup/rollup-linux-arm64-musl@4.62.5':
|
||||||
resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==}
|
resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.62.5':
|
'@rollup/rollup-linux-loong64-gnu@4.62.5':
|
||||||
resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==}
|
resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.62.5':
|
'@rollup/rollup-linux-loong64-musl@4.62.5':
|
||||||
resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==}
|
resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.62.5':
|
'@rollup/rollup-linux-ppc64-gnu@4.62.5':
|
||||||
resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==}
|
resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.62.5':
|
'@rollup/rollup-linux-ppc64-musl@4.62.5':
|
||||||
resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==}
|
resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.62.5':
|
'@rollup/rollup-linux-riscv64-gnu@4.62.5':
|
||||||
resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==}
|
resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.62.5':
|
'@rollup/rollup-linux-riscv64-musl@4.62.5':
|
||||||
resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==}
|
resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.62.5':
|
'@rollup/rollup-linux-s390x-gnu@4.62.5':
|
||||||
resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==}
|
resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.62.5':
|
'@rollup/rollup-linux-x64-gnu@4.62.5':
|
||||||
resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==}
|
resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.62.5':
|
'@rollup/rollup-linux-x64-musl@4.62.5':
|
||||||
resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==}
|
resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.62.5':
|
'@rollup/rollup-openbsd-x64@4.62.5':
|
||||||
resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==}
|
resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==}
|
||||||
@ -1522,6 +1521,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.4.37
|
vue: ^3.4.37
|
||||||
|
|
||||||
|
vue-i18n@11.4.10:
|
||||||
|
resolution: {integrity: sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.0
|
||||||
|
|
||||||
vue-router@4.6.4:
|
vue-router@4.6.4:
|
||||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -1771,6 +1776,24 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@intlify/core-base@11.4.10':
|
||||||
|
dependencies:
|
||||||
|
'@intlify/devtools-types': 11.4.10
|
||||||
|
'@intlify/message-compiler': 11.4.10
|
||||||
|
'@intlify/shared': 11.4.10
|
||||||
|
|
||||||
|
'@intlify/devtools-types@11.4.10':
|
||||||
|
dependencies:
|
||||||
|
'@intlify/core-base': 11.4.10
|
||||||
|
'@intlify/shared': 11.4.10
|
||||||
|
|
||||||
|
'@intlify/message-compiler@11.4.10':
|
||||||
|
dependencies:
|
||||||
|
'@intlify/shared': 11.4.10
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
'@intlify/shared@11.4.10': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@ -2867,6 +2890,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.41(typescript@5.7.3)
|
vue: 3.5.41(typescript@5.7.3)
|
||||||
|
|
||||||
|
vue-i18n@11.4.10(vue@3.5.41(typescript@5.7.3)):
|
||||||
|
dependencies:
|
||||||
|
'@intlify/core-base': 11.4.10
|
||||||
|
'@intlify/devtools-types': 11.4.10
|
||||||
|
'@intlify/shared': 11.4.10
|
||||||
|
'@vue/devtools-api': 6.6.4
|
||||||
|
vue: 3.5.41(typescript@5.7.3)
|
||||||
|
|
||||||
vue-router@4.6.4(vue@3.5.41(typescript@5.7.3)):
|
vue-router@4.6.4(vue@3.5.41(typescript@5.7.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
|
|||||||
161
src/api/client.ts
Normal file
161
src/api/client.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* 统一后端 API 客户端(在线服务分类工具专用)。
|
||||||
|
*
|
||||||
|
* ▍接口基地址
|
||||||
|
* 通过环境变量 VITE_API_BASE 配置,未配置时回落到线上地址,保证开箱可用。
|
||||||
|
* 本地联调可在 .env 中覆盖为 http://127.0.0.1:10100(service 开发端口)。
|
||||||
|
*
|
||||||
|
* ▍路径前缀
|
||||||
|
* service.xpcool.com 把开放接口挂在 /api/service/open 分组下,
|
||||||
|
* 所以工具接口的完整路径是 /api/service/open/tools/<name>。
|
||||||
|
* 前缀如果调整,只改这里一处即可。
|
||||||
|
*
|
||||||
|
* ▍统一响应契约(GoFrame 默认信封):
|
||||||
|
* { "code": 0, "message": "OK", "data": { ... } }
|
||||||
|
* code === 0 表示成功;非 0 表示失败,message 为可读错误信息(前端直接展示)。
|
||||||
|
*
|
||||||
|
* ▍约定:新建接口一律 POST,URL 不带任何参数,入参全部走 JSON body。
|
||||||
|
*/
|
||||||
|
|
||||||
|
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)?.replace(/\/+$/, '') ||
|
||||||
|
'https://service.xpcool.com'
|
||||||
|
|
||||||
|
/** 开放接口分组前缀(service 的路由组,见 internal/cmd/cmd.go) */
|
||||||
|
export const OPEN_API_PREFIX = '/api/service/open'
|
||||||
|
|
||||||
|
/** 后端是否已配置接入地址(存在默认地址后恒为 true,保留导出以兼容既有页面) */
|
||||||
|
export const backendReady = BASE_URL !== ''
|
||||||
|
|
||||||
|
export { BASE_URL }
|
||||||
|
|
||||||
|
/** 默认请求超时(毫秒)。超过则中断并提示,避免请求悬挂导致界面一直转圈 */
|
||||||
|
const DEFAULT_TIMEOUT = 10000
|
||||||
|
|
||||||
|
interface Envelope<T> {
|
||||||
|
code: number
|
||||||
|
message?: string
|
||||||
|
data?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 HTTP 状态码翻译成对用户有意义的中文提示。
|
||||||
|
* 直接抛「HTTP 404」对使用者毫无帮助,这里统一转成能指导下一步的说法。
|
||||||
|
*/
|
||||||
|
function describeStatus(status: number, serverMessage?: string): string {
|
||||||
|
if (serverMessage) return serverMessage
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
return '请求参数不合法'
|
||||||
|
case 404:
|
||||||
|
return '接口尚未上线(服务端未实现该路径)'
|
||||||
|
case 405:
|
||||||
|
return '请求方法不被支持'
|
||||||
|
case 429:
|
||||||
|
return '请求过于频繁,请稍后再试'
|
||||||
|
case 500:
|
||||||
|
case 502:
|
||||||
|
case 503:
|
||||||
|
case 504:
|
||||||
|
return '服务端暂时不可用,请稍后再试'
|
||||||
|
default:
|
||||||
|
return `请求失败(HTTP ${status})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
method: 'GET' | 'POST',
|
||||||
|
path: string,
|
||||||
|
params?: Record<string, string | number | undefined>,
|
||||||
|
body?: unknown,
|
||||||
|
timeout = DEFAULT_TIMEOUT,
|
||||||
|
): Promise<ApiResult<T>> {
|
||||||
|
if (!backendReady) {
|
||||||
|
return { ok: false, data: null, message: '后端接口地址未配置', status: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时中断:AbortController 在 finally 中清理定时器,避免泄漏
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = window.setTimeout(() => controller.abort(), timeout)
|
||||||
|
|
||||||
|
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),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
let json: Envelope<T> | null = null
|
||||||
|
try {
|
||||||
|
json = (await res.json()) as Envelope<T>
|
||||||
|
} catch {
|
||||||
|
// 响应不是 JSON(如网关返回 HTML 错误页),继续走下面的状态码分支
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
data: null,
|
||||||
|
message: describeStatus(res.status, json?.message),
|
||||||
|
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 (err) {
|
||||||
|
// 区分「主动超时」与「网络不可达」:两者的排查方向完全不同
|
||||||
|
const aborted = err instanceof DOMException && err.name === 'AbortError'
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
data: null,
|
||||||
|
message: aborted ? '请求超时,请检查网络后重试' : '网络错误:无法连接后端服务',
|
||||||
|
status: -1,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get<T>(
|
||||||
|
path: string,
|
||||||
|
params?: Record<string, string | number | undefined>,
|
||||||
|
timeout?: number,
|
||||||
|
): Promise<ApiResult<T>> {
|
||||||
|
return request<T>('GET', path, params, undefined, timeout)
|
||||||
|
},
|
||||||
|
post<T>(path: string, body?: unknown, timeout?: number): Promise<ApiResult<T>> {
|
||||||
|
return request<T>('POST', path, undefined, body, timeout)
|
||||||
|
},
|
||||||
|
}
|
||||||
161
src/api/openTools.ts
Normal file
161
src/api/openTools.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* service.xpcool.com 开放工具接口封装。
|
||||||
|
*
|
||||||
|
* 对应后端契约:api/open/tools/<name>/<name>.go
|
||||||
|
* 完整路径:POST {VITE_API_BASE}/api/service/open/tools/<name>
|
||||||
|
* 全部免鉴权、全部 POST、URL 不带参数,入参走 JSON body。
|
||||||
|
*
|
||||||
|
* 这里把「接口元数据」与「调用函数」放在一起:
|
||||||
|
* 元数据供「服务端接口状态」页做连通性探测,调用函数供各工具页直接使用,
|
||||||
|
* 两处共用同一份路径定义,避免改了路径忘了改探测列表。
|
||||||
|
*/
|
||||||
|
import { api, OPEN_API_PREFIX } from './client'
|
||||||
|
import type { ApiResult } from './client'
|
||||||
|
|
||||||
|
/** 开放工具接口前缀 */
|
||||||
|
const TOOLS_PREFIX = `${OPEN_API_PREFIX}/tools`
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 接口元数据
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface OpenEndpoint {
|
||||||
|
/** 工具标识(与路由 path 对应,便于在界面上做跳转) */
|
||||||
|
key: string
|
||||||
|
/** 接口名(显示用) */
|
||||||
|
name: string
|
||||||
|
/** 相对路径(不含基地址) */
|
||||||
|
path: string
|
||||||
|
/** HTTP 方法(当前全部为 POST) */
|
||||||
|
method: 'POST'
|
||||||
|
/** 探测用请求体(各接口的最小合法入参) */
|
||||||
|
probeBody: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部开放工具接口。
|
||||||
|
* 新增接口时在 api/open/tools 下加文件、在控制器加方法,然后在这里补一条,
|
||||||
|
* 「服务端接口状态」页与首页的接口计数会自动同步。
|
||||||
|
*/
|
||||||
|
export const OPEN_TOOL_ENDPOINTS: OpenEndpoint[] = [
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
name: 'endpointIp',
|
||||||
|
path: `${TOOLS_PREFIX}/ip`,
|
||||||
|
method: 'POST',
|
||||||
|
probeBody: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
name: 'endpointTime',
|
||||||
|
path: `${TOOLS_PREFIX}/time`,
|
||||||
|
method: 'POST',
|
||||||
|
probeBody: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'uuid',
|
||||||
|
name: 'endpointUuid',
|
||||||
|
path: `${TOOLS_PREFIX}/uuid`,
|
||||||
|
method: 'POST',
|
||||||
|
probeBody: { short: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'md5',
|
||||||
|
name: 'endpointMd5',
|
||||||
|
path: `${TOOLS_PREFIX}/md5`,
|
||||||
|
method: 'POST',
|
||||||
|
// 用固定文本做探针,返回结果可预期,便于人工核对
|
||||||
|
probeBody: { text: 'ping' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'random',
|
||||||
|
name: 'endpointRandom',
|
||||||
|
path: `${TOOLS_PREFIX}/random`,
|
||||||
|
method: 'POST',
|
||||||
|
probeBody: { length: 8, type: 'alnum' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 响应数据结构(与后端 api/open/tools/*/*.go 的 Res 一一对应)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** POST /tools/ip → IPRes */
|
||||||
|
export interface IpRes {
|
||||||
|
ip: string
|
||||||
|
/** 是否为内网/私网地址 */
|
||||||
|
internal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /tools/time → TimeRes */
|
||||||
|
export interface TimeRes {
|
||||||
|
/** Unix 秒 */
|
||||||
|
timestamp: number
|
||||||
|
/** 2006-01-02 15:04:05 */
|
||||||
|
dateTime: string
|
||||||
|
/** 2006-01-02 */
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /tools/uuid → UUIDRes */
|
||||||
|
export interface UuidRes {
|
||||||
|
uuid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /tools/md5 → MD5Res */
|
||||||
|
export interface Md5Res {
|
||||||
|
md5: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /tools/random → RandomRes */
|
||||||
|
export interface RandomRes {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 随机字符串字符类型(与后端 v:"in:alnum,digits,letters" 校验保持一致) */
|
||||||
|
export type RandomType = 'alnum' | 'digits' | 'letters'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 调用函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 获取调用方 IP 及是否为内网地址 */
|
||||||
|
export function fetchClientIp(): Promise<ApiResult<IpRes>> {
|
||||||
|
return api.post<IpRes>(`${TOOLS_PREFIX}/ip`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取服务端当前时间(用于校准本机时钟) */
|
||||||
|
export function fetchServerTime(): Promise<ApiResult<TimeRes>> {
|
||||||
|
return api.post<TimeRes>(`${TOOLS_PREFIX}/time`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一 ID。
|
||||||
|
* @param short true 返回 8 位短码;false 返回 32 位 ID
|
||||||
|
*/
|
||||||
|
export function fetchUuid(short = false): Promise<ApiResult<UuidRes>> {
|
||||||
|
return api.post<UuidRes>(`${TOOLS_PREFIX}/uuid`, { short })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算文本 MD5。
|
||||||
|
* @param text 待摘要文本(后端校验非空)
|
||||||
|
*/
|
||||||
|
export function fetchMd5(text: string): Promise<ApiResult<Md5Res>> {
|
||||||
|
return api.post<Md5Res>(`${TOOLS_PREFIX}/md5`, { text })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机字符串。
|
||||||
|
* @param length 长度,后端限制 1-128
|
||||||
|
* @param type 字符类型,默认 alnum(字母 + 数字)
|
||||||
|
*/
|
||||||
|
export function fetchRandomString(
|
||||||
|
length: number,
|
||||||
|
type: RandomType = 'alnum',
|
||||||
|
): Promise<ApiResult<RandomRes>> {
|
||||||
|
return api.post<RandomRes>(`${TOOLS_PREFIX}/random`, { length, type })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端随机字符串的长度上限(与 RandomReq 的 v:"max:128" 一致) */
|
||||||
|
export const RANDOM_MAX_LENGTH = 128
|
||||||
@ -1,15 +1,20 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 全局样式
|
* 全局样式
|
||||||
* 颜色一律使用 TDesign 设计令牌 CSS 变量(--td-*),
|
* ------------------------------------------------------------
|
||||||
* 当 html[theme-mode='dark'] 时自动切换为暗黑主题配色。
|
* 引入顺序很重要:
|
||||||
|
* 1. tokens.scss —— 设计令牌(--tk-*)
|
||||||
|
* 2. tdesign-theme.scss —— 令牌 → TDesign 变量(--td-*)+ UI 库尺度控制
|
||||||
|
* 3. 本文件其余部分 —— 基础样式与工具页通用类
|
||||||
|
*
|
||||||
|
* 所有颜色只允许来自 --tk-* / --td-*,禁止硬编码色值,
|
||||||
|
* 这样昼夜主题切换才能全站一致生效。
|
||||||
|
*
|
||||||
|
* 字号只允许来自 --tk-text-*:本版把正文提到 16px,
|
||||||
|
* 新增样式请勿写死 12/13px,否则又会回到「字太小」的老问题。
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
:root {
|
@use './tokens';
|
||||||
color-scheme: light;
|
@use './tdesign-theme';
|
||||||
}
|
|
||||||
html[theme-mode='dark'] {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
@ -34,7 +39,64 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图片通用约束(与 cropperjs 官方示例一致):上传的图片预览不超出容器 */
|
body {
|
||||||
|
font-family: var(--tk-font-sans);
|
||||||
|
/* 正文基准字号 = --tk-text-base(16px)。全站最小可见文字是 13px(--tk-text-xs),
|
||||||
|
* 主要出现在辅助说明与元信息上,不再有 12px 的正文。 */
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
line-height: 1.7;
|
||||||
|
background-color: var(--tk-bg);
|
||||||
|
color: var(--tk-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题切换时给整页一个短过渡,避免明暗突变刺眼。
|
||||||
|
* 只过渡颜色,不碰 transform / 布局,无性能风险。 */
|
||||||
|
body,
|
||||||
|
.app-header,
|
||||||
|
.app-aside,
|
||||||
|
.app-footer {
|
||||||
|
transition:
|
||||||
|
background-color var(--tk-dur-base) var(--tk-ease-out),
|
||||||
|
color var(--tk-dur-base) var(--tk-ease-out),
|
||||||
|
border-color var(--tk-dur-base) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 可访问性 ----------
|
||||||
|
* 统一键盘焦点环:只在键盘操作时出现,鼠标点击不显示。 */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--tk-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--tk-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 跳转到主内容(键盘用户按 Tab 首站即可跳过侧栏) */
|
||||||
|
.skip-to-content {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.skip-to-content:focus {
|
||||||
|
left: 12px;
|
||||||
|
top: 12px;
|
||||||
|
z-index: var(--tk-z-palette);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
background: var(--tk-surface-raised);
|
||||||
|
border: 1px solid var(--tk-border);
|
||||||
|
box-shadow: var(--tk-shadow-2);
|
||||||
|
color: var(--tk-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--tk-accent-surface-strong);
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 图片通用约束(与 cropperjs 官方示例一致) ---------- */
|
||||||
img {
|
img {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -45,34 +107,31 @@ img {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* ---------- 滚动条美化 ----------
|
||||||
font-family:
|
* 字号与控件都放大了一档,滚动条同步加粗到 10px,否则不好点中。 */
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
|
||||||
'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
background-color: var(--td-bg-color-page);
|
|
||||||
color: var(--td-text-color-primary);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 滚动条美化 */
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 10px;
|
||||||
height: 8px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: var(--td-scrollbar-color);
|
||||||
border-radius: 4px;
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
border-radius: var(--tk-radius-pill);
|
||||||
}
|
}
|
||||||
html[theme-mode='dark'] ::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.22);
|
background: var(--td-scrollbar-hover-color);
|
||||||
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 工具页通用区块间距 */
|
/* ============================================================
|
||||||
|
* 工具页通用区块(60+ 个页面共用,改这里即全站生效)
|
||||||
|
* ============================================================ */
|
||||||
.tool-section {
|
.tool-section {
|
||||||
margin-bottom: 16px;
|
margin-bottom: var(--tk-space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 结果展示区:长文本自动换行 */
|
/* 结果展示区:长文本自动换行 */
|
||||||
@ -81,15 +140,99 @@ html[theme-mode='dark'] ::-webkit-scrollbar-thumb {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 页面标题与描述 */
|
/* 页面标题与描述
|
||||||
|
* 标题明显大于正文,层级靠字号反差立住;移动端通过 clamp 下限回到 28px,
|
||||||
|
* 避免中文标题在窄屏被拆成三行。 */
|
||||||
.page-title {
|
.page-title {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 12px;
|
||||||
font-size: 22px;
|
font-family: var(--tk-font-display);
|
||||||
font-weight: 600;
|
font-size: clamp(28px, 3vw, 38px);
|
||||||
color: var(--td-text-color-primary);
|
font-weight: 650;
|
||||||
|
letter-spacing: var(--tk-track-title);
|
||||||
|
line-height: var(--tk-leading-title);
|
||||||
|
color: var(--tk-text);
|
||||||
}
|
}
|
||||||
.page-desc {
|
.page-desc {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 var(--tk-space-7);
|
||||||
font-size: 14px;
|
max-width: 62ch;
|
||||||
color: var(--td-text-color-secondary);
|
font-size: var(--tk-text-base);
|
||||||
|
line-height: var(--tk-leading-body);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分区标题:比页面标题低一档,用于工具页内部把长表单切成若干段落 */
|
||||||
|
.section-heading {
|
||||||
|
margin: var(--tk-space-6) 0 var(--tk-space-4);
|
||||||
|
font-size: var(--tk-text-lg);
|
||||||
|
font-weight: 620;
|
||||||
|
letter-spacing: var(--tk-track-title);
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行分隔线:统一用发丝线,不要画成带底色的长条 */
|
||||||
|
.rule {
|
||||||
|
height: 1px;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--tk-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单标签(各工具页统一宽度,保证左侧对齐) */
|
||||||
|
.form-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单动作区 */
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--tk-space-3);
|
||||||
|
margin-top: var(--tk-space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单行说明/元信息 */
|
||||||
|
.field-hint {
|
||||||
|
margin: var(--tk-space-2) 0 0;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 数值展示:等宽数字,避免刷新时宽度跳动 */
|
||||||
|
.mono-num {
|
||||||
|
font-family: var(--tk-num-font);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 可点击放大的图片/画布(配合 AppImageViewer 全站共用) */
|
||||||
|
.img-previewable {
|
||||||
|
cursor: zoom-in;
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
}
|
||||||
|
.img-previewable:hover {
|
||||||
|
box-shadow: 0 0 0 2px var(--tk-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 滚动进入动效(只做轻微上浮淡入,不做视差/钉住)
|
||||||
|
* 由 v-reveal 指令添加 .is-revealed;无 JS 时元素始终可见,不会白屏。
|
||||||
|
* ============================================================ */
|
||||||
|
.reveal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
transition:
|
||||||
|
opacity var(--tk-dur-slow) var(--tk-ease-out),
|
||||||
|
transform var(--tk-dur-slow) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.reveal.is-revealed {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
514
src/assets/styles/tdesign-theme.scss
Normal file
514
src/assets/styles/tdesign-theme.scss
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* TDesign 主题控制层(UI 库主题)
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* 本文件负责「控制 UI 库组件本身的色彩与风格」,分四件事:
|
||||||
|
* ① 色阶:品牌蓝 / 中性蓝灰 / 语义色的 10 级色板(我们的色,喂给 TDesign)
|
||||||
|
* ② 语义角色:把 tokens.scss 的 --tk-* 映射到 TDesign 的 --td-*
|
||||||
|
* ③ 组件尺度:控件高度 / 内边距 / 字号 / 行高(本版为解决「控件太小」新增)
|
||||||
|
* ④ 组件级微调:卡片、输入框、按钮、弹层的外形修正
|
||||||
|
*
|
||||||
|
* 为什么这样做:60+ 个工具页面里已有大量 t-card / t-button / t-input /
|
||||||
|
* t-select / t-tag,全部改一遍既不现实也容易漏。TDesign 的组件样式
|
||||||
|
* 完全建立在 --td-* 变量之上(例如 .t-input.t-size-l 的高度取自
|
||||||
|
* var(--td-comp-size-xl)),所以改变量 = 全站组件改版,属最小风险改造。
|
||||||
|
*
|
||||||
|
* ⚠️ 三条必须遵守的约束(踩过坑):
|
||||||
|
* 1. 只覆盖 TDesign 真实存在的变量名。写错名字不会报错,只是静默不生效。
|
||||||
|
* 新增前请在 node_modules/tdesign-vue-next 的样式产物里确认。
|
||||||
|
* 2. --td-font-body-* 是复合变量(size/line-height 简写),
|
||||||
|
* 所以放大字号时必须同步改 --td-font-size-* 与 --td-line-height-*,
|
||||||
|
* 只改前者会出现「大字号被小行高挤住」的破版。
|
||||||
|
* 3. 尺度变量不要动 --td-size-N 阶梯本身(那是 TDesign 的原子刻度,
|
||||||
|
* 图标、徽标、间距都在用);只覆盖 --td-comp-size-* 一层。
|
||||||
|
*
|
||||||
|
* 命名桥接对应关系(速查):
|
||||||
|
* --tk-accent → --td-brand-color (按钮/主色底)
|
||||||
|
* --tk-accent-text → --td-text-color-brand (链接/图标色)
|
||||||
|
* --tk-bg → --td-bg-color-page
|
||||||
|
* --tk-surface → --td-bg-color-container
|
||||||
|
* --tk-border → --td-border-level-1-color
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 浅色主题
|
||||||
|
* ============================================================ */
|
||||||
|
:root,
|
||||||
|
:root[theme-mode='light'] {
|
||||||
|
/* ---------- 品牌色阶(宝石蓝,1 最浅 → 10 最深) ---------- */
|
||||||
|
--td-brand-color-1: #eff4ff;
|
||||||
|
--td-brand-color-2: #dbe6fe;
|
||||||
|
--td-brand-color-3: #c0d4fe;
|
||||||
|
--td-brand-color-4: #96b6fd;
|
||||||
|
--td-brand-color-5: #6294fb;
|
||||||
|
--td-brand-color-6: #3b76f6;
|
||||||
|
--td-brand-color-7: #2563eb;
|
||||||
|
--td-brand-color-8: #1d4ed8;
|
||||||
|
--td-brand-color-9: #1e3fb0;
|
||||||
|
--td-brand-color-10: #1b3690;
|
||||||
|
|
||||||
|
/* ---------- 语义色阶 ---------- */
|
||||||
|
--td-success-color-1: #e6f7ee;
|
||||||
|
--td-success-color-2: #c9efdb;
|
||||||
|
--td-success-color-3: #98ddb9;
|
||||||
|
--td-success-color-4: #5ec592;
|
||||||
|
--td-success-color-5: #26a86d;
|
||||||
|
--td-success-color-6: #178a56;
|
||||||
|
--td-success-color-7: #0e6d43;
|
||||||
|
--td-success-color-8: #0a5233;
|
||||||
|
--td-success-color-9: #073a24;
|
||||||
|
--td-success-color-10: #042514;
|
||||||
|
|
||||||
|
--td-warning-color-1: #fdf3e3;
|
||||||
|
--td-warning-color-2: #f8e3c1;
|
||||||
|
--td-warning-color-3: #efcd96;
|
||||||
|
--td-warning-color-4: #e3b264;
|
||||||
|
--td-warning-color-5: #cf9433;
|
||||||
|
--td-warning-color-6: #b07a1f;
|
||||||
|
--td-warning-color-7: #8d5f14;
|
||||||
|
--td-warning-color-8: #6d480d;
|
||||||
|
--td-warning-color-9: #4f3308;
|
||||||
|
--td-warning-color-10: #342104;
|
||||||
|
|
||||||
|
--td-error-color-1: #fdeceb;
|
||||||
|
--td-error-color-2: #f9d3d1;
|
||||||
|
--td-error-color-3: #f0b0ac;
|
||||||
|
--td-error-color-4: #e2857f;
|
||||||
|
--td-error-color-5: #cc5a53;
|
||||||
|
--td-error-color-6: #ab403a;
|
||||||
|
--td-error-color-7: #8a2f2a;
|
||||||
|
--td-error-color-8: #6a211d;
|
||||||
|
--td-error-color-9: #4c1613;
|
||||||
|
--td-error-color-10: #320d0b;
|
||||||
|
|
||||||
|
/* ---------- 中性色阶(蓝灰,1 最浅 → 14 最深)
|
||||||
|
* 深浅两套主题共用同一条色阶:浅色主题取低号,深色主题取高号 ---------- */
|
||||||
|
--td-gray-color-1: #f4f6fb;
|
||||||
|
--td-gray-color-2: #eef2f9;
|
||||||
|
--td-gray-color-3: #e2e8f3;
|
||||||
|
--td-gray-color-4: #ccd7e8;
|
||||||
|
--td-gray-color-5: #b1bdd3;
|
||||||
|
--td-gray-color-6: #8f9cb4;
|
||||||
|
--td-gray-color-7: #6e7994;
|
||||||
|
--td-gray-color-8: #55617a;
|
||||||
|
--td-gray-color-9: #3d4759;
|
||||||
|
--td-gray-color-10: #2e3747;
|
||||||
|
--td-gray-color-11: #263147;
|
||||||
|
--td-gray-color-12: #1b2333;
|
||||||
|
--td-gray-color-13: #131926;
|
||||||
|
--td-gray-color-14: #0b0f18;
|
||||||
|
|
||||||
|
/* ---------- 语义角色 ---------- */
|
||||||
|
--td-brand-color: var(--tk-accent);
|
||||||
|
--td-brand-color-hover: var(--tk-accent-hover);
|
||||||
|
--td-brand-color-active: var(--tk-accent-active);
|
||||||
|
--td-brand-color-focus: var(--tk-accent-surface-strong);
|
||||||
|
--td-brand-color-disabled: #96b6fd;
|
||||||
|
--td-brand-color-light: var(--tk-accent-surface);
|
||||||
|
--td-brand-color-light-hover: var(--tk-accent-surface-strong);
|
||||||
|
|
||||||
|
--td-success-color: var(--td-success-color-5);
|
||||||
|
--td-success-color-hover: var(--td-success-color-4);
|
||||||
|
--td-success-color-active: var(--td-success-color-6);
|
||||||
|
--td-success-color-focus: var(--td-success-color-2);
|
||||||
|
--td-success-color-disabled: var(--td-success-color-3);
|
||||||
|
--td-success-color-light: var(--td-success-color-1);
|
||||||
|
--td-success-color-light-hover: var(--td-success-color-2);
|
||||||
|
|
||||||
|
--td-warning-color: var(--td-warning-color-5);
|
||||||
|
--td-warning-color-hover: var(--td-warning-color-4);
|
||||||
|
--td-warning-color-active: var(--td-warning-color-6);
|
||||||
|
--td-warning-color-focus: var(--td-warning-color-2);
|
||||||
|
--td-warning-color-disabled: var(--td-warning-color-3);
|
||||||
|
--td-warning-color-light: var(--td-warning-color-1);
|
||||||
|
--td-warning-color-light-hover: var(--td-warning-color-2);
|
||||||
|
|
||||||
|
--td-error-color: var(--td-error-color-5);
|
||||||
|
--td-error-color-hover: var(--td-error-color-4);
|
||||||
|
--td-error-color-active: var(--td-error-color-6);
|
||||||
|
--td-error-color-focus: var(--td-error-color-2);
|
||||||
|
--td-error-color-disabled: var(--td-error-color-3);
|
||||||
|
--td-error-color-light: var(--td-error-color-1);
|
||||||
|
--td-error-color-light-hover: var(--td-error-color-2);
|
||||||
|
|
||||||
|
/* ---------- 背景 ---------- */
|
||||||
|
--td-bg-color-page: var(--tk-bg);
|
||||||
|
--td-bg-color-container: var(--tk-surface);
|
||||||
|
--td-bg-color-container-hover: var(--tk-surface-hover);
|
||||||
|
--td-bg-color-container-active: var(--tk-surface-active);
|
||||||
|
--td-bg-color-container-select: var(--tk-surface-raised);
|
||||||
|
--td-bg-color-secondarycontainer: var(--tk-surface-sunken);
|
||||||
|
--td-bg-color-secondarycontainer-hover: var(--tk-surface-hover);
|
||||||
|
--td-bg-color-secondarycontainer-active: var(--tk-surface-active);
|
||||||
|
--td-bg-color-component: var(--tk-surface-sunken);
|
||||||
|
--td-bg-color-component-hover: var(--tk-surface-hover);
|
||||||
|
--td-bg-color-component-active: var(--tk-border-strong);
|
||||||
|
--td-bg-color-component-disabled: var(--tk-surface-sunken);
|
||||||
|
--td-bg-color-secondarycomponent: var(--tk-surface-hover);
|
||||||
|
--td-bg-color-secondarycomponent-hover: var(--tk-surface-active);
|
||||||
|
--td-bg-color-secondarycomponent-active: var(--tk-border-strong);
|
||||||
|
--td-bg-color-specialcomponent: var(--tk-surface-raised);
|
||||||
|
|
||||||
|
/* 遮罩:用带色相的深色,避免死黑 */
|
||||||
|
--td-mask-active: rgba(9, 15, 32, 0.55);
|
||||||
|
--td-mask-disabled: rgba(255, 255, 255, 0.6);
|
||||||
|
--td-mask-background: var(--tk-surface);
|
||||||
|
|
||||||
|
/* ---------- 文字 ---------- */
|
||||||
|
--td-text-color-primary: var(--tk-text);
|
||||||
|
--td-text-color-secondary: var(--tk-text-secondary);
|
||||||
|
--td-text-color-placeholder: var(--tk-text-placeholder);
|
||||||
|
--td-text-color-disabled: #b1bdd3;
|
||||||
|
--td-text-color-anti: var(--tk-text-on-accent);
|
||||||
|
--td-text-color-brand: var(--tk-accent-text);
|
||||||
|
--td-text-color-link: var(--tk-accent-text);
|
||||||
|
--td-text-color-watermark: rgba(15, 23, 41, 0.08);
|
||||||
|
|
||||||
|
/* ---------- 描边 ---------- */
|
||||||
|
--td-border-level-1-color: var(--tk-border);
|
||||||
|
--td-component-stroke: var(--tk-border);
|
||||||
|
--td-border-level-2-color: var(--tk-border-strong);
|
||||||
|
--td-component-border: var(--tk-border-strong);
|
||||||
|
|
||||||
|
/* ---------- 圆角:统一收口为 --tk-radius-* ---------- */
|
||||||
|
--td-radius-small: var(--tk-radius-sm);
|
||||||
|
--td-radius-default: var(--tk-radius-md);
|
||||||
|
--td-radius-medium: var(--tk-radius-lg);
|
||||||
|
--td-radius-large: var(--tk-radius-xl);
|
||||||
|
--td-radius-extraLarge: var(--tk-radius-2xl);
|
||||||
|
--td-radius-round: var(--tk-radius-pill);
|
||||||
|
--td-radius-circle: 50%;
|
||||||
|
|
||||||
|
/* ---------- 阴影:带背景色相的冷调投影 ---------- */
|
||||||
|
--td-shadow-1: var(--tk-shadow-1);
|
||||||
|
--td-shadow-2: var(--tk-shadow-2);
|
||||||
|
--td-shadow-3: var(--tk-shadow-3);
|
||||||
|
--td-shadow-inset-top: inset 0 0.5px 0 var(--tk-border);
|
||||||
|
--td-shadow-inset-right: inset 0.5px 0 0 var(--tk-border);
|
||||||
|
--td-shadow-inset-bottom: inset 0 -0.5px 0 var(--tk-border);
|
||||||
|
--td-shadow-inset-left: inset -0.5px 0 0 var(--tk-border);
|
||||||
|
--td-table-shadow-color: rgba(15, 23, 41, 0.08);
|
||||||
|
|
||||||
|
/* ---------- 滚动条 ---------- */
|
||||||
|
--td-scrollbar-color: rgba(15, 23, 41, 0.18);
|
||||||
|
--td-scrollbar-hover-color: rgba(15, 23, 41, 0.34);
|
||||||
|
--td-scroll-track-color: transparent;
|
||||||
|
|
||||||
|
/* ---------- 字体 ---------- */
|
||||||
|
--td-font-family: var(--tk-font-sans);
|
||||||
|
--td-font-family-medium: var(--tk-font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 深色主题
|
||||||
|
* ============================================================ */
|
||||||
|
:root[theme-mode='dark'] {
|
||||||
|
/* ---------- 品牌色阶(深色下 1 最深 → 10 最浅) ---------- */
|
||||||
|
--td-brand-color-1: #16244d;
|
||||||
|
--td-brand-color-2: #1a2d63;
|
||||||
|
--td-brand-color-3: #1e377c;
|
||||||
|
--td-brand-color-4: #234298;
|
||||||
|
--td-brand-color-5: #2a4fb5;
|
||||||
|
--td-brand-color-6: #3059d6;
|
||||||
|
--td-brand-color-7: #2563eb;
|
||||||
|
--td-brand-color-8: #4a80f5;
|
||||||
|
--td-brand-color-9: #7aa8ff;
|
||||||
|
--td-brand-color-10: #b3ccff;
|
||||||
|
|
||||||
|
/* ---------- 语义色阶 ---------- */
|
||||||
|
--td-success-color-1: #06291a;
|
||||||
|
--td-success-color-2: #07341f;
|
||||||
|
--td-success-color-3: #0a4429;
|
||||||
|
--td-success-color-4: #0c5533;
|
||||||
|
--td-success-color-5: #0e6a3f;
|
||||||
|
--td-success-color-6: #11804c;
|
||||||
|
--td-success-color-7: #199a5d;
|
||||||
|
--td-success-color-8: #2cb673;
|
||||||
|
--td-success-color-9: #52cf94;
|
||||||
|
--td-success-color-10: #86e0b6;
|
||||||
|
|
||||||
|
--td-warning-color-1: #342104;
|
||||||
|
--td-warning-color-2: #452c07;
|
||||||
|
--td-warning-color-3: #59380b;
|
||||||
|
--td-warning-color-4: #6f4610;
|
||||||
|
--td-warning-color-5: #8a5717;
|
||||||
|
--td-warning-color-6: #a76b1f;
|
||||||
|
--td-warning-color-7: #c48529;
|
||||||
|
--td-warning-color-8: #dda044;
|
||||||
|
--td-warning-color-9: #eebb6f;
|
||||||
|
--td-warning-color-10: #f6d79c;
|
||||||
|
|
||||||
|
--td-error-color-1: #320d0b;
|
||||||
|
--td-error-color-2: #43120f;
|
||||||
|
--td-error-color-3: #571713;
|
||||||
|
--td-error-color-4: #6c1e18;
|
||||||
|
--td-error-color-5: #83261f;
|
||||||
|
--td-error-color-6: #9d342b;
|
||||||
|
--td-error-color-7: #ba473c;
|
||||||
|
--td-error-color-8: #d26155;
|
||||||
|
--td-error-color-9: #e58377;
|
||||||
|
--td-error-color-10: #f2aca4;
|
||||||
|
|
||||||
|
/* ---------- 语义角色 ----------
|
||||||
|
* 注意:深色下按钮底保持 #2563eb(与白字 5.17:1),
|
||||||
|
* 而链接/图标另用更亮的 --tk-accent-text 保证在深底上可读。 */
|
||||||
|
--td-brand-color: var(--tk-accent);
|
||||||
|
--td-brand-color-hover: var(--tk-accent-hover);
|
||||||
|
--td-brand-color-active: var(--tk-accent-active);
|
||||||
|
--td-brand-color-focus: var(--tk-accent-surface-strong);
|
||||||
|
--td-brand-color-disabled: #1e377c;
|
||||||
|
--td-brand-color-light: var(--tk-accent-surface);
|
||||||
|
--td-brand-color-light-hover: var(--tk-accent-surface-strong);
|
||||||
|
|
||||||
|
--td-success-color: var(--td-success-color-8);
|
||||||
|
--td-success-color-hover: var(--td-success-color-9);
|
||||||
|
--td-success-color-active: var(--td-success-color-7);
|
||||||
|
--td-success-color-focus: var(--td-success-color-2);
|
||||||
|
--td-success-color-disabled: var(--td-success-color-3);
|
||||||
|
--td-success-color-light: var(--td-success-color-1);
|
||||||
|
--td-success-color-light-hover: var(--td-success-color-2);
|
||||||
|
|
||||||
|
--td-warning-color: var(--td-warning-color-8);
|
||||||
|
--td-warning-color-hover: var(--td-warning-color-7);
|
||||||
|
--td-warning-color-active: var(--td-warning-color-6);
|
||||||
|
--td-warning-color-focus: var(--td-warning-color-2);
|
||||||
|
--td-warning-color-disabled: var(--td-warning-color-3);
|
||||||
|
--td-warning-color-light: var(--td-warning-color-1);
|
||||||
|
--td-warning-color-light-hover: var(--td-warning-color-2);
|
||||||
|
|
||||||
|
--td-error-color: var(--td-error-color-8);
|
||||||
|
--td-error-color-hover: var(--td-error-color-7);
|
||||||
|
--td-error-color-active: var(--td-error-color-6);
|
||||||
|
--td-error-color-focus: var(--td-error-color-2);
|
||||||
|
--td-error-color-disabled: var(--td-error-color-3);
|
||||||
|
--td-error-color-light: var(--td-error-color-1);
|
||||||
|
--td-error-color-light-hover: var(--td-error-color-2);
|
||||||
|
|
||||||
|
/* ---------- 背景 ---------- */
|
||||||
|
--td-bg-color-page: var(--tk-bg);
|
||||||
|
--td-bg-color-container: var(--tk-surface);
|
||||||
|
--td-bg-color-container-hover: var(--tk-surface-hover);
|
||||||
|
--td-bg-color-container-active: var(--tk-surface-active);
|
||||||
|
--td-bg-color-container-select: var(--tk-surface-raised);
|
||||||
|
--td-bg-color-secondarycontainer: var(--tk-surface-sunken);
|
||||||
|
--td-bg-color-secondarycontainer-hover: var(--tk-surface-hover);
|
||||||
|
--td-bg-color-secondarycontainer-active: var(--tk-surface-active);
|
||||||
|
--td-bg-color-component: var(--tk-surface-sunken);
|
||||||
|
--td-bg-color-component-hover: var(--tk-surface-hover);
|
||||||
|
--td-bg-color-component-active: var(--tk-surface-active);
|
||||||
|
--td-bg-color-component-disabled: var(--tk-surface-sunken);
|
||||||
|
--td-bg-color-secondarycomponent: var(--tk-surface-hover);
|
||||||
|
--td-bg-color-secondarycomponent-hover: var(--tk-surface-active);
|
||||||
|
--td-bg-color-secondarycomponent-active: var(--tk-border-strong);
|
||||||
|
--td-bg-color-specialcomponent: var(--tk-surface-raised);
|
||||||
|
|
||||||
|
--td-mask-active: rgba(0, 0, 0, 0.72);
|
||||||
|
--td-mask-disabled: rgba(19, 25, 38, 0.6);
|
||||||
|
--td-mask-background: var(--tk-surface);
|
||||||
|
|
||||||
|
/* ---------- 文字 ---------- */
|
||||||
|
--td-text-color-primary: var(--tk-text);
|
||||||
|
--td-text-color-secondary: var(--tk-text-secondary);
|
||||||
|
--td-text-color-placeholder: var(--tk-text-placeholder);
|
||||||
|
--td-text-color-disabled: #55617a;
|
||||||
|
--td-text-color-anti: var(--tk-text-on-accent);
|
||||||
|
--td-text-color-brand: var(--tk-accent-text);
|
||||||
|
--td-text-color-link: var(--tk-accent-text);
|
||||||
|
--td-text-color-watermark: rgba(233, 238, 248, 0.1);
|
||||||
|
|
||||||
|
/* ---------- 描边 ---------- */
|
||||||
|
--td-border-level-1-color: var(--tk-border);
|
||||||
|
--td-component-stroke: var(--tk-border);
|
||||||
|
--td-border-level-2-color: var(--tk-border-strong);
|
||||||
|
--td-component-border: var(--tk-border-strong);
|
||||||
|
|
||||||
|
/* ---------- 圆角 / 阴影 / 滚动条 / 字体 ---------- */
|
||||||
|
--td-radius-small: var(--tk-radius-sm);
|
||||||
|
--td-radius-default: var(--tk-radius-md);
|
||||||
|
--td-radius-medium: var(--tk-radius-lg);
|
||||||
|
--td-radius-large: var(--tk-radius-xl);
|
||||||
|
--td-radius-extraLarge: var(--tk-radius-2xl);
|
||||||
|
--td-radius-round: var(--tk-radius-pill);
|
||||||
|
--td-radius-circle: 50%;
|
||||||
|
|
||||||
|
--td-shadow-1: var(--tk-shadow-1);
|
||||||
|
--td-shadow-2: var(--tk-shadow-2);
|
||||||
|
--td-shadow-3: var(--tk-shadow-3);
|
||||||
|
--td-shadow-inset-top: inset 0 0.5px 0 var(--tk-border);
|
||||||
|
--td-shadow-inset-right: inset 0.5px 0 0 var(--tk-border);
|
||||||
|
--td-shadow-inset-bottom: inset 0 -0.5px 0 var(--tk-border);
|
||||||
|
--td-shadow-inset-left: inset -0.5px 0 0 var(--tk-border);
|
||||||
|
--td-table-shadow-color: rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
--td-scrollbar-color: rgba(233, 238, 248, 0.18);
|
||||||
|
--td-scrollbar-hover-color: rgba(233, 238, 248, 0.34);
|
||||||
|
--td-scroll-track-color: transparent;
|
||||||
|
|
||||||
|
--td-font-family: var(--tk-font-sans);
|
||||||
|
--td-font-family-medium: var(--tk-font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* ③ UI 库组件尺度控制(本版新增,解决「控件太小」)
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* 与主题无关,因此三个选择器都要写(且必须用 :root[theme-mode=...] 这一
|
||||||
|
* 与 TDesign 同级权重的写法,否则会被 UI 库自身的深色变量盖掉):
|
||||||
|
* 直接改 --td-size-* 会连带影响图标/徽标,所以只覆盖 --td-comp-* 一层。
|
||||||
|
*
|
||||||
|
* 放大前后对照(TDesign 默认 → 本版):
|
||||||
|
* 默认输入框/按钮/下拉 32px → 40px
|
||||||
|
* 大号控件 40px → 48px
|
||||||
|
* 小号控件 24px → 28px
|
||||||
|
* 控件文字 14px → 16px(与正文同号,读起来不再「小一号」)
|
||||||
|
* ============================================================ */
|
||||||
|
/* ============================================================
|
||||||
|
* 深色主题的中性色阶(补在主题块之外,便于与尺度控制放在一起)
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* TDesign 在深色下使用「反向灰阶」(1 最深 → 14 最浅),
|
||||||
|
* 不覆盖的话组件会退回 UI 库自带的冷灰,与本主题的蓝灰体系脱节。
|
||||||
|
* ============================================================ */
|
||||||
|
:root[theme-mode='dark'] {
|
||||||
|
--td-gray-color-1: #0b0f18;
|
||||||
|
--td-gray-color-2: #131926;
|
||||||
|
--td-gray-color-3: #1b2333;
|
||||||
|
--td-gray-color-4: #222c3f;
|
||||||
|
--td-gray-color-5: #263147;
|
||||||
|
--td-gray-color-6: #35415c;
|
||||||
|
--td-gray-color-7: #4a5670;
|
||||||
|
--td-gray-color-8: #6e7994;
|
||||||
|
--td-gray-color-9: #8f9cb4;
|
||||||
|
--td-gray-color-10: #b1bdd3;
|
||||||
|
--td-gray-color-11: #ccd7e8;
|
||||||
|
--td-gray-color-12: #e2e8f3;
|
||||||
|
--td-gray-color-13: #eef2f9;
|
||||||
|
--td-gray-color-14: #f4f6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root,
|
||||||
|
:root[theme-mode='light'],
|
||||||
|
:root[theme-mode='dark'] {
|
||||||
|
/* ---------- 控件高度阶梯 ---------- */
|
||||||
|
--td-comp-size-xxxs: 18px; /* 复选框、小圆点 */
|
||||||
|
--td-comp-size-xxs: 24px; /* 小号控件(t-tag / 紧凑按钮) */
|
||||||
|
--td-comp-size-xs: 28px;
|
||||||
|
--td-comp-size-s: 32px;
|
||||||
|
--td-comp-size-m: 40px; /* ★ 默认控件:输入框 / 按钮 / 下拉 / 日期选择 */
|
||||||
|
--td-comp-size-l: 44px;
|
||||||
|
--td-comp-size-xl: 48px; /* large 尺寸输入框、下拉触发框 */
|
||||||
|
--td-comp-size-xxl: 52px;
|
||||||
|
--td-comp-size-xxxl: 60px;
|
||||||
|
|
||||||
|
/* ---------- 控件内边距:随高度同步放宽,否则大控件里的文字会贴边 ---------- */
|
||||||
|
--td-comp-paddingTB-xxs: 4px;
|
||||||
|
--td-comp-paddingTB-xs: 6px;
|
||||||
|
--td-comp-paddingTB-s: 8px;
|
||||||
|
--td-comp-paddingTB-m: 12px;
|
||||||
|
--td-comp-paddingTB-l: 14px;
|
||||||
|
--td-comp-paddingTB-xl: 20px;
|
||||||
|
--td-comp-paddingLR-xxs: 6px;
|
||||||
|
--td-comp-paddingLR-xs: 8px;
|
||||||
|
--td-comp-paddingLR-s: 12px;
|
||||||
|
--td-comp-paddingLR-m: 16px;
|
||||||
|
--td-comp-paddingLR-l: 20px;
|
||||||
|
--td-comp-paddingLR-xl: 28px;
|
||||||
|
|
||||||
|
/* ---------- 字号:控件文字提到 16px,与正文同号 ---------- */
|
||||||
|
--td-font-size-body-small: 13px;
|
||||||
|
--td-font-size-body-medium: 16px;
|
||||||
|
--td-font-size-body-large: 17px;
|
||||||
|
--td-font-size-title-small: 16px;
|
||||||
|
--td-font-size-title-medium: 18px;
|
||||||
|
--td-font-size-title-large: 20px;
|
||||||
|
--td-font-size-link-small: 13px;
|
||||||
|
--td-font-size-link-medium: 15px;
|
||||||
|
--td-font-size-link-large: 17px;
|
||||||
|
--td-font-size-mark-small: 13px;
|
||||||
|
--td-font-size-mark-medium: 15px;
|
||||||
|
|
||||||
|
/* ---------- 行高:必须与上面成对修改,否则大字号会被小行高挤住 ---------- */
|
||||||
|
--td-line-height-body-small: 22px;
|
||||||
|
--td-line-height-body-medium: 24px;
|
||||||
|
--td-line-height-body-large: 26px;
|
||||||
|
--td-line-height-title-small: 24px;
|
||||||
|
--td-line-height-title-medium: 28px;
|
||||||
|
--td-line-height-title-large: 30px;
|
||||||
|
--td-line-height-link-small: 22px;
|
||||||
|
--td-line-height-link-medium: 24px;
|
||||||
|
--td-line-height-link-large: 26px;
|
||||||
|
--td-line-height-mark-small: 22px;
|
||||||
|
--td-line-height-mark-medium: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* ④ 组件级微调(清晰蓝 · 卡片工作台)
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* 一条贯穿全站的原则:
|
||||||
|
* 内容容器(卡片/面板)→ 白底 + 细描边 + 轻投影,与页面底拉开层次
|
||||||
|
* 功能控件(输入框/按钮/下拉)→ 更实的描边 + 明确的聚焦反馈
|
||||||
|
* 本版与上一版最大的区别就在这里:卡片重新「有形」,不再是无框纸面。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* 卡片:轻投影 + 细描边;hover 时抬到第二档阴影 */
|
||||||
|
.t-card {
|
||||||
|
border-radius: var(--tk-radius-xl);
|
||||||
|
box-shadow: var(--tk-shadow-1);
|
||||||
|
}
|
||||||
|
.t-card--bordered {
|
||||||
|
border-color: var(--tk-border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.t-card--shadow {
|
||||||
|
box-shadow: var(--tk-shadow-2);
|
||||||
|
}
|
||||||
|
.t-card__header {
|
||||||
|
font-weight: 620;
|
||||||
|
font-size: var(--tk-text-lg);
|
||||||
|
letter-spacing: var(--tk-track-title);
|
||||||
|
}
|
||||||
|
.t-card__title {
|
||||||
|
letter-spacing: var(--tk-track-title);
|
||||||
|
}
|
||||||
|
/* 卡片内边距放大一档;窄屏收回默认值,否则手机上内容可用宽度会不够 */
|
||||||
|
.t-card .t-card__body {
|
||||||
|
padding: var(--tk-space-5) var(--tk-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框 / 文本域:聚焦时用主色描边 + 柔和外发光 */
|
||||||
|
.t-input__wrap,
|
||||||
|
.t-textarea__inner {
|
||||||
|
transition:
|
||||||
|
border-color var(--tk-dur-fast) var(--tk-ease-out),
|
||||||
|
box-shadow var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.t-is-focused .t-input__wrap,
|
||||||
|
.t-is-focused .t-textarea__inner {
|
||||||
|
box-shadow: 0 0 0 3px var(--tk-accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮:按压时轻微下沉,补足 TDesign 缺失的触感反馈 */
|
||||||
|
.t-button:not(.t-is-disabled):active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下拉/选择器的选项行:默认行高偏挤,放大后一屏可见项变少但扫读更快 */
|
||||||
|
.t-select-option,
|
||||||
|
.t-dropdown__item {
|
||||||
|
font-size: var(--td-font-size-body-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹层:统一使用较大的圆角与柔和阴影 */
|
||||||
|
.t-popup__content,
|
||||||
|
.t-dialog,
|
||||||
|
.t-drawer {
|
||||||
|
border-radius: var(--tk-radius-xl);
|
||||||
|
}
|
||||||
|
.t-dialog__header {
|
||||||
|
font-weight: 620;
|
||||||
|
letter-spacing: var(--tk-track-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏:大内边距在手机上会挤掉内容宽度,这里整体收回到常规密度 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.t-card .t-card__body {
|
||||||
|
padding: var(--tk-space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
247
src/assets/styles/tokens.scss
Normal file
247
src/assets/styles/tokens.scss
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* 设计令牌(Design Tokens)
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* 站点唯一的「视觉真相」来源。所有自定义样式只允许引用本文件
|
||||||
|
* 定义的 --tk-* 变量,禁止在组件里硬编码颜色/圆角/阴影。
|
||||||
|
*
|
||||||
|
* 风格定位:清晰蓝 · 卡片工作台(Clear Blue Workspace)
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* 与上一版「编辑感(青玉 + 大留白 + 发丝线)」的区别,也是本版的四条硬规则:
|
||||||
|
* ① 字号整体放大一档:正文 16px 起步、说明文字不低于 13px,
|
||||||
|
* 上一版 12/13/14 的密度在 1080p 以上屏幕上确实偏小。
|
||||||
|
* ② 控件大于内容:输入框/按钮/下拉默认 40px 高、15px 文字,
|
||||||
|
* 控件是「手上要碰的东西」,必须比正文更实、更大。
|
||||||
|
* ③ 容器有边界:卡片用「白底 + 细描边 + 轻投影」与页面底拉开层次,
|
||||||
|
* 不再追求上一版那种「一张纸铺到底」的无框版式。
|
||||||
|
* ④ 主色单一:全站只有一个强调色(宝石蓝),不做第二强调色。
|
||||||
|
*
|
||||||
|
* 主题通过 html 上的属性决定:
|
||||||
|
* theme-mode = light | dark → 由 theme store 计算后的「实际生效」主题
|
||||||
|
* theme-lang = zh-CN | en-US | ja-JP | ko-KR → 与 lang 属性同步
|
||||||
|
*
|
||||||
|
* 改动本文件前请先读 tdesign-theme.scss 顶部的桥接说明:
|
||||||
|
* 这里只放「我们的」令牌,TDesign 组件的尺度与色阶在那边控制。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* ---------- 与主题无关的常量令牌 ---------- */
|
||||||
|
:root {
|
||||||
|
/* 圆角规范(形状一致性:全站只用这一套)
|
||||||
|
* 控件 10px / 小控件 7px / 弹层 12px / 卡片 16px / 大面板 22px / 胶囊 999px
|
||||||
|
* 比上一版整体加大一档,配合放大的字号,观感更圆润友好。 */
|
||||||
|
--tk-radius-xs: 5px;
|
||||||
|
--tk-radius-sm: 7px;
|
||||||
|
--tk-radius-md: 10px;
|
||||||
|
--tk-radius-lg: 12px;
|
||||||
|
--tk-radius-xl: 16px;
|
||||||
|
--tk-radius-2xl: 22px;
|
||||||
|
--tk-radius-pill: 999px;
|
||||||
|
|
||||||
|
/* 间距节奏(4px 基准,常用档位是 4 的倍数)
|
||||||
|
* 本版留白比「编辑感」版式收敛:字号已经变大,留白再拉满会让页面显得空。 */
|
||||||
|
--tk-space-1: 4px;
|
||||||
|
--tk-space-2: 8px;
|
||||||
|
--tk-space-3: 12px;
|
||||||
|
--tk-space-4: 16px;
|
||||||
|
--tk-space-5: 20px;
|
||||||
|
--tk-space-6: 28px;
|
||||||
|
--tk-space-7: 40px;
|
||||||
|
--tk-space-8: 56px;
|
||||||
|
/* space-9 专门用于「分区之间」的留白 */
|
||||||
|
--tk-space-9: 68px;
|
||||||
|
|
||||||
|
/* 版心与栅格 */
|
||||||
|
--tk-shell-max: 1600px;
|
||||||
|
--tk-content-max: 1200px;
|
||||||
|
--tk-header-h: 68px;
|
||||||
|
--tk-aside-w: 268px;
|
||||||
|
|
||||||
|
/* 字号阶梯(本版的核心改动:整体上移一档)
|
||||||
|
* 旧 → 新:12→13 / 13→14 / 14→16 / 15→17 / 17→19 / 20→22 / 26→28 ...
|
||||||
|
* 请勿在组件里退回 12px:那正是上一版「字太小」的根因。 */
|
||||||
|
--tk-text-xs: 13px;
|
||||||
|
--tk-text-sm: 14px;
|
||||||
|
--tk-text-base: 16px;
|
||||||
|
--tk-text-md: 17px;
|
||||||
|
--tk-text-lg: 19px;
|
||||||
|
--tk-text-xl: 22px;
|
||||||
|
--tk-text-2xl: 28px;
|
||||||
|
--tk-text-3xl: 34px;
|
||||||
|
--tk-text-4xl: 42px;
|
||||||
|
--tk-text-5xl: 52px;
|
||||||
|
|
||||||
|
/* 行高与字距
|
||||||
|
* 规则成对使用:大字标题用 leading-title + 负字距;正文用 leading-body + 正常字距。
|
||||||
|
* 本版负字距比编辑感版式收得更小(-0.022em),因为字号变大后过紧的中文标题会糊。 */
|
||||||
|
--tk-leading-display: 1.12;
|
||||||
|
--tk-leading-title: 1.3;
|
||||||
|
--tk-leading-body: 1.72;
|
||||||
|
--tk-track-display: -0.022em;
|
||||||
|
--tk-track-title: -0.012em;
|
||||||
|
|
||||||
|
/* 展示字体:中文环境下「可用的展示字体」就是系统字体本身
|
||||||
|
*(PingFang / 微软雅黑 的中黑字重在标题上已经足够有性格)。
|
||||||
|
* 引入网络 CJK 展示字体会让首屏多出 2MB+ 负担,得不偿失。
|
||||||
|
* 因此这里显式声明为与正文同族,留出未来替换的单一改点。 */
|
||||||
|
--tk-font-display: var(--tk-font-sans);
|
||||||
|
|
||||||
|
/* 数字等宽(统计值/时间用,避免数字跳动) */
|
||||||
|
--tk-num-font: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Mono', Consolas,
|
||||||
|
'Liberation Mono', monospace;
|
||||||
|
|
||||||
|
/* 层叠上下文规范(禁止在组件里随手写 z-index)
|
||||||
|
* 1 基础 → 90 侧栏 → 100 顶栏 → 200 遮罩 → 300 弹窗 → 400 轻提示 → 500 命令面板 */
|
||||||
|
--tk-z-base: 1;
|
||||||
|
--tk-z-aside: 90;
|
||||||
|
--tk-z-header: 100;
|
||||||
|
--tk-z-overlay: 200;
|
||||||
|
--tk-z-modal: 300;
|
||||||
|
--tk-z-toast: 400;
|
||||||
|
--tk-z-palette: 500;
|
||||||
|
|
||||||
|
/* 动效曲线:进出对称的缓出曲线,时长克制 */
|
||||||
|
--tk-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--tk-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
--tk-dur-fast: 140ms;
|
||||||
|
--tk-dur-base: 220ms;
|
||||||
|
--tk-dur-slow: 380ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 字体栈:按语言切换,保证中日韩各自拿到正确字形 ---------- */
|
||||||
|
html[lang^='zh'] {
|
||||||
|
--tk-font-sans: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB',
|
||||||
|
'Source Han Sans SC', 'Noto Sans SC', 'Microsoft YaHei UI', 'Microsoft YaHei', system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
/* 注意:html 的 lang 属性写的是完整标签(如 ja-JP / ko-KR),
|
||||||
|
* 因此这里必须用前缀匹配,不能用等值匹配。 */
|
||||||
|
html[lang^='ja'] {
|
||||||
|
--tk-font-sans: -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', 'Yu Gothic UI',
|
||||||
|
'Yu Gothic', 'Noto Sans JP', Meiryo, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
html[lang^='ko'] {
|
||||||
|
--tk-font-sans: -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic',
|
||||||
|
'Noto Sans KR', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
html[lang^='en'] {
|
||||||
|
--tk-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', Roboto,
|
||||||
|
'Helvetica Neue', Arial, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
/* 兜底:未匹配到语言时用中性无衬线 */
|
||||||
|
html {
|
||||||
|
--tk-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 浅色主题
|
||||||
|
* ============================================================ */
|
||||||
|
:root,
|
||||||
|
:root[theme-mode='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
/* 强调色:宝石蓝。与白底对比 5.17:1(正文级 AA 通过) */
|
||||||
|
--tk-accent: #2563eb;
|
||||||
|
--tk-accent-hover: #1d4ed8;
|
||||||
|
--tk-accent-active: #1e40af;
|
||||||
|
/* 强调文字(链接/图标):比按钮底色略深,保证在白底上的可读性 */
|
||||||
|
--tk-accent-text: #1d4ed8;
|
||||||
|
--tk-accent-ring: rgba(37, 99, 235, 0.22);
|
||||||
|
/* 低饱和蓝底纹:分类图标底、选中态、标签底 */
|
||||||
|
--tk-accent-surface: rgba(37, 99, 235, 0.07);
|
||||||
|
--tk-accent-surface-strong: rgba(37, 99, 235, 0.13);
|
||||||
|
|
||||||
|
/* 中性表面(冷调蓝灰,与主色同色系)
|
||||||
|
* 页面底刻意比容器暗一档:卡片要靠这个差值「浮」起来,而不是只靠投影。 */
|
||||||
|
--tk-bg: #f4f6fb;
|
||||||
|
--tk-surface: #ffffff;
|
||||||
|
--tk-surface-raised: #ffffff;
|
||||||
|
--tk-surface-sunken: #eef2f9;
|
||||||
|
--tk-surface-hover: #f2f6fd;
|
||||||
|
--tk-surface-active: #e6edfa;
|
||||||
|
|
||||||
|
/* 描边 */
|
||||||
|
--tk-border: #e2e8f3;
|
||||||
|
--tk-border-strong: #ccd7e8;
|
||||||
|
--tk-border-subtle: #edf1f8;
|
||||||
|
/* 发丝线:只用于列表条目的分隔线(历史列表、首页索引),
|
||||||
|
* 不用于容器边界——容器边界请用 --tk-border。 */
|
||||||
|
--tk-hairline: #e9eef7;
|
||||||
|
|
||||||
|
/* 文字:primary 高对比 / secondary 与 placeholder 均过 AA */
|
||||||
|
--tk-text: #0f1729;
|
||||||
|
--tk-text-secondary: #55617a;
|
||||||
|
--tk-text-placeholder: #6e7994;
|
||||||
|
--tk-text-on-accent: #ffffff;
|
||||||
|
|
||||||
|
/* 语义色(仅用于状态,不参与品牌表达) */
|
||||||
|
--tk-success: #047857;
|
||||||
|
--tk-warning: #b45309;
|
||||||
|
--tk-danger: #b91c1c;
|
||||||
|
--tk-info: #1d4ed8;
|
||||||
|
|
||||||
|
/* 阴影:带背景色相的冷调投影,不用纯黑 */
|
||||||
|
--tk-shadow-1: 0 1px 2px rgba(15, 23, 41, 0.06), 0 1px 3px rgba(15, 23, 41, 0.05);
|
||||||
|
--tk-shadow-2: 0 4px 10px rgba(15, 23, 41, 0.08), 0 12px 24px rgba(15, 23, 41, 0.06);
|
||||||
|
--tk-shadow-3: 0 8px 20px rgba(15, 23, 41, 0.1), 0 24px 48px rgba(15, 23, 41, 0.08);
|
||||||
|
|
||||||
|
/* 顶栏/侧栏的半透明玻璃底 */
|
||||||
|
--tk-glass: rgba(255, 255, 255, 0.82);
|
||||||
|
--tk-glass-border: rgba(15, 23, 41, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 深色主题
|
||||||
|
* ============================================================ */
|
||||||
|
:root[theme-mode='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
/* 强调色:深色下按钮底保持 #2563eb(与白字 5.17:1);
|
||||||
|
* 链接/图标另用更亮的 #7aa8ff,保证在深底上可读。 */
|
||||||
|
--tk-accent: #2563eb;
|
||||||
|
--tk-accent-hover: #3b82f6;
|
||||||
|
--tk-accent-active: #1d4ed8;
|
||||||
|
--tk-accent-text: #7aa8ff;
|
||||||
|
--tk-accent-ring: rgba(122, 168, 255, 0.3);
|
||||||
|
--tk-accent-surface: rgba(122, 168, 255, 0.12);
|
||||||
|
--tk-accent-surface-strong: rgba(122, 168, 255, 0.2);
|
||||||
|
|
||||||
|
--tk-bg: #0b0f18;
|
||||||
|
--tk-surface: #131926;
|
||||||
|
--tk-surface-raised: #18202f;
|
||||||
|
--tk-surface-sunken: #18202f;
|
||||||
|
--tk-surface-hover: #1b2333;
|
||||||
|
--tk-surface-active: #222c3f;
|
||||||
|
|
||||||
|
--tk-border: #263147;
|
||||||
|
--tk-border-strong: #35415c;
|
||||||
|
--tk-border-subtle: #1d2536;
|
||||||
|
/* 深色下取比卡片底(#131926)略亮一档,才看得见分隔线但不刺眼 */
|
||||||
|
--tk-hairline: #212a3c;
|
||||||
|
|
||||||
|
--tk-text: #e9eef8;
|
||||||
|
--tk-text-secondary: #a4afc5;
|
||||||
|
--tk-text-placeholder: #7d88a0;
|
||||||
|
--tk-text-on-accent: #ffffff;
|
||||||
|
|
||||||
|
--tk-success: #34d399;
|
||||||
|
--tk-warning: #fbbf24;
|
||||||
|
--tk-danger: #f87171;
|
||||||
|
--tk-info: #7aa8ff;
|
||||||
|
|
||||||
|
--tk-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.32);
|
||||||
|
--tk-shadow-2: 0 4px 10px rgba(0, 0, 0, 0.45), 0 12px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
--tk-shadow-3: 0 8px 20px rgba(0, 0, 0, 0.5), 0 24px 48px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
--tk-glass: rgba(19, 25, 38, 0.82);
|
||||||
|
--tk-glass-border: rgba(233, 238, 248, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 降低动态效果偏好:全站动效降级为瞬时
|
||||||
|
* ============================================================ */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--tk-dur-fast: 0ms;
|
||||||
|
--tk-dur-base: 0ms;
|
||||||
|
--tk-dur-slow: 0ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/components/common/AppBrand.vue
Normal file
77
src/components/common/AppBrand.vue
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 品牌区:可点击回到首页 -->
|
||||||
|
<router-link to="/" class="brand" :aria-label="t('app.backHome')">
|
||||||
|
<span class="brand-mark" aria-hidden="true">
|
||||||
|
<AppIcon />
|
||||||
|
</span>
|
||||||
|
<span class="brand-text">{{ t('app.name') }}</span>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { AppIcon } from 'tdesign-icons-vue-next'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点品牌区(图标标 + 文字标)。
|
||||||
|
*
|
||||||
|
* 图标刻意复用 TDesign 图标库的 AppIcon(九宫格语义,贴合「工具箱」),
|
||||||
|
* 而不是手绘 SVG —— 手绘图标在不同字号下容易变形,也不便与其它图标统一线宽。
|
||||||
|
*
|
||||||
|
* 编辑感版式下品牌标记改用「实色小方块 + 文字标」:
|
||||||
|
* 原来的青玉渐变在这套版式里是唯一一处高饱和色块,会把视线从标题上拽走;
|
||||||
|
* 实色方块更安静,品牌的辨识度改由字重与字距承担(标题族同源)。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
font-size: 21px;
|
||||||
|
color: var(--tk-text-on-accent);
|
||||||
|
background: var(--tk-accent);
|
||||||
|
transition:
|
||||||
|
background-color var(--tk-dur-base) var(--tk-ease-out),
|
||||||
|
transform var(--tk-dur-base) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.brand:hover .brand-mark {
|
||||||
|
background: var(--tk-accent-hover);
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
font-family: var(--tk-font-display);
|
||||||
|
font-size: var(--tk-text-lg);
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: var(--tk-track-title);
|
||||||
|
color: var(--tk-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏优先保留图标标,长站点名会挤占主题/语言按钮的空间 */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.brand-text {
|
||||||
|
font-size: var(--tk-text-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.brand-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
src/components/common/AppImageViewer.vue
Normal file
36
src/components/common/AppImageViewer.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 全站统一的图片预览器:模态展示、点遮罩可关、ESC 关闭,缩放/下载由 TDesign 内置 -->
|
||||||
|
<t-image-viewer
|
||||||
|
:visible="visible"
|
||||||
|
:images="images"
|
||||||
|
:index="index"
|
||||||
|
:z-index="3000"
|
||||||
|
close-on-overlay
|
||||||
|
@update:visible="emit('update:visible', $event)"
|
||||||
|
@update:index="emit('update:index', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PreviewImage } from '@/composables/useImageViewer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppImageViewer:t-image-viewer 的统一薄封装。
|
||||||
|
* 用法(配合 useImageViewer):
|
||||||
|
* <AppImageViewer v-model:visible="visible.value" v-model:index="index.value" :images="images.value" />
|
||||||
|
* 模板中 ref 自动解包,直接写 visible / index 即可。
|
||||||
|
*/
|
||||||
|
defineProps<{
|
||||||
|
/** 是否显示(v-model:visible) */
|
||||||
|
visible: boolean
|
||||||
|
/** 预览图片组 */
|
||||||
|
images: PreviewImage[]
|
||||||
|
/** 当前下标(v-model:index) */
|
||||||
|
index?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [v: boolean]
|
||||||
|
'update:index': [i: number]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
80
src/components/common/BackendPending.vue
Normal file
80
src/components/common/BackendPending.vue
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pending">
|
||||||
|
<span class="pending-icon" aria-hidden="true">
|
||||||
|
<CloudIcon />
|
||||||
|
</span>
|
||||||
|
<div class="pending-body">
|
||||||
|
<p class="pending-title">{{ title ?? t('pending.title') }}</p>
|
||||||
|
<p class="pending-hint">{{ message ?? t('pending.hint') }}</p>
|
||||||
|
<p class="pending-ref">{{ t('pending.contactHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { CloudIcon } from 'tdesign-icons-vue-next'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「后端接口尚未实现」提示块。
|
||||||
|
*
|
||||||
|
* 为什么不用 t-alert 的告警样式:这不是错误,而是「功能尚未上线」的常态。
|
||||||
|
* 用错误色会误导用户以为站点坏了;这里用中性的信息态,
|
||||||
|
* 并明确说明「前端已就绪、后端实现后自动生效」,让用户知道下一步该等什么。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 自定义说明文案;不传时使用通用提示 */
|
||||||
|
message?: string
|
||||||
|
/** 自定义标题;不传时使用通用标题 */
|
||||||
|
title?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pending {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: var(--tk-space-5);
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid var(--tk-border);
|
||||||
|
border-left: 3px solid var(--tk-accent);
|
||||||
|
border-radius: var(--tk-radius-lg);
|
||||||
|
background: var(--tk-surface-sunken);
|
||||||
|
}
|
||||||
|
.pending-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
font-size: 19px;
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
background: var(--tk-surface);
|
||||||
|
}
|
||||||
|
.pending-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.pending-title {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
font-size: var(--tk-text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.pending-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
.pending-ref {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
471
src/components/common/CommandPalette.vue
Normal file
471
src/components/common/CommandPalette.vue
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 命令面板:Teleport 到 body,避免被侧栏/顶栏的层叠上下文裁切 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="palette">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="palette-mask"
|
||||||
|
role="presentation"
|
||||||
|
@mousedown.self="close()"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="panelRef"
|
||||||
|
class="palette-panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="t('palette.title')"
|
||||||
|
>
|
||||||
|
<!-- 搜索行:图标 + 原生输入框 + 关闭提示 -->
|
||||||
|
<div class="palette-search">
|
||||||
|
<SearchIcon class="search-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="keyword"
|
||||||
|
class="search-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
:placeholder="t('palette.placeholder')"
|
||||||
|
:aria-label="t('palette.placeholder')"
|
||||||
|
:aria-expanded="results.length > 0"
|
||||||
|
aria-controls="palette-listbox"
|
||||||
|
role="combobox"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
<button class="close-btn" type="button" :aria-label="t('palette.hintClose')" @click="close()">
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果 listbox -->
|
||||||
|
<div v-if="results.length" id="palette-listbox" class="palette-list" role="listbox">
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in results"
|
||||||
|
:key="item.path"
|
||||||
|
:ref="(el) => setItemRef(el, index)"
|
||||||
|
class="palette-item"
|
||||||
|
:class="{ active: index === activeIndex }"
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="index === activeIndex"
|
||||||
|
@mouseenter="activeIndex = index"
|
||||||
|
@click="go(item.path)"
|
||||||
|
>
|
||||||
|
<span class="item-icon" aria-hidden="true">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</span>
|
||||||
|
<span class="item-body">
|
||||||
|
<span class="item-name">{{ item.name }}</span>
|
||||||
|
<span class="item-desc">{{ item.desc }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="item.needsNetwork" class="item-badge">{{ t('home.serverBadge') }}</span>
|
||||||
|
<span class="item-category">{{ item.categoryLabel }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态:给出可用关键词示例,而不是只写「无结果」 -->
|
||||||
|
<div v-else class="palette-empty">
|
||||||
|
<p class="empty-title">{{ t('palette.empty') }}</p>
|
||||||
|
<p class="empty-hint">{{ t('palette.emptyHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部快捷键提示:这是面板唯一的操作说明,不放多余文案 -->
|
||||||
|
<div class="palette-foot">
|
||||||
|
<span class="foot-item"><kbd>↑</kbd><kbd>↓</kbd>{{ t('palette.hintNavigate') }}</span>
|
||||||
|
<span class="foot-item"><kbd>Enter</kbd>{{ t('palette.hintNavigate') }}</span>
|
||||||
|
<span class="foot-item"><kbd>Esc</kbd>{{ t('palette.hintClose') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { SearchIcon, CloseIcon, HomeIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import { tools, toolCategoryKey, toolCategories } from '@/router/tools'
|
||||||
|
import { toolName, toolDesc, categoryLabel } from '@/i18n/helpers'
|
||||||
|
import { useCommandPalette } from '@/composables/useCommandPalette'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令面板(⌘K / Ctrl+K)。
|
||||||
|
*
|
||||||
|
* 56 个工具靠侧栏滚动查找效率很低,命令面板是这类工具站的标配入口:
|
||||||
|
* 键盘可达、可搜索、能跨分类直达,且不占用常驻版式空间。
|
||||||
|
*
|
||||||
|
* 实现要点:
|
||||||
|
* - 结果列表是「扁平 + 分类标签」而不是按分类分组的多级列表,
|
||||||
|
* 搜索场景下用户关心的是「哪个工具」,分组标题只会增加视觉噪音。
|
||||||
|
* - 键盘上下键移动高亮项,Enter 跳转,Esc 关闭;鼠标悬停同步高亮,
|
||||||
|
* 保证鼠标与键盘不会各自维护一套选中位置。
|
||||||
|
* - 打开时锁 body 滚动并聚焦输入框,关闭时恢复,避免面板下方页面跟着滚。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const { visible, close } = useCommandPalette()
|
||||||
|
|
||||||
|
const keyword = ref('')
|
||||||
|
const activeIndex = ref(0)
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const itemRefs = ref<HTMLElement[]>([])
|
||||||
|
|
||||||
|
/** 分类 key → 分类展示名,避免在结果循环里反复查表 */
|
||||||
|
const categoryLabelMap = computed(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const c of toolCategories) map[c.key] = categoryLabel(c.key, c.label)
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
interface PaletteItem {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
icon: unknown
|
||||||
|
needsNetwork: boolean
|
||||||
|
categoryLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页入口 + 全部工具,每次语言变化时重新取词 */
|
||||||
|
const allItems = computed<PaletteItem[]>(() => [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: t('nav.home'),
|
||||||
|
desc: t('home.title'),
|
||||||
|
icon: HomeIcon,
|
||||||
|
needsNetwork: false,
|
||||||
|
categoryLabel: t('palette.groupPage'),
|
||||||
|
},
|
||||||
|
...tools.map((tool) => ({
|
||||||
|
path: `/${tool.path}`,
|
||||||
|
name: toolName(tool.path, tool.name),
|
||||||
|
desc: toolDesc(tool.path, tool.desc),
|
||||||
|
icon: tool.icon,
|
||||||
|
needsNetwork: Boolean(tool.needsNetwork),
|
||||||
|
categoryLabel: categoryLabelMap.value[toolCategoryKey(tool)] ?? '',
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 搜索结果:空关键词时展示全部,有关键词时按名称/描述/路径/分类匹配 */
|
||||||
|
const results = computed<PaletteItem[]>(() => {
|
||||||
|
const kw = keyword.value.trim().toLowerCase()
|
||||||
|
if (!kw) return allItems.value
|
||||||
|
return allItems.value.filter((item) =>
|
||||||
|
[item.name, item.desc, item.path, item.categoryLabel].join(' ').toLowerCase().includes(kw),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 结果变化后把高亮重置到第一项,否则可能出现索引越界或高亮停在末尾 */
|
||||||
|
watch(results, () => {
|
||||||
|
activeIndex.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 面板打开时聚焦输入框并清空上次的关键词 */
|
||||||
|
watch(visible, async (open) => {
|
||||||
|
if (!open) return
|
||||||
|
keyword.value = ''
|
||||||
|
activeIndex.value = 0
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
await nextTick()
|
||||||
|
inputRef.value?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 关闭时恢复页面滚动
|
||||||
|
watch(visible, (open) => {
|
||||||
|
if (!open) document.body.style.overflow = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function setItemRef(el: unknown, index: number) {
|
||||||
|
if (el instanceof HTMLElement) itemRefs.value[index] = el
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 高亮项滚进可视区,避免键盘翻页时选中项跑到屏幕外 */
|
||||||
|
function scrollActiveIntoView() {
|
||||||
|
void nextTick(() => {
|
||||||
|
itemRefs.value[activeIndex.value]?.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!results.value.length) return
|
||||||
|
activeIndex.value = (activeIndex.value + 1) % results.value.length
|
||||||
|
scrollActiveIntoView()
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!results.value.length) return
|
||||||
|
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
|
||||||
|
scrollActiveIntoView()
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const target = results.value[activeIndex.value]
|
||||||
|
if (target) go(target.path)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(path: string) {
|
||||||
|
close()
|
||||||
|
void router.push(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局快捷键:⌘K(macOS)/ Ctrl+K(Windows/Linux)。
|
||||||
|
* 面板已打开时再按一次等价于关闭,符合「开关」直觉。
|
||||||
|
*/
|
||||||
|
function onGlobalKeydown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
visible.value ? close() : (visible.value = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onGlobalKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onGlobalKeydown)
|
||||||
|
// 组件被卸载时若仍处于打开状态,必须把 body 滚动恢复,否则页面会永久无法滚动
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.palette-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: var(--tk-z-palette);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
/* 顶部留出 12vh:面板靠上更接近视线起点,也比垂直居中更容易触达键盘 */
|
||||||
|
padding: 12vh 16px 16px;
|
||||||
|
background: color-mix(in srgb, var(--tk-bg) 55%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 680px;
|
||||||
|
max-height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--tk-border);
|
||||||
|
border-radius: var(--tk-radius-2xl);
|
||||||
|
background: var(--tk-surface-raised);
|
||||||
|
box-shadow: var(--tk-shadow-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 搜索行 ---------- */
|
||||||
|
.palette-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 14px 0 18px;
|
||||||
|
height: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--tk-border-subtle);
|
||||||
|
}
|
||||||
|
.search-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--tk-text-lg);
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--tk-surface-hover);
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 结果列表 ---------- */
|
||||||
|
.palette-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.palette-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.palette-item.active {
|
||||||
|
background: var(--tk-accent-surface);
|
||||||
|
}
|
||||||
|
.item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
font-size: 19px;
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
background: var(--tk-surface-sunken);
|
||||||
|
}
|
||||||
|
.palette-item.active .item-icon {
|
||||||
|
background: var(--tk-surface-raised);
|
||||||
|
}
|
||||||
|
.item-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.item-name {
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--tk-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.item-desc {
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.item-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: var(--tk-radius-pill);
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
background: var(--tk-accent-surface-strong);
|
||||||
|
}
|
||||||
|
.item-category {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 空状态 ---------- */
|
||||||
|
.palette-empty {
|
||||||
|
padding: 40px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: var(--tk-text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.empty-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 底部快捷键提示 ---------- */
|
||||||
|
.palette-foot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-top: 1px solid var(--tk-border-subtle);
|
||||||
|
background: var(--tk-surface);
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.foot-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.palette-foot kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid var(--tk-border);
|
||||||
|
border-radius: var(--tk-radius-xs);
|
||||||
|
background: var(--tk-surface-raised);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 进出场:轻微上浮 + 淡入,遵守降低动态效果偏好 ---------- */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.palette-enter-active,
|
||||||
|
.palette-leave-active {
|
||||||
|
transition: opacity var(--tk-dur-base) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.palette-enter-active .palette-panel,
|
||||||
|
.palette-leave-active .palette-panel {
|
||||||
|
transition: transform var(--tk-dur-base) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.palette-enter-from,
|
||||||
|
.palette-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.palette-enter-from .palette-panel,
|
||||||
|
.palette-leave-to .palette-panel {
|
||||||
|
transform: translateY(-10px) scale(0.985);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端:面板占满宽度,减少上下留白以争取更多列表高度 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.palette-mask {
|
||||||
|
padding: 6vh 8px 8px;
|
||||||
|
}
|
||||||
|
.palette-panel {
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
.item-category {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -2,7 +2,9 @@
|
|||||||
<div v-if="props.items.length" class="history-panel">
|
<div v-if="props.items.length" class="history-panel">
|
||||||
<div class="history-head">
|
<div class="history-head">
|
||||||
<span class="history-title">历史记录</span>
|
<span class="history-title">历史记录</span>
|
||||||
<t-button variant="text" size="small" theme="danger" @click="emit('clear')">清空</t-button>
|
<t-button variant="text" theme="danger" class="history-clear" @click="emit('clear')">
|
||||||
|
清空
|
||||||
|
</t-button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="history-list">
|
<ul class="history-list">
|
||||||
<li
|
<li
|
||||||
@ -56,19 +58,29 @@ function formatTime(ts: number): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 历史记录跟随全站的编辑感版式:条目之间用发丝线分行,
|
||||||
|
* 不再给每条历史套一个描边盒子(列表一长就会变成一排小方块)。
|
||||||
|
* 注意这里必须用 --tk-* 令牌,写死色值会在昼夜切换时花掉。 */
|
||||||
.history-panel {
|
.history-panel {
|
||||||
margin-top: 16px;
|
margin-top: var(--tk-space-6);
|
||||||
}
|
}
|
||||||
.history-head {
|
.history-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 8px;
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.history-title {
|
.history-title {
|
||||||
font-size: 14px;
|
font-size: var(--tk-text-base);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--td-text-color-primary);
|
letter-spacing: var(--tk-track-title);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
/* 清空按钮:给足热区,但压住高度避免把标题行撑得过重 */
|
||||||
|
.history-clear {
|
||||||
|
min-height: 34px;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
}
|
}
|
||||||
.history-list {
|
.history-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -78,17 +90,12 @@ function formatTime(ts: number): string {
|
|||||||
.history-item {
|
.history-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
padding: 6px 10px;
|
min-height: 48px;
|
||||||
margin-bottom: 6px;
|
padding: 10px 2px;
|
||||||
border: 1px solid var(--td-border-level-1-color);
|
border-top: 1px solid var(--tk-hairline);
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--td-bg-color-container);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s;
|
transition: color var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
}
|
|
||||||
.history-item:hover {
|
|
||||||
border-color: var(--td-brand-color);
|
|
||||||
}
|
}
|
||||||
.history-text {
|
.history-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -96,15 +103,25 @@ function formatTime(ts: number): string {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 13px;
|
font-size: var(--tk-text-base);
|
||||||
color: var(--td-text-color-primary);
|
color: var(--tk-text-secondary);
|
||||||
|
transition: color var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.history-item:hover .history-text {
|
||||||
|
color: var(--tk-accent-text);
|
||||||
}
|
}
|
||||||
.history-time {
|
.history-time {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 12px;
|
font-family: var(--tk-num-font);
|
||||||
color: var(--td-text-color-placeholder);
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
}
|
}
|
||||||
|
/* 历史条目里的删除按钮热区撑到 34px,避免在密集列表里点不准 */
|
||||||
.history-del {
|
.history-del {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
98
src/components/common/LocaleSwitch.vue
Normal file
98
src/components/common/LocaleSwitch.vue
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<t-dropdown
|
||||||
|
trigger="click"
|
||||||
|
:options="menuOptions"
|
||||||
|
:max-height="240"
|
||||||
|
@click="onSelect"
|
||||||
|
>
|
||||||
|
<t-button
|
||||||
|
variant="text"
|
||||||
|
class="switch-btn"
|
||||||
|
:title="`${t('locale.label')}:${currentLabel}`"
|
||||||
|
:aria-label="t('locale.label')"
|
||||||
|
>
|
||||||
|
<template #icon><TranslateIcon /></template>
|
||||||
|
<span class="switch-text">{{ currentCode }}</span>
|
||||||
|
<ChevronDownIcon class="switch-caret" />
|
||||||
|
</t-button>
|
||||||
|
</t-dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { TranslateIcon, ChevronDownIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import { useLocaleStore } from '@/stores/locale'
|
||||||
|
import { syncDocumentTitle } from '@/router'
|
||||||
|
import type { AppLocale } from '@/i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言切换器(简体中文 / English / 日本語 / 한국어)。
|
||||||
|
*
|
||||||
|
* 交互取舍:用下拉菜单而不是逐次点击循环切换。
|
||||||
|
* 语言有 4 种且用户能明确知道自己要哪种,直接选择比「点三次才轮到」更省事,
|
||||||
|
* 也和右侧主题切换器保持一致的交互模式。
|
||||||
|
*
|
||||||
|
* 按钮上显示语言代码(ZH / EN / JA / KO)而不是国旗:
|
||||||
|
* 语言不等于国家,用国旗会让繁体中文、多语种地区的用户产生歧义。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
const localeStore = useLocaleStore()
|
||||||
|
|
||||||
|
/** 当前语言的按钮短码(中文用 ZH 而非国旗,避免语言与国家的绑定歧义) */
|
||||||
|
const currentCode = computed(() => localeStore.locale.split('-')[0].toUpperCase())
|
||||||
|
|
||||||
|
const currentLabel = computed(() => t(`locale.${localeStore.locale}`))
|
||||||
|
|
||||||
|
const menuOptions = computed(() =>
|
||||||
|
localeStore.options.map((opt) => ({
|
||||||
|
content: opt.label,
|
||||||
|
value: opt.value,
|
||||||
|
// 当前语言在菜单里打勾,方便确认
|
||||||
|
active: opt.value === localeStore.locale,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function onSelect(item: { value?: string | number }) {
|
||||||
|
const next = item.value
|
||||||
|
if (typeof next !== 'string') return
|
||||||
|
localeStore.set(next as AppLocale)
|
||||||
|
// 语言变了,浏览器标题也要跟着换,否则标签页会停留在旧语言
|
||||||
|
syncDocumentTitle()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.switch-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 12px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
}
|
||||||
|
.switch-btn:hover {
|
||||||
|
color: var(--tk-text);
|
||||||
|
background: var(--tk-surface-hover);
|
||||||
|
}
|
||||||
|
.switch-text {
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
/* 语言代码固定宽度,切换时按钮不会左右跳动 */
|
||||||
|
min-width: 26px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.switch-caret {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
/* 窄屏只留图标,避免顶栏拥挤 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.switch-text,
|
||||||
|
.switch-caret {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
84
src/components/common/ThemeSwitch.vue
Normal file
84
src/components/common/ThemeSwitch.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<t-dropdown trigger="click" :options="menuOptions" :max-height="240" @click="onSelect">
|
||||||
|
<t-button
|
||||||
|
variant="text"
|
||||||
|
class="switch-btn"
|
||||||
|
:title="`${t('theme.label')}:${currentLabel}`"
|
||||||
|
:aria-label="t('theme.label')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<SunnyIcon v-if="theme.mode === 'light'" />
|
||||||
|
<MoonIcon v-else />
|
||||||
|
</template>
|
||||||
|
<span class="switch-text">{{ currentLabel }}</span>
|
||||||
|
<ChevronDownIcon class="switch-caret" />
|
||||||
|
</t-button>
|
||||||
|
</t-dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { SunnyIcon, MoonIcon, ChevronDownIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import type { ThemePreference } from '@/stores/theme'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题切换器:浅色 / 深色 / 跟随系统 三态。
|
||||||
|
*
|
||||||
|
* 「跟随系统」单独作为一态而不是「默认行为」:
|
||||||
|
* 用户手动选过之后,我们希望他仍然能一键交回给系统,
|
||||||
|
* 而不是被迫在浅色和深色之间二选一。
|
||||||
|
*
|
||||||
|
* 按钮图标显示的是「当前实际生效」的主题(theme.mode),
|
||||||
|
* 菜单里勾选的才是用户偏好(theme.preference),
|
||||||
|
* 跟随系统时两者可能不一致,这样用户能一眼看出实际在用什么。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
const theme = useThemeStore()
|
||||||
|
|
||||||
|
const currentLabel = computed(() => t(`theme.${theme.preference}`))
|
||||||
|
|
||||||
|
const menuOptions = computed(() => [
|
||||||
|
{ content: t('theme.light'), value: 'light', active: theme.preference === 'light' },
|
||||||
|
{ content: t('theme.dark'), value: 'dark', active: theme.preference === 'dark' },
|
||||||
|
{ content: t('theme.auto'), value: 'auto', active: theme.preference === 'auto' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function onSelect(item: { value?: string | number }) {
|
||||||
|
const next = item.value
|
||||||
|
if (next === 'light' || next === 'dark' || next === 'auto') {
|
||||||
|
theme.set(next as ThemePreference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.switch-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 12px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
}
|
||||||
|
.switch-btn:hover {
|
||||||
|
color: var(--tk-text);
|
||||||
|
background: var(--tk-surface-hover);
|
||||||
|
}
|
||||||
|
.switch-text {
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.switch-caret {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.switch-text,
|
||||||
|
.switch-caret {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -33,18 +33,28 @@
|
|||||||
<!-- 已选文件列表(图片自动生成缩略图回显) -->
|
<!-- 已选文件列表(图片自动生成缩略图回显) -->
|
||||||
<div v-else class="file-list" @click.stop>
|
<div v-else class="file-list" @click.stop>
|
||||||
<div v-for="(f, i) in props.files" :key="`${f.name}-${f.size}-${i}`" class="file-item">
|
<div v-for="(f, i) in props.files" :key="`${f.name}-${f.size}-${i}`" class="file-item">
|
||||||
<img v-if="previews[i]" :src="previews[i]" alt="" class="thumb" />
|
<img
|
||||||
|
v-if="previews[i]"
|
||||||
|
:src="previews[i]"
|
||||||
|
alt=""
|
||||||
|
class="thumb thumb-clickable"
|
||||||
|
title="点击预览大图"
|
||||||
|
@click.stop="openPreview(i)"
|
||||||
|
/>
|
||||||
<span v-else class="thumb thumb-icon"><FileIcon /></span>
|
<span v-else class="thumb thumb-icon"><FileIcon /></span>
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
<span class="name" :title="f.name">{{ f.name }}</span>
|
<span class="name" :title="f.name">{{ f.name }}</span>
|
||||||
<span class="size">{{ formatBytes(f.size) }}</span>
|
<span class="size">{{ formatBytes(f.size) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<t-button variant="text" size="small" theme="danger" class="remove-btn" @click.stop="removeAt(i)">
|
<t-button variant="text" theme="danger" class="remove-btn" @click.stop="removeAt(i)">
|
||||||
<template #icon><DeleteIcon /></template>
|
<template #icon><DeleteIcon /></template>
|
||||||
</t-button>
|
</t-button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="props.multiple && props.files.length < props.max" class="add-more">点击可继续添加文件</p>
|
<p v-if="props.multiple && props.files.length < props.max" class="add-more">点击可继续添加文件</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 点击缩略图放大预览(TDesign ImageViewer) -->
|
||||||
|
<AppImageViewer v-model:visible="visible" v-model:index="index" :images="images" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -52,6 +62,8 @@
|
|||||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { DeleteIcon, FileIcon, UploadIcon } from 'tdesign-icons-vue-next'
|
import { DeleteIcon, FileIcon, UploadIcon } from 'tdesign-icons-vue-next'
|
||||||
import { formatBytes } from '@/utils'
|
import { formatBytes } from '@/utils'
|
||||||
|
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||||||
|
import { useImageViewer } from '@/composables/useImageViewer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用拖拽上传组件(替代 t-upload 文件模式):
|
* 通用拖拽上传组件(替代 t-upload 文件模式):
|
||||||
@ -85,6 +97,14 @@ const dragging = ref(false)
|
|||||||
/** 图片缩略图的对象 URL(与 files 一一对应,非图片为空串) */
|
/** 图片缩略图的对象 URL(与 files 一一对应,非图片为空串) */
|
||||||
const previews = ref<string[]>([])
|
const previews = ref<string[]>([])
|
||||||
|
|
||||||
|
/** 点击缩略图放大预览:图片组取所有非空缩略图,定位到当前点击项 */
|
||||||
|
const { visible, index, images, open } = useImageViewer()
|
||||||
|
function openPreview(i: number) {
|
||||||
|
// 计算「当前缩略图在所有图片缩略图中的序号」,保证预览定位正确
|
||||||
|
const imgIdx = previews.value.slice(0, i + 1).filter(Boolean).length - 1
|
||||||
|
open(previews.value.filter(Boolean), imgIdx)
|
||||||
|
}
|
||||||
|
|
||||||
// 深度计数:避免拖过子元素时 dragleave 导致高亮闪烁
|
// 深度计数:避免拖过子元素时 dragleave 导致高亮闪烁
|
||||||
let dragDepth = 0
|
let dragDepth = 0
|
||||||
|
|
||||||
@ -172,13 +192,13 @@ function removeAt(i: number) {
|
|||||||
.drop-zone {
|
.drop-zone {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 2px dashed var(--td-border-level-2-color);
|
border: 2px dashed var(--td-border-level-2-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--tk-radius-lg);
|
||||||
padding: 24px 16px;
|
padding: 32px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s,
|
border-color var(--tk-dur-fast) var(--tk-ease-out),
|
||||||
background-color 0.2s;
|
background-color var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
}
|
}
|
||||||
.drop-zone:hover,
|
.drop-zone:hover,
|
||||||
.drop-zone.dragging {
|
.drop-zone.dragging {
|
||||||
@ -189,17 +209,17 @@ function removeAt(i: number) {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
font-size: 40px;
|
font-size: 46px;
|
||||||
color: var(--td-brand-color);
|
color: var(--td-brand-color);
|
||||||
}
|
}
|
||||||
.tip {
|
.tip {
|
||||||
margin: 8px 0 4px;
|
margin: 10px 0 6px;
|
||||||
font-size: 14px;
|
font-size: var(--tk-text-md);
|
||||||
color: var(--td-text-color-primary);
|
color: var(--td-text-color-primary);
|
||||||
}
|
}
|
||||||
.sub {
|
.sub {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-sm);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.drop-overlay {
|
.drop-overlay {
|
||||||
@ -213,10 +233,10 @@ function removeAt(i: number) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
border-radius: 8px;
|
border-radius: var(--tk-radius-lg);
|
||||||
font-size: 15px;
|
font-size: var(--tk-text-md);
|
||||||
font-weight: 500;
|
font-weight: 550;
|
||||||
color: var(--td-brand-color);
|
color: var(--td-brand-color);
|
||||||
background: var(--td-brand-color-light);
|
background: var(--td-brand-color-light);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@ -224,22 +244,22 @@ function removeAt(i: number) {
|
|||||||
.file-list {
|
.file-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.file-item {
|
.file-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
padding: 8px 10px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--td-border-level-1-color);
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
border-radius: 6px;
|
border-radius: var(--tk-radius-md);
|
||||||
background: var(--td-bg-color-container);
|
background: var(--td-bg-color-container);
|
||||||
}
|
}
|
||||||
.thumb {
|
.thumb {
|
||||||
width: 48px;
|
width: 56px;
|
||||||
height: 48px;
|
height: 56px;
|
||||||
border-radius: 6px;
|
border-radius: var(--tk-radius-sm);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 1px solid var(--td-border-level-1-color);
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
@ -248,34 +268,47 @@ function removeAt(i: number) {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 22px;
|
font-size: 24px;
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
background: var(--td-bg-color-secondarycontainer);
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
}
|
}
|
||||||
|
/* 可点击预览的缩略图 */
|
||||||
|
.thumb-clickable {
|
||||||
|
cursor: zoom-in;
|
||||||
|
transition: transform var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.thumb-clickable:hover {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
.meta {
|
.meta {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
.name {
|
.name {
|
||||||
font-size: 13px;
|
font-size: var(--tk-text-base);
|
||||||
color: var(--td-text-color-primary);
|
color: var(--td-text-color-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.size {
|
.size {
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-sm);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
|
/* 删除按钮:t-button size=small 在这版主题下只有 24px,热区太小,
|
||||||
|
* 这里把它撑到 36px,仍保持「图标按钮」的形态 */
|
||||||
.remove-btn {
|
.remove-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
.add-more {
|
.add-more {
|
||||||
margin: 2px 0 0;
|
margin: 2px 0 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-sm);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/composables/useCommandPalette.ts
Normal file
23
src/composables/useCommandPalette.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令面板的共享开关状态。
|
||||||
|
*
|
||||||
|
* 之所以放在模块作用域而不是 Pinia:它只有一个布尔值,没有派生逻辑
|
||||||
|
* 也不需要跨 store 协作,用模块级 ref 最轻。顶栏的入口按钮、首页的搜索框
|
||||||
|
* 和面板组件本身都引用同一份状态,天然保持同步。
|
||||||
|
*/
|
||||||
|
const visible = ref(false)
|
||||||
|
|
||||||
|
export function useCommandPalette() {
|
||||||
|
function open() {
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
visible.value = !visible.value
|
||||||
|
}
|
||||||
|
return { visible, open, close, toggle }
|
||||||
|
}
|
||||||
36
src/composables/useImageViewer.ts
Normal file
36
src/composables/useImageViewer.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/** 可预览的图片:URL 字符串或 File 对象(TDesign ImageViewer 原生支持两种) */
|
||||||
|
export type PreviewImage = string | File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片预览组合式函数:全站各工具页共用。
|
||||||
|
* 典型用法:
|
||||||
|
* const { visible, index, images, open } = useImageViewer()
|
||||||
|
* <AppImageViewer v-model:visible="visible.value" ... />(模板中 ref 自动解包)
|
||||||
|
* 点击图片时 open(urls, i) 打开预览。
|
||||||
|
*/
|
||||||
|
export function useImageViewer() {
|
||||||
|
/** 是否显示预览 */
|
||||||
|
const visible = ref(false)
|
||||||
|
/** 当前预览的下标 */
|
||||||
|
const index = ref(0)
|
||||||
|
/** 预览的图片组(支持左右切换) */
|
||||||
|
const images = ref<PreviewImage[]>([])
|
||||||
|
|
||||||
|
/** 打开预览:传入图片组与起始下标 */
|
||||||
|
function open(imgs: PreviewImage[], startIndex = 0) {
|
||||||
|
const list = imgs.filter(Boolean)
|
||||||
|
if (!list.length) return
|
||||||
|
images.value = list
|
||||||
|
index.value = Math.min(Math.max(startIndex, 0), list.length - 1)
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭预览(一般由组件内部关闭,无需手动调用) */
|
||||||
|
function close() {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { visible, index, images, open, close }
|
||||||
|
}
|
||||||
24
src/config.ts
Normal file
24
src/config.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 站点级配置(唯一真相源)。
|
||||||
|
*
|
||||||
|
* 这里只放「跨页面复用、且改动时需要全站一致」的常量。
|
||||||
|
* 凡是会出现在多个页面的站点级信息(名称、备案号、外链地址),
|
||||||
|
* 一律放这里,不要在各页面里散着写死。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SITE = {
|
||||||
|
/** 站点名称(用于文档标题兜底、页脚版权等) */
|
||||||
|
name: 'XPcool 工具箱',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICP 备案号。
|
||||||
|
*
|
||||||
|
* 同域名下的各子站(xpcool.com / home / tool / admin)共用同一主体备案号,
|
||||||
|
* 因此这个值必须与主站 src/config.ts 的 SITE.icp 保持一致 ——
|
||||||
|
* 换主体或增号时,两处一起改。
|
||||||
|
*/
|
||||||
|
icp: '黔ICP备2026003933号',
|
||||||
|
|
||||||
|
/** 备案核验入口(工信部统一查询页) */
|
||||||
|
beianUrl: 'https://beian.miit.gov.cn/',
|
||||||
|
} as const
|
||||||
62
src/directives/reveal.ts
Normal file
62
src/directives/reveal.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import type { Directive, DirectiveBinding } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v-reveal:元素滚动进入视口时轻微上浮淡入。
|
||||||
|
*
|
||||||
|
* 为什么用 IntersectionObserver 而不是监听 scroll 事件:
|
||||||
|
* scroll 事件每帧触发,会被动引起大量布局计算与重绘;
|
||||||
|
* IntersectionObserver 由浏览器在合成层判定,几乎零成本。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* v-reveal —— 默认动效
|
||||||
|
* v-reveal="index * 40" —— 传入数字作为延迟毫秒数,做列表错峰出现
|
||||||
|
*
|
||||||
|
* 降级:浏览器不支持 IntersectionObserver 时直接显示,不会白屏。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 已初始化的观察器(全局单例,避免每个元素各建一个) */
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function ensureObserver(): IntersectionObserver | null {
|
||||||
|
if (typeof IntersectionObserver === 'undefined') return null
|
||||||
|
if (observer) return observer
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue
|
||||||
|
const el = entry.target as HTMLElement
|
||||||
|
el.classList.add('is-revealed')
|
||||||
|
// 只播一次:出现后立即停止观察,避免来回滚动反复触发
|
||||||
|
observer?.unobserve(el)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 元素露出 12% 即触发,比 0 更早,观感上不会「等到全出来才动」
|
||||||
|
threshold: 0.12,
|
||||||
|
// 底部预留 40px:元素刚贴到视口下沿就开始动,避免看起来迟滞
|
||||||
|
rootMargin: '0px 0px -40px 0px',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return observer
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reveal: Directive<HTMLElement, number | undefined> = {
|
||||||
|
mounted(el: HTMLElement, binding: DirectiveBinding<number | undefined>) {
|
||||||
|
el.classList.add('reveal')
|
||||||
|
|
||||||
|
// 传入数字时作为延迟,形成错峰出现的节奏
|
||||||
|
const delay = typeof binding.value === 'number' ? binding.value : 0
|
||||||
|
if (delay > 0) el.style.transitionDelay = `${delay}ms`
|
||||||
|
|
||||||
|
const io = ensureObserver()
|
||||||
|
if (!io) {
|
||||||
|
// 不支持则直接显示
|
||||||
|
el.classList.add('is-revealed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.observe(el)
|
||||||
|
},
|
||||||
|
unmounted(el: HTMLElement) {
|
||||||
|
observer?.unobserve(el)
|
||||||
|
},
|
||||||
|
}
|
||||||
50
src/i18n/helpers.ts
Normal file
50
src/i18n/helpers.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { i18n } from './index'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具注册表(router/tools.ts)的国际化取词助手。
|
||||||
|
*
|
||||||
|
* 设计取舍:中文名称与描述仍以 tools.ts 为「唯一数据源」保留在注册表里,
|
||||||
|
* 语言包只负责提供其它语言的翻译。取值时先查语言包,查不到就回退注册表原文,
|
||||||
|
* 这样即使某个工具忘了补翻译,页面也不会显示成空的 key。
|
||||||
|
*
|
||||||
|
* 这些函数直接使用 i18n.global,因此在 setup 之外(如路由守卫、工具函数)也能调用;
|
||||||
|
* 在组件的模板或 computed 中调用同样具备响应式能力(t 内部会读取 locale)。
|
||||||
|
*/
|
||||||
|
function translateOr(key: string, fallback: string): string {
|
||||||
|
if (i18n.global.te(key)) {
|
||||||
|
const value = i18n.global.t(key)
|
||||||
|
// t() 在 key 存在但值为空字符串时也会返回空串,这里一并用兜底值覆盖
|
||||||
|
if (value) return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取工具名称(已翻译;缺失时回退注册表中文名) */
|
||||||
|
export function toolName(path: string, fallback: string): string {
|
||||||
|
return translateOr(`tool.${path}.name`, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取工具描述(已翻译;缺失时回退注册表中文描述) */
|
||||||
|
export function toolDesc(path: string, fallback: string): string {
|
||||||
|
return translateOr(`tool.${path}.desc`, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取分类名称 */
|
||||||
|
export function categoryLabel(key: string, fallback: string): string {
|
||||||
|
return translateOr(`category.${key}.label`, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取分类描述 */
|
||||||
|
export function categoryDesc(key: string, fallback: string): string {
|
||||||
|
return translateOr(`category.${key}.desc`, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用取词(带兜底值) */
|
||||||
|
export function tOr(key: string, fallback: string): string {
|
||||||
|
return translateOr(key, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用取词(带命名参数) */
|
||||||
|
export function tArgs(key: string, args: Record<string, unknown>): string {
|
||||||
|
return i18n.global.t(key, args)
|
||||||
|
}
|
||||||
97
src/i18n/index.ts
Normal file
97
src/i18n/index.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import zhCN from './locales/zh-CN'
|
||||||
|
import enUS from './locales/en-US'
|
||||||
|
import jaJP from './locales/ja-JP'
|
||||||
|
import koKR from './locales/ko-KR'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 国际化(i18n)基础设施。
|
||||||
|
*
|
||||||
|
* 支持语言:简体中文 / 英语 / 日语 / 韩语。
|
||||||
|
* 语言判定优先级:本地存储(用户手动选择) > 浏览器语言 > 默认简体中文。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* 模板中 {{ $t('tool.uuid.name') }}
|
||||||
|
* 脚本中 const { t } = useI18n() → t('tool.uuid.name')
|
||||||
|
*
|
||||||
|
* 新增工具/页面文案时,请同时补齐 4 个语言包的同一个 key,
|
||||||
|
* 否则缺失语言会回退到 fallbackLocale(简体中文),不会报错但会出现中英混排。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 站点支持的语言列表 */
|
||||||
|
export const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ja-JP', 'ko-KR'] as const
|
||||||
|
|
||||||
|
export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||||
|
|
||||||
|
/** 语言选择本地存储 key(index.html 内联脚本会读取它,用于首屏防闪烁) */
|
||||||
|
export const LOCALE_STORAGE_KEY = 'tool-locale'
|
||||||
|
|
||||||
|
/** 兜底语言:简体中文(语言包最完整,作为所有缺失 key 的回退) */
|
||||||
|
export const FALLBACK_LOCALE: AppLocale = 'zh-CN'
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
'zh-CN': zhCN,
|
||||||
|
'en-US': enUS,
|
||||||
|
'ja-JP': jaJP,
|
||||||
|
'ko-KR': koKR,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断给定值是否为受支持的语言代码 */
|
||||||
|
export function isAppLocale(value: unknown): value is AppLocale {
|
||||||
|
return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把浏览器语言标签(如 zh-Hant-TW、en-GB)归一化到站点支持的语言。
|
||||||
|
* 匹配规则:先精确匹配,再按主语言前缀匹配,最后回退默认语言。
|
||||||
|
*/
|
||||||
|
export function normalizeLocale(raw: string | null | undefined): AppLocale {
|
||||||
|
if (!raw) return FALLBACK_LOCALE
|
||||||
|
if (isAppLocale(raw)) return raw
|
||||||
|
const primary = raw.toLowerCase().split('-')[0]
|
||||||
|
const matched = SUPPORTED_LOCALES.find((l) => l.toLowerCase().split('-')[0] === primary)
|
||||||
|
return matched ?? FALLBACK_LOCALE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析初始语言:本地存储优先,其次浏览器语言 */
|
||||||
|
export function resolveInitialLocale(): AppLocale {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(LOCALE_STORAGE_KEY)
|
||||||
|
if (isAppLocale(saved)) return saved
|
||||||
|
} catch {
|
||||||
|
// 隐私模式等场景下 localStorage 不可用,忽略即可
|
||||||
|
}
|
||||||
|
return normalizeLocale(typeof navigator !== 'undefined' ? navigator.language : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
// Composition API 模式(Vue 3 推荐),同时开启全局注入让模板可直接用 $t
|
||||||
|
legacy: false,
|
||||||
|
globalInjection: true,
|
||||||
|
locale: resolveInitialLocale(),
|
||||||
|
fallbackLocale: FALLBACK_LOCALE,
|
||||||
|
// 缺失 key 时不在控制台刷警告(开发期可临时改回 true 排查)
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
messages,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步语言到 DOM:写入 html[lang]。
|
||||||
|
* html[lang] 会影响字体栈选择(见 tokens.scss)与搜索引擎/读屏软件的语言判断,
|
||||||
|
* 因此切语言时必须一并更新。
|
||||||
|
*/
|
||||||
|
export function applyLocaleToDocument(locale: AppLocale) {
|
||||||
|
document.documentElement.setAttribute('lang', locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换语言:更新 i18n 实例、DOM 属性与本地存储 */
|
||||||
|
export function setAppLocale(locale: AppLocale) {
|
||||||
|
i18n.global.locale.value = locale
|
||||||
|
applyLocaleToDocument(locale)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCALE_STORAGE_KEY, locale)
|
||||||
|
} catch {
|
||||||
|
// 忽略存储失败
|
||||||
|
}
|
||||||
|
}
|
||||||
533
src/i18n/locales/en-US.ts
Normal file
533
src/i18n/locales/en-US.ts
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
/**
|
||||||
|
* English locale.
|
||||||
|
* Key structure must stay identical to zh-CN.ts (see that file for the convention).
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
app: {
|
||||||
|
name: 'Online Toolbox',
|
||||||
|
short: 'Toolbox',
|
||||||
|
tagline: 'Runs in your browser. Nothing leaves your device.',
|
||||||
|
footer: {
|
||||||
|
local: 'Runs entirely in the browser',
|
||||||
|
privacy: 'No data uploaded',
|
||||||
|
free: 'Free to use',
|
||||||
|
icpTitle: 'Verify the ICP filing (Ministry of Industry and Information Technology)',
|
||||||
|
},
|
||||||
|
home: 'Home',
|
||||||
|
backHome: 'Back to home',
|
||||||
|
},
|
||||||
|
|
||||||
|
nav: {
|
||||||
|
home: 'Home',
|
||||||
|
browse: 'Browse tools',
|
||||||
|
searchPlaceholder: 'Search tools by name or purpose',
|
||||||
|
searchAria: 'Search tools',
|
||||||
|
commandHint: 'Shortcut',
|
||||||
|
toolsCount: '{count} tools',
|
||||||
|
noResult: 'No matching tools',
|
||||||
|
noResultHint: 'Try another keyword, or browse the categories below.',
|
||||||
|
clearSearch: 'Clear search',
|
||||||
|
menuAria: 'Open navigation',
|
||||||
|
skipToContent: 'Skip to main content',
|
||||||
|
},
|
||||||
|
|
||||||
|
palette: {
|
||||||
|
title: 'Quick jump',
|
||||||
|
placeholder: 'Type a tool name, category, or keyword…',
|
||||||
|
empty: 'No matching tools',
|
||||||
|
emptyHint: 'Try keywords like "image", "JSON", or "time".',
|
||||||
|
hintNavigate: 'Select',
|
||||||
|
hintClose: 'Close',
|
||||||
|
hintToggle: 'Toggle theme',
|
||||||
|
groupTool: 'Tools',
|
||||||
|
groupPage: 'Pages',
|
||||||
|
},
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
label: 'Theme',
|
||||||
|
light: 'Light',
|
||||||
|
dark: 'Dark',
|
||||||
|
auto: 'System',
|
||||||
|
switchToLight: 'Switch to light mode',
|
||||||
|
switchToDark: 'Switch to dark mode',
|
||||||
|
switchToAuto: 'Follow system setting',
|
||||||
|
current: 'Current theme: {mode}',
|
||||||
|
},
|
||||||
|
|
||||||
|
locale: {
|
||||||
|
label: 'Language',
|
||||||
|
'zh-CN': '简体中文',
|
||||||
|
'en-US': 'English',
|
||||||
|
'ja-JP': '日本語',
|
||||||
|
'ko-KR': '한국어',
|
||||||
|
},
|
||||||
|
|
||||||
|
home: {
|
||||||
|
title: 'A toolbox that lives in your browser',
|
||||||
|
desc: '{count} everyday utilities. Every calculation and file operation happens on your own machine.',
|
||||||
|
statTools: 'Available tools',
|
||||||
|
statCategories: 'Categories',
|
||||||
|
statUpload: 'Data uploaded to a server',
|
||||||
|
statUploadValue: '0 bytes',
|
||||||
|
statLocal: 'Processed locally',
|
||||||
|
statLocalValue: 'Everything',
|
||||||
|
statLanguages: 'Languages',
|
||||||
|
statServer: 'Server endpoints',
|
||||||
|
statServerValue: '{count}',
|
||||||
|
searchPlaceholder: 'Search tools, or filter by purpose',
|
||||||
|
resultCount: '{count} tools',
|
||||||
|
filterAll: 'All',
|
||||||
|
emptyTitle: 'No matching tools',
|
||||||
|
emptyHint: 'Try a shorter keyword such as "image", "JSON", or "convert".',
|
||||||
|
resetFilter: 'Reset filters',
|
||||||
|
serverBadge: 'Online',
|
||||||
|
localBadge: 'Local',
|
||||||
|
},
|
||||||
|
|
||||||
|
category: {
|
||||||
|
work: {
|
||||||
|
label: 'Everyday work',
|
||||||
|
desc: 'Programming, office text, and file handling',
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
label: 'Developer tools',
|
||||||
|
desc: 'Formatting, conversion, and diffing for developers',
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
label: 'Creative',
|
||||||
|
desc: 'Gradients, placeholder images, and ASCII art',
|
||||||
|
},
|
||||||
|
life: {
|
||||||
|
label: 'Daily life',
|
||||||
|
desc: 'Everyday calculators, lookups, and helpers',
|
||||||
|
},
|
||||||
|
fun: {
|
||||||
|
label: 'Just for fun',
|
||||||
|
desc: 'Morse code and other small toys',
|
||||||
|
},
|
||||||
|
online: {
|
||||||
|
label: 'Server tools',
|
||||||
|
desc: 'Powered by our own backend API, needs a network connection',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
tool: {
|
||||||
|
'image-compress': {
|
||||||
|
name: 'Image compressor',
|
||||||
|
desc: 'Compress JPG/PNG/WebP locally with adjustable quality, then download',
|
||||||
|
},
|
||||||
|
'image-to-ico': {
|
||||||
|
name: 'Image to ICO',
|
||||||
|
desc: 'Turn PNG/JPG/WebP/SVG into icons with multi-size output and size control',
|
||||||
|
},
|
||||||
|
'json-format': {
|
||||||
|
name: 'JSON formatter',
|
||||||
|
desc: 'Format, minify, and validate JSON, then copy the result in one click',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
name: 'Timestamp converter',
|
||||||
|
desc: 'Convert between Unix timestamps and dates, in seconds or milliseconds',
|
||||||
|
},
|
||||||
|
base64: {
|
||||||
|
name: 'Base64 encoder',
|
||||||
|
desc: 'Encode and decode Base64 text, or convert image files to and from Base64',
|
||||||
|
},
|
||||||
|
'jwt-parse': {
|
||||||
|
name: 'JWT decoder',
|
||||||
|
desc: 'Read the header and payload of a JWT, including its expiry',
|
||||||
|
},
|
||||||
|
cron: {
|
||||||
|
name: 'Cron expression',
|
||||||
|
desc: 'Preview the next run times and build expressions visually',
|
||||||
|
},
|
||||||
|
'radix-convert': {
|
||||||
|
name: 'Number base converter',
|
||||||
|
desc: 'Convert between binary, octal, decimal, and hexadecimal',
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
name: 'Hash calculator',
|
||||||
|
desc: 'MD5, SHA1, SHA256, and SHA512 for text or files',
|
||||||
|
},
|
||||||
|
'url-parse': {
|
||||||
|
name: 'URL parser',
|
||||||
|
desc: 'Break a URL into protocol, host, path, and query, or build one',
|
||||||
|
},
|
||||||
|
translate: {
|
||||||
|
name: 'Translation',
|
||||||
|
desc: 'Chinese, English, Japanese, and Korean translation',
|
||||||
|
},
|
||||||
|
ocr: {
|
||||||
|
name: 'Image OCR',
|
||||||
|
desc: 'Extract text from images locally, Chinese and English supported',
|
||||||
|
},
|
||||||
|
uuid: {
|
||||||
|
name: 'UUID generator',
|
||||||
|
desc: 'Batch UUID v4 generation, either in-browser or through the backend API',
|
||||||
|
},
|
||||||
|
'regex-test': {
|
||||||
|
name: 'Regex tester',
|
||||||
|
desc: 'Test matching, groups, and replacements live with highlighted hits',
|
||||||
|
},
|
||||||
|
'case-convert': {
|
||||||
|
name: 'Case converter',
|
||||||
|
desc: 'Switch between camelCase, snake_case, kebab-case, and more',
|
||||||
|
},
|
||||||
|
'color-convert': {
|
||||||
|
name: 'Color converter',
|
||||||
|
desc: 'Convert between HEX, RGB, HSL, and HSV with a picker and swatches',
|
||||||
|
},
|
||||||
|
'yaml-json': {
|
||||||
|
name: 'YAML ↔ JSON',
|
||||||
|
desc: 'Two-way conversion between YAML and JSON with clear error positions',
|
||||||
|
},
|
||||||
|
'word-count': {
|
||||||
|
name: 'Word counter',
|
||||||
|
desc: 'Count characters, CJK glyphs, words, and paragraphs, plus reading time',
|
||||||
|
},
|
||||||
|
'zh-convert': {
|
||||||
|
name: 'Chinese converter',
|
||||||
|
desc: 'Convert between Simplified and Traditional Chinese',
|
||||||
|
},
|
||||||
|
'image-cropper': {
|
||||||
|
name: 'Image cropper',
|
||||||
|
desc: 'Crop images to a fixed ratio with live preview, export as JPG or PNG',
|
||||||
|
},
|
||||||
|
qrcode: {
|
||||||
|
name: 'QR code reader',
|
||||||
|
desc: 'Generate QR codes from text or links, and decode them from an image',
|
||||||
|
},
|
||||||
|
idcard: {
|
||||||
|
name: 'ID card parser',
|
||||||
|
desc: 'Validate a Chinese ID number and read its birth date, gender, and region',
|
||||||
|
},
|
||||||
|
'address-parse': {
|
||||||
|
name: 'Address parser',
|
||||||
|
desc: 'Split a Chinese address into province, city, district, and street',
|
||||||
|
},
|
||||||
|
'unit-convert': {
|
||||||
|
name: 'Unit converter',
|
||||||
|
desc: 'Eight families of units including length, mass, temperature, and data',
|
||||||
|
},
|
||||||
|
'rmb-upper': {
|
||||||
|
name: 'RMB in words',
|
||||||
|
desc: 'Write an amount in formal Chinese capital numerals, including cents',
|
||||||
|
},
|
||||||
|
'date-calc': {
|
||||||
|
name: 'Date calculator',
|
||||||
|
desc: 'Date differences, offsets, plus age, zodiac, and constellation',
|
||||||
|
},
|
||||||
|
'password-gen': {
|
||||||
|
name: 'Password generator',
|
||||||
|
desc: 'Batch-generate strong random passwords with a strength estimate',
|
||||||
|
},
|
||||||
|
bmi: {
|
||||||
|
name: 'BMI calculator',
|
||||||
|
desc: 'Body mass index with the Chinese classification and healthy weight range',
|
||||||
|
},
|
||||||
|
mortgage: {
|
||||||
|
name: 'Mortgage calculator',
|
||||||
|
desc: 'Compare equal-installment and equal-principal loans: payment and total interest',
|
||||||
|
},
|
||||||
|
'bill-split': {
|
||||||
|
name: 'Bill splitter',
|
||||||
|
desc: 'Split a shared bill and get a minimal set of transfers to settle up',
|
||||||
|
},
|
||||||
|
'random-decision': {
|
||||||
|
name: 'Random decision',
|
||||||
|
desc: 'Draw lots, roll dice, or pick a number when you cannot decide',
|
||||||
|
},
|
||||||
|
pomodoro: {
|
||||||
|
name: 'Pomodoro timer',
|
||||||
|
desc: 'Focus and break countdowns with a record of completed rounds',
|
||||||
|
},
|
||||||
|
'sql-format': {
|
||||||
|
name: 'SQL formatter',
|
||||||
|
desc: 'Beautify SQL with keyword line breaks and clause indentation, live',
|
||||||
|
},
|
||||||
|
'text-diff': {
|
||||||
|
name: 'Text diff',
|
||||||
|
desc: 'Compare two texts line by line with added and removed lines highlighted',
|
||||||
|
},
|
||||||
|
'json-to-code': {
|
||||||
|
name: 'JSON to code',
|
||||||
|
desc: 'Generate TypeScript interfaces, Go structs, or Python classes from JSON',
|
||||||
|
},
|
||||||
|
'gradient-generator': {
|
||||||
|
name: 'Gradient generator',
|
||||||
|
desc: 'Design CSS gradients visually with curated presets, ready to copy',
|
||||||
|
},
|
||||||
|
'placeholder-image': {
|
||||||
|
name: 'Placeholder image',
|
||||||
|
desc: 'Custom size and color placeholders, download as PNG or copy SVG',
|
||||||
|
},
|
||||||
|
'ascii-art': {
|
||||||
|
name: 'Image to ASCII',
|
||||||
|
desc: 'Turn a photo into ASCII art with adjustable density and inversion',
|
||||||
|
},
|
||||||
|
'morse-code': {
|
||||||
|
name: 'Morse code',
|
||||||
|
desc: 'Translate text to and from Morse, and play it back as real beeps',
|
||||||
|
},
|
||||||
|
'regex-visual': {
|
||||||
|
name: 'Regex visualizer',
|
||||||
|
desc: 'Break a regular expression into a structure tree with match testing',
|
||||||
|
},
|
||||||
|
'markdown-table': {
|
||||||
|
name: 'Markdown table',
|
||||||
|
desc: 'Paste data and get a Markdown or HTML table in one step',
|
||||||
|
},
|
||||||
|
'api-builder': {
|
||||||
|
name: 'API request builder',
|
||||||
|
desc: 'Compose a request visually and get ready-to-run fetch or curl code',
|
||||||
|
},
|
||||||
|
'gitignore-gen': {
|
||||||
|
name: '.gitignore generator',
|
||||||
|
desc: 'Pick your stack and get the ignore rules you actually need',
|
||||||
|
},
|
||||||
|
'code-to-image': {
|
||||||
|
name: 'Code to image',
|
||||||
|
desc: 'Render code as a shareable card image with syntax highlighting',
|
||||||
|
},
|
||||||
|
'color-namer': {
|
||||||
|
name: 'Color namer',
|
||||||
|
desc: 'Match a color value to the closest CSS color name',
|
||||||
|
},
|
||||||
|
'indent-convert': {
|
||||||
|
name: 'Indent converter',
|
||||||
|
desc: 'Convert between spaces and tabs, with 2, 4, or 8 space widths',
|
||||||
|
},
|
||||||
|
'code-stats': {
|
||||||
|
name: 'Code statistics',
|
||||||
|
desc: 'Count total, code, comment, and blank lines',
|
||||||
|
},
|
||||||
|
'hello-world': {
|
||||||
|
name: 'Hello World collection',
|
||||||
|
desc: 'Starter code for over 30 programming languages',
|
||||||
|
},
|
||||||
|
'json-diff': {
|
||||||
|
name: 'JSON diff',
|
||||||
|
desc: 'Compare two JSON documents key by key: added, removed, changed',
|
||||||
|
},
|
||||||
|
'env-parse': {
|
||||||
|
name: 'Env file parser',
|
||||||
|
desc: 'Convert between .env, JSON, and plain key-value pairs',
|
||||||
|
},
|
||||||
|
'random-string': {
|
||||||
|
name: 'Random string',
|
||||||
|
desc: 'Generate random strings by length and character set, locally or server-side',
|
||||||
|
},
|
||||||
|
'api-status': {
|
||||||
|
name: 'API status',
|
||||||
|
desc: 'Probe the backend endpoints for availability and response time',
|
||||||
|
},
|
||||||
|
'exchange-rate': {
|
||||||
|
name: 'Currency converter',
|
||||||
|
desc: 'Live exchange rates for major currencies',
|
||||||
|
},
|
||||||
|
'express-query': {
|
||||||
|
name: 'Parcel tracking',
|
||||||
|
desc: 'Look up delivery progress by tracking number',
|
||||||
|
},
|
||||||
|
'phone-locate': {
|
||||||
|
name: 'Phone number lookup',
|
||||||
|
desc: 'Find the region and carrier of a mobile number',
|
||||||
|
},
|
||||||
|
'ip-info': {
|
||||||
|
name: 'IP information',
|
||||||
|
desc: 'See the IP the server sees, whether it is private, plus your browser details',
|
||||||
|
},
|
||||||
|
'network-test': {
|
||||||
|
name: 'Network test',
|
||||||
|
desc: 'Check reachability with HTTP, Ping, TCP, and DNS probes',
|
||||||
|
},
|
||||||
|
'speed-test': {
|
||||||
|
name: 'Speed test',
|
||||||
|
desc: 'Measure download and upload throughput and latency',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
page: {
|
||||||
|
common: {
|
||||||
|
local: 'Local',
|
||||||
|
server: 'Server',
|
||||||
|
source: 'Compute on',
|
||||||
|
localHint: 'Done inside this browser, no network request',
|
||||||
|
serverHint: 'Calls the backend API, needs a network connection',
|
||||||
|
serverUnavailable: 'Server unavailable: {message}',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
copyResult: 'Copy result',
|
||||||
|
},
|
||||||
|
'ip-info': {
|
||||||
|
title: 'IP information',
|
||||||
|
desc: 'Ask the backend which IP it sees, check whether it is a private address, and inspect your local browser environment.',
|
||||||
|
serverTitle: 'Source IP as seen by the server',
|
||||||
|
ipLabel: 'IP address',
|
||||||
|
internal: 'Private address',
|
||||||
|
external: 'Public address',
|
||||||
|
internalHint: 'A private address usually comes from a company or home network, so the server cannot resolve a public location from it.',
|
||||||
|
externalHint: 'This is the public egress IP the server sees. It can differ from your local network if a proxy or NAT is involved.',
|
||||||
|
browserTitle: 'Local environment',
|
||||||
|
timezone: 'Time zone',
|
||||||
|
language: 'Browser language',
|
||||||
|
platform: 'Operating system',
|
||||||
|
screen: 'Screen resolution',
|
||||||
|
viewport: 'Viewport',
|
||||||
|
userAgent: 'User agent',
|
||||||
|
geoTitle: 'Location lookup',
|
||||||
|
geoUnavailable: 'The current backend returns only the IP and the private-address flag, without geolocation data.',
|
||||||
|
},
|
||||||
|
uuid: {
|
||||||
|
title: 'UUID generator',
|
||||||
|
desc: 'Generate UUIDs in batches, either locally with the browser CSPRNG or through the backend API.',
|
||||||
|
count: 'Count',
|
||||||
|
uppercase: 'Uppercase',
|
||||||
|
noHyphen: 'No hyphens',
|
||||||
|
modeLabel: 'Generation mode',
|
||||||
|
modeLocal: 'Local (UUID v4)',
|
||||||
|
modeServer: 'Server (GoFrame guid)',
|
||||||
|
serverHint: 'The server returns a 32-character ID, or an 8-character short code when requested.',
|
||||||
|
short: 'Short code (8 chars)',
|
||||||
|
resultTitle: 'Results',
|
||||||
|
regenerate: 'Regenerate',
|
||||||
|
copyAll: 'Copy all',
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
title: 'Hash calculator',
|
||||||
|
desc: 'Compute MD5, SHA1, SHA256, and SHA512 for text or files, locally by default.',
|
||||||
|
tabText: 'Text',
|
||||||
|
tabFile: 'File',
|
||||||
|
inputTitle: 'Input text',
|
||||||
|
inputPlaceholder: 'Enter the text to hash',
|
||||||
|
uppercase: 'Uppercase output',
|
||||||
|
compute: 'Compute',
|
||||||
|
clear: 'Clear',
|
||||||
|
uploadTitle: 'Upload a file',
|
||||||
|
uploadTip: 'Drop any file and the hashes are computed automatically',
|
||||||
|
resultTitle: 'Hash results',
|
||||||
|
serverMd5: 'Also compute MD5 on the server',
|
||||||
|
serverMd5Hint: 'Runs MD5 once more through the backend so you can check both implementations agree.',
|
||||||
|
compareTitle: 'Side-by-side comparison',
|
||||||
|
compareSame: 'Both sides match',
|
||||||
|
compareDiff: 'The two sides disagree, check the source',
|
||||||
|
compareServerFailed: 'Server-side MD5 failed: {message}',
|
||||||
|
errEmpty: 'Enter some text to hash first',
|
||||||
|
errCompute: 'Computation failed, please try again',
|
||||||
|
errFile: 'Could not read the file, please try again',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
title: 'Timestamp converter',
|
||||||
|
desc: 'Convert between timestamps and Beijing time (UTC+8), in seconds or milliseconds, and read the server clock.',
|
||||||
|
tabTs2Date: 'Timestamp → date',
|
||||||
|
tabDate2Ts: 'Date → timestamp',
|
||||||
|
tabBatch: 'Bulk convert',
|
||||||
|
convertTitle: 'Convert',
|
||||||
|
tsPlaceholder: 'Enter a timestamp, e.g. 1704067200',
|
||||||
|
unit: 'Unit',
|
||||||
|
unitSecond: 'Seconds',
|
||||||
|
unitMillisecond: 'Milliseconds',
|
||||||
|
now: 'Now',
|
||||||
|
convert: 'Convert',
|
||||||
|
batchConvert: 'Convert all',
|
||||||
|
resultTitle: 'Result',
|
||||||
|
pickTitle: 'Pick a Beijing time',
|
||||||
|
pickPlaceholder: 'Select date and time',
|
||||||
|
batchInputTitle: 'Bulk timestamps → Beijing time',
|
||||||
|
batchInputPlaceholder: 'One timestamp per line, e.g.:\n1704067200\n1704067200000',
|
||||||
|
batchResultTitle: 'Bulk results',
|
||||||
|
download: 'Download',
|
||||||
|
beijing: 'Beijing time',
|
||||||
|
localTime: 'Local time',
|
||||||
|
secondSuffix: ' (seconds)',
|
||||||
|
millisecondSuffix: ' (milliseconds)',
|
||||||
|
errInvalidTs: 'Enter a valid numeric timestamp',
|
||||||
|
errOutOfRange: 'Timestamp is outside the representable range',
|
||||||
|
errInvalidDate: 'Choose a valid date and time',
|
||||||
|
invalidTs: '[invalid timestamp]',
|
||||||
|
outOfRange: '[out of range]',
|
||||||
|
serverNow: 'Read server time',
|
||||||
|
serverTimeLabel: 'Server time',
|
||||||
|
serverOffset: 'Difference from local clock',
|
||||||
|
clockSkewOk: 'Local clock matches the server',
|
||||||
|
clockSkewWarn: 'Local clock may be off by {seconds} seconds',
|
||||||
|
serverFailed: 'Request failed: {message}',
|
||||||
|
},
|
||||||
|
'random-string': {
|
||||||
|
title: 'Random string',
|
||||||
|
desc: 'Generate random strings by length and character set, optionally from the server CSPRNG.',
|
||||||
|
length: 'Length',
|
||||||
|
type: 'Character set',
|
||||||
|
typeAlnum: 'Letters + digits',
|
||||||
|
typeDigits: 'Digits only',
|
||||||
|
typeLetters: 'Letters only',
|
||||||
|
count: 'How many',
|
||||||
|
modeLocal: 'Local (Web Crypto)',
|
||||||
|
modeServer: 'Server (crypto/rand)',
|
||||||
|
generate: 'Generate',
|
||||||
|
resultTitle: 'Results',
|
||||||
|
serverRangeHint: 'The server accepts lengths from 1 to 128; anything else falls back to local generation.',
|
||||||
|
strengthHint: 'Longer strings from a larger character set are harder to guess.',
|
||||||
|
},
|
||||||
|
'api-status': {
|
||||||
|
title: 'API status',
|
||||||
|
desc: 'Probe each backend endpoint for availability and response time to confirm the frontend and backend are talking.',
|
||||||
|
endpoint: 'Endpoint',
|
||||||
|
method: 'Method',
|
||||||
|
status: 'Status',
|
||||||
|
latency: 'Latency',
|
||||||
|
summary: '{ok} of {total} endpoints healthy',
|
||||||
|
allOk: 'All endpoints healthy',
|
||||||
|
partial: 'Some endpoints failed',
|
||||||
|
probe: 'Run probe',
|
||||||
|
probing: 'Probing…',
|
||||||
|
lastChecked: 'Last checked: {time}',
|
||||||
|
baseUrlLabel: 'API base URL',
|
||||||
|
sampleTitle: 'Sample response',
|
||||||
|
notConfigured: 'No API base URL configured, cannot probe.',
|
||||||
|
failed: 'Request failed',
|
||||||
|
endpointIp: 'Client IP',
|
||||||
|
endpointMd5: 'MD5 digest',
|
||||||
|
endpointRandom: 'Random string',
|
||||||
|
endpointTime: 'Server time',
|
||||||
|
endpointUuid: 'Unique ID',
|
||||||
|
ok: 'Healthy',
|
||||||
|
error: 'Failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
pending: {
|
||||||
|
title: 'This endpoint is not live yet',
|
||||||
|
hint: 'The page is ready. Once the backend implements the endpoint it starts working automatically, with no frontend change.',
|
||||||
|
networkTest:
|
||||||
|
'The HTTP reachability check works today. Ping, TCP, and DNS probes turn on once the backend implements them.',
|
||||||
|
speedTest:
|
||||||
|
'Download testing can use a public source (subject to its CORS policy). Upload testing turns on once the backend implements the upload endpoint.',
|
||||||
|
contactHint: 'The endpoint contract lives in docs/api-contract.md inside the project.',
|
||||||
|
},
|
||||||
|
|
||||||
|
common: {
|
||||||
|
copy: 'Copy',
|
||||||
|
copied: 'Copied to clipboard',
|
||||||
|
copyAll: 'Copy all',
|
||||||
|
clear: 'Clear',
|
||||||
|
download: 'Download',
|
||||||
|
loading: 'Working…',
|
||||||
|
query: 'Search',
|
||||||
|
querying: 'Searching…',
|
||||||
|
convert: 'Convert',
|
||||||
|
generate: 'Generate',
|
||||||
|
compute: 'Compute',
|
||||||
|
history: 'History',
|
||||||
|
historyEmpty: 'No history yet',
|
||||||
|
empty: 'Nothing here yet',
|
||||||
|
error: 'Something went wrong',
|
||||||
|
retry: 'Retry',
|
||||||
|
close: 'Close',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
unitSeconds: 'Seconds',
|
||||||
|
unitMilliseconds: 'Milliseconds',
|
||||||
|
yes: 'Yes',
|
||||||
|
no: 'No',
|
||||||
|
notProvided: 'Not provided',
|
||||||
|
},
|
||||||
|
}
|
||||||
533
src/i18n/locales/ja-JP.ts
Normal file
533
src/i18n/locales/ja-JP.ts
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
/**
|
||||||
|
* 日本語ロケール。
|
||||||
|
* キー構造は zh-CN.ts と完全に同一に保つこと(命名規則はそちらを参照)。
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
app: {
|
||||||
|
name: 'オンラインツールボックス',
|
||||||
|
short: 'ツールボックス',
|
||||||
|
tagline: 'ブラウザ内で完結、データは端末から出ません',
|
||||||
|
footer: {
|
||||||
|
local: 'すべてブラウザ内で処理',
|
||||||
|
privacy: 'データ送信なし',
|
||||||
|
free: '無料で利用可',
|
||||||
|
icpTitle: 'ICP 届出情報の確認(工業・情報化部)',
|
||||||
|
},
|
||||||
|
home: 'ホーム',
|
||||||
|
backHome: 'ホームに戻る',
|
||||||
|
},
|
||||||
|
|
||||||
|
nav: {
|
||||||
|
home: 'ホーム',
|
||||||
|
browse: 'ツール一覧',
|
||||||
|
searchPlaceholder: 'ツール名や用途で検索',
|
||||||
|
searchAria: 'ツールを検索',
|
||||||
|
commandHint: 'ショートカット',
|
||||||
|
toolsCount: '{count} 個のツール',
|
||||||
|
noResult: '該当するツールがありません',
|
||||||
|
noResultHint: '別のキーワードを試すか、下のカテゴリから探してください。',
|
||||||
|
clearSearch: '検索をクリア',
|
||||||
|
menuAria: 'ナビゲーションを開く',
|
||||||
|
skipToContent: 'メインコンテンツへスキップ',
|
||||||
|
},
|
||||||
|
|
||||||
|
palette: {
|
||||||
|
title: 'クイック移動',
|
||||||
|
placeholder: 'ツール名・カテゴリ・キーワードを入力…',
|
||||||
|
empty: '該当するツールがありません',
|
||||||
|
emptyHint: '「画像」「JSON」「時間」などのキーワードをお試しください。',
|
||||||
|
hintNavigate: '決定',
|
||||||
|
hintClose: '閉じる',
|
||||||
|
hintToggle: 'テーマ切替',
|
||||||
|
groupTool: 'ツール',
|
||||||
|
groupPage: 'ページ',
|
||||||
|
},
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
label: 'テーマ',
|
||||||
|
light: 'ライト',
|
||||||
|
dark: 'ダーク',
|
||||||
|
auto: 'システム設定',
|
||||||
|
switchToLight: 'ライトモードに切り替え',
|
||||||
|
switchToDark: 'ダークモードに切り替え',
|
||||||
|
switchToAuto: 'システム設定に合わせる',
|
||||||
|
current: '現在のテーマ:{mode}',
|
||||||
|
},
|
||||||
|
|
||||||
|
locale: {
|
||||||
|
label: '言語',
|
||||||
|
'zh-CN': '简体中文',
|
||||||
|
'en-US': 'English',
|
||||||
|
'ja-JP': '日本語',
|
||||||
|
'ko-KR': '한국어',
|
||||||
|
},
|
||||||
|
|
||||||
|
home: {
|
||||||
|
title: 'ブラウザだけで使えるツールボックス',
|
||||||
|
desc: '{count} の日常ツール。計算もファイル処理もすべて端末内で完結します。',
|
||||||
|
statTools: '利用できるツール',
|
||||||
|
statCategories: 'カテゴリ',
|
||||||
|
statUpload: 'サーバーへの送信データ',
|
||||||
|
statUploadValue: '0 バイト',
|
||||||
|
statLocal: 'ローカル処理',
|
||||||
|
statLocalValue: 'すべて',
|
||||||
|
statLanguages: '対応言語',
|
||||||
|
statServer: 'サーバー API',
|
||||||
|
statServerValue: '{count} 件',
|
||||||
|
searchPlaceholder: 'ツール名や用途で絞り込み',
|
||||||
|
resultCount: '全 {count} 件',
|
||||||
|
filterAll: 'すべて',
|
||||||
|
emptyTitle: '該当するツールがありません',
|
||||||
|
emptyHint: '「画像」「JSON」「変換」など短いキーワードをお試しください。',
|
||||||
|
resetFilter: '条件をリセット',
|
||||||
|
serverBadge: '通信あり',
|
||||||
|
localBadge: 'ローカル',
|
||||||
|
},
|
||||||
|
|
||||||
|
category: {
|
||||||
|
work: {
|
||||||
|
label: '仕事効率化',
|
||||||
|
desc: '開発・オフィス文書・ファイル処理',
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
label: '開発者向け',
|
||||||
|
desc: '整形・変換・差分比較のツール',
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
label: 'クリエイティブ',
|
||||||
|
desc: 'グラデーション、プレースホルダー画像、アスキーアート',
|
||||||
|
},
|
||||||
|
life: {
|
||||||
|
label: '暮らし',
|
||||||
|
desc: '日々の計算・調べもの・ちょっとした判断',
|
||||||
|
},
|
||||||
|
fun: {
|
||||||
|
label: 'お楽しみ',
|
||||||
|
desc: 'モールス信号などの小さな遊び',
|
||||||
|
},
|
||||||
|
online: {
|
||||||
|
label: 'サーバー連携',
|
||||||
|
desc: '自前のバックエンド API を使うツール(通信が必要)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
tool: {
|
||||||
|
'image-compress': {
|
||||||
|
name: '画像圧縮',
|
||||||
|
desc: 'JPG/PNG/WebP をローカルで圧縮。品質を調整してそのまま保存',
|
||||||
|
},
|
||||||
|
'image-to-ico': {
|
||||||
|
name: '画像 → ICO',
|
||||||
|
desc: 'PNG/JPG/WebP/SVG をアイコン化。複数サイズ合成と容量調整に対応',
|
||||||
|
},
|
||||||
|
'json-format': {
|
||||||
|
name: 'JSON 整形',
|
||||||
|
desc: 'JSON の整形・圧縮・検証と、結果のワンクリックコピー',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
name: 'タイムスタンプ変換',
|
||||||
|
desc: 'UNIX 時間と日時の相互変換。秒・ミリ秒と一括変換に対応',
|
||||||
|
},
|
||||||
|
base64: {
|
||||||
|
name: 'Base64 変換',
|
||||||
|
desc: 'テキストの Base64 変換と、画像ファイルとの相互変換',
|
||||||
|
},
|
||||||
|
'jwt-parse': {
|
||||||
|
name: 'JWT デコード',
|
||||||
|
desc: 'JWT のヘッダーとペイロードを読み取り、有効期限を表示',
|
||||||
|
},
|
||||||
|
cron: {
|
||||||
|
name: 'Cron 式',
|
||||||
|
desc: '次回実行時刻を計算し、式をビジュアルに組み立て',
|
||||||
|
},
|
||||||
|
'radix-convert': {
|
||||||
|
name: '基数変換',
|
||||||
|
desc: '2 進数・8 進数・10 進数・16 進数の相互変換',
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
name: 'ハッシュ計算',
|
||||||
|
desc: 'テキストやファイルの MD5・SHA1・SHA256・SHA512',
|
||||||
|
},
|
||||||
|
'url-parse': {
|
||||||
|
name: 'URL 解析',
|
||||||
|
desc: 'プロトコル・ホスト・パス・クエリに分解、または URL を組み立て',
|
||||||
|
},
|
||||||
|
translate: {
|
||||||
|
name: '翻訳',
|
||||||
|
desc: '中国語・英語・日本語・韓国語の相互翻訳',
|
||||||
|
},
|
||||||
|
ocr: {
|
||||||
|
name: '画像 OCR',
|
||||||
|
desc: '画像から文字をローカルで抽出。中国語と英語に対応',
|
||||||
|
},
|
||||||
|
uuid: {
|
||||||
|
name: 'UUID 生成',
|
||||||
|
desc: 'UUID の一括生成。ブラウザ内生成とサーバー API を選べます',
|
||||||
|
},
|
||||||
|
'regex-test': {
|
||||||
|
name: '正規表現テスト',
|
||||||
|
desc: 'マッチ・グループ・置換をリアルタイム検証、ヒット箇所を強調',
|
||||||
|
},
|
||||||
|
'case-convert': {
|
||||||
|
name: '命名規則変換',
|
||||||
|
desc: 'camelCase・snake_case・kebab-case などを相互変換',
|
||||||
|
},
|
||||||
|
'color-convert': {
|
||||||
|
name: 'カラー変換',
|
||||||
|
desc: 'HEX・RGB・HSL・HSV の相互変換。ピッカーとプリセット付き',
|
||||||
|
},
|
||||||
|
'yaml-json': {
|
||||||
|
name: 'YAML ↔ JSON',
|
||||||
|
desc: 'YAML と JSON の双方向変換。エラー位置が分かりやすい',
|
||||||
|
},
|
||||||
|
'word-count': {
|
||||||
|
name: '文字数カウント',
|
||||||
|
desc: '文字・漢字・単語・段落数と読了時間を集計',
|
||||||
|
},
|
||||||
|
'zh-convert': {
|
||||||
|
name: '簡体字 ⇄ 繁体字',
|
||||||
|
desc: '簡体字と繁体字の相互変換',
|
||||||
|
},
|
||||||
|
'image-cropper': {
|
||||||
|
name: '画像トリミング',
|
||||||
|
desc: '比率を指定して切り抜き、プレビューしながら JPG/PNG で保存',
|
||||||
|
},
|
||||||
|
qrcode: {
|
||||||
|
name: 'QR コード',
|
||||||
|
desc: 'テキストやリンクから QR コードを生成、画像から読み取りも可能',
|
||||||
|
},
|
||||||
|
idcard: {
|
||||||
|
name: '身分証番号の解析',
|
||||||
|
desc: '中国の身分証番号を検証し、生年月日・性別・地域を表示',
|
||||||
|
},
|
||||||
|
'address-parse': {
|
||||||
|
name: '住所の分解',
|
||||||
|
desc: '中国語の住所を省・市・区と詳細に分割',
|
||||||
|
},
|
||||||
|
'unit-convert': {
|
||||||
|
name: '単位換算',
|
||||||
|
desc: '長さ・重さ・温度・データ容量など 8 種類の単位換算',
|
||||||
|
},
|
||||||
|
'rmb-upper': {
|
||||||
|
name: '金額の大字表記',
|
||||||
|
desc: '金額を中国語の大字(改ざん防止表記)に変換。端数と負数にも対応',
|
||||||
|
},
|
||||||
|
'date-calc': {
|
||||||
|
name: '日付計算',
|
||||||
|
desc: '日付の差・加減算と、年齢・干支・星座の計算',
|
||||||
|
},
|
||||||
|
'password-gen': {
|
||||||
|
name: 'パスワード生成',
|
||||||
|
desc: '強力なランダムパスワードを一括生成し、強度も評価',
|
||||||
|
},
|
||||||
|
bmi: {
|
||||||
|
name: 'BMI 計算',
|
||||||
|
desc: 'BMI を計算し、中国基準の区分と適正体重範囲を表示',
|
||||||
|
},
|
||||||
|
mortgage: {
|
||||||
|
name: 'ローン計算',
|
||||||
|
desc: '元利均等と元金均等を比較。毎月の返済額と総利息を確認',
|
||||||
|
},
|
||||||
|
'bill-split': {
|
||||||
|
name: '割り勘計算',
|
||||||
|
desc: '複数人の支払いを整理し、最小回数の送金手順を算出',
|
||||||
|
},
|
||||||
|
'random-decision': {
|
||||||
|
name: 'ランダム決定',
|
||||||
|
desc: 'くじ引き・サイコロ・乱数で迷ったときの決定を支援',
|
||||||
|
},
|
||||||
|
pomodoro: {
|
||||||
|
name: 'ポモドーロ',
|
||||||
|
desc: '集中と休憩のタイマー。完了したセット数も記録',
|
||||||
|
},
|
||||||
|
'sql-format': {
|
||||||
|
name: 'SQL 整形',
|
||||||
|
desc: 'キーワード改行と句のインデントで SQL を読みやすく。リアルタイム表示',
|
||||||
|
},
|
||||||
|
'text-diff': {
|
||||||
|
name: 'テキスト差分',
|
||||||
|
desc: '2 つのテキストを行単位で比較し、追加・削除を強調',
|
||||||
|
},
|
||||||
|
'json-to-code': {
|
||||||
|
name: 'JSON → コード',
|
||||||
|
desc: 'JSON から TypeScript の型・Go の構造体・Python のクラスを生成',
|
||||||
|
},
|
||||||
|
'gradient-generator': {
|
||||||
|
name: 'グラデーション生成',
|
||||||
|
desc: 'CSS グラデーションをビジュアルに作成。定番プリセット付きですぐコピー',
|
||||||
|
},
|
||||||
|
'placeholder-image': {
|
||||||
|
name: 'プレースホルダー画像',
|
||||||
|
desc: 'サイズと配色を指定して生成。PNG 保存または SVG コードをコピー',
|
||||||
|
},
|
||||||
|
'ascii-art': {
|
||||||
|
name: '画像 → アスキーアート',
|
||||||
|
desc: '写真をアスキーアートに変換。密度と反転を調整可能',
|
||||||
|
},
|
||||||
|
'morse-code': {
|
||||||
|
name: 'モールス信号',
|
||||||
|
desc: 'テキストとモールス信号の相互変換。実際にビープ音で再生',
|
||||||
|
},
|
||||||
|
'regex-visual': {
|
||||||
|
name: '正規表現の可視化',
|
||||||
|
desc: '正規表現を構造ツリーに分解し、マッチテストも実行',
|
||||||
|
},
|
||||||
|
'markdown-table': {
|
||||||
|
name: 'Markdown テーブル',
|
||||||
|
desc: '表データを貼り付けるだけで Markdown / HTML テーブルを生成',
|
||||||
|
},
|
||||||
|
'api-builder': {
|
||||||
|
name: 'API リクエスト作成',
|
||||||
|
desc: 'リクエストを組み立て、fetch と curl のコードを生成',
|
||||||
|
},
|
||||||
|
'gitignore-gen': {
|
||||||
|
name: '.gitignore 生成',
|
||||||
|
desc: '技術スタックを選んで必要な除外ルールだけを生成',
|
||||||
|
},
|
||||||
|
'code-to-image': {
|
||||||
|
name: 'コード → 画像',
|
||||||
|
desc: 'シンタックスハイライト付きでコードをカード画像に変換',
|
||||||
|
},
|
||||||
|
'color-namer': {
|
||||||
|
name: '色名の特定',
|
||||||
|
desc: '色の値から最も近い CSS カラーネームを検索',
|
||||||
|
},
|
||||||
|
'indent-convert': {
|
||||||
|
name: 'インデント変換',
|
||||||
|
desc: 'スペースとタブの相互変換。2・4・8 スペースに対応',
|
||||||
|
},
|
||||||
|
'code-stats': {
|
||||||
|
name: 'コード統計',
|
||||||
|
desc: '総行数・コード行・コメント行・空行を集計',
|
||||||
|
},
|
||||||
|
'hello-world': {
|
||||||
|
name: 'Hello World 全集',
|
||||||
|
desc: '30 以上のプログラミング言語の入門コード早見表',
|
||||||
|
},
|
||||||
|
'json-diff': {
|
||||||
|
name: 'JSON 差分',
|
||||||
|
desc: '2 つの JSON をキー単位で比較し、追加・削除・変更を表示',
|
||||||
|
},
|
||||||
|
'env-parse': {
|
||||||
|
name: '環境変数の解析',
|
||||||
|
desc: '.env と JSON・キーと値の形式を相互変換',
|
||||||
|
},
|
||||||
|
'random-string': {
|
||||||
|
name: 'ランダム文字列',
|
||||||
|
desc: '長さと文字種を指定して生成。サーバー側の乱数源も選択可能',
|
||||||
|
},
|
||||||
|
'api-status': {
|
||||||
|
name: 'サーバー API 状態',
|
||||||
|
desc: 'バックエンド API の稼働状況と応答時間をリアルタイムに確認',
|
||||||
|
},
|
||||||
|
'exchange-rate': {
|
||||||
|
name: '為替換算',
|
||||||
|
desc: '主要通貨のリアルタイム為替レートで換算',
|
||||||
|
},
|
||||||
|
'express-query': {
|
||||||
|
name: '荷物追跡',
|
||||||
|
desc: '追跡番号から配送状況を確認',
|
||||||
|
},
|
||||||
|
'phone-locate': {
|
||||||
|
name: '電話番号の地域',
|
||||||
|
desc: '携帯番号の登録地域と通信事業者を検索',
|
||||||
|
},
|
||||||
|
'ip-info': {
|
||||||
|
name: 'IP 情報',
|
||||||
|
desc: 'サーバーから見た IP とプライベート判定、ブラウザ環境の情報を表示',
|
||||||
|
},
|
||||||
|
'network-test': {
|
||||||
|
name: 'ネットワークテスト',
|
||||||
|
desc: 'HTTP・Ping・TCP・DNS で到達性を確認',
|
||||||
|
},
|
||||||
|
'speed-test': {
|
||||||
|
name: '速度テスト',
|
||||||
|
desc: 'ダウンロード・アップロード速度と遅延を計測',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
page: {
|
||||||
|
common: {
|
||||||
|
local: 'ローカル',
|
||||||
|
server: 'サーバー',
|
||||||
|
source: '計算方法',
|
||||||
|
localHint: 'ブラウザ内で処理するため通信は発生しません',
|
||||||
|
serverHint: 'バックエンド API を呼び出すため通信が必要です',
|
||||||
|
serverUnavailable: 'サーバーを利用できません:{message}',
|
||||||
|
refresh: '再取得',
|
||||||
|
copyResult: '結果をコピー',
|
||||||
|
},
|
||||||
|
'ip-info': {
|
||||||
|
title: 'IP 情報',
|
||||||
|
desc: 'バックエンド API に問い合わせて接続元 IP を取得し、プライベートアドレスかどうかを判定します。ブラウザ環境の情報も併記します。',
|
||||||
|
serverTitle: 'サーバーが認識した接続元 IP',
|
||||||
|
ipLabel: 'IP アドレス',
|
||||||
|
internal: 'プライベートアドレス',
|
||||||
|
external: 'グローバルアドレス',
|
||||||
|
internalHint: 'プライベートアドレスは社内 LAN や家庭内ネットワーク由来のことが多く、サーバー側では公的な位置を特定できません。',
|
||||||
|
externalHint: 'サーバーが見ているのは公的な出口 IP です。プロキシや NAT を経由している場合は端末のネットワークと異なります。',
|
||||||
|
browserTitle: '端末の環境',
|
||||||
|
timezone: 'タイムゾーン',
|
||||||
|
language: 'ブラウザの言語',
|
||||||
|
platform: 'OS',
|
||||||
|
screen: '画面解像度',
|
||||||
|
viewport: '表示領域',
|
||||||
|
userAgent: 'ユーザーエージェント',
|
||||||
|
geoTitle: '位置情報の検索',
|
||||||
|
geoUnavailable: '現在のバックエンドは IP とプライベート判定のみを返し、位置情報は提供していません。',
|
||||||
|
},
|
||||||
|
uuid: {
|
||||||
|
title: 'UUID 生成',
|
||||||
|
desc: 'UUID を一括生成します。ブラウザの安全な乱数を使う方法と、サーバー API を使う方法を選べます。',
|
||||||
|
count: '個数',
|
||||||
|
uppercase: '大文字',
|
||||||
|
noHyphen: 'ハイフンなし',
|
||||||
|
modeLabel: '生成方法',
|
||||||
|
modeLocal: 'ローカル(UUID v4)',
|
||||||
|
modeServer: 'サーバー(GoFrame guid)',
|
||||||
|
serverHint: 'サーバーは 32 文字の ID を返します。「短コード」を選ぶと 8 文字になります。',
|
||||||
|
short: '短コード(8 文字)',
|
||||||
|
resultTitle: '生成結果',
|
||||||
|
regenerate: '再生成',
|
||||||
|
copyAll: 'すべてコピー',
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
title: 'ハッシュ計算',
|
||||||
|
desc: 'テキストやファイルの MD5・SHA1・SHA256・SHA512 を計算します。既定ではブラウザ内で完結します。',
|
||||||
|
tabText: 'テキスト',
|
||||||
|
tabFile: 'ファイル',
|
||||||
|
inputTitle: 'テキストを入力',
|
||||||
|
inputPlaceholder: 'ハッシュを計算するテキストを入力してください',
|
||||||
|
uppercase: '大文字で出力',
|
||||||
|
compute: '計算',
|
||||||
|
clear: 'クリア',
|
||||||
|
uploadTitle: 'ファイルをアップロード',
|
||||||
|
uploadTip: '任意のファイルを選ぶと自動でハッシュを計算します',
|
||||||
|
resultTitle: 'ハッシュ結果',
|
||||||
|
serverMd5: 'サーバー側でも MD5 を計算',
|
||||||
|
serverMd5Hint: 'バックエンドでもう一度 MD5 を計算し、両方の実装が一致するか確認できます。',
|
||||||
|
compareTitle: '両者の比較',
|
||||||
|
compareSame: '両者の結果は一致しています',
|
||||||
|
compareDiff: '両者の結果が一致しません。出典をご確認ください',
|
||||||
|
compareServerFailed: 'サーバー側の計算に失敗しました:{message}',
|
||||||
|
errEmpty: 'ハッシュを計算するテキストを入力してください',
|
||||||
|
errCompute: '計算に失敗しました。もう一度お試しください',
|
||||||
|
errFile: 'ファイルを読み込めませんでした。もう一度お試しください',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
title: 'タイムスタンプ変換',
|
||||||
|
desc: 'タイムスタンプと北京時間(UTC+8)を相互変換します。秒・ミリ秒と一括変換に対応し、サーバー時刻も取得できます。',
|
||||||
|
tabTs2Date: 'タイムスタンプ → 日時',
|
||||||
|
tabDate2Ts: '日時 → タイムスタンプ',
|
||||||
|
tabBatch: '一括変換',
|
||||||
|
convertTitle: '変換',
|
||||||
|
tsPlaceholder: 'タイムスタンプを入力(例:1704067200)',
|
||||||
|
unit: '単位',
|
||||||
|
unitSecond: '秒',
|
||||||
|
unitMillisecond: 'ミリ秒',
|
||||||
|
now: '現在時刻',
|
||||||
|
convert: '変換',
|
||||||
|
batchConvert: '一括変換',
|
||||||
|
resultTitle: '変換結果',
|
||||||
|
pickTitle: '北京時間を選択',
|
||||||
|
pickPlaceholder: '日時を選択してください',
|
||||||
|
batchInputTitle: 'タイムスタンプ一括 → 北京時間',
|
||||||
|
batchInputPlaceholder: '1 行に 1 つ入力してください(例):\n1704067200\n1704067200000',
|
||||||
|
batchResultTitle: '一括変換の結果',
|
||||||
|
download: 'ダウンロード',
|
||||||
|
beijing: '北京時間',
|
||||||
|
localTime: 'ローカル時刻',
|
||||||
|
secondSuffix: '(秒)',
|
||||||
|
millisecondSuffix: '(ミリ秒)',
|
||||||
|
errInvalidTs: '有効な数値のタイムスタンプを入力してください',
|
||||||
|
errOutOfRange: 'タイムスタンプが表現可能な範囲を超えています',
|
||||||
|
errInvalidDate: '有効な日時を選択してください',
|
||||||
|
invalidTs: '【無効なタイムスタンプ】',
|
||||||
|
outOfRange: '【範囲外】',
|
||||||
|
serverNow: 'サーバー時刻を取得',
|
||||||
|
serverTimeLabel: 'サーバー時刻',
|
||||||
|
serverOffset: '端末との差',
|
||||||
|
clockSkewOk: '端末の時計はサーバーと一致しています',
|
||||||
|
clockSkewWarn: '端末の時計が {seconds} 秒ずれている可能性があります',
|
||||||
|
serverFailed: '取得に失敗しました:{message}',
|
||||||
|
},
|
||||||
|
'random-string': {
|
||||||
|
title: 'ランダム文字列',
|
||||||
|
desc: '長さと文字種を指定してランダム文字列を生成します。サーバー側の暗号学的乱数源も選べます。',
|
||||||
|
length: '長さ',
|
||||||
|
type: '文字種',
|
||||||
|
typeAlnum: '英数字',
|
||||||
|
typeDigits: '数字のみ',
|
||||||
|
typeLetters: '英字のみ',
|
||||||
|
count: '生成数',
|
||||||
|
modeLocal: 'ローカル(Web Crypto)',
|
||||||
|
modeServer: 'サーバー(crypto/rand)',
|
||||||
|
generate: '生成',
|
||||||
|
resultTitle: '生成結果',
|
||||||
|
serverRangeHint: 'サーバーが受け付ける長さは 1〜128 です。それ以外はローカル生成に切り替わります。',
|
||||||
|
strengthHint: '長く、文字種が多いほど推測されにくくなります。',
|
||||||
|
},
|
||||||
|
'api-status': {
|
||||||
|
title: 'サーバー API 状態',
|
||||||
|
desc: 'バックエンドの各エンドポイントを順に確認し、稼働状況と応答時間を測定します。',
|
||||||
|
endpoint: 'エンドポイント',
|
||||||
|
method: 'メソッド',
|
||||||
|
status: '状態',
|
||||||
|
latency: '応答時間',
|
||||||
|
summary: '全 {total} 件中 {ok} 件が正常',
|
||||||
|
allOk: 'すべてのエンドポイントが正常です',
|
||||||
|
partial: '一部のエンドポイントが異常です',
|
||||||
|
probe: '計測を開始',
|
||||||
|
probing: '計測中…',
|
||||||
|
lastChecked: '最終計測:{time}',
|
||||||
|
baseUrlLabel: 'API ベース URL',
|
||||||
|
sampleTitle: 'レスポンス例',
|
||||||
|
notConfigured: 'API ベース URL が未設定のため計測できません。',
|
||||||
|
failed: 'リクエストに失敗',
|
||||||
|
endpointIp: 'クライアント IP',
|
||||||
|
endpointMd5: 'MD5 ダイジェスト',
|
||||||
|
endpointRandom: 'ランダム文字列',
|
||||||
|
endpointTime: 'サーバー時刻',
|
||||||
|
endpointUuid: '一意 ID',
|
||||||
|
ok: '正常',
|
||||||
|
error: '異常',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
pending: {
|
||||||
|
title: 'このエンドポイントは未公開です',
|
||||||
|
hint: 'ページ側は用意できています。バックエンドがエンドポイントを実装すると、フロント側の変更なしで使えるようになります。',
|
||||||
|
networkTest:
|
||||||
|
'HTTP の到達確認は現在も使えます。Ping・TCP・DNS はサーバー側の実装後に対応します。',
|
||||||
|
speedTest:
|
||||||
|
'ダウンロード計測は公開ソースで実行できます(相手側の CORS 制限を受けます)。アップロード計測はサーバー側の実装後に有効になります。',
|
||||||
|
contactHint: 'エンドポイントの契約はプロジェクト内の docs/api-contract.md にあります。',
|
||||||
|
},
|
||||||
|
|
||||||
|
common: {
|
||||||
|
copy: 'コピー',
|
||||||
|
copied: 'クリップボードにコピーしました',
|
||||||
|
copyAll: 'すべてコピー',
|
||||||
|
clear: 'クリア',
|
||||||
|
download: 'ダウンロード',
|
||||||
|
loading: '処理中…',
|
||||||
|
query: '検索',
|
||||||
|
querying: '検索中…',
|
||||||
|
convert: '変換',
|
||||||
|
generate: '生成',
|
||||||
|
compute: '計算',
|
||||||
|
history: '履歴',
|
||||||
|
historyEmpty: '履歴はまだありません',
|
||||||
|
empty: 'まだ何もありません',
|
||||||
|
error: 'エラーが発生しました',
|
||||||
|
retry: '再試行',
|
||||||
|
close: '閉じる',
|
||||||
|
confirm: 'OK',
|
||||||
|
cancel: 'キャンセル',
|
||||||
|
unitSeconds: '秒',
|
||||||
|
unitMilliseconds: 'ミリ秒',
|
||||||
|
yes: 'はい',
|
||||||
|
no: 'いいえ',
|
||||||
|
notProvided: '未提供',
|
||||||
|
},
|
||||||
|
}
|
||||||
533
src/i18n/locales/ko-KR.ts
Normal file
533
src/i18n/locales/ko-KR.ts
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
/**
|
||||||
|
* 한국어 로케일.
|
||||||
|
* 키 구조는 zh-CN.ts 와 완전히 동일하게 유지합니다(네이밍 규칙은 해당 파일 참고).
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
app: {
|
||||||
|
name: '온라인 툴박스',
|
||||||
|
short: '툴박스',
|
||||||
|
tagline: '브라우저 안에서 처리, 데이터는 기기 밖으로 나가지 않습니다',
|
||||||
|
footer: {
|
||||||
|
local: '모두 브라우저에서 처리',
|
||||||
|
privacy: '데이터 전송 없음',
|
||||||
|
free: '무료 사용',
|
||||||
|
icpTitle: 'ICP 등록 정보 확인(공업정보화부)',
|
||||||
|
},
|
||||||
|
home: '홈',
|
||||||
|
backHome: '홈으로 돌아가기',
|
||||||
|
},
|
||||||
|
|
||||||
|
nav: {
|
||||||
|
home: '홈',
|
||||||
|
browse: '도구 둘러보기',
|
||||||
|
searchPlaceholder: '도구 이름이나 용도로 검색',
|
||||||
|
searchAria: '도구 검색',
|
||||||
|
commandHint: '단축키',
|
||||||
|
toolsCount: '도구 {count}개',
|
||||||
|
noResult: '일치하는 도구가 없습니다',
|
||||||
|
noResultHint: '다른 키워드를 입력하거나 아래 분류에서 찾아보세요.',
|
||||||
|
clearSearch: '검색 지우기',
|
||||||
|
menuAria: '내비게이션 열기',
|
||||||
|
skipToContent: '본문으로 건너뛰기',
|
||||||
|
},
|
||||||
|
|
||||||
|
palette: {
|
||||||
|
title: '빠른 이동',
|
||||||
|
placeholder: '도구 이름, 분류 또는 키워드 입력…',
|
||||||
|
empty: '일치하는 도구가 없습니다',
|
||||||
|
emptyHint: '"이미지", "JSON", "시간" 같은 키워드를 시도해 보세요.',
|
||||||
|
hintNavigate: '선택',
|
||||||
|
hintClose: '닫기',
|
||||||
|
hintToggle: '테마 전환',
|
||||||
|
groupTool: '도구',
|
||||||
|
groupPage: '페이지',
|
||||||
|
},
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
label: '테마',
|
||||||
|
light: '라이트',
|
||||||
|
dark: '다크',
|
||||||
|
auto: '시스템 설정',
|
||||||
|
switchToLight: '라이트 모드로 전환',
|
||||||
|
switchToDark: '다크 모드로 전환',
|
||||||
|
switchToAuto: '시스템 설정 따르기',
|
||||||
|
current: '현재 테마: {mode}',
|
||||||
|
},
|
||||||
|
|
||||||
|
locale: {
|
||||||
|
label: '언어',
|
||||||
|
'zh-CN': '简体中文',
|
||||||
|
'en-US': 'English',
|
||||||
|
'ja-JP': '日本語',
|
||||||
|
'ko-KR': '한국어',
|
||||||
|
},
|
||||||
|
|
||||||
|
home: {
|
||||||
|
title: '브라우저만 있으면 되는 툴박스',
|
||||||
|
desc: '일상 도구 {count}개. 계산과 파일 처리가 모두 내 기기에서 끝납니다.',
|
||||||
|
statTools: '사용 가능한 도구',
|
||||||
|
statCategories: '분류',
|
||||||
|
statUpload: '서버로 전송되는 데이터',
|
||||||
|
statUploadValue: '0 바이트',
|
||||||
|
statLocal: '로컬 처리',
|
||||||
|
statLocalValue: '전부',
|
||||||
|
statLanguages: '지원 언어',
|
||||||
|
statServer: '서버 API',
|
||||||
|
statServerValue: '{count}개',
|
||||||
|
searchPlaceholder: '도구를 검색하거나 용도로 걸러보기',
|
||||||
|
resultCount: '전체 {count}개',
|
||||||
|
filterAll: '전체',
|
||||||
|
emptyTitle: '일치하는 도구가 없습니다',
|
||||||
|
emptyHint: '"이미지", "JSON", "변환"처럼 짧은 키워드를 시도해 보세요.',
|
||||||
|
resetFilter: '필터 초기화',
|
||||||
|
serverBadge: '네트워크',
|
||||||
|
localBadge: '로컬',
|
||||||
|
},
|
||||||
|
|
||||||
|
category: {
|
||||||
|
work: {
|
||||||
|
label: '업무 효율',
|
||||||
|
desc: '개발, 사무 텍스트, 파일 처리',
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
label: '개발자 도구',
|
||||||
|
desc: '서식 정리, 변환, 비교 도구',
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
label: '크리에이티브',
|
||||||
|
desc: '그라데이션, 플레이스홀더 이미지, 아스키 아트',
|
||||||
|
},
|
||||||
|
life: {
|
||||||
|
label: '생활',
|
||||||
|
desc: '일상 계산, 조회, 결정 도우미',
|
||||||
|
},
|
||||||
|
fun: {
|
||||||
|
label: '재미',
|
||||||
|
desc: '모스 부호 같은 작은 장난감',
|
||||||
|
},
|
||||||
|
online: {
|
||||||
|
label: '서버 연동',
|
||||||
|
desc: '자체 백엔드 API를 사용하는 도구, 네트워크 필요',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
tool: {
|
||||||
|
'image-compress': {
|
||||||
|
name: '이미지 압축',
|
||||||
|
desc: 'JPG/PNG/WebP를 로컬에서 압축하고 품질 조절 후 바로 저장',
|
||||||
|
},
|
||||||
|
'image-to-ico': {
|
||||||
|
name: '이미지 → ICO',
|
||||||
|
desc: 'PNG/JPG/WebP/SVG를 아이콘으로 변환, 다중 크기와 용량 조절 지원',
|
||||||
|
},
|
||||||
|
'json-format': {
|
||||||
|
name: 'JSON 정리',
|
||||||
|
desc: 'JSON 정리, 압축, 검증과 결과 원클릭 복사',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
name: '타임스탬프 변환',
|
||||||
|
desc: '타임스탬프와 날짜 상호 변환, 초/밀리초와 일괄 변환 지원',
|
||||||
|
},
|
||||||
|
base64: {
|
||||||
|
name: 'Base64 변환',
|
||||||
|
desc: '텍스트 Base64 인코딩과 이미지 파일 상호 변환',
|
||||||
|
},
|
||||||
|
'jwt-parse': {
|
||||||
|
name: 'JWT 디코드',
|
||||||
|
desc: 'JWT의 헤더와 페이로드를 읽고 만료 시간을 표시',
|
||||||
|
},
|
||||||
|
cron: {
|
||||||
|
name: 'Cron 표현식',
|
||||||
|
desc: '다음 실행 시각을 계산하고 표현식을 시각적으로 작성',
|
||||||
|
},
|
||||||
|
'radix-convert': {
|
||||||
|
name: '진법 변환',
|
||||||
|
desc: '2진수, 8진수, 10진수, 16진수 상호 변환',
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
name: '해시 계산',
|
||||||
|
desc: '텍스트와 파일의 MD5, SHA1, SHA256, SHA512 계산',
|
||||||
|
},
|
||||||
|
'url-parse': {
|
||||||
|
name: 'URL 분석',
|
||||||
|
desc: '프로토콜, 호스트, 경로, 쿼리로 나누거나 URL을 조립',
|
||||||
|
},
|
||||||
|
translate: {
|
||||||
|
name: '번역',
|
||||||
|
desc: '중국어, 영어, 일본어, 한국어 상호 번역',
|
||||||
|
},
|
||||||
|
ocr: {
|
||||||
|
name: '이미지 OCR',
|
||||||
|
desc: '이미지에서 문자를 로컬로 추출, 중국어와 영어 지원',
|
||||||
|
},
|
||||||
|
uuid: {
|
||||||
|
name: 'UUID 생성',
|
||||||
|
desc: 'UUID 일괄 생성, 브라우저 생성 또는 서버 API 중 선택',
|
||||||
|
},
|
||||||
|
'regex-test': {
|
||||||
|
name: '정규식 테스트',
|
||||||
|
desc: '매칭, 그룹, 치환을 실시간 검증하고 일치 부분을 강조',
|
||||||
|
},
|
||||||
|
'case-convert': {
|
||||||
|
name: '표기법 변환',
|
||||||
|
desc: 'camelCase, snake_case, kebab-case 등을 상호 변환',
|
||||||
|
},
|
||||||
|
'color-convert': {
|
||||||
|
name: '색상 변환',
|
||||||
|
desc: 'HEX, RGB, HSL, HSV 상호 변환, 색상 선택기와 팔레트 제공',
|
||||||
|
},
|
||||||
|
'yaml-json': {
|
||||||
|
name: 'YAML ↔ JSON',
|
||||||
|
desc: 'YAML과 JSON 양방향 변환, 오류 위치를 명확하게 표시',
|
||||||
|
},
|
||||||
|
'word-count': {
|
||||||
|
name: '글자 수 세기',
|
||||||
|
desc: '문자, 한자, 단어, 문단 수와 예상 읽기 시간 집계',
|
||||||
|
},
|
||||||
|
'zh-convert': {
|
||||||
|
name: '중국어 간체/번체',
|
||||||
|
desc: '중국어 간체와 번체 상호 변환',
|
||||||
|
},
|
||||||
|
'image-cropper': {
|
||||||
|
name: '이미지 자르기',
|
||||||
|
desc: '비율에 맞춰 자르고 미리 보기 후 JPG/PNG로 저장',
|
||||||
|
},
|
||||||
|
qrcode: {
|
||||||
|
name: 'QR 코드',
|
||||||
|
desc: '텍스트나 링크로 QR 코드를 만들고 이미지에서 읽어내기',
|
||||||
|
},
|
||||||
|
idcard: {
|
||||||
|
name: '중국 신분증 분석',
|
||||||
|
desc: '중국 신분증 번호를 검증하고 생년월일, 성별, 지역 표시',
|
||||||
|
},
|
||||||
|
'address-parse': {
|
||||||
|
name: '주소 분해',
|
||||||
|
desc: '중국어 주소를 성, 시, 구와 상세 주소로 분리',
|
||||||
|
},
|
||||||
|
'unit-convert': {
|
||||||
|
name: '단위 변환',
|
||||||
|
desc: '길이, 무게, 온도, 데이터 용량 등 8가지 단위 변환',
|
||||||
|
},
|
||||||
|
'rmb-upper': {
|
||||||
|
name: '금액 한자 표기',
|
||||||
|
desc: '금액을 중국어 대문자 표기로 변환, 소수와 음수 처리',
|
||||||
|
},
|
||||||
|
'date-calc': {
|
||||||
|
name: '날짜 계산',
|
||||||
|
desc: '날짜 차이와 더하기, 나이와 띠, 별자리 계산',
|
||||||
|
},
|
||||||
|
'password-gen': {
|
||||||
|
name: '비밀번호 생성',
|
||||||
|
desc: '강력한 무작위 비밀번호를 일괄 생성하고 강도 평가',
|
||||||
|
},
|
||||||
|
bmi: {
|
||||||
|
name: 'BMI 계산기',
|
||||||
|
desc: 'BMI를 계산하고 중국 기준 구분과 적정 체중 범위 표시',
|
||||||
|
},
|
||||||
|
mortgage: {
|
||||||
|
name: '대출 계산기',
|
||||||
|
desc: '원리금균등과 원금균등 비교, 월 상환액과 총 이자 확인',
|
||||||
|
},
|
||||||
|
'bill-split': {
|
||||||
|
name: '더치페이 계산',
|
||||||
|
desc: '여러 명의 결제 내역을 정리하고 최소 송금 횟수 계산',
|
||||||
|
},
|
||||||
|
'random-decision': {
|
||||||
|
name: '무작위 선택',
|
||||||
|
desc: '제비뽑기, 주사위, 난수로 결정을 대신해 줍니다',
|
||||||
|
},
|
||||||
|
pomodoro: {
|
||||||
|
name: '뽀모도로 타이머',
|
||||||
|
desc: '집중과 휴식 타이머, 완료한 세트 수도 기록',
|
||||||
|
},
|
||||||
|
'sql-format': {
|
||||||
|
name: 'SQL 정리',
|
||||||
|
desc: '키워드 줄바꿈과 절 들여쓰기로 SQL을 정돈, 실시간 미리보기',
|
||||||
|
},
|
||||||
|
'text-diff': {
|
||||||
|
name: '텍스트 비교',
|
||||||
|
desc: '두 텍스트를 줄 단위로 비교하고 추가/삭제를 강조',
|
||||||
|
},
|
||||||
|
'json-to-code': {
|
||||||
|
name: 'JSON → 코드',
|
||||||
|
desc: 'JSON에서 TypeScript 타입, Go 구조체, Python 클래스 생성',
|
||||||
|
},
|
||||||
|
'gradient-generator': {
|
||||||
|
name: '그라데이션 생성',
|
||||||
|
desc: 'CSS 그라데이션을 시각적으로 만들고 인기 프리셋 제공',
|
||||||
|
},
|
||||||
|
'placeholder-image': {
|
||||||
|
name: '플레이스홀더 이미지',
|
||||||
|
desc: '크기와 색상을 지정해 생성, PNG 저장 또는 SVG 코드 복사',
|
||||||
|
},
|
||||||
|
'ascii-art': {
|
||||||
|
name: '이미지 → 아스키 아트',
|
||||||
|
desc: '사진을 아스키 아트로 변환, 밀도와 반전 조절 가능',
|
||||||
|
},
|
||||||
|
'morse-code': {
|
||||||
|
name: '모스 부호',
|
||||||
|
desc: '텍스트와 모스 부호 상호 변환, 실제 삐 소리로 재생',
|
||||||
|
},
|
||||||
|
'regex-visual': {
|
||||||
|
name: '정규식 시각화',
|
||||||
|
desc: '정규식을 구조 트리로 분해하고 매칭 테스트 제공',
|
||||||
|
},
|
||||||
|
'markdown-table': {
|
||||||
|
name: 'Markdown 표',
|
||||||
|
desc: '표 데이터를 붙여 넣으면 Markdown/HTML 표를 생성',
|
||||||
|
},
|
||||||
|
'api-builder': {
|
||||||
|
name: 'API 요청 생성기',
|
||||||
|
desc: '요청을 조립하고 fetch와 curl 코드를 생성',
|
||||||
|
},
|
||||||
|
'gitignore-gen': {
|
||||||
|
name: '.gitignore 생성기',
|
||||||
|
desc: '기술 스택을 선택해 필요한 제외 규칙만 생성',
|
||||||
|
},
|
||||||
|
'code-to-image': {
|
||||||
|
name: '코드 → 이미지',
|
||||||
|
desc: '구문 강조와 함께 코드를 카드 이미지로 렌더링',
|
||||||
|
},
|
||||||
|
'color-namer': {
|
||||||
|
name: '색 이름 찾기',
|
||||||
|
desc: '색상 값에서 가장 가까운 CSS 색 이름을 찾기',
|
||||||
|
},
|
||||||
|
'indent-convert': {
|
||||||
|
name: '들여쓰기 변환',
|
||||||
|
desc: '공백과 탭 상호 변환, 2/4/8 공백 지원',
|
||||||
|
},
|
||||||
|
'code-stats': {
|
||||||
|
name: '코드 통계',
|
||||||
|
desc: '전체 줄, 코드 줄, 주석 줄, 빈 줄 집계',
|
||||||
|
},
|
||||||
|
'hello-world': {
|
||||||
|
name: 'Hello World 모음',
|
||||||
|
desc: '30개 이상 프로그래밍 언어의 입문 코드 모음',
|
||||||
|
},
|
||||||
|
'json-diff': {
|
||||||
|
name: 'JSON 비교',
|
||||||
|
desc: '두 JSON을 키 단위로 비교해 추가, 삭제, 변경을 표시',
|
||||||
|
},
|
||||||
|
'env-parse': {
|
||||||
|
name: '환경 변수 분석',
|
||||||
|
desc: '.env와 JSON, 키-값 형식 상호 변환',
|
||||||
|
},
|
||||||
|
'random-string': {
|
||||||
|
name: '무작위 문자열',
|
||||||
|
desc: '길이와 문자 종류로 생성, 서버 측 암호학적 난수원도 선택 가능',
|
||||||
|
},
|
||||||
|
'api-status': {
|
||||||
|
name: '서버 API 상태',
|
||||||
|
desc: '백엔드 API의 가용성과 응답 시간을 실시간으로 확인',
|
||||||
|
},
|
||||||
|
'exchange-rate': {
|
||||||
|
name: '환율 계산',
|
||||||
|
desc: '주요 통화의 실시간 환율 계산',
|
||||||
|
},
|
||||||
|
'express-query': {
|
||||||
|
name: '택배 조회',
|
||||||
|
desc: '운송장 번호로 배송 현황 조회',
|
||||||
|
},
|
||||||
|
'phone-locate': {
|
||||||
|
name: '휴대폰 번호 지역',
|
||||||
|
desc: '가입 지역과 통신사 조회',
|
||||||
|
},
|
||||||
|
'ip-info': {
|
||||||
|
name: 'IP 정보',
|
||||||
|
desc: '서버가 보는 IP와 사설 주소 여부, 브라우저 환경 정보를 표시',
|
||||||
|
},
|
||||||
|
'network-test': {
|
||||||
|
name: '네트워크 테스트',
|
||||||
|
desc: 'HTTP, Ping, TCP, DNS로 도달 가능성 확인',
|
||||||
|
},
|
||||||
|
'speed-test': {
|
||||||
|
name: '속도 측정',
|
||||||
|
desc: '다운로드, 업로드 속도와 지연 시간 측정',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
page: {
|
||||||
|
common: {
|
||||||
|
local: '로컬',
|
||||||
|
server: '서버',
|
||||||
|
source: '계산 방식',
|
||||||
|
localHint: '브라우저 안에서 처리하므로 네트워크 요청이 없습니다',
|
||||||
|
serverHint: '백엔드 API를 호출하므로 네트워크가 필요합니다',
|
||||||
|
serverUnavailable: '서버를 사용할 수 없습니다: {message}',
|
||||||
|
refresh: '다시 가져오기',
|
||||||
|
copyResult: '결과 복사',
|
||||||
|
},
|
||||||
|
'ip-info': {
|
||||||
|
title: 'IP 정보',
|
||||||
|
desc: '백엔드 API에 접속 출처 IP를 조회하고 사설 주소인지 판별합니다. 브라우저 환경 정보도 함께 보여 줍니다.',
|
||||||
|
serverTitle: '서버가 인식한 출처 IP',
|
||||||
|
ipLabel: 'IP 주소',
|
||||||
|
internal: '사설 주소',
|
||||||
|
external: '공인 주소',
|
||||||
|
internalHint: '사설 주소는 보통 회사나 가정의 내부 네트워크에서 오므로 서버가 공적 위치를 특정할 수 없습니다.',
|
||||||
|
externalHint: '서버가 보는 값은 공인 출구 IP입니다. 프록시나 NAT를 거치면 기기의 네트워크와 다를 수 있습니다.',
|
||||||
|
browserTitle: '기기 환경',
|
||||||
|
timezone: '시간대',
|
||||||
|
language: '브라우저 언어',
|
||||||
|
platform: '운영체제',
|
||||||
|
screen: '화면 해상도',
|
||||||
|
viewport: '표시 영역',
|
||||||
|
userAgent: '사용자 에이전트',
|
||||||
|
geoTitle: '위치 조회',
|
||||||
|
geoUnavailable: '현재 백엔드는 IP와 사설 주소 여부만 반환하며 위치 정보는 제공하지 않습니다.',
|
||||||
|
},
|
||||||
|
uuid: {
|
||||||
|
title: 'UUID 생성',
|
||||||
|
desc: 'UUID를 일괄 생성합니다. 브라우저의 안전한 난수를 쓰거나 서버 API를 호출할 수 있습니다.',
|
||||||
|
count: '개수',
|
||||||
|
uppercase: '대문자',
|
||||||
|
noHyphen: '하이픈 없음',
|
||||||
|
modeLabel: '생성 방식',
|
||||||
|
modeLocal: '로컬(UUID v4)',
|
||||||
|
modeServer: '서버(GoFrame guid)',
|
||||||
|
serverHint: '서버는 32자 ID를 반환합니다. 짧은 코드를 선택하면 8자가 됩니다.',
|
||||||
|
short: '짧은 코드(8자)',
|
||||||
|
resultTitle: '생성 결과',
|
||||||
|
regenerate: '다시 생성',
|
||||||
|
copyAll: '전체 복사',
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
title: '해시 계산',
|
||||||
|
desc: '텍스트나 파일의 MD5, SHA1, SHA256, SHA512를 계산합니다. 기본은 브라우저에서 처리합니다.',
|
||||||
|
tabText: '텍스트',
|
||||||
|
tabFile: '파일',
|
||||||
|
inputTitle: '텍스트 입력',
|
||||||
|
inputPlaceholder: '해시를 계산할 텍스트를 입력하세요',
|
||||||
|
uppercase: '대문자로 출력',
|
||||||
|
compute: '계산',
|
||||||
|
clear: '지우기',
|
||||||
|
uploadTitle: '파일 업로드',
|
||||||
|
uploadTip: '아무 파일이나 올리면 해시를 자동으로 계산합니다',
|
||||||
|
resultTitle: '해시 결과',
|
||||||
|
serverMd5: '서버에서도 MD5 계산',
|
||||||
|
serverMd5Hint: '백엔드에서 MD5를 한 번 더 계산해 양쪽 구현이 일치하는지 확인합니다.',
|
||||||
|
compareTitle: '양쪽 결과 비교',
|
||||||
|
compareSame: '양쪽 결과가 일치합니다',
|
||||||
|
compareDiff: '양쪽 결과가 다릅니다. 출처를 확인하세요',
|
||||||
|
compareServerFailed: '서버 측 계산 실패: {message}',
|
||||||
|
errEmpty: '해시를 계산할 텍스트를 입력하세요',
|
||||||
|
errCompute: '계산에 실패했습니다. 다시 시도하세요',
|
||||||
|
errFile: '파일을 읽지 못했습니다. 다시 시도하세요',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
title: '타임스탬프 변환',
|
||||||
|
desc: '타임스탬프와 베이징 시간(UTC+8)을 상호 변환합니다. 초/밀리초와 일괄 변환을 지원하고 서버 시간도 읽어옵니다.',
|
||||||
|
tabTs2Date: '타임스탬프 → 날짜',
|
||||||
|
tabDate2Ts: '날짜 → 타임스탬프',
|
||||||
|
tabBatch: '일괄 변환',
|
||||||
|
convertTitle: '변환',
|
||||||
|
tsPlaceholder: '타임스탬프 입력, 예: 1704067200',
|
||||||
|
unit: '단위',
|
||||||
|
unitSecond: '초',
|
||||||
|
unitMillisecond: '밀리초',
|
||||||
|
now: '현재 시각',
|
||||||
|
convert: '변환',
|
||||||
|
batchConvert: '일괄 변환',
|
||||||
|
resultTitle: '변환 결과',
|
||||||
|
pickTitle: '베이징 시간 선택',
|
||||||
|
pickPlaceholder: '날짜와 시각을 선택하세요',
|
||||||
|
batchInputTitle: '타임스탬프 일괄 → 베이징 시간',
|
||||||
|
batchInputPlaceholder: '한 줄에 하나씩 입력하세요. 예:\n1704067200\n1704067200000',
|
||||||
|
batchResultTitle: '일괄 결과',
|
||||||
|
download: '다운로드',
|
||||||
|
beijing: '베이징 시간',
|
||||||
|
localTime: '기기 시각',
|
||||||
|
secondSuffix: ' (초)',
|
||||||
|
millisecondSuffix: ' (밀리초)',
|
||||||
|
errInvalidTs: '올바른 숫자 타임스탬프를 입력하세요',
|
||||||
|
errOutOfRange: '타임스탬프가 표현 가능한 범위를 벗어났습니다',
|
||||||
|
errInvalidDate: '올바른 날짜와 시각을 선택하세요',
|
||||||
|
invalidTs: '[잘못된 타임스탬프]',
|
||||||
|
outOfRange: '[범위 초과]',
|
||||||
|
serverNow: '서버 시간 읽기',
|
||||||
|
serverTimeLabel: '서버 시간',
|
||||||
|
serverOffset: '기기 시각과의 차이',
|
||||||
|
clockSkewOk: '기기 시각이 서버와 일치합니다',
|
||||||
|
clockSkewWarn: '기기 시각이 {seconds}초 어긋난 것 같습니다',
|
||||||
|
serverFailed: '요청 실패: {message}',
|
||||||
|
},
|
||||||
|
'random-string': {
|
||||||
|
title: '무작위 문자열',
|
||||||
|
desc: '길이와 문자 종류를 지정해 무작위 문자열을 생성합니다. 서버 측 암호학적 난수원도 선택할 수 있습니다.',
|
||||||
|
length: '길이',
|
||||||
|
type: '문자 종류',
|
||||||
|
typeAlnum: '영문 + 숫자',
|
||||||
|
typeDigits: '숫자만',
|
||||||
|
typeLetters: '영문만',
|
||||||
|
count: '생성 개수',
|
||||||
|
modeLocal: '로컬(Web Crypto)',
|
||||||
|
modeServer: '서버(crypto/rand)',
|
||||||
|
generate: '생성',
|
||||||
|
resultTitle: '생성 결과',
|
||||||
|
serverRangeHint: '서버가 허용하는 길이는 1~128입니다. 그 외에는 로컬 생성으로 전환됩니다.',
|
||||||
|
strengthHint: '길이가 길고 문자 종류가 많을수록 추측하기 어렵습니다.',
|
||||||
|
},
|
||||||
|
'api-status': {
|
||||||
|
title: '서버 API 상태',
|
||||||
|
desc: '백엔드의 각 엔드포인트를 차례로 확인해 가용성과 응답 시간을 측정합니다.',
|
||||||
|
endpoint: '엔드포인트',
|
||||||
|
method: '메서드',
|
||||||
|
status: '상태',
|
||||||
|
latency: '응답 시간',
|
||||||
|
summary: '전체 {total}개 중 {ok}개 정상',
|
||||||
|
allOk: '모든 엔드포인트가 정상입니다',
|
||||||
|
partial: '일부 엔드포인트에 문제가 있습니다',
|
||||||
|
probe: '측정 시작',
|
||||||
|
probing: '측정 중…',
|
||||||
|
lastChecked: '마지막 측정: {time}',
|
||||||
|
baseUrlLabel: 'API 기본 주소',
|
||||||
|
sampleTitle: '응답 예시',
|
||||||
|
notConfigured: 'API 기본 주소가 없어 측정할 수 없습니다.',
|
||||||
|
failed: '요청 실패',
|
||||||
|
endpointIp: '클라이언트 IP',
|
||||||
|
endpointMd5: 'MD5 다이제스트',
|
||||||
|
endpointRandom: '무작위 문자열',
|
||||||
|
endpointTime: '서버 시간',
|
||||||
|
endpointUuid: '고유 ID',
|
||||||
|
ok: '정상',
|
||||||
|
error: '오류',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
pending: {
|
||||||
|
title: '이 엔드포인트는 아직 열려 있지 않습니다',
|
||||||
|
hint: '페이지는 준비되어 있습니다. 백엔드가 엔드포인트를 구현하면 프런트엔드 수정 없이 바로 동작합니다.',
|
||||||
|
networkTest:
|
||||||
|
'HTTP 도달 확인은 지금도 사용할 수 있습니다. Ping, TCP, DNS는 서버 구현 후 활성화됩니다.',
|
||||||
|
speedTest:
|
||||||
|
'다운로드 측정은 공개 소스로 가능합니다(상대 서버의 CORS 제약을 받습니다). 업로드 측정은 서버 구현 후 활성화됩니다.',
|
||||||
|
contactHint: '엔드포인트 규격은 프로젝트의 docs/api-contract.md 에 있습니다.',
|
||||||
|
},
|
||||||
|
|
||||||
|
common: {
|
||||||
|
copy: '복사',
|
||||||
|
copied: '클립보드에 복사했습니다',
|
||||||
|
copyAll: '전체 복사',
|
||||||
|
clear: '지우기',
|
||||||
|
download: '다운로드',
|
||||||
|
loading: '처리 중…',
|
||||||
|
query: '조회',
|
||||||
|
querying: '조회 중…',
|
||||||
|
convert: '변환',
|
||||||
|
generate: '생성',
|
||||||
|
compute: '계산',
|
||||||
|
history: '기록',
|
||||||
|
historyEmpty: '아직 기록이 없습니다',
|
||||||
|
empty: '아직 내용이 없습니다',
|
||||||
|
error: '오류가 발생했습니다',
|
||||||
|
retry: '다시 시도',
|
||||||
|
close: '닫기',
|
||||||
|
confirm: '확인',
|
||||||
|
cancel: '취소',
|
||||||
|
unitSeconds: '초',
|
||||||
|
unitMilliseconds: '밀리초',
|
||||||
|
yes: '예',
|
||||||
|
no: '아니오',
|
||||||
|
notProvided: '제공되지 않음',
|
||||||
|
},
|
||||||
|
}
|
||||||
550
src/i18n/locales/zh-CN.ts
Normal file
550
src/i18n/locales/zh-CN.ts
Normal file
@ -0,0 +1,550 @@
|
|||||||
|
/**
|
||||||
|
* 简体中文语言包(基准语言)
|
||||||
|
*
|
||||||
|
* 命名约定:
|
||||||
|
* app.* 站点级文案(名称、页脚)
|
||||||
|
* nav.* 导航、侧栏、命令面板
|
||||||
|
* home.* 首页
|
||||||
|
* category.* 工具分类(key 与 router/tools.ts 的 category 一致)
|
||||||
|
* tool.* 工具名称与描述(key 与工具路由 path 一致)
|
||||||
|
* page.* 具体工具页面内部的文案(按 path 分组)
|
||||||
|
* common.* 跨工具复用的通用文案
|
||||||
|
*
|
||||||
|
* 新增语言时请复制本文件为目标语言文件,保持 key 结构完全一致。
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
app: {
|
||||||
|
name: '在线工具箱',
|
||||||
|
short: '工具箱',
|
||||||
|
tagline: '纯前端处理,数据不出浏览器',
|
||||||
|
footer: {
|
||||||
|
local: '纯前端处理',
|
||||||
|
privacy: '数据不出浏览器',
|
||||||
|
free: '免费使用',
|
||||||
|
// 备案号本身不翻译,这里只翻译链接的提示语
|
||||||
|
icpTitle: '备案信息核验(工业和信息化部)',
|
||||||
|
},
|
||||||
|
// 站点级跳转链接文案
|
||||||
|
home: '首页',
|
||||||
|
backHome: '返回首页',
|
||||||
|
},
|
||||||
|
|
||||||
|
nav: {
|
||||||
|
home: '首页',
|
||||||
|
browse: '浏览工具',
|
||||||
|
searchPlaceholder: '搜索工具名称或用途',
|
||||||
|
searchAria: '搜索工具',
|
||||||
|
commandHint: '快捷键',
|
||||||
|
toolsCount: '{count} 个工具',
|
||||||
|
noResult: '没有匹配的工具',
|
||||||
|
noResultHint: '换个关键词试试,或直接浏览下方分类。',
|
||||||
|
clearSearch: '清空搜索',
|
||||||
|
menuAria: '打开导航',
|
||||||
|
skipToContent: '跳到主要内容',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 命令面板(⌘K / Ctrl+K)
|
||||||
|
palette: {
|
||||||
|
title: '快速跳转',
|
||||||
|
placeholder: '输入工具名称、分类或关键词…',
|
||||||
|
empty: '没有找到匹配的工具',
|
||||||
|
emptyHint: '试试「图片」「JSON」「时间」这类关键词。',
|
||||||
|
hintNavigate: '选择',
|
||||||
|
hintClose: '关闭',
|
||||||
|
hintToggle: '切换主题',
|
||||||
|
groupTool: '工具',
|
||||||
|
groupPage: '页面',
|
||||||
|
},
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
label: '主题',
|
||||||
|
light: '浅色',
|
||||||
|
dark: '深色',
|
||||||
|
auto: '跟随系统',
|
||||||
|
switchToLight: '切换到浅色模式',
|
||||||
|
switchToDark: '切换到深色模式',
|
||||||
|
switchToAuto: '切换为跟随系统',
|
||||||
|
current: '当前主题:{mode}',
|
||||||
|
},
|
||||||
|
|
||||||
|
locale: {
|
||||||
|
label: '语言',
|
||||||
|
'zh-CN': '简体中文',
|
||||||
|
'en-US': 'English',
|
||||||
|
'ja-JP': '日本語',
|
||||||
|
'ko-KR': '한국어',
|
||||||
|
},
|
||||||
|
|
||||||
|
home: {
|
||||||
|
title: '在浏览器里就能用的工具箱',
|
||||||
|
desc: '{count} 个日常工具,计算与文件处理全部在本机完成,数据不上传服务器。',
|
||||||
|
statTools: '可用工具',
|
||||||
|
statCategories: '工具分类',
|
||||||
|
statUpload: '上传到服务器的数据',
|
||||||
|
statUploadValue: '0 字节',
|
||||||
|
statLocal: '本地处理',
|
||||||
|
statLocalValue: '全部',
|
||||||
|
statLanguages: '界面语言',
|
||||||
|
statServer: '服务端接口',
|
||||||
|
statServerValue: '{count} 个',
|
||||||
|
// 首页工具卡片的搜索与分组
|
||||||
|
searchPlaceholder: '搜索工具,或按用途筛选',
|
||||||
|
resultCount: '共 {count} 个工具',
|
||||||
|
filterAll: '全部',
|
||||||
|
emptyTitle: '没有找到匹配的工具',
|
||||||
|
emptyHint: '试试更短的关键词,例如「图片」「JSON」「换算」。',
|
||||||
|
resetFilter: '重置筛选',
|
||||||
|
serverBadge: '需联网',
|
||||||
|
localBadge: '本地',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 工具分类(key 与 tools.ts 的 category 一致)
|
||||||
|
category: {
|
||||||
|
work: {
|
||||||
|
label: '工作效率',
|
||||||
|
desc: '开发编程、办公文本与文件处理',
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
label: '开发进阶',
|
||||||
|
desc: '面向开发者的格式化、转换与对比工具',
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
label: '创意设计',
|
||||||
|
desc: '生成渐变、占位图与字符画,灵感随手可得',
|
||||||
|
},
|
||||||
|
life: {
|
||||||
|
label: '生活助手',
|
||||||
|
desc: '日常计算、查询与决策小工具',
|
||||||
|
},
|
||||||
|
fun: {
|
||||||
|
label: '趣味娱乐',
|
||||||
|
desc: '摩斯电码等好玩的小玩具',
|
||||||
|
},
|
||||||
|
online: {
|
||||||
|
label: '服务端工具',
|
||||||
|
desc: '调用自建后端开放接口,需要联网',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 工具名称与描述(key 与工具路由 path 一致)
|
||||||
|
tool: {
|
||||||
|
'image-compress': {
|
||||||
|
name: '图片压缩',
|
||||||
|
desc: '本地压缩 JPG/PNG/WebP,可调质量,压缩后直接下载',
|
||||||
|
},
|
||||||
|
'image-to-ico': {
|
||||||
|
name: '图片转 ICO',
|
||||||
|
desc: 'PNG/JPG/WebP/SVG 转图标,多尺寸合成并控制输出体积',
|
||||||
|
},
|
||||||
|
'json-format': {
|
||||||
|
name: 'JSON 格式化',
|
||||||
|
desc: 'JSON 格式化、压缩、校验,一键复制结果',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
name: '时间戳转换',
|
||||||
|
desc: '时间戳与北京时间互转,支持秒/毫秒与批量转换',
|
||||||
|
},
|
||||||
|
base64: {
|
||||||
|
name: 'Base64 编解码',
|
||||||
|
desc: '文本 Base64 编解码,图片文件与 Base64 互转',
|
||||||
|
},
|
||||||
|
'jwt-parse': {
|
||||||
|
name: 'JWT 解析',
|
||||||
|
desc: '解析 JWT 的 Header 与 Payload,展示过期时间',
|
||||||
|
},
|
||||||
|
cron: {
|
||||||
|
name: 'Cron 表达式',
|
||||||
|
desc: '解析 Cron 下次执行时间,可视化生成表达式',
|
||||||
|
},
|
||||||
|
'radix-convert': {
|
||||||
|
name: '进制转换',
|
||||||
|
desc: '二进制、八进制、十进制、十六进制互转',
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
name: '哈希计算',
|
||||||
|
desc: '文本/文件计算 MD5、SHA1、SHA256、SHA512',
|
||||||
|
},
|
||||||
|
'url-parse': {
|
||||||
|
name: 'URL 解析',
|
||||||
|
desc: '解析协议/域名/路径/参数,也支持构造 URL',
|
||||||
|
},
|
||||||
|
translate: {
|
||||||
|
name: '翻译',
|
||||||
|
desc: '中文、英文、韩文、日文互译(免费在线接口)',
|
||||||
|
},
|
||||||
|
ocr: {
|
||||||
|
name: '图片 OCR 识别',
|
||||||
|
desc: '本地识别图片文字,支持中英文(WASM)',
|
||||||
|
},
|
||||||
|
uuid: {
|
||||||
|
name: 'UUID 生成',
|
||||||
|
desc: '批量生成 UUID v4,支持本机生成或调用服务端接口',
|
||||||
|
},
|
||||||
|
'regex-test': {
|
||||||
|
name: '正则测试',
|
||||||
|
desc: '实时测试正则匹配、分组与替换,匹配高亮显示',
|
||||||
|
},
|
||||||
|
'case-convert': {
|
||||||
|
name: '命名转换',
|
||||||
|
desc: '驼峰、蛇形、短横线等命名风格一键互转',
|
||||||
|
},
|
||||||
|
'color-convert': {
|
||||||
|
name: '色值转换',
|
||||||
|
desc: 'HEX、RGB、HSL、HSV 互转,取色器 + 预设色板',
|
||||||
|
},
|
||||||
|
'yaml-json': {
|
||||||
|
name: 'YAML ↔ JSON',
|
||||||
|
desc: 'YAML 与 JSON 双向转换,报错定位清晰',
|
||||||
|
},
|
||||||
|
'word-count': {
|
||||||
|
name: '字数统计',
|
||||||
|
desc: '统计字符、汉字、单词、段落与阅读时长',
|
||||||
|
},
|
||||||
|
'zh-convert': {
|
||||||
|
name: '简繁转换',
|
||||||
|
desc: '简体与繁体中文互转(opencc 词库)',
|
||||||
|
},
|
||||||
|
'image-cropper': {
|
||||||
|
name: '图片裁剪',
|
||||||
|
desc: '按比例裁剪图片,实时预览,导出 JPG/PNG',
|
||||||
|
},
|
||||||
|
qrcode: {
|
||||||
|
name: '二维码生成解析',
|
||||||
|
desc: '文本/链接生成二维码,上传图片解析二维码内容',
|
||||||
|
},
|
||||||
|
idcard: {
|
||||||
|
name: '身份证解析',
|
||||||
|
desc: '校验身份证合法性,解析生日、性别、年龄、地区',
|
||||||
|
},
|
||||||
|
'address-parse': {
|
||||||
|
name: '地址解析',
|
||||||
|
desc: '拆分中文地址的省、市、区与详细地址',
|
||||||
|
},
|
||||||
|
'unit-convert': {
|
||||||
|
name: '单位换算',
|
||||||
|
desc: '长度、质量、温度、数据存储等 8 类单位换算',
|
||||||
|
},
|
||||||
|
'rmb-upper': {
|
||||||
|
name: '人民币大写',
|
||||||
|
desc: '数字金额转人民币大写,处理角分与负数',
|
||||||
|
},
|
||||||
|
'date-calc': {
|
||||||
|
name: '日期计算',
|
||||||
|
desc: '日期差值、推算与年龄生肖星座计算',
|
||||||
|
},
|
||||||
|
'password-gen': {
|
||||||
|
name: '密码生成',
|
||||||
|
desc: '随机强密码批量生成,含强度评估',
|
||||||
|
},
|
||||||
|
bmi: {
|
||||||
|
name: 'BMI 计算器',
|
||||||
|
desc: '计算身体质量指数,中国标准分级与健康体重范围',
|
||||||
|
},
|
||||||
|
mortgage: {
|
||||||
|
name: '房贷计算器',
|
||||||
|
desc: '等额本息/等额本金对比,月供、总利息一目了然',
|
||||||
|
},
|
||||||
|
'bill-split': {
|
||||||
|
name: '账单拆分',
|
||||||
|
desc: '多人聚餐 AA 分账,自动生成转账方案',
|
||||||
|
},
|
||||||
|
'random-decision': {
|
||||||
|
name: '随机决定',
|
||||||
|
desc: '选项抽签治选择困难,随机数/骰子一键生成',
|
||||||
|
},
|
||||||
|
pomodoro: {
|
||||||
|
name: '番茄钟',
|
||||||
|
desc: '专注与休息倒计时,记录完成轮数',
|
||||||
|
},
|
||||||
|
'sql-format': {
|
||||||
|
name: 'SQL 格式化',
|
||||||
|
desc: 'SQL 语句美化:关键词换行、子句缩进,实时预览',
|
||||||
|
},
|
||||||
|
'text-diff': {
|
||||||
|
name: '文本对比',
|
||||||
|
desc: '逐行对比两段文本,增删高亮一目了然',
|
||||||
|
},
|
||||||
|
'json-to-code': {
|
||||||
|
name: 'JSON 转代码',
|
||||||
|
desc: 'JSON 一键生成 TypeScript 接口、Go 结构体与 Python 类',
|
||||||
|
},
|
||||||
|
'gradient-generator': {
|
||||||
|
name: '渐变生成器',
|
||||||
|
desc: '可视化调出 CSS 渐变,复制即用,内置流行配色预设',
|
||||||
|
},
|
||||||
|
'placeholder-image': {
|
||||||
|
name: '占位图生成',
|
||||||
|
desc: '自定义尺寸与配色的占位图,PNG 下载 / SVG 代码复制',
|
||||||
|
},
|
||||||
|
'ascii-art': {
|
||||||
|
name: '图片转字符画',
|
||||||
|
desc: '把图片转成 ASCII 字符画,可调密度与反转,酷炫复古',
|
||||||
|
},
|
||||||
|
'morse-code': {
|
||||||
|
name: '摩斯电码',
|
||||||
|
desc: '中英文与摩斯码互转,还能播放滴滴答答的电报声',
|
||||||
|
},
|
||||||
|
'regex-visual': {
|
||||||
|
name: '正则可视化',
|
||||||
|
desc: '把正则解析成结构树,附匹配测试',
|
||||||
|
},
|
||||||
|
'markdown-table': {
|
||||||
|
name: 'Markdown 表格',
|
||||||
|
desc: '粘贴数据一键生成 Markdown / HTML 表格',
|
||||||
|
},
|
||||||
|
'api-builder': {
|
||||||
|
name: 'API 请求构造器',
|
||||||
|
desc: '可视化拼接请求,生成 fetch 与 curl 代码',
|
||||||
|
},
|
||||||
|
'gitignore-gen': {
|
||||||
|
name: '.gitignore 生成器',
|
||||||
|
desc: '按技术栈勾选生成忽略规则',
|
||||||
|
},
|
||||||
|
'code-to-image': {
|
||||||
|
name: '代码转图片',
|
||||||
|
desc: '代码渲染成卡片图片,支持语法高亮',
|
||||||
|
},
|
||||||
|
'color-namer': {
|
||||||
|
name: '颜色命名器',
|
||||||
|
desc: '色值匹配最接近的 CSS 颜色名称',
|
||||||
|
},
|
||||||
|
'indent-convert': {
|
||||||
|
name: '缩进转换器',
|
||||||
|
desc: '空格与 Tab 缩进互转,2/4/8 空格切换',
|
||||||
|
},
|
||||||
|
'code-stats': {
|
||||||
|
name: '代码统计',
|
||||||
|
desc: '统计总行、代码行、注释行与空白行',
|
||||||
|
},
|
||||||
|
'hello-world': {
|
||||||
|
name: 'Hello World 大全',
|
||||||
|
desc: '30+ 编程语言的入门代码速查',
|
||||||
|
},
|
||||||
|
'json-diff': {
|
||||||
|
name: 'JSON 对比',
|
||||||
|
desc: '逐键对比两个 JSON 的新增、删除与修改',
|
||||||
|
},
|
||||||
|
'env-parse': {
|
||||||
|
name: '环境变量解析',
|
||||||
|
desc: '.env 与 JSON / 键值对双向转换',
|
||||||
|
},
|
||||||
|
'random-string': {
|
||||||
|
name: '随机字符串',
|
||||||
|
desc: '按长度与字符类型生成随机串,可选服务端密码学随机源',
|
||||||
|
},
|
||||||
|
'api-status': {
|
||||||
|
name: '服务端接口状态',
|
||||||
|
desc: '实时探测后端开放接口的可用性与响应耗时',
|
||||||
|
},
|
||||||
|
'exchange-rate': {
|
||||||
|
name: '汇率换算',
|
||||||
|
desc: '主流货币实时汇率换算',
|
||||||
|
},
|
||||||
|
'express-query': {
|
||||||
|
name: '快递查询',
|
||||||
|
desc: '物流轨迹一键查询',
|
||||||
|
},
|
||||||
|
'phone-locate': {
|
||||||
|
name: '手机号归属地',
|
||||||
|
desc: '归属地与运营商查询',
|
||||||
|
},
|
||||||
|
'ip-info': {
|
||||||
|
name: 'IP 信息查询',
|
||||||
|
desc: '查询访问来源 IP、是否内网地址,并展示本机环境信息',
|
||||||
|
},
|
||||||
|
'network-test': {
|
||||||
|
name: '网络测试',
|
||||||
|
desc: '站点可达性检测:HTTP / Ping / TCP / DNS',
|
||||||
|
},
|
||||||
|
'speed-test': {
|
||||||
|
name: '网络测速',
|
||||||
|
desc: '宽带下载/上传速度与延迟测试',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 具体工具页内部文案(已接入多语言的页面)
|
||||||
|
page: {
|
||||||
|
common: {
|
||||||
|
local: '本机',
|
||||||
|
server: '服务端',
|
||||||
|
source: '计算来源',
|
||||||
|
localHint: '在浏览器内完成,不产生网络请求',
|
||||||
|
serverHint: '调用后端开放接口,需要联网',
|
||||||
|
serverUnavailable: '服务端暂不可用:{message}',
|
||||||
|
refresh: '重新获取',
|
||||||
|
copyResult: '复制结果',
|
||||||
|
},
|
||||||
|
'ip-info': {
|
||||||
|
title: 'IP 信息查询',
|
||||||
|
desc: '调用服务端接口获取访问来源 IP,判断是否为内网地址,并附上本机浏览器环境信息。',
|
||||||
|
serverTitle: '服务端识别的来源 IP',
|
||||||
|
ipLabel: 'IP 地址',
|
||||||
|
internal: '内网地址',
|
||||||
|
external: '公网地址',
|
||||||
|
internalHint: '内网(私网)地址通常来自公司或家庭局域网,服务端无法据此定位到公网位置。',
|
||||||
|
externalHint: '服务端看到的是公网出口 IP,可能与本机所在网络不同(如经过代理或 NAT)。',
|
||||||
|
browserTitle: '本机环境',
|
||||||
|
timezone: '时区',
|
||||||
|
language: '浏览器语言',
|
||||||
|
platform: '操作系统',
|
||||||
|
screen: '屏幕分辨率',
|
||||||
|
viewport: '可视区域',
|
||||||
|
userAgent: 'User Agent',
|
||||||
|
geoTitle: '归属地查询',
|
||||||
|
geoUnavailable: '当前后端未提供 IP 归属地数据,仅返回 IP 与内网判定结果。',
|
||||||
|
},
|
||||||
|
uuid: {
|
||||||
|
title: 'UUID 生成',
|
||||||
|
desc: '批量生成 UUID,可在本机用浏览器安全随机数生成,也可调用服务端接口生成。',
|
||||||
|
count: '数量',
|
||||||
|
uppercase: '大写',
|
||||||
|
noHyphen: '无连字符',
|
||||||
|
modeLabel: '生成方式',
|
||||||
|
modeLocal: '本机(UUID v4)',
|
||||||
|
modeServer: '服务端(GoFrame guid)',
|
||||||
|
serverHint: '服务端返回 32 位 ID;勾选「短码」时返回 8 位随机短码。',
|
||||||
|
short: '短码(8 位)',
|
||||||
|
resultTitle: '生成结果',
|
||||||
|
regenerate: '重新生成',
|
||||||
|
copyAll: '复制全部',
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
title: '哈希计算',
|
||||||
|
desc: '对文本或文件计算 MD5、SHA1、SHA256、SHA512 哈希值,默认在浏览器本地完成。',
|
||||||
|
tabText: '文本哈希',
|
||||||
|
tabFile: '文件哈希',
|
||||||
|
inputTitle: '输入文本',
|
||||||
|
inputPlaceholder: '输入要计算哈希的文本',
|
||||||
|
uppercase: '大写输出',
|
||||||
|
compute: '计算',
|
||||||
|
clear: '清空',
|
||||||
|
uploadTitle: '上传文件',
|
||||||
|
uploadTip: '上传任意文件,自动计算哈希',
|
||||||
|
resultTitle: '哈希结果',
|
||||||
|
serverMd5: '同时用服务端计算 MD5',
|
||||||
|
serverMd5Hint: '额外调用后端接口算一次 MD5,用于核对两端算法是否一致。',
|
||||||
|
compareTitle: '两端口径对比',
|
||||||
|
compareSame: '两端结果一致',
|
||||||
|
compareDiff: '两端结果不一致,请注意来源',
|
||||||
|
compareServerFailed: '服务端计算失败:{message}',
|
||||||
|
errEmpty: '请输入要计算哈希的文本',
|
||||||
|
errCompute: '计算失败,请重试',
|
||||||
|
errFile: '文件读取失败,请重试',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
title: '时间戳转换',
|
||||||
|
desc: '时间戳与北京时间(UTC+8)互转,支持秒/毫秒与批量转换,并可读取服务端当前时间。',
|
||||||
|
tabTs2Date: '时间戳 → 时间',
|
||||||
|
tabDate2Ts: '时间 → 时间戳',
|
||||||
|
tabBatch: '批量转换',
|
||||||
|
convertTitle: '转换',
|
||||||
|
tsPlaceholder: '输入时间戳,例如 1704067200',
|
||||||
|
unit: '单位',
|
||||||
|
unitSecond: '秒',
|
||||||
|
unitMillisecond: '毫秒',
|
||||||
|
now: '当前时间',
|
||||||
|
convert: '转换',
|
||||||
|
batchConvert: '批量转换',
|
||||||
|
resultTitle: '转换结果',
|
||||||
|
pickTitle: '选择北京时间',
|
||||||
|
pickPlaceholder: '选择日期时间',
|
||||||
|
batchInputTitle: '批量时间戳 → 北京时间',
|
||||||
|
batchInputPlaceholder: '每行一个时间戳,例如:\n1704067200\n1704067200000',
|
||||||
|
batchResultTitle: '批量结果',
|
||||||
|
download: '下载',
|
||||||
|
beijing: '北京时间',
|
||||||
|
localTime: '本地时间',
|
||||||
|
secondSuffix: '(秒)',
|
||||||
|
millisecondSuffix: '(毫秒)',
|
||||||
|
errInvalidTs: '请输入合法的数字时间戳',
|
||||||
|
errOutOfRange: '时间戳超出可表示范围',
|
||||||
|
errInvalidDate: '请选择合法的日期时间',
|
||||||
|
invalidTs: '【无效时间戳】',
|
||||||
|
outOfRange: '【超出范围】',
|
||||||
|
serverNow: '读取服务端时间',
|
||||||
|
serverTimeLabel: '服务端时间',
|
||||||
|
serverOffset: '与本机相差',
|
||||||
|
clockSkewOk: '本机时间与服务端一致',
|
||||||
|
clockSkewWarn: '本机时间可能不准确,相差 {seconds} 秒',
|
||||||
|
serverFailed: '读取失败:{message}',
|
||||||
|
},
|
||||||
|
'random-string': {
|
||||||
|
title: '随机字符串',
|
||||||
|
desc: '按指定长度与字符类型生成随机字符串,可选服务端密码学随机源。',
|
||||||
|
length: '长度',
|
||||||
|
type: '字符类型',
|
||||||
|
typeAlnum: '字母 + 数字',
|
||||||
|
typeDigits: '纯数字',
|
||||||
|
typeLetters: '纯字母',
|
||||||
|
count: '生成数量',
|
||||||
|
modeLocal: '本机(Web Crypto)',
|
||||||
|
modeServer: '服务端(crypto/rand)',
|
||||||
|
generate: '生成',
|
||||||
|
resultTitle: '生成结果',
|
||||||
|
serverRangeHint: '服务端单次长度范围为 1-128,超出会自动改为本机生成。',
|
||||||
|
strengthHint: '长度越长、字符集越大,随机串越难被猜中。',
|
||||||
|
},
|
||||||
|
'api-status': {
|
||||||
|
title: '服务端接口状态',
|
||||||
|
desc: '逐个探测后端开放工具接口的可用性与响应耗时,用于确认前后端连通情况。',
|
||||||
|
endpoint: '接口',
|
||||||
|
method: '方法',
|
||||||
|
status: '状态',
|
||||||
|
latency: '耗时',
|
||||||
|
summary: '共 {total} 个接口,正常 {ok} 个',
|
||||||
|
allOk: '全部接口正常',
|
||||||
|
partial: '存在异常接口',
|
||||||
|
probe: '开始探测',
|
||||||
|
probing: '探测中…',
|
||||||
|
lastChecked: '最后探测:{time}',
|
||||||
|
baseUrlLabel: '接口基地址',
|
||||||
|
sampleTitle: '返回示例',
|
||||||
|
notConfigured: '未配置接口基地址,无法探测。',
|
||||||
|
failed: '请求失败',
|
||||||
|
endpointIp: '客户端 IP',
|
||||||
|
endpointMd5: 'MD5 摘要',
|
||||||
|
endpointRandom: '随机字符串',
|
||||||
|
endpointTime: '服务器时间',
|
||||||
|
endpointUuid: '唯一 ID',
|
||||||
|
ok: '正常',
|
||||||
|
error: '异常',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 工具页已就绪、但后端接口尚未实现的提示文案
|
||||||
|
pending: {
|
||||||
|
title: '该接口尚未上线',
|
||||||
|
hint: '前端页面已经就绪。后端实现对应接口后,这里会自动开始工作,无需改前端代码。',
|
||||||
|
networkTest:
|
||||||
|
'HTTP 站点检测已可直接使用;Ping / TCP / DNS 需要服务端实现探测接口后才会启用。',
|
||||||
|
speedTest:
|
||||||
|
'下载测速可用公网源进行(受对方 CORS 限制);上传测速需要服务端实现上传接口后才会启用。',
|
||||||
|
contactHint: '接口契约见项目内 docs/api-contract.md。',
|
||||||
|
},
|
||||||
|
|
||||||
|
common: {
|
||||||
|
copy: '复制', copied: '已复制到剪贴板',
|
||||||
|
copyAll: '复制全部',
|
||||||
|
clear: '清空',
|
||||||
|
download: '下载',
|
||||||
|
loading: '处理中…',
|
||||||
|
query: '查询',
|
||||||
|
querying: '查询中…',
|
||||||
|
convert: '转换',
|
||||||
|
generate: '生成',
|
||||||
|
compute: '计算',
|
||||||
|
history: '历史记录',
|
||||||
|
historyEmpty: '还没有历史记录',
|
||||||
|
empty: '暂无内容',
|
||||||
|
error: '出错了',
|
||||||
|
retry: '重试',
|
||||||
|
close: '关闭',
|
||||||
|
confirm: '确定',
|
||||||
|
cancel: '取消',
|
||||||
|
unitSeconds: '秒',
|
||||||
|
unitMilliseconds: '毫秒',
|
||||||
|
yes: '是',
|
||||||
|
no: '否',
|
||||||
|
notProvided: '未提供',
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -1,164 +1,346 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-layout">
|
<div class="shell">
|
||||||
<!-- 顶部栏:移动端菜单按钮 + Logo + 主题切换 -->
|
<!-- 键盘用户的第一个 Tab 站:直接跳过侧栏到正文 -->
|
||||||
|
<a class="skip-to-content" href="#main-content">{{ t('nav.skipToContent') }}</a>
|
||||||
|
|
||||||
|
<!-- ==================== 顶栏 ==================== -->
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<t-button variant="text" class="menu-btn" aria-label="打开导航" @click="drawerVisible = true">
|
<!-- 左区:移动端菜单按钮 + 品牌 -->
|
||||||
<template #icon><MenuIcon /></template>
|
<div class="header-left">
|
||||||
</t-button>
|
<button
|
||||||
|
class="icon-btn menu-btn"
|
||||||
<div class="logo" @click="router.push('/')">
|
type="button"
|
||||||
<span class="logo-icon">
|
:aria-label="t('nav.menuAria')"
|
||||||
<AppIcon />
|
@click="drawerVisible = true"
|
||||||
</span>
|
>
|
||||||
<span class="logo-text">在线工具箱</span>
|
<MenuIcon />
|
||||||
|
</button>
|
||||||
|
<AppBrand />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<t-button
|
<!-- 中区:命令面板入口。桌面端做成搜索框的样子,移动端收成图标 -->
|
||||||
variant="outline"
|
<div class="header-center">
|
||||||
shape="circle"
|
<button class="palette-trigger" type="button" @click="palette.open()">
|
||||||
:title="theme.mode === 'light' ? '切换到暗黑模式' : '切换到浅色模式'"
|
<SearchIcon class="trigger-icon" aria-hidden="true" />
|
||||||
@click="theme.toggle()"
|
<span class="trigger-text">{{ t('nav.searchPlaceholder') }}</span>
|
||||||
>
|
<kbd class="trigger-kbd">{{ shortcutLabel }}</kbd>
|
||||||
<template #icon>
|
</button>
|
||||||
<MoonIcon v-if="theme.mode === 'light'" />
|
</div>
|
||||||
<SunnyIcon v-else />
|
|
||||||
</template>
|
<!-- 右区:语言 + 主题 -->
|
||||||
</t-button>
|
<div class="header-right">
|
||||||
|
<LocaleSwitch />
|
||||||
|
<ThemeSwitch />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="body-row">
|
<!-- ==================== 侧栏 + 正文 ==================== -->
|
||||||
<!-- PC 端:左侧固定导航 -->
|
<div class="shell-body">
|
||||||
<aside class="app-aside">
|
<aside class="app-aside">
|
||||||
<SideNav />
|
<SideNav />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 主体内容区域 -->
|
<main id="main-content" class="app-content">
|
||||||
<main class="app-content">
|
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="app-footer">纯前端处理 · 数据不出浏览器 · 不上传服务器</footer>
|
<!-- ==================== 页脚 ==================== -->
|
||||||
|
<!-- 三句承诺用竖线分列而不是圆点堆叠,读起来是三条并列信息而非一串标签。
|
||||||
|
备案号单独一行:它是监管信息,不该和产品承诺混在同一组里。 -->
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div class="footer-promises">
|
||||||
|
<span>{{ t('app.footer.local') }}</span>
|
||||||
|
<span class="footer-divider" aria-hidden="true" />
|
||||||
|
<span>{{ t('app.footer.privacy') }}</span>
|
||||||
|
<span class="footer-divider" aria-hidden="true" />
|
||||||
|
<span>{{ t('app.footer.free') }}</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
class="footer-icp"
|
||||||
|
:href="SITE.beianUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:title="t('app.footer.icpTitle')"
|
||||||
|
>
|
||||||
|
{{ SITE.icp }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<!-- 移动端:抽屉式导航 -->
|
<!-- 移动端抽屉导航(PC 端侧栏隐藏时的替代入口) -->
|
||||||
<t-drawer
|
<t-drawer
|
||||||
v-model:visible="drawerVisible"
|
v-model:visible="drawerVisible"
|
||||||
placement="left"
|
placement="left"
|
||||||
:size="240"
|
:size="300"
|
||||||
:footer="false"
|
:footer="false"
|
||||||
:header="false"
|
:header="false"
|
||||||
:close-btn="true"
|
:close-btn="true"
|
||||||
>
|
>
|
||||||
<SideNav @navigate="drawerVisible = false" />
|
<SideNav @navigate="drawerVisible = false" />
|
||||||
</t-drawer>
|
</t-drawer>
|
||||||
|
|
||||||
|
<!-- 命令面板(全局单例,挂在布局层) -->
|
||||||
|
<CommandPalette />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { MenuIcon, MoonIcon, SunnyIcon, AppIcon } from 'tdesign-icons-vue-next'
|
import { MenuIcon, SearchIcon } from 'tdesign-icons-vue-next'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import AppBrand from '@/components/common/AppBrand.vue'
|
||||||
|
import LocaleSwitch from '@/components/common/LocaleSwitch.vue'
|
||||||
|
import ThemeSwitch from '@/components/common/ThemeSwitch.vue'
|
||||||
|
import CommandPalette from '@/components/common/CommandPalette.vue'
|
||||||
|
import { useCommandPalette } from '@/composables/useCommandPalette'
|
||||||
|
import { SITE } from '@/config'
|
||||||
import SideNav from './SideNav.vue'
|
import SideNav from './SideNav.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
/**
|
||||||
const theme = useThemeStore()
|
* 应用外壳布局。
|
||||||
|
*
|
||||||
|
* 结构:固定顶栏(68px)+ 左侧固定导航(268px)+ 内容区 + 页脚。
|
||||||
|
* 移动端(<768px)侧栏换成抽屉,顶栏保留品牌、命令面板与主题/语言切换。
|
||||||
|
*
|
||||||
|
* 版心约定:
|
||||||
|
* --tk-shell-max = 1600px 顶栏 / 页脚 / 主体三者的最大宽度
|
||||||
|
* --tk-content-max = 1200px 内容型工具页的版心(由 page-wrap 约束)
|
||||||
|
* 首页因为要铺开卡片网格,会自行放宽,不受 page-wrap 限制。
|
||||||
|
*
|
||||||
|
* 尺寸一律跟随令牌(--tk-header-h / --tk-aside-w),不要在组件里写死 px:
|
||||||
|
* 本版的整体放大是改令牌完成的,写死会让顶栏与内容脱节。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
const palette = useCommandPalette()
|
||||||
|
|
||||||
/** 移动端抽屉导航显隐 */
|
/** 移动端抽屉导航显隐 */
|
||||||
const drawerVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷键提示按平台显示。
|
||||||
|
* Windows 上显示 ⌘ 会让用户以为按不出来,这里按 UA 判断一次即可,
|
||||||
|
* 不需要精确到具体浏览器。
|
||||||
|
*/
|
||||||
|
const shortcutLabel = computed(() => {
|
||||||
|
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
|
||||||
|
return /Mac|iPhone|iPad|iPod/i.test(ua) ? '⌘K' : 'Ctrl K'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.main-layout {
|
.shell {
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: var(--tk-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 顶部栏 ---------- */
|
/* ==================== 顶栏 ==================== */
|
||||||
.app-header {
|
.app-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: var(--tk-z-header);
|
||||||
height: 56px;
|
height: var(--tk-header-h);
|
||||||
background: var(--td-bg-color-container);
|
background: var(--tk-glass);
|
||||||
border-bottom: 1px solid var(--td-border-level-1-color);
|
backdrop-filter: blur(14px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(180%);
|
||||||
|
border-bottom: 1px solid var(--tk-glass-border);
|
||||||
}
|
}
|
||||||
.header-inner {
|
.header-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: 1440px;
|
max-width: var(--tk-shell-max);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 16px;
|
padding: 0 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.logo {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
cursor: pointer;
|
flex-shrink: 0;
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
.logo-icon {
|
.header-center {
|
||||||
width: 30px;
|
flex: 1;
|
||||||
height: 30px;
|
min-width: 0;
|
||||||
border-radius: 8px;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶栏图标按钮(移动端菜单) */
|
||||||
|
.icon-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
border: none;
|
||||||
background: linear-gradient(135deg, var(--td-brand-color), #36b37e);
|
border-radius: var(--tk-radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
.logo-text {
|
.icon-btn:hover {
|
||||||
font-size: 17px;
|
background: var(--tk-surface-hover);
|
||||||
font-weight: 600;
|
color: var(--tk-text);
|
||||||
color: var(--td-text-color-primary);
|
|
||||||
}
|
}
|
||||||
.menu-btn {
|
.menu-btn {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.header-inner > :last-child {
|
|
||||||
margin-left: auto;
|
/* 命令面板入口:长得像搜索框,本质是按钮(点开面板而非直接输入)。
|
||||||
|
* 用无描边的「安静底纹」而不是带边框的输入框:
|
||||||
|
* 它不是一个可以直接打字的输入框,画成输入框会让人先去点它再发现要跳面板。
|
||||||
|
* 放大镜图标 + 占位文案 + 快捷键角标三重提示已经足够表达可搜索。
|
||||||
|
* 高度 44px 与站点默认控件同高,顶栏一眼看去是一排等高的可点区域。 */
|
||||||
|
.palette-trigger {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px 0 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
background: var(--tk-surface-sunken);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--tk-dur-fast) var(--tk-ease-out),
|
||||||
|
background-color var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.palette-trigger:hover {
|
||||||
|
background: var(--tk-surface-active);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
.palette-trigger:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
.trigger-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.trigger-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.trigger-kbd {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid var(--tk-border);
|
||||||
|
border-radius: var(--tk-radius-xs);
|
||||||
|
background: var(--tk-surface-sunken);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 主体 ---------- */
|
/* ==================== 主体 ==================== */
|
||||||
.body-row {
|
.shell-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: calc(100vh - 56px);
|
width: 100%;
|
||||||
|
max-width: var(--tk-shell-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左侧固定导航(PC 端) */
|
/* 左侧固定导航(PC 端)
|
||||||
|
* 侧栏与正文共用同一张页面底,仅靠留白与字号区分层级;
|
||||||
|
* 导航项 hover 用 --tk-surface(浅色下更亮的白、深色下同样更亮),
|
||||||
|
* 两种主题下都是「抬起」的方向感。 */
|
||||||
.app-aside {
|
.app-aside {
|
||||||
width: 224px;
|
width: var(--tk-aside-w);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 56px;
|
top: var(--tk-header-h);
|
||||||
height: calc(100vh - 56px);
|
height: calc(100dvh - var(--tk-header-h));
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--td-bg-color-container);
|
overscroll-behavior: contain;
|
||||||
border-right: 1px solid var(--td-border-level-1-color);
|
padding: 26px 20px 56px;
|
||||||
padding: 12px 10px;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 内容区留白:字号放大后正文变宽,这里的留白比上一版收敛一档,
|
||||||
|
* 避免 1080p 屏幕上正文被挤成窄窄一条。 */
|
||||||
.app-content {
|
.app-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 24px 20px;
|
padding: 32px 48px 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== 页脚 ====================
|
||||||
|
* 承诺在左、备案号在右:两者是不同性质的信息(产品承诺 / 监管信息),
|
||||||
|
* 居中堆叠会让它们看起来像同一组。 */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
padding: 12px;
|
border-top: 1px solid var(--tk-hairline);
|
||||||
text-align: center;
|
}
|
||||||
font-size: 12px;
|
.footer-inner {
|
||||||
color: var(--td-text-color-placeholder);
|
max-width: var(--tk-shell-max);
|
||||||
border-top: 1px solid var(--td-border-level-1-color);
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px 24px;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.footer-promises {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.footer-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--tk-border);
|
||||||
|
}
|
||||||
|
/* 备案号:监管信息,压到比产品承诺更弱的视觉层级,但仍要可读、可点 */
|
||||||
|
.footer-icp {
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition: color var(--tk-dur-fast) var(--tk-ease-out),
|
||||||
|
border-color var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.footer-icp:hover {
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
border-bottom-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 响应式 ==================== */
|
||||||
|
/* 中等宽度:收窄留白,但保持「正文两侧有空白」的基本观感 */
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.app-content {
|
||||||
|
padding: 28px 32px 64px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 响应式:移动端 H5 ---------- */
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.app-aside {
|
.app-aside {
|
||||||
display: none;
|
display: none;
|
||||||
@ -167,10 +349,33 @@ const drawerVisible = ref(false)
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.app-content {
|
.app-content {
|
||||||
padding: 16px 12px;
|
padding: 20px 16px 56px;
|
||||||
}
|
}
|
||||||
.logo-text {
|
.footer-inner {
|
||||||
font-size: 15px;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 24px 16px 32px;
|
||||||
|
}
|
||||||
|
/* 移动端搜索入口收成图标按钮:整条搜索框会挤掉品牌与主题切换 */
|
||||||
|
.palette-trigger {
|
||||||
|
max-width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.palette-trigger:hover {
|
||||||
|
background: var(--tk-surface-hover);
|
||||||
|
}
|
||||||
|
.trigger-text,
|
||||||
|
.trigger-kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.header-inner {
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,37 +1,169 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="side-nav">
|
<nav class="side-nav" :aria-label="t('nav.browse')">
|
||||||
<router-link to="/" class="nav-item" :class="{ active: route.path === '/' }" @click="emit('navigate')">
|
<!-- 侧栏内联筛选:56 个工具靠滚动查找太慢,就地过滤比开面板更快 -->
|
||||||
<HomeIcon class="nav-icon" />
|
<div class="nav-filter">
|
||||||
<span>首页</span>
|
<SearchIcon class="filter-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
v-model="keyword"
|
||||||
|
class="filter-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
:placeholder="t('nav.searchPlaceholder')"
|
||||||
|
:aria-label="t('nav.searchAria')"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="keyword"
|
||||||
|
class="filter-clear"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('nav.clearSearch')"
|
||||||
|
@click="keyword = ''"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 首页入口:不属于任何分类,单独置顶 -->
|
||||||
|
<router-link
|
||||||
|
to="/"
|
||||||
|
class="nav-item home-item"
|
||||||
|
:class="{ active: route.path === '/' }"
|
||||||
|
@click="emit('navigate')"
|
||||||
|
>
|
||||||
|
<HomeIcon class="nav-icon" aria-hidden="true" />
|
||||||
|
<span class="nav-label">{{ t('nav.home') }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<div class="nav-group-title">全部工具</div>
|
<!-- 分类分组:标题可折叠,折叠状态记在本地存储 -->
|
||||||
|
<div v-for="group in visibleGroups" :key="group.category.key" class="nav-group">
|
||||||
|
<button
|
||||||
|
class="group-head"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="!isCollapsed(group.category.key)"
|
||||||
|
@click="toggleGroup(group.category.key)"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
class="group-caret"
|
||||||
|
:class="{ collapsed: isCollapsed(group.category.key) }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="group-label">{{ categoryLabel(group.category.key, group.category.label) }}</span>
|
||||||
|
<span class="group-count">{{ group.items.length }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-show="!isCollapsed(group.category.key)" class="group-items">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="tool in tools"
|
v-for="tool in group.items"
|
||||||
:key="tool.path"
|
:key="tool.path"
|
||||||
:to="`/${tool.path}`"
|
:to="`/${tool.path}`"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: route.path === `/${tool.path}` }"
|
:class="{ active: route.path === `/${tool.path}` }"
|
||||||
@click="emit('navigate')"
|
@click="emit('navigate')"
|
||||||
>
|
>
|
||||||
<component :is="tool.icon" class="nav-icon" />
|
<component :is="tool.icon" class="nav-icon" aria-hidden="true" />
|
||||||
<span>{{ tool.name }}</span>
|
<span class="nav-label">{{ toolName(tool.path, tool.name) }}</span>
|
||||||
|
<span v-if="tool.needsNetwork" class="nav-dot" :title="t('home.serverBadge')" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选无结果:给出可执行的下一步,而不是只写「无结果」 -->
|
||||||
|
<div v-if="!visibleGroups.length" class="nav-empty">
|
||||||
|
<p class="empty-title">{{ t('nav.noResult') }}</p>
|
||||||
|
<p class="empty-hint">{{ t('nav.noResultHint') }}</p>
|
||||||
|
<button class="empty-btn" type="button" @click="keyword = ''">
|
||||||
|
{{ t('nav.clearSearch') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { HomeIcon } from 'tdesign-icons-vue-next'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { tools } from '@/router/tools'
|
import { HomeIcon, SearchIcon, CloseIcon, ChevronDownIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import { groupedTools } from '@/router/tools'
|
||||||
|
import { toolName, toolDesc, categoryLabel } from '@/i18n/helpers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 侧边导航:数据源为工具注册表 tools.ts。
|
* 侧边导航:数据源为工具注册表 tools.ts,按分类分组渲染。
|
||||||
* PC 端渲染在左侧固定栏,移动端渲染在抽屉内(点击后由父组件关闭抽屉)。
|
* PC 端渲染在左侧固定栏,移动端渲染在抽屉内(点击后由父组件关闭抽屉)。
|
||||||
|
*
|
||||||
|
* 三个细节:
|
||||||
|
* - 分组标题可折叠,折叠状态写入本地存储,刷新后保留用户的整理结果;
|
||||||
|
* 一旦开始筛选,折叠状态会被忽略(强制展开),否则用户会以为「搜不到」。
|
||||||
|
* - 需要联网的工具在右侧点一个小圆点标记,且这是全站唯一使用状态点的地方,
|
||||||
|
* 语义明确(是否需联网),不是装饰。
|
||||||
|
* - 筛选对「已翻译的名称」生效,因此英文/日文/韩文界面下用对应语言也能搜到。
|
||||||
|
*
|
||||||
|
* 尺寸:导航项行高 40px 起步(上一版 34px 偏小,鼠标点起来费劲),
|
||||||
|
* 字号与正文同号,避免侧栏比正文「小一号」。
|
||||||
*/
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
/** 分类分组(新增分类自动出现,无需改动本组件) */
|
||||||
|
const groups = groupedTools()
|
||||||
|
|
||||||
|
/** 侧栏筛选关键词 */
|
||||||
|
const keyword = ref('')
|
||||||
|
|
||||||
|
/** 折叠的分类 key 集合,持久化到 localStorage */
|
||||||
|
const COLLAPSE_KEY = 'tool-nav-collapsed'
|
||||||
|
const collapsed = ref<Set<string>>(loadCollapsed())
|
||||||
|
|
||||||
|
function loadCollapsed(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COLLAPSE_KEY)
|
||||||
|
const list = raw ? (JSON.parse(raw) as string[]) : []
|
||||||
|
return new Set(Array.isArray(list) ? list : [])
|
||||||
|
} catch {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCollapsed() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSE_KEY, JSON.stringify([...collapsed.value]))
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时静默降级为「仅当次会话生效」
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(key: string) {
|
||||||
|
const next = new Set(collapsed.value)
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key)
|
||||||
|
collapsed.value = next
|
||||||
|
saveCollapsed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否处于折叠状态;筛选期间一律视为展开,保证过滤结果可见 */
|
||||||
|
function isCollapsed(key: string): boolean {
|
||||||
|
if (keyword.value.trim()) return false
|
||||||
|
return collapsed.value.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按关键词过滤后的分组。
|
||||||
|
* 空分组直接剔除,避免出现「分类标题下方什么都没有」的尴尬区块。
|
||||||
|
*/
|
||||||
|
const visibleGroups = computed(() => {
|
||||||
|
const kw = keyword.value.trim().toLowerCase()
|
||||||
|
if (!kw) return groups
|
||||||
|
return groups
|
||||||
|
.map((group) => ({
|
||||||
|
category: group.category,
|
||||||
|
items: group.items.filter((tool) =>
|
||||||
|
[toolName(tool.path, tool.name), toolDesc(tool.path, tool.desc), tool.path]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(kw),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.items.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
/** 点击菜单项后通知父组件(移动端用于关闭抽屉) */
|
/** 点击菜单项后通知父组件(移动端用于关闭抽屉) */
|
||||||
navigate: []
|
navigate: []
|
||||||
@ -42,38 +174,209 @@ const emit = defineEmits<{
|
|||||||
.side-nav {
|
.side-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
|
||||||
}
|
}
|
||||||
.nav-group-title {
|
|
||||||
padding: 8px 12px 4px;
|
/* ---------- 筛选框 ----------
|
||||||
font-size: 12px;
|
* 只留一条底线而不是画一个框:侧栏里的框会和下方导航项的背景块打架,
|
||||||
color: var(--td-text-color-placeholder);
|
* 视觉上把「筛选」误读成「一个大按钮」。底线 + 聚焦变色已经足够提示可输入。
|
||||||
}
|
* 高度 42px:与导航项同档,点起来有足够的热区。 */
|
||||||
.nav-item {
|
.nav-filter {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 9px 12px;
|
height: 42px;
|
||||||
border-radius: 6px;
|
margin-bottom: 20px;
|
||||||
font-size: 14px;
|
padding: 0 2px;
|
||||||
color: var(--td-text-color-secondary);
|
border-bottom: 1px solid var(--tk-border);
|
||||||
text-decoration: none;
|
transition: border-color var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
transition:
|
|
||||||
background-color 0.15s,
|
|
||||||
color 0.15s;
|
|
||||||
}
|
}
|
||||||
.nav-item:hover {
|
.nav-filter:focus-within {
|
||||||
background: var(--td-bg-color-secondarycontainer);
|
border-bottom-color: var(--tk-accent);
|
||||||
color: var(--td-text-color-primary);
|
|
||||||
}
|
}
|
||||||
.nav-item.active {
|
.filter-icon {
|
||||||
background: var(--td-brand-color-light);
|
font-size: 17px;
|
||||||
color: var(--td-brand-color);
|
color: var(--tk-text-placeholder);
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.nav-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.filter-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.filter-input::placeholder {
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.filter-clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--tk-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.filter-clear:hover {
|
||||||
|
color: var(--tk-text);
|
||||||
|
background: var(--tk-surface-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 分组标题 ----------
|
||||||
|
* 分组之间靠 16px 留白拉开,不画横线;
|
||||||
|
* 分类名是「目录行」,字号比导航项小一档但字距略开,扫读时先看到分组再看到工具。 */
|
||||||
|
.nav-group {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.group-head {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--tk-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.group-head:hover {
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
.group-caret {
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.group-caret.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
.group-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
/* 0.06em:中文分组名(如「工作效率」)在这个字距下刚好成组,
|
||||||
|
* 再宽就会散成四个孤立的字。 */
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.group-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 导航项 ----------
|
||||||
|
* 行高 40px:这是全站点击最密集的列表,必须给足热区。
|
||||||
|
* hover 用 --tk-surface(浅色下更亮、深色下也更亮)而不是 hover 灰,
|
||||||
|
* 这样两种主题下都是同一种「抬起」的方向感。 */
|
||||||
|
.group-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background-color var(--tk-dur-fast) var(--tk-ease-out),
|
||||||
|
color var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--tk-surface);
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
/* 活动态:主色底纹 + 左侧 3px 指示条,比纯文字变色更容易在长列表里定位。
|
||||||
|
* 指示条压在条目左侧外 11px 处,贴在侧栏留白里,像书页边的一条书签。 */
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--tk-accent-surface);
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.nav-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -11px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--tk-radius-pill);
|
||||||
|
background: var(--tk-accent);
|
||||||
|
}
|
||||||
|
.home-item {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.nav-icon {
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 唯一的语义状态点:该工具需要联网 */
|
||||||
|
.nav-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tk-accent);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 空状态 ---------- */
|
||||||
|
.nav-empty {
|
||||||
|
padding: 28px 10px;
|
||||||
|
}
|
||||||
|
.empty-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
.empty-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.empty-btn {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--tk-border);
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
background: var(--tk-surface);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.empty-btn:hover {
|
||||||
|
border-color: var(--tk-accent);
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
17
src/main.ts
17
src/main.ts
@ -3,8 +3,12 @@ import { createPinia } from 'pinia'
|
|||||||
import TDesign from 'tdesign-vue-next'
|
import TDesign from 'tdesign-vue-next'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { i18n, applyLocaleToDocument, resolveInitialLocale } from './i18n'
|
||||||
|
import { reveal } from './directives/reveal'
|
||||||
|
import { useThemeStore } from './stores/theme'
|
||||||
|
|
||||||
// 样式:TDesign 组件样式 + UnoCSS 原子类 + 全局自定义样式
|
// 样式:TDesign 组件样式 + UnoCSS 原子类 + 全局自定义样式
|
||||||
|
// 注意顺序:自定义样式必须排在 TDesign 之后,否则令牌覆盖不生效
|
||||||
import 'tdesign-vue-next/es/style/index.css'
|
import 'tdesign-vue-next/es/style/index.css'
|
||||||
import 'virtual:uno.css'
|
import 'virtual:uno.css'
|
||||||
import './assets/styles/index.scss'
|
import './assets/styles/index.scss'
|
||||||
@ -12,12 +16,19 @@ import './assets/styles/index.scss'
|
|||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
|
app.use(i18n)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
// 全量注册 TDesign 组件(工具站体积不敏感,全量注册开发效率最高)
|
// 全量注册 TDesign 组件(工具站体积不敏感,全量注册开发效率最高)
|
||||||
app.use(TDesign)
|
app.use(TDesign)
|
||||||
|
// 全局注册滚动进入动效指令,首页与卡片列表使用
|
||||||
|
app.directive('reveal', reveal)
|
||||||
|
|
||||||
// 主题初始化:与 index.html 内联脚本保持一致(读取本地存储,刷新不丢失)
|
// 主题初始化:三态偏好(浅色/深色/跟随系统),与 index.html 内联脚本保持一致。
|
||||||
const savedTheme = localStorage.getItem('tool-theme') || 'light'
|
// index.html 已处理首屏防闪烁,这里负责接管系统主题变化的监听。
|
||||||
document.documentElement.setAttribute('theme-mode', savedTheme)
|
const theme = useThemeStore()
|
||||||
|
theme.init()
|
||||||
|
|
||||||
|
// 语言初始化:把 html[lang] 写成解析后的语言,字体栈随之切换
|
||||||
|
applyLocaleToDocument(resolveInitialLocale())
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@ -2,11 +2,16 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
import MainLayout from '@/layout/MainLayout.vue'
|
import MainLayout from '@/layout/MainLayout.vue'
|
||||||
import { tools } from './tools'
|
import { tools } from './tools'
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
|
import { toolName } from '@/i18n/helpers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 路由约定:每个工具对应一个独立路由,路径与 tools.ts 中的 path 一致。
|
* 路由约定:每个工具对应一个独立路由,路径与 tools.ts 中的 path 一致。
|
||||||
* 所有工具页面均嵌套在 MainLayout 下,共享侧边导航与主题切换。
|
* 所有工具页面均嵌套在 MainLayout 下,共享侧边导航与主题/语言切换。
|
||||||
* 未匹配路径统一重定向到首页。
|
* 未匹配路径统一重定向到首页。
|
||||||
|
*
|
||||||
|
* 标题本地化:meta 里存的是「取词用的 key」而不是写死的标题文案,
|
||||||
|
* 这样切换语言后重新计算即可得到对应语言的标题,无需重建路由表。
|
||||||
*/
|
*/
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@ -17,14 +22,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: () => import('@/views/HomeView.vue'),
|
component: () => import('@/views/HomeView.vue'),
|
||||||
meta: { title: '首页' },
|
// 首页标题直接取站点名,避免出现「首页 · 在线工具箱」这类冗余
|
||||||
|
meta: { titleKey: 'app.name' },
|
||||||
},
|
},
|
||||||
// 由工具注册表自动生成路由,无需手动维护
|
// 由工具注册表自动生成路由,无需手动维护
|
||||||
...tools.map((t) => ({
|
...tools.map((t) => ({
|
||||||
path: t.path,
|
path: t.path,
|
||||||
name: t.path,
|
name: t.path,
|
||||||
component: t.component,
|
component: t.component,
|
||||||
meta: { title: t.name },
|
meta: { titleKey: `tool.${t.path}.name`, toolPath: t.path, fallbackName: t.name },
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -37,10 +43,27 @@ const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 页面切换后同步更新浏览器标题
|
/** 按当前语言把 meta 转成页面标题 */
|
||||||
router.afterEach((to) => {
|
function resolveTitle(meta: Record<string, unknown>): string {
|
||||||
const title = (to.meta.title as string | undefined) ?? '首页'
|
const key = meta.titleKey as string | undefined
|
||||||
document.title = `${title} · 在线工具箱`
|
if (key === 'app.name') return i18n.global.t('app.name')
|
||||||
|
if (key && i18n.global.te(key)) return i18n.global.t(key)
|
||||||
|
const toolPath = meta.toolPath as string | undefined
|
||||||
|
if (toolPath) return toolName(toolPath, (meta.fallbackName as string | undefined) ?? '')
|
||||||
|
return i18n.global.t('app.name')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步浏览器标题。
|
||||||
|
* 除路由切换外,切换语言时也需要调用一次,否则标题会停留在旧语言。
|
||||||
|
*/
|
||||||
|
export function syncDocumentTitle() {
|
||||||
|
const meta = (router.currentRoute.value.meta ?? {}) as Record<string, unknown>
|
||||||
|
document.title = `${resolveTitle(meta)} · ${i18n.global.t('app.short')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
router.afterEach(() => {
|
||||||
|
syncDocumentTitle()
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import {
|
import {
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
|
FileImageIcon,
|
||||||
FrameIcon,
|
FrameIcon,
|
||||||
QrcodeIcon,
|
QrcodeIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
@ -24,64 +25,120 @@ import {
|
|||||||
MoneyIcon,
|
MoneyIcon,
|
||||||
InternetIcon,
|
InternetIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
|
HeartIcon,
|
||||||
|
WalletIcon,
|
||||||
|
UsergroupIcon,
|
||||||
|
GiftIcon,
|
||||||
|
AlarmIcon,
|
||||||
|
TerminalIcon,
|
||||||
|
GitBranchIcon,
|
||||||
|
ApiIcon,
|
||||||
|
BrushIcon,
|
||||||
|
SoundIcon,
|
||||||
|
ViewModuleIcon,
|
||||||
|
SearchIcon,
|
||||||
|
Table1Icon,
|
||||||
|
LogoGithubIcon,
|
||||||
|
ContrastIcon,
|
||||||
|
Edit2Icon,
|
||||||
|
ChartBarIcon,
|
||||||
|
EarthIcon,
|
||||||
|
GitMergeIcon,
|
||||||
|
SettingIcon,
|
||||||
|
CurrencyExchangeIcon,
|
||||||
|
MobileIcon,
|
||||||
|
WifiIcon,
|
||||||
|
RocketIcon,
|
||||||
|
TextboxIcon,
|
||||||
|
ServerIcon,
|
||||||
} from 'tdesign-icons-vue-next'
|
} from 'tdesign-icons-vue-next'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具注册表:网站内所有工具的“唯一数据源”。
|
* 工具注册表:网站内所有工具的“唯一数据源”。
|
||||||
* 侧边导航、首页卡片、路由表均由此文件生成。
|
* 侧边导航、首页卡片、命令面板、路由表均由此文件生成。
|
||||||
*
|
*
|
||||||
* ⚙️ 新增工具只需三步:
|
* ⚙️ 新增工具只需三步:
|
||||||
* 1. 在 src/views/<工具名>/ 下创建页面组件(可参考现有工具页面);
|
* 1. 在 src/views/<工具名>/ 下创建页面组件(可参考现有工具页面);
|
||||||
* 2. 在本文件的 tools 数组中追加一条配置(path/name/desc/icon/component);
|
* 2. 在本文件的 tools 数组中追加一条配置(path/name/desc/icon/category/component);
|
||||||
* 3. 无需修改路由文件、首页与侧边栏,它们会自动渲染新工具。
|
* 3. 在 src/i18n/locales/ 的 4 个语言包中补齐 tool.<path>.name / tool.<path>.desc。
|
||||||
|
* 无需修改路由文件、首页与侧边栏,它们会自动渲染新工具。
|
||||||
|
*
|
||||||
|
* 🌐 多语言约定:本文件里的 name / desc 是「简体中文原文」,同时作为翻译缺失时的兜底值;
|
||||||
|
* 其它语言由 i18n 语言包提供(见 src/i18n/helpers.ts 的 toolName / toolDesc)。
|
||||||
|
*
|
||||||
|
* 🗂️ 分类扩展:在 toolCategories 数组追加一条 { key/label/desc },
|
||||||
|
* 再把对应工具的 category 指向新 key 即可,导航与首页会自动出现新分组。
|
||||||
|
* 注意:没有任何工具的分类不会渲染(groupedTools 会过滤掉空分组)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** 工具分类定义(数据驱动,顺序即展示顺序) */
|
||||||
|
export interface ToolCategory {
|
||||||
|
/** 分类唯一标识,与 ToolItem.category 对应 */
|
||||||
|
key: string
|
||||||
|
/** 分类名称(导航分组标题、首页分区标题) */
|
||||||
|
label: string
|
||||||
|
/** 一句话描述(首页分区副标题) */
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toolCategories: ToolCategory[] = [
|
||||||
|
{ key: 'work', label: '工作效率', desc: '开发编程、办公文本与文件处理' },
|
||||||
|
{ key: 'dev', label: '开发进阶', desc: '面向开发者的格式化、转换与对比工具' },
|
||||||
|
{ key: 'design', label: '创意设计', desc: '生成渐变、占位图与字符画,灵感随手可得' },
|
||||||
|
{ key: 'life', label: '生活助手', desc: '日常计算、查询与决策小工具' },
|
||||||
|
{ key: 'fun', label: '趣味娱乐', desc: '摩斯电码等好玩的小玩具' },
|
||||||
|
{ key: 'online', label: '服务端工具', desc: '调用自建后端开放接口,需要联网' },
|
||||||
|
]
|
||||||
|
|
||||||
export interface ToolItem {
|
export interface ToolItem {
|
||||||
/** 路由路径(相对根路径,不含前导斜杠) */
|
/** 路由路径(相对根路径,不含前导斜杠),同时用作 i18n 的 key */
|
||||||
path: string
|
path: string
|
||||||
/** 工具名称(菜单、卡片、页面标题使用) */
|
/** 工具名称(简体中文原文,翻译缺失时兜底) */
|
||||||
name: string
|
name: string
|
||||||
/** 一句话描述(首页卡片展示) */
|
/** 一句话描述(简体中文原文,翻译缺失时兜底) */
|
||||||
desc: string
|
desc: string
|
||||||
/** 菜单/卡片图标(TDesign 图标组件) */
|
/** 菜单/卡片图标(TDesign 图标组件) */
|
||||||
icon: Component
|
icon: Component
|
||||||
|
/** 所属分类 key(必须在 toolCategories 中存在),缺省归入第一个分类 */
|
||||||
|
category?: string
|
||||||
|
/** 是否依赖后端接口(首页会打上「需联网」标记,并参与服务端工具分区) */
|
||||||
|
needsNetwork?: boolean
|
||||||
/** 页面组件(懒加载,减小首屏体积) */
|
/** 页面组件(懒加载,减小首屏体积) */
|
||||||
component: () => Promise<Component>
|
component: () => Promise<Component>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tools: ToolItem[] = [
|
export const tools: ToolItem[] = [
|
||||||
|
/* ==================== 工作效率 ==================== */
|
||||||
{
|
{
|
||||||
path: 'image-compress',
|
path: 'image-compress',
|
||||||
name: '图片压缩',
|
name: '图片压缩',
|
||||||
desc: '本地压缩 JPG/PNG/WebP,可调质量,压缩后直接下载',
|
desc: '本地压缩 JPG/PNG/WebP,可调质量,压缩后直接下载',
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/image-compress/ImageCompressView.vue'),
|
component: () => import('@/views/image-compress/ImageCompressView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'image-cropper',
|
path: 'image-to-ico',
|
||||||
name: '图片裁剪',
|
name: '图片转 ICO',
|
||||||
desc: '按比例裁剪图片,实时预览,导出 JPG/PNG',
|
desc: 'PNG/JPG/WebP/SVG 转图标,多尺寸合成并控制输出体积',
|
||||||
icon: FrameIcon,
|
icon: FileImageIcon,
|
||||||
component: () => import('@/views/image-cropper/ImageCropperView.vue'),
|
category: 'work',
|
||||||
},
|
component: () => import('@/views/image-to-ico/ImageToIcoView.vue'),
|
||||||
{
|
|
||||||
path: 'qrcode',
|
|
||||||
name: '二维码生成解析',
|
|
||||||
desc: '文本/链接生成二维码,上传图片解析二维码内容',
|
|
||||||
icon: QrcodeIcon,
|
|
||||||
component: () => import('@/views/qrcode/QrcodeView.vue'),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'json-format',
|
path: 'json-format',
|
||||||
name: 'JSON 格式化',
|
name: 'JSON 格式化',
|
||||||
desc: 'JSON 格式化、压缩、校验,一键复制结果',
|
desc: 'JSON 格式化、压缩、校验,一键复制结果',
|
||||||
icon: CodeIcon,
|
icon: CodeIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/json-format/JsonFormatView.vue'),
|
component: () => import('@/views/json-format/JsonFormatView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'timestamp',
|
path: 'timestamp',
|
||||||
name: '时间戳转换',
|
name: '时间戳转换',
|
||||||
desc: '时间戳与北京时间互转,支持秒/毫秒与批量转换',
|
desc: '时间戳与时间互转,支持秒/毫秒、批量转换与服务端时间校准',
|
||||||
icon: TimeIcon,
|
icon: TimeIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/timestamp/TimestampView.vue'),
|
component: () => import('@/views/timestamp/TimestampView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -89,6 +146,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: 'Base64 编解码',
|
name: 'Base64 编解码',
|
||||||
desc: '文本 Base64 编解码,图片文件与 Base64 互转',
|
desc: '文本 Base64 编解码,图片文件与 Base64 互转',
|
||||||
icon: FileIcon,
|
icon: FileIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/base64/Base64View.vue'),
|
component: () => import('@/views/base64/Base64View.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -96,6 +154,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: 'JWT 解析',
|
name: 'JWT 解析',
|
||||||
desc: '解析 JWT 的 Header 与 Payload,展示过期时间',
|
desc: '解析 JWT 的 Header 与 Payload,展示过期时间',
|
||||||
icon: LockOnIcon,
|
icon: LockOnIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/jwt-parse/JwtParseView.vue'),
|
component: () => import('@/views/jwt-parse/JwtParseView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -103,6 +162,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: 'Cron 表达式',
|
name: 'Cron 表达式',
|
||||||
desc: '解析 Cron 下次执行时间,可视化生成表达式',
|
desc: '解析 Cron 下次执行时间,可视化生成表达式',
|
||||||
icon: CalendarIcon,
|
icon: CalendarIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/cron/CronView.vue'),
|
component: () => import('@/views/cron/CronView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -110,34 +170,23 @@ export const tools: ToolItem[] = [
|
|||||||
name: '进制转换',
|
name: '进制转换',
|
||||||
desc: '二进制、八进制、十进制、十六进制互转',
|
desc: '二进制、八进制、十进制、十六进制互转',
|
||||||
icon: CalculatorIcon,
|
icon: CalculatorIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/radix-convert/RadixConvertView.vue'),
|
component: () => import('@/views/radix-convert/RadixConvertView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'hash',
|
path: 'hash',
|
||||||
name: '哈希计算',
|
name: '哈希计算',
|
||||||
desc: '文本/文件计算 MD5、SHA1、SHA256、SHA512',
|
desc: '文本/文件计算 MD5、SHA1、SHA256、SHA512,可对比服务端结果',
|
||||||
icon: FingerprintIcon,
|
icon: FingerprintIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/hash/HashView.vue'),
|
component: () => import('@/views/hash/HashView.vue'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'idcard',
|
|
||||||
name: '身份证解析',
|
|
||||||
desc: '校验身份证合法性,解析生日、性别、年龄、地区',
|
|
||||||
icon: CreditcardIcon,
|
|
||||||
component: () => import('@/views/idcard/IdcardView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'address-parse',
|
|
||||||
name: '地址解析',
|
|
||||||
desc: '拆分中文地址的省、市、区与详细地址',
|
|
||||||
icon: LocationIcon,
|
|
||||||
component: () => import('@/views/address-parse/AddressParseView.vue'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'url-parse',
|
path: 'url-parse',
|
||||||
name: 'URL 解析',
|
name: 'URL 解析',
|
||||||
desc: '解析协议/域名/路径/参数,也支持构造 URL',
|
desc: '解析协议/域名/路径/参数,也支持构造 URL',
|
||||||
icon: LinkIcon,
|
icon: LinkIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/url-parse/UrlParseView.vue'),
|
component: () => import('@/views/url-parse/UrlParseView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -145,6 +194,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: '翻译',
|
name: '翻译',
|
||||||
desc: '中文、英文、韩文、日文互译(免费在线接口)',
|
desc: '中文、英文、韩文、日文互译(免费在线接口)',
|
||||||
icon: TranslateIcon,
|
icon: TranslateIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/translate/TranslateView.vue'),
|
component: () => import('@/views/translate/TranslateView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -152,13 +202,15 @@ export const tools: ToolItem[] = [
|
|||||||
name: '图片 OCR 识别',
|
name: '图片 OCR 识别',
|
||||||
desc: '本地识别图片文字,支持中英文(WASM)',
|
desc: '本地识别图片文字,支持中英文(WASM)',
|
||||||
icon: BrowseIcon,
|
icon: BrowseIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/ocr/OcrView.vue'),
|
component: () => import('@/views/ocr/OcrView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'uuid',
|
path: 'uuid',
|
||||||
name: 'UUID 生成',
|
name: 'UUID 生成',
|
||||||
desc: '批量生成 UUID v4,支持大小写与去连字符',
|
desc: '批量生成 UUID,可选浏览器本地生成或调用服务端接口',
|
||||||
icon: RefreshIcon,
|
icon: RefreshIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/uuid/UuidView.vue'),
|
component: () => import('@/views/uuid/UuidView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -166,6 +218,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: '正则测试',
|
name: '正则测试',
|
||||||
desc: '实时测试正则匹配、分组与替换,匹配高亮显示',
|
desc: '实时测试正则匹配、分组与替换,匹配高亮显示',
|
||||||
icon: ControlPlatformIcon,
|
icon: ControlPlatformIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/regex-test/RegexTestView.vue'),
|
component: () => import('@/views/regex-test/RegexTestView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -173,6 +226,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: '命名转换',
|
name: '命名转换',
|
||||||
desc: '驼峰、蛇形、短横线等命名风格一键互转',
|
desc: '驼峰、蛇形、短横线等命名风格一键互转',
|
||||||
icon: CodeIcon,
|
icon: CodeIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/case-convert/CaseConvertView.vue'),
|
component: () => import('@/views/case-convert/CaseConvertView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -180,20 +234,15 @@ export const tools: ToolItem[] = [
|
|||||||
name: '色值转换',
|
name: '色值转换',
|
||||||
desc: 'HEX、RGB、HSL、HSV 互转,取色器 + 预设色板',
|
desc: 'HEX、RGB、HSL、HSV 互转,取色器 + 预设色板',
|
||||||
icon: Palette1Icon,
|
icon: Palette1Icon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/color-convert/ColorConvertView.vue'),
|
component: () => import('@/views/color-convert/ColorConvertView.vue'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'unit-convert',
|
|
||||||
name: '单位换算',
|
|
||||||
desc: '长度、质量、温度、数据存储等 8 类单位换算',
|
|
||||||
icon: SwapIcon,
|
|
||||||
component: () => import('@/views/unit-convert/UnitConvertView.vue'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'yaml-json',
|
path: 'yaml-json',
|
||||||
name: 'YAML ↔ JSON',
|
name: 'YAML ↔ JSON',
|
||||||
desc: 'YAML 与 JSON 双向转换,报错定位清晰',
|
desc: 'YAML 与 JSON 双向转换,报错定位清晰',
|
||||||
icon: FileCodeIcon,
|
icon: FileCodeIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/yaml-json/YamlJsonView.vue'),
|
component: () => import('@/views/yaml-json/YamlJsonView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -201,27 +250,73 @@ export const tools: ToolItem[] = [
|
|||||||
name: '字数统计',
|
name: '字数统计',
|
||||||
desc: '统计字符、汉字、单词、段落与阅读时长',
|
desc: '统计字符、汉字、单词、段落与阅读时长',
|
||||||
icon: ChartIcon,
|
icon: ChartIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/word-count/WordCountView.vue'),
|
component: () => import('@/views/word-count/WordCountView.vue'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'rmb-upper',
|
|
||||||
name: '人民币大写',
|
|
||||||
desc: '数字金额转人民币大写,处理角分与负数',
|
|
||||||
icon: MoneyIcon,
|
|
||||||
component: () => import('@/views/rmb-upper/RmbUpperView.vue'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'zh-convert',
|
path: 'zh-convert',
|
||||||
name: '简繁转换',
|
name: '简繁转换',
|
||||||
desc: '简体与繁体中文互转(opencc 词库)',
|
desc: '简体与繁体中文互转(opencc 词库)',
|
||||||
icon: TranslateIcon,
|
icon: TranslateIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/zh-convert/ZhConvertView.vue'),
|
component: () => import('@/views/zh-convert/ZhConvertView.vue'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* ==================== 生活助手 ==================== */
|
||||||
|
{
|
||||||
|
path: 'image-cropper',
|
||||||
|
name: '图片裁剪',
|
||||||
|
desc: '按比例裁剪图片,实时预览,导出 JPG/PNG',
|
||||||
|
icon: FrameIcon,
|
||||||
|
category: 'life',
|
||||||
|
component: () => import('@/views/image-cropper/ImageCropperView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'qrcode',
|
||||||
|
name: '二维码生成解析',
|
||||||
|
desc: '文本/链接生成二维码,上传图片解析二维码内容',
|
||||||
|
icon: QrcodeIcon,
|
||||||
|
category: 'life',
|
||||||
|
component: () => import('@/views/qrcode/QrcodeView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'idcard',
|
||||||
|
name: '身份证解析',
|
||||||
|
desc: '校验身份证合法性,解析生日、性别、年龄、地区',
|
||||||
|
icon: CreditcardIcon,
|
||||||
|
category: 'life',
|
||||||
|
component: () => import('@/views/idcard/IdcardView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'address-parse',
|
||||||
|
name: '地址解析',
|
||||||
|
desc: '拆分中文地址的省、市、区与详细地址',
|
||||||
|
icon: LocationIcon,
|
||||||
|
category: 'life',
|
||||||
|
component: () => import('@/views/address-parse/AddressParseView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'unit-convert',
|
||||||
|
name: '单位换算',
|
||||||
|
desc: '长度、质量、温度、数据存储等 8 类单位换算',
|
||||||
|
icon: SwapIcon,
|
||||||
|
category: 'life',
|
||||||
|
component: () => import('@/views/unit-convert/UnitConvertView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'rmb-upper',
|
||||||
|
name: '人民币大写',
|
||||||
|
desc: '数字金额转人民币大写,处理角分与负数',
|
||||||
|
icon: MoneyIcon,
|
||||||
|
category: 'life',
|
||||||
|
component: () => import('@/views/rmb-upper/RmbUpperView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'date-calc',
|
path: 'date-calc',
|
||||||
name: '日期计算',
|
name: '日期计算',
|
||||||
desc: '日期差值、推算与年龄生肖星座计算',
|
desc: '日期差值、推算与年龄生肖星座计算',
|
||||||
icon: CalendarIcon,
|
icon: CalendarIcon,
|
||||||
|
category: 'life',
|
||||||
component: () => import('@/views/date-calc/DateCalcView.vue'),
|
component: () => import('@/views/date-calc/DateCalcView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -229,13 +324,318 @@ export const tools: ToolItem[] = [
|
|||||||
name: '密码生成',
|
name: '密码生成',
|
||||||
desc: '随机强密码批量生成,含强度评估',
|
desc: '随机强密码批量生成,含强度评估',
|
||||||
icon: KeyIcon,
|
icon: KeyIcon,
|
||||||
|
category: 'life',
|
||||||
component: () => import('@/views/password-gen/PasswordGenView.vue'),
|
component: () => import('@/views/password-gen/PasswordGenView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'http-status',
|
path: 'bmi',
|
||||||
name: 'HTTP 状态码',
|
name: 'BMI 计算器',
|
||||||
desc: '常用 HTTP 状态码速查,支持搜索与分类',
|
desc: '计算身体质量指数,中国标准分级与健康体重范围',
|
||||||
|
icon: HeartIcon,
|
||||||
|
category: 'life',
|
||||||
|
component: () => import('@/views/bmi/BmiView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mortgage',
|
||||||
|
name: '房贷计算器',
|
||||||
|
desc: '等额本息/等额本金对比,月供、总利息一目了然',
|
||||||
|
icon: WalletIcon,
|
||||||
|
category: 'life',
|
||||||
|
component: () => import('@/views/mortgage/MortgageView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'bill-split',
|
||||||
|
name: '账单拆分',
|
||||||
|
desc: '多人聚餐 AA 分账,自动生成转账方案',
|
||||||
|
icon: UsergroupIcon,
|
||||||
|
category: 'life',
|
||||||
|
component: () => import('@/views/bill-split/BillSplitView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'random-decision',
|
||||||
|
name: '随机决定',
|
||||||
|
desc: '选项抽签治选择困难,随机数/骰子一键生成',
|
||||||
|
icon: GiftIcon,
|
||||||
|
category: 'life',
|
||||||
|
component: () => import('@/views/random-decision/RandomDecisionView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'pomodoro',
|
||||||
|
name: '番茄钟',
|
||||||
|
desc: '专注与休息倒计时,记录完成轮数',
|
||||||
|
icon: AlarmIcon,
|
||||||
|
category: 'life',
|
||||||
|
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'),
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ==================== 服务端工具(调用自建后端开放接口) ====================
|
||||||
|
* 接口基地址由 VITE_API_BASE 配置,路径为 /api/service/open/tools/*(全部 POST)。
|
||||||
|
* needsNetwork: true 的工具在首页会打上「需联网」标记。 */
|
||||||
|
{
|
||||||
|
path: 'api-status',
|
||||||
|
name: '服务端接口状态',
|
||||||
|
desc: '探测后端开放接口的可用性与响应耗时,确认前后端连通情况',
|
||||||
|
icon: ServerIcon,
|
||||||
|
category: 'online',
|
||||||
|
needsNetwork: true,
|
||||||
|
component: () => import('@/views/api-status/ApiStatusView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'random-string',
|
||||||
|
name: '随机字符串',
|
||||||
|
desc: '按长度与字符类型生成随机串,可选服务端密码学随机源',
|
||||||
|
icon: TextboxIcon,
|
||||||
|
category: 'online',
|
||||||
|
needsNetwork: true,
|
||||||
|
component: () => import('@/views/random-string/RandomStringView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'ip-info',
|
||||||
|
name: 'IP 信息查询',
|
||||||
|
desc: '查询服务端识别的来源 IP、是否内网地址,并展示本机环境信息',
|
||||||
icon: InternetIcon,
|
icon: InternetIcon,
|
||||||
component: () => import('@/views/http-status/HttpStatusView.vue'),
|
category: 'online',
|
||||||
|
needsNetwork: true,
|
||||||
|
component: () => import('@/views/ip-info/IpInfoView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'network-test',
|
||||||
|
name: '网络测试',
|
||||||
|
desc: '站点可达性检测:HTTP / Ping / TCP / DNS',
|
||||||
|
icon: WifiIcon,
|
||||||
|
category: 'online',
|
||||||
|
needsNetwork: true,
|
||||||
|
component: () => import('@/views/network-test/NetworkTestView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'speed-test',
|
||||||
|
name: '网络测速',
|
||||||
|
desc: '宽带下载/上传速度与延迟测试',
|
||||||
|
icon: RocketIcon,
|
||||||
|
category: 'online',
|
||||||
|
needsNetwork: true,
|
||||||
|
component: () => import('@/views/speed-test/SpeedTestView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'exchange-rate',
|
||||||
|
name: '汇率换算',
|
||||||
|
desc: '主流货币实时汇率换算',
|
||||||
|
icon: CurrencyExchangeIcon,
|
||||||
|
category: 'online',
|
||||||
|
needsNetwork: true,
|
||||||
|
component: () => import('@/views/exchange-rate/ExchangeRateView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'express-query',
|
||||||
|
name: '快递查询',
|
||||||
|
desc: '物流轨迹一键查询',
|
||||||
|
icon: LocationIcon,
|
||||||
|
category: 'online',
|
||||||
|
needsNetwork: true,
|
||||||
|
component: () => import('@/views/express-query/ExpressQueryView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'phone-locate',
|
||||||
|
name: '手机号归属地',
|
||||||
|
desc: '归属地与运营商查询',
|
||||||
|
icon: MobileIcon,
|
||||||
|
category: 'online',
|
||||||
|
needsNetwork: true,
|
||||||
|
component: () => import('@/views/phone-locate/PhoneLocateView.vue'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** 工具默认分类:未填写 category 时归入第一个分类 */
|
||||||
|
export function toolCategoryKey(tool: ToolItem): string {
|
||||||
|
return tool.category ?? toolCategories[0].key
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按分类分组返回工具(按 toolCategories 声明顺序)。
|
||||||
|
* 侧边导航、首页分区、命令面板统一使用该函数,新增分类零改动。
|
||||||
|
*
|
||||||
|
* 注意:这里会过滤掉「没有任何工具的分类」。历史上 toolCategories 里
|
||||||
|
* 有 health / study / travel 三个只声明未使用的分类,导致首页渲染出
|
||||||
|
* 标题下方空白的分区、侧栏出现空分组标题。过滤后新增分类也不会再复现。
|
||||||
|
*/
|
||||||
|
export function groupedTools(): Array<{ category: ToolCategory; items: ToolItem[] }> {
|
||||||
|
return toolCategories
|
||||||
|
.map((category) => ({
|
||||||
|
category,
|
||||||
|
items: tools.filter((t) => toolCategoryKey(t) === category.key),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.items.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依赖后端接口的工具(首页做区分展示,命令面板加联网标记) */
|
||||||
|
export function networkTools(): ToolItem[] {
|
||||||
|
return tools.filter((t) => t.needsNetwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具搜索:名称、描述、路由 path 三者任一命中即算匹配。
|
||||||
|
* 同时把 i18n 翻译结果一起纳入匹配,保证英文/日文/韩文界面下
|
||||||
|
* 用对应语言的关键词也能搜到(例如 "compress" / "압축")。
|
||||||
|
*/
|
||||||
|
export function searchTools(keyword: string, resolve: (tool: ToolItem) => string[]): ToolItem[] {
|
||||||
|
const kw = keyword.trim().toLowerCase()
|
||||||
|
if (!kw) return tools
|
||||||
|
return tools.filter((tool) => {
|
||||||
|
const haystack = [tool.path, tool.name, tool.desc, ...resolve(tool)]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
return haystack.includes(kw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
31
src/stores/locale.ts
Normal file
31
src/stores/locale.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { i18n, resolveInitialLocale, setAppLocale, SUPPORTED_LOCALES } from '@/i18n'
|
||||||
|
import type { AppLocale } from '@/i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言状态管理。
|
||||||
|
*
|
||||||
|
* 真正的语言数据由 vue-i18n 持有,这里只做「当前语言」的响应式包装,
|
||||||
|
* 方便在任意组件里读写并与本地存储保持同步。
|
||||||
|
*/
|
||||||
|
export const useLocaleStore = defineStore('locale', () => {
|
||||||
|
const locale = ref<AppLocale>(resolveInitialLocale())
|
||||||
|
|
||||||
|
/** 语言选项(供下拉菜单渲染,nativeName 用各语言自身的写法) */
|
||||||
|
const options = computed(() =>
|
||||||
|
SUPPORTED_LOCALES.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: i18n.global.t(`locale.${value}`),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 切换语言 */
|
||||||
|
function set(next: AppLocale) {
|
||||||
|
if (next === locale.value) return
|
||||||
|
locale.value = next
|
||||||
|
setAppLocale(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { locale, options, set }
|
||||||
|
})
|
||||||
@ -1,30 +1,102 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
|
/** 用户在界面上选择的主题偏好 */
|
||||||
|
export type ThemePreference = 'light' | 'dark' | 'auto'
|
||||||
|
|
||||||
|
/** 实际生效的主题(auto 会被解析成二者之一) */
|
||||||
export type ThemeMode = 'light' | 'dark'
|
export type ThemeMode = 'light' | 'dark'
|
||||||
|
|
||||||
/** 主题本地存储 key(与 index.html 内联脚本保持一致) */
|
/** 主题偏好本地存储 key(index.html 内联脚本会读取它做首屏防闪烁) */
|
||||||
const STORAGE_KEY = 'tool-theme'
|
export const THEME_STORAGE_KEY = 'tool-theme'
|
||||||
|
|
||||||
|
const MEDIA_QUERY = '(prefers-color-scheme: dark)'
|
||||||
|
|
||||||
|
/** 查询系统当前是否为深色(旧浏览器不支持 matchMedia 时按浅色处理) */
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
||||||
|
return window.matchMedia(MEDIA_QUERY).matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取初始偏好:本地存储优先,其次跟随系统 */
|
||||||
|
function resolveInitialPreference(): ThemePreference {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(THEME_STORAGE_KEY)
|
||||||
|
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved
|
||||||
|
} catch {
|
||||||
|
// 忽略存储不可用的场景
|
||||||
|
}
|
||||||
|
return 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主题状态管理:暗黑/浅色主题切换。
|
* 主题状态管理:浅色 / 深色 / 跟随系统 三态切换。
|
||||||
* TDesign 通过 html 元素的 `theme-mode` 属性控制主题,
|
*
|
||||||
* 这里同步该属性并持久化到 localStorage,刷新页面不丢失。
|
* 之所以要有「跟随系统」这一态:用户手动选过一次之后,
|
||||||
|
* 系统在日落时自动切深色时页面不会跟着变,体验会断裂。
|
||||||
|
* 三态让用户既能保留手动控制,也能交回给系统。
|
||||||
|
*
|
||||||
|
* 实现上把「偏好」与「实际生效主题」分开:
|
||||||
|
* preference → 用户选择,写 localStorage
|
||||||
|
* mode → 真实生效值,写到 html[theme-mode](TDesign 依赖该属性)
|
||||||
*/
|
*/
|
||||||
export const useThemeStore = defineStore('theme', () => {
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
const mode = ref<ThemeMode>((localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'light')
|
const preference = ref<ThemePreference>(resolveInitialPreference())
|
||||||
|
const systemDark = ref(systemPrefersDark())
|
||||||
|
|
||||||
/** 应用主题:同步 DOM 属性 + 持久化 */
|
/** 实际生效的主题 */
|
||||||
function apply(next: ThemeMode) {
|
const mode = computed<ThemeMode>(() => {
|
||||||
mode.value = next
|
if (preference.value === 'auto') return systemDark.value ? 'dark' : 'light'
|
||||||
document.documentElement.setAttribute('theme-mode', next)
|
return preference.value
|
||||||
localStorage.setItem(STORAGE_KEY, next)
|
})
|
||||||
|
|
||||||
|
/** 把主题写到 DOM 并持久化偏好 */
|
||||||
|
function apply(next: ThemePreference) {
|
||||||
|
preference.value = next
|
||||||
|
// TDesign 通过 html[theme-mode] 切换整套设计变量,必须是 light / dark 具值
|
||||||
|
document.documentElement.setAttribute('theme-mode', mode.value)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, next)
|
||||||
|
} catch {
|
||||||
|
// 忽略存储失败
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 切换主题 */
|
/**
|
||||||
function toggle() {
|
* 三态循环切换:浅色 → 深色 → 跟随系统 → 浅色。
|
||||||
apply(mode.value === 'light' ? 'dark' : 'light')
|
* 单按钮即可覆盖三种偏好,避免在顶栏塞三个按钮。
|
||||||
|
*/
|
||||||
|
function cycle() {
|
||||||
|
const order: ThemePreference[] = ['light', 'dark', 'auto']
|
||||||
|
const next = order[(order.indexOf(preference.value) + 1) % order.length]
|
||||||
|
apply(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { mode, apply, toggle }
|
/** 直接指定偏好 */
|
||||||
|
function set(next: ThemePreference) {
|
||||||
|
apply(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化:应用一次主题,并在「跟随系统」时监听系统主题变化。
|
||||||
|
* @returns 取消监听的清理函数(应用卸载时调用,避免监听器泄漏)
|
||||||
|
*/
|
||||||
|
function init(): () => void {
|
||||||
|
apply(preference.value)
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
const mql = window.matchMedia(MEDIA_QUERY)
|
||||||
|
const onChange = (e: MediaQueryListEvent) => {
|
||||||
|
systemDark.value = e.matches
|
||||||
|
// 仅在跟随系统时同步 DOM;用户手动指定时不受系统影响
|
||||||
|
if (preference.value === 'auto') {
|
||||||
|
document.documentElement.setAttribute('theme-mode', e.matches ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mql.addEventListener('change', onChange)
|
||||||
|
return () => mql.removeEventListener('change', onChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { preference, mode, apply, set, cycle, init }
|
||||||
})
|
})
|
||||||
|
|||||||
749
src/utils/ico.ts
Normal file
749
src/utils/ico.ts
Normal file
@ -0,0 +1,749 @@
|
|||||||
|
/**
|
||||||
|
* ICO 图标编码工具库(纯浏览器端实现,零第三方依赖)。
|
||||||
|
*
|
||||||
|
* 支持把任意能被浏览器解码的图片(PNG / JPG / WebP / GIF / BMP / SVG 等)
|
||||||
|
* 合成为标准 Windows .ico 文件,并可通过「尺寸组合 + 编码方式 + 颜色量化 +
|
||||||
|
* 体积上限」把输出文件大小控制在目标范围内。
|
||||||
|
*
|
||||||
|
* ICO 文件格式(简要):
|
||||||
|
* ICONDIR(6B) + ICONDIRENTRY(16B × N) + N 段图像数据
|
||||||
|
* 每段图像数据可以是 BMP DIB(BITMAPINFOHEADER + XOR + AND mask),
|
||||||
|
* 也可以是一份完整的 PNG 文件流(Windows Vista 起支持,读取端靠魔数自动识别)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 合成 ICO 时的图像填充方式 */
|
||||||
|
export type IcoFitMode = 'cover' | 'contain' | 'stretch'
|
||||||
|
|
||||||
|
/** 条目编码方式:auto=小尺寸用 BMP、大尺寸用 PNG;bmp=全 BMP;png=全 PNG */
|
||||||
|
export type IcoEncodeMode = 'auto' | 'bmp' | 'png'
|
||||||
|
|
||||||
|
/** ICO 构建参数 */
|
||||||
|
export interface IcoBuildParams {
|
||||||
|
/** 需要生成的图标尺寸(正方边长,1~256),内部会去重并升序排列 */
|
||||||
|
sizes: number[]
|
||||||
|
/** 填充方式 */
|
||||||
|
fit: IcoFitMode
|
||||||
|
/** 编码方式 */
|
||||||
|
mode: IcoEncodeMode
|
||||||
|
/** 是否启用颜色量化(有损,最多 256 色),可显著减小体积 */
|
||||||
|
quantize: boolean
|
||||||
|
/** 量化最大颜色数(2~256) */
|
||||||
|
maxColors: number
|
||||||
|
/** 输出体积上限(KB),0 表示不限制 */
|
||||||
|
limitKb: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个条目的编码结果描述(用于结果展示) */
|
||||||
|
export interface IcoEntryMeta {
|
||||||
|
/** 图标尺寸 */
|
||||||
|
size: number
|
||||||
|
/** 该条目实际使用的编码格式 */
|
||||||
|
format: 'PNG' | 'BMP'
|
||||||
|
/** 该条目数据字节数 */
|
||||||
|
bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ICO 构建结果 */
|
||||||
|
export interface IcoBuildResult {
|
||||||
|
/** 生成的 .ico 文件 */
|
||||||
|
blob: Blob
|
||||||
|
/** 各条目明细(升序) */
|
||||||
|
entries: IcoEntryMeta[]
|
||||||
|
/** 因体积上限被自动移除的尺寸 */
|
||||||
|
droppedSizes: number[]
|
||||||
|
/** 是否实际启用了颜色量化 */
|
||||||
|
quantized: boolean
|
||||||
|
/** 各尺寸的预览图(DataURL),供页面回显 */
|
||||||
|
previews: Array<{ size: number; url: string }>
|
||||||
|
/** 每个尺寸单独封装的 ICO 文件(与合成包内条目数据一致,用于逐尺寸下载 / 打包 ZIP) */
|
||||||
|
singles: Array<{ size: number; blob: Blob }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ICO 内部单条目:尺寸 + 编码后的图像数据 */
|
||||||
|
interface IcoImage {
|
||||||
|
size: number
|
||||||
|
data: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 一、CRC32 / zlib 基础能力
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** CRC32 查表(PNG chunk 校验用) */
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const table = new Uint32Array(256)
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||||
|
table[n] = c >>> 0
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
})()
|
||||||
|
|
||||||
|
function crc32(buf: Uint8Array): number {
|
||||||
|
let c = 0xffffffff
|
||||||
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
|
||||||
|
return (c ^ 0xffffffff) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拼接多段字节数组 */
|
||||||
|
function concatBytes(parts: Uint8Array[]): Uint8Array {
|
||||||
|
let total = 0
|
||||||
|
for (const p of parts) total += p.length
|
||||||
|
const out = new Uint8Array(total)
|
||||||
|
let pos = 0
|
||||||
|
for (const p of parts) {
|
||||||
|
out.set(p, pos)
|
||||||
|
pos += p.length
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adler32 校验(zlib 尾部) */
|
||||||
|
function adler32(data: Uint8Array): number {
|
||||||
|
let a = 1
|
||||||
|
let b = 0
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
a = (a + data[i]) % 65521
|
||||||
|
b = (b + a) % 65521
|
||||||
|
}
|
||||||
|
return (((b << 16) | a) >>> 0) as number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以「不压缩的 stored 块」封装成 zlib 流。
|
||||||
|
* 用于浏览器不支持 CompressionStream 时兜底(体积偏大但保证功能可用)。
|
||||||
|
*/
|
||||||
|
function zlibStored(data: Uint8Array): Uint8Array {
|
||||||
|
const parts: Uint8Array[] = [new Uint8Array([0x78, 0x01])]
|
||||||
|
let pos = 0
|
||||||
|
let remaining = data.length
|
||||||
|
// 即使数据为空也要输出一个 final 空块,否则流不合法
|
||||||
|
do {
|
||||||
|
const size = Math.min(remaining, 65535)
|
||||||
|
const header = new Uint8Array(5)
|
||||||
|
header[0] = remaining - size === 0 ? 1 : 0 // BFINAL
|
||||||
|
header[1] = size & 0xff // LEN(小端)
|
||||||
|
header[2] = (size >> 8) & 0xff
|
||||||
|
header[3] = ~size & 0xff // NLEN = ~LEN
|
||||||
|
header[4] = (~size >> 8) & 0xff
|
||||||
|
parts.push(header, data.subarray(pos, pos + size))
|
||||||
|
pos += size
|
||||||
|
remaining -= size
|
||||||
|
} while (remaining > 0)
|
||||||
|
const sum = new Uint8Array(4)
|
||||||
|
new DataView(sum.buffer).setUint32(0, adler32(data))
|
||||||
|
parts.push(sum)
|
||||||
|
return concatBytes(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩为 zlib 流:优先使用浏览器原生 CompressionStream(压缩率高),
|
||||||
|
* 不可用时降级为不压缩块,保证任何环境下都能产出合法 PNG。
|
||||||
|
*/
|
||||||
|
async function deflate(data: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const Ctor = (globalThis as { CompressionStream?: new (fmt: string) => TransformStream }).CompressionStream
|
||||||
|
if (typeof Ctor === 'function') {
|
||||||
|
try {
|
||||||
|
const stream = new Blob([data as unknown as BlobPart]).stream().pipeThrough(new Ctor('deflate'))
|
||||||
|
const buf = await new Response(stream).arrayBuffer()
|
||||||
|
if (buf.byteLength) return new Uint8Array(buf)
|
||||||
|
} catch {
|
||||||
|
// 浏览器策略或类型转换异常时走兜底逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zlibStored(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 二、PNG 编码
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||||
|
|
||||||
|
/** 组装一个 PNG chunk:长度(4) + 类型(4) + 数据 + CRC(4) */
|
||||||
|
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
||||||
|
const out = new Uint8Array(data.length + 12)
|
||||||
|
const dv = new DataView(out.buffer)
|
||||||
|
dv.setUint32(0, data.length)
|
||||||
|
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i)
|
||||||
|
out.set(data, 8)
|
||||||
|
dv.setUint32(data.length + 8, crc32(out.subarray(4, 8 + data.length)))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PNG 规范中的 Paeth 预测器 */
|
||||||
|
function paeth(a: number, b: number, c: number): number {
|
||||||
|
const p = a + b - c
|
||||||
|
const pa = Math.abs(p - a)
|
||||||
|
const pb = Math.abs(p - b)
|
||||||
|
const pc = Math.abs(p - c)
|
||||||
|
if (pa <= pb && pa <= pc) return a
|
||||||
|
return pb <= pc ? b : c
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对像素数据做逐行滤波,每行在 None/Sub/Up/Paeth 中按「绝对差之和」最小者自适应选择。
|
||||||
|
* 自适应滤波能显著提升 deflate 压缩率,是控制 ICO 体积的关键一环。
|
||||||
|
* @param raw 按行紧密排列的像素数据(每行字节数由 raw.length / height 推出,
|
||||||
|
* 位深小于 8 时行字节数不等于 width × bpp,必须这样算才正确)
|
||||||
|
*/
|
||||||
|
function filterRows(raw: Uint8Array, width: number, height: number, bpp: number): Uint8Array {
|
||||||
|
const stride = raw.length / height
|
||||||
|
const out = new Uint8Array((stride + 1) * height)
|
||||||
|
if (!stride || !height || !width) return out
|
||||||
|
// 四种候选的缓冲区与 SAD 累加器,复用避免每行重复分配
|
||||||
|
const candidates = [0, 1, 2, 4].map(() => new Uint8Array(stride))
|
||||||
|
const sads = new Float64Array(4)
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const cur = y * stride
|
||||||
|
const prev = cur - stride
|
||||||
|
for (let k = 0; k < 4; k++) sads[k] = 0
|
||||||
|
for (let x = 0; x < stride; x++) {
|
||||||
|
const v = raw[cur + x]
|
||||||
|
const a = x >= bpp ? raw[cur + x - bpp] : 0 // 左
|
||||||
|
const b = y > 0 ? raw[prev + x] : 0 // 上
|
||||||
|
const c = y > 0 && x >= bpp ? raw[prev + x - bpp] : 0 // 左上
|
||||||
|
candidates[0][x] = v
|
||||||
|
candidates[1][x] = (v - a) & 0xff
|
||||||
|
candidates[2][x] = (v - b) & 0xff
|
||||||
|
candidates[3][x] = (v - paeth(a, b, c)) & 0xff
|
||||||
|
// SAD:把字节当作有符号数累加,绝对值越小越易被压缩
|
||||||
|
for (let k = 0; k < 4; k++) sads[k] += candidates[k][x] < 128 ? candidates[k][x] : 256 - candidates[k][x]
|
||||||
|
}
|
||||||
|
let bestIdx = 0
|
||||||
|
for (let k = 1; k < 4; k++) if (sads[k] < sads[bestIdx]) bestIdx = k
|
||||||
|
out[y * (stride + 1)] = [0, 1, 2, 4][bestIdx]
|
||||||
|
out.set(candidates[bestIdx], y * (stride + 1) + 1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把逐像素的调色板索引按位深打包成 PNG 扫描行(每行字节数按位补齐) */
|
||||||
|
function packIndices(indices: Uint8Array, width: number, height: number, bitDepth: number): Uint8Array {
|
||||||
|
const rowBytes = Math.ceil((width * bitDepth) / 8)
|
||||||
|
const out = new Uint8Array(rowBytes * height)
|
||||||
|
let p = 0
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const rowStart = p
|
||||||
|
let bitBuf = 0
|
||||||
|
let bitCnt = 0
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
bitBuf = (bitBuf << bitDepth) | indices[y * width + x]
|
||||||
|
bitCnt += bitDepth
|
||||||
|
while (bitCnt >= 8) {
|
||||||
|
const shift = bitCnt - 8
|
||||||
|
out[p++] = (bitBuf >> shift) & 0xff
|
||||||
|
bitCnt -= 8
|
||||||
|
bitBuf &= (1 << bitCnt) - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bitCnt > 0) out[p++] = (bitBuf << (8 - bitCnt)) & 0xff
|
||||||
|
p = rowStart + rowBytes // 跳到下一行起点(补齐行尾未用位)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 量化结果:调色板(RGB)、每色 alpha、逐像素索引 */
|
||||||
|
interface Quantized {
|
||||||
|
palette: Uint8Array
|
||||||
|
alphas: Uint8Array
|
||||||
|
indices: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中位切分(median cut)颜色量化,把图像压到最多 maxColors 种颜色。
|
||||||
|
* 全透明像素固定占索引 0(无损),其余像素在 RGB 空间聚类;
|
||||||
|
* 半透明区域会取簇内平均 alpha,因此该模式为「有损」,适合追求极小体积时使用。
|
||||||
|
*/
|
||||||
|
function medianCut(rgba: Uint8ClampedArray, maxColors: number): Quantized {
|
||||||
|
const total = rgba.length / 4
|
||||||
|
/** 参与聚类的像素下标(排除全透明像素) */
|
||||||
|
const opaque: number[] = []
|
||||||
|
for (let i = 0; i < total; i++) if (rgba[i * 4 + 3] !== 0) opaque.push(i)
|
||||||
|
|
||||||
|
/** 一个颜色簇:像素下标集合 + RGB 包围盒 */
|
||||||
|
interface Box {
|
||||||
|
items: number[]
|
||||||
|
rMin: number
|
||||||
|
rMax: number
|
||||||
|
gMin: number
|
||||||
|
gMax: number
|
||||||
|
bMin: number
|
||||||
|
bMax: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBox(items: number[]): Box {
|
||||||
|
let rMin = 255
|
||||||
|
let rMax = 0
|
||||||
|
let gMin = 255
|
||||||
|
let gMax = 0
|
||||||
|
let bMin = 255
|
||||||
|
let bMax = 0
|
||||||
|
for (const i of items) {
|
||||||
|
const r = rgba[i * 4]
|
||||||
|
const g = rgba[i * 4 + 1]
|
||||||
|
const b = rgba[i * 4 + 2]
|
||||||
|
if (r < rMin) rMin = r
|
||||||
|
if (r > rMax) rMax = r
|
||||||
|
if (g < gMin) gMin = g
|
||||||
|
if (g > gMax) gMax = g
|
||||||
|
if (b < bMin) bMin = b
|
||||||
|
if (b > bMax) bMax = b
|
||||||
|
}
|
||||||
|
return { items, rMin, rMax, gMin, gMax, bMin, bMax }
|
||||||
|
}
|
||||||
|
|
||||||
|
const boxes: Box[] = []
|
||||||
|
// 存在全透明像素时,索引 0 预留给透明色,其余簇最多 maxColors - 1 个
|
||||||
|
const hasTransparent = opaque.length < total
|
||||||
|
const maxBoxes = Math.max(1, (hasTransparent ? maxColors - 1 : maxColors) || 1)
|
||||||
|
if (opaque.length) boxes.push(makeBox(opaque))
|
||||||
|
|
||||||
|
while (boxes.length < maxBoxes) {
|
||||||
|
// 每轮挑「跨度 × 像素数」最大的簇继续切分,收益最高
|
||||||
|
let target = -1
|
||||||
|
let best = -1
|
||||||
|
for (let i = 0; i < boxes.length; i++) {
|
||||||
|
const b = boxes[i]
|
||||||
|
if (b.items.length < 2) continue
|
||||||
|
const span = Math.max(b.rMax - b.rMin, b.gMax - b.gMin, b.bMax - b.bMin)
|
||||||
|
const score = span * b.items.length
|
||||||
|
if (score > best) {
|
||||||
|
best = score
|
||||||
|
target = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target < 0) break // 所有簇都已不可再分
|
||||||
|
const box = boxes[target]
|
||||||
|
const dr = box.rMax - box.rMin
|
||||||
|
const dg = box.gMax - box.gMin
|
||||||
|
const db = box.bMax - box.bMin
|
||||||
|
const channel = dr >= dg && dr >= db ? 0 : dg >= db ? 1 : 2
|
||||||
|
box.items.sort((x, y) => rgba[x * 4 + channel] - rgba[y * 4 + channel])
|
||||||
|
const mid = box.items.length >> 1
|
||||||
|
boxes.splice(target, 1, makeBox(box.items.slice(0, mid)), makeBox(box.items.slice(mid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成调色板:索引 0 为透明色,其后为各簇平均色
|
||||||
|
const palette: number[] = []
|
||||||
|
const alphas: number[] = []
|
||||||
|
if (hasTransparent) {
|
||||||
|
palette.push(0, 0, 0)
|
||||||
|
alphas.push(0)
|
||||||
|
}
|
||||||
|
for (const box of boxes) {
|
||||||
|
let r = 0
|
||||||
|
let g = 0
|
||||||
|
let b = 0
|
||||||
|
let a = 0
|
||||||
|
for (const i of box.items) {
|
||||||
|
r += rgba[i * 4]
|
||||||
|
g += rgba[i * 4 + 1]
|
||||||
|
b += rgba[i * 4 + 2]
|
||||||
|
a += rgba[i * 4 + 3]
|
||||||
|
}
|
||||||
|
const n = box.items.length || 1
|
||||||
|
palette.push(Math.round(r / n), Math.round(g / n), Math.round(b / n))
|
||||||
|
alphas.push(Math.round(a / n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐像素匹配最近颜色;相同 RGB 只计算一次并缓存,避免 O(n×k) 全量开销
|
||||||
|
const colorCount = alphas.length
|
||||||
|
const startIdx = hasTransparent ? 1 : 0
|
||||||
|
const indices = new Uint8Array(total)
|
||||||
|
const cache = new Map<number, number>()
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
if (hasTransparent && rgba[i * 4 + 3] === 0) {
|
||||||
|
indices[i] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const key = (rgba[i * 4] << 16) | (rgba[i * 4 + 1] << 8) | rgba[i * 4 + 2]
|
||||||
|
let idx = cache.get(key)
|
||||||
|
if (idx === undefined) {
|
||||||
|
let bestDist = Infinity
|
||||||
|
let bestIdx = startIdx
|
||||||
|
for (let c = startIdx; c < colorCount; c++) {
|
||||||
|
const dr = rgba[i * 4] - palette[c * 3]
|
||||||
|
const dg = rgba[i * 4 + 1] - palette[c * 3 + 1]
|
||||||
|
const db = rgba[i * 4 + 2] - palette[c * 3 + 2]
|
||||||
|
const dist = dr * dr + dg * dg + db * db
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist
|
||||||
|
bestIdx = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = bestIdx
|
||||||
|
cache.set(key, idx)
|
||||||
|
}
|
||||||
|
indices[i] = idx
|
||||||
|
}
|
||||||
|
return { palette: new Uint8Array(palette), alphas: new Uint8Array(alphas), indices }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计唯一颜色(含 alpha),颜色数不超过 max 时返回精确调色板(完全无损) */
|
||||||
|
function exactPalette(rgba: Uint8ClampedArray, max: number): Quantized | null {
|
||||||
|
const total = rgba.length / 4
|
||||||
|
const seen = new Map<number, number>()
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
if (seen.size > max) return null
|
||||||
|
const key = (((rgba[i * 4] << 24) | (rgba[i * 4 + 1] << 16) | (rgba[i * 4 + 2] << 8) | rgba[i * 4 + 3]) >>> 0) as number
|
||||||
|
if (!seen.has(key)) seen.set(key, seen.size)
|
||||||
|
}
|
||||||
|
const palette = new Uint8Array(seen.size * 3)
|
||||||
|
const alphas = new Uint8Array(seen.size)
|
||||||
|
const indices = new Uint8Array(total)
|
||||||
|
for (const [key, idx] of seen) {
|
||||||
|
palette[idx * 3] = (key >>> 24) & 0xff
|
||||||
|
palette[idx * 3 + 1] = (key >>> 16) & 0xff
|
||||||
|
palette[idx * 3 + 2] = (key >>> 8) & 0xff
|
||||||
|
alphas[idx] = key & 0xff
|
||||||
|
}
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const key = (((rgba[i * 4] << 24) | (rgba[i * 4 + 1] << 16) | (rgba[i * 4 + 2] << 8) | rgba[i * 4 + 3]) >>> 0) as number
|
||||||
|
indices[i] = seen.get(key) as number
|
||||||
|
}
|
||||||
|
return { palette, alphas, indices }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 ImageData 编码为 PNG 字节流(自实现,以便完全掌控调色板与体积)。
|
||||||
|
* - 颜色数 ≤ 256 时使用调色板(PLTE + tRNS),位深可降到 1/2/4/8 bit,体积极小;
|
||||||
|
* - 开启量化时强制走有损调色板;
|
||||||
|
* - 其余情况使用真彩色(全不透明用 RGB,含透明用 RGBA)。
|
||||||
|
*/
|
||||||
|
export async function encodePng(
|
||||||
|
image: ImageData,
|
||||||
|
options: { quantize?: boolean; maxColors?: number } = {},
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const { width, height, data } = image
|
||||||
|
const quantize = options.quantize ?? false
|
||||||
|
const maxColors = Math.min(256, Math.max(2, options.maxColors ?? 256))
|
||||||
|
|
||||||
|
let palette: Uint8Array | null = null
|
||||||
|
let alphas: Uint8Array | null = null
|
||||||
|
let raw: Uint8Array
|
||||||
|
let colorType: number
|
||||||
|
let bitDepth = 8
|
||||||
|
let bpp: number
|
||||||
|
|
||||||
|
// 量化模式:颜色数本就在上限内时直接用精确调色板(无损且更小),超出才走有损聚类
|
||||||
|
const quantized = quantize ? (exactPalette(data, maxColors) ?? medianCut(data, maxColors)) : exactPalette(data, 256)
|
||||||
|
if (quantized) {
|
||||||
|
palette = quantized.palette
|
||||||
|
alphas = quantized.alphas
|
||||||
|
// 按颜色数选择最小可行位深:2/4/16/256 色分别对应 1/2/4/8 bit
|
||||||
|
const count = alphas.length
|
||||||
|
bitDepth = count <= 2 ? 1 : count <= 4 ? 2 : count <= 16 ? 4 : 8
|
||||||
|
raw = packIndices(quantized.indices, width, height, bitDepth)
|
||||||
|
colorType = 3
|
||||||
|
bpp = 1
|
||||||
|
} else {
|
||||||
|
// 真彩色:统计是否存在透明像素,全不透明时省掉 alpha 通道
|
||||||
|
let hasAlpha = false
|
||||||
|
for (let i = 3; i < data.length; i += 4) {
|
||||||
|
if (data[i] < 255) {
|
||||||
|
hasAlpha = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
colorType = hasAlpha ? 6 : 2
|
||||||
|
bpp = hasAlpha ? 4 : 3
|
||||||
|
raw = new Uint8Array(width * height * bpp)
|
||||||
|
let p = 0
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
raw[p++] = data[i]
|
||||||
|
raw[p++] = data[i + 1]
|
||||||
|
raw[p++] = data[i + 2]
|
||||||
|
if (hasAlpha) raw[p++] = data[i + 3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ihdr = new Uint8Array(13)
|
||||||
|
const dv = new DataView(ihdr.buffer)
|
||||||
|
dv.setUint32(0, width)
|
||||||
|
dv.setUint32(4, height)
|
||||||
|
ihdr[8] = bitDepth
|
||||||
|
ihdr[9] = colorType
|
||||||
|
// 压缩方式 0、滤波方式 0、非隔行,默认值即为 0
|
||||||
|
const parts: Uint8Array[] = [PNG_SIGNATURE, pngChunk('IHDR', ihdr)]
|
||||||
|
|
||||||
|
if (palette) parts.push(pngChunk('PLTE', palette))
|
||||||
|
if (alphas) {
|
||||||
|
// tRNS:只有存在半透明色时才输出,长度截断到最后一个非 255 的 alpha
|
||||||
|
let last = -1
|
||||||
|
for (let i = 0; i < alphas.length; i++) if (alphas[i] < 255) last = i
|
||||||
|
if (last >= 0) parts.push(pngChunk('tRNS', alphas.subarray(0, last + 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const idat = await deflate(filterRows(raw, width, height, bpp))
|
||||||
|
parts.push(pngChunk('IDAT', idat))
|
||||||
|
parts.push(pngChunk('IEND', new Uint8Array(0)))
|
||||||
|
return concatBytes(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 三、BMP DIB 编码(传统 ICO 条目格式)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 32bpp 的 BMP DIB(BITMAPINFOHEADER + XOR 位图 + AND 遮罩)。
|
||||||
|
* ICO 中的 DIB 高度为图像高度的 2 倍(包含 1bpp 的 AND 遮罩),
|
||||||
|
* 像素按 BGRA 排列且自下而上存储(bottom-up)。
|
||||||
|
*/
|
||||||
|
export function encodeBmpDib(image: ImageData): Uint8Array {
|
||||||
|
const { width, height, data } = image
|
||||||
|
const xorSize = width * height * 4
|
||||||
|
// AND 遮罩每行按 4 字节对齐
|
||||||
|
const maskRowBytes = ((width + 31) >> 5) * 4
|
||||||
|
const andSize = maskRowBytes * height
|
||||||
|
const out = new Uint8Array(40 + xorSize + andSize)
|
||||||
|
const dv = new DataView(out.buffer)
|
||||||
|
dv.setUint32(0, 40, true) // biSize
|
||||||
|
dv.setInt32(4, width, true) // biWidth
|
||||||
|
dv.setInt32(8, height * 2, true) // biHeight:XOR + AND
|
||||||
|
dv.setUint16(12, 1, true) // biPlanes
|
||||||
|
dv.setUint16(14, 32, true) // biBitCount
|
||||||
|
dv.setUint32(16, 0, true) // biCompression = BI_RGB
|
||||||
|
dv.setUint32(20, xorSize + andSize, true) // biSizeImage
|
||||||
|
|
||||||
|
// XOR 位图:自下而上、BGRA
|
||||||
|
let p = 40
|
||||||
|
for (let y = height - 1; y >= 0; y--) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const s = (y * width + x) * 4
|
||||||
|
out[p++] = data[s + 2]
|
||||||
|
out[p++] = data[s + 1]
|
||||||
|
out[p++] = data[s]
|
||||||
|
out[p++] = data[s + 3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// AND 遮罩:1bpp,透明(alpha < 128)置 1,行尾补零对齐
|
||||||
|
for (let y = height - 1; y >= 0; y--) {
|
||||||
|
const rowStart = p
|
||||||
|
let byte = 0
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
if (data[(y * width + x) * 4 + 3] < 128) byte |= 0x80 >> (x & 7)
|
||||||
|
if ((x & 7) === 7) {
|
||||||
|
out[p++] = byte
|
||||||
|
byte = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (width & 7) out[p++] = byte
|
||||||
|
p = rowStart + maskRowBytes
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 四、ICO 容器封装
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** 把若干条目封装成标准 .ico 文件 */
|
||||||
|
export function encodeIco(images: IcoImage[]): Blob {
|
||||||
|
const count = images.length
|
||||||
|
const dirSize = 16 * count
|
||||||
|
let dataSize = 0
|
||||||
|
for (const img of images) dataSize += img.data.length
|
||||||
|
const out = new Uint8Array(6 + dirSize + dataSize)
|
||||||
|
const dv = new DataView(out.buffer)
|
||||||
|
dv.setUint16(0, 0, true) // reserved
|
||||||
|
dv.setUint16(2, 1, true) // type:1 = icon
|
||||||
|
dv.setUint16(4, count, true)
|
||||||
|
|
||||||
|
let offset = 6 + dirSize
|
||||||
|
let p = 6
|
||||||
|
for (const img of images) {
|
||||||
|
// 宽高字段为 1 字节,256 用 0 表示
|
||||||
|
out[p] = img.size >= 256 ? 0 : img.size
|
||||||
|
out[p + 1] = out[p]
|
||||||
|
out[p + 2] = 0 // 颜色数:0 表示 ≥ 256 色
|
||||||
|
out[p + 3] = 0 // reserved
|
||||||
|
dv.setUint16(p + 4, 1, true) // 位平面
|
||||||
|
dv.setUint16(p + 6, 32, true) // 位深
|
||||||
|
dv.setUint32(p + 8, img.data.length, true) // 数据字节数
|
||||||
|
dv.setUint32(p + 12, offset, true) // 数据偏移
|
||||||
|
out.set(img.data, offset)
|
||||||
|
offset += img.data.length
|
||||||
|
p += 16
|
||||||
|
}
|
||||||
|
return new Blob([out as unknown as BlobPart], { type: 'image/x-icon' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 五、图像渲染(逐级降采样 + 填充方式)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** 创建 2D 上下文并开启高质量平滑 */
|
||||||
|
function createCtx(w: number, h: number): CanvasRenderingContext2D {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = w
|
||||||
|
canvas.height = h
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||||
|
if (!ctx) throw new Error('当前浏览器不支持 Canvas 2D')
|
||||||
|
ctx.imageSmoothingEnabled = true
|
||||||
|
ctx.imageSmoothingQuality = 'high'
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把源图渲染为 size × size 的 ImageData。
|
||||||
|
* 采用逐级减半降采样(每步最多缩小一半),避免大图一步缩到 16px 产生严重锯齿。
|
||||||
|
*/
|
||||||
|
export function renderToImageData(
|
||||||
|
source: HTMLImageElement,
|
||||||
|
size: number,
|
||||||
|
fit: IcoFitMode = 'cover',
|
||||||
|
): ImageData {
|
||||||
|
const srcW = source.naturalWidth || source.width
|
||||||
|
const srcH = source.naturalHeight || source.height
|
||||||
|
if (!srcW || !srcH) throw new Error('无法读取图片尺寸,请更换图片重试')
|
||||||
|
|
||||||
|
// 超大源图先等比收进 2048px,避免一次性占用过多 canvas 内存
|
||||||
|
const capScale = Math.min(1, 2048 / Math.max(srcW, srcH))
|
||||||
|
let curW = Math.max(1, Math.round(srcW * capScale))
|
||||||
|
let curH = Math.max(1, Math.round(srcH * capScale))
|
||||||
|
let ctx = createCtx(curW, curH)
|
||||||
|
ctx.drawImage(source, 0, 0, curW, curH)
|
||||||
|
|
||||||
|
while (Math.max(curW, curH) > size * 2) {
|
||||||
|
const nextW = Math.max(size, Math.round(curW / 2))
|
||||||
|
const nextH = Math.max(size, Math.round(curH / 2))
|
||||||
|
const next = createCtx(nextW, nextH)
|
||||||
|
next.drawImage(ctx.canvas, 0, 0, nextW, nextH)
|
||||||
|
ctx = next
|
||||||
|
curW = nextW
|
||||||
|
curH = nextH
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最终绘制:按填充方式算出绘制区域
|
||||||
|
const finalCtx = createCtx(size, size)
|
||||||
|
if (fit === 'stretch') {
|
||||||
|
finalCtx.drawImage(ctx.canvas, 0, 0, size, size)
|
||||||
|
} else {
|
||||||
|
const scale = fit === 'cover' ? Math.max(size / curW, size / curH) : Math.min(size / curW, size / curH)
|
||||||
|
const dw = curW * scale
|
||||||
|
const dh = curH * scale
|
||||||
|
finalCtx.drawImage(ctx.canvas, (size - dw) / 2, (size - dh) / 2, dw, dh)
|
||||||
|
}
|
||||||
|
return finalCtx.getImageData(0, 0, size, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG 缺少固有尺寸时浏览器会以默认尺寸栅格化,导致图标发虚。
|
||||||
|
* 这里按 viewBox 补一个较大的固有尺寸(长边 512),保证后续降采样质量。
|
||||||
|
*/
|
||||||
|
export async function normalizeSvg(file: File): Promise<Blob> {
|
||||||
|
const text = await file.text()
|
||||||
|
const head = text.slice(0, 2000)
|
||||||
|
if (/\swidth\s*=/.test(head) && /\sheight\s*=/.test(head)) return file
|
||||||
|
const viewBox = /viewBox\s*=\s*["']\s*[-\d.]+\s*[, ]\s*[-\d.]+\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)/.exec(head)
|
||||||
|
let w = 512
|
||||||
|
let h = 512
|
||||||
|
if (viewBox) {
|
||||||
|
w = Math.round(Number(viewBox[1])) || 512
|
||||||
|
h = Math.round(Number(viewBox[2])) || 512
|
||||||
|
}
|
||||||
|
const scale = Math.max(1, 512 / Math.max(w, h))
|
||||||
|
const patched = text.replace(/<svg\b/i, `<svg width="${Math.round(w * scale)}" height="${Math.round(h * scale)}"`)
|
||||||
|
return new Blob([patched], { type: 'image/svg+xml' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载图片源(Blob/File)为 HTMLImageElement */
|
||||||
|
export function loadImage(source: Blob): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(source)
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
resolve(img)
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
reject(new Error('图片解码失败:文件可能已损坏,或浏览器不支持该格式'))
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 六、ICO 合成主流程(含体积控制)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** 按当前策略编码一批尺寸,返回条目数据与明细 */
|
||||||
|
async function encodeEntries(
|
||||||
|
sizes: number[],
|
||||||
|
rendered: Map<number, ImageData>,
|
||||||
|
mode: IcoEncodeMode,
|
||||||
|
quantize: boolean,
|
||||||
|
maxColors: number,
|
||||||
|
): Promise<{ images: IcoImage[]; meta: IcoEntryMeta[] }> {
|
||||||
|
const images: IcoImage[] = []
|
||||||
|
const meta: IcoEntryMeta[] = []
|
||||||
|
for (const size of sizes) {
|
||||||
|
const image = rendered.get(size)
|
||||||
|
if (!image) continue
|
||||||
|
// auto 模式:128 及以上的条目改用 PNG,避免 BMP 条目体积失控
|
||||||
|
const usePng = mode === 'png' || (mode === 'auto' && size >= 128)
|
||||||
|
const data = usePng ? await encodePng(image, { quantize, maxColors }) : encodeBmpDib(image)
|
||||||
|
images.push({ size, data })
|
||||||
|
meta.push({ size, format: usePng ? 'PNG' : 'BMP', bytes: data.length })
|
||||||
|
}
|
||||||
|
return { images, meta }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算 ICO 文件总字节数(6 字节头 + 16 字节目录 × N + 各条目数据) */
|
||||||
|
function icoTotalBytes(meta: IcoEntryMeta[]): number {
|
||||||
|
return 6 + 16 * meta.length + meta.reduce((s, m) => s + m.bytes, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合成 ICO:按参数生成多尺寸图标,并在需要时自动降级以满足体积上限。
|
||||||
|
* 降级顺序:① 开启颜色量化 → ② 从大到小移除尺寸条目(至少保留最小的一个)。
|
||||||
|
*/
|
||||||
|
export async function buildIco(source: HTMLImageElement, params: IcoBuildParams): Promise<IcoBuildResult> {
|
||||||
|
const sizes = Array.from(new Set(params.sizes.filter((s) => s >= 1 && s <= 256))).sort((a, b) => a - b)
|
||||||
|
if (!sizes.length) throw new Error('请至少选择一个图标尺寸')
|
||||||
|
|
||||||
|
// 先渲染所有尺寸并缓存,后续降级重试时无需重复渲染
|
||||||
|
const rendered = new Map<number, ImageData>()
|
||||||
|
for (const size of sizes) rendered.set(size, renderToImageData(source, size, params.fit))
|
||||||
|
|
||||||
|
const limitBytes = params.limitKb > 0 ? params.limitKb * 1024 : 0
|
||||||
|
let quantize = params.quantize
|
||||||
|
let active = [...sizes]
|
||||||
|
let encoded = await encodeEntries(active, rendered, params.mode, quantize, params.maxColors)
|
||||||
|
|
||||||
|
// 超过体积上限时逐步降级,直到满足或无法再降
|
||||||
|
while (limitBytes && icoTotalBytes(encoded.meta) > limitBytes) {
|
||||||
|
if (!quantize) {
|
||||||
|
quantize = true // 优先用有损量化换取体积
|
||||||
|
} else if (active.length > 1) {
|
||||||
|
active = active.slice(0, -1) // 移除最大的尺寸
|
||||||
|
} else {
|
||||||
|
break // 只剩最小尺寸且已量化,无法继续压缩
|
||||||
|
}
|
||||||
|
encoded = await encodeEntries(active, rendered, params.mode, quantize, params.maxColors)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = encodeIco(encoded.images)
|
||||||
|
const previews = active.map((size) => ({ size, url: imageDataToDataUrl(rendered.get(size) as ImageData) }))
|
||||||
|
// 单尺寸 ICO 直接复用合成包里已编码的条目数据,只多包一层 6+16 字节的头部,几乎零开销
|
||||||
|
const singles = encoded.images.map((img) => ({ size: img.size, blob: encodeIco([img]) }))
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
entries: encoded.meta,
|
||||||
|
droppedSizes: sizes.filter((s) => !active.includes(s)),
|
||||||
|
quantized: quantize,
|
||||||
|
previews,
|
||||||
|
singles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 ImageData 转成 PNG DataURL(用于页面预览回显) */
|
||||||
|
function imageDataToDataUrl(image: ImageData): string {
|
||||||
|
const ctx = createCtx(image.width, image.height)
|
||||||
|
ctx.putImageData(image, 0, 0)
|
||||||
|
return ctx.canvas.toDataURL('image/png')
|
||||||
|
}
|
||||||
@ -1,130 +1,655 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="home">
|
||||||
<!-- 站点头部介绍 -->
|
<!-- ==================== 抬头区 ====================
|
||||||
<div class="hero">
|
编辑感版式里抬头区只留三件东西:标题、一句说明、真实可用的搜索框。
|
||||||
<h1 class="hero-title">在线工具箱</h1>
|
不放 eyebrow 小标签(那是模板化开场,且和标题说的是同一件事),
|
||||||
<p class="hero-desc">
|
也不放第二个入口按钮:服务端工具的入口就在下方专区里,
|
||||||
14 个常用工具,全部在浏览器本地完成计算与文件处理,不上传任何数据到服务器。
|
同一意图出现两次只会让人犹豫点哪个。 -->
|
||||||
</p>
|
<section class="hero">
|
||||||
<div class="hero-badges">
|
<h1 class="hero-title">{{ t('home.title') }}</h1>
|
||||||
<t-tag theme="success" variant="light">纯前端</t-tag>
|
<p class="hero-desc">{{ t('home.desc', { count: tools.length }) }}</p>
|
||||||
<t-tag theme="primary" variant="light">数据不出浏览器</t-tag>
|
|
||||||
<t-tag theme="warning" variant="light">免费使用</t-tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 工具卡片导航 -->
|
<div class="hero-search">
|
||||||
<div class="tool-grid">
|
<SearchIcon class="hero-search-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
v-model="keyword"
|
||||||
|
class="hero-search-input"
|
||||||
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="t('home.searchPlaceholder')"
|
||||||
|
:aria-label="t('home.searchPlaceholder')"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="keyword"
|
||||||
|
class="hero-search-clear"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('nav.clearSearch')"
|
||||||
|
@click="keyword = ''"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ==================== 事实条 ====================
|
||||||
|
四组数字直接摊在纸面上,不加边框也不加底纹:
|
||||||
|
数字本身就是视觉元素,再套一层容器反而把它们锁住了。
|
||||||
|
数值一律等宽,切换语言或刷新时不会左右跳动。 -->
|
||||||
|
<section v-if="!isFiltering" class="facts">
|
||||||
|
<div v-for="stat in stats" :key="stat.label" class="fact">
|
||||||
|
<span class="fact-value mono-num">{{ stat.value }}</span>
|
||||||
|
<span class="fact-label">{{ stat.label }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ==================== 服务端工具专区 ====================
|
||||||
|
整块用青玉底纹与其它分区区分开:这是唯一需要联网、且有真实后端支撑的部分。
|
||||||
|
开始筛选时隐藏,避免与下方 online 分类重复出现同一批工具。 -->
|
||||||
|
<section v-if="!isFiltering && serverTools.length" class="server-zone">
|
||||||
|
<header class="zone-head">
|
||||||
|
<div class="zone-heading">
|
||||||
|
<h2 class="zone-title">{{ t('category.online.label') }}</h2>
|
||||||
|
<p class="zone-desc">{{ t('category.online.desc') }}</p>
|
||||||
|
</div>
|
||||||
|
<router-link to="/api-status" class="zone-link">
|
||||||
|
{{ t('page.api-status.title') }}
|
||||||
|
<ChevronRightIcon aria-hidden="true" />
|
||||||
|
</router-link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="zone-grid">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="tool in tools"
|
v-for="tool in serverTools"
|
||||||
:key="tool.path"
|
:key="tool.path"
|
||||||
:to="`/${tool.path}`"
|
:to="`/${tool.path}`"
|
||||||
class="tool-card"
|
class="server-card"
|
||||||
>
|
>
|
||||||
<span class="tool-icon">
|
<span class="server-card-icon"><component :is="tool.icon" aria-hidden="true" /></span>
|
||||||
<component :is="tool.icon" />
|
<span class="server-card-body">
|
||||||
|
<span class="server-card-name">{{ toolName(tool.path, tool.name) }}</span>
|
||||||
|
<span class="server-card-desc">{{ toolDesc(tool.path, tool.desc) }}</span>
|
||||||
|
<span v-if="endpointOf(tool.path)" class="server-card-endpoint mono-num">
|
||||||
|
{{ endpointOf(tool.path) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="tool-name">{{ tool.name }}</span>
|
|
||||||
<span class="tool-desc">{{ tool.desc }}</span>
|
|
||||||
<span class="tool-arrow">
|
|
||||||
<ChevronRightIcon />
|
|
||||||
</span>
|
</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ==================== 分类目录 ====================
|
||||||
|
58 个工具不再做成 58 个盒子,而是像书末索引一样用发丝线分行:
|
||||||
|
扫读时视线沿着一列文字往下走,比逐个辨认边框快得多,
|
||||||
|
也让「大量同类条目」这件事本身成为版式的一部分,而不是一堆重复的框。 -->
|
||||||
|
<section
|
||||||
|
v-for="(group, index) in visibleGroups"
|
||||||
|
:key="group.category.key"
|
||||||
|
v-reveal="Math.min(index, 3) * 50"
|
||||||
|
class="category"
|
||||||
|
>
|
||||||
|
<header class="category-head">
|
||||||
|
<h2 class="category-title">
|
||||||
|
{{ categoryLabel(group.category.key, group.category.label) }}
|
||||||
|
<span class="category-count mono-num">
|
||||||
|
{{ t('nav.toolsCount', { count: group.items.length }) }}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p class="category-desc">{{ categoryDesc(group.category.key, group.category.desc) }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tool-index">
|
||||||
|
<router-link
|
||||||
|
v-for="tool in group.items"
|
||||||
|
:key="tool.path"
|
||||||
|
:to="`/${tool.path}`"
|
||||||
|
class="tool-entry"
|
||||||
|
>
|
||||||
|
<span class="entry-icon"><component :is="tool.icon" aria-hidden="true" /></span>
|
||||||
|
<span class="entry-body">
|
||||||
|
<span class="entry-name">{{ toolName(tool.path, tool.name) }}</span>
|
||||||
|
<span class="entry-desc">{{ toolDesc(tool.path, tool.desc) }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="tool.needsNetwork" class="entry-badge">{{ t('home.serverBadge') }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ==================== 筛选无结果 ====================
|
||||||
|
给出可执行的下一步(换关键词 / 一键重置),而不是只显示一句「无结果」。 -->
|
||||||
|
<div v-if="isFiltering && !visibleGroups.length" class="empty">
|
||||||
|
<p class="empty-title">{{ t('home.emptyTitle') }}</p>
|
||||||
|
<p class="empty-hint">{{ t('home.emptyHint') }}</p>
|
||||||
|
<button class="empty-btn" type="button" @click="keyword = ''">
|
||||||
|
{{ t('home.resetFilter') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
|
import { computed, ref } from 'vue'
|
||||||
import { tools } from '@/router/tools'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { SearchIcon, CloseIcon, ChevronRightIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import { tools, groupedTools, networkTools } from '@/router/tools'
|
||||||
|
import { toolName, toolDesc, categoryLabel, categoryDesc } from '@/i18n/helpers'
|
||||||
|
import { OPEN_TOOL_ENDPOINTS } from '@/api/openTools'
|
||||||
|
import { SUPPORTED_LOCALES } from '@/i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页:工具目录。
|
||||||
|
*
|
||||||
|
* 版式取舍(编辑感方向):
|
||||||
|
* - 抬头区左对齐、不放 eyebrow,标题的字号反差就是这一页的开场。
|
||||||
|
* - 搜索框画成一条底线而不是一个输入框盒子,和整页的「纸面感」一致。
|
||||||
|
* - 工具列表做成带发丝线的索引,而不是 58 个描边卡片:
|
||||||
|
* 条目越多,越应该靠排版而不是靠容器来组织。
|
||||||
|
* - 服务端工具单独成区并加底纹,是唯一需要联网的部分,值得与本地工具区分。
|
||||||
|
* - 筛选期间隐藏事实条与服务端专区,避免同一批工具在一页里出现两次。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/** 抬头区搜索框的关键词,直接驱动下方列表过滤 */
|
||||||
|
const keyword = ref('')
|
||||||
|
|
||||||
|
const isFiltering = computed(() => keyword.value.trim().length > 0)
|
||||||
|
|
||||||
|
/** 服务端分类分组(与其他分类分开渲染) */
|
||||||
|
const SERVER_CATEGORY = 'online'
|
||||||
|
|
||||||
|
const serverTools = networkTools().filter((tool) => tool.category === SERVER_CATEGORY)
|
||||||
|
|
||||||
|
/** 全部分类分组(未筛选时剔除服务端分类,因为它已单独成区) */
|
||||||
|
const allGroups = groupedTools()
|
||||||
|
|
||||||
|
const visibleGroups = computed(() => {
|
||||||
|
const kw = keyword.value.trim().toLowerCase()
|
||||||
|
if (!kw) return allGroups.filter((group) => group.category.key !== SERVER_CATEGORY)
|
||||||
|
return allGroups
|
||||||
|
.map((group) => ({
|
||||||
|
category: group.category,
|
||||||
|
items: group.items.filter((tool) =>
|
||||||
|
[
|
||||||
|
toolName(tool.path, tool.name),
|
||||||
|
toolDesc(tool.path, tool.desc),
|
||||||
|
tool.path,
|
||||||
|
categoryLabel(group.category.key, group.category.label),
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(kw),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.items.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 概览数字:全部来自真实数据源,没有编造的指标 */
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ label: t('home.statTools'), value: String(tools.length) },
|
||||||
|
{ label: t('home.statUpload'), value: t('home.statUploadValue') },
|
||||||
|
{ label: t('home.statLanguages'), value: String(SUPPORTED_LOCALES.length) },
|
||||||
|
{ label: t('home.statServer'), value: String(OPEN_TOOL_ENDPOINTS.length) },
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 工具 → 服务端接口路径的展示文案(只有真正接入开放接口的工具才有) */
|
||||||
|
const ENDPOINT_LABELS: Record<string, string> = {
|
||||||
|
'random-string': 'POST /tools/random',
|
||||||
|
'ip-info': 'POST /tools/ip',
|
||||||
|
'uuid': 'POST /tools/uuid',
|
||||||
|
'timestamp': 'POST /tools/time',
|
||||||
|
'hash': 'POST /tools/md5',
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointOf(path: string): string {
|
||||||
|
return ENDPOINT_LABELS[path] ?? ''
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.hero {
|
.home {
|
||||||
text-align: center;
|
max-width: 1260px;
|
||||||
padding: 24px 0 32px;
|
|
||||||
}
|
|
||||||
.hero-title {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 30px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--td-text-color-primary);
|
|
||||||
}
|
|
||||||
.hero-desc {
|
|
||||||
margin: 0 auto 14px;
|
|
||||||
max-width: 560px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--td-text-color-secondary);
|
|
||||||
}
|
|
||||||
.hero-badges {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 工具卡片栅格:响应式,移动端单列 */
|
/* ==================== 抬头区 ====================
|
||||||
.tool-grid {
|
* 字号反差是这一页最主要的层次手段:标题 52px / 说明 19px(均取自令牌),
|
||||||
display: grid;
|
* 中间没有第三个档位,视线不会被夹在两层之间。
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
* 搜索框画成一条底线而不是盒子:它是「搜索」这件事本身,不该长成表单控件。 */
|
||||||
gap: 16px;
|
.hero {
|
||||||
|
padding: 16px 0 0;
|
||||||
}
|
}
|
||||||
.tool-card {
|
.hero-title {
|
||||||
position: relative;
|
margin: 0;
|
||||||
|
max-width: 20ch;
|
||||||
|
font-family: var(--tk-font-display);
|
||||||
|
font-size: var(--tk-text-5xl);
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: var(--tk-leading-display);
|
||||||
|
letter-spacing: var(--tk-track-display);
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.hero-desc {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
max-width: 56ch;
|
||||||
|
font-size: var(--tk-text-lg);
|
||||||
|
line-height: var(--tk-leading-body);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 抬头区搜索框:页面的主操作。
|
||||||
|
* 聚焦时底线加粗成 2px(用 box-shadow 叠加,避免改 border-width 引起 1px 位移)。 */
|
||||||
|
.hero-search {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 14px;
|
||||||
padding: 18px;
|
height: 60px;
|
||||||
border: 1px solid var(--td-border-level-1-color);
|
max-width: 640px;
|
||||||
border-radius: 10px;
|
margin-top: 36px;
|
||||||
background: var(--td-bg-color-container);
|
padding: 0 2px;
|
||||||
text-decoration: none;
|
border-bottom: 1px solid var(--tk-border-strong);
|
||||||
transition:
|
transition:
|
||||||
transform 0.15s,
|
border-color var(--tk-dur-fast) var(--tk-ease-out),
|
||||||
box-shadow 0.15s,
|
box-shadow var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
border-color 0.15s;
|
|
||||||
}
|
}
|
||||||
.tool-card:hover {
|
.hero-search:hover {
|
||||||
transform: translateY(-3px);
|
border-bottom-color: var(--tk-text-placeholder);
|
||||||
border-color: var(--td-brand-color);
|
|
||||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
}
|
||||||
html[theme-mode='dark'] .tool-card:hover {
|
.hero-search:focus-within {
|
||||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
|
border-bottom-color: var(--tk-accent);
|
||||||
|
box-shadow: 0 1px 0 0 var(--tk-accent);
|
||||||
}
|
}
|
||||||
.tool-icon {
|
.hero-search-icon {
|
||||||
width: 40px;
|
font-size: 21px;
|
||||||
height: 40px;
|
color: var(--tk-text-placeholder);
|
||||||
border-radius: 10px;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.hero-search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--tk-text-xl);
|
||||||
|
color: var(--tk-text);
|
||||||
|
/* 去掉 type=search 在 Safari / Chrome 上的原生清除按钮,统一用自绘按钮 */
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.hero-search-input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* 焦点已由整条底线表达,输入框自身不再画一遍焦点环(会在地线上多出一个方框) */
|
||||||
|
.hero-search-input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.hero-search-input::placeholder {
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.hero-search-clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 22px;
|
border: none;
|
||||||
color: var(--td-brand-color);
|
border-radius: var(--tk-radius-md);
|
||||||
background: var(--td-brand-color-light);
|
background: transparent;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
.tool-name {
|
.hero-search-clear:hover {
|
||||||
font-size: 15px;
|
background: var(--tk-surface-sunken);
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 事实条 ==================== */
|
||||||
|
.facts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px 48px;
|
||||||
|
padding: 44px 0 56px;
|
||||||
|
}
|
||||||
|
.fact {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.fact-value {
|
||||||
|
font-size: var(--tk-text-xl);
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.fact-label {
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 服务端工具专区 ====================
|
||||||
|
* 全页唯一有底纹的区块:它需要和本地工具区分开,这个区分值得用一整块底色来表达。 */
|
||||||
|
.server-zone {
|
||||||
|
margin-bottom: var(--tk-space-9);
|
||||||
|
padding: 28px 30px 32px;
|
||||||
|
border-radius: var(--tk-radius-2xl);
|
||||||
|
background: var(--tk-accent-surface);
|
||||||
|
}
|
||||||
|
.zone-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.zone-heading {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.zone-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-family: var(--tk-font-display);
|
||||||
|
font-size: var(--tk-text-xl);
|
||||||
|
font-weight: 630;
|
||||||
|
letter-spacing: var(--tk-track-title);
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.zone-desc {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 56ch;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
.zone-link {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.zone-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 专区内的卡片:实底 + 轻投影抬起,与服务端专区「接口」的气质一致,
|
||||||
|
* 也与下方分类的索引行(无底色)明显不同。 */
|
||||||
|
.zone-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.server-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border-radius: var(--tk-radius-xl);
|
||||||
|
background: var(--tk-surface);
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: var(--tk-shadow-1);
|
||||||
|
transition:
|
||||||
|
box-shadow var(--tk-dur-fast) var(--tk-ease-out),
|
||||||
|
transform var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.server-card:hover {
|
||||||
|
box-shadow: var(--tk-shadow-2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.server-card:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.server-card-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
background: var(--tk-accent-surface-strong);
|
||||||
|
}
|
||||||
|
.server-card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.server-card-name {
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--td-text-color-primary);
|
color: var(--tk-text);
|
||||||
}
|
}
|
||||||
.tool-desc {
|
.server-card-desc {
|
||||||
font-size: 13px;
|
font-size: var(--tk-text-sm);
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
color: var(--td-text-color-secondary);
|
color: var(--tk-text-secondary);
|
||||||
}
|
}
|
||||||
.tool-arrow {
|
.server-card-endpoint {
|
||||||
position: absolute;
|
margin-top: 4px;
|
||||||
right: 14px;
|
font-size: var(--tk-text-xs);
|
||||||
top: 50%;
|
letter-spacing: -0.01em;
|
||||||
transform: translateY(-50%);
|
color: var(--tk-text-placeholder);
|
||||||
color: var(--td-text-color-placeholder);
|
}
|
||||||
|
|
||||||
|
/* ==================== 分类目录 ====================
|
||||||
|
* 分区之间用 80px 留白分隔,不加横线:留白本身就是分隔符,
|
||||||
|
* 再画一条线等于把刚空出来的呼吸感又收回去了。 */
|
||||||
|
.category {
|
||||||
|
margin-bottom: var(--tk-space-9);
|
||||||
|
}
|
||||||
|
.category-head {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.category-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--tk-font-display);
|
||||||
|
font-size: var(--tk-text-2xl);
|
||||||
|
font-weight: 640;
|
||||||
|
letter-spacing: var(--tk-track-title);
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
/* 数量作为标题旁的弱化注记,而不是单独占一行 */
|
||||||
|
.category-count {
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.category-desc {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
max-width: 56ch;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 索引网格:条目之间只用「上边线」一条规则(不用四边描边,也不用上下双线)。
|
||||||
|
* 多列时每列各自成栏,列间距 48px 保证两栏文字不会读串行。 */
|
||||||
|
.tool-index {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
column-gap: 48px;
|
||||||
|
}
|
||||||
|
.tool-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 1px solid var(--tk-hairline);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
/* 图标常态是中性灰:58 个图标全部染成主色会把主色的含义稀释掉,
|
||||||
|
* 主色只在 hover 时出现,指向「你正在看的这一条」。 */
|
||||||
|
.entry-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin-top: 1px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
transition: color var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.entry-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.entry-name {
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--tk-text);
|
||||||
|
transition: color var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.entry-desc {
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
.entry-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 3px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: var(--tk-radius-pill);
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
background: var(--tk-accent-surface-strong);
|
||||||
|
}
|
||||||
|
.tool-entry:hover .entry-name,
|
||||||
|
.tool-entry:hover .entry-icon {
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
}
|
||||||
|
.tool-entry:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 空状态 ==================== */
|
||||||
|
.empty {
|
||||||
|
padding: 64px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-family: var(--tk-font-display);
|
||||||
|
font-size: var(--tk-text-lg);
|
||||||
|
font-weight: 640;
|
||||||
|
letter-spacing: var(--tk-track-title);
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.empty-hint {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
.empty-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
border: 1px solid var(--tk-border-strong);
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
background: var(--tk-surface);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--tk-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--tk-dur-fast) var(--tk-ease-out),
|
||||||
|
color var(--tk-dur-fast) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.empty-btn:hover {
|
||||||
|
border-color: var(--tk-accent);
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 响应式 ====================
|
||||||
|
* 留白随断点阶梯式收敛,但每一级都保持「标题明显大于正文」这条底线。 */
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.tool-index {
|
||||||
|
column-gap: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.hero-title {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
.hero-search-input {
|
||||||
|
font-size: var(--tk-text-lg);
|
||||||
|
}
|
||||||
|
.facts {
|
||||||
|
gap: 16px 36px;
|
||||||
|
padding: 40px 0 48px;
|
||||||
|
}
|
||||||
|
.category {
|
||||||
|
margin-bottom: 56px;
|
||||||
|
}
|
||||||
|
.server-zone {
|
||||||
|
margin-bottom: 56px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.hero-title {
|
.hero {
|
||||||
font-size: 24px;
|
padding: 8px 0 0;
|
||||||
}
|
}
|
||||||
.tool-grid {
|
.hero-title {
|
||||||
|
max-width: none;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
.hero-desc {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
}
|
||||||
|
.hero-search {
|
||||||
|
height: 52px;
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
.hero-search-input {
|
||||||
|
font-size: var(--tk-text-md);
|
||||||
|
}
|
||||||
|
.facts {
|
||||||
|
gap: 16px 32px;
|
||||||
|
padding: 36px 0 40px;
|
||||||
|
}
|
||||||
|
.fact-value {
|
||||||
|
font-size: var(--tk-text-lg);
|
||||||
|
}
|
||||||
|
.server-zone {
|
||||||
|
padding: 20px 18px 22px;
|
||||||
|
border-radius: var(--tk-radius-xl);
|
||||||
|
margin-bottom: 44px;
|
||||||
|
}
|
||||||
|
.zone-head {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.zone-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.category {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.category-title {
|
||||||
|
font-size: var(--tk-text-xl);
|
||||||
|
}
|
||||||
|
.tool-index {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -225,7 +225,7 @@ function clearAll() {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.info-label {
|
.info-label {
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.info-value {
|
.info-value {
|
||||||
|
|||||||
173
src/views/api-builder/ApiBuilderView.vue
Normal file
173
src/views/api-builder/ApiBuilderView.vue
Normal 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>
|
||||||
434
src/views/api-status/ApiStatusView.vue
Normal file
434
src/views/api-status/ApiStatusView.vue
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">{{ t('page.api-status.title') }}</h2>
|
||||||
|
<p class="page-desc">{{ t('page.api-status.desc') }}</p>
|
||||||
|
|
||||||
|
<!-- ==================== 概览与操作 ==================== -->
|
||||||
|
<section class="panel tool-section">
|
||||||
|
<div class="meta-row">
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">{{ t('page.api-status.baseUrlLabel') }}</span>
|
||||||
|
<span class="meta-value mono-num">{{ BASE_URL }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">{{ t('page.api-status.lastChecked', { time: lastChecked || '—' }) }}</span>
|
||||||
|
<span class="meta-value">
|
||||||
|
{{ probed ? summary : '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-row">
|
||||||
|
<t-button theme="primary" :disabled="probing" @click="probe">
|
||||||
|
<template #icon><RefreshIcon /></template>
|
||||||
|
{{ probing ? t('page.api-status.probing') : t('page.api-status.probe') }}
|
||||||
|
</t-button>
|
||||||
|
<span v-if="probed" class="verdict" :class="allOk ? 'is-ok' : 'is-bad'">
|
||||||
|
{{ allOk ? t('page.api-status.allOk') : t('page.api-status.partial') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ==================== 接口列表 ====================
|
||||||
|
逐个串行探测:并发会把延迟读数搅在一起,也容易触发限流。
|
||||||
|
每探完一个就立刻更新对应行,用户能看到进度而不是干等。 -->
|
||||||
|
<section class="panel tool-section">
|
||||||
|
<table class="endpoint-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-name">{{ t('page.api-status.endpoint') }}</th>
|
||||||
|
<th class="col-method">{{ t('page.api-status.method') }}</th>
|
||||||
|
<th class="col-status">{{ t('page.api-status.status') }}</th>
|
||||||
|
<th class="col-latency">{{ t('page.api-status.latency') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in rows" :key="row.key" class="endpoint-row">
|
||||||
|
<td class="col-name">
|
||||||
|
<span class="endpoint-name">{{ t(`page.api-status.${row.name}`) }}</span>
|
||||||
|
<span class="endpoint-path mono-num">{{ row.path }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-method">
|
||||||
|
<span class="method-badge mono-num">{{ row.method }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-status">
|
||||||
|
<!-- 状态点在此处承载真实语义(接口是否可用),不是装饰 -->
|
||||||
|
<span class="status" :class="`is-${row.state}`">
|
||||||
|
<span class="status-dot" aria-hidden="true" />
|
||||||
|
{{ statusText(row.state) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-latency">
|
||||||
|
<template v-if="row.latency > 0">
|
||||||
|
<span class="latency mono-num">{{ row.latency }}</span>
|
||||||
|
<span class="latency-unit">ms</span>
|
||||||
|
<!-- 无底槽的细条:只表达相对快慢,不做成仪表盘 -->
|
||||||
|
<span class="latency-bar" :style="{ width: `${barWidth(row.latency)}%` }" aria-hidden="true" />
|
||||||
|
</template>
|
||||||
|
<span v-else class="latency-empty">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p v-if="error" class="error-note">{{ error }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ==================== 返回示例 ====================
|
||||||
|
只展示最后一个成功接口的响应片段,帮助确认字段结构是否符合预期。 -->
|
||||||
|
<section v-if="sample" class="panel tool-section">
|
||||||
|
<header class="sample-head">
|
||||||
|
<h3 class="sample-title">{{ t('page.api-status.sampleTitle') }}</h3>
|
||||||
|
<CopyButton :text="sample" :label="t('common.copy')" />
|
||||||
|
</header>
|
||||||
|
<pre class="code-block sample-code">{{ sample }}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { RefreshIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import CopyButton from '@/components/common/CopyButton.vue'
|
||||||
|
import { api, BASE_URL, backendReady } from '@/api/client'
|
||||||
|
import { OPEN_TOOL_ENDPOINTS } from '@/api/openTools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端接口状态。
|
||||||
|
*
|
||||||
|
* 用途:一眼确认「前端 → service.xpcool.com 开放接口」这条链路是否通畅,
|
||||||
|
* 以及每个接口的响应耗时。部署后排查跨域、证书、网关转发问题时,
|
||||||
|
* 这个页面比逐个开控制台看 Network 快得多。
|
||||||
|
*
|
||||||
|
* 设计取舍:
|
||||||
|
* - 串行探测而不是并发:并发会让每个接口测到的耗时互相干扰,读数失去参考价值。
|
||||||
|
* - 延迟既给数字也给一条无底槽的细线,数字用于精确判断,细线用于快速比较。
|
||||||
|
* - 只展示最后一个成功响应的片段,避免把五个接口的完整返回都堆在页面上。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
type RowState = 'idle' | 'pending' | 'ok' | 'error'
|
||||||
|
|
||||||
|
interface ProbeRow {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
method: string
|
||||||
|
probeBody: Record<string, unknown>
|
||||||
|
state: RowState
|
||||||
|
/** 耗时(毫秒,取整) */
|
||||||
|
latency: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = ref<ProbeRow[]>(
|
||||||
|
OPEN_TOOL_ENDPOINTS.map((e) => ({
|
||||||
|
key: e.key,
|
||||||
|
name: e.name,
|
||||||
|
path: e.path,
|
||||||
|
method: e.method,
|
||||||
|
probeBody: e.probeBody,
|
||||||
|
state: 'idle' as RowState,
|
||||||
|
latency: 0,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const probing = ref(false)
|
||||||
|
const probed = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const lastChecked = ref('')
|
||||||
|
const sample = ref('')
|
||||||
|
|
||||||
|
const okCount = computed(() => rows.value.filter((r) => r.state === 'ok').length)
|
||||||
|
const allOk = computed(() => probed.value && okCount.value === rows.value.length)
|
||||||
|
|
||||||
|
const summary = computed(() =>
|
||||||
|
t('page.api-status.summary', { total: rows.value.length, ok: okCount.value }),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 状态文案 */
|
||||||
|
function statusText(state: RowState): string {
|
||||||
|
if (state === 'ok') return t('page.api-status.ok')
|
||||||
|
if (state === 'error') return t('page.api-status.error')
|
||||||
|
if (state === 'pending') return t('common.loading')
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 细条宽度:以 500ms 为满格,超过即封顶,避免个别慢接口把刻度拉爆 */
|
||||||
|
function barWidth(latency: number): number {
|
||||||
|
return Math.min(100, Math.max(6, (latency / 500) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe() {
|
||||||
|
if (!backendReady) {
|
||||||
|
error.value = t('page.api-status.notConfigured')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
probing.value = true
|
||||||
|
probed.value = false
|
||||||
|
error.value = ''
|
||||||
|
sample.value = ''
|
||||||
|
|
||||||
|
// 重置上一轮结果,避免新旧读数混在一起
|
||||||
|
for (const row of rows.value) {
|
||||||
|
row.state = 'idle'
|
||||||
|
row.latency = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows.value) {
|
||||||
|
row.state = 'pending'
|
||||||
|
const started = performance.now()
|
||||||
|
const res = await api.post(row.path, row.probeBody)
|
||||||
|
row.latency = Math.round(performance.now() - started)
|
||||||
|
row.state = res.ok ? 'ok' : 'error'
|
||||||
|
if (res.ok && res.data) {
|
||||||
|
sample.value = `// ${row.method} ${row.path}\n${JSON.stringify(res.data, null, 2)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastChecked.value = new Date().toLocaleTimeString()
|
||||||
|
probed.value = true
|
||||||
|
probing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void probe()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrap {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 概览 ---------- */
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 40px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.meta-label {
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.meta-value {
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
color: var(--tk-text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.verdict {
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.verdict.is-ok {
|
||||||
|
color: var(--tk-success);
|
||||||
|
}
|
||||||
|
.verdict.is-bad {
|
||||||
|
color: var(--tk-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 接口表格 ---------- */
|
||||||
|
.endpoint-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.endpoint-table th {
|
||||||
|
padding: 0 10px 8px;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--tk-border);
|
||||||
|
}
|
||||||
|
.endpoint-table td {
|
||||||
|
padding: 12px 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
/* 只在行之间用细分隔线,最后一行不加,避免底部出现悬空横线 */
|
||||||
|
.endpoint-row + .endpoint-row td {
|
||||||
|
border-top: 1px solid var(--tk-border-subtle);
|
||||||
|
}
|
||||||
|
.col-name {
|
||||||
|
width: 42%;
|
||||||
|
}
|
||||||
|
.col-method {
|
||||||
|
width: 15%;
|
||||||
|
}
|
||||||
|
.col-status {
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
.col-latency {
|
||||||
|
width: 23%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-name {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.endpoint-path {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.method-badge {
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--tk-radius-xs);
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
background: var(--tk-surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 状态 ---------- */
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.status.is-ok {
|
||||||
|
color: var(--tk-success);
|
||||||
|
}
|
||||||
|
.status.is-ok .status-dot {
|
||||||
|
background: var(--tk-success);
|
||||||
|
}
|
||||||
|
.status.is-error {
|
||||||
|
color: var(--tk-danger);
|
||||||
|
}
|
||||||
|
.status.is-error .status-dot {
|
||||||
|
background: var(--tk-danger);
|
||||||
|
}
|
||||||
|
.status.is-pending {
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.status.is-pending .status-dot {
|
||||||
|
animation: status-blink 1s var(--tk-ease-in-out) infinite;
|
||||||
|
}
|
||||||
|
@keyframes status-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 延迟 ---------- */
|
||||||
|
.col-latency {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.latency {
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.latency-unit {
|
||||||
|
margin-left: 2px;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
/* 无底槽细条:只表达相对快慢 */
|
||||||
|
.latency-bar {
|
||||||
|
display: block;
|
||||||
|
height: 2px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: var(--tk-radius-pill);
|
||||||
|
background: var(--tk-accent);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: width var(--tk-dur-base) var(--tk-ease-out);
|
||||||
|
}
|
||||||
|
.latency-empty {
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-note {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
color: var(--tk-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 返回示例 ---------- */
|
||||||
|
.sample-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.sample-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--tk-text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.sample-code {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 响应式:窄屏把表格转成两行卡片式布局 ---------- */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.endpoint-table,
|
||||||
|
.endpoint-table tbody,
|
||||||
|
.endpoint-table tr,
|
||||||
|
.endpoint-table td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.endpoint-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.endpoint-row {
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.endpoint-row + .endpoint-row {
|
||||||
|
border-top: 1px solid var(--tk-border-subtle);
|
||||||
|
}
|
||||||
|
.endpoint-row + .endpoint-row td {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
.endpoint-table td {
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
.col-method,
|
||||||
|
.col-status,
|
||||||
|
.col-latency {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: auto;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
218
src/views/ascii-art/AsciiArtView.vue
Normal file
218
src/views/ascii-art/AsciiArtView.vue
Normal 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: var(--tk-text-xs);
|
||||||
|
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: var(--tk-text-xs);
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -61,11 +61,20 @@
|
|||||||
<ErrorAlert :message="b64Error" @close="b64Error = ''" />
|
<ErrorAlert :message="b64Error" @close="b64Error = ''" />
|
||||||
|
|
||||||
<t-card v-if="imgUrl" class="tool-section" title="转换结果">
|
<t-card v-if="imgUrl" class="tool-section" title="转换结果">
|
||||||
<img :src="imgUrl" alt="Base64 还原的图片" class="result-img" />
|
<img
|
||||||
|
:src="imgUrl"
|
||||||
|
alt="Base64 还原的图片"
|
||||||
|
class="result-img img-previewable"
|
||||||
|
title="点击预览大图"
|
||||||
|
@click="openViewer([imgUrl])"
|
||||||
|
/>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<DownloadButton :filename="imgFilename" :get-blob="getImgBlob" label="下载图片" />
|
<DownloadButton :filename="imgFilename" :get-blob="getImgBlob" label="下载图片" />
|
||||||
</div>
|
</div>
|
||||||
</t-card>
|
</t-card>
|
||||||
|
|
||||||
|
<!-- 结果图放大预览 -->
|
||||||
|
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||||||
</t-tab-panel>
|
</t-tab-panel>
|
||||||
</t-tabs>
|
</t-tabs>
|
||||||
|
|
||||||
@ -86,6 +95,8 @@ import CopyButton from '@/components/common/CopyButton.vue'
|
|||||||
import DownloadButton from '@/components/common/DownloadButton.vue'
|
import DownloadButton from '@/components/common/DownloadButton.vue'
|
||||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||||
import { useHistory } from '@/composables/useHistory'
|
import { useHistory } from '@/composables/useHistory'
|
||||||
|
import { useImageViewer } from '@/composables/useImageViewer'
|
||||||
|
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||||||
import { dataUrlToBlob, fileToDataUrl } from '@/utils'
|
import { dataUrlToBlob, fileToDataUrl } from '@/utils'
|
||||||
|
|
||||||
const tab = ref('text')
|
const tab = ref('text')
|
||||||
@ -97,6 +108,9 @@ const error = ref('')
|
|||||||
|
|
||||||
const history = useHistory('base64')
|
const history = useHistory('base64')
|
||||||
|
|
||||||
|
/** 结果图放大预览(TDesign ImageViewer) */
|
||||||
|
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||||||
|
|
||||||
/** 编码:UTF-8 安全的 base64(先转字节再 btoa,避免中文报错) */
|
/** 编码:UTF-8 安全的 base64(先转字节再 btoa,避免中文报错) */
|
||||||
function encodeText() {
|
function encodeText() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|||||||
276
src/views/bill-split/BillSplitView.vue
Normal file
276
src/views/bill-split/BillSplitView.vue
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
<!--
|
||||||
|
账单拆分(AA 分账):录入每人已付金额,计算人均消费与净差额,自动生成最少转账方案。
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">账单拆分</h2>
|
||||||
|
<p class="page-desc">多人聚餐、出游的 AA 分账:录入每人已付金额,自动计算人均与转账方案。</p>
|
||||||
|
|
||||||
|
<t-card class="tool-section" title="成员与已付金额">
|
||||||
|
<div v-for="(item, index) in entries" :key="index" class="split-row">
|
||||||
|
<t-input v-model="item.name" :placeholder="`成员 ${index + 1}`" class="split-name" />
|
||||||
|
<t-input v-model="item.amount" placeholder="0.00" class="split-amount">
|
||||||
|
<template #suffix>元</template>
|
||||||
|
</t-input>
|
||||||
|
<t-button
|
||||||
|
theme="danger"
|
||||||
|
variant="text"
|
||||||
|
shape="square"
|
||||||
|
:disabled="entries.length <= 2"
|
||||||
|
@click="removeEntry(index)"
|
||||||
|
>
|
||||||
|
<template #icon><DeleteIcon /></template>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button variant="dashed" @click="addEntry">
|
||||||
|
<template #icon><AddCircleIcon /></template>
|
||||||
|
添加成员
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button theme="primary" @click="calc">计算分账</t-button>
|
||||||
|
<t-button variant="outline" @click="resetEntries">重置</t-button>
|
||||||
|
</div>
|
||||||
|
</t-card>
|
||||||
|
|
||||||
|
<ErrorAlert :message="error" @close="error = ''" />
|
||||||
|
|
||||||
|
<t-card v-if="result" class="tool-section" title="分账结果">
|
||||||
|
<div class="avg-line">
|
||||||
|
共 {{ result.count }} 人,总消费 <b>{{ result.total }}</b>,人均 <b>{{ result.avg }}</b>
|
||||||
|
</div>
|
||||||
|
<div v-if="result.transfers.length" class="transfer-list">
|
||||||
|
<div v-for="(t, i) in result.transfers" :key="i" class="transfer-item">
|
||||||
|
<span class="transfer-from">{{ t.from }}</span>
|
||||||
|
<span class="transfer-arrow">→</span>
|
||||||
|
<span class="transfer-to">{{ t.to }}</span>
|
||||||
|
<b class="transfer-amount">{{ t.amount }}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="avg-line">大家付得一样多,无需转账。</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<CopyButton :text="result.summary" />
|
||||||
|
</div>
|
||||||
|
</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 { AddCircleIcon, DeleteIcon } 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 { useHistory } from '@/composables/useHistory'
|
||||||
|
|
||||||
|
interface SplitEntry {
|
||||||
|
name: string
|
||||||
|
amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Transfer {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitResult {
|
||||||
|
count: number
|
||||||
|
total: string
|
||||||
|
avg: string
|
||||||
|
transfers: Transfer[]
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = useHistory('bill-split')
|
||||||
|
|
||||||
|
function defaultEntries(): SplitEntry[] {
|
||||||
|
return [
|
||||||
|
{ name: '', amount: '' },
|
||||||
|
{ name: '', amount: '' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = ref<SplitEntry[]>(defaultEntries())
|
||||||
|
const error = ref('')
|
||||||
|
const result = ref<SplitResult | null>(null)
|
||||||
|
|
||||||
|
function addEntry() {
|
||||||
|
if (entries.value.length >= 20) {
|
||||||
|
error.value = '最多支持 20 人'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries.value.push({ name: '', amount: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEntry(index: number) {
|
||||||
|
if (entries.value.length <= 2) return
|
||||||
|
entries.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEntries() {
|
||||||
|
entries.value = defaultEntries()
|
||||||
|
error.value = ''
|
||||||
|
result.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 金额格式化为「元」展示:千分位 + 两位小数 */
|
||||||
|
function fmtYuan(v: number): string {
|
||||||
|
return `${v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 贪心结算:欠款方按净差额从小到大、收款方从大到小依次配对,
|
||||||
|
* 可以在 O(n) 轮内给出笔数较少的转账方案。
|
||||||
|
*/
|
||||||
|
function settle(balances: Array<{ name: string; net: number }>): Transfer[] {
|
||||||
|
const eps = 0.005
|
||||||
|
const debtors = balances.filter((b) => b.net < -eps).sort((a, b) => a.net - b.net)
|
||||||
|
const creditors = balances.filter((b) => b.net > eps).sort((a, b) => b.net - a.net)
|
||||||
|
const transfers: Transfer[] = []
|
||||||
|
let i = 0
|
||||||
|
let j = 0
|
||||||
|
while (i < debtors.length && j < creditors.length) {
|
||||||
|
const pay = Math.min(-debtors[i].net, creditors[j].net)
|
||||||
|
transfers.push({ from: debtors[i].name, to: creditors[j].name, amount: fmtYuan(pay) })
|
||||||
|
debtors[i].net += pay
|
||||||
|
creditors[j].net -= pay
|
||||||
|
if (debtors[i].net > -eps) i++
|
||||||
|
if (creditors[j].net < eps) j++
|
||||||
|
}
|
||||||
|
return transfers
|
||||||
|
}
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
error.value = ''
|
||||||
|
result.value = null
|
||||||
|
const parsed: Array<{ name: string; paid: number }> = []
|
||||||
|
for (let i = 0; i < entries.value.length; i++) {
|
||||||
|
const raw = entries.value[i].amount.trim()
|
||||||
|
if (!raw) {
|
||||||
|
error.value = `请填写「${entries.value[i].name.trim() || `成员 ${i + 1}`}」的已付金额`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const paid = Number(raw)
|
||||||
|
if (!Number.isFinite(paid) || paid < 0) {
|
||||||
|
error.value = `「${entries.value[i].name.trim() || `成员 ${i + 1}`}」的金额不合法`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parsed.push({ name: entries.value[i].name.trim() || `成员 ${i + 1}`, paid })
|
||||||
|
}
|
||||||
|
const total = parsed.reduce((s, p) => s + p.paid, 0)
|
||||||
|
if (total <= 0) {
|
||||||
|
error.value = '总消费需大于 0'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const avg = total / parsed.length
|
||||||
|
// 净差额 = 已付 − 人均;为负说明需要补钱,为正说明需要收钱
|
||||||
|
const balances = parsed.map((p) => ({ name: p.name, net: p.paid - avg }))
|
||||||
|
const transfers = settle(balances)
|
||||||
|
result.value = {
|
||||||
|
count: parsed.length,
|
||||||
|
total: fmtYuan(total),
|
||||||
|
avg: fmtYuan(avg),
|
||||||
|
transfers,
|
||||||
|
summary: [
|
||||||
|
`总消费 ${fmtYuan(total)},${parsed.length} 人,人均 ${fmtYuan(avg)}`,
|
||||||
|
transfers.length
|
||||||
|
? '转账方案:\n' + transfers.map((t) => ` ${t.from} → ${t.to}:${t.amount}`).join('\n')
|
||||||
|
: '大家付得一样多,无需转账',
|
||||||
|
].join('\n'),
|
||||||
|
}
|
||||||
|
history.add(JSON.stringify(entries.value.map((e) => [e.name, e.amount])))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHistorySelect(text: string) {
|
||||||
|
// 历史为 JSON 数组 [[名字, 金额], ...];解析失败静默忽略
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(text) as unknown
|
||||||
|
if (!Array.isArray(list) || list.length < 2) return
|
||||||
|
const restored: SplitEntry[] = []
|
||||||
|
for (const item of list) {
|
||||||
|
if (!Array.isArray(item) || restored.length >= 20) continue
|
||||||
|
restored.push({ name: String(item[0] ?? ''), amount: String(item[1] ?? '') })
|
||||||
|
}
|
||||||
|
if (restored.length < 2) return
|
||||||
|
entries.value = restored
|
||||||
|
calc()
|
||||||
|
} catch {
|
||||||
|
// 忽略无法解析的历史内容
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.split-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.split-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.split-amount {
|
||||||
|
width: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.avg-line {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.avg-line b {
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
}
|
||||||
|
.transfer-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.transfer-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.transfer-from,
|
||||||
|
.transfer-to {
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.transfer-arrow {
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
.transfer-amount {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.split-amount {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
163
src/views/bmi/BmiView.vue
Normal file
163
src/views/bmi/BmiView.vue
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<!--
|
||||||
|
BMI 计算器:根据身高体重计算身体质量指数,按中国成人标准分级并给出健康体重范围。
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">BMI 计算器</h2>
|
||||||
|
<p class="page-desc">计算身体质量指数(BMI),按中国成人标准分级,并给出当前身高的健康体重范围。</p>
|
||||||
|
|
||||||
|
<t-card class="tool-section" title="输入身高体重">
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label">身高</span>
|
||||||
|
<t-input v-model="height" placeholder="170" clearable style="max-width: 200px">
|
||||||
|
<template #suffix>cm</template>
|
||||||
|
</t-input>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top: 12px">
|
||||||
|
<span class="form-label">体重</span>
|
||||||
|
<t-input v-model="weight" placeholder="65" clearable style="max-width: 200px">
|
||||||
|
<template #suffix>kg</template>
|
||||||
|
</t-input>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button theme="primary" @click="calc">计算</t-button>
|
||||||
|
</div>
|
||||||
|
</t-card>
|
||||||
|
|
||||||
|
<ErrorAlert :message="error" @close="error = ''" />
|
||||||
|
|
||||||
|
<t-card v-if="result" class="tool-section" title="计算结果">
|
||||||
|
<div class="bmi-line">
|
||||||
|
<span class="bmi-value">{{ result.bmi }}</span>
|
||||||
|
<t-tag :theme="result.theme" variant="light" size="large">{{ result.level }}</t-tag>
|
||||||
|
</div>
|
||||||
|
<div class="code-block">{{ result.detail }}</div>
|
||||||
|
<p class="form-tip">分级采用中国成人标准:<18.5 偏瘦,18.5~23.9 正常,24~27.9 超重,≥28 肥胖。结果仅供参考,不构成医学建议。</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<CopyButton :text="result.detail" />
|
||||||
|
</div>
|
||||||
|
</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 BmiResult {
|
||||||
|
bmi: string
|
||||||
|
level: string
|
||||||
|
theme: 'success' | 'primary' | 'warning' | 'danger'
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const height = ref('')
|
||||||
|
const weight = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const result = ref<BmiResult | null>(null)
|
||||||
|
|
||||||
|
const history = useHistory('bmi')
|
||||||
|
|
||||||
|
/** 按中国成人标准分级,返回等级与展示主题色 */
|
||||||
|
function classify(bmi: number): { level: string; theme: BmiResult['theme'] } {
|
||||||
|
if (bmi < 18.5) return { level: '偏瘦', theme: 'primary' }
|
||||||
|
if (bmi < 24) return { level: '正常', theme: 'success' }
|
||||||
|
if (bmi < 28) return { level: '超重', theme: 'warning' }
|
||||||
|
return { level: '肥胖', theme: 'danger' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
error.value = ''
|
||||||
|
result.value = null
|
||||||
|
// 支持中文全角点号与首尾空白;其余非法字符在 Number 转换后为 NaN
|
||||||
|
const h = Number(height.value.trim().replace('。', '.'))
|
||||||
|
const w = Number(weight.value.trim().replace('。', '.'))
|
||||||
|
if (!Number.isFinite(h) || h <= 0) {
|
||||||
|
error.value = '请输入合法的身高(单位 cm)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (h < 50 || h > 272) {
|
||||||
|
error.value = '身高需在 50 ~ 272 cm 之间'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(w) || w <= 0) {
|
||||||
|
error.value = '请输入合法的体重(单位 kg)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (w < 10 || w > 500) {
|
||||||
|
error.value = '体重需在 10 ~ 500 kg 之间'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const meter = h / 100
|
||||||
|
const bmi = w / (meter * meter)
|
||||||
|
const { level, theme } = classify(bmi)
|
||||||
|
// 健康体重范围:按中国标准 BMI 18.5 ~ 23.9 反推
|
||||||
|
const minKg = 18.5 * meter * meter
|
||||||
|
const maxKg = 23.9 * meter * meter
|
||||||
|
const diff = w < minKg ? `距健康下限还差 ${(minKg - w).toFixed(1)} kg`
|
||||||
|
: w > maxKg ? `超出健康上限 ${(w - maxKg).toFixed(1)} kg`
|
||||||
|
: '处于健康体重区间'
|
||||||
|
result.value = {
|
||||||
|
bmi: bmi.toFixed(1),
|
||||||
|
level,
|
||||||
|
theme,
|
||||||
|
detail: [
|
||||||
|
`身高:${h} cm,体重:${w} kg`,
|
||||||
|
`BMI = ${bmi.toFixed(2)}(${level})`,
|
||||||
|
`健康体重范围:${minKg.toFixed(1)} ~ ${maxKg.toFixed(1)} kg`,
|
||||||
|
diff,
|
||||||
|
].join('\n'),
|
||||||
|
}
|
||||||
|
history.add(`${height.value.trim()}|${weight.value.trim()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHistorySelect(text: string) {
|
||||||
|
// 历史格式:身高|体重
|
||||||
|
const [h, w] = text.split('|')
|
||||||
|
if (!h || !w) return
|
||||||
|
height.value = h
|
||||||
|
weight.value = w
|
||||||
|
calc()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-label {
|
||||||
|
width: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
.bmi-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.bmi-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.form-tip {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
239
src/views/code-stats/CodeStatsView.vue
Normal file
239
src/views/code-stats/CodeStatsView.vue
Normal 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>
|
||||||
320
src/views/code-to-image/CodeToImageView.vue
Normal file
320
src/views/code-to-image/CodeToImageView.vue
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
<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 img-previewable" title="点击预览大图" @click="previewCanvas" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button theme="primary" @click="downloadPng">
|
||||||
|
<template #icon><DownloadIcon /></template>
|
||||||
|
下载 PNG
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</t-card>
|
||||||
|
|
||||||
|
<!-- 生成图放大预览 -->
|
||||||
|
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||||||
|
</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 AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||||||
|
import { downloadDataUrl } from '@/composables/useDownload'
|
||||||
|
import { useImageViewer } from '@/composables/useImageViewer'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
/** 点击画布放大预览(把当前画布导出为 PNG 后交给 ImageViewer) */
|
||||||
|
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||||||
|
function previewCanvas() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (canvas) openViewer([canvas.toDataURL('image/png')])
|
||||||
|
}
|
||||||
|
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>
|
||||||
244
src/views/color-namer/ColorNamerView.vue
Normal file
244
src/views/color-namer/ColorNamerView.vue
Normal 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: var(--tk-text-xs);
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -267,7 +267,7 @@ function onHistorySelect(text: string) {
|
|||||||
background: var(--td-bg-color-secondarycontainer);
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
}
|
}
|
||||||
.field-label {
|
.field-label {
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.field-value {
|
.field-value {
|
||||||
|
|||||||
@ -398,7 +398,7 @@ function onHistorySelect(text: string) {
|
|||||||
}
|
}
|
||||||
.form-tip {
|
.form-tip {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
/* 日期选择器:窄屏下占满可用宽度,避免固定宽度撑出横向滚动条 */
|
/* 日期选择器:窄屏下占满可用宽度,避免固定宽度撑出横向滚动条 */
|
||||||
|
|||||||
167
src/views/env-parse/EnvParseView.vue
Normal file
167
src/views/env-parse/EnvParseView.vue
Normal 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>
|
||||||
147
src/views/exchange-rate/ExchangeRateView.vue
Normal file
147
src/views/exchange-rate/ExchangeRateView.vue
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">{{ t('tool.exchange-rate.name') }}</h2>
|
||||||
|
<p class="page-desc">{{ t('tool.exchange-rate.desc') }}</p>
|
||||||
|
|
||||||
|
<BackendPending />
|
||||||
|
|
||||||
|
<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="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 { useI18n } from 'vue-i18n'
|
||||||
|
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||||
|
import BackendPending from '@/components/common/BackendPending.vue'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
|
||||||
|
/** 页面标题与说明改由语言包提供;接口尚未上线,用 BackendPending 说明现状 */
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
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: var(--tk-text-xs);
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
src/views/express-query/ExpressQueryView.vue
Normal file
150
src/views/express-query/ExpressQueryView.vue
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">{{ t('tool.express-query.name') }}</h2>
|
||||||
|
<p class="page-desc">{{ t('tool.express-query.desc') }}</p>
|
||||||
|
|
||||||
|
<BackendPending />
|
||||||
|
|
||||||
|
<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="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 } from '@/api/client'
|
||||||
|
import BackendPending from '@/components/common/BackendPending.vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
/** 页面标题与说明改由语言包提供;接口尚未上线,用 BackendPending 说明现状 */
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
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: var(--tk-text-xs);
|
||||||
|
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: var(--tk-text-xs);
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
164
src/views/gitignore-gen/GitignoreGenView.vue
Normal file
164
src/views/gitignore-gen/GitignoreGenView.vue
Normal 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>
|
||||||
279
src/views/gradient-generator/GradientGeneratorView.vue
Normal file
279
src/views/gradient-generator/GradientGeneratorView.vue
Normal 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>
|
||||||
@ -1,50 +1,89 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
<h2 class="page-title">哈希计算</h2>
|
<h2 class="page-title">{{ t('page.hash.title') }}</h2>
|
||||||
<p class="page-desc">对文本或文件计算 MD5、SHA1、SHA256、SHA512 哈希值,全部在浏览器本地完成。</p>
|
<p class="page-desc">{{ t('page.hash.desc') }}</p>
|
||||||
|
|
||||||
<t-tabs v-model="tab">
|
<t-tabs v-model="tab">
|
||||||
<!-- 文本哈希 -->
|
<!-- 文本哈希 -->
|
||||||
<t-tab-panel value="text" label="文本哈希">
|
<t-tab-panel value="text" :label="t('page.hash.tabText')">
|
||||||
<t-card class="tool-section" title="输入文本">
|
<section class="panel tool-section">
|
||||||
|
<h3 class="section-title">{{ t('page.hash.inputTitle') }}</h3>
|
||||||
<t-textarea
|
<t-textarea
|
||||||
v-model="textInput"
|
v-model="textInput"
|
||||||
placeholder="输入要计算哈希的文本"
|
:placeholder="t('page.hash.inputPlaceholder')"
|
||||||
:autosize="{ minRows: 4, maxRows: 10 }"
|
:autosize="{ minRows: 4, maxRows: 10 }"
|
||||||
/>
|
/>
|
||||||
<div class="form-row" style="margin-top: 12px">
|
<div class="form-row" style="margin-top: 12px">
|
||||||
<t-checkbox v-model="uppercase">大写输出</t-checkbox>
|
<t-checkbox v-model="uppercase">{{ t('page.hash.uppercase') }}</t-checkbox>
|
||||||
<t-button theme="primary" @click="computeText">计算</t-button>
|
<t-button theme="primary" :disabled="serverLoading" @click="computeText">
|
||||||
<t-button variant="outline" @click="clearText">清空</t-button>
|
{{ t('page.hash.compute') }}
|
||||||
|
</t-button>
|
||||||
|
<t-button variant="outline" @click="clearText">{{ t('page.hash.clear') }}</t-button>
|
||||||
</div>
|
</div>
|
||||||
</t-card>
|
|
||||||
|
<!-- 服务端 MD5:默认关闭,仅在需要核对两端实现时才发请求 -->
|
||||||
|
<div class="server-option">
|
||||||
|
<t-checkbox v-model="useServerMd5">{{ t('page.hash.serverMd5') }}</t-checkbox>
|
||||||
|
<p class="option-hint">{{ t('page.hash.serverMd5Hint') }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</t-tab-panel>
|
</t-tab-panel>
|
||||||
|
|
||||||
<!-- 文件哈希 -->
|
<!-- 文件哈希 -->
|
||||||
<t-tab-panel value="file" label="文件哈希">
|
<t-tab-panel value="file" :label="t('page.hash.tabFile')">
|
||||||
<t-card class="tool-section" title="上传文件">
|
<section class="panel tool-section">
|
||||||
<UploadDrop v-model:files="files" tip="上传任意文件,自动计算哈希" />
|
<h3 class="section-title">{{ t('page.hash.uploadTitle') }}</h3>
|
||||||
<p v-if="file" class="file-meta">
|
<UploadDrop v-model:files="files" :tip="t('page.hash.uploadTip')" />
|
||||||
{{ file.name }} · {{ formatBytes(file.size) }}
|
<p v-if="file" class="file-meta">{{ file.name }} · {{ formatBytes(file.size) }}</p>
|
||||||
</p>
|
</section>
|
||||||
</t-card>
|
|
||||||
</t-tab-panel>
|
</t-tab-panel>
|
||||||
</t-tabs>
|
</t-tabs>
|
||||||
|
|
||||||
<ErrorAlert :message="error" @close="error = ''" />
|
<ErrorAlert :message="error" @close="error = ''" />
|
||||||
|
|
||||||
<t-card v-if="hashResults.length" class="tool-section" title="哈希结果">
|
<!-- ==================== 哈希结果 ==================== -->
|
||||||
|
<section v-if="hashResults.length" class="panel tool-section">
|
||||||
|
<header class="result-head">
|
||||||
|
<h3 class="section-title">{{ t('page.hash.resultTitle') }}</h3>
|
||||||
|
<CopyButton :text="allResults" :label="t('common.copyAll')" />
|
||||||
|
</header>
|
||||||
|
|
||||||
<div v-for="r in hashResults" :key="r.alg" class="hash-row">
|
<div v-for="r in hashResults" :key="r.alg" class="hash-row">
|
||||||
<div class="hash-head">
|
<div class="hash-head">
|
||||||
<span class="hash-alg">{{ r.alg }}</span>
|
<span class="hash-alg">{{ r.alg }}</span>
|
||||||
<CopyButton :text="r.value" label="复制" />
|
<CopyButton :text="r.value" :label="t('common.copy')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="code-block">{{ r.value }}</div>
|
<div class="code-block">{{ r.value }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
</section>
|
||||||
<CopyButton :text="allResults" label="复制全部" />
|
|
||||||
|
<!-- ==================== 两端口径对比 ==================== -->
|
||||||
|
<section v-if="showCompare" class="panel tool-section">
|
||||||
|
<h3 class="section-title">{{ t('page.hash.compareTitle') }}</h3>
|
||||||
|
|
||||||
|
<p v-if="serverLoading" class="compare-pending">{{ t('common.loading') }}</p>
|
||||||
|
|
||||||
|
<!-- 服务端算失败时只提示这一条,不覆盖本机结果(本机结果依然可信) -->
|
||||||
|
<p v-else-if="serverError" class="compare-pending compare-error">
|
||||||
|
{{ t('page.hash.compareServerFailed', { message: serverError }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="compare-grid">
|
||||||
|
<div class="compare-cell">
|
||||||
|
<span class="compare-label">{{ t('page.common.local') }}</span>
|
||||||
|
<span class="compare-value mono-num">{{ localMd5 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</t-card>
|
<div class="compare-cell">
|
||||||
|
<span class="compare-label">{{ t('page.common.server') }}</span>
|
||||||
|
<span class="compare-value mono-num">{{ serverMd5 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="compare-verdict" :class="md5Match ? 'is-ok' : 'is-diff'">
|
||||||
|
{{ md5Match ? t('page.hash.compareSame') : t('page.hash.compareDiff') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
<HistoryPanel
|
<HistoryPanel
|
||||||
:items="history.items"
|
:items="history.items"
|
||||||
@ -56,7 +95,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 哈希计算。
|
||||||
|
*
|
||||||
|
* 本机计算是主路径:MD5 走 spark-md5,SHA 系列走 Web Crypto,全部不联网。
|
||||||
|
* 「服务端 MD5」是一个可选的旁路:同一段文本再让后端算一次,
|
||||||
|
* 目的是核对前后端实现是否一致(也是 open/tools/md5 的真实用途)。
|
||||||
|
*
|
||||||
|
* 之所以做成开关而不是默认行为 —— 计算哈希是个高频、纯本地的动作,
|
||||||
|
* 不该为了算个 MD5 就让每次点击都多一次网络往返。
|
||||||
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import SparkMD5 from 'spark-md5'
|
import SparkMD5 from 'spark-md5'
|
||||||
import UploadDrop from '@/components/common/UploadDrop.vue'
|
import UploadDrop from '@/components/common/UploadDrop.vue'
|
||||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||||
@ -64,6 +114,9 @@ import CopyButton from '@/components/common/CopyButton.vue'
|
|||||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||||
import { useHistory } from '@/composables/useHistory'
|
import { useHistory } from '@/composables/useHistory'
|
||||||
import { bufferToHex, formatBytes } from '@/utils'
|
import { bufferToHex, formatBytes } from '@/utils'
|
||||||
|
import { fetchMd5 } from '@/api/openTools'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const tab = ref('text')
|
const tab = ref('text')
|
||||||
const textInput = ref('')
|
const textInput = ref('')
|
||||||
@ -76,8 +129,28 @@ const file = ref<File | null>(null)
|
|||||||
|
|
||||||
const history = useHistory('hash')
|
const history = useHistory('hash')
|
||||||
|
|
||||||
|
/* ---------- 服务端 MD5 对比 ---------- */
|
||||||
|
const useServerMd5 = ref(false)
|
||||||
|
const serverLoading = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
/** 服务端返回的原始小写值,比对时不走大写格式化 */
|
||||||
|
const serverMd5Raw = ref('')
|
||||||
|
|
||||||
const allResults = computed(() => hashResults.value.map((r) => `${r.alg}: ${r.value}`).join('\n'))
|
const allResults = computed(() => hashResults.value.map((r) => `${r.alg}: ${r.value}`).join('\n'))
|
||||||
|
|
||||||
|
/** 本机算出的 MD5(原始小写,用于和两端比对) */
|
||||||
|
const localMd5Raw = computed(() => hashResults.value.find((r) => r.alg === 'MD5')?.value ?? '')
|
||||||
|
|
||||||
|
const localMd5 = computed(() => localMd5Raw.value)
|
||||||
|
const serverMd5 = computed(() => serverMd5Raw.value)
|
||||||
|
|
||||||
|
/** 两侧都有值时才展示对比区块 */
|
||||||
|
const showCompare = computed(() => useServerMd5.value && !!localMd5Raw.value && !!textInput.value)
|
||||||
|
/** 比对统一用小写:大小写只是展示层选择,不该影响结论 */
|
||||||
|
const md5Match = computed(
|
||||||
|
() => !!serverMd5Raw.value && serverMd5Raw.value.toLowerCase() === localMd5Raw.value.toLowerCase(),
|
||||||
|
)
|
||||||
|
|
||||||
/** 统一大小写输出 */
|
/** 统一大小写输出 */
|
||||||
function fmt(hex: string): string {
|
function fmt(hex: string): string {
|
||||||
return uppercase.value ? hex.toUpperCase() : hex
|
return uppercase.value ? hex.toUpperCase() : hex
|
||||||
@ -106,19 +179,38 @@ async function computeHashes(data: Uint8Array) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 服务端 MD5:失败只提示,不影响本机结果 */
|
||||||
|
async function computeServerMd5(text: string) {
|
||||||
|
serverLoading.value = true
|
||||||
|
serverError.value = ''
|
||||||
|
serverMd5Raw.value = ''
|
||||||
|
const res = await fetchMd5(text)
|
||||||
|
serverLoading.value = false
|
||||||
|
if (!res.ok) {
|
||||||
|
serverError.value = res.message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverMd5Raw.value = res.data?.md5 ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
async function computeText() {
|
async function computeText() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
hashResults.value = []
|
hashResults.value = []
|
||||||
|
serverError.value = ''
|
||||||
|
serverMd5Raw.value = ''
|
||||||
if (!textInput.value) {
|
if (!textInput.value) {
|
||||||
error.value = '请输入要计算哈希的文本'
|
error.value = t('page.hash.errEmpty')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await computeHashes(new TextEncoder().encode(textInput.value))
|
await computeHashes(new TextEncoder().encode(textInput.value))
|
||||||
history.add(textInput.value)
|
history.add(textInput.value)
|
||||||
} catch {
|
} catch {
|
||||||
error.value = '计算失败,请重试'
|
error.value = t('page.hash.errCompute')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
// 本机结果先渲染出来,再去请求服务端,避免等待网络时页面空白
|
||||||
|
if (useServerMd5.value) void computeServerMd5(textInput.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文件变化时(含拖拽删除/清空)自动计算哈希 */
|
/** 文件变化时(含拖拽删除/清空)自动计算哈希 */
|
||||||
@ -126,6 +218,8 @@ watch(files, (list) => {
|
|||||||
const raw = list[0]
|
const raw = list[0]
|
||||||
error.value = ''
|
error.value = ''
|
||||||
hashResults.value = []
|
hashResults.value = []
|
||||||
|
serverError.value = ''
|
||||||
|
serverMd5Raw.value = ''
|
||||||
if (!(raw instanceof File)) {
|
if (!(raw instanceof File)) {
|
||||||
file.value = null
|
file.value = null
|
||||||
return
|
return
|
||||||
@ -134,6 +228,13 @@ watch(files, (list) => {
|
|||||||
void computeFileHash(raw)
|
void computeFileHash(raw)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 打开开关后,如果已经有文本和结果,立刻补算一次服务端 MD5 */
|
||||||
|
watch(useServerMd5, (on) => {
|
||||||
|
if (on && textInput.value && localMd5Raw.value && !serverMd5Raw.value) {
|
||||||
|
void computeServerMd5(textInput.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function computeFileHash(raw: File) {
|
async function computeFileHash(raw: File) {
|
||||||
try {
|
try {
|
||||||
// 大文件按 4MB 分片读取,避免一次性占用过多内存
|
// 大文件按 4MB 分片读取,避免一次性占用过多内存
|
||||||
@ -158,7 +259,7 @@ async function computeFileHash(raw: File) {
|
|||||||
{ alg: 'SHA512', value: fmt(sha512) },
|
{ alg: 'SHA512', value: fmt(sha512) },
|
||||||
]
|
]
|
||||||
} catch {
|
} catch {
|
||||||
error.value = '文件读取失败,请重试'
|
error.value = t('page.hash.errFile')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,33 +273,122 @@ function clearText() {
|
|||||||
textInput.value = ''
|
textInput.value = ''
|
||||||
error.value = ''
|
error.value = ''
|
||||||
hashResults.value = []
|
hashResults.value = []
|
||||||
|
serverError.value = ''
|
||||||
|
serverMd5Raw.value = ''
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: var(--tk-text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
/* ---------- 服务端 MD5 开关 ---------- */
|
||||||
|
.server-option {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--tk-border);
|
||||||
|
}
|
||||||
|
.option-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
.file-meta {
|
.file-meta {
|
||||||
margin: 10px 0 0;
|
margin: 10px 0 0;
|
||||||
font-size: 13px;
|
font-size: var(--tk-text-sm);
|
||||||
color: var(--td-text-color-secondary);
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 结果 ---------- */
|
||||||
|
.result-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.result-head .section-title {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
.hash-row {
|
.hash-row {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.hash-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.hash-head {
|
.hash-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
.hash-alg {
|
.hash-alg {
|
||||||
font-size: 13px;
|
font-size: var(--tk-text-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--td-text-color-secondary);
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 两端口径对比 ---------- */
|
||||||
|
.compare-pending {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.compare-error {
|
||||||
|
color: var(--tk-danger);
|
||||||
|
}
|
||||||
|
.compare-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.compare-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--tk-border);
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
background: var(--tk-surface-sunken);
|
||||||
|
}
|
||||||
|
.compare-label {
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.compare-value {
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--tk-text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.compare-verdict {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.compare-verdict.is-ok {
|
||||||
|
color: var(--tk-success);
|
||||||
|
}
|
||||||
|
.compare-verdict.is-diff {
|
||||||
|
color: var(--tk-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.compare-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
132
src/views/hello-world/HelloWorldView.vue
Normal file
132
src/views/hello-world/HelloWorldView.vue
Normal 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: var(--tk-text-xs);
|
||||||
|
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>
|
||||||
@ -192,7 +192,7 @@ function clearAll() {
|
|||||||
background: var(--td-bg-color-secondarycontainer);
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
}
|
}
|
||||||
.info-label {
|
.info-label {
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.info-value {
|
.info-value {
|
||||||
|
|||||||
@ -108,14 +108,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</t-card>
|
</t-card>
|
||||||
|
|
||||||
<!-- 原图 / 压缩图放大预览(v-if 保证每次打开都从 defaultIndex 开始) -->
|
<!-- 原图 / 压缩图放大预览(统一使用共享 AppImageViewer) -->
|
||||||
<t-image-viewer
|
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||||||
v-if="viewerVisible"
|
|
||||||
v-model="viewerVisible"
|
|
||||||
:images="viewerImages"
|
|
||||||
:default-index="viewerIndex"
|
|
||||||
:close-on-overlay="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HistoryPanel
|
<HistoryPanel
|
||||||
:items="history.items"
|
:items="history.items"
|
||||||
@ -136,7 +130,9 @@ import JSZip from 'jszip'
|
|||||||
import UploadDrop from '@/components/common/UploadDrop.vue'
|
import UploadDrop from '@/components/common/UploadDrop.vue'
|
||||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||||
|
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||||||
import { useHistory } from '@/composables/useHistory'
|
import { useHistory } from '@/composables/useHistory'
|
||||||
|
import { useImageViewer } from '@/composables/useImageViewer'
|
||||||
import { downloadBlob } from '@/composables/useDownload'
|
import { downloadBlob } from '@/composables/useDownload'
|
||||||
import { formatBytes, genFilename } from '@/utils'
|
import { formatBytes, genFilename } from '@/utils'
|
||||||
|
|
||||||
@ -188,10 +184,8 @@ const error = ref('')
|
|||||||
const doneCount = ref(0)
|
const doneCount = ref(0)
|
||||||
const results = ref<CompressResult[]>([])
|
const results = ref<CompressResult[]>([])
|
||||||
|
|
||||||
/** 放大预览状态:展示「原图 + 压缩图」,从点击的那张开始 */
|
/** 放大预览:统一走共享 useImageViewer,展示「原图 + 压缩图」,从点击的那张开始 */
|
||||||
const viewerVisible = ref(false)
|
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||||||
const viewerImages = ref<string[]>([])
|
|
||||||
const viewerIndex = ref(0)
|
|
||||||
|
|
||||||
/** 历史记录:保存「张数 | 压缩方式 | 格式 | 尺寸」摘要,点击可恢复压缩参数 */
|
/** 历史记录:保存「张数 | 压缩方式 | 格式 | 尺寸」摘要,点击可恢复压缩参数 */
|
||||||
const history = useHistory('image-compress')
|
const history = useHistory('image-compress')
|
||||||
@ -341,9 +335,7 @@ function downloadOne(r: CompressResult) {
|
|||||||
|
|
||||||
/** 放大预览:优先展示「原图 + 压缩图」两张并可左右切换,失败时只有原图 */
|
/** 放大预览:优先展示「原图 + 压缩图」两张并可左右切换,失败时只有原图 */
|
||||||
function openPreview(r: CompressResult, index: number) {
|
function openPreview(r: CompressResult, index: number) {
|
||||||
viewerImages.value = r.outUrl ? [r.sourceUrl, r.outUrl] : [r.sourceUrl]
|
openViewer(r.outUrl ? [r.sourceUrl, r.outUrl] : [r.sourceUrl], r.outUrl ? index : 0)
|
||||||
viewerIndex.value = r.outUrl ? index : 0
|
|
||||||
viewerVisible.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全部结果打包为 ZIP 下载(jszip,纯本地生成) */
|
/** 全部结果打包为 ZIP 下载(jszip,纯本地生成) */
|
||||||
@ -436,7 +428,7 @@ onBeforeUnmount(() => {
|
|||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
.form-suffix {
|
.form-suffix {
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.form-tip {
|
.form-tip {
|
||||||
@ -497,7 +489,7 @@ onBeforeUnmount(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-error-color);
|
color: var(--td-error-color);
|
||||||
border: 1px dashed var(--td-border-level-2-color);
|
border: 1px dashed var(--td-border-level-2-color);
|
||||||
background: var(--td-bg-color-container);
|
background: var(--td-bg-color-container);
|
||||||
@ -517,12 +509,12 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
.result-size {
|
.result-size {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-secondary);
|
color: var(--td-text-color-secondary);
|
||||||
}
|
}
|
||||||
.result-error {
|
.result-error {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-error-color);
|
color: var(--td-error-color);
|
||||||
}
|
}
|
||||||
.result-actions {
|
.result-actions {
|
||||||
|
|||||||
@ -26,12 +26,21 @@
|
|||||||
<ErrorAlert :message="error" @close="error = ''" />
|
<ErrorAlert :message="error" @close="error = ''" />
|
||||||
|
|
||||||
<t-card v-if="outUrl" class="tool-section" title="裁剪结果">
|
<t-card v-if="outUrl" class="tool-section" title="裁剪结果">
|
||||||
<img :src="outUrl" alt="裁剪结果预览" class="result-img" />
|
<img
|
||||||
|
:src="outUrl"
|
||||||
|
alt="裁剪结果预览"
|
||||||
|
class="result-img img-previewable"
|
||||||
|
title="点击预览大图"
|
||||||
|
@click="openViewer([outUrl])"
|
||||||
|
/>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<DownloadButton :filename="outName" :get-blob="getOutBlob" label="下载裁剪图" />
|
<DownloadButton :filename="outName" :get-blob="getOutBlob" label="下载裁剪图" />
|
||||||
</div>
|
</div>
|
||||||
</t-card>
|
</t-card>
|
||||||
|
|
||||||
|
<!-- 结果图放大预览 -->
|
||||||
|
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||||||
|
|
||||||
<HistoryPanel
|
<HistoryPanel
|
||||||
:items="history.items"
|
:items="history.items"
|
||||||
@select="onHistorySelect"
|
@select="onHistorySelect"
|
||||||
@ -50,6 +59,8 @@ import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
|||||||
import DownloadButton from '@/components/common/DownloadButton.vue'
|
import DownloadButton from '@/components/common/DownloadButton.vue'
|
||||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||||
import { useHistory } from '@/composables/useHistory'
|
import { useHistory } from '@/composables/useHistory'
|
||||||
|
import { useImageViewer } from '@/composables/useImageViewer'
|
||||||
|
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||||||
import { dataUrlToBlob, genFilename } from '@/utils'
|
import { dataUrlToBlob, genFilename } from '@/utils'
|
||||||
|
|
||||||
/** 比例选项:'free' 表示自由裁剪 */
|
/** 比例选项:'free' 表示自由裁剪 */
|
||||||
@ -78,6 +89,9 @@ let cropper: Cropper | null = null
|
|||||||
|
|
||||||
const history = useHistory('image-cropper')
|
const history = useHistory('image-cropper')
|
||||||
|
|
||||||
|
/** 结果图放大预览(TDesign ImageViewer) */
|
||||||
|
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||||||
|
|
||||||
const outName = computed(() => genFilename('cropped', outType.value.split('/')[1]))
|
const outName = computed(() => genFilename('cropped', outType.value.split('/')[1]))
|
||||||
|
|
||||||
function ratioValue(v: string): number {
|
function ratioValue(v: string): number {
|
||||||
|
|||||||
532
src/views/image-to-ico/ImageToIcoView.vue
Normal file
532
src/views/image-to-ico/ImageToIcoView.vue
Normal file
@ -0,0 +1,532 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">图片转 ICO</h2>
|
||||||
|
<p class="page-desc">
|
||||||
|
把 PNG / JPG / WebP / GIF / BMP / SVG 等图片转成 Windows 图标(.ico),自由组合尺寸、控制输出体积,全程在浏览器本地完成,图片不会上传服务器。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<t-card class="tool-section" title="上传图片">
|
||||||
|
<UploadDrop
|
||||||
|
v-model:files="files"
|
||||||
|
accept="image/*"
|
||||||
|
:multiple="false"
|
||||||
|
tip="支持 PNG / JPG / WebP / GIF / BMP / SVG 等常见格式"
|
||||||
|
/>
|
||||||
|
<p v-if="sourceInfo" class="source-info">
|
||||||
|
{{ sourceInfo.name }} · {{ sourceInfo.width }} × {{ sourceInfo.height }} · {{ formatBytes(sourceInfo.size) }}
|
||||||
|
</p>
|
||||||
|
</t-card>
|
||||||
|
|
||||||
|
<t-card class="tool-section" title="转换设置">
|
||||||
|
<!-- 图标尺寸:预设 + 勾选 + 自定义 -->
|
||||||
|
<div class="form-row form-row-top">
|
||||||
|
<span class="form-label">图标尺寸</span>
|
||||||
|
<div class="size-presets">
|
||||||
|
<t-button v-for="p in sizePresets" :key="p.label" size="small" variant="outline" @click="applyPreset(p)">
|
||||||
|
{{ p.label }}
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label"></span>
|
||||||
|
<t-checkbox-group v-model="sizes">
|
||||||
|
<t-checkbox v-for="s in sizeOptions" :key="s" :value="s">{{ s }}×{{ s }}</t-checkbox>
|
||||||
|
</t-checkbox-group>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label"></span>
|
||||||
|
<t-input-number v-model="customSize" :min="8" :max="256" :step="8" theme="normal" class="custom-input" />
|
||||||
|
<t-button variant="outline" size="small" @click="addCustomSize">添加自定义尺寸</t-button>
|
||||||
|
<span class="form-suffix">尺寸越多、单尺寸越大,ICO 体积越大(上限 256)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 非正方形图片的适配方式 -->
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label">填充方式</span>
|
||||||
|
<t-radio-group v-model="fit" variant="default-filled">
|
||||||
|
<t-radio-button value="cover">裁剪居中</t-radio-button>
|
||||||
|
<t-radio-button value="contain">完整留白</t-radio-button>
|
||||||
|
<t-radio-button value="stretch">拉伸铺满</t-radio-button>
|
||||||
|
</t-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编码方式:直接决定体积与兼容性 -->
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label">编码方式</span>
|
||||||
|
<t-select v-model="mode" :options="modeOptions" class="form-select" />
|
||||||
|
<span class="form-suffix">{{ modeHint }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 颜色量化:有损,用于进一步压体积 -->
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label">颜色量化</span>
|
||||||
|
<t-switch v-model="quantize" />
|
||||||
|
<span class="form-suffix">有损压缩到 256 色以内,体积可再降 50%+(logo / 纯色图标几乎无损)</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="quantize" class="form-row">
|
||||||
|
<span class="form-label">颜色数量</span>
|
||||||
|
<t-slider v-model="maxColors" class="form-slider" :min="8" :max="256" :step="8" />
|
||||||
|
<span class="form-value">{{ maxColors }} 色</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 体积上限:超出后自动降级(先量化,再从大到小删尺寸) -->
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label">限制体积</span>
|
||||||
|
<t-switch v-model="limitEnabled" />
|
||||||
|
<span class="form-suffix">超出上限时自动降级:先启用量化,再移除最大的尺寸</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="limitEnabled" class="form-row">
|
||||||
|
<span class="form-label">上限</span>
|
||||||
|
<t-input-number v-model="limitKb" :min="1" :max="1024" :step="8" theme="normal" />
|
||||||
|
<span class="form-suffix">KB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button theme="primary" :loading="busy" @click="convert">{{ busy ? '生成中…' : '生成 ICO' }}</t-button>
|
||||||
|
<t-button variant="outline" :disabled="!result && !files.length" @click="resetAll">清空</t-button>
|
||||||
|
</div>
|
||||||
|
</t-card>
|
||||||
|
|
||||||
|
<ErrorAlert :message="error" @close="error = ''" />
|
||||||
|
|
||||||
|
<t-card v-if="result" class="tool-section" title="转换结果">
|
||||||
|
<div class="result-head">
|
||||||
|
<div class="ico-preview checker">
|
||||||
|
<img
|
||||||
|
v-if="icoUrl"
|
||||||
|
:src="icoUrl"
|
||||||
|
alt="ICO 预览"
|
||||||
|
class="img-previewable"
|
||||||
|
title="点击预览大图"
|
||||||
|
@click="openViewer(previewUrls, previewUrls.length - 1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="result-summary">
|
||||||
|
<p class="summary-line">
|
||||||
|
共 <strong>{{ result.entries.length }}</strong> 个尺寸条目,文件大小
|
||||||
|
<strong class="size-strong">{{ formatBytes(result.blob.size) }}</strong>
|
||||||
|
</p>
|
||||||
|
<p class="summary-line">
|
||||||
|
<t-tag v-for="e in result.entries" :key="e.size" size="small" theme="primary" variant="light" class="entry-tag">
|
||||||
|
{{ e.size }} {{ e.format }} {{ formatBytes(e.bytes) }}
|
||||||
|
</t-tag>
|
||||||
|
</p>
|
||||||
|
<p v-if="result.quantized && !quantize" class="summary-tip">已自动启用颜色量化以满足体积上限</p>
|
||||||
|
<p v-if="result.droppedSizes.length" class="summary-tip">
|
||||||
|
为满足体积上限,已移除尺寸:{{ result.droppedSizes.join(' / ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="result-actions">
|
||||||
|
<!-- 多个尺寸时支持把每个尺寸的单独 ICO 打包成 ZIP 一键下载 -->
|
||||||
|
<t-button v-if="result.singles.length > 1" theme="primary" :loading="zipping" @click="downloadAllZip">
|
||||||
|
打包下载 ZIP
|
||||||
|
</t-button>
|
||||||
|
<t-button v-else theme="primary" @click="downloadIco">下载 ICO</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t-divider />
|
||||||
|
|
||||||
|
<p class="grid-title">各尺寸预览(点击图片放大,右下角可单独下载 PNG / ICO)</p>
|
||||||
|
<div class="preview-grid">
|
||||||
|
<div v-for="(p, pi) in result.previews" :key="p.size" class="preview-cell">
|
||||||
|
<div class="preview-box checker">
|
||||||
|
<img
|
||||||
|
:src="p.url"
|
||||||
|
:alt="`${p.size}px`"
|
||||||
|
:style="{ width: Math.min(p.size, 96) + 'px' }"
|
||||||
|
class="img-previewable"
|
||||||
|
title="点击预览大图"
|
||||||
|
@click="openViewer(previewUrls, pi)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="preview-meta">
|
||||||
|
<div class="preview-size">{{ p.size }}×{{ p.size }}</div>
|
||||||
|
<div class="preview-btns">
|
||||||
|
<t-button size="small" variant="text" theme="primary" @click="downloadPng(p)">PNG</t-button>
|
||||||
|
<t-button size="small" variant="text" theme="primary" @click="downloadSingleIco(p.size)">ICO</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t-card>
|
||||||
|
|
||||||
|
<HistoryPanel :items="history.items" @select="onHistorySelect" @remove="history.remove" @clear="history.clear" />
|
||||||
|
|
||||||
|
<!-- 预览图放大查看(多尺寸成组,可左右切换) -->
|
||||||
|
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import JSZip from 'jszip'
|
||||||
|
import UploadDrop from '@/components/common/UploadDrop.vue'
|
||||||
|
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||||
|
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||||
|
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||||||
|
import { useHistory } from '@/composables/useHistory'
|
||||||
|
import { useImageViewer } from '@/composables/useImageViewer'
|
||||||
|
import { downloadBlob } from '@/composables/useDownload'
|
||||||
|
import { dataUrlToBlob, formatBytes } from '@/utils'
|
||||||
|
import {
|
||||||
|
buildIco,
|
||||||
|
loadImage,
|
||||||
|
normalizeSvg,
|
||||||
|
type IcoBuildResult,
|
||||||
|
type IcoEncodeMode,
|
||||||
|
type IcoFitMode,
|
||||||
|
} from '@/utils/ico'
|
||||||
|
|
||||||
|
/** 可选的图标边长(Windows / favicon 常用尺寸) */
|
||||||
|
const sizeOptions = [16, 24, 32, 48, 64, 96, 128, 256]
|
||||||
|
|
||||||
|
/** 常用尺寸组合,一键套用 */
|
||||||
|
const sizePresets = [
|
||||||
|
{ label: 'Windows 全套', sizes: [16, 24, 32, 48, 64, 96, 128, 256] },
|
||||||
|
{ label: '常用组合', sizes: [16, 32, 48, 64, 128, 256] },
|
||||||
|
{ label: '网站 favicon', sizes: [16, 32, 48] },
|
||||||
|
{ label: '仅 256', sizes: [256] },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 编码方式选项 */
|
||||||
|
const modeOptions = [
|
||||||
|
{ label: '自动(推荐)', value: 'auto' },
|
||||||
|
{ label: '全部 BMP(兼容老系统)', value: 'bmp' },
|
||||||
|
{ label: '全部 PNG(体积最小)', value: 'png' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const files = ref<File[]>([])
|
||||||
|
const sizes = ref<number[]>([16, 32, 48, 64, 256])
|
||||||
|
const customSize = ref(128)
|
||||||
|
const fit = ref<IcoFitMode>('cover')
|
||||||
|
const mode = ref<IcoEncodeMode>('auto')
|
||||||
|
const quantize = ref(false)
|
||||||
|
const maxColors = ref(256)
|
||||||
|
const limitEnabled = ref(false)
|
||||||
|
const limitKb = ref(64)
|
||||||
|
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const result = ref<IcoBuildResult | null>(null)
|
||||||
|
/** ICO 文件的对象 URL(用于页面内预览,切换结果时释放旧的) */
|
||||||
|
const icoUrl = ref('')
|
||||||
|
/** 源图信息(文件名、原始宽高、大小) */
|
||||||
|
const sourceInfo = ref<{ name: string; width: number; height: number; size: number } | null>(null)
|
||||||
|
|
||||||
|
/** 历史记录:存「文件名 | 参数摘要」,点击可恢复转换参数 */
|
||||||
|
const history = useHistory('image-to-ico')
|
||||||
|
|
||||||
|
/** 预览图放大查看(各尺寸 PNG DataURL 成组) */
|
||||||
|
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||||||
|
const previewUrls = computed(() => result.value?.previews.map((p) => p.url) ?? [])
|
||||||
|
|
||||||
|
/** 编码方式对应的说明文案 */
|
||||||
|
const modeHint = computed(() => {
|
||||||
|
if (mode.value === 'bmp') return 'BMP 兼容性最好,但 128/256 尺寸体积会明显偏大'
|
||||||
|
if (mode.value === 'png') return 'PNG 体积最小,需 Windows Vista 及以上系统'
|
||||||
|
return '自动:128 及以上用 PNG 压缩,小尺寸用 BMP,兼顾体积与兼容性'
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyPreset(preset: { sizes: number[] }) {
|
||||||
|
sizes.value = [...preset.sizes]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加自定义尺寸(去重、升序维护) */
|
||||||
|
function addCustomSize() {
|
||||||
|
const s = Math.min(256, Math.max(8, Math.round(customSize.value)))
|
||||||
|
if (!sizes.value.includes(s)) sizes.value = [...sizes.value, s].sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主流程:解码源图 → 合成 ICO → 生成预览与历史 */
|
||||||
|
async function convert() {
|
||||||
|
if (!files.value.length) {
|
||||||
|
error.value = '请先上传一张图片'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!sizes.value.length) {
|
||||||
|
error.value = '请至少选择一个图标尺寸'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
clearResult()
|
||||||
|
try {
|
||||||
|
const file = files.value[0]
|
||||||
|
// SVG 需补齐固有尺寸,否则浏览器会以默认尺寸栅格化导致图标发虚
|
||||||
|
const source = file.type === 'image/svg+xml' ? await normalizeSvg(file) : file
|
||||||
|
const img = await loadImage(source)
|
||||||
|
sourceInfo.value = {
|
||||||
|
name: file.name,
|
||||||
|
width: img.naturalWidth,
|
||||||
|
height: img.naturalHeight,
|
||||||
|
size: file.size,
|
||||||
|
}
|
||||||
|
const built = await buildIco(img, {
|
||||||
|
sizes: sizes.value,
|
||||||
|
fit: fit.value,
|
||||||
|
mode: mode.value,
|
||||||
|
quantize: quantize.value,
|
||||||
|
maxColors: maxColors.value,
|
||||||
|
limitKb: limitEnabled.value ? limitKb.value : 0,
|
||||||
|
})
|
||||||
|
result.value = built
|
||||||
|
icoUrl.value = URL.createObjectURL(built.blob)
|
||||||
|
// 历史只存参数摘要,不存文件本体(localStorage 有配额限制)
|
||||||
|
history.add(
|
||||||
|
`${file.name} | sizes=${sizes.value.join(',')} | fit=${fit.value} | mode=${mode.value} | q=${
|
||||||
|
quantize.value ? maxColors.value : 'off'
|
||||||
|
} | limit=${limitEnabled.value ? limitKb.value : 0}`,
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '转换失败,请更换图片重试'
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadIco() {
|
||||||
|
if (!result.value) return
|
||||||
|
const base = (sourceInfo.value?.name ?? 'icon').replace(/\.[^.]+$/, '')
|
||||||
|
downloadBlob(result.value.blob, `${base}.ico`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单独下载某个尺寸的 PNG(做 favicon / 应用图标时常用) */
|
||||||
|
function downloadPng(p: { size: number; url: string }) {
|
||||||
|
const base = (sourceInfo.value?.name ?? 'icon').replace(/\.[^.]+$/, '')
|
||||||
|
downloadBlob(dataUrlToBlob(p.url), `${base}_${p.size}x${p.size}.png`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单独下载某个尺寸的 ICO 文件 */
|
||||||
|
function downloadSingleIco(size: number) {
|
||||||
|
const single = result.value?.singles.find((s) => s.size === size)
|
||||||
|
if (!single) return
|
||||||
|
const base = (sourceInfo.value?.name ?? 'icon').replace(/\.[^.]+$/, '')
|
||||||
|
downloadBlob(single.blob, `${base}_${size}x${size}.ico`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一键把所有单尺寸 ICO 打包成 ZIP 下载 */
|
||||||
|
const zipping = ref(false)
|
||||||
|
async function downloadAllZip() {
|
||||||
|
const singles = result.value?.singles
|
||||||
|
if (!singles?.length) return
|
||||||
|
zipping.value = true
|
||||||
|
try {
|
||||||
|
const base = (sourceInfo.value?.name ?? 'icon').replace(/\.[^.]+$/, '')
|
||||||
|
const zip = new JSZip()
|
||||||
|
for (const s of singles) zip.file(`${base}_${s.size}x${s.size}.ico`, s.blob)
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' })
|
||||||
|
downloadBlob(blob, `${base}_ico.zip`)
|
||||||
|
} finally {
|
||||||
|
zipping.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击历史:解析摘要串恢复转换参数 */
|
||||||
|
function onHistorySelect(text: string) {
|
||||||
|
const sMatch = /sizes=([\d,]+)/.exec(text)
|
||||||
|
if (sMatch) {
|
||||||
|
sizes.value = sMatch[1]
|
||||||
|
.split(',')
|
||||||
|
.map(Number)
|
||||||
|
.filter((n) => n > 0 && n <= 256)
|
||||||
|
}
|
||||||
|
const fMatch = /fit=(\w+)/.exec(text)
|
||||||
|
if (fMatch) fit.value = fMatch[1] as IcoFitMode
|
||||||
|
const mMatch = /mode=(\w+)/.exec(text)
|
||||||
|
if (mMatch) mode.value = mMatch[1] as IcoEncodeMode
|
||||||
|
const qMatch = /q=(\d+|off)/.exec(text)
|
||||||
|
if (qMatch) {
|
||||||
|
quantize.value = qMatch[1] !== 'off'
|
||||||
|
if (quantize.value) maxColors.value = Number(qMatch[1])
|
||||||
|
}
|
||||||
|
const lMatch = /limit=(\d+)/.exec(text)
|
||||||
|
if (lMatch) {
|
||||||
|
limitEnabled.value = Number(lMatch[1]) > 0
|
||||||
|
if (limitEnabled.value) limitKb.value = Number(lMatch[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResult() {
|
||||||
|
if (icoUrl.value) URL.revokeObjectURL(icoUrl.value)
|
||||||
|
icoUrl.value = ''
|
||||||
|
result.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll() {
|
||||||
|
clearResult()
|
||||||
|
files.value = []
|
||||||
|
sourceInfo.value = null
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (icoUrl.value) URL.revokeObjectURL(icoUrl.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-label {
|
||||||
|
width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.form-row-top {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.size-presets {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.custom-input {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
.form-select {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
.form-slider {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
.form-value {
|
||||||
|
width: 60px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-suffix {
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.source-info {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
/* 透明棋盘格背景:用于展示带 alpha 通道的图标 */
|
||||||
|
.checker {
|
||||||
|
background-color: var(--td-bg-color-secondarycontainer);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, rgba(127, 127, 127, 0.25) 25%, transparent 25%, transparent 75%, rgba(127, 127, 127, 0.25) 75%),
|
||||||
|
linear-gradient(45deg, rgba(127, 127, 127, 0.25) 25%, transparent 25%, transparent 75%, rgba(127, 127, 127, 0.25) 75%);
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
6px 6px;
|
||||||
|
background-size: 12px 12px;
|
||||||
|
}
|
||||||
|
.result-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ico-preview {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ico-preview img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
.result-summary {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.summary-line {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.size-strong {
|
||||||
|
color: var(--td-success-color);
|
||||||
|
}
|
||||||
|
.entry-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.summary-tip {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--td-warning-color);
|
||||||
|
}
|
||||||
|
.result-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.grid-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
.preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.preview-cell {
|
||||||
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
|
}
|
||||||
|
.preview-box {
|
||||||
|
height: 104px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* 小尺寸图标按原像素展示,避免浏览器缩放导致模糊 */
|
||||||
|
.preview-box img {
|
||||||
|
image-rendering: pixelated;
|
||||||
|
max-width: 96px;
|
||||||
|
max-height: 96px;
|
||||||
|
}
|
||||||
|
.preview-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.preview-btns {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.preview-size {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.result-actions {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
148
src/views/indent-convert/IndentConvertView.vue
Normal file
148
src/views/indent-convert/IndentConvertView.vue
Normal 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>
|
||||||
324
src/views/ip-info/IpInfoView.vue
Normal file
324
src/views/ip-info/IpInfoView.vue
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">{{ t('page.ip-info.title') }}</h2>
|
||||||
|
<p class="page-desc">{{ t('page.ip-info.desc') }}</p>
|
||||||
|
|
||||||
|
<!-- ==================== 服务端识别的来源 IP ====================
|
||||||
|
这是本页唯一由后端提供的数据,放在最上方并做视觉强调。 -->
|
||||||
|
<section class="panel block">
|
||||||
|
<header class="block-head">
|
||||||
|
<h3 class="block-title">{{ t('page.ip-info.serverTitle') }}</h3>
|
||||||
|
<div class="head-actions">
|
||||||
|
<t-button variant="outline" size="small" :disabled="loading" @click="load">
|
||||||
|
<template #icon><RefreshIcon /></template>
|
||||||
|
{{ loading ? t('common.loading') : t('page.common.refresh') }}
|
||||||
|
</t-button>
|
||||||
|
<CopyButton v-if="ipInfo" :text="ipInfo.ip" :label="t('common.copy')" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 加载骨架:形状与最终内容一致,避免出结果时页面跳动 -->
|
||||||
|
<div v-if="loading && !ipInfo" class="skeleton-row">
|
||||||
|
<span class="skeleton skeleton-ip" />
|
||||||
|
<span class="skeleton skeleton-badge" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="ipInfo">
|
||||||
|
<div class="ip-row">
|
||||||
|
<span class="ip-value mono-num">{{ ipInfo.ip }}</span>
|
||||||
|
<span class="ip-badge" :class="ipInfo.internal ? 'is-internal' : 'is-public'">
|
||||||
|
{{ ipInfo.internal ? t('page.ip-info.internal') : t('page.ip-info.external') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="ip-hint">
|
||||||
|
{{ ipInfo.internal ? t('page.ip-info.internalHint') : t('page.ip-info.externalHint') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 错误态:直接展示后端返回的可读信息,并给出重试入口 -->
|
||||||
|
<div v-else-if="error" class="state state-error">
|
||||||
|
<p class="state-text">{{ error }}</p>
|
||||||
|
<t-button size="small" variant="outline" @click="load">{{ t('common.retry') }}</t-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ==================== 本机环境 ====================
|
||||||
|
这些信息全部由浏览器本地读取,与后端无关,也不会上传。 -->
|
||||||
|
<section class="panel block">
|
||||||
|
<header class="block-head">
|
||||||
|
<h3 class="block-title">{{ t('page.ip-info.browserTitle') }}</h3>
|
||||||
|
<span class="block-tag">{{ t('page.common.local') }}</span>
|
||||||
|
</header>
|
||||||
|
<dl class="info-grid">
|
||||||
|
<div v-for="item in localEnv" :key="item.label" class="info-item">
|
||||||
|
<dt class="info-label">{{ item.label }}</dt>
|
||||||
|
<dd class="info-value" :class="{ 'mono-num': item.mono }">{{ item.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ==================== 归属地说明 ====================
|
||||||
|
后端目前不返回归属地数据。与其空着或编造,不如明确说明现状和原因。 -->
|
||||||
|
<section class="panel block">
|
||||||
|
<header class="block-head">
|
||||||
|
<h3 class="block-title">{{ t('page.ip-info.geoTitle') }}</h3>
|
||||||
|
</header>
|
||||||
|
<p class="geo-note">{{ t('page.ip-info.geoUnavailable') }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { RefreshIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import CopyButton from '@/components/common/CopyButton.vue'
|
||||||
|
import { fetchClientIp } from '@/api/openTools'
|
||||||
|
import type { IpRes } from '@/api/openTools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP 信息查询。
|
||||||
|
*
|
||||||
|
* 数据来源分两块,界面上也据此分成两个区块:
|
||||||
|
* 1. 服务端识别的来源 IP —— 调用 POST /api/service/open/tools/ip,
|
||||||
|
* 返回 { ip, internal }。这一项是「服务端视角」,可能与本机网卡地址不同。
|
||||||
|
* 2. 本机浏览器环境 —— 全部本地读取,不产生任何网络请求。
|
||||||
|
*
|
||||||
|
* 归属地(国家/省份/城市/运营商)后端暂未提供,这里不做前端猜测,
|
||||||
|
* 而是明确说明现状,避免给出看起来像真的、实际是编造的地区信息。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const ipInfo = ref<IpRes | null>(null)
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
const res = await fetchClientIp()
|
||||||
|
loading.value = false
|
||||||
|
if (res.ok && res.data) {
|
||||||
|
ipInfo.value = res.data
|
||||||
|
} else {
|
||||||
|
error.value = res.message
|
||||||
|
ipInfo.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本机环境信息;均为浏览器本地读取,刷新时重算以反映窗口尺寸变化 */
|
||||||
|
const viewport = ref('')
|
||||||
|
const localEnv = computed(() => {
|
||||||
|
const nav = navigator as Navigator & { userAgentData?: { platform?: string } }
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('page.ip-info.timezone'),
|
||||||
|
value: `${Intl.DateTimeFormat().resolvedOptions().timeZone} (UTC${formatOffset()})`,
|
||||||
|
},
|
||||||
|
{ label: t('page.ip-info.language'), value: navigator.language },
|
||||||
|
{
|
||||||
|
label: t('page.ip-info.platform'),
|
||||||
|
value: nav.userAgentData?.platform || navigator.platform || t('common.notProvided'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('page.ip-info.screen'),
|
||||||
|
value: `${window.screen.width} × ${window.screen.height} @${dpr}x`,
|
||||||
|
mono: true,
|
||||||
|
},
|
||||||
|
{ label: t('page.ip-info.viewport'), value: viewport.value, mono: true },
|
||||||
|
{ label: t('page.ip-info.userAgent'), value: navigator.userAgent, mono: true },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 以 UTC±HH:MM 形式给出本机时区偏移 */
|
||||||
|
function formatOffset(): string {
|
||||||
|
const minutes = -new Date().getTimezoneOffset()
|
||||||
|
const sign = minutes >= 0 ? '+' : '-'
|
||||||
|
const abs = Math.abs(minutes)
|
||||||
|
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
|
||||||
|
const mm = String(abs % 60).padStart(2, '0')
|
||||||
|
return `${sign}${hh}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateViewport() {
|
||||||
|
viewport.value = `${window.innerWidth} × ${window.innerHeight}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateViewport()
|
||||||
|
window.addEventListener('resize', updateViewport, { passive: true })
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载时移除监听,避免多个页面来回切换后监听器累积
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', updateViewport)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrap {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用区块面板(描边 + 圆角 + 内边距,不加重投影) */
|
||||||
|
.block {
|
||||||
|
margin-bottom: var(--tk-space-4);
|
||||||
|
padding: 20px 22px;
|
||||||
|
}
|
||||||
|
.block-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.block-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--tk-text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.block-tag {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: var(--tk-radius-pill);
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
background: var(--tk-surface-sunken);
|
||||||
|
}
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- IP 展示 ---------- */
|
||||||
|
.ip-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ip-value {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 680;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.ip-badge {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--tk-radius-pill);
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.is-public {
|
||||||
|
color: var(--tk-accent-text);
|
||||||
|
background: var(--tk-accent-surface-strong);
|
||||||
|
}
|
||||||
|
.is-internal {
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
background: var(--tk-surface-sunken);
|
||||||
|
}
|
||||||
|
.ip-hint {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
max-width: 62ch;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 加载骨架 ---------- */
|
||||||
|
.skeleton-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.skeleton {
|
||||||
|
display: block;
|
||||||
|
border-radius: var(--tk-radius-sm);
|
||||||
|
background: var(--tk-surface-sunken);
|
||||||
|
}
|
||||||
|
.skeleton-ip {
|
||||||
|
width: 240px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
.skeleton-badge {
|
||||||
|
width: 68px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: var(--tk-radius-pill);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.skeleton {
|
||||||
|
animation: skeleton-pulse 1.4s var(--tk-ease-in-out) infinite;
|
||||||
|
}
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 错误态 ---------- */
|
||||||
|
.state-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.state-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
color: var(--tk-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 本机环境 ---------- */
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 14px 22px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.info-item {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--tk-text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 归属地说明 ---------- */
|
||||||
|
.geo-note {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 66ch;
|
||||||
|
font-size: var(--tk-text-base);
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.block {
|
||||||
|
padding: 16px 15px;
|
||||||
|
}
|
||||||
|
.ip-value {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
261
src/views/json-diff/JsonDiffView.vue
Normal file
261
src/views/json-diff/JsonDiffView.vue
Normal 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>
|
||||||
304
src/views/json-to-code/JsonToCodeView.vue
Normal file
304
src/views/json-to-code/JsonToCodeView.vue
Normal 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>
|
||||||
@ -217,7 +217,7 @@ function clearAll() {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.info-label {
|
.info-label {
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.info-value {
|
.info-value {
|
||||||
|
|||||||
209
src/views/markdown-table/MarkdownTableView.vue
Normal file
209
src/views/markdown-table/MarkdownTableView.vue
Normal 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+ 空格分隔 示例: 姓名\t年龄\t城市 张三\t28\t北京 李四\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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
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>
|
||||||
277
src/views/morse-code/MorseCodeView.vue
Normal file
277
src/views/morse-code/MorseCodeView.vue
Normal 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: var(--tk-text-xs);
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
257
src/views/mortgage/MortgageView.vue
Normal file
257
src/views/mortgage/MortgageView.vue
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
<!--
|
||||||
|
房贷计算器:等额本息与等额本金一次算出并对比,展示月供、总利息与总还款额。
|
||||||
|
-->
|
||||||
|
<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">
|
||||||
|
<span class="form-label">贷款金额</span>
|
||||||
|
<t-input v-model="amount" placeholder="100" clearable style="max-width: 200px">
|
||||||
|
<template #suffix>万元</template>
|
||||||
|
</t-input>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top: 12px">
|
||||||
|
<span class="form-label">贷款期限</span>
|
||||||
|
<t-input v-model="years" placeholder="30" clearable style="max-width: 200px">
|
||||||
|
<template #suffix>年</template>
|
||||||
|
</t-input>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top: 12px">
|
||||||
|
<span class="form-label">年利率</span>
|
||||||
|
<t-input v-model="rate" placeholder="3.1" clearable style="max-width: 200px">
|
||||||
|
<template #suffix>%</template>
|
||||||
|
</t-input>
|
||||||
|
<t-space size="small">
|
||||||
|
<t-button size="small" variant="outline" @click="rate = '3.1'">商贷示例 3.1%</t-button>
|
||||||
|
<t-button size="small" variant="outline" @click="rate = '2.6'">公积金示例 2.6%</t-button>
|
||||||
|
</t-space>
|
||||||
|
</div>
|
||||||
|
<p class="form-tip">示例利率仅为快速填充参考,请以实际执行利率为准;支持组合贷场景下分段自行计算后相加。</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button theme="primary" @click="calc">计算</t-button>
|
||||||
|
</div>
|
||||||
|
</t-card>
|
||||||
|
|
||||||
|
<ErrorAlert :message="error" @close="error = ''" />
|
||||||
|
|
||||||
|
<t-card v-if="result" class="tool-section" title="计算结果">
|
||||||
|
<div class="result-grid">
|
||||||
|
<div class="result-col">
|
||||||
|
<div class="result-col-title">等额本息(每月还款额固定)</div>
|
||||||
|
<div class="result-line"><span>每月还款</span><b>{{ result.instMonthly }}</b></div>
|
||||||
|
<div class="result-line"><span>总利息</span><b>{{ result.instInterest }}</b></div>
|
||||||
|
<div class="result-line"><span>总还款额</span><b>{{ result.instTotal }}</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="result-col">
|
||||||
|
<div class="result-col-title">等额本金(每月递减)</div>
|
||||||
|
<div class="result-line"><span>首月还款</span><b>{{ result.capFirst }}</b></div>
|
||||||
|
<div class="result-line"><span>每月递减</span><b>{{ result.capDecrease }}</b></div>
|
||||||
|
<div class="result-line"><span>末月还款</span><b>{{ result.capLast }}</b></div>
|
||||||
|
<div class="result-line"><span>总利息</span><b>{{ result.capInterest }}</b></div>
|
||||||
|
<div class="result-line"><span>总还款额</span><b>{{ result.capTotal }}</b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="code-block">{{ result.summary }}</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<CopyButton :text="result.summary" />
|
||||||
|
</div>
|
||||||
|
</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 MortgageResult {
|
||||||
|
instMonthly: string
|
||||||
|
instInterest: string
|
||||||
|
instTotal: string
|
||||||
|
capFirst: string
|
||||||
|
capDecrease: string
|
||||||
|
capLast: string
|
||||||
|
capInterest: string
|
||||||
|
capTotal: string
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = ref('')
|
||||||
|
const years = ref('30')
|
||||||
|
const rate = ref('3.1')
|
||||||
|
const error = ref('')
|
||||||
|
const result = ref<MortgageResult | null>(null)
|
||||||
|
|
||||||
|
const history = useHistory('mortgage')
|
||||||
|
|
||||||
|
/** 金额格式化为「元」展示:千分位 + 两位小数 */
|
||||||
|
function fmtYuan(v: number): string {
|
||||||
|
return `${v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 金额格式化为「万元」展示:保留两位小数 */
|
||||||
|
function fmtWan(v: number): string {
|
||||||
|
return `${(v / 10000).toFixed(2)} 万元`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等额本息:每月还款额固定,M = P·r·(1+r)^n / ((1+r)^n − 1) */
|
||||||
|
function equalInstallment(principal: number, monthRate: number, n: number) {
|
||||||
|
if (monthRate === 0) {
|
||||||
|
const monthly = principal / n
|
||||||
|
return { monthly, totalInterest: 0 }
|
||||||
|
}
|
||||||
|
const f = Math.pow(1 + monthRate, n)
|
||||||
|
const monthly = (principal * monthRate * f) / (f - 1)
|
||||||
|
return { monthly, totalInterest: monthly * n - principal }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等额本金:每月归还固定本金 + 剩余本金利息,逐月递减 */
|
||||||
|
function equalPrincipal(principal: number, monthRate: number, n: number) {
|
||||||
|
const base = principal / n
|
||||||
|
const first = base + principal * monthRate
|
||||||
|
const last = base + base * monthRate
|
||||||
|
const decrease = base * monthRate
|
||||||
|
// 等差数列求和:总利息 = (n+1)·P·r / 2
|
||||||
|
const totalInterest = ((n + 1) * principal * monthRate) / 2
|
||||||
|
return { first, last, decrease, totalInterest }
|
||||||
|
}
|
||||||
|
|
||||||
|
function calc() {
|
||||||
|
error.value = ''
|
||||||
|
result.value = null
|
||||||
|
const a = Number(amount.value.trim())
|
||||||
|
const y = Number(years.value.trim())
|
||||||
|
const r = Number(rate.value.trim())
|
||||||
|
if (!Number.isFinite(a) || a <= 0) {
|
||||||
|
error.value = '请输入合法的贷款金额(万元)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (a > 10000) {
|
||||||
|
error.value = '贷款金额不能超过 10000 万元'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(y) || y <= 0 || !Number.isInteger(y)) {
|
||||||
|
error.value = '贷款期限需为正整数(年)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (y > 50) {
|
||||||
|
error.value = '贷款期限不能超过 50 年'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(r) || r < 0) {
|
||||||
|
error.value = '请输入合法的年利率(%)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (r > 24) {
|
||||||
|
error.value = '年利率不能超过 24%'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const principal = a * 10000
|
||||||
|
const n = y * 12
|
||||||
|
const monthRate = r / 100 / 12
|
||||||
|
const inst = equalInstallment(principal, monthRate, n)
|
||||||
|
const cap = equalPrincipal(principal, monthRate, n)
|
||||||
|
const save = inst.totalInterest - cap.totalInterest
|
||||||
|
result.value = {
|
||||||
|
instMonthly: fmtYuan(inst.monthly),
|
||||||
|
instInterest: fmtYuan(inst.totalInterest),
|
||||||
|
instTotal: fmtYuan(inst.monthly * n),
|
||||||
|
capFirst: fmtYuan(cap.first),
|
||||||
|
capDecrease: fmtYuan(cap.decrease),
|
||||||
|
capLast: fmtYuan(cap.last),
|
||||||
|
capInterest: fmtYuan(cap.totalInterest),
|
||||||
|
capTotal: fmtYuan(principal + cap.totalInterest),
|
||||||
|
summary: [
|
||||||
|
`贷款 ${a} 万元,期限 ${y} 年(${n} 期),年利率 ${r}%`,
|
||||||
|
`等额本息:每月固定还款 ${fmtYuan(inst.monthly)},总利息 ${fmtWan(inst.totalInterest)}`,
|
||||||
|
`等额本金:首月 ${fmtYuan(cap.first)},每月递减 ${fmtYuan(cap.decrease)},末月 ${fmtYuan(cap.last)},总利息 ${fmtWan(cap.totalInterest)}`,
|
||||||
|
save > 0
|
||||||
|
? `等额本金比等额本息少付利息 ${fmtYuan(save)},但前期月供压力更大`
|
||||||
|
: '两种方式总利息相同(利率为 0)',
|
||||||
|
].join('\n'),
|
||||||
|
}
|
||||||
|
history.add(JSON.stringify({ a: amount.value.trim(), y: years.value.trim(), r: rate.value.trim() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHistorySelect(text: string) {
|
||||||
|
// 历史为 JSON 快照;解析失败静默忽略
|
||||||
|
try {
|
||||||
|
const h = JSON.parse(text) as { a?: string; y?: string; r?: string } | null
|
||||||
|
if (!h) return
|
||||||
|
if (typeof h.a === 'string') amount.value = h.a
|
||||||
|
if (typeof h.y === 'string') years.value = h.y
|
||||||
|
if (typeof h.r === 'string') rate.value = h.r
|
||||||
|
calc()
|
||||||
|
} catch {
|
||||||
|
// 忽略无法解析的历史内容
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-label {
|
||||||
|
width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
.result-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.result-col {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
|
}
|
||||||
|
.result-col-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
}
|
||||||
|
.result-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
.result-line b {
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.form-tip {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.result-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
269
src/views/network-test/NetworkTestView.vue
Normal file
269
src/views/network-test/NetworkTestView.vue
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">{{ t('tool.network-test.name') }}</h2>
|
||||||
|
<p class="page-desc">{{ t('tool.network-test.desc') }}</p>
|
||||||
|
|
||||||
|
<BackendPending :message="t('pending.networkTest')" />
|
||||||
|
|
||||||
|
<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="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="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="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 } from '@/api/client'
|
||||||
|
import BackendPending from '@/components/common/BackendPending.vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面标题与说明改由语言包提供。
|
||||||
|
* 注意:Ping / TCP / DNS 三种模式依赖服务端实现探测接口,目前尚未上线,
|
||||||
|
* 相关按钮可点但会返回「接口尚未上线」,界面上用 BackendPending 说明现状。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
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: var(--tk-text-xs);
|
||||||
|
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: var(--tk-text-xs);
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
.ri b {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -7,7 +7,14 @@
|
|||||||
|
|
||||||
<t-card class="tool-section" title="上传图片">
|
<t-card class="tool-section" title="上传图片">
|
||||||
<UploadDrop v-model:files="files" accept="image/*" tip="支持中文 / 英文识别" />
|
<UploadDrop v-model:files="files" accept="image/*" tip="支持中文 / 英文识别" />
|
||||||
<img v-if="previewUrl" :src="previewUrl" alt="待识别图片" class="preview-img" />
|
<img
|
||||||
|
v-if="previewUrl"
|
||||||
|
:src="previewUrl"
|
||||||
|
alt="待识别图片"
|
||||||
|
class="preview-img img-previewable"
|
||||||
|
title="点击预览大图"
|
||||||
|
@click="openViewer([previewUrl])"
|
||||||
|
/>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<t-button theme="primary" :loading="busy" :disabled="!file" @click="recognize">
|
<t-button theme="primary" :loading="busy" :disabled="!file" @click="recognize">
|
||||||
开始识别
|
开始识别
|
||||||
@ -41,6 +48,9 @@
|
|||||||
@remove="history.remove"
|
@remove="history.remove"
|
||||||
@clear="history.clear"
|
@clear="history.clear"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 待识别图放大预览 -->
|
||||||
|
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -56,6 +66,8 @@ import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
|||||||
import CopyButton from '@/components/common/CopyButton.vue'
|
import CopyButton from '@/components/common/CopyButton.vue'
|
||||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||||
import { useHistory } from '@/composables/useHistory'
|
import { useHistory } from '@/composables/useHistory'
|
||||||
|
import { useImageViewer } from '@/composables/useImageViewer'
|
||||||
|
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||||||
import { downloadText } from '@/composables/useDownload'
|
import { downloadText } from '@/composables/useDownload'
|
||||||
import { genFilename } from '@/utils'
|
import { genFilename } from '@/utils'
|
||||||
|
|
||||||
@ -72,6 +84,9 @@ type WorkerInstance = Awaited<ReturnType<typeof createWorker>>
|
|||||||
const files = ref<File[]>([])
|
const files = ref<File[]>([])
|
||||||
const file = ref<File | null>(null)
|
const file = ref<File | null>(null)
|
||||||
const previewUrl = ref('')
|
const previewUrl = ref('')
|
||||||
|
|
||||||
|
/** 待识别图放大预览(TDesign ImageViewer) */
|
||||||
|
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
const progressLabel = ref('')
|
const progressLabel = ref('')
|
||||||
|
|||||||
@ -224,7 +224,7 @@ onMounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.form-suffix {
|
.form-suffix {
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.form-actions {
|
.form-actions {
|
||||||
@ -253,7 +253,7 @@ onMounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.strength-entropy {
|
.strength-entropy {
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.strength-bar {
|
.strength-bar {
|
||||||
|
|||||||
116
src/views/phone-locate/PhoneLocateView.vue
Normal file
116
src/views/phone-locate/PhoneLocateView.vue
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">{{ t('tool.phone-locate.name') }}</h2>
|
||||||
|
<p class="page-desc">{{ t('tool.phone-locate.desc') }}</p>
|
||||||
|
|
||||||
|
<BackendPending />
|
||||||
|
|
||||||
|
<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="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 } from '@/api/client'
|
||||||
|
import BackendPending from '@/components/common/BackendPending.vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
/** 页面标题与说明改由语言包提供;接口尚未上线,用 BackendPending 说明现状 */
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
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: var(--tk-text-xs);
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
.pi b {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
267
src/views/placeholder-image/PlaceholderImageView.vue
Normal file
267
src/views/placeholder-image/PlaceholderImageView.vue
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
<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 img-previewable" title="点击预览大图" @click="previewCanvas" />
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 占位图放大预览 -->
|
||||||
|
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||||||
|
</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 { useImageViewer } from '@/composables/useImageViewer'
|
||||||
|
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||||||
|
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)
|
||||||
|
|
||||||
|
/** 点击画布放大预览(把当前画布导出为 PNG 后交给 ImageViewer) */
|
||||||
|
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||||||
|
function previewCanvas() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (canvas) openViewer([canvas.toDataURL('image/png')])
|
||||||
|
}
|
||||||
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
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: var(--tk-text-xs);
|
||||||
|
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>
|
||||||
226
src/views/pomodoro/PomodoroView.vue
Normal file
226
src/views/pomodoro/PomodoroView.vue
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
<!--
|
||||||
|
番茄钟:专注/休息倒计时,基于目标时间戳计时(后台标签页不漂移),
|
||||||
|
阶段结束自动切换并提示音提醒,记录已完成轮数。
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">番茄钟</h2>
|
||||||
|
<p class="page-desc">番茄工作法计时:专注一段时间后短暂休息,循环往复。页面保持打开即可持续计时。</p>
|
||||||
|
|
||||||
|
<t-card class="tool-section">
|
||||||
|
<div class="timer-head">
|
||||||
|
<t-tag :theme="phase === 'focus' ? 'primary' : 'success'" variant="light" size="large">
|
||||||
|
{{ phase === 'focus' ? '专注中' : '休息中' }}
|
||||||
|
</t-tag>
|
||||||
|
<span class="rounds">已完成 {{ rounds }} 轮</span>
|
||||||
|
<t-checkbox v-model="sound">结束提示音</t-checkbox>
|
||||||
|
</div>
|
||||||
|
<div class="timer-display" :class="{ break: phase === 'break' }">{{ display }}</div>
|
||||||
|
<div class="timer-actions">
|
||||||
|
<t-button v-if="!running" theme="primary" size="large" @click="start">
|
||||||
|
{{ remaining < phaseDuration() ? '继续' : '开始' }}
|
||||||
|
</t-button>
|
||||||
|
<t-button v-else theme="warning" size="large" @click="pause">暂停</t-button>
|
||||||
|
<t-button variant="outline" size="large" @click="reset">重置</t-button>
|
||||||
|
</div>
|
||||||
|
</t-card>
|
||||||
|
|
||||||
|
<t-card class="tool-section" title="时长设置">
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label">专注时长</span>
|
||||||
|
<t-input-number v-model="focusMin" :min="1" :max="180" theme="normal" />
|
||||||
|
<span class="unit-text">分钟</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top: 12px">
|
||||||
|
<span class="form-label">休息时长</span>
|
||||||
|
<t-input-number v-model="restMin" :min="1" :max="60" theme="normal" />
|
||||||
|
<span class="unit-text">分钟</span>
|
||||||
|
</div>
|
||||||
|
<p class="form-tip">计时进行中修改时长不影响当前倒计时;重置后按新时长生效。推荐 25 分钟专注 + 5 分钟休息。</p>
|
||||||
|
</t-card>
|
||||||
|
|
||||||
|
<HistoryPanel
|
||||||
|
:items="history.items"
|
||||||
|
@select="onHistorySelect"
|
||||||
|
@remove="history.remove"
|
||||||
|
@clear="history.clear"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||||
|
import { useHistory } from '@/composables/useHistory'
|
||||||
|
|
||||||
|
const history = useHistory('pomodoro')
|
||||||
|
|
||||||
|
const focusMin = ref<number | null>(25)
|
||||||
|
const restMin = ref<number | null>(5)
|
||||||
|
const phase = ref<'focus' | 'break'>('focus')
|
||||||
|
const running = ref(false)
|
||||||
|
/** 剩余秒数 */
|
||||||
|
const remaining = ref(25 * 60)
|
||||||
|
/** 已完成专注轮数 */
|
||||||
|
const rounds = ref(0)
|
||||||
|
const sound = ref(true)
|
||||||
|
/** 当前阶段是否还未开始过(未开始时修改时长直接同步剩余时间) */
|
||||||
|
let untouched = true
|
||||||
|
/** 倒计时目标时间戳:基于它计算剩余时间,避免 setInterval 在后台被节流导致漂移 */
|
||||||
|
let endTime = 0
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function validFocus(): number {
|
||||||
|
return focusMin.value && focusMin.value > 0 ? focusMin.value : 25
|
||||||
|
}
|
||||||
|
function validRest(): number {
|
||||||
|
return restMin.value && restMin.value > 0 ? restMin.value : 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前阶段的完整时长(秒) */
|
||||||
|
function phaseDuration(): number {
|
||||||
|
return (phase.value === 'focus' ? validFocus() : validRest()) * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
const display = computed(() => {
|
||||||
|
const s = Math.max(0, remaining.value)
|
||||||
|
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 未开始时调整时长,倒计时同步刷新 */
|
||||||
|
watch([focusMin, restMin], () => {
|
||||||
|
if (!running.value && untouched) remaining.value = phaseDuration()
|
||||||
|
})
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (running.value) return
|
||||||
|
running.value = true
|
||||||
|
untouched = false
|
||||||
|
endTime = Date.now() + remaining.value * 1000
|
||||||
|
timer = setInterval(tick, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
if (!running.value) return
|
||||||
|
running.value = false
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
pause()
|
||||||
|
phase.value = 'focus'
|
||||||
|
untouched = true
|
||||||
|
remaining.value = phaseDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const s = Math.max(0, Math.round((endTime - Date.now()) / 1000))
|
||||||
|
remaining.value = s
|
||||||
|
if (s <= 0) onPhaseEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阶段结束:专注结束计一轮并切休息,休息结束切回专注,均自动开始下一阶段 */
|
||||||
|
function onPhaseEnd() {
|
||||||
|
beep()
|
||||||
|
if (phase.value === 'focus') {
|
||||||
|
rounds.value++
|
||||||
|
phase.value = 'break'
|
||||||
|
} else {
|
||||||
|
phase.value = 'focus'
|
||||||
|
}
|
||||||
|
untouched = false
|
||||||
|
remaining.value = phaseDuration()
|
||||||
|
endTime = Date.now() + remaining.value * 1000
|
||||||
|
addHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 简短提示音:Web Audio 生成 880Hz 正弦波,无需外部音频资源 */
|
||||||
|
function beep() {
|
||||||
|
if (!sound.value) return
|
||||||
|
try {
|
||||||
|
const ctx = new AudioContext()
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
const gain = ctx.createGain()
|
||||||
|
osc.connect(gain)
|
||||||
|
gain.connect(ctx.destination)
|
||||||
|
osc.frequency.value = 880
|
||||||
|
gain.gain.setValueAtTime(0.2, ctx.currentTime)
|
||||||
|
osc.start()
|
||||||
|
osc.stop(ctx.currentTime + 0.4)
|
||||||
|
osc.onended = () => ctx.close()
|
||||||
|
} catch {
|
||||||
|
// 个别浏览器限制音频播放,静默忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 历史只存时长参数(计时状态不持久化),格式:focus=25 | rest=5 */
|
||||||
|
function addHistory() {
|
||||||
|
history.add(`focus=${validFocus()} | rest=${validRest()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHistorySelect(text: string) {
|
||||||
|
// 历史格式:focus=N | rest=N
|
||||||
|
const f = /focus=(\d+)/.exec(text)
|
||||||
|
const r = /rest=(\d+)/.exec(text)
|
||||||
|
if (f) focusMin.value = Math.min(180, Math.max(1, Number(f[1])))
|
||||||
|
if (r) restMin.value = Math.min(60, Math.max(1, Number(r[1])))
|
||||||
|
if (f || r) reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件卸载必须清理定时器
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timer-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.rounds {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
.timer-display {
|
||||||
|
margin: 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 72px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
.timer-display.break {
|
||||||
|
color: var(--td-success-color);
|
||||||
|
}
|
||||||
|
.timer-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.form-label {
|
||||||
|
width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
.unit-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
.form-tip {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.timer-display {
|
||||||
|
font-size: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -35,7 +35,13 @@
|
|||||||
|
|
||||||
<t-card v-if="qrUrl" class="tool-section" title="生成结果">
|
<t-card v-if="qrUrl" class="tool-section" title="生成结果">
|
||||||
<div class="qr-result">
|
<div class="qr-result">
|
||||||
<img :src="qrUrl" alt="生成的二维码" class="qr-img" />
|
<img
|
||||||
|
:src="qrUrl"
|
||||||
|
alt="生成的二维码"
|
||||||
|
class="qr-img img-previewable"
|
||||||
|
title="点击预览大图"
|
||||||
|
@click="openViewer([qrUrl])"
|
||||||
|
/>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<DownloadButton :filename="`qrcode_${size}.png`" :get-blob="getQrBlob" label="下载 PNG" />
|
<DownloadButton :filename="`qrcode_${size}.png`" :get-blob="getQrBlob" label="下载 PNG" />
|
||||||
<CopyButton :text="text" label="复制内容" />
|
<CopyButton :text="text" label="复制内容" />
|
||||||
@ -77,6 +83,9 @@
|
|||||||
/>
|
/>
|
||||||
</t-tab-panel>
|
</t-tab-panel>
|
||||||
</t-tabs>
|
</t-tabs>
|
||||||
|
|
||||||
|
<!-- 二维码放大预览 -->
|
||||||
|
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -90,6 +99,8 @@ import CopyButton from '@/components/common/CopyButton.vue'
|
|||||||
import DownloadButton from '@/components/common/DownloadButton.vue'
|
import DownloadButton from '@/components/common/DownloadButton.vue'
|
||||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||||
import { useHistory } from '@/composables/useHistory'
|
import { useHistory } from '@/composables/useHistory'
|
||||||
|
import { useImageViewer } from '@/composables/useImageViewer'
|
||||||
|
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||||||
import { dataUrlToBlob } from '@/utils'
|
import { dataUrlToBlob } from '@/utils'
|
||||||
|
|
||||||
const tab = ref('generate')
|
const tab = ref('generate')
|
||||||
@ -107,6 +118,9 @@ const text = ref('')
|
|||||||
const size = ref(256)
|
const size = ref(256)
|
||||||
const level = ref('M')
|
const level = ref('M')
|
||||||
const dark = ref('#000000')
|
const dark = ref('#000000')
|
||||||
|
|
||||||
|
/** 二维码放大预览(TDesign ImageViewer) */
|
||||||
|
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||||||
const light = ref('#ffffff')
|
const light = ref('#ffffff')
|
||||||
const genBusy = ref(false)
|
const genBusy = ref(false)
|
||||||
const genError = ref('')
|
const genError = ref('')
|
||||||
|
|||||||
279
src/views/random-decision/RandomDecisionView.vue
Normal file
279
src/views/random-decision/RandomDecisionView.vue
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
<!--
|
||||||
|
随机决定:选项抽签(治选择困难)与随机数生成两个面板,全部使用 crypto 安全随机源。
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">随机决定</h2>
|
||||||
|
<p class="page-desc">今天吃什么、谁去取快递?把选项交给随机数。也支持批量生成随机数。</p>
|
||||||
|
|
||||||
|
<t-tabs v-model="tab">
|
||||||
|
<!-- 选项抽签 -->
|
||||||
|
<t-tab-panel value="pick" label="选项抽签">
|
||||||
|
<t-card class="tool-section" title="候选选项(每行一个)">
|
||||||
|
<t-textarea
|
||||||
|
v-model="options"
|
||||||
|
:autosize="{ minRows: 6, maxRows: 12 }"
|
||||||
|
placeholder="每行填写一个选项"
|
||||||
|
/>
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button theme="primary" @click="pick">抽一个</t-button>
|
||||||
|
<t-button variant="outline" @click="loadPreset('meal')">今天吃什么</t-button>
|
||||||
|
<t-button variant="outline" @click="loadPreset('do')">做不做</t-button>
|
||||||
|
<t-checkbox v-model="excludePicked">抽过的选项不再参与</t-checkbox>
|
||||||
|
<t-button v-if="excluded.size" size="small" variant="text" @click="excluded.clear()">
|
||||||
|
恢复全部选项(已排除 {{ excluded.size }} 个)
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</t-card>
|
||||||
|
<ErrorAlert :message="error1" @close="error1 = ''" />
|
||||||
|
<t-card v-if="picked" class="tool-section">
|
||||||
|
<div class="picked-box">{{ picked }}</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<CopyButton :text="picked" />
|
||||||
|
</div>
|
||||||
|
</t-card>
|
||||||
|
</t-tab-panel>
|
||||||
|
|
||||||
|
<!-- 随机数 -->
|
||||||
|
<t-tab-panel value="number" label="随机数">
|
||||||
|
<t-card class="tool-section" title="随机数范围">
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label">范围</span>
|
||||||
|
<t-input v-model="numMin" placeholder="1" class="num-input" />
|
||||||
|
<span class="num-sep">~</span>
|
||||||
|
<t-input v-model="numMax" placeholder="100" class="num-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top: 12px">
|
||||||
|
<span class="form-label">数量</span>
|
||||||
|
<t-input v-model="numCount" placeholder="1" class="num-input" />
|
||||||
|
<t-checkbox v-model="numUnique">不重复</t-checkbox>
|
||||||
|
<t-checkbox v-model="numSorted">升序排列</t-checkbox>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button theme="primary" @click="genNumbers">生成</t-button>
|
||||||
|
</div>
|
||||||
|
</t-card>
|
||||||
|
<ErrorAlert :message="error2" @close="error2 = ''" />
|
||||||
|
<t-card v-if="numbers" class="tool-section" title="生成结果">
|
||||||
|
<div class="code-block">{{ numbers }}</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<CopyButton :text="numbers" />
|
||||||
|
</div>
|
||||||
|
</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 { reactive, 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 RandomHistory {
|
||||||
|
tab: string
|
||||||
|
options?: string
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
count?: string
|
||||||
|
unique?: boolean
|
||||||
|
sorted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = useHistory('random-decision')
|
||||||
|
const tab = ref('pick')
|
||||||
|
|
||||||
|
/** 安全随机整数:基于 crypto.getRandomValues 拒绝采样,避免取模偏斜 */
|
||||||
|
function secureRandInt(upperExclusive: number): number {
|
||||||
|
const limit = Math.floor(0x100000000 / upperExclusive) * upperExclusive
|
||||||
|
const buf = new Uint32Array(1)
|
||||||
|
let v: number
|
||||||
|
do {
|
||||||
|
crypto.getRandomValues(buf)
|
||||||
|
v = buf[0]
|
||||||
|
} while (v >= limit)
|
||||||
|
return v % upperExclusive
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 选项抽签 ---------- */
|
||||||
|
const PRESETS: Record<string, string> = {
|
||||||
|
meal: ['火锅', '烧烤', '麻辣烫', '黄焖鸡', '兰州拉面', '沙县小吃', '饺子', '盖浇饭', '轻食沙拉', '寿司', '披萨', '炸鸡'].join('\n'),
|
||||||
|
do: ['做', '不做', '再想想', '抛硬币无效,必须做'].join('\n'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = ref(PRESETS.meal)
|
||||||
|
const picked = ref('')
|
||||||
|
const excluded = reactive(new Set<string>())
|
||||||
|
const error1 = ref('')
|
||||||
|
|
||||||
|
function loadPreset(key: string) {
|
||||||
|
options.value = PRESETS[key]
|
||||||
|
picked.value = ''
|
||||||
|
excluded.clear()
|
||||||
|
error1.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptions(): string[] {
|
||||||
|
return options.value
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick() {
|
||||||
|
error1.value = ''
|
||||||
|
const all = parseOptions()
|
||||||
|
if (all.length === 0) {
|
||||||
|
error1.value = '请至少填写一个选项'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const candidates = all.filter((o) => !excluded.has(o))
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
error1.value = '所有选项都已被排除,请先恢复选项'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const winner = candidates[secureRandInt(candidates.length)]
|
||||||
|
picked.value = winner
|
||||||
|
if (excludePicked.value) excluded.add(winner)
|
||||||
|
history.add(JSON.stringify({ tab: 'pick', options: options.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludePicked = ref(false)
|
||||||
|
|
||||||
|
/* ---------- 随机数 ---------- */
|
||||||
|
const numMin = ref('1')
|
||||||
|
const numMax = ref('100')
|
||||||
|
const numCount = ref('1')
|
||||||
|
const numUnique = ref(true)
|
||||||
|
const numSorted = ref(false)
|
||||||
|
const numbers = ref('')
|
||||||
|
const error2 = ref('')
|
||||||
|
|
||||||
|
function genNumbers() {
|
||||||
|
error2.value = ''
|
||||||
|
numbers.value = ''
|
||||||
|
const min = Number(numMin.value.trim())
|
||||||
|
const max = Number(numMax.value.trim())
|
||||||
|
const count = Number(numCount.value.trim())
|
||||||
|
if (!Number.isInteger(min) || !Number.isInteger(max)) {
|
||||||
|
error2.value = '范围需为整数'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (min > max) {
|
||||||
|
error2.value = '最小值不能大于最大值'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(count) || count < 1 || count > 1000) {
|
||||||
|
error2.value = '数量需为 1 ~ 1000 的整数'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const span = max - min + 1
|
||||||
|
if (span > 1e9) {
|
||||||
|
error2.value = '范围跨度不能超过 10 亿'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (numUnique.value && count > span) {
|
||||||
|
error2.value = `不重复模式下,范围内只有 ${span} 个整数,无法生成 ${count} 个`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const out: number[] = []
|
||||||
|
if (numUnique.value) {
|
||||||
|
// 小范围用洗牌取样,大范围用 Set 拒绝采样,两种路径都是无偏的
|
||||||
|
if (span <= 100000) {
|
||||||
|
const pool = Array.from({ length: span }, (_, i) => min + i)
|
||||||
|
for (let i = pool.length - 1; i > 0; i--) {
|
||||||
|
const j = secureRandInt(i + 1)
|
||||||
|
;[pool[i], pool[j]] = [pool[j], pool[i]]
|
||||||
|
}
|
||||||
|
out.push(...pool.slice(0, count))
|
||||||
|
} else {
|
||||||
|
const seen = new Set<number>()
|
||||||
|
while (out.length < count) {
|
||||||
|
const v = min + secureRandInt(span)
|
||||||
|
if (!seen.has(v)) {
|
||||||
|
seen.add(v)
|
||||||
|
out.push(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < count; i++) out.push(min + secureRandInt(span))
|
||||||
|
}
|
||||||
|
if (numSorted.value) out.sort((a, b) => a - b)
|
||||||
|
numbers.value = out.join(',')
|
||||||
|
history.add(
|
||||||
|
JSON.stringify({
|
||||||
|
tab: 'number',
|
||||||
|
min: numMin.value.trim(),
|
||||||
|
max: numMax.value.trim(),
|
||||||
|
count: numCount.value.trim(),
|
||||||
|
unique: numUnique.value,
|
||||||
|
sorted: numSorted.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHistorySelect(text: string) {
|
||||||
|
// 历史内容为 JSON,按 tab 恢复对应面板输入;非 JSON 旧数据忽略
|
||||||
|
try {
|
||||||
|
const h = JSON.parse(text) as Partial<RandomHistory> | null
|
||||||
|
if (!h || typeof h.tab !== 'string') return
|
||||||
|
if (h.tab === 'pick' && typeof h.options === 'string') {
|
||||||
|
tab.value = 'pick'
|
||||||
|
options.value = h.options
|
||||||
|
excluded.clear()
|
||||||
|
picked.value = ''
|
||||||
|
} else if (h.tab === 'number') {
|
||||||
|
tab.value = 'number'
|
||||||
|
if (typeof h.min === 'string') numMin.value = h.min
|
||||||
|
if (typeof h.max === 'string') numMax.value = h.max
|
||||||
|
if (typeof h.count === 'string') numCount.value = h.count
|
||||||
|
if (typeof h.unique === 'boolean') numUnique.value = h.unique
|
||||||
|
if (typeof h.sorted === 'boolean') numSorted.value = h.sorted
|
||||||
|
genNumbers()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略无法解析的历史内容
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-label {
|
||||||
|
width: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
.num-input {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
.num-sep {
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
.picked-box {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
278
src/views/random-string/RandomStringView.vue
Normal file
278
src/views/random-string/RandomStringView.vue
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">{{ t('page.random-string.title') }}</h2>
|
||||||
|
<p class="page-desc">{{ t('page.random-string.desc') }}</p>
|
||||||
|
|
||||||
|
<!-- ==================== 生成参数 ==================== -->
|
||||||
|
<section class="panel tool-section">
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label">{{ t('page.random-string.modeLabel') }}</span>
|
||||||
|
<t-radio-group v-model="mode" variant="default-filled">
|
||||||
|
<t-radio-button value="local">{{ t('page.random-string.modeLocal') }}</t-radio-button>
|
||||||
|
<t-radio-button value="server">{{ t('page.random-string.modeServer') }}</t-radio-button>
|
||||||
|
</t-radio-group>
|
||||||
|
</div>
|
||||||
|
<p class="mode-hint">{{ mode === 'local' ? t('page.common.localHint') : t('page.common.serverHint') }}</p>
|
||||||
|
|
||||||
|
<div class="form-row param-row">
|
||||||
|
<span class="form-label">{{ t('page.random-string.length') }}</span>
|
||||||
|
<t-slider
|
||||||
|
v-model="length"
|
||||||
|
:min="1"
|
||||||
|
:max="maxLength"
|
||||||
|
:step="1"
|
||||||
|
:label="false"
|
||||||
|
class="length-slider"
|
||||||
|
/>
|
||||||
|
<t-input-number
|
||||||
|
v-model="length"
|
||||||
|
:min="1"
|
||||||
|
:max="maxLength"
|
||||||
|
:step="1"
|
||||||
|
theme="normal"
|
||||||
|
class="num-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row param-row">
|
||||||
|
<span class="form-label">{{ t('page.random-string.type') }}</span>
|
||||||
|
<t-radio-group v-model="type" variant="default-filled">
|
||||||
|
<t-radio-button value="alnum">{{ t('page.random-string.typeAlnum') }}</t-radio-button>
|
||||||
|
<t-radio-button value="digits">{{ t('page.random-string.typeDigits') }}</t-radio-button>
|
||||||
|
<t-radio-button value="letters">{{ t('page.random-string.typeLetters') }}</t-radio-button>
|
||||||
|
</t-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row param-row">
|
||||||
|
<span class="form-label">{{ t('page.random-string.count') }}</span>
|
||||||
|
<t-input-number
|
||||||
|
v-model="count"
|
||||||
|
:min="1"
|
||||||
|
:max="maxCount"
|
||||||
|
:step="1"
|
||||||
|
theme="normal"
|
||||||
|
class="num-input"
|
||||||
|
/>
|
||||||
|
<span v-if="mode === 'server'" class="count-hint">{{ t('page.random-string.serverRangeHint') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button theme="primary" :disabled="loading" @click="generate">
|
||||||
|
<template #icon><RefreshIcon /></template>
|
||||||
|
{{ loading ? t('common.loading') : t('page.random-string.generate') }}
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ErrorAlert :message="error" @close="error = ''" />
|
||||||
|
|
||||||
|
<!-- ==================== 结果 ==================== -->
|
||||||
|
<section v-if="results.length" class="panel tool-section">
|
||||||
|
<header class="result-head">
|
||||||
|
<h3 class="result-title">{{ t('page.random-string.resultTitle') }}</h3>
|
||||||
|
<CopyButton :text="allText" :label="t('common.copyAll')" />
|
||||||
|
</header>
|
||||||
|
<ul class="result-list">
|
||||||
|
<li v-for="(value, index) in results" :key="`${index}-${value}`" class="result-row">
|
||||||
|
<span class="result-index mono-num">{{ index + 1 }}</span>
|
||||||
|
<span class="result-value mono-num">{{ value }}</span>
|
||||||
|
<t-button variant="text" size="small" shape="square" :title="t('common.copy')" @click="copyText(value)">
|
||||||
|
<template #icon><CopyIcon /></template>
|
||||||
|
</t-button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="strength-hint">{{ t('page.random-string.strengthHint') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<HistoryPanel
|
||||||
|
:items="history.items"
|
||||||
|
@select="onHistorySelect"
|
||||||
|
@remove="history.remove"
|
||||||
|
@clear="history.clear"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { RefreshIcon, CopyIcon } 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 { useHistory } from '@/composables/useHistory'
|
||||||
|
import { copyText } from '@/composables/useClipboard'
|
||||||
|
import { fetchRandomString, RANDOM_MAX_LENGTH } from '@/api/openTools'
|
||||||
|
import type { RandomType } from '@/api/openTools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 随机字符串生成。
|
||||||
|
*
|
||||||
|
* 两种来源并存是有意义的:
|
||||||
|
* - 本机(Web Crypto):零请求、可离线、可一次生成很多条,日常够用。
|
||||||
|
* - 服务端(Go crypto/rand):当需要「与业务系统同一随机源」或做接口联调时使用,
|
||||||
|
* 也能顺带验证前后端链路是否通畅。
|
||||||
|
*
|
||||||
|
* 服务端接口单次只返回一个字符串,所以服务端模式下的数量上限更低,
|
||||||
|
* 界面上会显示这条限制,而不是让用户填了 50 再等 50 个请求。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
const history = useHistory('random-string')
|
||||||
|
|
||||||
|
/** 生成来源 */
|
||||||
|
const mode = ref<'local' | 'server'>('local')
|
||||||
|
const length = ref(24)
|
||||||
|
const type = ref<RandomType>('alnum')
|
||||||
|
const count = ref(5)
|
||||||
|
const results = ref<string[]>([])
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const maxLength = RANDOM_MAX_LENGTH
|
||||||
|
/** 服务端模式每个字符串都是一次请求,上限压到 10,避免一次打出几十个请求 */
|
||||||
|
const maxCount = computed(() => (mode.value === 'server' ? 10 : 50))
|
||||||
|
|
||||||
|
/** 本机生成的字符集(与后端 alnum / digits / letters 的语义保持一致) */
|
||||||
|
const CHARSETS: Record<RandomType, string> = {
|
||||||
|
alnum: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
|
||||||
|
digits: '0123456789',
|
||||||
|
letters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本机生成:用 crypto.getRandomValues 取 32 位随机数后按字符集长度取模。
|
||||||
|
* 取模会有极小的偏置(字符集最大 62,偏置量级约 2^-26),
|
||||||
|
* 对「生成随机密码/串」这类用途完全可以忽略;若要做密码学用途请走服务端模式。
|
||||||
|
*/
|
||||||
|
function randomLocal(len: number, charset: string): string {
|
||||||
|
const size = Math.max(1, Math.min(RANDOM_MAX_LENGTH, Math.floor(len)))
|
||||||
|
const buffer = new Uint32Array(size)
|
||||||
|
crypto.getRandomValues(buffer)
|
||||||
|
let out = ''
|
||||||
|
for (let i = 0; i < size; i++) out += charset[buffer[i] % charset.length]
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const allText = computed(() => results.value.join('\n'))
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
error.value = ''
|
||||||
|
results.value = []
|
||||||
|
const len = Math.max(1, Math.min(maxLength, Math.floor(length.value) || 1))
|
||||||
|
const num = Math.max(1, Math.min(maxCount.value, Math.floor(count.value) || 1))
|
||||||
|
|
||||||
|
if (mode.value === 'local') {
|
||||||
|
results.value = Array.from({ length: num }, () => randomLocal(len, CHARSETS[type.value]))
|
||||||
|
} else {
|
||||||
|
loading.value = true
|
||||||
|
// 服务端接口一次只返回一个值,并发请求 num 次;任一失败即整体报错
|
||||||
|
const responses = await Promise.all(
|
||||||
|
Array.from({ length: num }, () => fetchRandomString(len, type.value)),
|
||||||
|
)
|
||||||
|
loading.value = false
|
||||||
|
const failed = responses.find((r) => !r.ok)
|
||||||
|
if (failed) {
|
||||||
|
error.value = failed.message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results.value = responses.map((r) => r.data?.value ?? '').filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
history.add(allText.value.slice(0, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从历史恢复:历史上限 300 字符,可能被截断,按行拆开后仍可正常展示 */
|
||||||
|
function onHistorySelect(text: string) {
|
||||||
|
results.value = text
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrap {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
.param-row {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.mode-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.length-slider {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.num-input {
|
||||||
|
width: 132px;
|
||||||
|
}
|
||||||
|
.count-hint {
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 结果 ---------- */
|
||||||
|
.result-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.result-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--tk-text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
|
.result-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.result-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 8px 6px 10px;
|
||||||
|
border: 1px solid var(--tk-border);
|
||||||
|
border-radius: var(--tk-radius-md);
|
||||||
|
background: var(--tk-surface-sunken);
|
||||||
|
}
|
||||||
|
.result-index {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.result-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--tk-text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.strength-hint {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.length-slider {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.num-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -213,7 +213,7 @@ function onHistorySelect(raw: string) {
|
|||||||
}
|
}
|
||||||
.hint-text {
|
.hint-text {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.no-match {
|
.no-match {
|
||||||
@ -252,7 +252,7 @@ function onHistorySelect(raw: string) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--td-bg-color-secondarycontainer);
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-secondary);
|
color: var(--td-text-color-secondary);
|
||||||
}
|
}
|
||||||
.match-text {
|
.match-text {
|
||||||
@ -264,7 +264,7 @@ function onHistorySelect(raw: string) {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
.match-pos {
|
.match-pos {
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.match-groups {
|
.match-groups {
|
||||||
@ -275,7 +275,7 @@ function onHistorySelect(raw: string) {
|
|||||||
}
|
}
|
||||||
.group-tag {
|
.group-tag {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--td-bg-color-secondarycontainer);
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
|
|||||||
368
src/views/regex-visual/RegexVisualView.vue
Normal file
368
src/views/regex-visual/RegexVisualView.vue
Normal 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: var(--tk-text-xs);
|
||||||
|
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: var(--tk-text-xs);
|
||||||
|
}
|
||||||
|
.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: var(--tk-text-xs);
|
||||||
|
}
|
||||||
|
.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>
|
||||||
417
src/views/speed-test/SpeedTestView.vue
Normal file
417
src/views/speed-test/SpeedTestView.vue
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<h2 class="page-title">{{ t('tool.speed-test.name') }}</h2>
|
||||||
|
<p class="page-desc">{{ t('tool.speed-test.desc') }}</p>
|
||||||
|
|
||||||
|
<BackendPending :message="t('pending.speedTest')" />
|
||||||
|
|
||||||
|
<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 || !BACKEND_SPEEDTEST_READY" @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 { BASE_URL } from '@/api/client'
|
||||||
|
import BackendPending from '@/components/common/BackendPending.vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
/** 页面标题与说明改由语言包提供 */
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端测速接口是否已实现。
|
||||||
|
*
|
||||||
|
* service.xpcool.com 的开放接口目前只有 ip / time / uuid / md5 / random 五个,
|
||||||
|
* 下载与上传测速接口尚未实现,因此这里固定为 false:
|
||||||
|
* - 下载测速会自动改用公网测速源(受对方 CORS 限制)
|
||||||
|
* - 上传测速按钮保持禁用,并在页面上说明原因
|
||||||
|
* 后端实现 /tools/speedtest/* 后把这里改成 true 即可,无需其它改动。
|
||||||
|
*/
|
||||||
|
const BACKEND_SPEEDTEST_READY = false
|
||||||
|
|
||||||
|
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' && !BACKEND_SPEEDTEST_READY) {
|
||||||
|
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 (!BACKEND_SPEEDTEST_READY) {
|
||||||
|
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() || (BACKEND_SPEEDTEST_READY ? `${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>
|
||||||
355
src/views/sql-format/SqlFormatView.vue
Normal file
355
src/views/sql-format/SqlFormatView.vue
Normal 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>
|
||||||
319
src/views/text-diff/TextDiffView.vue
Normal file
319
src/views/text-diff/TextDiffView.vue
Normal 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
|
||||||
|
// DP:dp[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>
|
||||||
@ -1,32 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
<h2 class="page-title">时间戳转换</h2>
|
<h2 class="page-title">{{ t('page.timestamp.title') }}</h2>
|
||||||
<p class="page-desc">时间戳与北京时间(UTC+8)互转,支持秒/毫秒与批量转换。</p>
|
<p class="page-desc">{{ t('page.timestamp.desc') }}</p>
|
||||||
|
|
||||||
|
<!-- ==================== 服务端时钟 ====================
|
||||||
|
放在最上方:既能当"现在几点"用,也能顺带验证前后端链路。 -->
|
||||||
|
<section class="panel tool-section clock-panel">
|
||||||
|
<div class="clock-grid">
|
||||||
|
<div class="clock-cell">
|
||||||
|
<span class="clock-label">{{ t('page.timestamp.serverTimeLabel') }}</span>
|
||||||
|
<span class="clock-value mono-num">{{ serverDateTime || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="clock-cell">
|
||||||
|
<span class="clock-label">{{ t('page.timestamp.serverOffset') }}</span>
|
||||||
|
<span class="clock-value mono-num">{{ offsetText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="serverError" class="clock-verdict is-warn">
|
||||||
|
{{ t('page.timestamp.serverFailed', { message: serverError }) }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="skewState" class="clock-verdict" :class="skewState.ok ? 'is-ok' : 'is-warn'">
|
||||||
|
{{ skewState.text }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button variant="outline" :disabled="serverLoading" @click="readServerTime">
|
||||||
|
<template #icon><RefreshIcon /></template>
|
||||||
|
{{ serverLoading ? t('common.loading') : t('page.timestamp.serverNow') }}
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<t-tabs v-model="tab">
|
<t-tabs v-model="tab">
|
||||||
<!-- 时间戳 -> 时间 -->
|
<!-- 时间戳 -> 时间 -->
|
||||||
<t-tab-panel value="ts2date" label="时间戳 → 时间">
|
<t-tab-panel value="ts2date" :label="t('page.timestamp.tabTs2Date')">
|
||||||
<t-card class="tool-section" title="转换">
|
<section class="panel tool-section">
|
||||||
<t-input v-model="tsInput" placeholder="输入时间戳,例如 1704067200" clearable />
|
<h3 class="section-title">{{ t('page.timestamp.convertTitle') }}</h3>
|
||||||
|
<t-input v-model="tsInput" :placeholder="t('page.timestamp.tsPlaceholder')" clearable />
|
||||||
<div class="form-row" style="margin-top: 12px">
|
<div class="form-row" style="margin-top: 12px">
|
||||||
<span class="form-label">单位</span>
|
<span class="form-label">{{ t('page.timestamp.unit') }}</span>
|
||||||
<t-select v-model="unit" :options="unitOptions" class="form-select" />
|
<t-select v-model="unit" :options="unitOptions" class="form-select" />
|
||||||
<t-button variant="outline" @click="fillNow">当前时间</t-button>
|
<t-button variant="outline" @click="fillNow">{{ t('page.timestamp.now') }}</t-button>
|
||||||
<t-button theme="primary" @click="convertOne">转换</t-button>
|
<t-button theme="primary" @click="convertOne">{{ t('page.timestamp.convert') }}</t-button>
|
||||||
</div>
|
</div>
|
||||||
</t-card>
|
</section>
|
||||||
<ErrorAlert :message="error" @close="error = ''" />
|
<ErrorAlert :message="error" @close="error = ''" />
|
||||||
<t-card v-if="oneResult" class="tool-section" title="转换结果">
|
<section v-if="oneResult" class="panel tool-section">
|
||||||
|
<h3 class="section-title">{{ t('page.timestamp.resultTitle') }}</h3>
|
||||||
<div class="code-block">{{ oneResult }}</div>
|
<div class="code-block">{{ oneResult }}</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<CopyButton :text="oneResult" />
|
<CopyButton :text="oneResult" :label="t('page.common.copyResult')" />
|
||||||
</div>
|
</div>
|
||||||
</t-card>
|
</section>
|
||||||
</t-tab-panel>
|
</t-tab-panel>
|
||||||
|
|
||||||
<!-- 时间 -> 时间戳 -->
|
<!-- 时间 -> 时间戳 -->
|
||||||
<t-tab-panel value="date2ts" label="时间 → 时间戳">
|
<t-tab-panel value="date2ts" :label="t('page.timestamp.tabDate2Ts')">
|
||||||
<t-card class="tool-section" title="选择北京时间">
|
<section class="panel tool-section">
|
||||||
|
<h3 class="section-title">{{ t('page.timestamp.pickTitle') }}</h3>
|
||||||
<t-date-picker
|
<t-date-picker
|
||||||
v-model="dateValue"
|
v-model="dateValue"
|
||||||
enable-time-picker
|
enable-time-picker
|
||||||
@ -34,47 +66,50 @@
|
|||||||
clearable
|
clearable
|
||||||
format="YYYY-MM-DD HH:mm:ss"
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
value-type="YYYY-MM-DD HH:mm:ss"
|
value-type="YYYY-MM-DD HH:mm:ss"
|
||||||
placeholder="选择日期时间"
|
:placeholder="t('page.timestamp.pickPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<div class="form-row" style="margin-top: 12px">
|
<div class="form-row" style="margin-top: 12px">
|
||||||
<span class="form-label">单位</span>
|
<span class="form-label">{{ t('page.timestamp.unit') }}</span>
|
||||||
<t-select v-model="unit2" :options="unitOptions" class="form-select" />
|
<t-select v-model="unit2" :options="unitOptions" class="form-select" />
|
||||||
<t-button theme="primary" @click="convertDate">转换</t-button>
|
<t-button theme="primary" @click="convertDate">{{ t('page.timestamp.convert') }}</t-button>
|
||||||
</div>
|
</div>
|
||||||
</t-card>
|
</section>
|
||||||
<ErrorAlert :message="error2" @close="error2 = ''" />
|
<ErrorAlert :message="error2" @close="error2 = ''" />
|
||||||
<t-card v-if="dateResult" class="tool-section" title="转换结果">
|
<section v-if="dateResult" class="panel tool-section">
|
||||||
|
<h3 class="section-title">{{ t('page.timestamp.resultTitle') }}</h3>
|
||||||
<div class="code-block">{{ dateResult }}</div>
|
<div class="code-block">{{ dateResult }}</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<CopyButton :text="dateResult" />
|
<CopyButton :text="dateResult" :label="t('page.common.copyResult')" />
|
||||||
</div>
|
</div>
|
||||||
</t-card>
|
</section>
|
||||||
</t-tab-panel>
|
</t-tab-panel>
|
||||||
|
|
||||||
<!-- 批量转换 -->
|
<!-- 批量转换 -->
|
||||||
<t-tab-panel value="batch" label="批量转换">
|
<t-tab-panel value="batch" :label="t('page.timestamp.tabBatch')">
|
||||||
<t-card class="tool-section" title="批量时间戳 → 北京时间">
|
<section class="panel tool-section">
|
||||||
|
<h3 class="section-title">{{ t('page.timestamp.batchInputTitle') }}</h3>
|
||||||
<t-textarea
|
<t-textarea
|
||||||
v-model="batchInput"
|
v-model="batchInput"
|
||||||
placeholder="每行一个时间戳,例如: 1704067200 1704067200000"
|
:placeholder="t('page.timestamp.batchInputPlaceholder')"
|
||||||
:autosize="{ minRows: 6, maxRows: 12 }"
|
:autosize="{ minRows: 6, maxRows: 12 }"
|
||||||
/>
|
/>
|
||||||
<div class="form-row" style="margin-top: 12px">
|
<div class="form-row" style="margin-top: 12px">
|
||||||
<span class="form-label">单位</span>
|
<span class="form-label">{{ t('page.timestamp.unit') }}</span>
|
||||||
<t-select v-model="unit3" :options="unitOptions" class="form-select" />
|
<t-select v-model="unit3" :options="unitOptions" class="form-select" />
|
||||||
<t-button theme="primary" @click="convertBatch">批量转换</t-button>
|
<t-button theme="primary" @click="convertBatch">{{ t('page.timestamp.batchConvert') }}</t-button>
|
||||||
</div>
|
</div>
|
||||||
</t-card>
|
</section>
|
||||||
<t-card v-if="batchResult" class="tool-section" title="批量结果">
|
<section v-if="batchResult" class="panel tool-section">
|
||||||
|
<h3 class="section-title">{{ t('page.timestamp.batchResultTitle') }}</h3>
|
||||||
<t-textarea v-model="batchResult" :autosize="{ minRows: 6, maxRows: 16 }" readonly />
|
<t-textarea v-model="batchResult" :autosize="{ minRows: 6, maxRows: 16 }" readonly />
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<CopyButton :text="batchResult" />
|
<CopyButton :text="batchResult" :label="t('page.common.copyResult')" />
|
||||||
<t-button variant="outline" size="small" @click="downloadBatch">
|
<t-button variant="outline" size="small" @click="downloadBatch">
|
||||||
<template #icon><DownloadIcon /></template>
|
<template #icon><DownloadIcon /></template>
|
||||||
下载
|
{{ t('page.timestamp.download') }}
|
||||||
</t-button>
|
</t-button>
|
||||||
</div>
|
</div>
|
||||||
</t-card>
|
</section>
|
||||||
</t-tab-panel>
|
</t-tab-panel>
|
||||||
</t-tabs>
|
</t-tabs>
|
||||||
|
|
||||||
@ -88,32 +123,98 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
/**
|
||||||
import { DownloadIcon } from 'tdesign-icons-vue-next'
|
* 时间戳转换。
|
||||||
|
*
|
||||||
|
* 转换本身是纯本地计算(Date + UTC 换算),不打网络请求。
|
||||||
|
* 唯一联网的地方是顶部的「服务端时钟」:它解决两个真实痛点 ——
|
||||||
|
* 1. 想知道"标准时间"是多少,而本机时钟可能不准;
|
||||||
|
* 2. 想确认前后端链路通不通(open/tools/time 是最轻量的探针)。
|
||||||
|
*
|
||||||
|
* 偏移量用 Unix 秒计算,因此与服务端所在时区无关;只有展示用的
|
||||||
|
* 日期字符串会受服务端时区影响,界面上不做推断,原样呈现。
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { DownloadIcon, RefreshIcon } from 'tdesign-icons-vue-next'
|
||||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||||
import CopyButton from '@/components/common/CopyButton.vue'
|
import CopyButton from '@/components/common/CopyButton.vue'
|
||||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||||
import { useHistory } from '@/composables/useHistory'
|
import { useHistory } from '@/composables/useHistory'
|
||||||
import { downloadText } from '@/composables/useDownload'
|
import { downloadText } from '@/composables/useDownload'
|
||||||
import { formatBeijing, genFilename } from '@/utils'
|
import { formatBeijing, genFilename } from '@/utils'
|
||||||
|
import { fetchServerTime } from '@/api/openTools'
|
||||||
|
|
||||||
const unitOptions = [
|
const { t } = useI18n()
|
||||||
{ label: '秒', value: 's' },
|
const history = useHistory('timestamp')
|
||||||
{ label: '毫秒', value: 'ms' },
|
|
||||||
]
|
/** 单位选项跟随语言切换,所以用 computed 而不是常量 */
|
||||||
|
const unitOptions = computed(() => [
|
||||||
|
{ label: t('page.timestamp.unitSecond'), value: 's' },
|
||||||
|
{ label: t('page.timestamp.unitMillisecond'), value: 'ms' },
|
||||||
|
])
|
||||||
|
|
||||||
const tab = ref('ts2date')
|
const tab = ref('ts2date')
|
||||||
|
|
||||||
|
/* ---------- 服务端时钟 ---------- */
|
||||||
|
const serverDateTime = ref('')
|
||||||
|
const serverTimestamp = ref<number | null>(null)
|
||||||
|
const offsetSeconds = ref<number | null>(null)
|
||||||
|
const serverLoading = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
/** 超过该秒数就认为本机时钟明显不准,需要提示用户 */
|
||||||
|
const CLOCK_SKEW_TOLERANCE = 2
|
||||||
|
|
||||||
|
const offsetText = computed(() => {
|
||||||
|
if (offsetSeconds.value === null) return '—'
|
||||||
|
const v = offsetSeconds.value
|
||||||
|
return `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(2)} s`
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 时钟偏差结论;未读取过服务端时间时不显示 */
|
||||||
|
const skewState = computed(() => {
|
||||||
|
if (offsetSeconds.value === null) return null
|
||||||
|
const abs = Math.abs(offsetSeconds.value)
|
||||||
|
if (abs <= CLOCK_SKEW_TOLERANCE) return { ok: true, text: t('page.timestamp.clockSkewOk') }
|
||||||
|
return { ok: false, text: t('page.timestamp.clockSkewWarn', { seconds: abs.toFixed(2) }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function readServerTime() {
|
||||||
|
serverLoading.value = true
|
||||||
|
serverError.value = ''
|
||||||
|
const t0 = Date.now()
|
||||||
|
const res = await fetchServerTime()
|
||||||
|
const t1 = Date.now()
|
||||||
|
serverLoading.value = false
|
||||||
|
|
||||||
|
if (!res.ok || !res.data) {
|
||||||
|
serverError.value = res.message
|
||||||
|
serverDateTime.value = ''
|
||||||
|
serverTimestamp.value = null
|
||||||
|
offsetSeconds.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端取时刻发生在 [t0, t1] 之间,把服务端秒数换算到 t1 时点需补偿半个往返耗时,
|
||||||
|
// 这样偏差估计的误差上界是 rtt/2,而不是整个 rtt。
|
||||||
|
const rtt = t1 - t0
|
||||||
|
const serverAtT1 = res.data.timestamp * 1000 + rtt / 2
|
||||||
|
offsetSeconds.value = (serverAtT1 - t1) / 1000
|
||||||
|
serverDateTime.value = res.data.dateTime
|
||||||
|
serverTimestamp.value = res.data.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 时间戳 -> 时间 ---------- */
|
/* ---------- 时间戳 -> 时间 ---------- */
|
||||||
const tsInput = ref('')
|
const tsInput = ref('')
|
||||||
const unit = ref('s')
|
const unit = ref('s')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const oneResult = ref('')
|
const oneResult = ref('')
|
||||||
|
|
||||||
const history = useHistory('timestamp')
|
|
||||||
|
|
||||||
function fillNow() {
|
function fillNow() {
|
||||||
tsInput.value = String(unit.value === 's' ? Math.floor(Date.now() / 1000) : Date.now())
|
// 已经读过服务端时间时优先用服务端秒数,填进去的才是"准的现在"
|
||||||
|
const base = serverTimestamp.value !== null ? serverTimestamp.value * 1000 : Date.now()
|
||||||
|
tsInput.value = String(unit.value === 's' ? Math.floor(base / 1000) : base)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将输入按单位转换为时间戳(毫秒) */
|
/** 将输入按单位转换为时间戳(毫秒) */
|
||||||
@ -128,15 +229,15 @@ function convertOne() {
|
|||||||
oneResult.value = ''
|
oneResult.value = ''
|
||||||
const ts = parseTs(tsInput.value, unit.value)
|
const ts = parseTs(tsInput.value, unit.value)
|
||||||
if (ts === null) {
|
if (ts === null) {
|
||||||
error.value = '请输入合法的数字时间戳'
|
error.value = t('page.timestamp.errInvalidTs')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const d = new Date(ts)
|
const d = new Date(ts)
|
||||||
if (Number.isNaN(d.getTime())) {
|
if (Number.isNaN(d.getTime())) {
|
||||||
error.value = '时间戳超出可表示范围'
|
error.value = t('page.timestamp.errOutOfRange')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
oneResult.value = `北京时间:${formatBeijing(d, true)}\n本地时间:${d.toLocaleString()}`
|
oneResult.value = `${t('page.timestamp.beijing')}:${formatBeijing(d, true)}\n${t('page.timestamp.localTime')}:${d.toLocaleString()}`
|
||||||
history.add(tsInput.value)
|
history.add(tsInput.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,14 +252,16 @@ function convertDate() {
|
|||||||
dateResult.value = ''
|
dateResult.value = ''
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}):(\d{2}))?$/.exec(dateValue.value.trim())
|
const m = /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}):(\d{2}))?$/.exec(dateValue.value.trim())
|
||||||
if (!m) {
|
if (!m) {
|
||||||
error2.value = '请选择合法的日期时间'
|
error2.value = t('page.timestamp.errInvalidDate')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [y, mo, d, h = 0, mi = 0, s = 0] = m.slice(1).map(Number)
|
const [y, mo, d, h = 0, mi = 0, s = 0] = m.slice(1).map(Number)
|
||||||
// 将输入的北京时间按 UTC+8 换算为 UTC 毫秒时间戳
|
// 将输入的北京时间按 UTC+8 换算为 UTC 毫秒时间戳
|
||||||
const ts = Date.UTC(y, mo - 1, d, h, mi, s) - 8 * 3600 * 1000
|
const ts = Date.UTC(y, mo - 1, d, h, mi, s) - 8 * 3600 * 1000
|
||||||
dateResult.value =
|
dateResult.value =
|
||||||
unit2.value === 's' ? `${Math.floor(ts / 1000)}(秒)` : `${ts}(毫秒)`
|
unit2.value === 's'
|
||||||
|
? `${Math.floor(ts / 1000)}${t('page.timestamp.secondSuffix')}`
|
||||||
|
: `${ts}${t('page.timestamp.millisecondSuffix')}`
|
||||||
history.add(dateValue.value)
|
history.add(dateValue.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,13 +274,13 @@ function convertBatch() {
|
|||||||
batchResult.value = batchInput.value
|
batchResult.value = batchInput.value
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
const t = line.trim()
|
const raw = line.trim()
|
||||||
if (!t) return ''
|
if (!raw) return ''
|
||||||
const ts = parseTs(t, unit3.value)
|
const ts = parseTs(raw, unit3.value)
|
||||||
if (ts === null) return `${t} => 【无效时间戳】`
|
if (ts === null) return `${raw} => ${t('page.timestamp.invalidTs')}`
|
||||||
const d = new Date(ts)
|
const d = new Date(ts)
|
||||||
if (Number.isNaN(d.getTime())) return `${t} => 【超出范围】`
|
if (Number.isNaN(d.getTime())) return `${raw} => ${t('page.timestamp.outOfRange')}`
|
||||||
return `${t} => ${formatBeijing(d, true)}`
|
return `${raw} => ${formatBeijing(d, true)}`
|
||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
}
|
}
|
||||||
@ -201,24 +304,68 @@ function onHistorySelect(text: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.form-label {
|
.section-title {
|
||||||
width: 56px;
|
margin: 0 0 12px;
|
||||||
flex-shrink: 0;
|
font-size: var(--tk-text-md);
|
||||||
font-size: 14px;
|
font-weight: 600;
|
||||||
color: var(--td-text-color-secondary);
|
color: var(--tk-text);
|
||||||
}
|
}
|
||||||
.form-select {
|
.form-select {
|
||||||
width: 110px;
|
width: 110px;
|
||||||
}
|
}
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
/* ---------- 服务端时钟 ---------- */
|
||||||
gap: 8px;
|
.clock-panel {
|
||||||
margin-top: 16px;
|
/* 用青玉底纹和普通工具区块区分开:这一块是"服务端信息"而非本地计算 */
|
||||||
flex-wrap: wrap;
|
background: var(--tk-accent-surface);
|
||||||
|
border-color: var(--tk-accent-ring);
|
||||||
}
|
}
|
||||||
|
.clock-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--tk-space-4);
|
||||||
|
}
|
||||||
|
.clock-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.clock-label {
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-secondary);
|
||||||
|
}
|
||||||
|
.clock-value {
|
||||||
|
font-size: var(--tk-text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--tk-text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.clock-verdict {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
}
|
||||||
|
.clock-verdict.is-ok {
|
||||||
|
color: var(--tk-success);
|
||||||
|
}
|
||||||
|
.clock-verdict.is-warn {
|
||||||
|
color: var(--tk-warning);
|
||||||
|
}
|
||||||
|
.clock-panel .form-actions {
|
||||||
|
margin-top: var(--tk-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
/* 日期选择器:窄屏下占满可用宽度,避免固定宽度撑出横向滚动条 */
|
/* 日期选择器:窄屏下占满可用宽度,避免固定宽度撑出横向滚动条 */
|
||||||
:deep(.t-date-picker) {
|
:deep(.t-date-picker) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 340px;
|
max-width: 340px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.clock-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--tk-space-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -695,7 +695,7 @@ function clearAll() {
|
|||||||
|
|
||||||
.char-count {
|
.char-count {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@ -753,12 +753,12 @@ function clearAll() {
|
|||||||
}
|
}
|
||||||
.dict-example {
|
.dict-example {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-secondary);
|
color: var(--td-text-color-secondary);
|
||||||
}
|
}
|
||||||
.dict-src {
|
.dict-src {
|
||||||
margin: 10px 0 0;
|
margin: 10px 0 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -782,7 +782,7 @@ function clearAll() {
|
|||||||
}
|
}
|
||||||
.s-hint {
|
.s-hint {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -796,7 +796,7 @@ function clearAll() {
|
|||||||
}
|
}
|
||||||
.attr {
|
.attr {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -242,7 +242,7 @@ function onHistorySelect(text: string) {
|
|||||||
background: var(--td-bg-color-secondarycontainer);
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
}
|
}
|
||||||
.info-label {
|
.info-label {
|
||||||
font-size: 12px;
|
font-size: var(--tk-text-xs);
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
}
|
}
|
||||||
.info-value {
|
.info-value {
|
||||||
|
|||||||
@ -1,36 +1,66 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
<h2 class="page-title">UUID 生成器</h2>
|
<h2 class="page-title">{{ t('page.uuid.title') }}</h2>
|
||||||
<p class="page-desc">批量生成 RFC 4122 版本 4 的随机 UUID(基于浏览器安全随机数),支持大写与无连字符格式。</p>
|
<p class="page-desc">{{ t('page.uuid.desc') }}</p>
|
||||||
|
|
||||||
<t-card class="tool-section" title="生成选项">
|
<!-- ==================== 生成选项 ==================== -->
|
||||||
|
<section class="panel tool-section">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<span class="form-label">数量</span>
|
<span class="form-label">{{ t('page.uuid.modeLabel') }}</span>
|
||||||
|
<t-radio-group v-model="mode" variant="default-filled">
|
||||||
|
<t-radio-button value="local">{{ t('page.uuid.modeLocal') }}</t-radio-button>
|
||||||
|
<t-radio-button value="server">{{ t('page.uuid.modeServer') }}</t-radio-button>
|
||||||
|
</t-radio-group>
|
||||||
|
</div>
|
||||||
|
<p class="mode-hint">{{ mode === 'local' ? t('page.common.localHint') : t('page.uuid.serverHint') }}</p>
|
||||||
|
|
||||||
|
<div class="form-row option-row">
|
||||||
|
<span class="form-label">{{ t('page.uuid.count') }}</span>
|
||||||
<t-input-number v-model="count" :min="1" :max="100" :step="1" theme="normal" class="count-input" />
|
<t-input-number v-model="count" :min="1" :max="100" :step="1" theme="normal" class="count-input" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row option-row">
|
|
||||||
<t-checkbox v-model="uppercase">大写</t-checkbox>
|
|
||||||
<t-checkbox v-model="noHyphen">无连字符</t-checkbox>
|
|
||||||
</div>
|
|
||||||
</t-card>
|
|
||||||
|
|
||||||
<t-card v-if="list.length" class="tool-section" title="生成结果">
|
<!-- 本机模式才有的大小写/连字符选项;服务端返回定长 ID,这两项不适用 -->
|
||||||
<div class="result-actions">
|
<div v-if="mode === 'local'" class="form-row option-row">
|
||||||
<CopyButton :text="allText" label="复制全部" />
|
<t-checkbox v-model="uppercase">{{ t('page.uuid.uppercase') }}</t-checkbox>
|
||||||
<t-button theme="primary" @click="regenerate">
|
<t-checkbox v-model="noHyphen">{{ t('page.uuid.noHyphen') }}</t-checkbox>
|
||||||
|
</div>
|
||||||
|
<!-- 服务端模式对应 short 参数:8 位短码 -->
|
||||||
|
<div v-else class="form-row option-row">
|
||||||
|
<t-checkbox v-model="short">{{ t('page.uuid.short') }}</t-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<t-button theme="primary" :disabled="loading" @click="regenerate">
|
||||||
<template #icon><RefreshIcon /></template>
|
<template #icon><RefreshIcon /></template>
|
||||||
重新生成
|
{{ loading ? t('common.loading') : t('page.uuid.regenerate') }}
|
||||||
</t-button>
|
</t-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="uuid-list">
|
</section>
|
||||||
<div v-for="(u, i) in list" :key="i" class="uuid-row">
|
|
||||||
<span class="uuid-text">{{ u }}</span>
|
<ErrorAlert :message="error" @close="error = ''" />
|
||||||
<t-button variant="text" size="small" shape="square" title="复制" @click="copyText(u)">
|
|
||||||
|
<!-- ==================== 生成结果 ==================== -->
|
||||||
|
<section v-if="list.length" class="panel tool-section">
|
||||||
|
<header class="result-head">
|
||||||
|
<h3 class="result-title">{{ t('page.uuid.resultTitle') }}</h3>
|
||||||
|
<CopyButton :text="allText" :label="t('page.uuid.copyAll')" />
|
||||||
|
</header>
|
||||||
|
<ul class="uuid-list">
|
||||||
|
<li v-for="(u, i) in list" :key="`${i}-${u}`" class="uuid-row">
|
||||||
|
<span class="uuid-index mono-num">{{ i + 1 }}</span>
|
||||||
|
<span class="uuid-text mono-num">{{ u }}</span>
|
||||||
|
<t-button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
shape="square"
|
||||||
|
:title="t('common.copy')"
|
||||||
|
@click="copyText(u)"
|
||||||
|
>
|
||||||
<template #icon><CopyIcon /></template>
|
<template #icon><CopyIcon /></template>
|
||||||
</t-button>
|
</t-button>
|
||||||
</div>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
</t-card>
|
</section>
|
||||||
|
|
||||||
<HistoryPanel
|
<HistoryPanel
|
||||||
:items="history.items"
|
:items="history.items"
|
||||||
@ -42,20 +72,38 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// UUID 生成器:基于 Web Crypto 安全随机数生成 RFC 4122 v4 UUID,支持批量、大写、无连字符与历史恢复。
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { CopyIcon, RefreshIcon } from 'tdesign-icons-vue-next'
|
import { CopyIcon, RefreshIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||||
import CopyButton from '@/components/common/CopyButton.vue'
|
import CopyButton from '@/components/common/CopyButton.vue'
|
||||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||||
import { useHistory } from '@/composables/useHistory'
|
import { useHistory } from '@/composables/useHistory'
|
||||||
import { copyText } from '@/composables/useClipboard'
|
import { copyText } from '@/composables/useClipboard'
|
||||||
|
import { fetchUuid } from '@/api/openTools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID 生成器,提供两种来源:
|
||||||
|
* - 本机:Web Crypto 生成标准 RFC 4122 v4,可一次批量出 100 条,支持大小写与去连字符。
|
||||||
|
* - 服务端:POST /api/service/open/tools/uuid,返回 GoFrame guid(32 位,或 8 位短码)。
|
||||||
|
*
|
||||||
|
* 两种来源的产出形态并不一样(v4 有连字符、服务端是 32 位无连字符 guid),
|
||||||
|
* 界面上按模式切换可选项,而不是让用户在服务端模式下看到无效的「大写/无连字符」开关。
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const mode = ref<'local' | 'server'>('local')
|
||||||
const count = ref(5)
|
const count = ref(5)
|
||||||
const uppercase = ref(false)
|
const uppercase = ref(false)
|
||||||
const noHyphen = ref(false)
|
const noHyphen = ref(false)
|
||||||
|
const short = ref(false)
|
||||||
const list = ref<string[]>([])
|
const list = ref<string[]>([])
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
const history = useHistory('uuid')
|
const history = useHistory('uuid')
|
||||||
|
|
||||||
|
const allText = computed(() => list.value.join('\n'))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成单个 v4 UUID:16 字节安全随机数,再写入版本位与变体位。
|
* 生成单个 v4 UUID:16 字节安全随机数,再写入版本位与变体位。
|
||||||
* 无连字符时跳过 8-4-4-4-12 的分隔符;大写按需转换(数字不受影响)。
|
* 无连字符时跳过 8-4-4-4-12 的分隔符;大写按需转换(数字不受影响)。
|
||||||
@ -73,18 +121,43 @@ function generateUuid(noHyphenFlag: boolean, upperFlag: boolean): string {
|
|||||||
return upperFlag ? hex.toUpperCase() : hex
|
return upperFlag ? hex.toUpperCase() : hex
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按当前选项生成一批 UUID;数量夹取到合法范围,防止输入框清空等边界情况 */
|
/** 数量夹取到合法范围,防止输入框清空等边界情况 */
|
||||||
function generate() {
|
function safeCount(): number {
|
||||||
const n = Number.isFinite(count.value) ? Math.min(100, Math.max(1, Math.floor(count.value))) : 1
|
const n = Number.isFinite(count.value) ? Math.floor(count.value) : 1
|
||||||
|
return Math.min(100, Math.max(1, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本机生成一批 */
|
||||||
|
function generateLocal() {
|
||||||
|
const n = safeCount()
|
||||||
list.value = Array.from({ length: n }, () => generateUuid(noHyphen.value, uppercase.value))
|
list.value = Array.from({ length: n }, () => generateUuid(noHyphen.value, uppercase.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
const allText = computed(() => list.value.join('\n'))
|
/** 服务端生成一批:接口一次返回一个,并发请求 n 次 */
|
||||||
|
async function generateServer() {
|
||||||
|
const n = safeCount()
|
||||||
|
loading.value = true
|
||||||
|
const responses = await Promise.all(Array.from({ length: n }, () => fetchUuid(short.value)))
|
||||||
|
loading.value = false
|
||||||
|
const failed = responses.find((r) => !r.ok)
|
||||||
|
if (failed) {
|
||||||
|
error.value = failed.message
|
||||||
|
list.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list.value = responses.map((r) => r.data?.uuid ?? '').filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
/** 用户主动重新生成时才写历史(超 300 字符截断),避免每次进入页面都污染历史 */
|
async function regenerate() {
|
||||||
function regenerate() {
|
error.value = ''
|
||||||
generate()
|
list.value = []
|
||||||
history.add(allText.value.slice(0, 300))
|
if (mode.value === 'local') {
|
||||||
|
generateLocal()
|
||||||
|
} else {
|
||||||
|
await generateServer()
|
||||||
|
}
|
||||||
|
// 用户主动生成时才写历史(超 300 字符截断),避免每次进入页面都污染历史
|
||||||
|
if (list.value.length) history.add(allText.value.slice(0, 300))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onHistorySelect(text: string) {
|
function onHistorySelect(text: string) {
|
||||||
@ -95,48 +168,70 @@ function onHistorySelect(text: string) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(generate)
|
onMounted(() => {
|
||||||
|
// 首屏只做本机生成,不自动打网络请求
|
||||||
|
generateLocal()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.form-label {
|
.page-wrap {
|
||||||
width: 56px;
|
max-width: 780px;
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--td-text-color-secondary);
|
|
||||||
}
|
|
||||||
.count-input {
|
|
||||||
width: 140px;
|
|
||||||
}
|
}
|
||||||
.option-row {
|
.option-row {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.result-actions {
|
.mode-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: var(--tk-text-sm);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
}
|
||||||
|
.count-input {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
.result-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
.result-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--tk-text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tk-text);
|
||||||
|
}
|
||||||
.uuid-list {
|
.uuid-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
.uuid-row {
|
.uuid-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 6px 10px;
|
padding: 6px 8px 6px 10px;
|
||||||
border: 1px solid var(--td-border-level-1-color);
|
border: 1px solid var(--tk-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--tk-radius-md);
|
||||||
background: var(--td-bg-color-container);
|
background: var(--tk-surface-sunken);
|
||||||
|
}
|
||||||
|
.uuid-index {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
font-size: var(--tk-text-xs);
|
||||||
|
color: var(--tk-text-placeholder);
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.uuid-text {
|
.uuid-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-family: monospace;
|
font-size: var(--tk-text-sm);
|
||||||
font-size: 13px;
|
line-height: 1.5;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
color: var(--td-text-color-primary);
|
color: var(--tk-text);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,18 +1,33 @@
|
|||||||
import { defineConfig, presetUno, transformerDirectives } from 'unocss'
|
import { defineConfig, presetUno, transformerDirectives } from 'unocss'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UnoCSS 配置:仅使用 presetUno(类 Tailwind 原子类),
|
* UnoCSS 配置:仅使用 presetUno(类 Tailwind 原子类)。
|
||||||
* 颜色值统一走 TDesign 的 CSS 变量,自动适配暗黑/浅色主题。
|
*
|
||||||
|
* 颜色一律走设计令牌 CSS 变量(--tk-* / --td-*),不要写死色值,
|
||||||
|
* 这样昼夜主题切换与品牌改色都只需改 tokens.scss 一处。
|
||||||
|
* 颜色写法用 arbitrary value,例如:bg-[var(--tk-surface)]
|
||||||
|
*
|
||||||
|
* 尺寸同理:字号用 text-13px / text-16px 这种「与令牌同一档位」的值,
|
||||||
|
* 不要在这里写回 12px,那是上一版「字太小」的根因。
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
presets: [presetUno()],
|
presets: [presetUno()],
|
||||||
transformers: [transformerDirectives()],
|
transformers: [transformerDirectives()],
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
// 工具页面通用容器
|
// 工具页通用容器(内容型页面统一版心)
|
||||||
'page-wrap': 'mx-auto w-full max-w-5xl',
|
'page-wrap': 'mx-auto w-full max-w-[var(--tk-content-max)]',
|
||||||
// 表单行:标签 + 控件 的通用栅格(响应式)
|
// 表单行:标签 + 控件 的通用栅格(响应式,移动端自动竖排)
|
||||||
'form-row': 'flex flex-col gap-2 md:flex-row md:items-center md:gap-4',
|
'form-row': 'flex flex-col gap-2 md:flex-row md:items-center md:gap-4',
|
||||||
// 结果代码块
|
// 结果代码块:等宽字体 + 面板块底色
|
||||||
'code-block': 'rounded-md bg-[var(--td-bg-color-secondarycontainer)] p-3 font-mono text-13px break-all whitespace-pre-wrap',
|
'code-block':
|
||||||
|
'rounded-[var(--tk-radius-md)] bg-[var(--tk-surface-sunken)] px-4 py-3.5 font-mono text-14px leading-relaxed break-all whitespace-pre-wrap',
|
||||||
|
// 通用面板(自定义区块的轻量容器)
|
||||||
|
// 自带内边距,直接写 class="panel tool-section" 即可,不必每处再补 padding。
|
||||||
|
// 本版容器重新「有形」:白底 + 实描边,与页面底(--tk-bg)拉开层次。
|
||||||
|
// 注意:页面 scoped 样式(如 .block[data-v-*])权重高于本类,需要单独覆盖时照常写即可。
|
||||||
|
'panel':
|
||||||
|
'rounded-[var(--tk-radius-xl)] border border-[var(--tk-border)] bg-[var(--tk-surface)] p-5 md:p-7',
|
||||||
|
// 说明性小字
|
||||||
|
'hint-text': 'text-13px text-[var(--tk-text-placeholder)]',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user