feat: 新增 12 个常用工具(UUID/正则/命名/色值/单位/YAML/字数/人民币大写/简繁/日期/密码/HTTP状态码)

This commit is contained in:
夏犀麟 2026-08-21 18:00:10 +08:00
parent d52e85a107
commit 3ba01515ad
17 changed files with 2896 additions and 5 deletions

View File

@ -1,15 +1,16 @@
# 在线工具箱tool.xpcool.com
纯前端在线工具站:**Vite + Vue3 + Pinia + TypeScript + UnoCSS + TDesign**。15 个常用工具,所有文件处理与计算均在浏览器本地完成,**不上传任何用户数据到服务器**。
纯前端在线工具站:**Vite + Vue3 + Pinia + TypeScript + UnoCSS + TDesign**。27 个常用工具,所有文件处理与计算均在浏览器本地完成,**不上传任何用户数据到服务器**(翻译工具除外,见隐私说明)
## 功能特性
- 15 个实用工具:图片压缩/裁剪、二维码生成解析、JSON 格式化、时间戳转换、Base64 编解码、JWT 解析、Cron 表达式、进制转换、哈希计算、身份证解析、地址解析、URL 解析、翻译、图片 OCR 识别
- 27 个实用工具:图片压缩/裁剪、二维码生成解析、JSON 格式化、时间戳转换、Base64 编解码、JWT 解析、Cron 表达式、进制转换、哈希计算、身份证解析、地址解析、URL 解析、翻译、图片 OCR 识别、UUID 生成、正则测试、命名转换、色值转换、单位换算、YAML ↔ JSON、字数统计、人民币大写、简繁转换、日期计算、密码生成、HTTP 状态码速查
- 左侧固定导航 + 中间内容区PC Web 与移动端 H5 响应式适配(移动端为抽屉导航)
- 暗黑 / 浅色主题切换,选择持久化,刷新不丢失
- 每个工具独立路由、独立本地历史记录localStorage
- 所有文件上传支持点击选择 + 拖拽上传,图片自动缩略图回显;图片压缩支持批量、按质量/限制大小、等比缩放与 ZIP 批量下载
- 翻译工具参考有道翻译的交互:中/英/韩/日互译、双栏对照、单词释义(拼音 + 词性释义 + 例句)、真人发音朗读、逐句对照与来源标注
- 开发常用工具全覆盖UUID 批量生成、正则测试与替换预览、命名风格转换、色值转换、单位换算、YAML/JSON 互转、HTTP 状态码速查
- 通用组件:拖拽上传、复制按钮、下载按钮、错误提示、历史记录面板
- 全部能力由浏览器原生 API、WebAssembly 与 npm 前端库实现,无任何 mock 后端接口、无 CDN 脚本引入
@ -25,7 +26,7 @@
| 样式 | UnoCSS 原子类 + SCSS使用 TDesign 设计令牌变量) |
| 路由 | Vue Router 4History 模式) |
工具依赖:`qrcode`、`jsqr`、`cropperjs`、`browser-image-compression`、`tesseract.js`、`cron-parser`、`spark-md5`、`jszip`、`pinyin-pro`(全部通过 npm 安装)。
工具依赖:`qrcode`、`jsqr`、`cropperjs`、`browser-image-compression`、`tesseract.js`、`cron-parser`、`spark-md5`、`jszip`、`pinyin-pro`、`js-yaml`、`opencc-js`(全部通过 npm 安装)。
## 快速开始
@ -68,6 +69,18 @@ pnpm preview
| `/url-parse` | URL 解析 |
| `/translate` | 翻译(中英韩日) |
| `/ocr` | 图片 OCR 识别 |
| `/uuid` | UUID 生成 |
| `/regex-test` | 正则测试 |
| `/case-convert` | 命名风格转换 |
| `/color-convert` | 色值转换 |
| `/unit-convert` | 单位换算 |
| `/yaml-json` | YAML ↔ JSON 转换 |
| `/word-count` | 字数统计 |
| `/rmb-upper` | 人民币大写转换 |
| `/zh-convert` | 简繁转换 |
| `/date-calc` | 日期计算 |
| `/password-gen` | 密码生成器 |
| `/http-status` | HTTP 状态码速查 |
## 目录结构

View File

@ -57,7 +57,7 @@ export interface ToolItem {
2. 在 `tools.ts``tools` 数组末尾追加一条配置,`component` 使用 `() => import('@/views/<tool-name>/<ToolName>View.vue')`
3. 完成——菜单、首页卡片、路由、文档标题自动生效。可运行 `pnpm type-check` 验证。
**注意:** `path` 必须与页面内 `useHistory('xxx')` 的 key 一致(历史记录约定),并避免与现有 15 个 path 重复。
**注意:** `path` 必须与页面内 `useHistory('xxx')` 的 key 一致(历史记录约定),并避免与现有 27 个 path 重复。
### 3.2 历史记录约定
@ -103,6 +103,18 @@ export interface ToolItem {
| URL 解析 | `views/url-parse` | 原生 `URL` / `URLSearchParams` | 无协议输入自动补 `https://`;构造器用 `searchParams.append` 自动编码 |
| 翻译 | `views/translate` | 有道演示接口 + 腾讯翻译君(翻译);`dict.youdao.com/dictvoice`发音Free Dictionary API → Datamuse词典`pinyin-pro`(拼音) | **唯一需要联网的工具**,界面参考有道翻译:语言对/场景下拉、双栏对照、字符计数、单词释义(拼音+词性释义+例句、发音朗读、逐句对照、反馈按钮、来源标注有道AI翻译标准模型/腾讯翻译君)。翻译接口:语言码映射 zh-CN→zh-CHS有道/zh腾讯「自动检测」用脚本特征兜底判定中文/日文/韩文/英文两个接口依次尝试降级免费接口有频率限制411页面已提示。**发音**`dict.youdao.com/dictvoice?audio=<文本>&type=<2=美音|0=中文/韩文>` 免签日文不支持500日文译文改用有道接口返回的签名 `tSpeakUrl`(仅有道成功时可用,否则隐藏按钮)。**词典**:英文单词先查 `api.dictionaryapi.dev`(含音标/例句,部分地区网络不稳),超时降级 `api.datamuse.com/words?sp=<词>&md=d`defs 为 "词性\t释义" 文本,需 split。中文源词拼音由 pinyin-pro 本地生成(`toneType: 'symbol'`)。**已知限制**:有道演示接口不支持 en→jaerrorCode 102自动降级腾讯「逐句对照」按句拆分后逐句翻译并发 3、最多 15 句「AI 润色」仅保留入口(需官方 Key点击展示说明 |
| OCR | `views/ocr` | `tesseract.js`WASM | 见第 5 节离线说明 |
| UUID 生成 | `views/uuid` | 原生 `crypto.getRandomValues` | RFC 4122 v4版本/变体位手工写入);支持数量 1-100、大写、去连字符历史存生成结果文本截断 300 字符),点击按行恢复 |
| 正则测试 | `views/regex-test` | 原生 `RegExp` | exec 循环收集匹配:**无 g 时只取第一处**、空匹配需手动推进 lastIndex 防死循环;高亮用 segments 数组 + span 渲染(禁止 v-html替换用 `text.replace(re, $1)`;历史存 JSON 快照pattern/flags/text解析失败静默忽略 |
| 命名转换 | `views/case-convert` | 纯 TS 分词 | 先按非字母数字切分(中文每汉字一词元),再用插空格法切 camelCase 边界(避免 lookbehind 兼容问题);输出 6 种风格 |
| 色值转换 | `views/color-convert` | 手写 RGB/HSL/HSV 公式 | 解析 #RGB/#RRGGBB/#RRGGBBAA、rgb(a)、hsl(a);原生 `<input type="color">` + 预设色板;预览文字色按相对亮度自动切换(暗黑模式可读) |
| 单位换算 | `views/unit-convert` | 纯 TS 单位表factor 基准换算) | 8 类单位;**温度特殊**C/F/K 三向公式不走 factor结果 `toPrecision(6)` 去尾零;切换类别自动重置为该类前两个单位 |
| YAML ↔ JSON | `views/yaml-json` | `js-yaml`(命名导入 `load`/`dump`,勿用 default import | YAML 未加引号的日期字符串会被隐式解析为日期,页面已提示;空输入/`load` 返回 undefined 需显式报错 |
| 字数统计 | `views/word-count` | 原生正则 + `TextEncoder` | 10 项实时统计;阅读时长 = 汉字/400 + 英文单词/200历史在输入失焦时记录无转换按钮避免打字刷屏 |
| 人民币大写 | `views/rmb-upper` | 纯 TS + BigInt 整数运算 | 金额按字符串处理并转「整数 + 分」的整数运算,**避免浮点精度**;每 4 位一组(个/万/亿/万亿组间补零规则已用测试样例覆盖0/10/1001/100000/负数/角分) |
| 简繁转换 | `views/zh-convert` | `opencc-js``import * as OpenCC`Converter 顶层创建复用) | cn↔tw 标准映射,不含台湾用语变体;**体积大**(懒加载 chunk 约 500KB gzip如需减小可改 CustomConverter 按需加载词典 |
| 日期计算 | `views/date-calc` | 原生 Date本地时间构造 | 三个 tab差值/推算/年龄生肖。`parseDate` 用 `new Date(y, m-1, d)` + 回读校验防时区偏移与 2 月 30 类非法日期;月/年推算先归 1 号再 setMonth/setFullYear溢出钳制到目标月最后一天生肖 `((year-4)%12+12)%12` 正模 |
| 密码生成 | `views/password-gen` | `crypto.getRandomValues` + 拒绝采样 | 每种选中字符集至少出现一次 + Fisher-Yates 洗牌t-slider 数值用外部 `<span>` 显示label 不能传函数);强度 = 长度×log2(池大小) 分四档 |
| HTTP 状态码 | `views/http-status` | 内置静态数据56 条1xx-5xx | 搜索code/短语/中文)+ 分类筛选;历史存搜索词 |
## 5. OCR 特殊说明(重要)
@ -165,4 +177,4 @@ pnpm preview # 预览 dist
- **PWA / 离线**:可接入 `vite-plugin-pwa`,配合第 5 节 OCR 离线方案实现完全离线工具站。
- **国际化**:文案目前硬编码中文,若需 i18n 建议 vue-i18n工具注册表的 `name/desc` 改为 key。
- **体积优化**TDesign 目前全量注册(约 1.4MB gzip 373KB若追求极致可改按需引入unplugin-vue-components + TDesignResolver
- **更多工具**色值转换、UUID 生成、正则测试、YAML 转换js-yaml图片格式互转canvas等均可按现有模式快速复制。
- **更多工具**图片格式互转canvas、Markdown 预览markdown-it、SQL 格式化sql-formatter、CSS 渐变生成器、番茄钟、BMI/贷款计算器等均可按现有模式快速复制。

View File

@ -12,11 +12,14 @@
"type-check": "vue-tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@types/js-yaml": "^4.0.9",
"browser-image-compression": "^2.0.2",
"cron-parser": "^4.9.0",
"cropperjs": "^1.6.2",
"js-yaml": "^5.3.0",
"jsqr": "^1.4.0",
"jszip": "^3.10.1",
"opencc-js": "^1.4.1",
"pinia": "^3.0.1",
"pinyin-pro": "^3.29.3",
"qrcode": "^1.5.4",

32
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
browser-image-compression:
specifier: ^2.0.2
version: 2.0.2
@ -17,12 +20,18 @@ importers:
cropperjs:
specifier: ^1.6.2
version: 1.6.2
js-yaml:
specifier: ^5.3.0
version: 5.3.0
jsqr:
specifier: ^1.4.0
version: 1.4.0
jszip:
specifier: ^3.10.1
version: 3.10.1
opencc-js:
specifier: ^1.4.1
version: 1.4.1
pinia:
specifier: ^3.0.1
version: 3.0.4(typescript@5.7.3)(vue@3.5.41(typescript@5.7.3))
@ -691,6 +700,9 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
@ -881,6 +893,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@ -1121,6 +1136,10 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-yaml@5.3.0:
resolution: {integrity: sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==}
hasBin: true
jsqr@1.4.0:
resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==}
@ -1205,6 +1224,9 @@ packages:
ofetch@1.5.1:
resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==}
opencc-js@1.4.1:
resolution: {integrity: sha512-2lPLcrg7cnh1ATSduOxhnk/jq6KlR6w+5Ihl4wF7Z5e5Rva4vxTyugV5rZ7EiUCOZl+EDOLetIix+BhgYDCC+A==}
opencollective-postinstall@2.0.3:
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
hasBin: true
@ -1912,6 +1934,8 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/js-yaml@4.0.9': {}
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.25
@ -2210,6 +2234,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.2
argparse@2.0.1: {}
balanced-match@1.0.2: {}
binary-extensions@2.3.0: {}
@ -2452,6 +2478,10 @@ snapshots:
jiti@2.7.0: {}
js-yaml@5.3.0:
dependencies:
argparse: 2.0.1
jsqr@1.4.0: {}
jszip@3.10.1:
@ -2527,6 +2557,8 @@ snapshots:
node-fetch-native: 1.6.7
ufo: 1.6.4
opencc-js@1.4.1: {}
opencollective-postinstall@2.0.3: {}
p-limit@2.3.0:

View File

@ -15,6 +15,15 @@ import {
LinkIcon,
TranslateIcon,
BrowseIcon,
RefreshIcon,
ControlPlatformIcon,
Palette1Icon,
SwapIcon,
FileCodeIcon,
ChartIcon,
MoneyIcon,
InternetIcon,
KeyIcon,
} from 'tdesign-icons-vue-next'
/**
@ -145,4 +154,88 @@ export const tools: ToolItem[] = [
icon: BrowseIcon,
component: () => import('@/views/ocr/OcrView.vue'),
},
{
path: 'uuid',
name: 'UUID 生成',
desc: '批量生成 UUID v4支持大小写与去连字符',
icon: RefreshIcon,
component: () => import('@/views/uuid/UuidView.vue'),
},
{
path: 'regex-test',
name: '正则测试',
desc: '实时测试正则匹配、分组与替换,匹配高亮显示',
icon: ControlPlatformIcon,
component: () => import('@/views/regex-test/RegexTestView.vue'),
},
{
path: 'case-convert',
name: '命名转换',
desc: '驼峰、蛇形、短横线等命名风格一键互转',
icon: CodeIcon,
component: () => import('@/views/case-convert/CaseConvertView.vue'),
},
{
path: 'color-convert',
name: '色值转换',
desc: 'HEX、RGB、HSL、HSV 互转,取色器 + 预设色板',
icon: Palette1Icon,
component: () => import('@/views/color-convert/ColorConvertView.vue'),
},
{
path: 'unit-convert',
name: '单位换算',
desc: '长度、质量、温度、数据存储等 8 类单位换算',
icon: SwapIcon,
component: () => import('@/views/unit-convert/UnitConvertView.vue'),
},
{
path: 'yaml-json',
name: 'YAML ↔ JSON',
desc: 'YAML 与 JSON 双向转换,报错定位清晰',
icon: FileCodeIcon,
component: () => import('@/views/yaml-json/YamlJsonView.vue'),
},
{
path: 'word-count',
name: '字数统计',
desc: '统计字符、汉字、单词、段落与阅读时长',
icon: ChartIcon,
component: () => import('@/views/word-count/WordCountView.vue'),
},
{
path: 'rmb-upper',
name: '人民币大写',
desc: '数字金额转人民币大写,处理角分与负数',
icon: MoneyIcon,
component: () => import('@/views/rmb-upper/RmbUpperView.vue'),
},
{
path: 'zh-convert',
name: '简繁转换',
desc: '简体与繁体中文互转opencc 词库)',
icon: TranslateIcon,
component: () => import('@/views/zh-convert/ZhConvertView.vue'),
},
{
path: 'date-calc',
name: '日期计算',
desc: '日期差值、推算与年龄生肖星座计算',
icon: CalendarIcon,
component: () => import('@/views/date-calc/DateCalcView.vue'),
},
{
path: 'password-gen',
name: '密码生成',
desc: '随机强密码批量生成,含强度评估',
icon: KeyIcon,
component: () => import('@/views/password-gen/PasswordGenView.vue'),
},
{
path: 'http-status',
name: 'HTTP 状态码',
desc: '常用 HTTP 状态码速查,支持搜索与分类',
icon: InternetIcon,
component: () => import('@/views/http-status/HttpStatusView.vue'),
},
]

View File

@ -0,0 +1,157 @@
<!-- 命名转换把输入串切分为词元实时生成 camelCase / PascalCase / snake_case / kebab-case 6 种风格 -->
<template>
<div class="page-wrap">
<h2 class="page-title">命名转换</h2>
<p class="page-desc">输入变量名或短语实时转换为 camelCasePascalCasesnake_casekebab-case 6 种常见命名风格</p>
<t-card class="tool-section" title="输入">
<t-input
v-model="input"
placeholder="输入变量名,例如 my_user_name 或 myUserName"
clearable
@enter="saveHistory"
/>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="words.length" class="tool-section" title="转换结果">
<div class="convert-list">
<div v-for="row in resultRows" :key="row.name" class="convert-row">
<span class="convert-label">{{ row.name }}</span>
<code class="convert-value">{{ row.value }}</code>
<CopyButton :text="row.value" label="复制" />
</div>
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
const history = useHistory('case-convert')
const input = ref('')
// error
const error = ref('')
/**
* 将输入切分为全小写词元数组
* 先按非字母数字切分下划线/连字符/空格等作为分隔符中文每个汉字视为独立词元
* 再把连续字母数字串按 camelCase 边界小写/数字大写字母数字数字字母细分
*/
function tokenize(s: string): string[] {
const words: string[] = []
const segments = s.match(/[a-zA-Z0-9]+|[\u4e00-\u9fa5]/g) ?? []
for (const seg of segments) {
if (/^[\u4e00-\u9fa5]$/.test(seg)) {
words.push(seg)
continue
}
//
const parts = seg
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Za-z])(\d)/g, '$1 $2')
.replace(/(\d)([A-Za-z])/g, '$1 $2')
.split(/\s+/)
.map((w) => w.toLowerCase())
words.push(...parts)
}
return words
}
const words = computed(() => tokenize(input.value))
/** 单词首字母大写(对中文词元无副作用) */
function capitalize(w: string): string {
return w.charAt(0).toUpperCase() + w.slice(1)
}
/** 6 种命名风格的实时输出 */
const outputs = computed(() => {
const ws = words.value
return {
camel: ws.map((w, i) => (i === 0 ? w : capitalize(w))).join(''),
pascal: ws.map(capitalize).join(''),
snake: ws.join('_'),
upperSnake: ws.join('_').toUpperCase(),
kebab: ws.join('-'),
space: ws.join(' '),
}
})
/** 供模板遍历的展示行(风格名固定宽度放左侧) */
const resultRows = computed(() => [
{ name: 'camelCase', value: outputs.value.camel },
{ name: 'PascalCase', value: outputs.value.pascal },
{ name: 'snake_case', value: outputs.value.snake },
{ name: 'UPPER_SNAKE_CASE', value: outputs.value.upperSnake },
{ name: 'kebab-case', value: outputs.value.kebab },
{ name: '空格分隔', value: outputs.value.space },
])
/** 回车把当前输入存入历史 */
function saveHistory() {
if (input.value.trim()) history.add(input.value)
}
function onHistorySelect(text: string) {
input.value = text
}
</script>
<style scoped>
.convert-list {
display: flex;
flex-direction: column;
}
.convert-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--td-border-level-1-color);
}
.convert-row:last-child {
border-bottom: none;
}
.convert-label {
width: 150px;
flex-shrink: 0;
font-size: 13px;
font-weight: 600;
color: var(--td-text-color-secondary);
}
.convert-value {
flex: 1;
min-width: 0;
padding: 6px 10px;
border-radius: 6px;
background: var(--td-bg-color-secondarycontainer);
font-family: monospace;
font-size: 13px;
color: var(--td-text-color-primary);
overflow-wrap: anywhere;
}
@media (max-width: 767px) {
/* 窄屏下风格名与内容上下排列,避免横向滚动 */
.convert-row {
flex-wrap: wrap;
}
.convert-label {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,400 @@
<template>
<div class="page-wrap">
<h2 class="page-title">色值转换</h2>
<p class="page-desc">HEX / RGB / HSL / HSV 相互转换支持取色器与预设色板全部计算在本地完成</p>
<t-card class="tool-section" title="输入颜色">
<t-input
v-model="input"
placeholder="#3B82F6 / rgb(59,130,246) / hsl(217,91%,60%)"
clearable
@change="parse"
/>
<div class="form-row" style="margin-top: 12px">
<span class="form-label">取色</span>
<input v-model="picker" type="color" class="color-picker" @change="onPickerChange" />
</div>
<div class="form-row" style="margin-top: 12px">
<span class="form-label">预设</span>
<div class="preset-row">
<button
v-for="p in presets"
:key="p"
type="button"
class="preset-dot"
:style="{ backgroundColor: p }"
:title="p"
@click="applyPreset(p)"
/>
</div>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="result" class="tool-section" title="转换结果">
<div class="preview-block" :style="previewStyle">{{ result.hex }}</div>
<div v-for="row in outputRows" :key="row.label" class="output-row">
<span class="output-label">{{ row.label }}</span>
<div class="code-block output-value">{{ row.value }}</div>
<CopyButton :text="row.value" :label="`复制 ${row.label}`" />
</div>
<div class="channel-grid">
<div v-for="c in channels" :key="c.label" class="channel-item">
<span class="channel-label">{{ c.label }}</span>
<span class="channel-value">{{ c.value }}</span>
</div>
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
// RGB/HSL/HSV HEX/RGB(A)/HSL(A)
import { computed, ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
interface Rgb {
r: number
g: number
b: number
}
interface ColorResult {
hex: string
rgb: string
hsl: string
hsv: string
r: number
g: number
b: number
h: number
s: number
l: number
v: number
}
const input = ref('')
const picker = ref('#3B82F6')
const error = ref('')
const result = ref<ColorResult | null>(null)
const history = useHistory('color-convert')
//
const presets = [
'#3B82F6',
'#EF4444',
'#22C55E',
'#F59E0B',
'#8B5CF6',
'#EC4899',
'#14B8A6',
'#F97316',
'#64748B',
'#0F172A',
]
/** 去掉多余尾零:先 toFixed(1) 保留 1 位小数,再经 Number 转回最短表示 */
function fmt(n: number): string {
return String(Number(n.toFixed(1)))
}
function toHex(rgb: Rgb): string {
const part = (v: number): string => v.toString(16).padStart(2, '0')
return `#${part(rgb.r)}${part(rgb.g)}${part(rgb.b)}`
}
/** HSL → RGB 标准公式h 0-360s/l 0-1返回 0-255 整数 */
function hslToRgb(h: number, s: number, l: number): Rgb {
const c = (1 - Math.abs(2 * l - 1)) * s
const hp = h / 60
const x = c * (1 - Math.abs((hp % 2) - 1))
let r = 0
let g = 0
let b = 0
if (hp < 1) {
r = c
g = x
} else if (hp < 2) {
r = x
g = c
} else if (hp < 3) {
g = c
b = x
} else if (hp < 4) {
g = x
b = c
} else if (hp < 5) {
r = x
b = c
} else {
r = c
b = x
}
const m = l - c / 2
return { r: Math.round((r + m) * 255), g: Math.round((g + m) * 255), b: Math.round((b + m) * 255) }
}
/** RGB → HSL 标准公式h 0-360、s/l 0-100 */
function rgbToHsl(rgb: Rgb): { h: number; s: number; l: number } {
const r = rgb.r / 255
const g = rgb.g / 255
const b = rgb.b / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
const d = max - min
if (d === 0) return { h: 0, s: 0, l: l * 100 } //
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
let h: number
if (max === r) h = (g - b) / d + (g < b ? 6 : 0)
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
return { h: h * 60, s: s * 100, l: l * 100 }
}
/** RGB → HSV 标准公式h 0-360、s/v 0-100 */
function rgbToHsv(rgb: Rgb): { h: number; s: number; v: number } {
const r = rgb.r / 255
const g = rgb.g / 255
const b = rgb.b / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
let h = 0
if (d !== 0) {
if (max === r) h = (g - b) / d + (g < b ? 6 : 0)
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h *= 60
}
const s = max === 0 ? 0 : (d / max) * 100
return { h, s, v: max * 100 }
}
/**
* 解析颜色字符串支持 #RGB#RRGGBB#RRGGBBAAAlpha 忽略
* rgb()/rgba()hsl()/hsla()失败返回 null
*/
function parseColor(raw: string): Rgb | null {
const s = raw.trim().toLowerCase()
if (!s) return null
// HEX#RGB
let m = /^#([0-9a-f]{3})$/.exec(s)
if (m) {
const d = m[1]
return {
r: parseInt(d[0] + d[0], 16),
g: parseInt(d[1] + d[1], 16),
b: parseInt(d[2] + d[2], 16),
}
}
// HEX#RRGGBB #RRGGBBAA Alpha
m = /^#([0-9a-f]{6})(?:[0-9a-f]{2})?$/.exec(s)
if (m) {
return {
r: parseInt(m[1].slice(0, 2), 16),
g: parseInt(m[1].slice(2, 4), 16),
b: parseInt(m[1].slice(4, 6), 16),
}
}
// rgb()/rgba()Alpha 0-255
m = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+%?)?\s*\)$/.exec(s)
if (m) {
const r = Number(m[1])
const g = Number(m[2])
const b = Number(m[3])
if (r <= 255 && g <= 255 && b <= 255) return { r, g, b }
return null
}
// hsl()/hsla() 0-360/ 0-100
m = /^hsla?\(\s*(-?[\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*[\d.]+%?)?\s*\)$/.exec(s)
if (m) {
const h = ((Number(m[1]) % 360) + 360) % 360
const sat = Number(m[2])
const light = Number(m[3])
if (sat > 100 || light > 100) return null
return hslToRgb(h, sat / 100, light / 100)
}
return null
}
function parse() {
error.value = ''
const raw = input.value.trim()
if (!raw) {
result.value = null
return
}
const rgb = parseColor(raw)
if (!rgb) {
result.value = null
error.value = '无法解析该颜色,支持 #RGB、#RRGGBB、#RRGGBBAA、rgb()/rgba()、hsl()/hsla() 格式'
return
}
const hsl = rgbToHsl(rgb)
const hsv = rgbToHsv(rgb)
result.value = {
r: rgb.r,
g: rgb.g,
b: rgb.b,
hex: toHex(rgb),
rgb: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
hsl: `hsl(${fmt(hsl.h)}, ${fmt(hsl.s)}%, ${fmt(hsl.l)}%)`,
hsv: `hsv(${fmt(hsv.h)}, ${fmt(hsv.s)}%, ${fmt(hsv.v)}%)`,
h: hsl.h,
s: hsl.s,
l: hsl.l,
v: hsv.v,
}
history.add(raw)
}
function onPickerChange() {
//
input.value = picker.value
parse()
}
function applyPreset(hex: string) {
input.value = hex
parse()
}
function onHistorySelect(text: string) {
input.value = text
parse()
}
const outputRows = computed<{ label: string; value: string }[]>(() => {
const c = result.value
if (!c) return []
return [
{ label: 'HEX', value: c.hex },
{ label: 'RGB', value: c.rgb },
{ label: 'HSL', value: c.hsl },
{ label: 'HSV', value: c.hsv },
]
})
/** 预览色块样式:背景为当前色,文字按相对亮度自动选择深/浅色,两种主题下都可读 */
const previewStyle = computed((): Record<string, string> => {
const c = result.value
if (!c) return {}
const lum = (0.299 * c.r + 0.587 * c.g + 0.114 * c.b) / 255
return {
backgroundColor: c.hex,
color: lum > 0.5 ? 'var(--td-text-color-primary)' : 'var(--td-text-color-anti)',
}
})
const channels = computed<{ label: string; value: number }[]>(() => {
const c = result.value
if (!c) return []
return [
{ label: 'R', value: c.r },
{ label: 'G', value: c.g },
{ label: 'B', value: c.b },
{ label: 'H', value: Math.round(c.h) },
{ label: 'S', value: Math.round(c.s) },
{ label: 'L', value: Math.round(c.l) },
{ label: 'V', value: Math.round(c.v) },
]
})
</script>
<style scoped>
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.color-picker {
width: 48px;
height: 32px;
padding: 0;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
cursor: pointer;
}
.preset-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preset-dot {
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
border: 1px solid var(--td-border-level-1-color);
cursor: pointer;
}
.preview-block {
height: 96px;
border-radius: 8px;
border: 1px solid var(--td-border-level-1-color);
display: flex;
align-items: center;
justify-content: center;
font-family: monospace;
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.output-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.output-label {
width: 40px;
flex-shrink: 0;
font-size: 13px;
font-weight: 600;
color: var(--td-text-color-secondary);
}
.output-value {
flex: 1;
min-width: 0;
}
.channel-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
margin-top: 16px;
}
.channel-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
}
.channel-label {
font-size: 13px;
font-weight: 600;
color: var(--td-text-color-secondary);
}
.channel-value {
font-family: monospace;
font-size: 13px;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,409 @@
<!-- 日期计算日期差值 / 日期推算 / 年龄生肖全部按本地时间计算无时区偏移 -->
<template>
<div class="page-wrap">
<h2 class="page-title">日期计算</h2>
<p class="page-desc">计算两个日期的差值按天///年推算日期以及年龄生肖与星座</p>
<t-tabs v-model="tab">
<!-- 日期差值 -->
<t-tab-panel value="date-diff" label="日期差值">
<t-card class="tool-section" title="选择两个日期">
<div class="form-row">
<span class="form-label">日期 A</span>
<t-date-picker
v-model="dateA"
allow-input
clearable
format="YYYY-MM-DD"
value-type="YYYY-MM-DD"
placeholder="选择日期 A"
/>
</div>
<div class="form-row" style="margin-top: 12px">
<span class="form-label">日期 B</span>
<t-date-picker
v-model="dateB"
allow-input
clearable
format="YYYY-MM-DD"
value-type="YYYY-MM-DD"
placeholder="选择日期 B"
/>
</div>
<div class="form-actions">
<t-button theme="primary" @click="calcDiff">计算</t-button>
</div>
</t-card>
<ErrorAlert :message="error1" @close="error1 = ''" />
<t-card v-if="diffResult" class="tool-section" title="计算结果">
<div class="code-block">{{ diffResult }}</div>
<div class="form-actions">
<CopyButton :text="diffResult" />
</div>
</t-card>
</t-tab-panel>
<!-- 日期推算 -->
<t-tab-panel value="date-add" label="日期推算">
<t-card class="tool-section" title="输入基准日期与偏移量">
<div class="form-row">
<span class="form-label">基准日期</span>
<t-date-picker
v-model="baseDate"
allow-input
clearable
format="YYYY-MM-DD"
value-type="YYYY-MM-DD"
placeholder="选择基准日期"
/>
</div>
<div class="form-row" style="margin-top: 12px">
<span class="form-label">偏移量</span>
<t-input-number v-model="addNum" :min="1" :max="100000" theme="normal" />
<t-select v-model="addUnit" :options="unitOptions" class="form-select" />
<t-radio-group v-model="addDir" variant="default-filled">
<t-radio-button value="after">之后</t-radio-button>
<t-radio-button value="before">之前</t-radio-button>
</t-radio-group>
</div>
<p class="form-tip">
月份/年份加减时若原日期超过目标月最后一天 1 31 日加 1 个月结果自动钳制到目标月最后一天
</p>
<div class="form-actions">
<t-button theme="primary" @click="calcAdd">计算</t-button>
</div>
</t-card>
<ErrorAlert :message="error2" @close="error2 = ''" />
<t-card v-if="addResult" class="tool-section" title="推算结果">
<div class="code-block">{{ addResult }}</div>
<div class="form-actions">
<CopyButton :text="addResult" />
</div>
</t-card>
</t-tab-panel>
<!-- 年龄生肖 -->
<t-tab-panel value="age" label="年龄生肖">
<t-card class="tool-section" title="选择出生日期">
<div class="form-row">
<span class="form-label">出生日期</span>
<t-date-picker
v-model="birthDate"
allow-input
clearable
format="YYYY-MM-DD"
value-type="YYYY-MM-DD"
placeholder="选择出生日期"
/>
</div>
<div class="form-actions">
<t-button theme="primary" @click="calcAge">计算</t-button>
</div>
</t-card>
<ErrorAlert :message="error3" @close="error3 = ''" />
<t-card v-if="ageResult" class="tool-section" title="年龄与生肖">
<div class="code-block">{{ ageResult }}</div>
<div class="form-actions">
<CopyButton :text="ageResult" />
</div>
</t-card>
</t-tab-panel>
</t-tabs>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
/** 历史记录结构:不同面板各存所需字段,恢复时按 tab 回填并计算 */
interface DateCalcHistory {
tab: string
a?: string
b?: string
base?: string
n?: number
unit?: string
dir?: string
birth?: string
}
const history = useHistory('date-calc')
const tab = ref('date-diff')
/* ---------- 公共日期工具 ---------- */
/** 解析 YYYY-MM-DD 字符串;按本地时间构造,避免 new Date('YYYY-MM-DD') 按 UTC 解析导致的时区偏移 */
function parseDate(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim())
if (!m) return null
const y = Number(m[1])
const mo = Number(m[2])
const d = Number(m[3])
const date = new Date(y, mo - 1, d)
// 2023-02-30
if (date.getFullYear() !== y || date.getMonth() !== mo - 1 || date.getDate() !== d) return null
return date
}
/** 输出 YYYY-MM-DD 字符串 */
function formatDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** 返回「星期X」 */
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']
function weekdayName(d: Date): string {
return `星期${WEEKDAYS[d.getDay()]}`
}
/** 今天的 YYYY-MM-DD 字符串(本地时间) */
function todayStr(): string {
return formatDate(new Date())
}
/** 两个日期相差的天数:用 UTC 毫秒差计算,避免本地夏令时导致的 23/25 小时天误差 */
function diffDays(a: Date, b: Date): number {
return Math.round(
(Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()) -
Date.UTC(a.getFullYear(), a.getMonth(), a.getDate())) /
86400000,
)
}
/* ---------- 日期差值 ---------- */
const dateA = ref(todayStr())
const dateB = ref(todayStr())
const error1 = ref('')
const diffResult = ref('')
function calcDiff() {
error1.value = ''
diffResult.value = ''
const a = parseDate(dateA.value)
const b = parseDate(dateB.value)
if (!a || !b) {
error1.value = '请选择合法的日期'
return
}
const diff = diffDays(a, b)
const absDays = Math.abs(diff)
// 365.25 /30.44 /
const years = Math.floor(absDays / 365.25)
const months = Math.floor((absDays - years * 365.25) / 30.44)
const days = Math.round(absDays - years * 365.25 - months * 30.44)
const relation =
diff > 0
? `${dateB.value}${dateA.value}`
: diff < 0
? `${dateB.value}${dateA.value}`
: '两个日期相同'
diffResult.value = [
`相差天数:${absDays} 天(${relation}`,
`${years}${months} 个月 ${days}`,
`相差周数:${(diff / 7).toFixed(1)}`,
`${dateA.value}${weekdayName(a)}${dateB.value}${weekdayName(b)}`,
].join('\n')
history.add(JSON.stringify({ tab: 'date-diff', a: dateA.value, b: dateB.value }))
}
/* ---------- 日期推算 ---------- */
const baseDate = ref(todayStr())
const addNum = ref(1)
const addUnit = ref('day')
const addDir = ref('after')
const error2 = ref('')
const addResult = ref('')
const unitOptions = [
{ label: '天', value: 'day' },
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' },
]
/** 按整月/整年加减;原日期超过目标月最后一天(如 1 月 31 日加 1 个月)时钳制到当月最后一天 */
function addMonthsOrYears(base: Date, delta: number, unit: 'month' | 'year'): Date {
const d = new Date(base)
const day = base.getDate()
// 1 setMonth/setFullYear
d.setDate(1)
if (unit === 'month') d.setMonth(d.getMonth() + delta)
else d.setFullYear(d.getFullYear() + delta)
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
d.setDate(Math.min(day, lastDay))
return d
}
function calcAdd() {
error2.value = ''
addResult.value = ''
const base = parseDate(baseDate.value)
if (!base) {
error2.value = '请选择合法的基准日期'
return
}
const n = addNum.value
if (!Number.isFinite(n) || n <= 0) {
error2.value = '偏移量必须是正整数'
return
}
const sign = addDir.value === 'after' ? 1 : -1
let result: Date
if (addUnit.value === 'month' || addUnit.value === 'year') {
result = addMonthsOrYears(base, sign * n, addUnit.value)
} else {
// /1 = 86400000
const ms = n * (addUnit.value === 'week' ? 7 : 1) * 86400000
result = new Date(base.getTime() + sign * ms)
}
addResult.value = `目标日期:${formatDate(result)}${weekdayName(result)}`
history.add(
JSON.stringify({ tab: 'date-add', base: baseDate.value, n, unit: addUnit.value, dir: addDir.value }),
)
}
/* ---------- 年龄生肖 ---------- */
const birthDate = ref('')
const error3 = ref('')
const ageResult = ref('')
const SHENGXIAO = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪']
/** 生肖:公式 (year - 4) % 12负年份需先取正模再查表 */
function zodiac(year: number): string {
return SHENGXIAO[(((year - 4) % 12) + 12) % 12]
}
/** 星座按月日区间查表12.221.19 为跨年的摩羯座,单独判断 */
function constellation(d: Date): string {
const v = (d.getMonth() + 1) * 100 + d.getDate()
const table: Array<{ name: string; from: number; to: number }> = [
{ name: '水瓶座', from: 120, to: 218 },
{ name: '双鱼座', from: 219, to: 320 },
{ name: '白羊座', from: 321, to: 419 },
{ name: '金牛座', from: 420, to: 520 },
{ name: '双子座', from: 521, to: 621 },
{ name: '巨蟹座', from: 622, to: 722 },
{ name: '狮子座', from: 723, to: 822 },
{ name: '处女座', from: 823, to: 922 },
{ name: '天秤座', from: 923, to: 1023 },
{ name: '天蝎座', from: 1024, to: 1122 },
{ name: '射手座', from: 1123, to: 1221 },
]
if (v >= 1222 || v <= 119) return '摩羯座'
return table.find((c) => v >= c.from && v <= c.to)?.name ?? '摩羯座'
}
function calcAge() {
error3.value = ''
ageResult.value = ''
const birth = parseDate(birthDate.value)
if (!birth) {
error3.value = '请选择合法的出生日期'
return
}
const now = new Date()
//
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
if (birth.getTime() > today.getTime()) {
error3.value = '出生日期不能晚于今天'
return
}
// 1
let fullYears = today.getFullYear() - birth.getFullYear()
if (today.getTime() < new Date(today.getFullYear(), birth.getMonth(), birth.getDate()).getTime()) {
fullYears--
}
// //
let y = today.getFullYear() - birth.getFullYear()
let m = today.getMonth() - birth.getMonth()
let d = today.getDate() - birth.getDate()
if (d < 0) {
m--
d += new Date(today.getFullYear(), today.getMonth(), 0).getDate()
}
if (m < 0) {
y--
m += 12
}
//
const lived = diffDays(birth, today) + 1
ageResult.value = [
`周岁:${fullYears}`,
`精确年龄:${y}${m} 个月 ${d}`,
`已活天数:${lived} 天(含今天)`,
`生肖:${zodiac(birth.getFullYear())}`,
`星座:${constellation(birth)}`,
].join('\n')
history.add(JSON.stringify({ tab: 'age', birth: birthDate.value }))
}
function onHistorySelect(text: string) {
// JSON tab JSON
try {
const h = JSON.parse(text) as Partial<DateCalcHistory> | null
if (!h || typeof h.tab !== 'string') return
if (h.tab === 'date-diff' && typeof h.a === 'string' && typeof h.b === 'string') {
tab.value = 'date-diff'
dateA.value = h.a
dateB.value = h.b
calcDiff()
} else if (h.tab === 'date-add' && typeof h.base === 'string') {
tab.value = 'date-add'
baseDate.value = h.base
if (typeof h.n === 'number') addNum.value = h.n
if (h.unit === 'day' || h.unit === 'week' || h.unit === 'month' || h.unit === 'year') {
addUnit.value = h.unit
}
if (h.dir === 'after' || h.dir === 'before') addDir.value = h.dir
calcAdd()
} else if (h.tab === 'age' && typeof h.birth === 'string') {
tab.value = 'age'
birthDate.value = h.birth
calcAge()
}
} catch {
//
}
}
</script>
<style scoped>
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.form-select {
width: 110px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.form-tip {
margin: 12px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
/* 日期选择器:窄屏下占满可用宽度,避免固定宽度撑出横向滚动条 */
:deep(.t-date-picker) {
width: 100%;
max-width: 340px;
}
</style>

View File

@ -0,0 +1,274 @@
<!-- HTTP 状态码内置常用状态码表支持按分类与关键词状态码/短语/中文说明检索与复制 -->
<template>
<div class="page-wrap">
<h2 class="page-title">HTTP 状态码</h2>
<p class="page-desc">查询常用 HTTP 状态码的含义支持按状态码英文短语与中文说明搜索</p>
<t-card class="tool-section" title="查询">
<div class="form-row">
<t-input
v-model="search"
class="form-input"
placeholder="搜索状态码、短语或说明,例如 404 / Not Found / 限流"
clearable
@enter="saveHistory"
/>
<t-select v-model="category" :options="categoryOptions" class="form-select" />
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card class="tool-section" title="状态码列表">
<p class="summary"> {{ filtered.length }} </p>
<template v-if="filtered.length">
<div v-for="g in grouped" :key="g.key" class="status-group">
<h3 class="status-group-title">{{ g.title }}</h3>
<div class="status-list">
<div v-for="s in g.items" :key="s.code" class="status-row">
<span class="status-code">{{ s.code }}</span>
<span class="status-phrase">{{ s.phrase }}</span>
<span class="status-desc">{{ s.desc }}</span>
<t-button
variant="text"
size="small"
shape="square"
title="复制"
@click="copyText(`${s.code} ${s.phrase}`)"
>
<template #icon><CopyIcon /></template>
</t-button>
</div>
</div>
</div>
</template>
<p v-else class="empty-tip">未找到匹配的状态码换个关键词试试</p>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { CopyIcon } from 'tdesign-icons-vue-next'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
import { copyText } from '@/composables/useClipboard'
const history = useHistory('http-status')
interface StatusItem {
/** 状态码 */
code: number
/** 英文短语 */
phrase: string
/** 中文说明 */
desc: string
}
/** 常用 HTTP 状态码表(覆盖 1xx5xx 全部常用码,共 56 条) */
const STATUS_LIST: StatusItem[] = [
// 1xx
{ code: 100, phrase: 'Continue', desc: '继续:请求已收到,客户端应继续发送剩余部分' },
{ code: 101, phrase: 'Switching Protocols', desc: '切换协议:服务器同意切换协议(如升级到 WebSocket' },
{ code: 102, phrase: 'Processing', desc: '处理中服务器已收到请求并正在处理WebDAV' },
{ code: 103, phrase: 'Early Hints', desc: '早期提示:在最终响应前返回提示头,用于预加载资源' },
// 2xx
{ code: 200, phrase: 'OK', desc: '请求成功' },
{ code: 201, phrase: 'Created', desc: '已创建:请求成功且创建了新资源' },
{ code: 202, phrase: 'Accepted', desc: '已接受:请求已被接受,但尚未处理完成' },
{ code: 203, phrase: 'Non-Authoritative Information', desc: '非权威信息:返回内容来自缓存或代理,而非源服务器' },
{ code: 204, phrase: 'No Content', desc: '无内容:请求成功,但响应体为空' },
{ code: 205, phrase: 'Reset Content', desc: '重置内容:请求成功,客户端应重置表单视图' },
{ code: 206, phrase: 'Partial Content', desc: '部分内容:成功返回范围请求的部分数据(断点续传)' },
// 3xx
{ code: 300, phrase: 'Multiple Choices', desc: '多种选择:请求有多个可选响应,需用户或客户端选择' },
{ code: 301, phrase: 'Moved Permanently', desc: '永久重定向:资源已永久移动到新地址' },
{ code: 302, phrase: 'Found', desc: '临时重定向:资源暂时位于新地址' },
{ code: 303, phrase: 'See Other', desc: '查看其他:应使用 GET 请求重定向到另一地址' },
{ code: 304, phrase: 'Not Modified', desc: '未修改:资源未变化,可使用缓存' },
{ code: 305, phrase: 'Use Proxy', desc: '使用代理:必须通过指定代理访问(已废弃)' },
{ code: 307, phrase: 'Temporary Redirect', desc: '临时重定向:临时重定向且不改变请求方法' },
{ code: 308, phrase: 'Permanent Redirect', desc: '永久重定向:永久重定向且不改变请求方法' },
// 4xx
{ code: 400, phrase: 'Bad Request', desc: '请求错误:请求语法错误或无法被理解' },
{ code: 401, phrase: 'Unauthorized', desc: '未授权:需要身份认证' },
{ code: 402, phrase: 'Payment Required', desc: '需要付费:预留状态码,用于未来的支付场景' },
{ code: 403, phrase: 'Forbidden', desc: '禁止访问:服务器理解请求但拒绝执行' },
{ code: 404, phrase: 'Not Found', desc: '资源不存在:请求的资源找不到' },
{ code: 405, phrase: 'Method Not Allowed', desc: '方法不允许:请求方法不被该资源支持' },
{ code: 406, phrase: 'Not Acceptable', desc: '不可接受:无法生成客户端可接受的内容' },
{ code: 407, phrase: 'Proxy Authentication Required', desc: '需要代理认证:必须先通过代理认证' },
{ code: 408, phrase: 'Request Timeout', desc: '请求超时:服务器等待请求超时' },
{ code: 409, phrase: 'Conflict', desc: '冲突:请求与资源当前状态冲突' },
{ code: 410, phrase: 'Gone', desc: '已删除:资源已永久删除且无转发地址' },
{ code: 411, phrase: 'Length Required', desc: '需要长度:缺少 Content-Length 请求头' },
{ code: 412, phrase: 'Precondition Failed', desc: '前置条件失败:请求头中的前置条件不满足' },
{ code: 413, phrase: 'Payload Too Large', desc: '载荷过大:请求体超过服务器限制' },
{ code: 414, phrase: 'URI Too Long', desc: 'URI 过长:请求 URI 超过服务器可解析长度' },
{ code: 415, phrase: 'Unsupported Media Type', desc: '不支持的媒体类型:请求格式不被支持' },
{ code: 416, phrase: 'Range Not Satisfiable', desc: '范围无效:请求的范围无法满足' },
{ code: 417, phrase: 'Expectation Failed', desc: '预期失败Expect 请求头无法满足' },
{ code: 418, phrase: "I'm a teapot", desc: '我是茶壶愚人节玩笑状态码RFC 2324' },
{ code: 422, phrase: 'Unprocessable Entity', desc: '无法处理:请求语义错误,服务器无法处理其内容' },
{ code: 423, phrase: 'Locked', desc: '已锁定资源被锁定WebDAV' },
{ code: 424, phrase: 'Failed Dependency', desc: '依赖失败:当前请求依赖的另一请求失败' },
{ code: 426, phrase: 'Upgrade Required', desc: '需要升级:客户端应升级协议(如 HTTPS' },
{ code: 428, phrase: 'Precondition Required', desc: '需要前置条件:请求必须带条件头,防止丢失更新' },
{ code: 429, phrase: 'Too Many Requests', desc: '请求过多:请求频率超限(限流)' },
{ code: 431, phrase: 'Request Header Fields Too Large', desc: '请求头过大:请求头字段超过服务器限制' },
{ code: 451, phrase: 'Unavailable For Legal Reasons', desc: '法律原因不可用:因法律原因无法提供资源' },
// 5xx
{ code: 500, phrase: 'Internal Server Error', desc: '服务器内部错误' },
{ code: 501, phrase: 'Not Implemented', desc: '未实现:服务器不支持请求的功能' },
{ code: 502, phrase: 'Bad Gateway', desc: '网关错误:网关或代理收到上游无效响应' },
{ code: 503, phrase: 'Service Unavailable', desc: '服务不可用:服务器暂时过载或维护中' },
{ code: 504, phrase: 'Gateway Timeout', desc: '网关超时:网关或代理等待上游响应超时' },
{ code: 505, phrase: 'HTTP Version Not Supported', desc: '版本不支持:服务器不支持的 HTTP 协议版本' },
{ code: 507, phrase: 'Insufficient Storage', desc: '存储不足服务器存储空间不足WebDAV' },
{ code: 508, phrase: 'Loop Detected', desc: '检测到循环:请求处理过程中检测到无限循环' },
{ code: 510, phrase: 'Not Extended', desc: '未扩展:需要进一步扩展才能完成请求' },
{ code: 511, phrase: 'Network Authentication Required', desc: '需要网络认证:需要网络认证才能访问' },
]
const search = ref('')
const category = ref('all')
// error
const error = ref('')
const categoryOptions = [
{ label: '全部分类', value: 'all' },
{ label: '1xx 信息响应', value: '1' },
{ label: '2xx 成功', value: '2' },
{ label: '3xx 重定向', value: '3' },
{ label: '4xx 客户端错误', value: '4' },
{ label: '5xx 服务器错误', value: '5' },
]
const GROUP_TITLES: Record<number, string> = {
1: '1xx 信息响应',
2: '2xx 成功',
3: '3xx 重定向',
4: '4xx 客户端错误',
5: '5xx 服务器错误',
}
/** 按分类与关键词过滤:状态码、英文短语、中文说明均参与匹配(不区分大小写) */
const filtered = computed(() => {
const kw = search.value.trim().toLowerCase()
return STATUS_LIST.filter((s) => {
if (category.value !== 'all' && Math.floor(s.code / 100) !== Number(category.value)) return false
if (!kw) return true
return String(s.code).includes(kw) || s.phrase.toLowerCase().includes(kw) || s.desc.includes(kw)
})
})
/** 将过滤结果按 1xx5xx 分组,便于分类小标题展示 */
const grouped = computed(() => {
const groups: Array<{ key: number; title: string; items: StatusItem[] }> = []
for (const s of filtered.value) {
const k = Math.floor(s.code / 100)
let g = groups.find((x) => x.key === k)
if (!g) {
g = { key: k, title: GROUP_TITLES[k] ?? `${k}xx`, items: [] }
groups.push(g)
}
g.items.push(s)
}
return groups
})
/** 回车把当前搜索词存入历史 */
function saveHistory() {
const kw = search.value.trim()
if (kw) history.add(kw)
}
function onHistorySelect(text: string) {
search.value = text
}
</script>
<style scoped>
.form-input {
max-width: 420px;
}
.form-select {
width: 160px;
}
.summary {
margin: 0 0 4px;
font-size: 13px;
color: var(--td-text-color-secondary);
}
.status-group {
margin-top: 12px;
}
.status-group-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.status-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.status-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
}
.status-code {
width: 44px;
flex-shrink: 0;
font-family: monospace;
font-size: 15px;
font-weight: 700;
color: var(--td-brand-color);
}
.status-phrase {
width: 240px;
flex-shrink: 0;
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-primary);
overflow-wrap: anywhere;
}
.status-desc {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--td-text-color-secondary);
}
.empty-tip {
font-size: 13px;
color: var(--td-text-color-placeholder);
}
@media (max-width: 767px) {
/* 窄屏下描述换行到下一行,避免横向滚动 */
.status-row {
flex-wrap: wrap;
gap: 4px 12px;
}
.status-phrase {
width: auto;
flex: 1;
}
.status-desc {
flex-basis: 100%;
}
}
</style>

View File

@ -0,0 +1,285 @@
<!-- 密码生成器基于 crypto.getRandomValues 的安全随机密码批量生成与强度评估 -->
<template>
<div class="page-wrap">
<h2 class="page-title">密码生成器</h2>
<p class="page-desc">使用浏览器安全随机数批量生成高强度密码全部在本地完成密码不会上传</p>
<t-card class="tool-section" title="生成选项">
<div class="form-row">
<span class="form-label">长度</span>
<t-slider v-model="len" class="form-slider" :min="4" :max="64" :step="1" />
<span class="form-value">{{ len }}</span>
</div>
<div class="form-row">
<span class="form-label">字符集</span>
<div class="checkbox-group">
<t-checkbox v-model="optUpper">大写字母 A-Z</t-checkbox>
<t-checkbox v-model="optLower">小写字母 a-z</t-checkbox>
<t-checkbox v-model="optDigit">数字 0-9</t-checkbox>
<t-checkbox v-model="optSymbol">特殊符号 !@#$%</t-checkbox>
</div>
</div>
<div class="form-row">
<span class="form-label">易混淆字符</span>
<t-switch v-model="excludeAmbiguous" />
<span class="form-suffix">排除 0 O 1 l I | 等易混淆字符</span>
</div>
<div class="form-row">
<span class="form-label">数量</span>
<t-input-number v-model="count" :min="1" :max="20" theme="normal" />
</div>
<div class="form-actions">
<t-button theme="primary" @click="regenerate">生成密码</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="passwords.length" class="tool-section" title="生成结果">
<div class="strength-row">
<span class="strength-label">强度</span>
<span class="strength-level" :style="{ color: strength.color }">{{ strength.level }}</span>
<span class="strength-entropy"> {{ strength.entropyText }} bit</span>
</div>
<t-progress :percentage="strength.percent" :color="strength.color" :label="false" class="strength-bar" />
<div class="pw-list">
<div v-for="(p, i) in passwords" :key="i" class="pw-row">
<code class="pw-text">{{ p }}</code>
<t-button variant="text" size="small" shape="square" title="复制" @click="copyText(p)">
<template #icon><CopyIcon /></template>
</t-button>
</div>
</div>
<div class="form-actions">
<CopyButton :text="allText" label="复制全部" />
<t-button variant="outline" size="small" @click="regenerate">
<template #icon><RefreshIcon /></template>
重新生成
</t-button>
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { CopyIcon, RefreshIcon } from 'tdesign-icons-vue-next'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
import { copyText } from '@/composables/useClipboard'
const history = useHistory('password-gen')
const len = ref(16)
const optUpper = ref(true)
const optLower = ref(true)
const optDigit = ref(true)
const optSymbol = ref(true)
const excludeAmbiguous = ref(false)
const count = ref(5)
const error = ref('')
const passwords = ref<string[]>([])
const SYMBOLS = '!@#$%^&*()-_=+[]{};:,.<>?/~|'
/** 易混淆字符集合0 O 1 l I |),开启开关后从字符池中剔除 */
const AMBIGUOUS = new Set(['0', 'O', '1', 'l', 'I', '|'])
/** 构建当前选中的字符池列表(每种字符集一个池,按开关过滤易混淆字符) */
function buildPools(): string[] {
const pools: string[] = []
if (optUpper.value) pools.push('ABCDEFGHIJKLMNOPQRSTUVWXYZ')
if (optLower.value) pools.push('abcdefghijklmnopqrstuvwxyz')
if (optDigit.value) pools.push('0123456789')
if (optSymbol.value) pools.push(SYMBOLS)
if (!excludeAmbiguous.value) return pools
return pools.map((p) => p.split('').filter((c) => !AMBIGUOUS.has(c)).join(''))
}
/**
* 从字符池中随机取一个字符
* crypto.getRandomValues 32 位随机数 + 拒绝采样消除直接取模带来的分布偏差
*/
function randomChar(pool: string): string {
const buf = new Uint32Array(1)
// [0, limit) limit
const limit = Math.floor(0x100000000 / pool.length) * pool.length
let r = 0
do {
crypto.getRandomValues(buf)
r = buf[0]
} while (r >= limit)
return pool[r % pool.length]
}
/** Fisher-Yates 洗牌(同样用拒绝采样保证均匀) */
function shuffleArray(arr: string[]): void {
const buf = new Uint32Array(1)
for (let i = arr.length - 1; i > 0; i--) {
const n = i + 1
const limit = Math.floor(0x100000000 / n) * n
let r = 0
do {
crypto.getRandomValues(buf)
r = buf[0]
} while (r >= limit)
const j = r % n
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
}
/** 生成一批密码:每种选中字符集至少出现一次,再随机补齐并洗牌 */
function generatePasswords() {
error.value = ''
passwords.value = []
const pools = buildPools()
if (!pools.length) {
error.value = '请至少选择一种字符集'
return
}
const all = pools.join('')
// NaN
const length = Number.isFinite(len.value) ? Math.min(64, Math.max(4, Math.floor(len.value))) : 16
const total = Number.isFinite(count.value) ? Math.min(20, Math.max(1, Math.floor(count.value))) : 5
const list: string[] = []
for (let i = 0; i < total; i++) {
const chars: string[] = []
// 1
for (const pool of pools) chars.push(randomChar(pool))
//
while (chars.length < length) chars.push(randomChar(all))
shuffleArray(chars)
list.push(chars.join(''))
}
passwords.value = list
}
/** 全部密码拼接文本(用于「复制全部」) */
const allText = computed(() => passwords.value.join('\n'))
/** 强度评估:熵 = 长度 × log2(池大小),映射到等级、颜色与进度条百分比 */
const strength = computed(() => {
const poolSize = buildPools().join('').length
const entropy = poolSize > 0 ? len.value * Math.log2(poolSize) : 0
const percent = Math.min(100, Math.round((entropy / 160) * 100))
const entropyText = entropy.toFixed(1)
if (entropy < 40) return { level: '弱', color: 'var(--td-error-color)', entropyText, percent }
if (entropy < 80) return { level: '中', color: 'var(--td-warning-color)', entropyText, percent }
if (entropy < 120) return { level: '强', color: 'var(--td-success-color)', entropyText, percent }
return { level: '极强', color: 'var(--td-brand-color)', entropyText, percent }
})
/** 历史最多存 300 字符:按行累加,超出则截断(保留完整行) */
function recordHistory() {
let text = ''
for (const p of passwords.value) {
if (text && text.length + 1 + p.length > 300) break
text = text ? `${text}\n${p}` : p
}
if (text) history.add(text)
}
function regenerate() {
generatePasswords()
recordHistory()
}
function onHistorySelect(text: string) {
const list = text.split('\n').map((s) => s.trim()).filter(Boolean)
if (!list.length) return
passwords.value = list
//
len.value = Math.min(64, Math.max(4, list[0].length))
}
// 便
onMounted(() => {
generatePasswords()
})
</script>
<style scoped>
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.form-slider {
flex: 1;
max-width: 300px;
}
.form-value {
width: 32px;
font-size: 13px;
color: var(--td-brand-color);
font-weight: 600;
}
.form-suffix {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 4px 20px;
}
.strength-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.strength-label {
font-size: 13px;
color: var(--td-text-color-secondary);
}
.strength-level {
font-size: 14px;
font-weight: 600;
}
.strength-entropy {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.strength-bar {
max-width: 480px;
margin-bottom: 12px;
}
.pw-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.pw-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
}
.pw-text {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
font-family: monospace;
font-size: 13px;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,284 @@
<template>
<div class="page-wrap">
<h2 class="page-title">正则表达式测试</h2>
<p class="page-desc">匹配高亮与替换测试支持 g / i / m / s 标志命名分组与反向引用全程本地计算</p>
<t-tabs v-model="tab">
<!-- 匹配测试 -->
<t-tab-panel value="match" label="匹配测试">
<t-card class="tool-section" title="输入">
<t-input v-model="pattern" placeholder="例如 (\w+)@(\w+\.\w+)" clearable />
<div class="form-row" style="margin-top: 12px">
<span class="form-label">标志</span>
<t-checkbox-group v-model="flags">
<t-checkbox value="g">g 全局</t-checkbox>
<t-checkbox value="i">i 忽略大小写</t-checkbox>
<t-checkbox value="m">m 多行</t-checkbox>
<t-checkbox value="s">s 点号匹配换行</t-checkbox>
</t-checkbox-group>
</div>
<t-textarea
v-model="textValue"
placeholder="输入要测试的文本"
:autosize="{ minRows: 5, maxRows: 12 }"
style="margin-top: 12px"
/>
<div class="form-actions">
<t-button theme="primary" @click="runMatch">开始匹配</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<p v-if="executed && !matches.length" class="no-match">未找到匹配内容</p>
<t-card v-if="segments.length" class="tool-section" title="高亮显示">
<div class="highlight-box">
<template v-for="(s, i) in segments" :key="i">
<span v-if="s.match" class="match-seg">{{ s.text }}</span>
<span v-else>{{ s.text }}</span>
</template>
</div>
</t-card>
<t-card v-if="matches.length" class="tool-section" :title="`匹配列表(共 ${matches.length} 处)`">
<div v-for="(m, i) in matches" :key="i" class="match-item">
<span class="match-no">{{ i + 1 }}</span>
<code class="match-text">{{ m.text || '(空匹配)' }}</code>
<span class="match-pos">位置 {{ m.index }}</span>
<div v-if="m.groups && Object.keys(m.groups).length" class="match-groups">
<span v-for="(gv, gk) in m.groups" :key="gk" class="group-tag">{{ gk }}: {{ gv }}</span>
</div>
</div>
</t-card>
</t-tab-panel>
<!-- 替换 -->
<t-tab-panel value="replace" label="替换">
<t-card class="tool-section" title="替换文本">
<t-input v-model="replacement" placeholder="替换内容,支持 $1、$2 反向引用与 $& 整体匹配" clearable />
<p class="hint-text">提示未勾选 g 标志时仅替换第一处匹配</p>
<div class="form-actions">
<t-button theme="primary" @click="runReplace">预览替换</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="replaceResult" class="tool-section" title="替换结果">
<t-textarea v-model="replaceResult" :autosize="{ minRows: 5, maxRows: 12 }" readonly />
<div class="form-actions">
<CopyButton :text="replaceResult" label="复制结果" />
</div>
</t-card>
</t-tab-panel>
</t-tabs>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
//
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
interface MatchItem {
index: number
text: string
groups: Record<string, string> | undefined
}
interface Segment {
text: string
match: boolean
}
const tab = ref('match')
const pattern = ref('')
const flags = ref<string[]>(['g'])
const textValue = ref('')
const error = ref('')
const matches = ref<MatchItem[]>([])
const segments = ref<Segment[]>([])
const executed = ref(false)
const replacement = ref('')
const replaceResult = ref('')
const history = useHistory('regex-test')
/** 编译正则;表达式为空或非法时展示错误并返回 null */
function compileRegex(): RegExp | null {
if (!pattern.value.trim()) {
error.value = '请输入正则表达式'
return null
}
try {
return new RegExp(pattern.value, flags.value.join(''))
} catch (e) {
//
error.value = e instanceof Error ? e.message : String(e)
return null
}
}
function runMatch() {
error.value = ''
matches.value = []
segments.value = []
executed.value = false
const re = compileRegex()
if (!re) return
const text = textValue.value
const results: MatchItem[] = []
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
results.push({ index: m.index, text: m[0], groups: m.groups })
if (!re.global) break // g lastIndex
if (m[0] === '') re.lastIndex += 1 //
}
matches.value = results
segments.value = buildSegments(text, results)
executed.value = true
if (pattern.value.trim()) {
// 200
history.add(JSON.stringify({ p: pattern.value, f: flags.value.join(''), t: text.slice(0, 200) }))
}
}
/** 按匹配区间把原文切分为「匹配 / 非匹配」片段,供模板逐段渲染高亮(不使用 v-html */
function buildSegments(text: string, results: MatchItem[]): Segment[] {
const segs: Segment[] = []
let cursor = 0
for (const r of results) {
if (r.text === '') continue //
if (r.index > cursor) segs.push({ text: text.slice(cursor, r.index), match: false })
segs.push({ text: r.text, match: true })
cursor = r.index + r.text.length
}
if (cursor < text.length) segs.push({ text: text.slice(cursor), match: false })
return segs
}
function runReplace() {
error.value = ''
replaceResult.value = ''
const re = compileRegex()
if (!re) return
try {
// replace g $1
replaceResult.value = textValue.value.replace(re, replacement.value)
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
}
}
function onHistorySelect(raw: string) {
try {
const obj: unknown = JSON.parse(raw)
if (typeof obj !== 'object' || obj === null) return
const { p, f, t } = obj as { p?: unknown; f?: unknown; t?: unknown }
if (typeof p !== 'string') return
pattern.value = p
if (typeof f === 'string') flags.value = f.split('').filter((c) => 'gims'.includes(c))
if (typeof t === 'string') textValue.value = t
tab.value = 'match'
runMatch()
} catch {
//
}
}
</script>
<style scoped>
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.hint-text {
margin: 8px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.no-match {
margin: 0 0 12px;
font-size: 13px;
color: var(--td-text-color-placeholder);
}
.highlight-box {
padding: 12px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-all;
}
.match-seg {
background: color-mix(in srgb, var(--td-brand-color) 20%, transparent);
border-radius: 2px;
}
.match-item {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 6px 0;
border-bottom: 1px dashed var(--td-border-level-1-color);
}
.match-no {
flex-shrink: 0;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--td-bg-color-secondarycontainer);
font-size: 12px;
color: var(--td-text-color-secondary);
}
.match-text {
font-family: monospace;
color: var(--td-brand-color);
background: var(--td-bg-color-secondarycontainer);
padding: 2px 6px;
border-radius: 4px;
word-break: break-all;
}
.match-pos {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.match-groups {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%;
}
.group-tag {
font-family: monospace;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-secondary);
}
</style>

View File

@ -0,0 +1,195 @@
<!--
人民币大写转换工具将数字金额转换为中文大写金额全部按字符串整数运算避免浮点精度问题
-->
<template>
<div class="page-wrap">
<h2 class="page-title">人民币大写转换</h2>
<p class="page-desc">将数字金额转换为人民币大写 1234567.89 壹佰贰拾叁万肆仟伍佰陆拾柒元捌角玖分全部在浏览器本地完成</p>
<t-card class="tool-section" title="输入金额">
<t-input v-model="amount" placeholder="1234567.89" clearable />
<div class="form-actions">
<t-button theme="primary" @click="convert">转换</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="upperResult" class="tool-section" title="转换结果">
<div class="upper-result">{{ upperResult }}</div>
<div class="lower-result">小写金额{{ lowerResult }}</div>
<div class="form-actions">
<CopyButton :text="upperResult" />
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
/** 数字 → 大写汉字映射 */
const DIGITS = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']
/** 组内单位:下标 0 为个位无单位1-3 依次为拾/佰/仟 */
const INT_UNITS = ['', '拾', '佰', '仟']
/** 每 4 位一组之间的单位:个、万、亿、万亿 */
const GROUP_UNITS = ['', '万', '亿', '万亿']
const amount = ref('')
const error = ref('')
const upperResult = ref('')
const lowerResult = ref('')
const history = useHistory('rmb-upper')
/** 把不含前导零的 1-4 位数字组转为大写,如 1001 → 壹仟零壹 */
function groupToUpper(g: string): string {
let out = ''
// 0
let zeroPending = false
for (let i = 0; i < g.length; i++) {
const d = Number(g[i])
// //
const pos = g.length - 1 - i
if (d === 0) {
zeroPending = out !== ''
} else {
if (zeroPending) out += '零'
out += DIGITS[d] + INT_UNITS[pos]
zeroPending = false
}
}
return out
}
/** 把非负整数(十进制字符串,无前导零)转为中文大写 */
function intToUpper(s: string): string {
if (s === '0') return '零'
// 4 groups[0]
const groups: string[] = []
const chars = s.split('').reverse()
for (let i = 0; i < chars.length; i += 4) {
groups.push(chars.slice(i, i + 4).reverse().join(''))
}
const parts: string[] = []
for (let i = groups.length - 1; i >= 0; i--) {
const g = groups[i]
// 0
const upper = groupToUpper(g.replace(/^0+/, ''))
if (!upper) continue
// 0 100000001 亿
if (g[0] === '0' && parts.length > 0) parts.push('零')
parts.push(upper + GROUP_UNITS[i])
}
return parts.join('')
}
/** 拼接最终大写金额(含负号与角分) */
function buildUpper(negative: boolean, intStr: string, fen: number): string {
//
if (intStr === '0' && fen === 0) return '零元整'
let out = negative ? '负' : ''
if (intStr !== '0') out += intToUpper(intStr) + '元'
if (fen > 0) {
const jiao = Math.floor(fen / 10)
const f = fen % 10
if (jiao > 0) {
out += DIGITS[jiao] + '角'
} else if (intStr !== '0') {
// 100.05
out += '零'
}
if (f > 0) out += DIGITS[f] + '分'
} else {
out += '整'
}
return out
}
/** 千分位格式化1234567 → 1,234,567 */
function formatThousands(s: string): string {
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
function convert() {
error.value = ''
upperResult.value = ''
lowerResult.value = ''
const raw = amount.value.trim()
if (!raw) {
error.value = '请输入金额'
return
}
// + +
const m = /^(-?)(\d+)(?:\.(\d+))?$/.exec(raw)
if (!m) {
error.value = '金额格式不合法:请输入数字(可带负号),小数超过两位时自动四舍五入到分'
return
}
const negative = m[1] === '-'
// 007 7
const intStr = m[2].replace(/^0+/, '') || '0'
if (intStr.length > 15) {
error.value = '整数部分最多支持 15 位'
return
}
// 3 3
const dec = ((m[3] ?? '') + '00').slice(0, 3)
const fen = Number(dec.slice(0, 2)) + (Number(dec[2]) >= 5 ? 1 : 0)
// BigInt × 100 + 0.1+0.2
const totalFen = BigInt(intStr) * 100n + BigInt(fen)
const finalInt = (totalFen / 100n).toString()
const finalFen = Number(totalFen % 100n)
// 15 9 + .999
if (finalInt.length > 15) {
error.value = '金额超出支持范围(整数部分最多 15 位)'
return
}
const isZero = finalInt === '0' && finalFen === 0
upperResult.value = buildUpper(negative, finalInt, finalFen)
// +
lowerResult.value =
(negative && !isZero ? '-' : '') +
formatThousands(finalInt) +
(finalFen > 0 ? `.${String(finalFen).padStart(2, '0')}` : '')
history.add(raw)
}
function onHistorySelect(text: string) {
amount.value = text
convert()
}
</script>
<style scoped>
.upper-result {
font-size: 20px;
font-weight: 600;
line-height: 1.6;
color: var(--td-text-color-primary);
word-break: break-all;
}
.lower-result {
margin-top: 8px;
font-size: 13px;
color: var(--td-text-color-secondary);
word-break: break-all;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
</style>

View File

@ -0,0 +1,246 @@
<template>
<div class="page-wrap">
<h2 class="page-title">单位换算</h2>
<p class="page-desc">长度面积体积质量温度数据存储速度时间八类单位实时换算结果保留最多 6 位有效数字</p>
<t-card class="tool-section" title="换算设置">
<div class="form-row">
<span class="form-label">类别</span>
<t-select v-model="category" :options="categories" class="cat-select" @change="onCategoryChange" />
</div>
<div class="form-row row-gap">
<span class="form-label">数值</span>
<t-input-number v-model="value" theme="normal" :step="1" class="value-input" @change="recordHistory" />
</div>
<div class="form-row row-gap">
<span class="form-label"></span>
<t-select v-model="fromUnit" :options="unitOptions" class="unit-select" @change="recordHistory" />
</div>
<div class="form-row row-gap">
<span class="form-label"></span>
<t-select v-model="toUnit" :options="unitOptions" class="unit-select" @change="recordHistory" />
</div>
</t-card>
<t-card v-if="resultText" class="tool-section" title="换算结果">
<div class="code-block">{{ resultText }}</div>
<div class="form-actions">
<CopyButton :text="resultText" label="复制结果" />
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
//
import { computed, ref } from 'vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
interface UnitDef {
name: string
/** 1 个该单位 = factor 个基准单位(各类别基准单位为表中第一个) */
factor: number
}
type CategoryKey = 'length' | 'area' | 'volume' | 'mass' | 'temperature' | 'data' | 'speed' | 'time'
type FactorCategory = Exclude<CategoryKey, 'temperature'>
const categories: { label: string; value: CategoryKey }[] = [
{ label: '长度', value: 'length' },
{ label: '面积', value: 'area' },
{ label: '体积', value: 'volume' },
{ label: '质量', value: 'mass' },
{ label: '温度', value: 'temperature' },
{ label: '数据存储', value: 'data' },
{ label: '速度', value: 'speed' },
{ label: '时间', value: 'time' },
]
const unitTables: Record<FactorCategory, UnitDef[]> = {
length: [
{ name: '米', factor: 1 },
{ name: '千米', factor: 1000 },
{ name: '分米', factor: 0.1 },
{ name: '厘米', factor: 0.01 },
{ name: '毫米', factor: 0.001 },
{ name: '微米', factor: 1e-6 },
{ name: '英里', factor: 1609.344 },
{ name: '码', factor: 0.9144 },
{ name: '英尺', factor: 0.3048 },
{ name: '英寸', factor: 0.0254 },
{ name: '海里', factor: 1852 },
],
area: [
{ name: '平方米', factor: 1 },
{ name: '平方千米', factor: 1e6 },
{ name: '公顷', factor: 1e4 },
{ name: '亩', factor: 2000 / 3 },
{ name: '平方英尺', factor: 0.092903 },
{ name: '平方英里', factor: 2.59e6 },
{ name: '英亩', factor: 4046.856 },
],
volume: [
{ name: '立方米', factor: 1 },
{ name: '升', factor: 0.001 },
{ name: '毫升', factor: 1e-6 },
{ name: '加仑(美)', factor: 0.00378541 },
{ name: '夸脱(美)', factor: 0.000946353 },
{ name: '立方英尺', factor: 0.0283168 },
],
mass: [
{ name: '千克', factor: 1 },
{ name: '克', factor: 0.001 },
{ name: '毫克', factor: 1e-6 },
{ name: '吨', factor: 1000 },
{ name: '磅', factor: 0.45359237 },
{ name: '盎司', factor: 0.0283495 },
{ name: '斤', factor: 0.5 },
{ name: '两', factor: 0.05 },
],
data: [
{ name: '字节', factor: 1 },
{ name: 'bit', factor: 0.125 },
{ name: 'KB', factor: 1024 },
{ name: 'MB', factor: 1024 ** 2 },
{ name: 'GB', factor: 1024 ** 3 },
{ name: 'TB', factor: 1024 ** 4 },
],
speed: [
{ name: '米/秒', factor: 1 },
{ name: '千米/时', factor: 1 / 3.6 },
{ name: '英里/时', factor: 0.44704 },
{ name: '节', factor: 0.514444 },
{ name: '英尺/秒', factor: 0.3048 },
],
time: [
{ name: '秒', factor: 1 },
{ name: '毫秒', factor: 0.001 },
{ name: '分钟', factor: 60 },
{ name: '小时', factor: 3600 },
{ name: '天', factor: 86400 },
{ name: '周', factor: 604800 },
{ name: '年(365天)', factor: 31536000 },
],
}
// factor
const tempUnits: { label: string; value: string }[] = [
{ label: '摄氏度 (°C)', value: 'c' },
{ label: '华氏度 (°F)', value: 'f' },
{ label: '开尔文 (K)', value: 'k' },
]
const category = ref<CategoryKey>('length')
const value = ref(1)
const fromUnit = ref('米')
const toUnit = ref('千米')
const history = useHistory('unit-convert')
function optionsFor(cat: CategoryKey): { label: string; value: string }[] {
if (cat === 'temperature') return tempUnits
return unitTables[cat].map((u) => ({ label: u.name, value: u.name }))
}
const unitOptions = computed(() => optionsFor(category.value))
/** 温度三向换算:统一先换算为摄氏度,再转到目标温标 */
function convertTemp(v: number, from: string, to: string): number {
let c: number
if (from === 'f') c = ((v - 32) * 5) / 9
else if (from === 'k') c = v - 273.15
else c = v
if (to === 'f') return (c * 9) / 5 + 32
if (to === 'k') return c + 273.15
return c
}
function factorOf(name: string): number {
if (category.value === 'temperature') return 1
return unitTables[category.value].find((u) => u.name === name)?.factor ?? 1
}
/** 数值展示toPrecision 限制 6 位有效数字,再经 Number 去掉多余尾零 */
function formatNum(n: number): string {
return String(Number(n.toPrecision(6)))
}
const resultText = computed(() => {
const v = value.value
if (!Number.isFinite(v)) return ''
const fromLabel = unitOptions.value.find((o) => o.value === fromUnit.value)?.label ?? fromUnit.value
const toLabel = unitOptions.value.find((o) => o.value === toUnit.value)?.label ?? toUnit.value
const converted =
category.value === 'temperature'
? convertTemp(v, fromUnit.value, toUnit.value)
: (v * factorOf(fromUnit.value)) / factorOf(toUnit.value)
if (!Number.isFinite(converted)) return ''
return `${formatNum(v)} ${fromLabel} = ${formatNum(converted)} ${toLabel}`
})
/** 类别切换后旧单位已不存在,重置为该类别前两个单位,并记录当前换算 */
function onCategoryChange() {
const opts = unitOptions.value
fromUnit.value = opts[0].value
toUnit.value = opts[1]?.value ?? opts[0].value
recordHistory()
}
/** 历史按「类别|数值|从单位|到单位」结构化保存,点击可完整恢复 */
function recordHistory() {
const v = value.value
if (!Number.isFinite(v)) return
const catLabel = categories.find((c) => c.value === category.value)?.label ?? category.value
history.add(`${catLabel}|${v}|${fromUnit.value}|${toUnit.value}`)
}
function onHistorySelect(text: string) {
const parts = text.split('|')
if (parts.length !== 4) return
const cat = categories.find((c) => c.label === parts[0])
if (!cat) return
const n = Number(parts[1])
if (!Number.isFinite(n)) return
category.value = cat.value
//
const opts = optionsFor(cat.value)
if (opts.some((o) => o.value === parts[2])) fromUnit.value = parts[2]
if (opts.some((o) => o.value === parts[3])) toUnit.value = parts[3]
value.value = n
}
</script>
<style scoped>
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.row-gap {
margin-top: 12px;
}
.cat-select {
width: 150px;
}
.unit-select {
width: 150px;
}
.value-input {
width: 160px;
}
</style>

142
src/views/uuid/UuidView.vue Normal file
View File

@ -0,0 +1,142 @@
<template>
<div class="page-wrap">
<h2 class="page-title">UUID 生成器</h2>
<p class="page-desc">批量生成 RFC 4122 版本 4 的随机 UUID基于浏览器安全随机数支持大写与无连字符格式</p>
<t-card class="tool-section" title="生成选项">
<div class="form-row">
<span class="form-label">数量</span>
<t-input-number v-model="count" :min="1" :max="100" :step="1" theme="normal" class="count-input" />
</div>
<div class="form-row option-row">
<t-checkbox v-model="uppercase">大写</t-checkbox>
<t-checkbox v-model="noHyphen">无连字符</t-checkbox>
</div>
</t-card>
<t-card v-if="list.length" class="tool-section" title="生成结果">
<div class="result-actions">
<CopyButton :text="allText" label="复制全部" />
<t-button theme="primary" @click="regenerate">
<template #icon><RefreshIcon /></template>
重新生成
</t-button>
</div>
<div class="uuid-list">
<div v-for="(u, i) in list" :key="i" class="uuid-row">
<span class="uuid-text">{{ u }}</span>
<t-button variant="text" size="small" shape="square" title="复制" @click="copyText(u)">
<template #icon><CopyIcon /></template>
</t-button>
</div>
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
// UUID Web Crypto RFC 4122 v4 UUID
import { computed, onMounted, ref } from 'vue'
import { CopyIcon, RefreshIcon } from 'tdesign-icons-vue-next'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
import { copyText } from '@/composables/useClipboard'
const count = ref(5)
const uppercase = ref(false)
const noHyphen = ref(false)
const list = ref<string[]>([])
const history = useHistory('uuid')
/**
* 生成单个 v4 UUID16 字节安全随机数再写入版本位与变体位
* 无连字符时跳过 8-4-4-4-12 的分隔符大写按需转换数字不受影响
*/
function generateUuid(noHyphenFlag: boolean, upperFlag: boolean): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40 // 4
bytes[8] = (bytes[8] & 0x3f) | 0x80 // RFC 4122
let hex = ''
bytes.forEach((b, i) => {
hex += b.toString(16).padStart(2, '0')
if (!noHyphenFlag && [3, 5, 7, 9].includes(i)) hex += '-'
})
return upperFlag ? hex.toUpperCase() : hex
}
/** 按当前选项生成一批 UUID数量夹取到合法范围防止输入框清空等边界情况 */
function generate() {
const n = Number.isFinite(count.value) ? Math.min(100, Math.max(1, Math.floor(count.value))) : 1
list.value = Array.from({ length: n }, () => generateUuid(noHyphen.value, uppercase.value))
}
const allText = computed(() => list.value.join('\n'))
/** 用户主动重新生成时才写历史(超 300 字符截断),避免每次进入页面都污染历史 */
function regenerate() {
generate()
history.add(allText.value.slice(0, 300))
}
function onHistorySelect(text: string) {
//
list.value = text
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
}
onMounted(generate)
</script>
<style scoped>
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.count-input {
width: 140px;
}
.option-row {
margin-top: 12px;
}
.result-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.uuid-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.uuid-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
}
.uuid-text {
flex: 1;
min-width: 0;
font-family: monospace;
font-size: 13px;
word-break: break-all;
color: var(--td-text-color-primary);
}
</style>

View File

@ -0,0 +1,124 @@
<!--
字数统计工具实时统计文本的字符汉字单词行数段落UTF-8 字节数与预计阅读时长
-->
<template>
<div class="page-wrap">
<h2 class="page-title">字数统计</h2>
<p class="page-desc">实时统计文本的字符数汉字数单词数行数段落数与预计阅读时长全部在浏览器本地完成</p>
<t-card class="tool-section" title="输入文本">
<t-textarea
v-model="text"
placeholder="在此输入或粘贴要统计的文本……"
:autosize="{ minRows: 8, maxRows: 16 }"
@blur="recordHistory"
/>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card class="tool-section" title="统计结果">
<div class="stat-grid">
<div v-for="item in stats" :key="item.label" class="stat-card">
<div class="stat-value">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
</div>
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
const text = ref('')
// error
const error = ref('')
const history = useHistory('word-count')
//
const PUNCT_RE = /[,。!?;:、“”‘’()《》〈〉【】〔〕…—–·~,.!?;:'"()\[\]{}<>\/\\|@#$%^&*_+=`~-]/g
interface StatItem {
label: string
value: string
}
const stats = computed<StatItem[]>(() => {
const t = text.value
// CJK
const hanzi = (t.match(/[\u4e00-\u9fff]/g) ?? []).length
// / well-knowndon't
const words = (t.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) ?? []).length
// 400 / + 200 / 1
const minutes = t.trim() ? Math.max(1, Math.ceil(hanzi / 400 + words / 200)) : 0
return [
{ label: '总字符数', value: String(t.length) },
{ label: '不含空白字符数', value: String(t.replace(/\s/g, '').length) },
{ label: '汉字数', value: String(hanzi) },
{ label: '英文单词数', value: String(words) },
{ label: '数字字符数', value: String((t.match(/[0-9-]/g) ?? []).length) },
{ label: '标点符号数', value: String((t.match(PUNCT_RE) ?? []).length) },
{ label: '行数', value: String(t ? t.split('\n').length : 0) },
{ label: '段落数', value: String(t.trim() ? t.split(/\n\s*\n/).filter((p) => p.trim()).length : 0) },
{ label: 'UTF-8 字节数', value: String(new TextEncoder().encode(t).length) },
{ label: '预计阅读时长', value: `${minutes} 分钟` },
]
})
//
let lastRecorded = ''
function recordHistory() {
const t = text.value.trim()
if (!t || t === lastRecorded) return
lastRecorded = t
history.add(t.slice(0, 300))
}
function onHistorySelect(item: string) {
text.value = item
}
</script>
<style scoped>
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.stat-card {
padding: 14px 16px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
background: var(--td-bg-color-container);
}
.stat-value {
font-size: 22px;
font-weight: 600;
line-height: 1.4;
color: var(--td-brand-color);
word-break: break-all;
}
.stat-label {
margin-top: 4px;
font-size: 13px;
color: var(--td-text-color-secondary);
}
@media (max-width: 767px) {
/* 移动端降为单列,避免小屏下卡片拥挤 */
.stat-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,120 @@
<!--
YAML JSON 转换工具基于 js-yaml 在浏览器本地完成 YAML JSON 互转内容不上传
-->
<template>
<div class="page-wrap">
<h2 class="page-title">YAML JSON 转换</h2>
<p class="page-desc">YAML JSON 格式互转全部在浏览器本地完成内容不会上传</p>
<!-- YAML 隐式类型解析提示未加引号的日期/数字字符串可能被解析为对应类型 -->
<t-alert
class="tip-alert"
theme="warning"
message="提示YAML 解析可能将未加引号的日期或数字字符串解析为日期/数字类型,若有歧义建议给字符串加引号。"
/>
<t-card class="tool-section" title="转换">
<t-radio-group v-model="direction" variant="default-filled">
<t-radio-button value="y2j">YAML JSON</t-radio-button>
<t-radio-button value="j2y">JSON YAML</t-radio-button>
</t-radio-group>
<t-textarea
v-model="input"
class="input-area"
:placeholder="placeholder"
:autosize="{ minRows: 8, maxRows: 16 }"
/>
<div class="form-actions">
<t-button theme="primary" @click="convert">转换</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="output" class="tool-section" title="转换结果">
<t-textarea v-model="output" :autosize="{ minRows: 6, maxRows: 16 }" readonly />
<div class="form-actions">
<CopyButton :text="output" />
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { load, dump } from 'js-yaml'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
type Direction = 'y2j' | 'j2y'
const direction = ref<Direction>('y2j')
const input = ref('')
const output = ref('')
const error = ref('')
const history = useHistory('yaml-json')
//
const placeholder = computed(() =>
direction.value === 'y2j'
? '输入 YAML 内容,例如:\nname: 张三\nage: 18'
: '输入 JSON 内容,例如:\n{ "name": "张三", "age": 18 }',
)
function convert() {
error.value = ''
output.value = ''
const text = input.value.trim()
if (!text) {
error.value = '请输入要转换的内容'
return
}
try {
if (direction.value === 'y2j') {
// load unknown undefined
const data = load(text)
if (data === undefined) {
error.value = 'YAML 内容为空或无法解析'
return
}
output.value = JSON.stringify(data, null, 2)
} else {
output.value = dump(JSON.parse(text))
}
// 300 localStorage
history.add(input.value.slice(0, 300))
} catch (e) {
error.value = `转换失败:${(e as Error).message}`
}
}
function onHistorySelect(text: string) {
input.value = text
convert()
}
</script>
<style scoped>
.input-area {
margin-top: 12px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.tip-alert {
margin-bottom: 12px;
}
</style>

View File

@ -0,0 +1,102 @@
<!--
简繁转换工具基于 opencc-js 词库在浏览器本地完成简体中文与繁体中文互转
-->
<template>
<div class="page-wrap">
<h2 class="page-title">简繁转换</h2>
<p class="page-desc">简体中文与繁体中文互转基于 opencc-js 词库在浏览器本地完成内容不会上传</p>
<t-alert
class="tip-alert"
theme="info"
message="提示:转换基于 opencc-js 的标准 cn/tw 映射词库,不包含台湾地区用语变体。"
/>
<t-card class="tool-section" title="转换">
<t-radio-group v-model="direction" variant="default-filled">
<t-radio-button value="s2t">简体 繁体</t-radio-button>
<t-radio-button value="t2s">繁体 简体</t-radio-button>
</t-radio-group>
<t-textarea
v-model="input"
class="input-area"
placeholder="输入要转换的中文文本,例如:软件 / 軟體"
:autosize="{ minRows: 6, maxRows: 14 }"
/>
<div class="form-actions">
<t-button theme="primary" @click="convert">转换</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="output" class="tool-section" title="转换结果">
<t-textarea v-model="output" :autosize="{ minRows: 6, maxRows: 14 }" readonly />
<div class="form-actions">
<CopyButton :text="output" />
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import * as OpenCC from 'opencc-js'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
type Direction = 's2t' | 't2s'
//
const s2t = OpenCC.Converter({ from: 'cn', to: 'tw' })
const t2s = OpenCC.Converter({ from: 'tw', to: 'cn' })
const direction = ref<Direction>('s2t')
const input = ref('')
const output = ref('')
const error = ref('')
const history = useHistory('zh-convert')
function convert() {
error.value = ''
output.value = ''
const text = input.value.trim()
if (!text) {
error.value = '请输入要转换的文本'
return
}
output.value = direction.value === 's2t' ? s2t(text) : t2s(text)
// 300 localStorage
history.add(input.value.slice(0, 300))
}
function onHistorySelect(text: string) {
input.value = text
convert()
}
</script>
<style scoped>
.input-area {
margin-top: 12px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.tip-alert {
margin-bottom: 12px;
}
</style>