feat: 新增 12 个常用工具(UUID/正则/命名/色值/单位/YAML/字数/人民币大写/简繁/日期/密码/HTTP状态码)
This commit is contained in:
parent
d52e85a107
commit
3ba01515ad
19
README.md
19
README.md
@ -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 4(History 模式) |
|
||||
|
||||
工具依赖:`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 状态码速查 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
|
||||
16
agents.md
16
agents.md
@ -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→ja(errorCode 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/贷款计算器等均可按现有模式快速复制。
|
||||
|
||||
@ -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
32
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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'),
|
||||
},
|
||||
]
|
||||
|
||||
157
src/views/case-convert/CaseConvertView.vue
Normal file
157
src/views/case-convert/CaseConvertView.vue
Normal 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">输入变量名或短语,实时转换为 camelCase、PascalCase、snake_case、kebab-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>
|
||||
400
src/views/color-convert/ColorConvertView.vue
Normal file
400
src/views/color-convert/ColorConvertView.vue
Normal 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-360,s/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、#RRGGBBAA(Alpha 忽略)、
|
||||
* 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>
|
||||
409
src/views/date-calc/DateCalcView.vue
Normal file
409
src/views/date-calc/DateCalcView.vue
Normal 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.22–1.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>
|
||||
274
src/views/http-status/HttpStatusView.vue
Normal file
274
src/views/http-status/HttpStatusView.vue
Normal 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 状态码表(覆盖 1xx–5xx 全部常用码,共 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)
|
||||
})
|
||||
})
|
||||
|
||||
/** 将过滤结果按 1xx–5xx 分组,便于分类小标题展示 */
|
||||
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>
|
||||
285
src/views/password-gen/PasswordGenView.vue
Normal file
285
src/views/password-gen/PasswordGenView.vue
Normal 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>
|
||||
284
src/views/regex-test/RegexTestView.vue
Normal file
284
src/views/regex-test/RegexTestView.vue
Normal 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>
|
||||
195
src/views/rmb-upper/RmbUpperView.vue
Normal file
195
src/views/rmb-upper/RmbUpperView.vue
Normal 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>
|
||||
246
src/views/unit-convert/UnitConvertView.vue
Normal file
246
src/views/unit-convert/UnitConvertView.vue
Normal 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
142
src/views/uuid/UuidView.vue
Normal 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 UUID:16 字节安全随机数,再写入版本位与变体位。
|
||||
* 无连字符时跳过 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>
|
||||
124
src/views/word-count/WordCountView.vue
Normal file
124
src/views/word-count/WordCountView.vue
Normal 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-known、don'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-90-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>
|
||||
120
src/views/yaml-json/YamlJsonView.vue
Normal file
120
src/views/yaml-json/YamlJsonView.vue
Normal 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>
|
||||
102
src/views/zh-convert/ZhConvertView.vue
Normal file
102
src/views/zh-convert/ZhConvertView.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user