From af233e1628faec4fd162ceca378e9a08bc17a574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=8F=E7=8A=80=E9=BA=9F?= Date: Mon, 14 Sep 2026 02:05:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E8=A7=89=E9=87=8D=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20+=20=E4=B8=AD=E8=8B=B1=E6=97=A5=E9=9F=A9=E5=9B=9B?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=20+=20=E4=B8=89=E6=80=81=E6=98=BC=E5=A4=9C?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=20+=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视觉体系(核心是「令牌 → 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 --- .env.example | 6 +- .workbuddy/memory/CHANGELOG.md | 2 + docs/CHANGELOG.md | 79 +++ docs/PROJECT_STATE.md | 129 +++- docs/api-contract.md | 254 ++++--- index.html | 52 +- package.json | 1 + pnpm-lock.yaml | 71 +- src/api/client.ts | 117 +++- src/api/openTools.ts | 161 +++++ src/assets/styles/index.scss | 180 ++++- src/assets/styles/tdesign-theme.scss | 379 +++++++++++ src/assets/styles/tokens.scss | 212 ++++++ src/components/common/AppBrand.vue | 72 ++ src/components/common/BackendPending.vue | 80 +++ src/components/common/CommandPalette.vue | 470 +++++++++++++ src/components/common/LocaleSwitch.vue | 98 +++ src/components/common/ThemeSwitch.vue | 84 +++ src/composables/useCommandPalette.ts | 23 + src/config.ts | 24 + src/directives/reveal.ts | 62 ++ src/i18n/helpers.ts | 50 ++ src/i18n/index.ts | 97 +++ src/i18n/locales/en-US.ts | 536 +++++++++++++++ src/i18n/locales/ja-JP.ts | 536 +++++++++++++++ src/i18n/locales/ko-KR.ts | 536 +++++++++++++++ src/i18n/locales/zh-CN.ts | 553 +++++++++++++++ src/layout/MainLayout.vue | 335 +++++++--- src/layout/SideNav.vue | 356 +++++++++- src/main.ts | 17 +- src/router/index.ts | 37 +- src/router/tools.ts | 137 ++-- src/stores/locale.ts | 31 + src/stores/theme.ts | 104 ++- src/views/HomeView.vue | 667 +++++++++++++++---- src/views/api-status/ApiStatusView.vue | 434 ++++++++++++ src/views/exchange-rate/ExchangeRateView.vue | 15 +- src/views/express-query/ExpressQueryView.vue | 15 +- src/views/hash/HashView.vue | 250 ++++++- src/views/ip-info/IpInfoView.vue | 351 ++++++++-- src/views/network-test/NetworkTestView.vue | 23 +- src/views/phone-locate/PhoneLocateView.vue | 15 +- src/views/random-string/RandomStringView.vue | 278 ++++++++ src/views/speed-test/SpeedTestView.vue | 32 +- src/views/timestamp/TimestampView.vue | 269 ++++++-- src/views/uuid/UuidView.vue | 197 ++++-- unocss.config.ts | 25 +- 47 files changed, 7666 insertions(+), 786 deletions(-) create mode 100644 src/api/openTools.ts create mode 100644 src/assets/styles/tdesign-theme.scss create mode 100644 src/assets/styles/tokens.scss create mode 100644 src/components/common/AppBrand.vue create mode 100644 src/components/common/BackendPending.vue create mode 100644 src/components/common/CommandPalette.vue create mode 100644 src/components/common/LocaleSwitch.vue create mode 100644 src/components/common/ThemeSwitch.vue create mode 100644 src/composables/useCommandPalette.ts create mode 100644 src/config.ts create mode 100644 src/directives/reveal.ts create mode 100644 src/i18n/helpers.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales/en-US.ts create mode 100644 src/i18n/locales/ja-JP.ts create mode 100644 src/i18n/locales/ko-KR.ts create mode 100644 src/i18n/locales/zh-CN.ts create mode 100644 src/stores/locale.ts create mode 100644 src/views/api-status/ApiStatusView.vue create mode 100644 src/views/random-string/RandomStringView.vue diff --git a/.env.example b/.env.example index 9ab5a35..02d4177 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.workbuddy/memory/CHANGELOG.md b/.workbuddy/memory/CHANGELOG.md index 31f916e..4457d74 100644 --- a/.workbuddy/memory/CHANGELOG.md +++ b/.workbuddy/memory/CHANGELOG.md @@ -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 + vite,33.88s) + 2026-09-01 | CHG | 全站接入图片预览(TDesign ImageViewer):新增共享 AppImageViewer 组件 + useImageViewer 组合式函数;接入 9 处——UploadDrop 缩略图、Base64 还原图、图片裁剪结果、图片转 ICO 预览(多尺寸成组)、OCR 待识别图、二维码结果、图片压缩(原直连 t-image-viewer 重构为共享组件)、代码转图片画布、占位图画布;全局样式新增 .img-previewable 2026-09-01 | CHG | 图片转 ICO 结果区增强:逐尺寸单独 ICO 下载(buildIco 新增 singles 字段,复用条目数据零额外编码)+ 多尺寸一键打包 ZIP 下载(jszip);另修复 pnpm wrapper 中 node 版本路径(22.22.2 → 22.22.2-2) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 37643e4..aee8bad 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 / **tool(58 个工具名与描述全译)** / page / pending / common。 +- 新增 `src/components/common/BackendPending.vue`:用中性信息态(非 `t-alert` 错误色)如实说明 + 「前端已就绪、后端接口尚未上线」,**不伪装成可用**。 +- `index.html` 重写防闪烁内联脚本(同步执行,解析主题 `auto` + 语言归一化),与 theme store、i18n index + **三处逻辑必须同步**;`theme-mode` 属性 + 明暗两套 `theme-color` meta。 + +### 🔌 服务端接口对接(service.xpcool.com `/api/service/open/tools/*`) +- 新增 `src/api/openTools.ts`:接口元数据(`OPEN_TOOL_ENDPOINTS`,供「接口状态」页探测)与调用函数 + 共用同一份路径定义,避免改路径忘了改探测列表。5 个接口实测线上 **全部 HTTP 200**。 +- `src/api/client.ts` 重写:`BASE_URL` 回落到 `https://service.xpcool.com`(**不建 .env 也能用**); + 新增 AbortController 超时(10s)、`describeStatus()` 状态码中文翻译(404 → 「接口尚未上线」)、 + 区分超时与网络不可达。 +- 改造 6 个页面接入真实后端(均为「本机 / 服务端」双模式,**默认本机、不自动打网络请求**): + - `ip-info`:服务端识别的来源 IP + 内网判定,另附本机环境(时区/语言/平台/屏幕/UA)。 + **后端不返回归属地,界面如实说明,不做前端猜测。** + - `uuid`:本机 RFC 4122 v4 / 服务端 GoFrame guid,服务端模式并发 n 次(接口一次只返一个)。 + - `random-string`(新工具):本机 Web Crypto(已注明取模偏置量级约 2^-26)/ 服务端 `crypto/rand`, + 长度 1-128,服务端模式数量上限压到 10。 + - `api-status`(新工具):**串行**探测 5 个接口(并发会干扰延迟读数), + `performance.now()` 计时、500ms 封顶刻度条、状态点承载真实语义、窄屏表格转卡片。 + - `hash`:新增「同时用服务端计算 MD5」开关(默认关闭——算哈希是高频纯本地动作, + 不该每次点击都多一次网络往返),做**两端口径对比**,比对统一折小写。 + - `timestamp`:新增服务端时钟卡片,偏移量基于 Unix 秒(**与时区无关**)并补偿半个往返耗时, + 误差上界 `rtt/2`;偏差 > 2s 提示本机时钟可能不准。 +- 未实现接口的工具页(exchange-rate / express-query / phone-locate / network-test / speed-test) + 统一改用 ``,去掉「后端未就绪就禁用按钮」的旧逻辑。 + +### 🧹 修复 +- **空分类渲染缺陷**:health / study / travel 三个分类无任何工具,却仍渲染出空区块与空标题。 + 现分类精简为 6 个(work / dev / design / life / fun / online),`groupedTools()` 过滤空分组。 +- `SideNav.vue` 在 ` diff --git a/package.json b/package.json index c8b4751..7e31ad7 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dca9e07..fcb416e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/api/client.ts b/src/api/client.ts index 37c0aee..84a4f15 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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:10100(service 开发端口)。 * - * 统一响应契约(详见 docs/api-contract.md): - * { "code": 0, "message": "ok", "data": { ... } } - * code === 0 表示成功;非 0 表示失败,message 为可读错误信息。 + * ▍路径前缀 + * service.xpcool.com 把开放接口挂在 /api/service/open 分组下, + * 所以工具接口的完整路径是 /api/service/open/tools/。 + * 前缀如果调整,只改这里一处即可。 + * + * ▍统一响应契约(GoFrame 默认信封): + * { "code": 0, "message": "OK", "data": { ... } } + * code === 0 表示成功;非 0 表示失败,message 为可读错误信息(前端直接展示)。 + * + * ▍约定:新建接口一律 POST,URL 不带任何参数,入参全部走 JSON body。 */ export interface ApiResult { 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 { code: number @@ -34,15 +47,46 @@ interface Envelope { 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( method: 'GET' | 'POST', path: string, params?: Record, body?: unknown, + timeout = DEFAULT_TIMEOUT, ): Promise> { 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( 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 | null = null try { json = (await res.json()) as Envelope } 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(path: string, params?: Record): Promise> { - return request('GET', path, params) + get( + path: string, + params?: Record, + timeout?: number, + ): Promise> { + return request('GET', path, params, undefined, timeout) }, - post(path: string, body?: unknown): Promise> { - return request('POST', path, undefined, body) + post(path: string, body?: unknown, timeout?: number): Promise> { + return request('POST', path, undefined, body, timeout) }, } diff --git a/src/api/openTools.ts b/src/api/openTools.ts new file mode 100644 index 0000000..fb06c7d --- /dev/null +++ b/src/api/openTools.ts @@ -0,0 +1,161 @@ +/** + * service.xpcool.com 开放工具接口封装。 + * + * 对应后端契约:api/open/tools//.go + * 完整路径:POST {VITE_API_BASE}/api/service/open/tools/ + * 全部免鉴权、全部 POST、URL 不带参数,入参走 JSON body。 + * + * 这里把「接口元数据」与「调用函数」放在一起: + * 元数据供「服务端接口状态」页做连通性探测,调用函数供各工具页直接使用, + * 两处共用同一份路径定义,避免改了路径忘了改探测列表。 + */ +import { api, OPEN_API_PREFIX } from './client' +import type { ApiResult } from './client' + +/** 开放工具接口前缀 */ +const TOOLS_PREFIX = `${OPEN_API_PREFIX}/tools` + +// ============================================================ +// 接口元数据 +// ============================================================ + +export interface OpenEndpoint { + /** 工具标识(与路由 path 对应,便于在界面上做跳转) */ + key: string + /** 接口名(显示用) */ + name: string + /** 相对路径(不含基地址) */ + path: string + /** HTTP 方法(当前全部为 POST) */ + method: 'POST' + /** 探测用请求体(各接口的最小合法入参) */ + probeBody: Record +} + +/** + * 全部开放工具接口。 + * 新增接口时在 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> { + return api.post(`${TOOLS_PREFIX}/ip`, {}) +} + +/** 获取服务端当前时间(用于校准本机时钟) */ +export function fetchServerTime(): Promise> { + return api.post(`${TOOLS_PREFIX}/time`, {}) +} + +/** + * 生成唯一 ID。 + * @param short true 返回 8 位短码;false 返回 32 位 ID + */ +export function fetchUuid(short = false): Promise> { + return api.post(`${TOOLS_PREFIX}/uuid`, { short }) +} + +/** + * 计算文本 MD5。 + * @param text 待摘要文本(后端校验非空) + */ +export function fetchMd5(text: string): Promise> { + return api.post(`${TOOLS_PREFIX}/md5`, { text }) +} + +/** + * 生成随机字符串。 + * @param length 长度,后端限制 1-128 + * @param type 字符类型,默认 alnum(字母 + 数字) + */ +export function fetchRandomString( + length: number, + type: RandomType = 'alnum', +): Promise> { + return api.post(`${TOOLS_PREFIX}/random`, { length, type }) +} + +/** 后端随机字符串的长度上限(与 RandomReq 的 v:"max:128" 一致) */ +export const RANDOM_MAX_LENGTH = 128 diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss index 2a791e7..a1dcb9f 100644 --- a/src/assets/styles/index.scss +++ b/src/assets/styles/index.scss @@ -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; + } } diff --git a/src/assets/styles/tdesign-theme.scss b/src/assets/styles/tdesign-theme.scss new file mode 100644 index 0000000..8a1aec5 --- /dev/null +++ b/src/assets/styles/tdesign-theme.scss @@ -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; +} diff --git a/src/assets/styles/tokens.scss b/src/assets/styles/tokens.scss new file mode 100644 index 0000000..97ac951 --- /dev/null +++ b/src/assets/styles/tokens.scss @@ -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; + } +} diff --git a/src/components/common/AppBrand.vue b/src/components/common/AppBrand.vue new file mode 100644 index 0000000..0486245 --- /dev/null +++ b/src/components/common/AppBrand.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/src/components/common/BackendPending.vue b/src/components/common/BackendPending.vue new file mode 100644 index 0000000..54b1a87 --- /dev/null +++ b/src/components/common/BackendPending.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/components/common/CommandPalette.vue b/src/components/common/CommandPalette.vue new file mode 100644 index 0000000..77a6d99 --- /dev/null +++ b/src/components/common/CommandPalette.vue @@ -0,0 +1,470 @@ + + + + + diff --git a/src/components/common/LocaleSwitch.vue b/src/components/common/LocaleSwitch.vue new file mode 100644 index 0000000..afb1b21 --- /dev/null +++ b/src/components/common/LocaleSwitch.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/components/common/ThemeSwitch.vue b/src/components/common/ThemeSwitch.vue new file mode 100644 index 0000000..aca6b77 --- /dev/null +++ b/src/components/common/ThemeSwitch.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/src/composables/useCommandPalette.ts b/src/composables/useCommandPalette.ts new file mode 100644 index 0000000..b90ff72 --- /dev/null +++ b/src/composables/useCommandPalette.ts @@ -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 } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..64929b3 --- /dev/null +++ b/src/config.ts @@ -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 diff --git a/src/directives/reveal.ts b/src/directives/reveal.ts new file mode 100644 index 0000000..ca66455 --- /dev/null +++ b/src/directives/reveal.ts @@ -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 = { + mounted(el: HTMLElement, binding: DirectiveBinding) { + 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) + }, +} diff --git a/src/i18n/helpers.ts b/src/i18n/helpers.ts new file mode 100644 index 0000000..65b8297 --- /dev/null +++ b/src/i18n/helpers.ts @@ -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 { + return i18n.global.t(key, args) +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..03fb3a1 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,97 @@ +import { createI18n } from 'vue-i18n' +import zhCN from './locales/zh-CN' +import enUS from './locales/en-US' +import jaJP from './locales/ja-JP' +import koKR from './locales/ko-KR' + +/** + * 国际化(i18n)基础设施。 + * + * 支持语言:简体中文 / 英语 / 日语 / 韩语。 + * 语言判定优先级:本地存储(用户手动选择) > 浏览器语言 > 默认简体中文。 + * + * 使用方式: + * 模板中 {{ $t('tool.uuid.name') }} + * 脚本中 const { t } = useI18n() → t('tool.uuid.name') + * + * 新增工具/页面文案时,请同时补齐 4 个语言包的同一个 key, + * 否则缺失语言会回退到 fallbackLocale(简体中文),不会报错但会出现中英混排。 + */ + +/** 站点支持的语言列表 */ +export const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ja-JP', 'ko-KR'] as const + +export type AppLocale = (typeof SUPPORTED_LOCALES)[number] + +/** 语言选择本地存储 key(index.html 内联脚本会读取它,用于首屏防闪烁) */ +export const LOCALE_STORAGE_KEY = 'tool-locale' + +/** 兜底语言:简体中文(语言包最完整,作为所有缺失 key 的回退) */ +export const FALLBACK_LOCALE: AppLocale = 'zh-CN' + +const messages = { + 'zh-CN': zhCN, + 'en-US': enUS, + 'ja-JP': jaJP, + 'ko-KR': koKR, +} + +/** 判断给定值是否为受支持的语言代码 */ +export function isAppLocale(value: unknown): value is AppLocale { + return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value) +} + +/** + * 把浏览器语言标签(如 zh-Hant-TW、en-GB)归一化到站点支持的语言。 + * 匹配规则:先精确匹配,再按主语言前缀匹配,最后回退默认语言。 + */ +export function normalizeLocale(raw: string | null | undefined): AppLocale { + if (!raw) return FALLBACK_LOCALE + if (isAppLocale(raw)) return raw + const primary = raw.toLowerCase().split('-')[0] + const matched = SUPPORTED_LOCALES.find((l) => l.toLowerCase().split('-')[0] === primary) + return matched ?? FALLBACK_LOCALE +} + +/** 解析初始语言:本地存储优先,其次浏览器语言 */ +export function resolveInitialLocale(): AppLocale { + try { + const saved = localStorage.getItem(LOCALE_STORAGE_KEY) + if (isAppLocale(saved)) return saved + } catch { + // 隐私模式等场景下 localStorage 不可用,忽略即可 + } + return normalizeLocale(typeof navigator !== 'undefined' ? navigator.language : null) +} + +export const i18n = createI18n({ + // Composition API 模式(Vue 3 推荐),同时开启全局注入让模板可直接用 $t + legacy: false, + globalInjection: true, + locale: resolveInitialLocale(), + fallbackLocale: FALLBACK_LOCALE, + // 缺失 key 时不在控制台刷警告(开发期可临时改回 true 排查) + missingWarn: false, + fallbackWarn: false, + messages, +}) + +/** + * 同步语言到 DOM:写入 html[lang]。 + * html[lang] 会影响字体栈选择(见 tokens.scss)与搜索引擎/读屏软件的语言判断, + * 因此切语言时必须一并更新。 + */ +export function applyLocaleToDocument(locale: AppLocale) { + document.documentElement.setAttribute('lang', locale) +} + +/** 切换语言:更新 i18n 实例、DOM 属性与本地存储 */ +export function setAppLocale(locale: AppLocale) { + i18n.global.locale.value = locale + applyLocaleToDocument(locale) + try { + localStorage.setItem(LOCALE_STORAGE_KEY, locale) + } catch { + // 忽略存储失败 + } +} diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts new file mode 100644 index 0000000..c1189b5 --- /dev/null +++ b/src/i18n/locales/en-US.ts @@ -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', + }, +} diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts new file mode 100644 index 0000000..236568b --- /dev/null +++ b/src/i18n/locales/ja-JP.ts @@ -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: '未提供', + }, +} diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts new file mode 100644 index 0000000..d7f77c0 --- /dev/null +++ b/src/i18n/locales/ko-KR.ts @@ -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: '제공되지 않음', + }, +} diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts new file mode 100644 index 0000000..1ead586 --- /dev/null +++ b/src/i18n/locales/zh-CN.ts @@ -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: '未提供', + }, +} diff --git a/src/layout/MainLayout.vue b/src/layout/MainLayout.vue index ba05bbe..2b861c8 100644 --- a/src/layout/MainLayout.vue +++ b/src/layout/MainLayout.vue @@ -1,164 +1,327 @@ diff --git a/src/layout/SideNav.vue b/src/layout/SideNav.vue index 166ee24..515dca3 100644 --- a/src/layout/SideNav.vue +++ b/src/layout/SideNav.vue @@ -1,42 +1,166 @@ diff --git a/src/views/api-status/ApiStatusView.vue b/src/views/api-status/ApiStatusView.vue new file mode 100644 index 0000000..0ddf1d6 --- /dev/null +++ b/src/views/api-status/ApiStatusView.vue @@ -0,0 +1,434 @@ + + + + + diff --git a/src/views/exchange-rate/ExchangeRateView.vue b/src/views/exchange-rate/ExchangeRateView.vue index 51aa1de..e200e16 100644 --- a/src/views/exchange-rate/ExchangeRateView.vue +++ b/src/views/exchange-rate/ExchangeRateView.vue @@ -1,9 +1,9 @@