feat: 视觉重设计 + 中英日韩四语言 + 三态昼夜主题 + 服务端接口对接
All checks were successful
Build and Deploy (tool.xpcool.com) / build-and-deploy (push) Successful in 1m6s

视觉体系(核心是「令牌 → TDesign 桥接」)
- 新增 tokens.scss:--tk-* 设计令牌(青玉 Jade 单一强调色、冷调中性色阶、
  圆角/间距/字号/z-index/动效规范、按语言切换字体栈)
- 新增 tdesign-theme.scss:--tk-* → --td-* 桥接(含完整冷调灰阶 1-14)
  → 60+ 个未改动的存量工具页零成本继承新配色与圆角
- index.scss 重写:只过渡颜色、统一 focus-visible、reduced-motion 降级
- 新增 v-reveal 滚动进入指令

布局与交互
- MainLayout 重写:毛玻璃 sticky 顶栏 + 三区布局 + 页脚(含备案号)
- 新增 CommandPalette(⌘K/Ctrl+K)、LocaleSwitch(显示语言代码不用国旗)、
  ThemeSwitch(三态;图标显示生效 mode、菜单勾选用户偏好 preference)
- SideNav 重写:内联筛选、可折叠分组持久化、活动态指示条、需联网标记
- HomeView 重写:左对齐 Hero + 真实可用搜索 + 概览数字条 + 服务端工具专区

国际化与主题
- vue-i18n 11 + zh-CN/en-US/ja-JP/ko-KR 完整语言包(58 个工具名与描述全译)
- helpers 取词带中文兜底;index.html 防闪烁内联脚本与 store 三处同步
- 主题三态 light/dark/auto,preference 与 mode 分离

服务端对接(service.xpcool.com /api/service/open/tools/*)
- 新增 openTools.ts:接口元数据与调用函数共用同一份路径定义
- 改造/新建 6 页:ip-info、uuid、hash(服务端 MD5 两端口径对比)、
  timestamp(服务端时钟 + 时钟偏移)、random-string、api-status
- 均为「本机 / 服务端」双模式,默认本机、不自动打网络请求
- 后端未实现的 5 个接口用 BackendPending 如实标注,不伪装可用

修复
- 空分类渲染缺陷(health/study/travel 无工具却渲染空区块),分类 9 → 6
- UnoCSS panel 快捷类补内边距;IpInfoView 漏 onBeforeUnmount;
  SideNav 末尾 import;HomeView 错误导入 SUPPORTED_LOCALES

其他
- 新增 src/config.ts 集中站点常量,页脚展示备案号并链工信部核验
- docs/api-contract.md 按线上实际重写;PROJECT_STATE.md 全面更新
- 构建通过:vue-tsc --noEmit + vite build
This commit is contained in:
夏犀麟 2026-09-14 02:05:38 +08:00
parent 8ba67edc18
commit af233e1628
47 changed files with 7666 additions and 786 deletions

View File

@ -1,3 +1,7 @@
# 复制为 .env 并填写你的后端地址后生效(无需提交到 git
# 后端接口契约见 docs/api-contract.md
VITE_API_BASE=https://api.xpcool.com
#
# 默认值代码内回落https://service.xpcool.com
# 即:不创建 .env 也能正常调用已上线的 open/tools 接口。
# 仅在需要指向本地/测试后端时才覆盖它。
VITE_API_BASE=https://service.xpcool.com

View File

@ -1,6 +1,8 @@
# tool.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要
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 + vite33.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

View File

@ -5,6 +5,85 @@
---
## 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 / **tool58 个工具名与描述全译)** / 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
### 🗑 删除工具(页面目录 + 注册条目 + 相关契约全部清除)

View File

@ -12,36 +12,78 @@
## 1. 项目快照
- **名称**tool.xpcool.com在线工具箱
- **形态**:纯前端 SPA无后端、无 mock。计算与文件处理在浏览器本地完成「在线服务」分类的工具依赖自建后端见第 5 节)。
- **技术栈**Vite 6 + Vue 3`<script setup>`+ Pinia + TypeScriptstrict+ UnoCSS + TDesign Vue Next + pnpm**禁止换 npm/yarn**
- **形态**:纯前端 SPA无自建 mock。计算与文件处理在浏览器本地完成「服务端工具」分类的工具调用自建后端见第 5 节)。
- **技术栈**Vite 6 + Vue 3`<script setup>`+ Pinia + TypeScriptstrict+ 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-08-24共 9 分类 55 个工具)
## 2. 当前全貌(截至 2026-09-14共 6 分类 58 个工具)
> ⚠️ **分类在本次改造中从 9 个精简为 6 个**:原 health / study / travel 三个分类下没有任何工具,
> 却仍然渲染出空区块与空标题,属既有缺陷。现 `groupedTools()` 会过滤空分组,分类与工具一一对应。
> 第 4 节待办里的「批次 A/B/C」若要推进需先重新加入对应分类定义。
| 分类 | 数量 | 说明 | 工具path |
| --- | --- | --- | --- |
| work 工作效率 | 18 | 开发编程、办公文本与文件处理 | image-compress / json-format / timestamp / base64 / jwt-parse / cron / radix-convert / hash / url-parse / translate / ocr / uuid / regex-test / case-convert / color-convert / yaml-json / word-count / zh-convert |
| 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 |
| health 健康生活 | 0 | 睡眠、饮食与身体指标 | ⬜ 待实现(批次 A |
| study 学习办公 | 0 | 拼音标注、记忆卡片与模板 | ⬜ 待实现(批次 B |
| travel 旅行出行 | 0 | 出行清单、时差与距离 | ⬜ 待实现(批次 C |
| fun 趣味娱乐 | 1 | 摩斯电码等小玩具 | morse-code |
| online 在线服务 | 6 | 依赖后端接口 | exchange-rate / express-query / phone-locate / ip-info / network-test / speed-test |
| 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. 架构与关键机制(新增工具前必读)
- **工具注册表** `src/router/tools.ts` 是全站唯一数据源:分类 `toolCategories` + 工具 `tools` 数组,侧边导航/首页/路由全部自动生成。新增工具 = 建页面 + 加一条配置path/name/desc/icon/category/component 懒加载),**禁止手改路由文件**。
### 3.1 工具注册
- **工具注册表** `src/router/tools.ts` 是全站唯一数据源:分类 `toolCategories` + 工具 `tools` 数组,侧边导航/首页/路由/命令面板全部自动生成。新增工具 = 建页面 + 加一条配置path/name/desc/icon/category/component 懒加载),**禁止手改路由文件**。
- `ToolItem.needsNetwork` 标记需联网工具(侧栏圆点 + 首页服务端工具专区)。
- `groupedTools()` / `networkTools()` / `searchTools()` 供首页、服务端专区、命令面板使用。
### 3.2 视觉体系(令牌 → TDesign 桥接)
- **唯一真相源**`src/assets/styles/tokens.scss` 定义全部 `--tk-*` 令牌(颜色/圆角/间距/字号/z-index/动效/字体栈)。
- **桥接层**`src/assets/styles/tdesign-theme.scss` 把 `--tk-*` 映射到 TDesign 的 `--td-*`
**改主题只需改 tokens.scss 一处60+ 个未改动的工具页会自动继承新配色与圆角** —— 这是低风险全站改造的关键机制。
- **硬规则**:自定义样式一律引用 `--tk-*`(或 `--td-*`**禁止硬编码色值/圆角/阴影**,否则昼夜切换会花掉。
- **形状一致性**:全站只用 tokens 里那一套圆角xs4/sm6/md8/lg10/xl14/2xl20/pill999
- **单一强调色**:青玉 Jade。全站只允许一处渐变品牌图标
- **动效档位**:克制(上浮淡入 + 颜色过渡),`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` 也能用**;默认超时 10sAbortController
- 已实现的 5 个接口封装在 `src/api/openTools.ts`,元数据 `OPEN_TOOL_ENDPOINTS` 同时供「接口状态」页探测。
### 3.6 其他约定
- **历史记录**`useHistory(key)`key 约定等于路由 pathlocalStorage 持久化(`tool-history:<key>`),上限 10 条。
- **主题**`stores/theme.ts`,自定义样式一律用 `var(--td-*)` 令牌,禁止硬编码颜色。
- **公共组件**`CopyButton` / `DownloadButton` / `ErrorAlert` / `HistoryPanel` / `UploadDrop`,新页面必须复用。
- **页面样式**`.page-wrap / .page-title / .page-desc / .tool-section / .result-area` 全局已定义;`.form-actions / .form-row / .code-block` 各页面 scoped 自建。
- **后端请求**:统一走 `src/api/client.ts``api.get/post`),响应契约 `{ code, message, data }``VITE_API_BASE` 未配置时返回「后端未接入」,页面据此降级。
- **网络测试双模式**`network-test` 的 HTTP 检测走前端直连(受 CORS 限制Ping / TCP / DNS 走 `POST /api/tools/network-test`(契约见第 5 节)。
- **网络测速**`speed-test` 下载测速走流式 fetch`GET /api/tools/speedtest/download`,三模式:后端/公网/自定义),上传测速走 `POST /api/tools/speedtest/upload`(后端计时优先),延迟测试前端多次采样。
- **公共组件**`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 第三部分)
@ -51,24 +93,61 @@
- **批次 D 创意设计补全9**:阴影生成器 / 圆角生成器 / 调色板生成 / 颜色对比度检查 / 像素画编辑器 / 纹理生成器 / 文字效果生成 / Emoji 放大镜 / 图片九宫格切图
- **批次 E 趣味娱乐补全14**:加密暗号机 / Emoji 密文 / 藏头诗生成 / 绕口令生成器 / 塔罗牌占卜 / 星座运势 / 名字评分 / 电子木鱼 / 猜成语小游戏 / 音效合成器 / 节日倒计时 / 情侣默契问答 / 名言警句生成 / 骰子塔
## 5. 后端契约(用户正在开发后端服务
## 5. 后端契约(service.xpcool.com
- 契约文档:`docs/api-contract.md`7 个接口exchange-rate / express-query / phone-locate / ip-info / network-test / speedtest-download / speedtest-upload
- 前端已按契约类型化,后端按文档实现即可直接对接,**前端零改动**。
- 接入方式:复制 `.env.example``.env`,填 `VITE_API_BASE`
- **契约文档**`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. 技术约定与坑(跨会话必须记住)
- **TDesign 图标**:引用前先在 `node_modules/tdesign-icons-vue-next/esm/components/` 确认存在(如 `PaletteIcon` 不存在,只有 `Palette1Icon`)。
### 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>` 再构建。
- **大整数**:进制转换等工具禁用 `parseInt/Number`,用 BigInt 逐位解析(现有 radix-convert 已实现)。
- **暗黑模式**:所有颜色用 `--td-*` 令牌canvas 绘制注意主题切换(尽量用令牌色或中性色)。
- **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-6`)。页面 scoped 样式(如 `.block[data-v-*]`)权重更高,
需要单独覆盖 padding 时照常写即可。
- **`--td-radius-*` 刻意不跟随主题**(浅深共用),改圆角请改 `tokens.scss``--tk-radius-*`
- **canvas 绘制**:注意主题切换,尽量用令牌色或中性色,不要写死黑白。
### 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. 读本文件(状态全貌)→ `CHANGELOG.md`(最近的变更)→ `docs/api-contract.md`(后端契约)
2. 运行 `pnpm type-check` 确认基线
3. 如需继续实现工具:从第 4 节待办批次挑一批,按 `docs/tool-ideas.md` 的工具描述实现
4. 完成后:更新 CHANGELOG.md + 本文件 + `.workbuddy/memory/YYYY-MM-DD.md`
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 站已于本次补齐)。

View File

@ -1,181 +1,157 @@
# 后端接口契约 · tool.xpcool.com
本文档定义工具箱「在线服务」分类所有工具的后端接口规范。
前端已通过 `src/api/client.ts` 统一请求,**后端按本文档实现接口即可直接接入**,前端无需改动。
后端为 `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://api.xpcool.com`),未配置时页面显示「后端未接入」。
- **统一响应格式**(所有接口):
- **Base URL**:环境变量 `VITE_API_BASE`,默认 `https://service.xpcool.com`(见 `.env.example`)。
未配置时前端仍可用默认值,不会白屏。
- **统一响应格式**GoFrame `MiddlewareHandlerResponse` 信封):
```json
{ "code": 0, "message": "ok", "data": { ... } }
{ "code": 0, "message": "OK", "data": { ... } }
```
- `code = 0` 成功;非 0 失败,`message` 为可读错误信息(前端直接展示给用户)。
- **编码**UTF-8GET 参数走 Query StringPOST 走 JSON Body无需鉴权公开工具站
- **CORS**:需要允许前端域名跨域(`Access-Control-Allow-Origin: *`)。
- 建议实现限流(如单 IP 每分钟 60 次),防止免费接口被刷。
## 接口一览
| # | 接口 | 说明 |
| --- | --- | --- |
| 1 | `GET /api/tools/exchange-rate` | 汇率换算 |
| 2 | `GET /api/tools/express-query` | 快递查询 |
| 3 | `GET /api/tools/phone-locate` | 手机号归属地 |
| 4 | `GET /api/tools/ip-info` | IP 信息查询 |
| 5 | `POST /api/tools/network-test` | 网络测试ping / tcp / dns |
| 6 | `GET /api/tools/speedtest/download` | 下载测速(返回随机二进制流) |
| 7 | `POST /api/tools/speedtest/upload` | 上传测速(接收二进制流) |
- **方法****一律 POST**URL 不带任何参数(查询参数、路径参数都禁止),入参全部走 JSON Body。
这是本项目 2026-08-27 起的强制接口设计规范。
- **鉴权**:无(公开工具站)。
- **CORS**:后端 `CORS` 中间件已开启,允许跨域。
- **超时**:前端 `DEFAULT_TIMEOUT = 10000ms`,超时与网络不可达在 `describeStatus()` 中区分提示。
---
## 1. 汇率换算 `GET /api/tools/exchange-rate`
## 一、已实现接口5 个)
**参数**`from`(源货币代码,如 CNY、`to`(目标货币代码,如 USD、`amount`(金额,可选,默认 1
前缀:`/api/service/open/tools`
**响应 data**
| # | 路径 | 说明 | 前端封装 |
| --- | --- | --- | --- |
| 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
{
"from": "CNY",
"to": "USD",
"rate": 0.1398,
"amount": 100,
"result": 13.98,
"updated_at": "2026-08-24 17:00"
}
{ "code": 0, "message": "OK", "data": { "ip": "1.2.3.4", "internal": false } }
```
## 3. 快递查询 `GET /api/tools/express-query`
**参数**`no`(快递单号)
**响应 data**
```json
{
"no": "SF1234567890",
"company": "顺丰速运",
"status": "在途",
"traces": [
{ "time": "2026-08-24 15:32", "desc": "快件已到达【深圳中转场】" },
{ "time": "2026-08-24 09:10", "desc": "快件已从【杭州】发出" }
]
}
```
## 4. 手机号归属地 `GET /api/tools/phone-locate`
**参数**`phone`11 位手机号)
**响应 data**
```json
{
"phone": "13800138000",
"area": "北京",
"carrier": "中国移动",
"postcode": "100000",
"updated_at": "2026-08-24 17:00"
}
```
## 5. IP 信息查询 `GET /api/tools/ip-info`
**参数**`ip`IPv4可选不传则查询请求方 IP
**响应 data**
```json
{
"ip": "1.2.3.4",
"country": "中国",
"province": "广东",
"city": "深圳",
"isp": "电信",
"updated_at": "2026-08-24 17:00"
}
```
## 6. 网络测试 `POST /api/tools/network-test`
**请求体**
```json
{ "type": "ping", "host": "baidu.com", "port": null, "timeout": 5000 }
```
| 字段 | 必填 | 说明 |
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| type | 是 | `ping` / `tcp` / `dns` |
| host | 是 | 主机名或 IP |
| port | tcp 必填 | 目标端口1-65535其他类型传 null |
| timeout | 否 | 超时毫秒,默认 5000 |
| ip | string | 服务端看到的来源 IP |
| internal | bool | 是否为内网/私网地址 |
**响应 data**
> 注意:**不返回归属地**(国家/省/市/ISP。前端「IP 信息查询」页对此如实说明,不做猜测。
> 若要补归属地,需后端接入离线 IP 库后新增字段,前端预留位置。
### 2. `POST /tools/time`
请求体:`{}`
```json
{
"type": "ping",
"host": "baidu.com",
"port": null,
"reachable": true,
"latency_ms": 23.5,
"detail": { "min_ms": 20.1, "avg_ms": 23.5, "max_ms": 28.3, "loss_pct": 0 }
"code": 0,
"message": "OK",
"data": { "timestamp": 1757788000, "dateTime": "2026-09-14 01:45:00", "date": "2026-09-14" }
}
```
各类型 `detail` 字段约定:
| type | detail 字段 | 说明 |
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| ping | `min_ms` / `avg_ms` / `max_ms` / `loss_pct` | 3 次探测的最小/平均/最大延迟ms与丢包率0-100 |
| tcp | `tcp_connect_ms` | TCP 握手耗时ms |
| dns | `dns_resolve_ms` / `ip` | 解析耗时与解析出的 IP |
| timestamp | int | Unix 秒 |
| dateTime | string | `2006-01-02 15:04:05`(时区取决于服务端配置,前端不推断) |
| date | string | `2006-01-02` |
`reachable=false``latency_ms` 传 0`detail` 传 `{ "error": "错误描述" }`
前端用它做两件事:显示服务端时间、估算本机时钟偏移。
偏移量基于 `timestamp`Unix 秒,与时区无关),并补偿半个往返耗时,误差上界为 `rtt/2`
## 7. 网络测速
### 3. `POST /tools/uuid`
### 下载测速 `GET /api/tools/speedtest/download?size=10485760`
- 返回 **200 + `application/octet-stream` 随机字节流**(不是 JSON响应头需带 `Content-Length`(前端据此显示进度)。
- `size` 为请求的字节数(建议支持 5MB ~ 50MB
- 前端流式读取并按耗时计算下载 Mbps无需后端返回额外数据。
### 上传测速 `POST /api/tools/speedtest/upload`
- 请求体:`application/octet-stream` 原始二进制(前端生成随机数据上传)。
- 响应 JSON
请求体:`{ "short": false }`
```json
{ "code": 0, "message": "ok", "data": { "bytes": 5242880, "duration_ms": 812, "speed_mbps": 51.6 } }
{ "code": 0, "message": "OK", "data": { "uuid": "a1b2c3d4e5f6..." } }
```
| 字段 | 说明 |
| --- | --- |
| bytes | 实际接收字节数 |
| duration_ms | 接收耗时ms |
| speed_mbps | 后端计算的上传速度Mbps |
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| short | bool | true 返回 8 位短码false 返回 32 位 ID |
> 建议:下载测速时每次生成新的随机数据(`crypto/rand`),避免被缓存/CDN 命中导致测速失真;响应头 `Cache-Control: no-store`
前端服务端模式下一律**并发**调用 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"` |
---
## 错误示例
## 二、待实现接口
```json
{ "code": 40001, "message": "城市不存在,请检查输入", "data": null }
```
以下工具在「在线服务」分类中,但后端尚无对应接口。
前端已用 `BackendPending` 组件如实标注「接口尚未上线」,**不会伪装成可用**
后端实现后把对应页面的 `BACKEND_*_READY` 常量改为 `true`(或去掉开关)即可自动生效。
| code | 含义 |
| 工具 | 建议路径 | 说明 |
| --- | --- | --- |
| 汇率换算 | `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` 直连线上):
| 接口 | 结果 |
| --- | --- |
| 40001 | 参数不合法(如城市不存在、单号格式错误) |
| 50001 | 上游数据源异常 |
| 50002 | 频率限制,请稍后重试 |
| `/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=https://api.xpcool.com`
2. 重启 `pnpm dev`,在线服务分类的工具即可请求
3. 后端实现时对照各接口的响应 `data` 字段,字段名保持一致即可(前端已按契约类型化)
1. 复制 `.env.example``.env`,按需覆盖 `VITE_API_BASE`(不填也能跑,默认线上地址)。
2. `pnpm dev` / `pnpm build`
3. 新增后端接口时:
- 在 `src/api/openTools.ts``OpenEndpoint` 元数据 + 调用函数;
- 在对应工具页接入,并补 `src/i18n/locales/*.ts``page.*` 文案(**四语言都要补**)。

View File

@ -1,18 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" theme-mode="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta
name="description"
content="纯前端在线工具箱:图片压缩/裁剪、二维码、JSON 格式化、时间戳、Base64、JWT、Cron、进制转换、哈希、身份证、地址、URL 解析、OCR 等 14 个实用工具,所有数据仅在浏览器本地处理。"
content="纯前端在线工具箱:图片压缩裁剪、二维码、JSON 格式化、时间戳、Base64、JWT、Cron、进制转换、哈希、身份证解析、OCR 等 56 个实用工具,支持中英日韩四种语言与日间夜间主题,数据全部在浏览器本地处理。"
/>
<meta name="theme-color" content="#f6f8f8" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0c100f" media="(prefers-color-scheme: dark)" />
<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>
;(function () {
var theme = localStorage.getItem('tool-theme') || 'light'
document.documentElement.setAttribute('theme-mode', theme)
var SUPPORTED = ['zh-CN', 'en-US', 'ja-JP', 'ko-KR']
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>
</head>

View File

@ -29,6 +29,7 @@
"tesseract.js": "^5.1.1",
"tesseract.js-core": "^5.1.0",
"vue": "^3.5.13",
"vue-i18n": "^11.4.10",
"vue-router": "^4.5.0"
},
"devDependencies": {

71
pnpm-lock.yaml generated
View File

@ -59,6 +59,9 @@ importers:
vue:
specifier: ^3.5.13
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:
specifier: ^4.5.0
version: 4.6.4(vue@3.5.41(typescript@5.7.3))
@ -442,6 +445,22 @@ packages:
'@iconify/utils@2.3.0':
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':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -460,7 +479,6 @@ packages:
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-android-arm64@2.6.0':
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
@ -491,42 +509,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.6.0':
resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.6.0':
resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.6.0':
resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.6.0':
resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.6.0':
resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.6.0':
resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
@ -593,79 +605,66 @@ packages:
resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.62.5':
resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.62.5':
resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.62.5':
resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.62.5':
resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.62.5':
resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.62.5':
resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.62.5':
resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.62.5':
resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.62.5':
resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.62.5':
resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.62.5':
resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.62.5':
resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.62.5':
resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==}
@ -1522,6 +1521,12 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
@ -1771,6 +1776,24 @@ snapshots:
transitivePeerDependencies:
- 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':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -2867,6 +2890,14 @@ snapshots:
dependencies:
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)):
dependencies:
'@vue/devtools-api': 6.6.4

View File

@ -1,32 +1,45 @@
/**
* API 线
*
* VITE_API_BASE https://api.xpcool.com
* .env vite.config.ts define
*
*
* VITE_API_BASE 线
* .env http://127.0.0.1:10100service 开发端口)。
*
* docs/api-contract.md
* { "code": 0, "message": "ok", "data": { ... } }
* code === 0 0 message
*
* service.xpcool.com /api/service/open
* /api/service/open/tools/<name>
*
*
* GoFrame
* { "code": 0, "message": "OK", "data": { ... } }
* code === 0 0 message
*
* POSTURL JSON body
*/
export interface ApiResult<T = unknown> {
ok: boolean
data: T | null
message: string
/** HTTP 状态码0 表示未接入/未发出请求,-1 表示网络错误 */
/** HTTP 状态码0 表示未发出请求,-1 表示网络错误或超时 */
status: number
}
const BASE_URL: string = (import.meta.env.VITE_API_BASE as string | undefined) ?? ''
/** 接口基地址(末尾不带斜杠) */
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 !== ''
/** 后端 Base URL在线服务工具直接拼接下载/上传地址时使用) */
export { BASE_URL }
const OFFLINE_MSG = '后端服务尚未接入,请先在 .env 配置 VITE_API_BASE并按 docs/api-contract.md 实现接口'
/** 默认请求超时(毫秒)。超过则中断并提示,避免请求悬挂导致界面一直转圈 */
const DEFAULT_TIMEOUT = 10000
interface Envelope<T> {
code: number
@ -34,15 +47,46 @@ interface Envelope<T> {
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: OFFLINE_MSG, status: 0 }
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) {
@ -50,39 +94,68 @@ async function request<T>(
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
// 响应不是 JSON如网关返回 HTML 错误页),继续走下面的状态码分支
}
if (!res.ok) {
return {
ok: false,
data: null,
message: json?.message || `请求失败HTTP ${res.status}`,
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: true,
data: (json.data ?? null) as T | null,
message: json.message || 'ok',
status: res.status,
}
}
return { ok: false, data: null, message: json?.message || '接口返回异常', status: res.status }
} catch {
return { ok: false, data: null, message: '网络错误:无法连接后端服务', status: -1 }
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>): Promise<ApiResult<T>> {
return request<T>('GET', path, params)
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): Promise<ApiResult<T>> {
return request<T>('POST', path, undefined, body)
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
View File

@ -0,0 +1,161 @@
/**
* service.xpcool.com
*
* api/open/tools/<name>/<name>.go
* POST {VITE_API_BASE}/api/service/open/tools/<name>
* POSTURL 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

View File

@ -1,15 +1,17 @@
/* ============================================================
* 全局样式
* 颜色一律使用 TDesign 设计令牌 CSS 变量--td-*
* html[theme-mode='dark'] 时自动切换为暗黑主题配色
* ------------------------------------------------------------
* 引入顺序很重要
* 1. tokens.scss 设计令牌--tk-*
* 2. tdesign-theme.scss 令牌 TDesign 变量--td-*
* 3. 本文件其余部分 基础样式与工具页通用类
*
* 所有颜色只允许来自 --tk-* / --td-*禁止硬编码色值
* 这样昼夜主题切换才能全站一致生效
* ============================================================ */
:root {
color-scheme: light;
}
html[theme-mode='dark'] {
color-scheme: dark;
}
@use './tokens';
@use './tdesign-theme';
html,
body,
@ -34,7 +36,62 @@ body {
}
}
/* 图片通用约束(与 cropperjs 官方示例一致):上传的图片预览不超出容器 */
body {
font-family: var(--tk-font-sans);
font-size: var(--tk-text-base);
line-height: 1.6;
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: 8px 14px;
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 {
display: block;
max-width: 100%;
@ -45,34 +102,30 @@ img {
max-width: 100%;
}
body {
font-family:
-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 {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
border-radius: 4px;
background: var(--td-scrollbar-color);
border: 3px solid transparent;
background-clip: content-box;
border-radius: var(--tk-radius-pill);
}
html[theme-mode='dark'] ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.22);
::-webkit-scrollbar-thumb:hover {
background: var(--td-scrollbar-hover-color);
background-clip: content-box;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* 工具页通用区块间距 */
/* ============================================================
* 工具页通用区块60+ 个页面共用改这里即全站生效
* ============================================================ */
.tool-section {
margin-bottom: 16px;
margin-bottom: var(--tk-space-4);
}
/* 结果展示区:长文本自动换行 */
@ -83,21 +136,76 @@ html[theme-mode='dark'] ::-webkit-scrollbar-thumb {
/* 页面标题与描述 */
.page-title {
margin: 0 0 4px;
font-size: 22px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 6px;
font-size: var(--tk-text-2xl);
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.25;
color: var(--tk-text);
}
.page-desc {
margin: 0 0 16px;
font-size: 14px;
color: var(--td-text-color-secondary);
margin: 0 0 var(--tk-space-5);
max-width: 68ch;
font-size: var(--tk-text-base);
line-height: 1.65;
color: var(--tk-text-secondary);
}
/* 可点击放大的图片/画布(配合 AppImageViewer 全站共用):手型光标 + hover 高亮 */
/* 表单标签(各工具页统一宽度,保证左侧对齐) */
.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-2);
margin-top: var(--tk-space-4);
}
/* 单行说明/元信息 */
.field-hint {
margin: var(--tk-space-2) 0 0;
font-size: var(--tk-text-sm);
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(--td-brand-color);
box-shadow: 0 0 0 2px var(--tk-accent);
}
/* ============================================================
* 滚动进入动效MOTION 档位 4/10只做轻微上浮淡入不做视差/钉住
* v-reveal 指令添加 .is-revealed JS 时元素始终可见不会白屏
* ============================================================ */
.reveal {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: no-preference) {
.reveal {
opacity: 0;
transform: translateY(14px);
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;
}
}

View File

@ -0,0 +1,379 @@
/* ============================================================
* TDesign 主题桥接层
* ------------------------------------------------------------
* tokens.scss 里的 --tk-* 设计令牌映射到 TDesign --td-* 变量
*
* 这样做的好处60+ 个工具页面里已有的大量 t-card / t-button /
* t-input / t-tag 无需改一行代码就整体换上了新的青玉配色与圆角体系
* 属于一次改主题全站生效的低风险改造方式
*
* 命名桥接对应关系速查
* --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,
html[theme-mode='light'] {
/* ---------- 品牌色阶青玉1 最浅 → 10 最深) ---------- */
--td-brand-color-1: #e9f7f2;
--td-brand-color-2: #c6eddf;
--td-brand-color-3: #99dfc8;
--td-brand-color-4: #66cead;
--td-brand-color-5: #36bc93;
--td-brand-color-6: #17a37b;
--td-brand-color-7: #0c7c59;
--td-brand-color-8: #096548;
--td-brand-color-9: #074e38;
--td-brand-color-10: #053827;
/* ---------- 语义色阶 ---------- */
--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: #f6f8f8;
--td-gray-color-2: #eef1f0;
--td-gray-color-3: #e3e8e6;
--td-gray-color-4: #d3dad7;
--td-gray-color-5: #c2cbc8;
--td-gray-color-6: #a3afab;
--td-gray-color-7: #7c8885;
--td-gray-color-8: #5a6562;
--td-gray-color-9: #3e4845;
--td-gray-color-10: #333c39;
--td-gray-color-11: #252d2b;
--td-gray-color-12: #1a201e;
--td-gray-13: #131817;
--td-gray-color-13: #131817;
--td-gray-color-14: #0c100f;
/* ---------- 语义角色 ---------- */
--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: #99dfc8;
--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, 20, 16, 0.55);
--td-mask-disabled: rgba(252, 253, 253, 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: #a3afab;
--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(17, 26, 23, 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(16, 32, 28, 0.08);
/* ---------- 滚动条 ---------- */
--td-scrollbar-color: rgba(17, 26, 23, 0.16);
--td-scrollbar-hover-color: rgba(17, 26, 23, 0.32);
--td-scroll-track-color: transparent;
/* ---------- 字体 ---------- */
--td-font-family: var(--tk-font-sans);
--td-font-family-medium: var(--tk-font-sans);
}
/* ============================================================
* 深色主题
* ============================================================ */
html[theme-mode='dark'] {
/* ---------- 品牌色阶(深色下 1 最深 → 10 最浅) ---------- */
--td-brand-color-1: #0a2e22;
--td-brand-color-2: #0b3d2d;
--td-brand-color-3: #0c4d39;
--td-brand-color-4: #0d5c44;
--td-brand-color-5: #0e6f52;
--td-brand-color-6: #0e8460;
--td-brand-color-7: #10946b;
--td-brand-color-8: #2aa87c;
--td-brand-color-9: #3fc79b;
--td-brand-color-10: #78dfba;
/* ---------- 语义色阶 ---------- */
--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;
/* ---------- 语义角色 ----------
* 注意深色下按钮底压深到 #0e8460与白字 4.68: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: #0c4d39;
--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: #1b2220;
--td-bg-color-component-hover: #222a28;
--td-bg-color-component-active: #2b3331;
--td-bg-color-component-disabled: #171d1b;
--td-bg-color-secondarycomponent: #222a28;
--td-bg-color-secondarycomponent-hover: #2b3331;
--td-bg-color-secondarycomponent-active: #343c3a;
--td-bg-color-specialcomponent: var(--tk-surface-raised);
--td-mask-active: rgba(0, 0, 0, 0.72);
--td-mask-disabled: rgba(19, 24, 23, 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: #5a6562;
--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(232, 237, 235, 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(232, 237, 235, 0.16);
--td-scrollbar-hover-color: rgba(232, 237, 235, 0.32);
--td-scroll-track-color: transparent;
--td-font-family: var(--tk-font-sans);
--td-font-family-medium: var(--tk-font-sans);
}
/* ============================================================
* 少量组件级微调
* TDesign 默认的字重/描边在青玉配色下略重这里做轻量化处理
* ============================================================ */
/* 卡片默认无投影靠描边区分层次hover 才浮起(避免整页都是悬浮块) */
.t-card {
box-shadow: none;
}
.t-card--bordered {
border-color: var(--tk-border);
}
.t-card__header {
font-weight: 600;
letter-spacing: -0.01em;
}
/* 输入框:聚焦时用主色描边 + 柔和外发光,替代默认的粗描边 */
.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-popup__content,
.t-dialog,
.t-drawer {
border-radius: var(--tk-radius-xl);
}
.t-dialog__header {
font-weight: 600;
}

View File

@ -0,0 +1,212 @@
/* ============================================================
* 设计令牌Design Tokens
* ------------------------------------------------------------
* 站点唯一的视觉真相来源所有自定义样式只允许引用本文件
* 定义的 --tk-* 变量禁止在组件里硬编码颜色/圆角/阴影
*
* 主题通过 html 上的属性决定
* theme-mode = light | dark theme store 计算后的实际生效主题
* theme-lang = zh-CN | en-US | ja-JP | ko-KR lang 属性同步
*
* 中性色冷调石板灰不带暖黄与青玉主色同色系
* 强调色青玉 Jade单一强调色全站一致不做第二强调色
* ============================================================ */
/* ---------- 与主题无关的常量令牌 ---------- */
:root {
/* 圆角规范形状一致性全站只用这一套
* 控件 8px / 小控件 6px / 弹层 10px / 卡片 14px / 大面板 20px / 胶囊 999px */
--tk-radius-xs: 4px;
--tk-radius-sm: 6px;
--tk-radius-md: 8px;
--tk-radius-lg: 10px;
--tk-radius-xl: 14px;
--tk-radius-2xl: 20px;
--tk-radius-pill: 999px;
/* 间距节奏8px 基准) */
--tk-space-1: 4px;
--tk-space-2: 8px;
--tk-space-3: 12px;
--tk-space-4: 16px;
--tk-space-5: 24px;
--tk-space-6: 32px;
--tk-space-7: 48px;
--tk-space-8: 64px;
/* 版心与栅格 */
--tk-shell-max: 1560px;
--tk-content-max: 1120px;
--tk-header-h: 60px;
--tk-aside-w: 248px;
/* 字号阶梯 */
--tk-text-xs: 12px;
--tk-text-sm: 13px;
--tk-text-base: 14px;
--tk-text-md: 15px;
--tk-text-lg: 17px;
--tk-text-xl: 20px;
--tk-text-2xl: 26px;
--tk-text-3xl: 34px;
--tk-text-4xl: 44px;
/* 数字等宽(统计值用,避免数字跳动) */
--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;
/* 动效曲线进出对称的缓出曲线时长克制MOTION 档位 4/10 */
--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,
html[theme-mode='light'] {
color-scheme: light;
/* 强调色:青玉。与白底对比度 5.19:1正文级 AA 通过) */
--tk-accent: #0c7c59;
--tk-accent-hover: #0a6b4c;
--tk-accent-active: #085a40;
--tk-accent-ring: rgba(12, 124, 89, 0.24);
/* 低饱和青玉底纹:分类图标底、选中态、标签底 */
--tk-accent-surface: rgba(12, 124, 89, 0.08);
--tk-accent-surface-strong: rgba(12, 124, 89, 0.14);
/* 中性表面(冷调) */
--tk-bg: #f6f8f8;
--tk-surface: #fcfdfd;
--tk-surface-raised: #ffffff;
--tk-surface-sunken: #f1f4f3;
--tk-surface-hover: #f3f6f5;
--tk-surface-active: #ebefee;
/* 描边 */
--tk-border: #e3e8e6;
--tk-border-strong: #d3dad7;
--tk-border-subtle: #eef1f0;
/* 文字primary 16.6:1 / secondary 5.5:1 / placeholder 4.6:1均过 AA */
--tk-text: #111a17;
--tk-text-secondary: #5b6763;
--tk-text-placeholder: #66736f;
--tk-text-on-accent: #ffffff;
/* 语义色(仅用于状态,不参与品牌表达) */
--tk-success: #0c7c59;
--tk-warning: #9a6212;
--tk-danger: #b4302c;
--tk-info: #1d5fa8;
/* 阴影:带背景色相的冷调投影,不用纯黑 */
--tk-shadow-1: 0 1px 2px rgba(16, 32, 28, 0.05), 0 2px 6px rgba(16, 32, 28, 0.04);
--tk-shadow-2: 0 4px 12px rgba(16, 32, 28, 0.07), 0 12px 28px rgba(16, 32, 28, 0.05);
--tk-shadow-3: 0 8px 24px rgba(16, 32, 28, 0.09), 0 24px 56px rgba(16, 32, 28, 0.07);
/* 顶栏/侧栏的半透明玻璃底 */
--tk-glass: rgba(252, 253, 253, 0.82);
--tk-glass-border: rgba(17, 26, 23, 0.07);
}
/* ============================================================
* 深色主题
* ============================================================ */
html[theme-mode='dark'] {
color-scheme: dark;
/* 强调色深色下按钮底压深到 #0e8460与白字对比 4.68:1
* 正文/链接用更亮的 #3fc79b与页面底 8.85:1 */
--tk-accent: #0e8460;
--tk-accent-hover: #10946b;
--tk-accent-active: #0c6f51;
--tk-accent-text: #3fc79b;
--tk-accent-ring: rgba(63, 199, 155, 0.3);
--tk-accent-surface: rgba(63, 199, 155, 0.12);
--tk-accent-surface-strong: rgba(63, 199, 155, 0.2);
--tk-bg: #0c100f;
--tk-surface: #131817;
--tk-surface-raised: #171d1b;
--tk-surface-sunken: #171d1b;
--tk-surface-hover: #1a201e;
--tk-surface-active: #202725;
--tk-border: #252d2b;
--tk-border-strong: #333c39;
--tk-border-subtle: #1d2422;
/* primary 高对比 / secondary 8.46:1 / placeholder 5.21:1 */
--tk-text: #e8edeb;
--tk-text-secondary: #a3afab;
--tk-text-placeholder: #7c8885;
--tk-text-on-accent: #ffffff;
--tk-success: #3fc79b;
--tk-warning: #d9a441;
--tk-danger: #f08a84;
--tk-info: #6fa8e6;
--tk-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.3);
--tk-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.45), 0 12px 28px rgba(0, 0, 0, 0.35);
--tk-shadow-3: 0 8px 24px rgba(0, 0, 0, 0.5), 0 24px 56px rgba(0, 0, 0, 0.4);
--tk-glass: rgba(19, 24, 23, 0.82);
--tk-glass-border: rgba(232, 237, 235, 0.08);
}
/* 浅色主题下的强调文字色(深色主题单独覆盖) */
html[theme-mode='light'] {
--tk-accent-text: #0c7c59;
}
/* ============================================================
* 降低动态效果偏好全站动效降级为瞬时
* ============================================================ */
@media (prefers-reduced-motion: reduce) {
:root {
--tk-dur-fast: 0ms;
--tk-dur-base: 0ms;
--tk-dur-slow: 0ms;
}
}

View File

@ -0,0 +1,72 @@
<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: 10px;
text-decoration: none;
color: inherit;
flex-shrink: 0;
}
.brand-mark {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--tk-radius-md);
font-size: 19px;
color: #fff;
/* 唯一渐变:青玉由深到浅,与强调色同色系 */
background: linear-gradient(140deg, var(--tk-accent) 0%, #2fb98d 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28);
transition: transform var(--tk-dur-base) var(--tk-ease-out);
}
.brand:hover .brand-mark {
transform: rotate(-6deg) scale(1.04);
}
.brand-text {
font-size: var(--tk-text-lg);
font-weight: 650;
letter-spacing: -0.02em;
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>

View 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>

View File

@ -0,0 +1,470 @@
<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)
}
/**
* 全局快捷键KmacOS/ Ctrl+KWindows/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: 620px;
max-height: 68vh;
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: 10px;
padding: 0 12px 0 16px;
height: 56px;
flex-shrink: 0;
border-bottom: 1px solid var(--tk-border-subtle);
}
.search-icon {
font-size: 18px;
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-md);
color: var(--tk-text);
}
.search-input::placeholder {
color: var(--tk-text-placeholder);
}
.close-btn {
flex-shrink: 0;
width: 30px;
height: 30px;
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: 16px;
}
.close-btn:hover {
background: var(--tk-surface-hover);
color: var(--tk-text);
}
/* ---------- 结果列表 ---------- */
.palette-list {
flex: 1;
overflow-y: auto;
padding: 6px;
overscroll-behavior: contain;
}
.palette-item {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
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: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--tk-radius-sm);
font-size: 17px;
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: 1px;
}
.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-xs);
color: var(--tk-text-placeholder);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-badge {
flex-shrink: 0;
padding: 1px 7px;
border-radius: var(--tk-radius-pill);
font-size: 11px;
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: 16px;
padding: 8px 16px;
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: 4px;
}
.palette-foot kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
border: 1px solid var(--tk-border);
border-radius: var(--tk-radius-xs);
background: var(--tk-surface-raised);
font-family: inherit;
font-size: 11px;
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>

View 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: 4px;
padding: 0 8px;
height: 34px;
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-sm);
font-weight: 600;
letter-spacing: 0.02em;
/* 语言代码固定宽度,切换时按钮不会左右跳动 */
min-width: 22px;
text-align: center;
}
.switch-caret {
font-size: 14px;
opacity: 0.6;
}
/* 窄屏只留图标,避免顶栏拥挤 */
@media (max-width: 767px) {
.switch-text,
.switch-caret {
display: none;
}
}
</style>

View 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: 4px;
padding: 0 8px;
height: 34px;
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-sm);
font-weight: 500;
}
.switch-caret {
font-size: 14px;
opacity: 0.6;
}
@media (max-width: 767px) {
.switch-text,
.switch-caret {
display: none;
}
}
</style>

View 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 }
}

24
src/config.ts Normal file
View 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
View 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
View 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
View 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]
/** 语言选择本地存储 keyindex.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-TWen-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 {
// 忽略存储失败
}
}

536
src/i18n/locales/en-US.ts Normal file
View File

@ -0,0 +1,536 @@
/**
* 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: {
eyebrow: 'Runs locally in your browser',
title: 'A toolbox that lives in your browser',
desc: '{count} everyday utilities. Every calculation and file operation happens on your own machine.',
ctaPrimary: 'Browse all tools',
ctaSecondary: 'Server tools',
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',
},
}

536
src/i18n/locales/ja-JP.ts Normal file
View File

@ -0,0 +1,536 @@
/**
*
* 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: {
eyebrow: 'ブラウザ内で動作',
title: 'ブラウザだけで使えるツールボックス',
desc: '{count} の日常ツール。計算もファイル処理もすべて端末内で完結します。',
ctaPrimary: 'すべてのツール',
ctaSecondary: 'サーバー連携ツール',
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: '未提供',
},
}

536
src/i18n/locales/ko-KR.ts Normal file
View File

@ -0,0 +1,536 @@
/**
* .
* 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: {
eyebrow: '브라우저에서 바로 실행',
title: '브라우저만 있으면 되는 툴박스',
desc: '일상 도구 {count}개. 계산과 파일 처리가 모두 내 기기에서 끝납니다.',
ctaPrimary: '전체 도구 보기',
ctaSecondary: '서버 연동 도구',
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: '제공되지 않음',
},
}

553
src/i18n/locales/zh-CN.ts Normal file
View File

@ -0,0 +1,553 @@
/**
*
*
*
* 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: {
eyebrow: '浏览器本地运行',
title: '在浏览器里就能用的工具箱',
desc: '{count} 个日常工具,计算与文件处理全部在本机完成,数据不上传服务器。',
ctaPrimary: '浏览全部工具',
ctaSecondary: '服务端工具',
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: '未提供',
},
}

View File

@ -1,164 +1,327 @@
<template>
<div class="main-layout">
<!-- 顶部栏移动端菜单按钮 + Logo + 主题切换 -->
<div class="shell">
<!-- 键盘用户的第一个 Tab 直接跳过侧栏到正文 -->
<a class="skip-to-content" href="#main-content">{{ t('nav.skipToContent') }}</a>
<!-- ==================== 顶栏 ==================== -->
<header class="app-header">
<div class="header-inner">
<t-button variant="text" class="menu-btn" aria-label="打开导航" @click="drawerVisible = true">
<template #icon><MenuIcon /></template>
</t-button>
<div class="logo" @click="router.push('/')">
<span class="logo-icon">
<AppIcon />
</span>
<span class="logo-text">在线工具箱</span>
<!-- 左区移动端菜单按钮 + 品牌 -->
<div class="header-left">
<button
class="icon-btn menu-btn"
type="button"
:aria-label="t('nav.menuAria')"
@click="drawerVisible = true"
>
<MenuIcon />
</button>
<AppBrand />
</div>
<t-button
variant="outline"
shape="circle"
:title="theme.mode === 'light' ? '切换到暗黑模式' : '切换到浅色模式'"
@click="theme.toggle()"
>
<template #icon>
<MoonIcon v-if="theme.mode === 'light'" />
<SunnyIcon v-else />
</template>
</t-button>
<!-- 中区命令面板入口桌面端做成搜索框的样子移动端收成图标 -->
<div class="header-center">
<button class="palette-trigger" type="button" @click="palette.open()">
<SearchIcon class="trigger-icon" aria-hidden="true" />
<span class="trigger-text">{{ t('nav.searchPlaceholder') }}</span>
<kbd class="trigger-kbd">{{ shortcutLabel }}</kbd>
</button>
</div>
<!-- 右区语言 + 主题 -->
<div class="header-right">
<LocaleSwitch />
<ThemeSwitch />
</div>
</div>
</header>
<div class="body-row">
<!-- PC 左侧固定导航 -->
<!-- ==================== 侧栏 + 正文 ==================== -->
<div class="shell-body">
<aside class="app-aside">
<SideNav />
</aside>
<!-- 主体内容区域 -->
<main class="app-content">
<main id="main-content" class="app-content">
<router-view />
</main>
</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
v-model:visible="drawerVisible"
placement="left"
:size="240"
:size="272"
:footer="false"
:header="false"
:close-btn="true"
>
<SideNav @navigate="drawerVisible = false" />
</t-drawer>
<!-- 命令面板全局单例挂在布局层 -->
<CommandPalette />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { MenuIcon, MoonIcon, SunnyIcon, AppIcon } from 'tdesign-icons-vue-next'
import { useThemeStore } from '@/stores/theme'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { MenuIcon, SearchIcon } from 'tdesign-icons-vue-next'
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'
const router = useRouter()
const theme = useThemeStore()
/**
* 应用外壳布局
*
* 结构固定顶栏60px+ 左侧固定导航248px+ 内容区 + 页脚
* 移动端<768px侧栏换成抽屉顶栏保留品牌命令面板与主题/语言切换
*
* 版心约定
* --tk-shell-max = 1560px 顶栏 / 页脚 / 主体三者的最大宽度
* --tk-content-max = 1120px 内容型工具页的版心 page-wrap 约束
* 首页因为要铺开卡片网格会自行放宽不受 page-wrap 限制
*/
const { t } = useI18n()
const palette = useCommandPalette()
/** 移动端抽屉导航显隐 */
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>
<style scoped>
.main-layout {
min-height: 100vh;
.shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
background: var(--tk-bg);
}
/* ---------- 顶部栏 ---------- */
/* ==================== 顶栏 ==================== */
.app-header {
position: sticky;
top: 0;
z-index: 100;
height: 56px;
background: var(--td-bg-color-container);
border-bottom: 1px solid var(--td-border-level-1-color);
z-index: var(--tk-z-header);
height: var(--tk-header-h);
background: var(--tk-glass);
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
border-bottom: 1px solid var(--tk-glass-border);
}
.header-inner {
height: 100%;
max-width: 1440px;
max-width: var(--tk-shell-max);
margin: 0 auto;
padding: 0 16px;
display: flex;
align-items: center;
gap: 12px;
}
.logo {
.header-left {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
gap: 10px;
flex-shrink: 0;
}
.logo-icon {
width: 30px;
height: 30px;
border-radius: 8px;
.header-center {
flex: 1;
min-width: 0;
display: flex;
justify-content: center;
}
.header-right {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
/* 顶栏图标按钮(移动端菜单) */
.icon-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(135deg, var(--td-brand-color), #36b37e);
border: none;
border-radius: var(--tk-radius-md);
background: transparent;
color: var(--tk-text-secondary);
cursor: pointer;
font-size: 18px;
}
.logo-text {
font-size: 17px;
font-weight: 600;
color: var(--td-text-color-primary);
.icon-btn:hover {
background: var(--tk-surface-hover);
color: var(--tk-text);
}
.menu-btn {
display: none;
}
.header-inner > :last-child {
margin-left: auto;
/* 命令面板入口:长得像搜索框,本质是按钮(点开面板而非直接输入) */
.palette-trigger {
width: 100%;
max-width: 460px;
height: 36px;
display: flex;
align-items: center;
gap: 9px;
padding: 0 10px 0 12px;
border: 1px solid var(--tk-border);
border-radius: var(--tk-radius-md);
background: var(--tk-surface);
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 {
border-color: var(--tk-border-strong);
background: var(--tk-surface-hover);
color: var(--tk-text-secondary);
}
.palette-trigger:active {
transform: translateY(1px);
}
.trigger-icon {
font-size: 16px;
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: 1px 6px;
border: 1px solid var(--tk-border);
border-radius: var(--tk-radius-xs);
background: var(--tk-surface-sunken);
font-family: inherit;
font-size: 11px;
line-height: 16px;
color: var(--tk-text-placeholder);
}
/* ---------- 主体 ---------- */
.body-row {
/* ==================== 主体 ==================== */
.shell-body {
flex: 1;
display: flex;
min-height: calc(100vh - 56px);
width: 100%;
max-width: var(--tk-shell-max);
margin: 0 auto;
align-items: flex-start;
}
/* 左侧固定导航PC 端) */
.app-aside {
width: 224px;
width: var(--tk-aside-w);
flex-shrink: 0;
position: sticky;
top: 56px;
height: calc(100vh - 56px);
top: var(--tk-header-h);
height: calc(100dvh - var(--tk-header-h));
overflow-y: auto;
background: var(--td-bg-color-container);
border-right: 1px solid var(--td-border-level-1-color);
padding: 12px 10px;
overscroll-behavior: contain;
border-right: 1px solid var(--tk-border);
padding: 14px 10px 32px;
background: var(--tk-surface);
}
.app-content {
flex: 1;
min-width: 0;
padding: 24px 20px;
padding: 28px 28px 56px;
}
/* ==================== 页脚 ==================== */
.app-footer {
padding: 12px;
text-align: center;
border-top: 1px solid var(--tk-border);
background: var(--tk-surface);
}
.footer-inner {
max-width: var(--tk-shell-max);
margin: 0 auto;
padding: 18px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
font-size: var(--tk-text-xs);
color: var(--tk-text-placeholder);
}
.footer-promises {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 14px;
}
.footer-divider {
width: 1px;
height: 11px;
background: var(--tk-border-strong);
}
/* 备案号:监管信息,压到比产品承诺更弱的视觉层级,但仍要可读、可点 */
.footer-icp {
font-size: 12px;
color: var(--td-text-color-placeholder);
border-top: 1px solid var(--td-border-level-1-color);
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;
}
/* ---------- 响应式:移动端 H5 ---------- */
/* ==================== 响应式 ==================== */
@media (max-width: 767px) {
.app-aside {
display: none;
@ -167,10 +330,28 @@ const drawerVisible = ref(false)
display: inline-flex;
}
.app-content {
padding: 16px 12px;
padding: 18px 14px 44px;
}
.logo-text {
font-size: 15px;
/* 移动端搜索入口收成图标按钮:整条搜索框会挤掉品牌与主题切换 */
.palette-trigger {
max-width: 36px;
height: 34px;
padding: 0;
justify-content: center;
border-color: transparent;
background: transparent;
font-size: 18px;
}
.palette-trigger:hover {
background: var(--tk-surface-hover);
}
.trigger-text,
.trigger-kbd {
display: none;
}
.header-inner {
padding: 0 12px;
gap: 6px;
}
}
</style>

View File

@ -1,42 +1,166 @@
<template>
<nav class="side-nav">
<router-link to="/" class="nav-item" :class="{ active: route.path === '/' }" @click="emit('navigate')">
<HomeIcon class="nav-icon" />
<span>首页</span>
<nav class="side-nav" :aria-label="t('nav.browse')">
<!-- 侧栏内联筛选56 个工具靠滚动查找太慢就地过滤比开面板更快 -->
<div class="nav-filter">
<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>
<template v-for="group in groups" :key="group.category.key">
<div class="nav-group-title">{{ group.category.label }}</div>
<router-link
v-for="tool in group.items"
:key="tool.path"
:to="`/${tool.path}`"
class="nav-item"
:class="{ active: route.path === `/${tool.path}` }"
@click="emit('navigate')"
<!-- 分类分组标题可折叠折叠状态记在本地存储 -->
<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)"
>
<component :is="tool.icon" class="nav-icon" />
<span>{{ tool.name }}</span>
</router-link>
</template>
<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
v-for="tool in group.items"
:key="tool.path"
:to="`/${tool.path}`"
class="nav-item"
:class="{ active: route.path === `/${tool.path}` }"
@click="emit('navigate')"
>
<component :is="tool.icon" class="nav-icon" aria-hidden="true" />
<span class="nav-label">{{ toolName(tool.path, tool.name) }}</span>
<span v-if="tool.needsNetwork" class="nav-dot" :title="t('home.serverBadge')" />
</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>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { HomeIcon } from 'tdesign-icons-vue-next'
import { useI18n } from 'vue-i18n'
import { HomeIcon, SearchIcon, CloseIcon, ChevronDownIcon } from 'tdesign-icons-vue-next'
import { groupedTools } from '@/router/tools'
import { toolName, toolDesc, categoryLabel } from '@/i18n/helpers'
/**
* 侧边导航数据源为工具注册表 tools.ts按分类分组渲染
* PC 端渲染在左侧固定栏移动端渲染在抽屉内点击后由父组件关闭抽屉
*
* 三个细节
* - 分组标题可折叠折叠状态写入本地存储刷新后保留用户的整理结果
* 一旦开始筛选折叠状态会被忽略强制展开否则用户会以为搜不到
* - 需要联网的工具在右侧点一个小圆点标记且这是全站唯一使用状态点的地方
* 语义明确是否需联网不是装饰
* - 筛选对已翻译的名称生效因此英文/日文/韩文界面下用对应语言也能搜到
*/
const { t } = useI18n()
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<{
/** 点击菜单项后通知父组件(移动端用于关闭抽屉) */
navigate: []
@ -49,36 +173,196 @@ const emit = defineEmits<{
flex-direction: column;
gap: 2px;
}
.nav-group-title {
padding: 8px 12px 4px;
font-size: 12px;
color: var(--td-text-color-placeholder);
/* ---------- 筛选框 ---------- */
.nav-filter {
position: relative;
display: flex;
align-items: center;
gap: 7px;
height: 34px;
margin-bottom: 10px;
padding: 0 8px 0 10px;
border: 1px solid var(--tk-border);
border-radius: var(--tk-radius-md);
background: var(--tk-surface-sunken);
}
.filter-icon {
font-size: 15px;
color: var(--tk-text-placeholder);
flex-shrink: 0;
}
.filter-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: var(--tk-text-sm);
color: var(--tk-text);
}
.filter-input::placeholder {
color: var(--tk-text-placeholder);
}
.filter-clear {
flex-shrink: 0;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--tk-radius-xs);
background: transparent;
color: var(--tk-text-placeholder);
cursor: pointer;
font-size: 13px;
}
.filter-clear:hover {
color: var(--tk-text);
background: var(--tk-surface-active);
}
/* ---------- 分组标题 ---------- */
.nav-group {
margin-top: 6px;
}
.group-head {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 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);
background: var(--tk-surface-hover);
}
.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;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.group-count {
flex-shrink: 0;
font-size: 11px;
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
/* ---------- 导航项 ---------- */
.group-items {
display: flex;
flex-direction: column;
gap: 1px;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 6px;
font-size: 14px;
color: var(--td-text-color-secondary);
padding: 7px 10px;
border-radius: var(--tk-radius-md);
font-size: var(--tk-text-base);
color: var(--tk-text-secondary);
text-decoration: none;
transition:
background-color 0.15s,
color 0.15s;
background-color var(--tk-dur-fast) var(--tk-ease-out),
color var(--tk-dur-fast) var(--tk-ease-out);
}
.nav-item:hover {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
background: var(--tk-surface-hover);
color: var(--tk-text);
}
/* 活动态:青玉底纹 + 左侧 2px 指示条,比纯文字变色更容易在长列表里定位 */
.nav-item.active {
background: var(--td-brand-color-light);
color: var(--td-brand-color);
font-weight: 500;
background: var(--tk-accent-surface-strong);
color: var(--tk-accent-text);
font-weight: 550;
}
.nav-item.active::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 16px;
border-radius: var(--tk-radius-pill);
background: var(--tk-accent);
}
.home-item {
margin-bottom: 4px;
}
.nav-icon {
width: 18px;
height: 18px;
width: 17px;
height: 17px;
flex-shrink: 0;
}
.nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 唯一的语义状态点:该工具需要联网 */
.nav-dot {
width: 5px;
height: 5px;
flex-shrink: 0;
border-radius: 50%;
background: var(--tk-accent);
opacity: 0.55;
}
/* ---------- 空状态 ---------- */
.nav-empty {
padding: 20px 10px;
}
.empty-title {
margin: 0 0 4px;
font-size: var(--tk-text-sm);
font-weight: 600;
color: var(--tk-text-secondary);
}
.empty-hint {
margin: 0 0 10px;
font-size: var(--tk-text-xs);
line-height: 1.55;
color: var(--tk-text-placeholder);
}
.empty-btn {
padding: 5px 10px;
border: 1px solid var(--tk-border);
border-radius: var(--tk-radius-sm);
background: var(--tk-surface);
font-family: inherit;
font-size: var(--tk-text-xs);
color: var(--tk-text-secondary);
cursor: pointer;
}
.empty-btn:hover {
border-color: var(--tk-accent);
color: var(--tk-accent-text);
}
</style>

View File

@ -3,8 +3,12 @@ import { createPinia } from 'pinia'
import TDesign from 'tdesign-vue-next'
import App from './App.vue'
import router from './router'
import { i18n, applyLocaleToDocument, resolveInitialLocale } from './i18n'
import { reveal } from './directives/reveal'
import { useThemeStore } from './stores/theme'
// 样式TDesign 组件样式 + UnoCSS 原子类 + 全局自定义样式
// 注意顺序:自定义样式必须排在 TDesign 之后,否则令牌覆盖不生效
import 'tdesign-vue-next/es/style/index.css'
import 'virtual:uno.css'
import './assets/styles/index.scss'
@ -12,12 +16,19 @@ import './assets/styles/index.scss'
const app = createApp(App)
app.use(createPinia())
app.use(i18n)
app.use(router)
// 全量注册 TDesign 组件(工具站体积不敏感,全量注册开发效率最高)
app.use(TDesign)
// 全局注册滚动进入动效指令,首页与卡片列表使用
app.directive('reveal', reveal)
// 主题初始化:与 index.html 内联脚本保持一致(读取本地存储,刷新不丢失)
const savedTheme = localStorage.getItem('tool-theme') || 'light'
document.documentElement.setAttribute('theme-mode', savedTheme)
// 主题初始化:三态偏好(浅色/深色/跟随系统),与 index.html 内联脚本保持一致。
// index.html 已处理首屏防闪烁,这里负责接管系统主题变化的监听。
const theme = useThemeStore()
theme.init()
// 语言初始化:把 html[lang] 写成解析后的语言,字体栈随之切换
applyLocaleToDocument(resolveInitialLocale())
app.mount('#app')

View File

@ -2,11 +2,16 @@ import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import MainLayout from '@/layout/MainLayout.vue'
import { tools } from './tools'
import { i18n } from '@/i18n'
import { toolName } from '@/i18n/helpers'
/**
* tools.ts path
* MainLayout
* MainLayout /
*
*
* meta key
*
*/
const routes: RouteRecordRaw[] = [
{
@ -17,14 +22,15 @@ const routes: RouteRecordRaw[] = [
path: '',
name: 'home',
component: () => import('@/views/HomeView.vue'),
meta: { title: '首页' },
// 首页标题直接取站点名,避免出现「首页 · 在线工具箱」这类冗余
meta: { titleKey: 'app.name' },
},
// 由工具注册表自动生成路由,无需手动维护
...tools.map((t) => ({
path: t.path,
name: t.path,
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,
})
// 页面切换后同步更新浏览器标题
router.afterEach((to) => {
const title = (to.meta.title as string | undefined) ?? '首页'
document.title = `${title} · 在线工具箱`
/** 按当前语言把 meta 转成页面标题 */
function resolveTitle(meta: Record<string, unknown>): string {
const key = meta.titleKey as string | undefined
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

View File

@ -49,19 +49,26 @@ import {
MobileIcon,
WifiIcon,
RocketIcon,
TextboxIcon,
ServerIcon,
} from 'tdesign-icons-vue-next'
/**
*
*
*
*
*
* 1. src/views/<工具名>/
* 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
*/
/** 工具分类定义(数据驱动,顺序即展示顺序) */
@ -79,24 +86,23 @@ export const toolCategories: ToolCategory[] = [
{ key: 'dev', label: '开发进阶', desc: '面向开发者的格式化、转换与对比工具' },
{ key: 'design', label: '创意设计', desc: '生成渐变、占位图与字符画,灵感随手可得' },
{ key: 'life', label: '生活助手', desc: '日常计算、查询与决策小工具' },
{ key: 'health', label: '健康生活', desc: '睡眠、饮食与身体指标计算' },
{ key: 'study', label: '学习办公', desc: '拼音标注、记忆卡片与文档模板' },
{ key: 'travel', label: '旅行出行', desc: '出行清单、时差与距离计算' },
{ key: 'fun', label: '趣味娱乐', desc: '摩斯电码、抽奖转盘等好玩的小玩具' },
{ key: 'online', label: '在线服务', desc: '依赖后端接口的工具,后端接入中' },
{ key: 'fun', label: '趣味娱乐', desc: '摩斯电码等好玩的小玩具' },
{ key: 'online', label: '服务端工具', desc: '调用自建后端开放接口,需要联网' },
]
export interface ToolItem {
/** 路由路径(相对根路径,不含前导斜杠) */
/** 路由路径(相对根路径,不含前导斜杠),同时用作 i18n 的 key */
path: string
/** 工具名称(菜单、卡片、页面标题使用 */
/** 工具名称(简体中文原文,翻译缺失时兜底 */
name: string
/** 一句话描述(首页卡片展示 */
/** 一句话描述(简体中文原文,翻译缺失时兜底 */
desc: string
/** 菜单/卡片图标TDesign 图标组件) */
icon: Component
/** 所属分类 key必须在 toolCategories 中存在),缺省归入第一个分类 */
category?: string
/** 是否依赖后端接口(首页会打上「需联网」标记,并参与服务端工具分区) */
needsNetwork?: boolean
/** 页面组件(懒加载,减小首屏体积) */
component: () => Promise<Component>
}
@ -130,7 +136,7 @@ export const tools: ToolItem[] = [
{
path: 'timestamp',
name: '时间戳转换',
desc: '时间戳与北京时间互转,支持秒/毫秒与批量转换',
desc: '时间戳与时间互转,支持秒/毫秒、批量转换与服务端时间校准',
icon: TimeIcon,
category: 'work',
component: () => import('@/views/timestamp/TimestampView.vue'),
@ -170,7 +176,7 @@ export const tools: ToolItem[] = [
{
path: 'hash',
name: '哈希计算',
desc: '文本/文件计算 MD5、SHA1、SHA256、SHA512',
desc: '文本/文件计算 MD5、SHA1、SHA256、SHA512,可对比服务端结果',
icon: FingerprintIcon,
category: 'work',
component: () => import('@/views/hash/HashView.vue'),
@ -202,7 +208,7 @@ export const tools: ToolItem[] = [
{
path: 'uuid',
name: 'UUID 生成',
desc: '批量生成 UUID v4支持大小写与去连字符',
desc: '批量生成 UUID,可选浏览器本地生成或调用服务端接口',
icon: RefreshIcon,
category: 'work',
component: () => import('@/views/uuid/UuidView.vue'),
@ -514,37 +520,34 @@ export const tools: ToolItem[] = [
component: () => import('@/views/env-parse/EnvParseView.vue'),
},
/* ==================== 在线服务(后端预留) ==================== */
/* ==================== ====================
* VITE_API_BASE /api/service/open/tools/* POST
* needsNetwork: true */
{
path: 'exchange-rate',
name: '汇率换算',
desc: '主流货币实时汇率换算(后端接入中)',
icon: CurrencyExchangeIcon,
path: 'api-status',
name: '服务端接口状态',
desc: '探测后端开放接口的可用性与响应耗时,确认前后端连通情况',
icon: ServerIcon,
category: 'online',
component: () => import('@/views/exchange-rate/ExchangeRateView.vue'),
needsNetwork: true,
component: () => import('@/views/api-status/ApiStatusView.vue'),
},
{
path: 'express-query',
name: '快递查询',
desc: '物流轨迹一键查询(后端接入中)',
icon: LocationIcon,
path: 'random-string',
name: '随机字符串',
desc: '按长度与字符类型生成随机串,可选服务端密码学随机源',
icon: TextboxIcon,
category: 'online',
component: () => import('@/views/express-query/ExpressQueryView.vue'),
},
{
path: 'phone-locate',
name: '手机号归属地',
desc: '归属地与运营商查询(后端接入中)',
icon: MobileIcon,
category: 'online',
component: () => import('@/views/phone-locate/PhoneLocateView.vue'),
needsNetwork: true,
component: () => import('@/views/random-string/RandomStringView.vue'),
},
{
path: 'ip-info',
name: 'IP 信息查询',
desc: 'IP 归属地与运营商查询(后端接入中)',
desc: '查询服务端识别的来源 IP、是否内网地址并展示本机环境信息',
icon: InternetIcon,
category: 'online',
needsNetwork: true,
component: () => import('@/views/ip-info/IpInfoView.vue'),
},
{
@ -553,6 +556,7 @@ export const tools: ToolItem[] = [
desc: '站点可达性检测HTTP / Ping / TCP / DNS',
icon: WifiIcon,
category: 'online',
needsNetwork: true,
component: () => import('@/views/network-test/NetworkTestView.vue'),
},
{
@ -561,8 +565,36 @@ export const tools: ToolItem[] = [
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 时归入第一个分类 */
@ -571,12 +603,39 @@ export function toolCategoryKey(tool: ToolItem): string {
}
/**
* toolCategories
* 使
* 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),
}))
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
View 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 }
})

View File

@ -1,30 +1,102 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { ref } from 'vue'
/** 用户在界面上选择的主题偏好 */
export type ThemePreference = 'light' | 'dark' | 'auto'
/** 实际生效的主题auto 会被解析成二者之一) */
export type ThemeMode = 'light' | 'dark'
/** 主题本地存储 key与 index.html 内联脚本保持一致) */
const STORAGE_KEY = 'tool-theme'
/** 主题偏好本地存储 keyindex.html 内联脚本会读取它做首屏防闪烁) */
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', () => {
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) {
mode.value = next
document.documentElement.setAttribute('theme-mode', next)
localStorage.setItem(STORAGE_KEY, next)
/** 实际生效的主题 */
const mode = computed<ThemeMode>(() => {
if (preference.value === 'auto') return systemDark.value ? 'dark' : 'light'
return preference.value
})
/** 把主题写到 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 }
})

View File

@ -1,25 +1,96 @@
<template>
<div class="page-wrap">
<!-- 站点头部介绍 -->
<div class="hero">
<h1 class="hero-title">在线工具箱</h1>
<p class="hero-desc">
{{ tools.length }} 个常用工具全部在浏览器本地完成计算与文件处理不上传任何数据到服务器
</p>
<div class="hero-badges">
<t-tag theme="success" variant="light">纯前端</t-tag>
<t-tag theme="primary" variant="light">数据不出浏览器</t-tag>
<t-tag theme="warning" variant="light">免费使用</t-tag>
</div>
</div>
<div class="home">
<!-- ==================== 抬头区 ====================
左对齐单列这一页的主角是快速找到工具不是品牌宣言
所以把真实的搜索框放进抬头区作为主操作而不是摆两个装饰按钮 -->
<section class="hero">
<p class="hero-eyebrow">{{ t('home.eyebrow') }}</p>
<h1 class="hero-title">{{ t('home.title') }}</h1>
<p class="hero-desc">{{ t('home.desc', { count: tools.length }) }}</p>
<!-- 按分类分区的工具卡片导航 -->
<section v-for="group in groups" :key="group.category.key" class="category-section">
<div class="category-head">
<h2 class="category-title">{{ group.category.label }}</h2>
<span class="category-desc">{{ group.category.desc }}</span>
<span class="category-count">{{ group.items.length }} 个工具</span>
<div class="hero-search">
<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>
<router-link to="/api-status" class="hero-link">
{{ t('home.ctaSecondary') }}
<ChevronRightIcon aria-hidden="true" />
</router-link>
</section>
<!-- ==================== 概览数字条 ====================
四组数字用竖线分列数字统一等宽刷新时不会左右跳动 -->
<section v-if="!isFiltering" class="stats">
<div v-for="stat in stats" :key="stat.label" class="stat">
<span class="stat-value mono-num">{{ stat.value }}</span>
<span class="stat-label">{{ stat.label }}</span>
</div>
</section>
<!-- ==================== 服务端工具专区 ====================
整块用青玉底纹与其它分区区分开这是本期唯一需要联网且有真实后端支撑的部分
开始筛选时隐藏避免与下方 online 分类重复出现同一批工具 -->
<section v-if="!isFiltering && serverTools.length" class="server-zone">
<header class="zone-head">
<h2 class="zone-title">{{ t('category.online.label') }}</h2>
<p class="zone-desc">{{ t('category.online.desc') }}</p>
<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
v-for="tool in serverTools"
:key="tool.path"
:to="`/${tool.path}`"
class="server-card"
>
<span class="server-card-icon"><component :is="tool.icon" aria-hidden="true" /></span>
<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>
</router-link>
</div>
</section>
<!-- ==================== 分类分区 ====================
56 个工具的目录页必然是重复的卡片网格这里把重复做整齐而不是做变化
分区标题行统一结构卡片网格统一列宽用户可以靠固定的视觉节奏快速扫读 -->
<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) }}</h2>
<p class="category-desc">{{ categoryDesc(group.category.key, group.category.desc) }}</p>
<span class="category-count">{{ t('nav.toolsCount', { count: group.items.length }) }}</span>
</header>
<div class="tool-grid">
<router-link
v-for="tool in group.items"
@ -27,141 +98,507 @@
:to="`/${tool.path}`"
class="tool-card"
>
<span class="tool-icon">
<component :is="tool.icon" />
</span>
<span class="tool-name">{{ tool.name }}</span>
<span class="tool-desc">{{ tool.desc }}</span>
<span class="tool-arrow">
<ChevronRightIcon />
<span class="card-head">
<span class="card-icon"><component :is="tool.icon" aria-hidden="true" /></span>
<span v-if="tool.needsNetwork" class="card-tag">{{ t('home.serverBadge') }}</span>
</span>
<span class="card-name">{{ toolName(tool.path, tool.name) }}</span>
<span class="card-desc">{{ toolDesc(tool.path, tool.desc) }}</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>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
import { tools, groupedTools } from '@/router/tools'
import { computed, ref } from 'vue'
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'
/** 分类分组(新增分类自动出现,无需改动本页面) */
const groups = groupedTools()
/**
* 首页工具目录
*
* 版式取舍
* - 抬头区左对齐而不是居中居中大标题在目录型产品上会把视线拉向空处
* 左对齐能让标题 搜索框 卡片形成一条自然的阅读动线
* - 抬头区放真实可用的搜索框直接过滤下方列表不是装饰性按钮
* - 服务端工具单独成区并加底纹是本期唯一需要联网的部分值得与本地工具区分
* - 筛选期间隐藏概览数字与服务端专区避免同一批工具在一页里出现两次
*/
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>
<style scoped>
.home {
max-width: 1180px;
}
/* ==================== 抬头区 ==================== */
.hero {
text-align: center;
padding: 24px 0 32px;
padding: 8px 0 36px;
max-width: 720px;
}
.hero-eyebrow {
margin: 0 0 12px;
font-size: var(--tk-text-xs);
font-weight: 600;
letter-spacing: 0.08em;
color: var(--tk-accent-text);
}
.hero-title {
margin: 0 0 8px;
font-size: 30px;
font-weight: 700;
color: var(--td-text-color-primary);
margin: 0 0 14px;
font-size: var(--tk-text-4xl);
font-weight: 680;
line-height: 1.1;
letter-spacing: -0.03em;
color: var(--tk-text);
}
.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;
margin: 0 0 26px;
max-width: 54ch;
font-size: var(--tk-text-md);
line-height: 1.6;
color: var(--tk-text-secondary);
}
/* 分类分区 */
.category-section {
margin-bottom: 28px;
}
.category-head {
/* 抬头区搜索框:页面的主操作,尺寸比顶栏的入口更大更醒目 */
.hero-search {
display: flex;
align-items: baseline;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.category-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.category-desc {
font-size: 13px;
color: var(--td-text-color-secondary);
}
.category-count {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
/* 工具卡片栅格:响应式,移动端单列 */
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.tool-card {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 10px;
background: var(--td-bg-color-container);
text-decoration: none;
height: 48px;
padding: 0 12px 0 16px;
border: 1px solid var(--tk-border-strong);
border-radius: var(--tk-radius-xl);
background: var(--tk-surface);
box-shadow: var(--tk-shadow-1);
transition:
transform 0.15s,
box-shadow 0.15s,
border-color 0.15s;
border-color var(--tk-dur-fast) var(--tk-ease-out),
box-shadow var(--tk-dur-fast) var(--tk-ease-out);
}
.tool-card:hover {
transform: translateY(-3px);
border-color: var(--td-brand-color);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
.hero-search:focus-within {
border-color: var(--tk-accent);
box-shadow: 0 0 0 3px var(--tk-accent-ring);
}
html[theme-mode='dark'] .tool-card:hover {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
.hero-search-icon {
font-size: 18px;
color: var(--tk-text-placeholder);
flex-shrink: 0;
}
.tool-icon {
width: 40px;
height: 40px;
border-radius: 10px;
.hero-search-input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: var(--tk-text-md);
color: var(--tk-text);
/* 去掉 type=search 在 Safari / Chrome 上的原生清除按钮,统一用自绘按钮 */
appearance: none;
}
.hero-search-input::-webkit-search-cancel-button {
display: none;
}
.hero-search-input::placeholder {
color: var(--tk-text-placeholder);
}
.hero-search-clear {
flex-shrink: 0;
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--td-brand-color);
background: var(--td-brand-color-light);
}
.tool-name {
border: none;
border-radius: var(--tk-radius-sm);
background: transparent;
color: var(--tk-text-placeholder);
cursor: pointer;
font-size: 15px;
}
.hero-search-clear:hover {
background: var(--tk-surface-hover);
color: var(--tk-text);
}
.hero-link {
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 16px;
font-size: var(--tk-text-base);
font-weight: 550;
color: var(--tk-accent-text);
text-decoration: none;
}
.hero-link:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
/* ==================== 概览数字条 ==================== */
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
margin-bottom: 40px;
border-top: 1px solid var(--tk-border);
border-bottom: 1px solid var(--tk-border);
background: var(--tk-border);
}
.stat {
display: flex;
flex-direction: column;
gap: 4px;
padding: 18px 20px;
background: var(--tk-bg);
}
.stat-value {
font-size: var(--tk-text-xl);
font-weight: 650;
letter-spacing: -0.02em;
color: var(--tk-text);
}
.stat-label {
font-size: var(--tk-text-xs);
color: var(--tk-text-placeholder);
}
/* ==================== 服务端工具专区 ==================== */
.server-zone {
margin-bottom: 44px;
padding: 26px 26px 28px;
border: 1px solid var(--tk-border);
border-radius: var(--tk-radius-2xl);
background: var(--tk-accent-surface);
}
.zone-head {
margin-bottom: 20px;
}
.zone-title {
margin: 0 0 6px;
font-size: var(--tk-text-xl);
font-weight: 650;
letter-spacing: -0.02em;
color: var(--tk-text);
}
.zone-desc {
margin: 0 0 10px;
max-width: 60ch;
font-size: var(--tk-text-base);
color: var(--tk-text-secondary);
}
.zone-link {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: var(--tk-text-sm);
font-weight: 550;
color: var(--tk-accent-text);
text-decoration: none;
}
.zone-link:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
/* 横向卡片:与服务端专区「接口」的气质一致,也与下方分类的竖向卡片区分开 */
.zone-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px;
}
.server-card {
display: flex;
align-items: flex-start;
gap: 13px;
padding: 15px 16px;
border: 1px solid var(--tk-border);
border-radius: var(--tk-radius-xl);
background: var(--tk-surface);
text-decoration: none;
transition:
border-color var(--tk-dur-fast) var(--tk-ease-out),
transform var(--tk-dur-fast) var(--tk-ease-out);
}
.server-card:hover {
border-color: var(--tk-accent);
transform: translateY(-2px);
}
.server-card-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-accent-surface-strong);
}
.server-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.server-card-name {
font-size: var(--tk-text-base);
font-weight: 600;
color: var(--td-text-color-primary);
color: var(--tk-text);
}
.tool-desc {
font-size: 13px;
.server-card-desc {
font-size: var(--tk-text-sm);
line-height: 1.5;
color: var(--td-text-color-secondary);
color: var(--tk-text-secondary);
}
.tool-arrow {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--td-text-color-placeholder);
.server-card-endpoint {
margin-top: 3px;
font-size: 11px;
letter-spacing: -0.01em;
color: var(--tk-text-placeholder);
}
/* ==================== 分类分区 ==================== */
.category {
margin-bottom: 40px;
}
.category-head {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--tk-border);
}
.category-title {
margin: 0 0 3px;
font-size: var(--tk-text-lg);
font-weight: 650;
letter-spacing: -0.015em;
color: var(--tk-text);
}
.category-desc {
margin: 0;
font-size: var(--tk-text-sm);
color: var(--tk-text-secondary);
}
.category-count {
display: block;
margin-top: 6px;
font-size: var(--tk-text-xs);
color: var(--tk-text-placeholder);
}
/* ==================== 工具卡片网格 ==================== */
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
gap: 12px;
}
.tool-card {
display: flex;
flex-direction: column;
gap: 7px;
padding: 16px;
border: 1px solid var(--tk-border);
border-radius: var(--tk-radius-xl);
background: var(--tk-surface);
text-decoration: none;
transition:
border-color var(--tk-dur-fast) var(--tk-ease-out),
transform var(--tk-dur-fast) var(--tk-ease-out),
box-shadow var(--tk-dur-fast) var(--tk-ease-out);
}
.tool-card:hover {
border-color: var(--tk-accent);
transform: translateY(-2px);
box-shadow: var(--tk-shadow-2);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-icon {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--tk-radius-md);
font-size: 18px;
color: var(--tk-accent-text);
background: var(--tk-surface-sunken);
}
.tool-card:hover .card-icon {
background: var(--tk-accent-surface-strong);
}
.card-tag {
flex-shrink: 0;
padding: 1px 7px;
border-radius: var(--tk-radius-pill);
font-size: 11px;
font-weight: 500;
color: var(--tk-accent-text);
background: var(--tk-surface-sunken);
}
.card-name {
font-size: var(--tk-text-base);
font-weight: 600;
color: var(--tk-text);
}
.card-desc {
font-size: var(--tk-text-sm);
line-height: 1.55;
color: var(--tk-text-secondary);
}
/* ==================== 空状态 ==================== */
.empty {
padding: 56px 20px;
text-align: center;
}
.empty-title {
margin: 0 0 6px;
font-size: var(--tk-text-lg);
font-weight: 650;
color: var(--tk-text);
}
.empty-hint {
margin: 0 0 18px;
font-size: var(--tk-text-base);
color: var(--tk-text-secondary);
}
.empty-btn {
padding: 8px 16px;
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;
}
.empty-btn:hover {
border-color: var(--tk-accent);
color: var(--tk-accent-text);
}
/* ==================== 响应式 ==================== */
@media (max-width: 1023px) {
.hero-title {
font-size: var(--tk-text-3xl);
}
.stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 767px) {
.hero {
padding: 4px 0 28px;
}
.hero-title {
font-size: 24px;
font-size: 28px;
}
.hero-desc {
font-size: var(--tk-text-base);
margin-bottom: 20px;
}
.hero-search {
height: 44px;
border-radius: var(--tk-radius-lg);
}
.stats {
margin-bottom: 32px;
}
.stat {
padding: 14px 16px;
}
.server-zone {
padding: 20px 16px;
border-radius: var(--tk-radius-xl);
margin-bottom: 34px;
}
.zone-grid {
grid-template-columns: 1fr;
}
.tool-grid {
grid-template-columns: 1fr;
}
.category {
margin-bottom: 32px;
}
}
</style>

View 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: 11px;
color: var(--tk-text-placeholder);
word-break: break-all;
}
.method-badge {
padding: 2px 7px;
border-radius: var(--tk-radius-xs);
font-size: 11px;
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: 11px;
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>

View File

@ -1,9 +1,9 @@
<template>
<div class="page-wrap">
<h2 class="page-title">汇率换算</h2>
<p class="page-desc">实时汇率换算支持主流货币汇率由后端接口提供</p>
<h2 class="page-title">{{ t('tool.exchange-rate.name') }}</h2>
<p class="page-desc">{{ t('tool.exchange-rate.desc') }}</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端服务尚未接入:在 .env 中配置 VITE_API_BASE 并按 docs/api-contract.md 实现接口后即可使用" />
<BackendPending />
<t-card class="tool-section" title="换算">
<div class="form-row">
@ -11,7 +11,7 @@
<t-select v-model="from" :options="CURRENCIES" class="cur-select" />
<span class="swap-icon"></span>
<t-select v-model="to" :options="CURRENCIES" class="cur-select" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="query">
<t-button theme="primary" :disabled="loading" @click="query">
{{ loading ? '查询中…' : '换算' }}
</t-button>
</div>
@ -33,8 +33,13 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { api, backendReady } from '@/api/client'
import BackendPending from '@/components/common/BackendPending.vue'
import { api } from '@/api/client'
/** 页面标题与说明改由语言包提供;接口尚未上线,用 BackendPending 说明现状 */
const { t } = useI18n()
interface RateData {
from: string

View File

@ -1,14 +1,14 @@
<template>
<div class="page-wrap">
<h2 class="page-title">快递查询</h2>
<p class="page-desc">输入快递单号查询物流轨迹数据由后端接口提供</p>
<h2 class="page-title">{{ t('tool.express-query.name') }}</h2>
<p class="page-desc">{{ t('tool.express-query.desc') }}</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端服务尚未接入:在 .env 中配置 VITE_API_BASE 并按 docs/api-contract.md 实现接口后即可使用" />
<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="!backendReady || loading" @click="query">
<t-button theme="primary" :disabled="loading" @click="query">
{{ loading ? '查询中…' : '查询' }}
</t-button>
</div>
@ -39,7 +39,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { api, backendReady } from '@/api/client'
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

View File

@ -1,50 +1,89 @@
<template>
<div class="page-wrap">
<h2 class="page-title">哈希计算</h2>
<p class="page-desc">对文本或文件计算 MD5SHA1SHA256SHA512 哈希值全部在浏览器本地完成</p>
<h2 class="page-title">{{ t('page.hash.title') }}</h2>
<p class="page-desc">{{ t('page.hash.desc') }}</p>
<t-tabs v-model="tab">
<!-- 文本哈希 -->
<t-tab-panel value="text" label="文本哈希">
<t-card class="tool-section" title="输入文本">
<t-tab-panel value="text" :label="t('page.hash.tabText')">
<section class="panel tool-section">
<h3 class="section-title">{{ t('page.hash.inputTitle') }}</h3>
<t-textarea
v-model="textInput"
placeholder="输入要计算哈希的文本"
:placeholder="t('page.hash.inputPlaceholder')"
:autosize="{ minRows: 4, maxRows: 10 }"
/>
<div class="form-row" style="margin-top: 12px">
<t-checkbox v-model="uppercase">大写输出</t-checkbox>
<t-button theme="primary" @click="computeText">计算</t-button>
<t-button variant="outline" @click="clearText">清空</t-button>
<t-checkbox v-model="uppercase">{{ t('page.hash.uppercase') }}</t-checkbox>
<t-button theme="primary" :disabled="serverLoading" @click="computeText">
{{ t('page.hash.compute') }}
</t-button>
<t-button variant="outline" @click="clearText">{{ t('page.hash.clear') }}</t-button>
</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 value="file" label="文件哈希">
<t-card class="tool-section" title="上传文件">
<UploadDrop v-model:files="files" tip="上传任意文件,自动计算哈希" />
<p v-if="file" class="file-meta">
{{ file.name }} · {{ formatBytes(file.size) }}
</p>
</t-card>
<t-tab-panel value="file" :label="t('page.hash.tabFile')">
<section class="panel tool-section">
<h3 class="section-title">{{ t('page.hash.uploadTitle') }}</h3>
<UploadDrop v-model:files="files" :tip="t('page.hash.uploadTip')" />
<p v-if="file" class="file-meta">{{ file.name }} · {{ formatBytes(file.size) }}</p>
</section>
</t-tab-panel>
</t-tabs>
<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 class="hash-head">
<span class="hash-alg">{{ r.alg }}</span>
<CopyButton :text="r.value" label="复制" />
<CopyButton :text="r.value" :label="t('common.copy')" />
</div>
<div class="code-block">{{ r.value }}</div>
</div>
<div class="form-actions">
<CopyButton :text="allResults" label="复制全部" />
</div>
</t-card>
</section>
<!-- ==================== 两端口径对比 ==================== -->
<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 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
:items="history.items"
@ -56,7 +95,18 @@
</template>
<script setup lang="ts">
/**
* 哈希计算
*
* 本机计算是主路径MD5 spark-md5SHA 系列走 Web Crypto全部不联网
* 服务端 MD5是一个可选的旁路同一段文本再让后端算一次
* 目的是核对前后端实现是否一致也是 open/tools/md5 的真实用途
*
* 之所以做成开关而不是默认行为 计算哈希是个高频纯本地的动作
* 不该为了算个 MD5 就让每次点击都多一次网络往返
*/
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SparkMD5 from 'spark-md5'
import UploadDrop from '@/components/common/UploadDrop.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 { useHistory } from '@/composables/useHistory'
import { bufferToHex, formatBytes } from '@/utils'
import { fetchMd5 } from '@/api/openTools'
const { t } = useI18n()
const tab = ref('text')
const textInput = ref('')
@ -76,8 +129,28 @@ const file = ref<File | null>(null)
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'))
/** 本机算出的 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 {
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() {
error.value = ''
hashResults.value = []
serverError.value = ''
serverMd5Raw.value = ''
if (!textInput.value) {
error.value = '请输入要计算哈希的文本'
error.value = t('page.hash.errEmpty')
return
}
try {
await computeHashes(new TextEncoder().encode(textInput.value))
history.add(textInput.value)
} 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]
error.value = ''
hashResults.value = []
serverError.value = ''
serverMd5Raw.value = ''
if (!(raw instanceof File)) {
file.value = null
return
@ -134,6 +228,13 @@ watch(files, (list) => {
void computeFileHash(raw)
})
/** 打开开关后,如果已经有文本和结果,立刻补算一次服务端 MD5 */
watch(useServerMd5, (on) => {
if (on && textInput.value && localMd5Raw.value && !serverMd5Raw.value) {
void computeServerMd5(textInput.value)
}
})
async function computeFileHash(raw: File) {
try {
// 4MB
@ -158,7 +259,7 @@ async function computeFileHash(raw: File) {
{ alg: 'SHA512', value: fmt(sha512) },
]
} catch {
error.value = '文件读取失败,请重试'
error.value = t('page.hash.errFile')
}
}
@ -172,33 +273,122 @@ function clearText() {
textInput.value = ''
error.value = ''
hashResults.value = []
serverError.value = ''
serverMd5Raw.value = ''
}
</script>
<style scoped>
.section-title {
margin: 0 0 12px;
font-size: var(--tk-text-md);
font-weight: 600;
color: var(--tk-text);
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
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 {
margin: 10px 0 0;
font-size: 13px;
color: var(--td-text-color-secondary);
font-size: var(--tk-text-sm);
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 {
margin-bottom: 16px;
margin-bottom: 14px;
}
.hash-row:last-child {
margin-bottom: 0;
}
.hash-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.hash-alg {
font-size: 13px;
font-size: var(--tk-text-xs);
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>

View File

@ -1,105 +1,324 @@
<template>
<div class="page-wrap">
<h2 class="page-title">IP 信息查询</h2>
<p class="page-desc">查询 IP 地址的归属地运营商等信息数据由后端接口提供</p>
<h2 class="page-title">{{ t('page.ip-info.title') }}</h2>
<p class="page-desc">{{ t('page.ip-info.desc') }}</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端服务尚未接入:在 .env 中配置 VITE_API_BASE 并按 docs/api-contract.md 实现接口后即可使用" />
<!-- ==================== 服务端识别的来源 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>
<t-card class="tool-section" title="查询 IP">
<div class="form-row">
<t-input v-model="ip" placeholder="留空则查询本机 IP" clearable @enter="query" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="query">
{{ loading ? '查询中…' : '查询' }}
</t-button>
<!-- 加载骨架形状与最终内容一致避免出结果时页面跳动 -->
<div v-if="loading && !ipInfo" class="skeleton-row">
<span class="skeleton skeleton-ip" />
<span class="skeleton skeleton-badge" />
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<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>
<t-card v-if="data" class="tool-section" title="IP 信息">
<div class="ip-main">{{ data.ip }}</div>
<div class="ip-grid">
<div class="ii"><span>国家/地区</span><b>{{ data.country }}</b></div>
<div class="ii"><span>省份</span><b>{{ data.province || '—' }}</b></div>
<div class="ii"><span>城市</span><b>{{ data.city || '—' }}</b></div>
<div class="ii"><span>运营商</span><b>{{ data.isp || '—' }}</b></div>
<!-- 错误态直接展示后端返回的可读信息并给出重试入口 -->
<div 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>
</t-card>
</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 { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { api, backendReady } from '@/api/client'
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'
interface IpData {
ip: string
country: string
province: string
city: string
isp: string
updated_at: string
}
/**
* IP 信息查询
*
* 数据来源分两块界面上也据此分成两个区块
* 1. 服务端识别的来源 IP 调用 POST /api/service/open/tools/ip
* 返回 { ip, internal }这一项是服务端视角可能与本机网卡地址不同
* 2. 本机浏览器环境 全部本地读取不产生任何网络请求
*
* 归属地国家/省份/城市/运营商后端暂未提供这里不做前端猜测
* 而是明确说明现状避免给出看起来像真的实际是编造的地区信息
*/
const { t } = useI18n()
const ip = ref('')
const data = ref<IpData | null>(null)
const ipInfo = ref<IpRes | null>(null)
const error = ref('')
const loading = ref(false)
async function query() {
async function load() {
error.value = ''
data.value = null
const ipText = ip.value.trim()
if (ipText && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ipText)) {
error.value = 'IP 格式不正确,请输入 IPv4 地址'
return
}
loading.value = true
const res = await api.get<IpData>('/api/tools/ip-info', ipText ? { ip: ipText } : undefined)
const res = await fetchClientIp()
loading.value = false
if (res.ok) {
data.value = res.data
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: 720px;
max-width: 760px;
}
.form-row {
/* 通用区块面板(描边 + 圆角 + 内边距,不加重投影) */
.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: 11px;
color: var(--tk-text-placeholder);
background: var(--tk-surface-sunken);
}
.head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.ip-main {
font-family: ui-monospace, Consolas, monospace;
font-size: 26px;
font-weight: 700;
color: var(--td-brand-color);
padding: 8px 0 16px;
/* ---------- IP 展示 ---------- */
.ip-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.ip-grid {
.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(140px, 1fr));
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px 22px;
margin: 0;
}
.ii {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
.info-item {
min-width: 0;
}
.ii span {
font-size: 12px;
color: var(--td-text-color-placeholder);
.info-label {
margin-bottom: 3px;
font-size: var(--tk-text-xs);
color: var(--tk-text-placeholder);
}
.ii b {
font-size: 15px;
color: var(--td-text-color-primary);
.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>

View File

@ -1,9 +1,9 @@
<template>
<div class="page-wrap">
<h2 class="page-title">网络测试</h2>
<p class="page-desc">检测目标站点的可达性与响应耗时HTTP 检测在浏览器本地完成Ping / TCP 端口 / DNS 解析由后端完成</p>
<h2 class="page-title">{{ t('tool.network-test.name') }}</h2>
<p class="page-desc">{{ t('tool.network-test.desc') }}</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端未接入HTTP 站点检测仍可直接使用Ping / TCP / DNS 需在 .env 配置 VITE_API_BASE 并按 docs/api-contract.md 实现接口" />
<BackendPending :message="t('pending.networkTest')" />
<t-card class="tool-section">
<t-tabs v-model="tab">
@ -22,7 +22,7 @@
<t-tab-panel value="ping" label="Ping">
<div class="form-row">
<t-input v-model="host" placeholder="主机名,如 baidu.com" class="url-input" clearable @enter="runDiag('ping')" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="runDiag('ping')">
<t-button theme="primary" :disabled="loading" @click="runDiag('ping')">
{{ loading ? '测试中…' : 'Ping' }}
</t-button>
</div>
@ -33,7 +33,7 @@
<div class="form-row">
<t-input v-model="host" placeholder="主机名或 IP" class="url-input" clearable @enter="runDiag('tcp')" />
<t-input-number v-model="port" :min="1" :max="65535" class="port-input" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="runDiag('tcp')">
<t-button theme="primary" :disabled="loading" @click="runDiag('tcp')">
{{ loading ? '测试中…' : '连接测试' }}
</t-button>
</div>
@ -43,7 +43,7 @@
<t-tab-panel value="dns" label="DNS 解析">
<div class="form-row">
<t-input v-model="host" placeholder="域名,如 baidu.com" class="url-input" clearable @enter="runDiag('dns')" />
<t-button theme="primary" :disabled="!backendReady || loading" @click="runDiag('dns')">
<t-button theme="primary" :disabled="loading" @click="runDiag('dns')">
{{ loading ? '解析中…' : '解析' }}
</t-button>
</div>
@ -88,7 +88,16 @@
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { api, backendReady } from '@/api/client'
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('')

View File

@ -1,14 +1,14 @@
<template>
<div class="page-wrap">
<h2 class="page-title">手机号归属地</h2>
<p class="page-desc">查询手机号归属地与运营商数据由后端接口提供</p>
<h2 class="page-title">{{ t('tool.phone-locate.name') }}</h2>
<p class="page-desc">{{ t('tool.phone-locate.desc') }}</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端服务尚未接入:在 .env 中配置 VITE_API_BASE 并按 docs/api-contract.md 实现接口后即可使用" />
<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="!backendReady || loading" @click="query">
<t-button theme="primary" :disabled="loading" @click="query">
{{ loading ? '查询中…' : '查询' }}
</t-button>
</div>
@ -33,7 +33,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { api, backendReady } from '@/api/client'
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

View 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>

View File

@ -1,9 +1,9 @@
<template>
<div class="page-wrap">
<h2 class="page-title">网络测速</h2>
<p class="page-desc">测试宽带下载 / 上传速度与网络延迟后端模式最精确未接入时下载可用公网测速源</p>
<h2 class="page-title">{{ t('tool.speed-test.name') }}</h2>
<p class="page-desc">{{ t('tool.speed-test.desc') }}</p>
<t-alert v-if="!backendReady" class="tool-section" theme="warning" message="后端未接入:下载测速将使用公网源(实验性,依赖其 CORS上传测速需配置 VITE_API_BASE 并按契约实现接口" />
<BackendPending :message="t('pending.speedTest')" />
<t-card class="tool-section">
<t-tabs v-model="tab">
@ -65,7 +65,7 @@
</t-radio-group>
</div>
<div class="opt-row">
<t-button theme="primary" :disabled="running || !backendReady" @click="runUpload">开始测速</t-button>
<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">
@ -125,7 +125,23 @@
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import { backendReady, BASE_URL } from '@/api/client'
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('')
@ -165,7 +181,7 @@ async function runDownload() {
error.value = '请填写测速文件 URL'
return
}
if (dlSource.value === 'backend' && !backendReady) {
if (dlSource.value === 'backend' && !BACKEND_SPEEDTEST_READY) {
error.value = '后端未接入,请选择公网源或自定义 URL'
return
}
@ -227,7 +243,7 @@ const upResult = ref<{ mbps: string; bytes: string; seconds: string; mode: strin
async function runUpload() {
error.value = ''
upResult.value = null
if (!backendReady) {
if (!BACKEND_SPEEDTEST_READY) {
error.value = '上传测速需要后端支持,请先配置 VITE_API_BASE'
return
}
@ -289,7 +305,7 @@ const latencyResult = ref<{ avg: number; min: number; max: number; jitter: numbe
async function runLatency() {
error.value = ''
latencyResult.value = null
const url = latencyUrl.value.trim() || (backendReady ? `${BASE_URL}/api/tools/speedtest/download?size=1` : PUBLIC_SOURCE + 1024 * 1024)
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

View File

@ -1,32 +1,64 @@
<template>
<div class="page-wrap">
<h2 class="page-title">时间戳转换</h2>
<p class="page-desc">时间戳与北京时间UTC+8互转支持秒/毫秒与批量转换</p>
<h2 class="page-title">{{ t('page.timestamp.title') }}</h2>
<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-tab-panel value="ts2date" label="时间戳 → 时间">
<t-card class="tool-section" title="转换">
<t-input v-model="tsInput" placeholder="输入时间戳,例如 1704067200" clearable />
<t-tab-panel value="ts2date" :label="t('page.timestamp.tabTs2Date')">
<section class="panel tool-section">
<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">
<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-button variant="outline" @click="fillNow">当前时间</t-button>
<t-button theme="primary" @click="convertOne">转换</t-button>
<t-button variant="outline" @click="fillNow">{{ t('page.timestamp.now') }}</t-button>
<t-button theme="primary" @click="convertOne">{{ t('page.timestamp.convert') }}</t-button>
</div>
</t-card>
</section>
<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="form-actions">
<CopyButton :text="oneResult" />
<CopyButton :text="oneResult" :label="t('page.common.copyResult')" />
</div>
</t-card>
</section>
</t-tab-panel>
<!-- 时间 -> 时间戳 -->
<t-tab-panel value="date2ts" label="时间 → 时间戳">
<t-card class="tool-section" title="选择北京时间">
<t-tab-panel value="date2ts" :label="t('page.timestamp.tabDate2Ts')">
<section class="panel tool-section">
<h3 class="section-title">{{ t('page.timestamp.pickTitle') }}</h3>
<t-date-picker
v-model="dateValue"
enable-time-picker
@ -34,47 +66,50 @@
clearable
format="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">
<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-button theme="primary" @click="convertDate">转换</t-button>
<t-button theme="primary" @click="convertDate">{{ t('page.timestamp.convert') }}</t-button>
</div>
</t-card>
</section>
<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="form-actions">
<CopyButton :text="dateResult" />
<CopyButton :text="dateResult" :label="t('page.common.copyResult')" />
</div>
</t-card>
</section>
</t-tab-panel>
<!-- 批量转换 -->
<t-tab-panel value="batch" label="批量转换">
<t-card class="tool-section" title="批量时间戳 → 北京时间">
<t-tab-panel value="batch" :label="t('page.timestamp.tabBatch')">
<section class="panel tool-section">
<h3 class="section-title">{{ t('page.timestamp.batchInputTitle') }}</h3>
<t-textarea
v-model="batchInput"
placeholder="每行一个时间戳,例如:&#10;1704067200&#10;1704067200000"
:placeholder="t('page.timestamp.batchInputPlaceholder')"
:autosize="{ minRows: 6, maxRows: 12 }"
/>
<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-button theme="primary" @click="convertBatch">批量转换</t-button>
<t-button theme="primary" @click="convertBatch">{{ t('page.timestamp.batchConvert') }}</t-button>
</div>
</t-card>
<t-card v-if="batchResult" class="tool-section" title="批量结果">
</section>
<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 />
<div class="form-actions">
<CopyButton :text="batchResult" />
<CopyButton :text="batchResult" :label="t('page.common.copyResult')" />
<t-button variant="outline" size="small" @click="downloadBatch">
<template #icon><DownloadIcon /></template>
下载
{{ t('page.timestamp.download') }}
</t-button>
</div>
</t-card>
</section>
</t-tab-panel>
</t-tabs>
@ -88,32 +123,98 @@
</template>
<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 CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
import { downloadText } from '@/composables/useDownload'
import { formatBeijing, genFilename } from '@/utils'
import { fetchServerTime } from '@/api/openTools'
const unitOptions = [
{ label: '秒', value: 's' },
{ label: '毫秒', value: 'ms' },
]
const { t } = useI18n()
const history = useHistory('timestamp')
/** 单位选项跟随语言切换,所以用 computed 而不是常量 */
const unitOptions = computed(() => [
{ label: t('page.timestamp.unitSecond'), value: 's' },
{ label: t('page.timestamp.unitMillisecond'), value: 'ms' },
])
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 unit = ref('s')
const error = ref('')
const oneResult = ref('')
const history = useHistory('timestamp')
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 = ''
const ts = parseTs(tsInput.value, unit.value)
if (ts === null) {
error.value = '请输入合法的数字时间戳'
error.value = t('page.timestamp.errInvalidTs')
return
}
const d = new Date(ts)
if (Number.isNaN(d.getTime())) {
error.value = '时间戳超出可表示范围'
error.value = t('page.timestamp.errOutOfRange')
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)
}
@ -151,14 +252,16 @@ function convertDate() {
dateResult.value = ''
const m = /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}):(\d{2}))?$/.exec(dateValue.value.trim())
if (!m) {
error2.value = '请选择合法的日期时间'
error2.value = t('page.timestamp.errInvalidDate')
return
}
const [y, mo, d, h = 0, mi = 0, s = 0] = m.slice(1).map(Number)
// UTC+8 UTC
const ts = Date.UTC(y, mo - 1, d, h, mi, s) - 8 * 3600 * 1000
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)
}
@ -171,13 +274,13 @@ function convertBatch() {
batchResult.value = batchInput.value
.split('\n')
.map((line) => {
const t = line.trim()
if (!t) return ''
const ts = parseTs(t, unit3.value)
if (ts === null) return `${t} => 【无效时间戳】`
const raw = line.trim()
if (!raw) return ''
const ts = parseTs(raw, unit3.value)
if (ts === null) return `${raw} => ${t('page.timestamp.invalidTs')}`
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return `${t} => 【超出范围】`
return `${t} => ${formatBeijing(d, true)}`
if (Number.isNaN(d.getTime())) return `${raw} => ${t('page.timestamp.outOfRange')}`
return `${raw} => ${formatBeijing(d, true)}`
})
.join('\n')
}
@ -201,24 +304,68 @@ function onHistorySelect(text: string) {
</script>
<style scoped>
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
.section-title {
margin: 0 0 12px;
font-size: var(--tk-text-md);
font-weight: 600;
color: var(--tk-text);
}
.form-select {
width: 110px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
/* ---------- 服务端时钟 ---------- */
.clock-panel {
/* 用青玉底纹和普通工具区块区分开:这一块是"服务端信息"而非本地计算 */
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) {
width: 100%;
max-width: 340px;
}
@media (max-width: 640px) {
.clock-grid {
grid-template-columns: 1fr;
gap: var(--tk-space-3);
}
}
</style>

View File

@ -1,36 +1,66 @@
<template>
<div class="page-wrap">
<h2 class="page-title">UUID 生成器</h2>
<p class="page-desc">批量生成 RFC 4122 版本 4 的随机 UUID基于浏览器安全随机数支持大写与无连字符格式</p>
<h2 class="page-title">{{ t('page.uuid.title') }}</h2>
<p class="page-desc">{{ t('page.uuid.desc') }}</p>
<t-card class="tool-section" title="生成选项">
<!-- ==================== 生成选项 ==================== -->
<section class="panel tool-section">
<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" />
</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="生成结果">
<div class="result-actions">
<CopyButton :text="allText" label="复制全部" />
<t-button theme="primary" @click="regenerate">
<!-- 本机模式才有的大小写/连字符选项服务端返回定长 ID这两项不适用 -->
<div v-if="mode === 'local'" class="form-row option-row">
<t-checkbox v-model="uppercase">{{ t('page.uuid.uppercase') }}</t-checkbox>
<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>
重新生成
{{ loading ? t('common.loading') : t('page.uuid.regenerate') }}
</t-button>
</div>
<div class="uuid-list">
<div v-for="(u, i) in list" :key="i" class="uuid-row">
<span class="uuid-text">{{ u }}</span>
<t-button variant="text" size="small" shape="square" title="复制" @click="copyText(u)">
</section>
<ErrorAlert :message="error" @close="error = ''" />
<!-- ==================== 生成结果 ==================== -->
<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>
</t-button>
</div>
</div>
</t-card>
</li>
</ul>
</section>
<HistoryPanel
:items="history.items"
@ -42,20 +72,38 @@
</template>
<script setup lang="ts">
// UUID Web Crypto RFC 4122 v4 UUID
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { CopyIcon, RefreshIcon } 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 { fetchUuid } from '@/api/openTools'
/**
* UUID 生成器提供两种来源
* - 本机Web Crypto 生成标准 RFC 4122 v4可一次批量出 100 支持大小写与去连字符
* - 服务端POST /api/service/open/tools/uuid返回 GoFrame guid32 8 位短码
*
* 两种来源的产出形态并不一样v4 有连字符服务端是 32 位无连字符 guid
* 界面上按模式切换可选项而不是让用户在服务端模式下看到无效的大写/无连字符开关
*/
const { t } = useI18n()
const mode = ref<'local' | 'server'>('local')
const count = ref(5)
const uppercase = ref(false)
const noHyphen = ref(false)
const short = ref(false)
const list = ref<string[]>([])
const error = ref('')
const loading = ref(false)
const history = useHistory('uuid')
const allText = computed(() => list.value.join('\n'))
/**
* 生成单个 v4 UUID16 字节安全随机数再写入版本位与变体位
* 无连字符时跳过 8-4-4-4-12 的分隔符大写按需转换数字不受影响
@ -73,18 +121,43 @@ function generateUuid(noHyphenFlag: boolean, upperFlag: boolean): string {
return upperFlag ? hex.toUpperCase() : hex
}
/** 按当前选项生成一批 UUID数量夹取到合法范围防止输入框清空等边界情况 */
function generate() {
const n = Number.isFinite(count.value) ? Math.min(100, Math.max(1, Math.floor(count.value))) : 1
/** 数量夹取到合法范围,防止输入框清空等边界情况 */
function safeCount(): number {
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))
}
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 字符截断),避免每次进入页面都污染历史 */
function regenerate() {
generate()
history.add(allText.value.slice(0, 300))
async function regenerate() {
error.value = ''
list.value = []
if (mode.value === 'local') {
generateLocal()
} else {
await generateServer()
}
// 300
if (list.value.length) history.add(allText.value.slice(0, 300))
}
function onHistorySelect(text: string) {
@ -95,48 +168,70 @@ function onHistorySelect(text: string) {
.filter(Boolean)
}
onMounted(generate)
onMounted(() => {
//
generateLocal()
})
</script>
<style scoped>
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.count-input {
width: 140px;
.page-wrap {
max-width: 780px;
}
.option-row {
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;
gap: 8px;
flex-wrap: wrap;
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);
}
.uuid-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
gap: 5px;
}
.uuid-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
gap: 10px;
padding: 6px 8px 6px 10px;
border: 1px solid var(--tk-border);
border-radius: var(--tk-radius-md);
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 {
flex: 1;
min-width: 0;
font-family: monospace;
font-size: 13px;
font-size: var(--tk-text-sm);
line-height: 1.5;
word-break: break-all;
color: var(--td-text-color-primary);
color: var(--tk-text);
}
</style>

View File

@ -1,18 +1,29 @@
import { defineConfig, presetUno, transformerDirectives } from 'unocss'
/**
* UnoCSS 使 presetUno Tailwind
* TDesign CSS /
* UnoCSS 使 presetUno Tailwind
*
* CSS --tk-* / --td-*
* tokens.scss
* arbitrary valuebg-[var(--tk-surface)]
*/
export default defineConfig({
presets: [presetUno()],
transformers: [transformerDirectives()],
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',
// 结果代码块
'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-3 py-2.5 font-mono text-13px leading-relaxed break-all whitespace-pre-wrap',
// 通用面板(自定义区块的轻量容器)
// 自带内边距,直接写 class="panel tool-section" 即可,不必每处再补 padding。
// 注意:页面 scoped 样式(如 .block[data-v-*])权重高于本类,需要单独覆盖时照常写即可。
'panel':
'rounded-[var(--tk-radius-xl)] border border-[var(--tk-border)] bg-[var(--tk-surface)] p-5 md:p-6',
// 说明性小字
'hint-text': 'text-12px text-[var(--tk-text-placeholder)]',
},
})