Initial release of Web Tools 0.0.5
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
.runtime-data
|
||||
data
|
||||
dist
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
outputs
|
||||
*.tar
|
||||
*.zip
|
||||
@@ -0,0 +1 @@
|
||||
WEB_TOOLS_PORT=8080
|
||||
@@ -0,0 +1,6 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.ico binary
|
||||
*.png binary
|
||||
*.tar binary
|
||||
*.zip binary
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
data/
|
||||
dist/
|
||||
node_modules/
|
||||
.runtime-data/
|
||||
coverage/
|
||||
.env
|
||||
!.env.example
|
||||
*.local
|
||||
*.log
|
||||
*.tar
|
||||
*.zip
|
||||
@@ -0,0 +1,19 @@
|
||||
# Web Tools Agent Guide
|
||||
|
||||
Before changing this project, read `README.zh-CN.md` in full. Its architecture constraints, extension procedure, validation checklist, and release steps are authoritative.
|
||||
|
||||
Key rules:
|
||||
|
||||
- Preserve the anonymous, no-login, no-database design. Tool calculations are isolated in each browser tab; only navigation order is shared globally.
|
||||
- Never send tool input, keys, text, or images to the server or a third party.
|
||||
- Keep each tool independently lazy-loaded through `src/tools/registry.ts`.
|
||||
- Put testable computation in `src/lib/` and cover it in `tests/`.
|
||||
- Preserve the documented file, record, image, random-output, syntax-rendering, and request-size limits. Update the limits table and tests when any limit changes.
|
||||
- Treat asynchronous replacement and clearing as race-sensitive: stale Worker, file, image, or key-generation results must never replace current state.
|
||||
- Keep IDs synchronized across `shared/tool-catalog.json`, `shared/default-navigation.json`, and `src/tools/registry.ts`.
|
||||
- Use the shared UI components and existing responsive styles.
|
||||
- Do not edit generated `dist/` directly; rebuild it from source.
|
||||
- Do not overwrite or delete `data/navigation.json`.
|
||||
- Run `npm run check` after every change. Run `npm audit --audit-level=low` before a release.
|
||||
- For UI changes, verify desktop and mobile portrait layouts in a real browser.
|
||||
- A release must update all version references and `CHANGELOG.md`, rebuild the delivery ZIP, and regenerate SHA-256.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
本文档记录面向使用者和维护者的重要变更。版本号以 `package.json` 为准。
|
||||
|
||||
## 0.0.5 - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
- CRC 文件读取、Intel HEX 解析和 CRC 计算统一放入独立 Web Worker,页面只保留文件摘要和结果。
|
||||
- 设置页排序操作提取为可测试的纯函数,并增加加载就绪状态。
|
||||
- 工具目录、默认导航、图标注册和懒加载组件增加运行时及自动化一致性检查。
|
||||
- Node.js 最低版本明确为 22.12.0,增加 `typecheck` 和统一的 `npm run check` 门禁。
|
||||
- 补全生产依赖版本与许可证说明。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复 CRC 更换文件、重新计算及旧 Worker 响应覆盖当前状态的竞态问题。
|
||||
- Intel HEX 增加文件、记录数和 block 数限制,并降低记录合并时的内存占用。
|
||||
- PNG 在浏览器解码前校验签名、IHDR、文件大小、尺寸和总像素;清空或更换图片后忽略旧转换结果。
|
||||
- ICO 在创建 Canvas 前校验并规范化尺寸。
|
||||
- 修复设置页首次加载时保存按钮可能被错误启用的问题。
|
||||
- 修复缺失 JS/CSS 被错误返回 `index.html` 和 200 状态的问题。
|
||||
- 修复导航原子写入失败后可能残留临时文件的问题。
|
||||
- Base64 解码拒绝非规范尾部填充位。
|
||||
- 大型 JSON/XML 结果在语法 token 过多时降级为纯文本,避免创建过量 DOM 节点。
|
||||
- 移动端菜单增加正确的展开状态、动态标签和路由切换自动关闭行为。
|
||||
|
||||
### Verification
|
||||
|
||||
- 49 条 Vitest 测试通过,覆盖算法、边界、目录一致性、排序逻辑和真实 HTTP 服务。
|
||||
- Vue/TypeScript 类型检查和 Vite 生产构建通过。
|
||||
- npm 依赖审计结果为 0 个已知漏洞。
|
||||
- 桌面端和 390 x 844 移动端完成浏览器验收,9 个工具路由无横向溢出或控制台错误。
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY index.html tsconfig*.json vite.config.ts ./
|
||||
COPY src ./src
|
||||
COPY shared ./shared
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=8080 \
|
||||
DATA_DIR=/data
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /data && chown -R node:node /app /data
|
||||
COPY --from=build --chown=node:node /app/dist ./dist
|
||||
COPY --chown=node:node server ./server
|
||||
COPY --chown=node:node shared ./shared
|
||||
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "server/index.mjs"]
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
# Web Tools
|
||||
|
||||
一个匿名访问、自托管的浏览器本地工具箱。支持多人同时使用;除全局导航顺序外,文本、密钥、文件、图片和计算结果均只存在于各自浏览器中,不会发送到服务端。
|
||||
|
||||
当前应用版本:`0.0.5`。
|
||||
|
||||
## 内置工具
|
||||
|
||||
- ASCII/UTF-8 与 Hex 互转
|
||||
- Base64 与 Base64 URL 编码、解码,解码结果支持 UTF-8、连续 Hex 和代码字节数组
|
||||
- AES-128-CMAC(RFC 4493)
|
||||
- CRC-8/16/32/64 预设与自定义参数,支持 BIN 和多 block Intel HEX
|
||||
- RSA 2048/3072/4096 位密钥对,导出 PEM,以及可分别复制的 N/E Hex 或代码字节数组
|
||||
- 密码学安全随机数,支持连续 Hex、空格 Hex 和代码字节数组
|
||||
- JSON 格式化、压缩、校验和语法着色
|
||||
- XML 格式化、压缩、校验和语法着色
|
||||
- PNG 转多尺寸 ICO
|
||||
|
||||
## 交付物与源码
|
||||
|
||||
容器交付 ZIP 同时包含源码、测试、生产构建结果和部署文件,不是只有压缩后的网页。主要目录如下:
|
||||
|
||||
```text
|
||||
src/ Vue 3 前端源码
|
||||
components/ 所有工具共用的 UI 组件
|
||||
lib/ 可测试的纯计算与浏览器辅助逻辑
|
||||
tools/ 每个工具一个独立 Vue 组件
|
||||
workers/ 大文件计算使用的浏览器后台线程
|
||||
views/SettingView.vue /setting 工具管理页
|
||||
server/ 零依赖 Node.js 静态服务和导航配置 API
|
||||
shared/tool-catalog.json 工具与分类的权威元数据目录
|
||||
shared/default-navigation.json 新部署的默认分类和工具顺序
|
||||
tests/ Vitest 单元测试
|
||||
dist/ npm run build 生成的生产前端
|
||||
Dockerfile 多阶段容器构建
|
||||
docker-compose.yml 默认部署配置
|
||||
THIRD_PARTY_NOTICES.md 第三方依赖许可说明
|
||||
CHANGELOG.md 版本变更、修复和验证记录
|
||||
```
|
||||
|
||||
交付包不包含 `node_modules`。Docker 首次构建需要访问 Node 镜像仓库和 npm 软件源;若环境完全离线,应另外交付 Docker 镜像 tar。
|
||||
|
||||
## 架构约束
|
||||
|
||||
- Vue 3、TypeScript、Vite 前端;Node.js 标准库服务端;不引入数据库。
|
||||
- 工具组件彼此独立并懒加载,公共交互使用 `ToolPage`、`ModeTabs`、`TextEditor` 和 `src/styles.css` 中的现有样式。
|
||||
- 工具的计算、解析和文件生成逻辑放在 `src/lib/`,Vue 组件只负责状态与交互。
|
||||
- 大文件或长时间计算必须放入 `src/workers/` 的 Web Worker,避免阻塞页面交互;Worker 仍只在浏览器本地处理数据。
|
||||
- 路由和左侧导航由工具目录自动生成,增加普通工具时不得手工新增路由。
|
||||
- 工具输入只在浏览器内存中处理,不得发送给服务端、遥测服务或第三方接口。
|
||||
- 服务端只持久化导航顺序,文件为 `/data/navigation.json`,写入采用临时文件和原子替换。
|
||||
- `/setting` 是固定但不公开显示的管理入口;项目没有登录、收藏、赞助或用户系统。
|
||||
- 页面必须保持统一 UI,并兼容桌面端和移动端竖屏。
|
||||
|
||||
## 多人使用与状态边界
|
||||
|
||||
- 每个浏览器标签页拥有独立的 Vue 组件状态、文件对象、图像对象和 Web Worker。不同用户、浏览器或标签页不会共享工具输入与结果。
|
||||
- CRC、RSA、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。
|
||||
- `/setting` 保存的是所有访问者共享的导航配置。并发保存没有版本冲突合并,最后完成原子替换的请求生效。
|
||||
- 原子替换保证读取者看到完整的旧配置或新配置,不会看到写到一半的 JSON;它不保证保留多个用户各自的排序修改。
|
||||
- 已打开的页面不会实时接收其他用户保存的新顺序,刷新或重新打开后才会读取。
|
||||
- 深浅主题保存在当前浏览器同源 `localStorage` 中;系统剪贴板由操作系统管理。这两项不会改变工具计算结果。
|
||||
|
||||
## 资源限制
|
||||
|
||||
限制用于避免异常文件、解压后超大图像或高密度语法 token 长时间占用浏览器。修改这些数值时必须同步源码常量、测试和本文档。
|
||||
|
||||
| 功能 | 当前限制 | 超限行为 |
|
||||
| --- | --- | --- |
|
||||
| CRC 固件文件 | 最大 64 MiB | 选择时拒绝,不保留旧文件结果 |
|
||||
| Intel HEX 数据记录 | 最多 500,000 条 | Worker 停止解析并显示错误 |
|
||||
| Intel HEX 数据块 | 最多 1,024 个 block | Worker 停止解析并显示错误 |
|
||||
| PNG 压缩文件 | 最大 32 MiB | 创建预览前拒绝 |
|
||||
| PNG 尺寸 | 单边最大 16,384 像素,总计最大 32,000,000 像素 | 浏览器解码前拒绝 |
|
||||
| 安全随机数 | 每次 1 至 65,536 字节 | 拒绝无效长度 |
|
||||
| JSON/XML 语法着色 | 最大 100,000 字符且不超过 10,000 个 token | 结果仍完整显示,但降级为纯文本以减少 DOM 节点 |
|
||||
| 导航配置请求体 | 最大 64 KiB | 服务端返回 400 |
|
||||
|
||||
## Docker Compose 部署
|
||||
|
||||
要求 Linux AMD64/ARM64、Docker Engine 24+、Docker Compose v2。
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
默认访问地址:`http://服务器地址:8080`
|
||||
|
||||
自定义端口:
|
||||
|
||||
```bash
|
||||
WEB_TOOLS_PORT=9000 docker compose up -d --build
|
||||
```
|
||||
|
||||
检查状态:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs --tail=100 web-tools
|
||||
```
|
||||
|
||||
停止服务:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
导航配置保存在 Docker 卷 `web-tools-data` 中的 `/data/navigation.json`。全新部署且从未保存过排序时,应用直接使用内置默认配置,该文件可能尚不存在;首次保存后会创建。删除或重建容器不会丢失已有配置,执行 `docker compose down -v` 才会删除该卷。
|
||||
|
||||
健康检查地址:`http://服务器地址:8080/healthz`
|
||||
|
||||
## 工具管理
|
||||
|
||||
管理入口不显示在页面中,直接访问:
|
||||
|
||||
```text
|
||||
http://服务器地址:8080/setting
|
||||
```
|
||||
|
||||
可以调整分类顺序、分类内工具顺序及工具所属分类。保存时采用同目录临时文件和原子替换。
|
||||
|
||||
导出配置(至少保存过一次排序后执行):
|
||||
|
||||
```bash
|
||||
docker compose cp web-tools:/data/navigation.json ./navigation.json
|
||||
```
|
||||
|
||||
导入配置:
|
||||
|
||||
```bash
|
||||
docker compose cp ./navigation.json web-tools:/data/navigation.json
|
||||
docker compose restart web-tools
|
||||
```
|
||||
|
||||
升级应用前建议导出该文件。新版本增加工具时,服务端会保留已有顺序,并把缺少的新工具追加到其默认分类。
|
||||
|
||||
## 离线镜像
|
||||
|
||||
若交付目录包含 `personal-web-tools-0.0.5-amd64.tar`,可直接导入:
|
||||
|
||||
```bash
|
||||
docker load -i personal-web-tools-0.0.5-amd64.tar
|
||||
docker compose up -d --no-build
|
||||
```
|
||||
|
||||
当前源码 ZIP 本身不是 Docker 镜像 tar。
|
||||
|
||||
## 本地开发
|
||||
|
||||
要求 Node.js 22.12.0 或更高版本。首次安装使用锁定依赖:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
```
|
||||
|
||||
终端一运行配置服务:
|
||||
|
||||
```bash
|
||||
PORT=8081 DATA_DIR=./data node server/index.mjs
|
||||
```
|
||||
|
||||
终端二运行 Vite;`/api` 和 `/healthz` 会代理到 `127.0.0.1:8081`:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
生产模式本地运行:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
生产服务默认监听 `0.0.0.0:8080`。
|
||||
|
||||
完整工程检查:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm audit --audit-level=low
|
||||
```
|
||||
|
||||
`npm run check` 会依次运行全部 Vitest 测试、TypeScript/Vue 类型检查和生产构建。服务端测试使用随机本地端口验证 SPA 直达路由、静态资源 404、导航 API、请求大小限制及跨站写入拦截。
|
||||
|
||||
自动化测试还覆盖标准算法向量、Base64 规范性、Intel HEX 记录与 block 边界、PNG 头部与像素限制、导航排序纯函数、目录/图标/懒加载组件一致性,以及原子写入失败后的临时文件清理。
|
||||
|
||||
## 增加工具
|
||||
|
||||
### 1. 先定义稳定标识
|
||||
|
||||
为工具选择永久的 kebab-case `id`,例如 `url-encode`。已发布工具的 `id` 不应修改,因为路由、懒加载映射和用户保存的导航配置都依赖它。
|
||||
|
||||
确定工具所属分类。新增分类时,需要同时更新:
|
||||
|
||||
- `shared/tool-catalog.json` 中的 `categories`
|
||||
- `shared/default-navigation.json` 中的 `categories`
|
||||
|
||||
仅新增工具时,分类配置版本仍保持 `version: 1`,不要因为工具数量变化而升级配置格式版本。
|
||||
|
||||
### 2. 编写独立计算逻辑
|
||||
|
||||
将编码、解析、加密、校验或文件生成逻辑放入 `src/lib/`。优先使用浏览器标准 API或经过验证的现有依赖,不在 Vue 组件中堆积算法实现。
|
||||
|
||||
在 `tests/` 添加正常输入、边界输入、无效输入和已知测试向量。涉及字节的工具必须明确 UTF-8、Hex、Base64、换行及分隔符语义,避免隐式改变数据。
|
||||
|
||||
文件或高开销工具还必须定义文件大小、记录数量、图像尺寸或输出 DOM 数量等资源上限。异步工具在清空、更换输入或卸载后必须忽略旧任务结果;计算量较大的文件解析与 CRC 计算放入 Web Worker。
|
||||
|
||||
### 3. 编写统一的工具页面
|
||||
|
||||
在 `src/tools/` 新增一个 Vue 组件,并参考现有 `Base64Tool.vue` 或与需求最接近的工具。
|
||||
|
||||
- 页面外层使用 `ToolPage`。
|
||||
- 文本输入输出优先使用 `TextEditor`。
|
||||
- 模式切换使用 `ModeTabs`。
|
||||
- 操作按钮使用 Lucide 图标和现有 `action-button` 类。
|
||||
- 必须提供清空、错误提示和必要的复制/粘贴操作。
|
||||
- 不增加登录、收藏、赞助、遥测、外部网络请求或独立视觉风格。
|
||||
- 在窄屏下确认控件不溢出、不重叠,文字不会遮挡。
|
||||
|
||||
### 4. 登记工具元数据
|
||||
|
||||
在 `shared/tool-catalog.json` 的 `tools` 中增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "url-encode",
|
||||
"title": "URL 编解码",
|
||||
"category": "encoding",
|
||||
"description": "编码和解码 URL 组件。",
|
||||
"icon": "Link"
|
||||
}
|
||||
```
|
||||
|
||||
`id` 必须唯一,`category` 必须存在。`icon` 使用 Lucide Vue 图标名称。
|
||||
|
||||
在 `shared/default-navigation.json` 的目标分类工具数组中加入相同 `id`,位置即新部署的默认显示顺序。
|
||||
|
||||
### 5. 注册图标与懒加载组件
|
||||
|
||||
在 `src/tools/registry.ts` 中完成三件事:
|
||||
|
||||
1. 从 `@lucide/vue` 导入目录中填写的图标。
|
||||
2. 把图标加入 `iconComponents`。
|
||||
3. 把工具 `id` 和 `defineAsyncComponent(() => import(...))` 加入 `toolComponents`。
|
||||
|
||||
工具目录、默认导航和注册表中的 `id` 必须完全一致。路由会从注册表自动生成,不需要修改 `src/router.ts`。
|
||||
|
||||
现有自动化测试会校验目录分类、工具 ID、图标注册和懒加载组件映射。新增工具后若漏改任一处,`npm test` 或应用启动会直接报错。
|
||||
|
||||
### 6. 验证兼容性
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm audit --audit-level=low
|
||||
```
|
||||
|
||||
还应在真实浏览器中检查:
|
||||
|
||||
- 新工具的默认路径 `/tools/<id>` 可以直接打开和刷新。
|
||||
- 左侧导航、搜索和 `/setting` 正常显示新工具。
|
||||
- 已保存的旧 `navigation.json` 启动后会自动补入新工具。
|
||||
- 桌面端和约 `390 x 844` 的移动端竖屏布局正常。
|
||||
- 浏览器控制台无错误,工具内容没有发往服务端。
|
||||
|
||||
## AI 接手清单
|
||||
|
||||
后续由 AI 增加或修改工具时,应先阅读本文件,然后依次检查:
|
||||
|
||||
1. `shared/tool-catalog.json` 和 `shared/default-navigation.json`
|
||||
2. `src/tools/registry.ts`
|
||||
3. 与目标功能最接近的 `src/tools/*.vue`
|
||||
4. `src/components/ToolPage.vue`、`TextEditor.vue`、`ModeTabs.vue`
|
||||
5. 相关 `src/lib/` 和 `tests/`
|
||||
6. `server/navigation.mjs` 中的导航兼容逻辑
|
||||
|
||||
AI 不应仅修改 `dist/`;必须修改源码并重新构建。不得覆盖或删除用户的 `data/navigation.json`。发现工作区已有未说明的改动时应保留并兼容,不得擅自回退。
|
||||
|
||||
处理多人访问、文件工具或异步计算时,还必须检查本文档中的“多人使用与状态边界”和“资源限制”,避免重新引入共享状态、旧任务覆盖新输入或无上限解析。
|
||||
|
||||
完成任务后至少报告:修改的工具、数据格式选择、测试和构建结果、容器包路径及 SHA-256。涉及 UI 的改动还要进行桌面和移动端浏览器验收。
|
||||
|
||||
## 版本与发布
|
||||
|
||||
应用版本的权威来源是 `package.json`。发布新版本时:
|
||||
|
||||
1. 使用 `npm version patch --no-git-tag-version` 或按兼容性选择 minor/major,使 `package.json` 与 `package-lock.json` 同步。
|
||||
2. 更新 `docker-compose.yml` 中的镜像标签。
|
||||
3. 更新本文档中的当前版本和离线镜像文件名。
|
||||
4. 在 `CHANGELOG.md` 记录用户可见变更、重要修复和验证结果。
|
||||
5. 运行测试、生产构建和依赖审计。
|
||||
6. 重新制作源码/容器部署 ZIP,并生成 SHA-256。
|
||||
|
||||
版本号固定显示在左侧导航底部;版权信息 `Copyright © mid All Rights Reserved.` 固定显示在所有页面底部。第三方许可保留在 `THIRD_PARTY_NOTICES.md`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 无登录、无统计、无遥测、无 CDN 和第三方网络请求。
|
||||
- `/setting` 是固定但不显示的入口,不构成身份认证;直接公开到互联网时任何访问者都可能修改排序。
|
||||
- 工具内容仅在浏览器内存中处理,默认不会持久化。
|
||||
- 容器以非 root 用户运行,根文件系统只读,全部 Linux capabilities 已移除。
|
||||
- 服务端只接受不超过 64 KiB 的导航 JSON 配置。
|
||||
- 服务端只提供静态文件、`GET/PUT /api/navigation` 和 `GET /healthz`。
|
||||
- 只有无扩展名且明确请求 HTML 的前端路由会回退到 `index.html`;缺失的 JS、CSS 和其他资源返回 404。
|
||||
- JSON API 响应使用 `Cache-Control: no-store`,避免浏览器缓存导航和健康状态。
|
||||
- 安全响应头禁止第三方脚本、跨站嵌入、摄像头、麦克风和定位权限。
|
||||
@@ -0,0 +1,51 @@
|
||||
# Third-Party Notices
|
||||
|
||||
This project bundles open-source dependencies. Exact dependency integrity hashes and the development-only toolchain are recorded in `package-lock.json`. The following production dependency set is included by the current lockfile.
|
||||
|
||||
| Package | Version | License |
|
||||
| --- | --- | --- |
|
||||
| `@lucide/vue` | 1.29.0 | ISC; selected Feather-derived icons are MIT |
|
||||
| `@stablelib/aes`, `binary`, `cmac`, `constant-time`, `int`, `wipe` | 2.0.1 | MIT |
|
||||
| `@stablelib/blockcipher` | 2.0.0 | MIT |
|
||||
| `node-forge` | 1.4.0 | BSD-3-Clause OR GPL-2.0; this distribution selects BSD-3-Clause |
|
||||
| `vue`, `@vue/*` runtime/compiler packages | 3.5.41 | MIT |
|
||||
| `vue-router` | 4.6.4 | MIT |
|
||||
| `@vue/devtools-api` | 6.6.4 | MIT |
|
||||
| `xml-formatter` | 3.7.0 | MIT |
|
||||
| `xml-parser-xo` | 4.1.6 | MIT |
|
||||
| `@babel/helper-string-parser`, `@babel/helper-validator-identifier` | 7.29.7 | MIT |
|
||||
| `@babel/parser`, `@babel/types` | 7.29.8 | MIT |
|
||||
| `@jridgewell/sourcemap-codec` | 1.5.5 | MIT |
|
||||
| `csstype` | 3.2.3 | MIT |
|
||||
| `entities` | 7.0.1 | BSD-2-Clause |
|
||||
| `estree-walker` | 2.0.2 | MIT |
|
||||
| `magic-string` | 0.30.21 | MIT |
|
||||
| `nanoid` | 3.3.17 | MIT |
|
||||
| `picocolors` | 1.1.1 | ISC |
|
||||
| `postcss` | 8.5.26 | MIT |
|
||||
| `source-map-js` | 1.2.1 | BSD-3-Clause |
|
||||
| `typescript` | 5.8.3 | Apache-2.0 |
|
||||
|
||||
Primary copyright notices:
|
||||
|
||||
- Vue: Copyright (c) 2018-present, Yuxi (Evan) You.
|
||||
- Vue Router: Copyright (c) 2019-present, Eduardo San Martin Morote.
|
||||
- Lucide: Copyright (c) 2026 Lucide Icons and Contributors. Feather-derived icons: Copyright (c) 2013-present, Cole Bemis.
|
||||
- StableLib: Copyright (C) 2016 Dmitry Chestnykh. The AES implementation also contains public-domain Rijndael material and BSD-licensed Go-derived material identified in the upstream package.
|
||||
- xml-formatter: Copyright 2019 Chris Bottin. xml-parser-xo: Copyright 2020 Chris Bottin.
|
||||
- node-forge: Copyright (c) 2010, Digital Bazaar, Inc.
|
||||
|
||||
Full per-package license files are available in each package installed by `npm ci`. The complete node-forge BSD license selected for this distribution follows.
|
||||
|
||||
## node-forge New BSD License
|
||||
|
||||
Copyright (c) 2010, Digital Bazaar, Inc.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of Digital Bazaar, Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
web-tools:
|
||||
image: personal-web-tools:0.0.5
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: personal-web-tools
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
ports:
|
||||
- "${WEB_TOOLS_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- web-tools-data:/data
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
web-tools-data:
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f5f6f7" />
|
||||
<meta name="description" content="浏览器本地运行的个人 Web 工具箱" />
|
||||
<title>Web Tools</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1666
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "personal-web-tools",
|
||||
"version": "0.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"typecheck": "vue-tsc -b",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"check": "npm test && npm run build",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"test:watch": "vitest --config vitest.config.ts",
|
||||
"start": "node server/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.29.0",
|
||||
"@stablelib/aes": "^2.0.1",
|
||||
"@stablelib/cmac": "^2.0.1",
|
||||
"node-forge": "^1.4.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"xml-formatter": "^3.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^3.3.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import path from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
loadCatalog,
|
||||
loadDefaultNavigation,
|
||||
readOrCreateNavigation,
|
||||
validateNavigation,
|
||||
writeJsonAtomic,
|
||||
} from './navigation.mjs'
|
||||
|
||||
const serverFile = fileURLToPath(import.meta.url)
|
||||
const serverDir = path.dirname(serverFile)
|
||||
const projectRoot = path.resolve(serverDir, '..')
|
||||
export const MAX_JSON_BODY_SIZE = 64 * 1024
|
||||
|
||||
const mimeTypes = new Map([
|
||||
['.css', 'text/css; charset=utf-8'],
|
||||
['.html', 'text/html; charset=utf-8'],
|
||||
['.ico', 'image/x-icon'],
|
||||
['.js', 'text/javascript; charset=utf-8'],
|
||||
['.json', 'application/json; charset=utf-8'],
|
||||
['.map', 'application/json; charset=utf-8'],
|
||||
['.png', 'image/png'],
|
||||
['.svg', 'image/svg+xml'],
|
||||
['.txt', 'text/plain; charset=utf-8'],
|
||||
['.woff2', 'font/woff2'],
|
||||
])
|
||||
|
||||
function applySecurityHeaders(response) {
|
||||
response.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
|
||||
response.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||
response.setHeader('Referrer-Policy', 'no-referrer')
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
response.setHeader('X-Frame-Options', 'DENY')
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, payload, headers = {}) {
|
||||
const body = `${JSON.stringify(payload)}\n`
|
||||
response.statusCode = statusCode
|
||||
response.setHeader('Cache-Control', 'no-store')
|
||||
response.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
response.setHeader('Content-Length', Buffer.byteLength(body))
|
||||
for (const [name, value] of Object.entries(headers)) response.setHeader(name, value)
|
||||
response.end(body)
|
||||
}
|
||||
|
||||
function sendText(response, statusCode, body, headers = {}) {
|
||||
response.statusCode = statusCode
|
||||
response.setHeader('Cache-Control', 'no-store')
|
||||
response.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
response.setHeader('Content-Length', Buffer.byteLength(body))
|
||||
for (const [name, value] of Object.entries(headers)) response.setHeader(name, value)
|
||||
response.end(body)
|
||||
}
|
||||
|
||||
async function readJsonBody(request) {
|
||||
const contentLength = Number(request.headers['content-length'])
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_JSON_BODY_SIZE) throw new Error('请求内容过大')
|
||||
|
||||
const chunks = []
|
||||
let size = 0
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length
|
||||
if (size > MAX_JSON_BODY_SIZE) throw new Error('请求内容过大')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
}
|
||||
|
||||
function isMissingFile(error) {
|
||||
return error?.code === 'ENOENT' || error?.code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
function acceptsHtmlNavigation(request, pathname) {
|
||||
if (path.extname(pathname)) return false
|
||||
return String(request.headers.accept || '').split(',').some((value) => value.trim().toLowerCase().startsWith('text/html'))
|
||||
}
|
||||
|
||||
async function serveStatic(request, response, staticRoot) {
|
||||
const url = new URL(request.url, 'http://localhost')
|
||||
let pathname
|
||||
try {
|
||||
pathname = decodeURIComponent(url.pathname)
|
||||
} catch {
|
||||
sendText(response, 400, 'Bad request\n')
|
||||
return
|
||||
}
|
||||
|
||||
const requested = pathname === '/' ? '/index.html' : pathname
|
||||
const candidate = path.resolve(staticRoot, `.${requested}`)
|
||||
if (!candidate.startsWith(`${staticRoot}${path.sep}`)) {
|
||||
sendText(response, 403, 'Forbidden\n')
|
||||
return
|
||||
}
|
||||
|
||||
let filePath = candidate
|
||||
let details
|
||||
try {
|
||||
details = await stat(filePath)
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) throw error
|
||||
}
|
||||
if (!details?.isFile()) {
|
||||
if (!acceptsHtmlNavigation(request, pathname)) {
|
||||
sendText(response, 404, 'Not found\n')
|
||||
return
|
||||
}
|
||||
filePath = path.join(staticRoot, 'index.html')
|
||||
details = await stat(filePath)
|
||||
if (!details.isFile()) throw new Error('静态入口文件不存在')
|
||||
}
|
||||
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', mimeTypes.get(path.extname(filePath).toLowerCase()) || 'application/octet-stream')
|
||||
response.setHeader('Content-Length', details.size)
|
||||
if (path.basename(filePath) === 'index.html') response.setHeader('Cache-Control', 'no-cache')
|
||||
else response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
if (request.method === 'HEAD') {
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
await pipeline(createReadStream(filePath), response)
|
||||
}
|
||||
|
||||
export async function createWebToolsServer(options = {}) {
|
||||
const staticRoot = path.resolve(options.staticRoot || path.join(projectRoot, 'dist'))
|
||||
const dataDir = path.resolve(options.dataDir || process.env.DATA_DIR || path.join(projectRoot, 'data'))
|
||||
const navigationPath = path.join(dataDir, 'navigation.json')
|
||||
const catalog = options.catalog || await loadCatalog()
|
||||
const defaults = options.defaults || await loadDefaultNavigation()
|
||||
const logger = options.logger || console
|
||||
|
||||
return createServer(async (request, response) => {
|
||||
applySecurityHeaders(response)
|
||||
try {
|
||||
const url = new URL(request.url, 'http://localhost')
|
||||
if (request.method === 'GET' && url.pathname === '/healthz') {
|
||||
sendJson(response, 200, { status: 'ok' })
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/navigation') {
|
||||
if (request.method === 'GET') {
|
||||
sendJson(response, 200, await readOrCreateNavigation(navigationPath, catalog, defaults))
|
||||
return
|
||||
}
|
||||
if (request.method === 'PUT') {
|
||||
if (request.headers['sec-fetch-site'] === 'cross-site') {
|
||||
sendJson(response, 403, { error: '不允许跨站写入配置' })
|
||||
return
|
||||
}
|
||||
let navigation
|
||||
try {
|
||||
navigation = validateNavigation(await readJsonBody(request), catalog)
|
||||
} catch (error) {
|
||||
sendJson(response, 400, { error: error instanceof Error ? error.message : '配置格式无效' })
|
||||
return
|
||||
}
|
||||
await writeJsonAtomic(navigationPath, navigation)
|
||||
sendJson(response, 200, navigation)
|
||||
return
|
||||
}
|
||||
sendJson(response, 405, { error: '请求方法不支持' }, { Allow: 'GET, PUT' })
|
||||
return
|
||||
}
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
sendJson(response, 404, { error: '接口不存在' })
|
||||
return
|
||||
}
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
sendText(response, 405, '', { Allow: 'GET, HEAD' })
|
||||
return
|
||||
}
|
||||
await serveStatic(request, response, staticRoot)
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
if (response.headersSent) response.destroy()
|
||||
else sendJson(response, 500, { error: '服务器错误' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function startWebToolsServer() {
|
||||
const host = process.env.HOST || '0.0.0.0'
|
||||
const port = Number(process.env.PORT || 8080)
|
||||
const server = await createWebToolsServer()
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Web Tools listening on http://${host}:${port}`)
|
||||
console.log(`Navigation config: ${path.resolve(process.env.DATA_DIR || path.join(projectRoot, 'data'), 'navigation.json')}`)
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === serverFile) {
|
||||
startWebToolsServer().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const serverDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(serverDir, '..')
|
||||
|
||||
async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'))
|
||||
}
|
||||
|
||||
export async function loadCatalog() {
|
||||
return readJson(path.join(projectRoot, 'shared', 'tool-catalog.json'))
|
||||
}
|
||||
|
||||
export async function loadDefaultNavigation() {
|
||||
return readJson(path.join(projectRoot, 'shared', 'default-navigation.json'))
|
||||
}
|
||||
|
||||
export function normalizeNavigation(input, catalog, defaults) {
|
||||
const categoryIds = new Set(catalog.categories.map((category) => category.id))
|
||||
const toolById = new Map(catalog.tools.map((tool) => [tool.id, tool]))
|
||||
const seenCategories = new Set()
|
||||
const seenTools = new Set()
|
||||
const categories = []
|
||||
|
||||
const inputCategories = Array.isArray(input?.categories) ? input.categories : []
|
||||
for (const candidate of inputCategories) {
|
||||
if (!candidate || !categoryIds.has(candidate.id) || seenCategories.has(candidate.id)) continue
|
||||
seenCategories.add(candidate.id)
|
||||
const tools = []
|
||||
for (const toolId of Array.isArray(candidate.tools) ? candidate.tools : []) {
|
||||
if (!toolById.has(toolId) || seenTools.has(toolId)) continue
|
||||
seenTools.add(toolId)
|
||||
tools.push(toolId)
|
||||
}
|
||||
categories.push({ id: candidate.id, tools })
|
||||
}
|
||||
|
||||
for (const defaultCategory of defaults.categories) {
|
||||
if (!seenCategories.has(defaultCategory.id)) {
|
||||
seenCategories.add(defaultCategory.id)
|
||||
categories.push({ id: defaultCategory.id, tools: [] })
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of catalog.tools) {
|
||||
if (seenTools.has(tool.id)) continue
|
||||
const preferred = categories.find((category) => category.id === tool.category) ?? categories[0]
|
||||
preferred?.tools.push(tool.id)
|
||||
seenTools.add(tool.id)
|
||||
}
|
||||
|
||||
return { version: 1, categories }
|
||||
}
|
||||
|
||||
export function validateNavigation(input, catalog) {
|
||||
if (!input || input.version !== 1 || !Array.isArray(input.categories)) {
|
||||
throw new Error('配置格式无效')
|
||||
}
|
||||
const validCategories = new Set(catalog.categories.map((category) => category.id))
|
||||
const validTools = new Set(catalog.tools.map((tool) => tool.id))
|
||||
const seenCategories = new Set()
|
||||
const seenTools = new Set()
|
||||
|
||||
for (const category of input.categories) {
|
||||
if (!category || !validCategories.has(category.id) || seenCategories.has(category.id)) {
|
||||
throw new Error(`分类无效或重复:${category?.id ?? 'unknown'}`)
|
||||
}
|
||||
if (!Array.isArray(category.tools)) throw new Error(`分类 ${category.id} 缺少工具列表`)
|
||||
seenCategories.add(category.id)
|
||||
for (const toolId of category.tools) {
|
||||
if (!validTools.has(toolId) || seenTools.has(toolId)) throw new Error(`工具无效或重复:${toolId}`)
|
||||
seenTools.add(toolId)
|
||||
}
|
||||
}
|
||||
|
||||
if (seenCategories.size !== validCategories.size || seenTools.size !== validTools.size) {
|
||||
throw new Error('配置必须包含全部分类和工具')
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
export async function writeJsonAtomic(filePath, value, operations = { mkdir, open, rename, unlink }) {
|
||||
const serialized = `${JSON.stringify(value, null, 2)}\n`
|
||||
const temporaryPath = `${filePath}.${randomUUID()}.tmp`
|
||||
let handle
|
||||
try {
|
||||
await operations.mkdir(path.dirname(filePath), { recursive: true })
|
||||
handle = await operations.open(temporaryPath, 'wx', 0o600)
|
||||
await handle.writeFile(serialized, 'utf8')
|
||||
await handle.sync()
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
await operations.rename(temporaryPath, filePath)
|
||||
} catch (error) {
|
||||
if (handle) await handle.close().catch(() => undefined)
|
||||
await operations.unlink(temporaryPath).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function readOrCreateNavigation(filePath, catalog, defaults) {
|
||||
let current
|
||||
try {
|
||||
current = await readJson(filePath)
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
current = defaults
|
||||
}
|
||||
const normalized = normalizeNavigation(current, catalog, defaults)
|
||||
if (JSON.stringify(current) !== JSON.stringify(normalized)) await writeJsonAtomic(filePath, normalized)
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"categories": [
|
||||
{ "id": "encoding", "tools": ["ascii-hex", "base64"] },
|
||||
{ "id": "checksum", "tools": ["crc"] },
|
||||
{ "id": "crypto", "tools": ["aes-cmac", "rsa-key-pair", "random"] },
|
||||
{ "id": "formatting", "tools": ["json", "xml"] },
|
||||
{ "id": "image", "tools": ["png-ico"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"version": 1,
|
||||
"categories": [
|
||||
{ "id": "encoding", "title": "编码转换" },
|
||||
{ "id": "checksum", "title": "校验计算" },
|
||||
{ "id": "crypto", "title": "加密计算" },
|
||||
{ "id": "formatting", "title": "格式化" },
|
||||
{ "id": "image", "title": "图像处理" }
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"id": "ascii-hex",
|
||||
"title": "ASCII / Hex",
|
||||
"category": "encoding",
|
||||
"description": "在 UTF-8 文本与十六进制字节之间转换。",
|
||||
"icon": "Binary"
|
||||
},
|
||||
{
|
||||
"id": "base64",
|
||||
"title": "Base64",
|
||||
"category": "encoding",
|
||||
"description": "编码、解码并校验 Base64 内容。",
|
||||
"icon": "FileCode2"
|
||||
},
|
||||
{
|
||||
"id": "aes-cmac",
|
||||
"title": "AES-128-CMAC",
|
||||
"category": "crypto",
|
||||
"description": "使用 128 位密钥计算消息认证码。",
|
||||
"icon": "ShieldCheck"
|
||||
},
|
||||
{
|
||||
"id": "rsa-key-pair",
|
||||
"title": "RSA 密钥对",
|
||||
"category": "crypto",
|
||||
"description": "生成 RSA 密钥对并导出 PEM 与 N/E 参数。",
|
||||
"icon": "KeyRound"
|
||||
},
|
||||
{
|
||||
"id": "random",
|
||||
"title": "安全随机数",
|
||||
"category": "crypto",
|
||||
"description": "生成指定字节长度的密码学安全随机数。",
|
||||
"icon": "Dices"
|
||||
},
|
||||
{
|
||||
"id": "crc",
|
||||
"title": "CRC",
|
||||
"category": "checksum",
|
||||
"description": "解析 BIN 或 Intel HEX 数据并计算 CRC。",
|
||||
"icon": "BadgeCheck"
|
||||
},
|
||||
{
|
||||
"id": "json",
|
||||
"title": "JSON",
|
||||
"category": "formatting",
|
||||
"description": "格式化、压缩并校验 JSON 字符串。",
|
||||
"icon": "Braces"
|
||||
},
|
||||
{
|
||||
"id": "xml",
|
||||
"title": "XML",
|
||||
"category": "formatting",
|
||||
"description": "格式化、压缩并校验 XML 字符串。",
|
||||
"icon": "Code2"
|
||||
},
|
||||
{
|
||||
"id": "png-ico",
|
||||
"title": "PNG → ICO",
|
||||
"category": "image",
|
||||
"description": "在浏览器本地生成多尺寸 ICO 图标。",
|
||||
"icon": "Image"
|
||||
}
|
||||
]
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRight, Menu, Moon, Sun, X } from '@lucide/vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import { categoryById } from '@/tools/registry'
|
||||
import { useNavigation } from '@/composables/useNavigation'
|
||||
|
||||
const route = useRoute()
|
||||
const { navigation, loadNavigation } = useNavigation()
|
||||
const menuOpen = ref(false)
|
||||
const darkMode = ref(false)
|
||||
|
||||
const breadcrumbCategory = computed(() => {
|
||||
if (route.name === 'setting') return '配置'
|
||||
return categoryById.get(String(route.meta.categoryId))?.title ?? '工具箱'
|
||||
})
|
||||
|
||||
function applyTheme(isDark: boolean) {
|
||||
darkMode.value = isDark
|
||||
document.documentElement.dataset.theme = isDark ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
applyTheme(!darkMode.value)
|
||||
localStorage.setItem('web-tools-theme', darkMode.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
menuOpen.value = false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem('web-tools-theme')
|
||||
applyTheme(saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
void loadNavigation()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout" :class="{ 'menu-open': menuOpen }">
|
||||
<AppSidebar :navigation="navigation" @navigate="menuOpen = false" />
|
||||
<button v-if="menuOpen" class="menu-scrim" type="button" aria-label="关闭工具菜单" @click="menuOpen = false" />
|
||||
<main class="app-main">
|
||||
<header class="topbar">
|
||||
<button
|
||||
class="icon-button mobile-menu"
|
||||
type="button"
|
||||
:aria-label="menuOpen ? '关闭工具菜单' : '打开工具菜单'"
|
||||
:aria-expanded="menuOpen"
|
||||
aria-controls="tool-navigation"
|
||||
@click="menuOpen = !menuOpen"
|
||||
>
|
||||
<X v-if="menuOpen" :size="18" />
|
||||
<Menu v-else :size="18" />
|
||||
</button>
|
||||
<span>{{ breadcrumbCategory }}</span>
|
||||
<ChevronRight :size="14" aria-hidden="true" />
|
||||
<strong>{{ route.meta.title }}</strong>
|
||||
<span class="topbar-spacer" />
|
||||
<button class="icon-button" type="button" :aria-label="darkMode ? '切换浅色主题' : '切换深色主题'" @click="toggleTheme">
|
||||
<Sun v-if="darkMode" :size="17" />
|
||||
<Moon v-else :size="17" />
|
||||
</button>
|
||||
</header>
|
||||
<RouterView />
|
||||
<footer class="app-footer">Copyright © mid All Rights Reserved.</footer>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { Info, Search, Wrench } from '@lucide/vue'
|
||||
import packageJson from '../../package.json'
|
||||
import { computed, ref, type DeepReadonly } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { categoryById, toolById } from '@/tools/registry'
|
||||
import type { NavigationConfig } from '@/types'
|
||||
|
||||
const props = defineProps<{ navigation: DeepReadonly<NavigationConfig> }>()
|
||||
const emit = defineEmits<{ navigate: [] }>()
|
||||
const route = useRoute()
|
||||
const query = ref('')
|
||||
const appVersion = packageJson.version
|
||||
|
||||
const sections = computed(() => props.navigation.categories.map((navigationCategory) => {
|
||||
const category = categoryById.get(navigationCategory.id)
|
||||
const tools = navigationCategory.tools
|
||||
.map((toolId) => toolById.get(toolId))
|
||||
.filter((tool) => tool && (!query.value.trim() || tool.title.toLowerCase().includes(query.value.trim().toLowerCase())))
|
||||
return { category, tools }
|
||||
}).filter((section) => section.category && section.tools.length > 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside id="tool-navigation" class="app-sidebar" aria-label="工具导航">
|
||||
<div class="brand-row">
|
||||
<span class="brand-mark"><Wrench :size="17" aria-hidden="true" /></span>
|
||||
<span><strong>Web Tools</strong><small>Personal workspace</small></span>
|
||||
</div>
|
||||
<label class="sidebar-search">
|
||||
<Search :size="15" aria-hidden="true" />
|
||||
<input v-model="query" type="search" placeholder="搜索工具" aria-label="搜索工具" />
|
||||
</label>
|
||||
<nav class="tool-navigation">
|
||||
<section v-for="section in sections" :key="section.category!.id">
|
||||
<h2>{{ section.category!.title }}</h2>
|
||||
<RouterLink
|
||||
v-for="(tool, index) in section.tools"
|
||||
:key="tool!.id"
|
||||
:to="`/tools/${tool!.id}`"
|
||||
:class="{ 'is-active': route.meta.toolId === tool!.id }"
|
||||
@click="emit('navigate')"
|
||||
>
|
||||
<component :is="tool!.iconComponent" :size="16" aria-hidden="true" />
|
||||
<span>{{ tool!.title }}</span>
|
||||
<small>{{ String(index + 1).padStart(2, '0') }}</small>
|
||||
</RouterLink>
|
||||
</section>
|
||||
<p v-if="sections.length === 0" class="empty-search">没有匹配的工具</p>
|
||||
</nav>
|
||||
<div class="sidebar-local"><Info :size="14" aria-hidden="true" />版本 {{ appVersion }}</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts" generic="T extends string">
|
||||
import type { ModeOption } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
modelValue: T
|
||||
options: ModeOption<T>[]
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: T] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mode-tabs" role="group" :aria-label="label">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="mode-tab"
|
||||
:class="{ 'is-active': modelValue === option.value }"
|
||||
:aria-pressed="modelValue === option.value"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { tokenizeSyntaxForDisplay, type SyntaxLanguage } from '@/lib/syntax'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value: string
|
||||
label: string
|
||||
language: SyntaxLanguage
|
||||
format?: string
|
||||
meta?: string
|
||||
}>(), {
|
||||
format: '',
|
||||
meta: '',
|
||||
})
|
||||
|
||||
const tokens = computed(() => tokenizeSyntaxForDisplay(props.value, props.language))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="syntax-viewer" :aria-label="label">
|
||||
<span class="editor-header">
|
||||
<strong>{{ label }}</strong>
|
||||
<span v-if="format">{{ format }}</span>
|
||||
<span v-if="meta" class="editor-meta">{{ meta }}</span>
|
||||
</span>
|
||||
<pre tabindex="0"><code><template v-if="tokens"><span v-for="(token, index) in tokens" :key="index" :class="`syntax-${token.type}`">{{ token.text }}</span></template><template v-else>{{ value }}</template></code></pre>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
label: string
|
||||
format?: string
|
||||
meta?: string
|
||||
placeholder?: string
|
||||
readonly?: boolean
|
||||
rows?: number
|
||||
}>(), {
|
||||
format: '',
|
||||
meta: '',
|
||||
placeholder: '',
|
||||
readonly: false,
|
||||
rows: 9,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
function update(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="editor-panel">
|
||||
<span class="editor-header">
|
||||
<strong>{{ label }}</strong>
|
||||
<span v-if="format">{{ format }}</span>
|
||||
<span v-if="meta" class="editor-meta">{{ meta }}</span>
|
||||
</span>
|
||||
<textarea
|
||||
:value="props.modelValue"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:rows="rows"
|
||||
spellcheck="false"
|
||||
@input="update"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleCheck, LockKeyhole } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
category: string
|
||||
title: string
|
||||
description: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tool-page">
|
||||
<header class="tool-heading">
|
||||
<div>
|
||||
<p class="tool-category">{{ category }}</p>
|
||||
<h1>{{ title }}</h1>
|
||||
<p class="tool-description">{{ description }}</p>
|
||||
</div>
|
||||
<span class="local-state"><CircleCheck :size="15" />本地处理</span>
|
||||
</header>
|
||||
<slot />
|
||||
<div class="privacy-note">
|
||||
<LockKeyhole :size="16" aria-hidden="true" />
|
||||
<span><strong>输入不会上传。</strong> 内容只保留在当前浏览器页面。</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
import defaultNavigationData from '@shared/default-navigation.json'
|
||||
import type { NavigationConfig } from '@/types'
|
||||
|
||||
const navigation = ref<NavigationConfig>(structuredClone(defaultNavigationData) as NavigationConfig)
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
let loaded = false
|
||||
let pendingLoad: Promise<void> | undefined
|
||||
|
||||
export function useNavigation() {
|
||||
async function loadNavigation(force = false) {
|
||||
if (loaded && !force) return
|
||||
if (pendingLoad && !force) return pendingLoad
|
||||
pendingLoad = (async () => {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const response = await fetch('/api/navigation', { headers: { Accept: 'application/json' } })
|
||||
if (!response.ok) throw new Error(`读取配置失败(${response.status})`)
|
||||
navigation.value = await response.json() as NavigationConfig
|
||||
loaded = true
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '读取配置失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
pendingLoad = undefined
|
||||
}
|
||||
})()
|
||||
return pendingLoad
|
||||
}
|
||||
|
||||
async function saveNavigation(next: NavigationConfig) {
|
||||
const response = await fetch('/api/navigation', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(next),
|
||||
})
|
||||
const payload = await response.json().catch(() => ({})) as NavigationConfig & { error?: string }
|
||||
if (!response.ok) throw new Error(payload.error || `保存配置失败(${response.status})`)
|
||||
navigation.value = payload
|
||||
loaded = true
|
||||
return payload
|
||||
}
|
||||
|
||||
function resetLocalNavigation() {
|
||||
return structuredClone(defaultNavigationData) as NavigationConfig
|
||||
}
|
||||
|
||||
return {
|
||||
navigation: readonly(navigation),
|
||||
loading: readonly(loading),
|
||||
loadError: readonly(loadError),
|
||||
loadNavigation,
|
||||
saveNavigation,
|
||||
resetLocalNavigation,
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export async function copyText(value: string): Promise<void> {
|
||||
if (!value) throw new Error('没有可复制的内容')
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
return
|
||||
}
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand('copy')
|
||||
textarea.remove()
|
||||
if (!copied) throw new Error('浏览器不支持自动复制,请手动选择结果')
|
||||
}
|
||||
|
||||
export async function pasteText(): Promise<string> {
|
||||
if (!navigator.clipboard?.readText) throw new Error('当前连接不支持读取剪贴板,请使用 Ctrl+V')
|
||||
return navigator.clipboard.readText()
|
||||
}
|
||||
|
||||
export function downloadBytes(data: Uint8Array, filename: string, type: string): void {
|
||||
const blob = new Blob([Uint8Array.from(data).buffer], { type })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export function parseHex(input: string, exactBytes?: number): Uint8Array {
|
||||
const trimmed = input.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
if (exactBytes === 0 || exactBytes === undefined) return new Uint8Array()
|
||||
throw new Error(`需要 ${exactBytes} 字节,当前为 0 字节`)
|
||||
}
|
||||
|
||||
const tokens = trimmed.split(/[\s,,]+/)
|
||||
const normalizedTokens = tokens.map((token) => token.replace(/^0x/i, ''))
|
||||
const emptyToken = normalizedTokens.findIndex((token) => !token)
|
||||
if (emptyToken !== -1) throw new Error(`第 ${emptyToken + 1} 个十六进制片段缺少数值`)
|
||||
const normalized = normalizedTokens.join('')
|
||||
const invalid = normalized.match(/[^0-9a-f]/i)
|
||||
if (invalid) throw new Error(`包含无效的十六进制字符“${invalid[0]}”`)
|
||||
if (normalized.length % 2 !== 0) throw new Error('十六进制字符数量必须为偶数')
|
||||
|
||||
const byteLength = normalized.length / 2
|
||||
if (exactBytes !== undefined && byteLength !== exactBytes) {
|
||||
throw new Error(`需要 ${exactBytes} 字节,当前为 ${byteLength} 字节`)
|
||||
}
|
||||
|
||||
const result = new Uint8Array(byteLength)
|
||||
for (let index = 0; index < byteLength; index += 1) {
|
||||
result[index] = Number.parseInt(normalized.slice(index * 2, index * 2 + 2), 16)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array, delimiter = ' ', uppercase = true): string {
|
||||
const value = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(delimiter)
|
||||
return uppercase ? value.toUpperCase() : value
|
||||
}
|
||||
|
||||
export type ByteDisplayFormat = 'continuous-hex' | 'spaced-hex' | 'byte-array'
|
||||
|
||||
export interface FormatBytesOptions {
|
||||
format?: ByteDisplayFormat
|
||||
uppercase?: boolean
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: Uint8Array, options: FormatBytesOptions = {}): string {
|
||||
const format = options.format ?? 'continuous-hex'
|
||||
const values = Array.from(bytes, (byte) => {
|
||||
const hex = byte.toString(16).padStart(2, '0')
|
||||
const formatted = options.uppercase === false ? hex : hex.toUpperCase()
|
||||
return format === 'byte-array' ? `0x${formatted}` : formatted
|
||||
})
|
||||
|
||||
if (format === 'spaced-hex') return values.join(' ')
|
||||
if (format === 'byte-array') return values.join(', ')
|
||||
return values.join('')
|
||||
}
|
||||
|
||||
export function textToBytes(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value)
|
||||
}
|
||||
|
||||
export function bytesToText(value: Uint8Array): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(value)
|
||||
} catch {
|
||||
throw new Error('字节内容不是有效的 UTF-8 文本')
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBinary(bytes: Uint8Array): string {
|
||||
const chunkSize = 0x8000
|
||||
let result = ''
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
result += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function encodeBase64(bytes: Uint8Array, urlSafe = false): string {
|
||||
const encoded = btoa(bytesToBinary(bytes))
|
||||
return urlSafe ? encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') : encoded
|
||||
}
|
||||
|
||||
export function decodeBase64(input: string, urlSafe = false): Uint8Array {
|
||||
let normalized = input.replace(/\s+/g, '')
|
||||
if (urlSafe) normalized = normalized.replace(/-/g, '+').replace(/_/g, '/')
|
||||
if (!normalized) return new Uint8Array()
|
||||
if (/[^A-Za-z0-9+/=]/.test(normalized)) throw new Error('包含无效的 Base64 字符')
|
||||
if (normalized.length % 4 === 1) throw new Error('Base64 长度无效')
|
||||
normalized = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) throw new Error('Base64 填充位置无效')
|
||||
try {
|
||||
const binary = atob(normalized)
|
||||
if (btoa(binary) !== normalized) throw new Error('non-canonical')
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
} catch {
|
||||
throw new Error('Base64 内容无效')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AES } from '@stablelib/aes'
|
||||
import { CMAC } from '@stablelib/cmac'
|
||||
|
||||
export function aes128Cmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
if (key.length !== 16) throw new Error(`AES-128 密钥必须是 16 字节,当前为 ${key.length} 字节`)
|
||||
const aes = new AES(key)
|
||||
const cmac = new CMAC(aes)
|
||||
try {
|
||||
cmac.update(message)
|
||||
return cmac.digest()
|
||||
} finally {
|
||||
cmac.clean()
|
||||
aes.clean()
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
export interface CrcParameters {
|
||||
width: number
|
||||
polynomial: bigint
|
||||
initialValue: bigint
|
||||
inputReflected: boolean
|
||||
outputReflected: boolean
|
||||
xorOut: bigint
|
||||
}
|
||||
|
||||
export interface CrcPreset extends CrcParameters {
|
||||
id: string
|
||||
name: string
|
||||
check: bigint
|
||||
}
|
||||
|
||||
export const CRC_PRESETS: CrcPreset[] = [
|
||||
{ id: 'crc-32-iso-hdlc', name: 'CRC-32 / ISO-HDLC', width: 32, polynomial: 0x04c11db7n, initialValue: 0xffffffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffffffn, check: 0xcbf43926n },
|
||||
{ id: 'crc-16-ccitt-false', name: 'CRC-16 / CCITT-FALSE', width: 16, polynomial: 0x1021n, initialValue: 0xffffn, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x29b1n },
|
||||
{ id: 'crc-16-modbus', name: 'CRC-16 / MODBUS', width: 16, polynomial: 0x8005n, initialValue: 0xffffn, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0x4b37n },
|
||||
{ id: 'crc-16-arc', name: 'CRC-16 / ARC', width: 16, polynomial: 0x8005n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0xbb3dn },
|
||||
{ id: 'crc-16-xmodem', name: 'CRC-16 / XMODEM', width: 16, polynomial: 0x1021n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x31c3n },
|
||||
{ id: 'crc-8', name: 'CRC-8', width: 8, polynomial: 0x07n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0xf4n },
|
||||
]
|
||||
|
||||
function reflectBigInt(value: bigint, width: number): bigint {
|
||||
let reflected = 0n
|
||||
for (let bit = 0; bit < width; bit += 1) {
|
||||
if (value & (1n << BigInt(bit))) reflected |= 1n << BigInt(width - 1 - bit)
|
||||
}
|
||||
return reflected
|
||||
}
|
||||
|
||||
function validateParameters(parameters: CrcParameters) {
|
||||
if (!Number.isInteger(parameters.width) || parameters.width < 8 || parameters.width > 64) {
|
||||
throw new Error('CRC 位宽必须是 8 至 64 的整数')
|
||||
}
|
||||
const mask = (1n << BigInt(parameters.width)) - 1n
|
||||
if (parameters.polynomial <= 0n || parameters.polynomial > mask) throw new Error('CRC 多项式超出当前位宽')
|
||||
if (parameters.initialValue < 0n || parameters.initialValue > mask) throw new Error('CRC 初始值超出当前位宽')
|
||||
if (parameters.xorOut < 0n || parameters.xorOut > mask) throw new Error('CRC 异或输出值超出当前位宽')
|
||||
}
|
||||
|
||||
function reflectNumber(value: number, width: number): number {
|
||||
let current = value >>> 0
|
||||
let reflected = 0
|
||||
for (let bit = 0; bit < width; bit += 1) {
|
||||
reflected = ((reflected * 2) + (current & 1)) >>> 0
|
||||
current >>>= 1
|
||||
}
|
||||
return reflected
|
||||
}
|
||||
|
||||
function calculateNumberCrc(chunks: Iterable<Uint8Array>, parameters: CrcParameters): bigint {
|
||||
const width = parameters.width
|
||||
const mask = width === 32 ? 0xffffffff : (2 ** width) - 1
|
||||
const trim = (value: number) => width === 32 ? value >>> 0 : (value & mask) >>> 0
|
||||
const polynomial = Number(parameters.polynomial) >>> 0
|
||||
const table = new Uint32Array(256)
|
||||
let remainder = trim(Number(parameters.initialValue))
|
||||
|
||||
if (parameters.inputReflected) {
|
||||
const reflectedPolynomial = reflectNumber(polynomial, width)
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let entry = index
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
entry = entry & 1 ? (entry >>> 1) ^ reflectedPolynomial : entry >>> 1
|
||||
}
|
||||
table[index] = trim(entry)
|
||||
}
|
||||
for (const data of chunks) {
|
||||
for (const byte of data) {
|
||||
remainder = trim((remainder >>> 8) ^ table[(remainder ^ byte) & 0xff])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const topBit = 2 ** (width - 1)
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let entry = trim(index << (width - 8))
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
entry = trim(entry & topBit ? (entry << 1) ^ polynomial : entry << 1)
|
||||
}
|
||||
table[index] = entry
|
||||
}
|
||||
for (const data of chunks) {
|
||||
for (const byte of data) {
|
||||
const tableIndex = ((remainder >>> (width - 8)) ^ byte) & 0xff
|
||||
remainder = trim((remainder << 8) ^ table[tableIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parameters.inputReflected !== parameters.outputReflected) remainder = reflectNumber(remainder, width)
|
||||
return BigInt(trim(remainder ^ Number(parameters.xorOut)))
|
||||
}
|
||||
|
||||
function calculateBigIntCrc(chunks: Iterable<Uint8Array>, parameters: CrcParameters): bigint {
|
||||
const width = parameters.width
|
||||
const shift = BigInt(width - 8)
|
||||
const mask = (1n << BigInt(width)) - 1n
|
||||
const table: bigint[] = []
|
||||
let remainder = parameters.initialValue & mask
|
||||
|
||||
if (parameters.inputReflected) {
|
||||
const reflectedPolynomial = reflectBigInt(parameters.polynomial, width)
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let entry = BigInt(index)
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
entry = entry & 1n ? (entry >> 1n) ^ reflectedPolynomial : entry >> 1n
|
||||
}
|
||||
table[index] = entry & mask
|
||||
}
|
||||
for (const data of chunks) {
|
||||
for (const byte of data) {
|
||||
remainder = ((remainder >> 8n) ^ table[Number((remainder ^ BigInt(byte)) & 0xffn)]) & mask
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const topBit = 1n << BigInt(width - 1)
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let entry = BigInt(index) << shift
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
entry = entry & topBit ? (entry << 1n) ^ parameters.polynomial : entry << 1n
|
||||
entry &= mask
|
||||
}
|
||||
table[index] = entry
|
||||
}
|
||||
for (const data of chunks) {
|
||||
for (const byte of data) {
|
||||
const tableIndex = Number(((remainder >> shift) ^ BigInt(byte)) & 0xffn)
|
||||
remainder = ((remainder << 8n) ^ table[tableIndex]) & mask
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parameters.inputReflected !== parameters.outputReflected) remainder = reflectBigInt(remainder, width)
|
||||
return (remainder ^ parameters.xorOut) & mask
|
||||
}
|
||||
|
||||
export function calculateCrcChunks(chunks: Iterable<Uint8Array>, parameters: CrcParameters): bigint {
|
||||
validateParameters(parameters)
|
||||
return parameters.width <= 32
|
||||
? calculateNumberCrc(chunks, parameters)
|
||||
: calculateBigIntCrc(chunks, parameters)
|
||||
}
|
||||
|
||||
export function calculateCrc(data: Uint8Array, parameters: CrcParameters): bigint {
|
||||
return calculateCrcChunks([data], parameters)
|
||||
}
|
||||
|
||||
export function formatCrc(value: bigint, width: number): string {
|
||||
return value.toString(16).toUpperCase().padStart(Math.ceil(width / 4), '0')
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { calculateCrc, calculateCrcChunks, formatCrc, type CrcParameters } from '@/lib/crc'
|
||||
|
||||
export interface CrcWorkerBlock {
|
||||
startAddress: number
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export interface CrcWorkerParseRequest {
|
||||
type: 'parse'
|
||||
id: number
|
||||
file: File
|
||||
}
|
||||
|
||||
export interface CrcWorkerCalculateRequest {
|
||||
type: 'calculate'
|
||||
id: number
|
||||
parameters: CrcParameters
|
||||
}
|
||||
|
||||
export type CrcWorkerRequest = CrcWorkerParseRequest | CrcWorkerCalculateRequest
|
||||
|
||||
export interface CrcBlockResult {
|
||||
startAddress: number
|
||||
endAddress: number
|
||||
length: number
|
||||
crc: string
|
||||
}
|
||||
|
||||
export type CrcWorkerResponse = {
|
||||
type: 'parsed'
|
||||
id: number
|
||||
format: 'Intel HEX' | 'BIN'
|
||||
totalBytes: number
|
||||
blockCount: number
|
||||
warnings: string[]
|
||||
} | {
|
||||
type: 'calculated'
|
||||
id: number
|
||||
combinedResult: string
|
||||
blockResults: CrcBlockResult[]
|
||||
} | {
|
||||
type: 'error'
|
||||
id: number
|
||||
operation: CrcWorkerRequest['type']
|
||||
error: string
|
||||
}
|
||||
|
||||
export function calculateCrcReport(blocks: CrcWorkerBlock[], parameters: CrcParameters) {
|
||||
const combinedValue = calculateCrcChunks(blocks.map((block) => block.data), parameters)
|
||||
const combinedResult = formatCrc(combinedValue, parameters.width)
|
||||
const blockResults = blocks.map((block) => ({
|
||||
startAddress: block.startAddress,
|
||||
endAddress: block.startAddress + block.data.length - 1,
|
||||
length: block.data.length,
|
||||
crc: blocks.length === 1
|
||||
? combinedResult
|
||||
: formatCrc(calculateCrc(block.data, parameters), parameters.width),
|
||||
}))
|
||||
return { combinedResult, blockResults }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { parseIntelHex, type DataBlock } from '@/lib/intelHex'
|
||||
|
||||
export const MAX_FIRMWARE_FILE_SIZE = 64 * 1024 * 1024
|
||||
|
||||
export type FirmwareFormat = 'Intel HEX' | 'BIN'
|
||||
|
||||
export interface ParsedFirmware {
|
||||
format: FirmwareFormat
|
||||
blocks: DataBlock[]
|
||||
totalBytes: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export function getFirmwareFormat(fileName: string): FirmwareFormat {
|
||||
const extension = fileName.split('.').at(-1)?.toLowerCase()
|
||||
if (extension === 'hex' || extension === 'ihx') return 'Intel HEX'
|
||||
if (extension === 'bin') return 'BIN'
|
||||
throw new Error('请选择 .hex、.ihx 或 .bin 文件')
|
||||
}
|
||||
|
||||
export function parseFirmwareContent(fileName: string, content: string | Uint8Array): ParsedFirmware {
|
||||
const format = getFirmwareFormat(fileName)
|
||||
if (format === 'Intel HEX') {
|
||||
if (typeof content !== 'string') throw new Error('Intel HEX 文件内容必须是文本')
|
||||
return { format, ...parseIntelHex(content) }
|
||||
}
|
||||
if (typeof content === 'string') throw new Error('BIN 文件内容必须是二进制数据')
|
||||
if (content.length === 0) throw new Error('文件为空')
|
||||
return {
|
||||
format,
|
||||
blocks: [{ startAddress: 0, data: content }],
|
||||
totalBytes: content.length,
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface IcoFrame {
|
||||
size: number
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export function normalizeIcoSizes(sizes: number[]): number[] {
|
||||
if (sizes.length === 0) throw new Error('至少需要选择一个图标尺寸')
|
||||
if (sizes.length > 255) throw new Error('图标尺寸数量过多')
|
||||
for (const size of sizes) {
|
||||
if (!Number.isInteger(size) || size < 1 || size > 256) throw new Error(`ICO 尺寸无效:${size}`)
|
||||
}
|
||||
return [...new Set(sizes)].sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
export function packIco(frames: IcoFrame[]): Uint8Array {
|
||||
if (frames.length === 0) throw new Error('至少需要选择一个图标尺寸')
|
||||
if (frames.length > 255) throw new Error('图标尺寸数量过多')
|
||||
|
||||
for (const frame of frames) {
|
||||
if (!Number.isInteger(frame.size) || frame.size < 1 || frame.size > 256) {
|
||||
throw new Error(`ICO 尺寸无效:${frame.size}`)
|
||||
}
|
||||
if (frame.data.length === 0) throw new Error(`尺寸 ${frame.size} 的图像数据为空`)
|
||||
}
|
||||
|
||||
const directorySize = 6 + frames.length * 16
|
||||
const dataSize = frames.reduce((sum, frame) => sum + frame.data.length, 0)
|
||||
const output = new Uint8Array(directorySize + dataSize)
|
||||
const view = new DataView(output.buffer)
|
||||
|
||||
view.setUint16(0, 0, true)
|
||||
view.setUint16(2, 1, true)
|
||||
view.setUint16(4, frames.length, true)
|
||||
|
||||
let imageOffset = directorySize
|
||||
frames.forEach((frame, index) => {
|
||||
const entryOffset = 6 + index * 16
|
||||
view.setUint8(entryOffset, frame.size === 256 ? 0 : frame.size)
|
||||
view.setUint8(entryOffset + 1, frame.size === 256 ? 0 : frame.size)
|
||||
view.setUint8(entryOffset + 2, 0)
|
||||
view.setUint8(entryOffset + 3, 0)
|
||||
view.setUint16(entryOffset + 4, 1, true)
|
||||
view.setUint16(entryOffset + 6, 32, true)
|
||||
view.setUint32(entryOffset + 8, frame.data.length, true)
|
||||
view.setUint32(entryOffset + 12, imageOffset, true)
|
||||
output.set(frame.data, imageOffset)
|
||||
imageOffset += frame.data.length
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
function canvasToPng(canvas: HTMLCanvasElement): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('PNG 编码失败'))
|
||||
return
|
||||
}
|
||||
resolve(new Uint8Array(await blob.arrayBuffer()))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
export async function imageToIco(image: CanvasImageSource, sizes: number[]): Promise<Uint8Array> {
|
||||
const uniqueSizes = normalizeIcoSizes(sizes)
|
||||
const frames = await Promise.all(uniqueSizes.map(async (size) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('浏览器无法创建图像画布')
|
||||
context.imageSmoothingEnabled = true
|
||||
context.imageSmoothingQuality = 'high'
|
||||
context.clearRect(0, 0, size, size)
|
||||
context.drawImage(image, 0, 0, size, size)
|
||||
return { size, data: await canvasToPng(canvas) }
|
||||
}))
|
||||
return packIco(frames)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
export interface DataBlock {
|
||||
startAddress: number
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export interface ParsedIntelHex {
|
||||
blocks: DataBlock[]
|
||||
totalBytes: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface IntelHexLimits {
|
||||
maxDataRecords?: number
|
||||
maxBlocks?: number
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_INTEL_HEX_DATA_RECORDS = 500_000
|
||||
export const DEFAULT_MAX_INTEL_HEX_BLOCKS = 1_024
|
||||
|
||||
interface DataRecord {
|
||||
startAddress: number
|
||||
data: Uint8Array
|
||||
line: number
|
||||
}
|
||||
|
||||
function parseRecord(line: string, lineNumber: number): Uint8Array {
|
||||
if (!line.startsWith(':')) throw new Error(`第 ${lineNumber} 行缺少 Intel HEX 起始符“:”`)
|
||||
const hex = line.slice(1)
|
||||
if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) throw new Error(`第 ${lineNumber} 行包含无效 Hex`)
|
||||
if (hex.length < 10) throw new Error(`第 ${lineNumber} 行记录长度不足`)
|
||||
const declaredByteCount = Number.parseInt(hex.slice(0, 2), 16)
|
||||
if (hex.length !== (declaredByteCount + 5) * 2) throw new Error(`第 ${lineNumber} 行数据长度与记录声明不一致`)
|
||||
const bytes = Uint8Array.from({ length: hex.length / 2 }, (_, index) => Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16))
|
||||
const checksum = bytes.reduce((sum, byte) => (sum + byte) & 0xff, 0)
|
||||
if (checksum !== 0) throw new Error(`第 ${lineNumber} 行校验和错误`)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function validateLimit(value: number, label: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) throw new Error(`${label} 必须是正整数`)
|
||||
return value
|
||||
}
|
||||
|
||||
function mergeRecords(records: DataRecord[], maxBlocks: number): DataBlock[] {
|
||||
const sorted = [...records].sort((left, right) => left.startAddress - right.startAddress || left.line - right.line)
|
||||
const groups: Array<{ startAddress: number; endAddress: number; firstRecord: number; lastRecord: number }> = []
|
||||
|
||||
for (let index = 0; index < sorted.length; index += 1) {
|
||||
const record = sorted[index]
|
||||
const recordEnd = record.startAddress + record.data.length
|
||||
const current = groups.at(-1)
|
||||
if (!current || record.startAddress > current.endAddress) {
|
||||
if (groups.length >= maxBlocks) throw new Error(`Intel HEX 数据块不能超过 ${maxBlocks.toLocaleString()} 个`)
|
||||
groups.push({ startAddress: record.startAddress, endAddress: recordEnd, firstRecord: index, lastRecord: index + 1 })
|
||||
continue
|
||||
}
|
||||
current.endAddress = Math.max(current.endAddress, recordEnd)
|
||||
current.lastRecord = index + 1
|
||||
}
|
||||
|
||||
return groups.map((group) => {
|
||||
const data = new Uint8Array(group.endAddress - group.startAddress)
|
||||
let writtenEnd = 0
|
||||
for (let recordIndex = group.firstRecord; recordIndex < group.lastRecord; recordIndex += 1) {
|
||||
const record = sorted[recordIndex]
|
||||
const offset = record.startAddress - group.startAddress
|
||||
const overlapLength = Math.min(record.data.length, Math.max(0, writtenEnd - offset))
|
||||
for (let index = 0; index < overlapLength; index += 1) {
|
||||
if (data[offset + index] !== record.data[index]) {
|
||||
throw new Error(`第 ${record.line} 行与已有地址 0x${(record.startAddress + index).toString(16).toUpperCase()} 的数据冲突`)
|
||||
}
|
||||
}
|
||||
data.set(record.data, offset)
|
||||
writtenEnd = Math.max(writtenEnd, offset + record.data.length)
|
||||
}
|
||||
return { startAddress: group.startAddress, data }
|
||||
})
|
||||
}
|
||||
|
||||
export function parseIntelHex(value: string, limits: IntelHexLimits = {}): ParsedIntelHex {
|
||||
const maxDataRecords = validateLimit(limits.maxDataRecords ?? DEFAULT_MAX_INTEL_HEX_DATA_RECORDS, 'Intel HEX 数据记录上限')
|
||||
const maxBlocks = validateLimit(limits.maxBlocks ?? DEFAULT_MAX_INTEL_HEX_BLOCKS, 'Intel HEX 数据块上限')
|
||||
const lines = value.replace(/^\uFEFF/, '').split(/\r?\n/)
|
||||
const records: DataRecord[] = []
|
||||
const warnings: string[] = []
|
||||
let baseAddress = 0
|
||||
let eofSeen = false
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index].trim()
|
||||
if (!line) continue
|
||||
const lineNumber = index + 1
|
||||
if (eofSeen) throw new Error(`第 ${lineNumber} 行位于 EOF 记录之后`)
|
||||
const bytes = parseRecord(line, lineNumber)
|
||||
const byteCount = bytes[0]
|
||||
const recordAddress = (bytes[1] << 8) | bytes[2]
|
||||
const recordType = bytes[3]
|
||||
const data = bytes.slice(4, 4 + byteCount)
|
||||
|
||||
if (recordType === 0x00) {
|
||||
const startAddress = baseAddress + recordAddress
|
||||
if (startAddress + data.length > 0x100000000) throw new Error(`第 ${lineNumber} 行地址超出 32 位范围`)
|
||||
if (data.length) {
|
||||
if (records.length >= maxDataRecords) throw new Error(`Intel HEX 数据记录不能超过 ${maxDataRecords.toLocaleString()} 条`)
|
||||
records.push({ startAddress, data, line: lineNumber })
|
||||
}
|
||||
} else if (recordType === 0x01) {
|
||||
if (byteCount !== 0 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行 EOF 记录格式无效`)
|
||||
eofSeen = true
|
||||
} else if (recordType === 0x02) {
|
||||
if (byteCount !== 2 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行扩展段地址记录格式无效`)
|
||||
baseAddress = ((data[0] << 8) | data[1]) << 4
|
||||
} else if (recordType === 0x04) {
|
||||
if (byteCount !== 2 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行扩展线性地址记录格式无效`)
|
||||
baseAddress = ((data[0] << 8) | data[1]) * 0x10000
|
||||
} else if (recordType === 0x03 || recordType === 0x05) {
|
||||
if (byteCount !== 4 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行启动地址记录格式无效`)
|
||||
} else {
|
||||
throw new Error(`第 ${lineNumber} 行包含不支持的记录类型 0x${recordType.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!records.length) throw new Error('Intel HEX 文件不包含有效数据记录')
|
||||
if (!eofSeen) warnings.push('文件缺少 EOF 记录,已按现有数据计算')
|
||||
const blocks = mergeRecords(records, maxBlocks)
|
||||
return { blocks, totalBytes: blocks.reduce((total, block) => total + block.data.length, 0), warnings }
|
||||
}
|
||||
|
||||
export function concatenateBlocks(blocks: DataBlock[]): Uint8Array {
|
||||
const length = blocks.reduce((total, block) => total + block.data.length, 0)
|
||||
const combined = new Uint8Array(length)
|
||||
let offset = 0
|
||||
for (const block of blocks) {
|
||||
combined.set(block.data, offset)
|
||||
offset += block.data.length
|
||||
}
|
||||
return combined
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { NavigationConfig } from '@/types'
|
||||
|
||||
export function cloneNavigation(value: NavigationConfig): NavigationConfig {
|
||||
return {
|
||||
version: value.version,
|
||||
categories: value.categories.map((category) => ({ id: category.id, tools: [...category.tools] })),
|
||||
}
|
||||
}
|
||||
|
||||
function clampInsertionIndex(index: number, length: number): number {
|
||||
if (!Number.isFinite(index)) return length
|
||||
return Math.min(Math.max(0, Math.trunc(index)), length)
|
||||
}
|
||||
|
||||
export function reorderCategory(value: NavigationConfig, sourceIndex: number, targetIndex: number): NavigationConfig {
|
||||
const next = cloneNavigation(value)
|
||||
if (!Number.isInteger(sourceIndex) || sourceIndex < 0 || sourceIndex >= next.categories.length) return next
|
||||
const [category] = next.categories.splice(sourceIndex, 1)
|
||||
next.categories.splice(clampInsertionIndex(targetIndex, next.categories.length), 0, category)
|
||||
return next
|
||||
}
|
||||
|
||||
export function moveCategoryBy(value: NavigationConfig, sourceIndex: number, delta: number): NavigationConfig {
|
||||
const targetIndex = sourceIndex + delta
|
||||
if (!Number.isInteger(delta) || targetIndex < 0 || targetIndex >= value.categories.length) return cloneNavigation(value)
|
||||
return reorderCategory(value, sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
export interface ToolPosition {
|
||||
categoryIndex: number
|
||||
toolIndex: number
|
||||
}
|
||||
|
||||
export function relocateTool(value: NavigationConfig, source: ToolPosition, target: ToolPosition): NavigationConfig {
|
||||
const next = cloneNavigation(value)
|
||||
const sourceCategory = next.categories[source.categoryIndex]
|
||||
const targetCategory = next.categories[target.categoryIndex]
|
||||
if (!sourceCategory || !targetCategory || !Number.isInteger(source.toolIndex) || source.toolIndex < 0 || source.toolIndex >= sourceCategory.tools.length) return next
|
||||
|
||||
const [toolId] = sourceCategory.tools.splice(source.toolIndex, 1)
|
||||
targetCategory.tools.splice(clampInsertionIndex(target.toolIndex, targetCategory.tools.length), 0, toolId)
|
||||
return next
|
||||
}
|
||||
|
||||
export function moveToolBy(value: NavigationConfig, categoryIndex: number, toolIndex: number, delta: number): NavigationConfig {
|
||||
const tools = value.categories[categoryIndex]?.tools
|
||||
const targetIndex = toolIndex + delta
|
||||
if (!tools || !Number.isInteger(delta) || targetIndex < 0 || targetIndex >= tools.length) return cloneNavigation(value)
|
||||
return relocateTool(value, { categoryIndex, toolIndex }, { categoryIndex, toolIndex: targetIndex })
|
||||
}
|
||||
|
||||
export function moveToolToCategory(value: NavigationConfig, toolId: string, sourceCategoryIndex: number, targetCategoryId: string): NavigationConfig {
|
||||
const sourceCategory = value.categories[sourceCategoryIndex]
|
||||
const targetCategoryIndex = value.categories.findIndex((category) => category.id === targetCategoryId)
|
||||
const sourceToolIndex = sourceCategory?.tools.indexOf(toolId) ?? -1
|
||||
if (sourceToolIndex < 0 || targetCategoryIndex < 0 || sourceCategory.id === targetCategoryId) return cloneNavigation(value)
|
||||
return relocateTool(
|
||||
value,
|
||||
{ categoryIndex: sourceCategoryIndex, toolIndex: sourceToolIndex },
|
||||
{ categoryIndex: targetCategoryIndex, toolIndex: value.categories[targetCategoryIndex].tools.length },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export const PNG_SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
export const PNG_HEADER_LENGTH = 33
|
||||
export const MAX_PNG_FILE_SIZE = 32 * 1024 * 1024
|
||||
export const MAX_PNG_DIMENSION = 16_384
|
||||
export const MAX_PNG_PIXELS = 32_000_000
|
||||
|
||||
export interface PngMetadata {
|
||||
width: number
|
||||
height: number
|
||||
pixels: number
|
||||
}
|
||||
|
||||
export function parsePngHeader(bytes: Uint8Array, fileSize = bytes.length): PngMetadata {
|
||||
if (!Number.isSafeInteger(fileSize) || fileSize < PNG_HEADER_LENGTH) throw new Error('PNG 文件内容不完整')
|
||||
if (fileSize > MAX_PNG_FILE_SIZE) throw new Error('PNG 文件不能超过 32 MiB')
|
||||
if (bytes.length < PNG_HEADER_LENGTH) throw new Error('PNG 文件头不完整')
|
||||
if (!PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) throw new Error('请选择有效的 PNG 图片')
|
||||
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
const ihdrLength = view.getUint32(8, false)
|
||||
const ihdrType = String.fromCharCode(...bytes.slice(12, 16))
|
||||
if (ihdrLength !== 13 || ihdrType !== 'IHDR') throw new Error('PNG 文件缺少有效的 IHDR 数据块')
|
||||
|
||||
const width = view.getUint32(16, false)
|
||||
const height = view.getUint32(20, false)
|
||||
if (width === 0 || height === 0) throw new Error('PNG 图片尺寸无效')
|
||||
if (width > MAX_PNG_DIMENSION || height > MAX_PNG_DIMENSION) {
|
||||
throw new Error(`PNG 图片宽高不能超过 ${MAX_PNG_DIMENSION.toLocaleString()} 像素`)
|
||||
}
|
||||
const pixels = width * height
|
||||
if (pixels > MAX_PNG_PIXELS) throw new Error(`PNG 图片总像素不能超过 ${MAX_PNG_PIXELS.toLocaleString()}`)
|
||||
return { width, height, pixels }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface RandomSource {
|
||||
getRandomValues<T extends ArrayBufferView>(array: T): T
|
||||
}
|
||||
|
||||
export function secureRandomBytes(length: number, source: RandomSource = globalThis.crypto): Uint8Array {
|
||||
if (!Number.isInteger(length) || length < 1 || length > 65_536) throw new Error('随机数长度必须是 1 至 65536 字节的整数')
|
||||
if (!source?.getRandomValues) throw new Error('当前浏览器不支持安全随机数生成')
|
||||
const output = new Uint8Array(length)
|
||||
const chunkSize = 65_536
|
||||
for (let offset = 0; offset < output.length; offset += chunkSize) {
|
||||
source.getRandomValues(output.subarray(offset, Math.min(offset + chunkSize, output.length)))
|
||||
}
|
||||
return output
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import forge from 'node-forge'
|
||||
|
||||
export interface GeneratedRsaKeyPair {
|
||||
publicKeyPem: string
|
||||
privateKeyPem: string
|
||||
modulusHex: string
|
||||
exponentHex: string
|
||||
source: 'Web Crypto' | 'node-forge'
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
const chunkSize = 0x8000
|
||||
let binary = ''
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
export function formatPem(data: ArrayBuffer | Uint8Array, label: string): string {
|
||||
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
|
||||
const base64 = bytesToBase64(bytes)
|
||||
const lines = base64.match(/.{1,64}/g) ?? []
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
export function base64UrlToHex(value: string): string {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
return Array.from(binary, (character) => character.charCodeAt(0).toString(16).padStart(2, '0')).join('').toUpperCase()
|
||||
}
|
||||
|
||||
function normalizePem(value: string): string {
|
||||
return `${value.replace(/\r\n/g, '\n').trimEnd()}\n`
|
||||
}
|
||||
|
||||
async function generateWithWebCrypto(modulusLength: number): Promise<GeneratedRsaKeyPair> {
|
||||
const keyPair = await globalThis.crypto.subtle.generateKey({
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength,
|
||||
publicExponent: Uint8Array.from([0x01, 0x00, 0x01]),
|
||||
hash: 'SHA-256',
|
||||
}, true, ['sign', 'verify']) as CryptoKeyPair
|
||||
const [publicDer, privateDer, publicJwk] = await Promise.all([
|
||||
globalThis.crypto.subtle.exportKey('spki', keyPair.publicKey),
|
||||
globalThis.crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
|
||||
globalThis.crypto.subtle.exportKey('jwk', keyPair.publicKey),
|
||||
])
|
||||
if (!publicJwk.n || !publicJwk.e) throw new Error('浏览器未返回 RSA 公钥参数')
|
||||
return {
|
||||
publicKeyPem: formatPem(publicDer, 'PUBLIC KEY'),
|
||||
privateKeyPem: formatPem(privateDer, 'PRIVATE KEY'),
|
||||
modulusHex: base64UrlToHex(publicJwk.n),
|
||||
exponentHex: base64UrlToHex(publicJwk.e),
|
||||
source: 'Web Crypto',
|
||||
}
|
||||
}
|
||||
|
||||
function nativeRandomSeed(length: number): string {
|
||||
if (!globalThis.crypto?.getRandomValues) throw new Error('当前浏览器不支持安全随机数生成')
|
||||
const bytes = new Uint8Array(length)
|
||||
globalThis.crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
|
||||
}
|
||||
|
||||
function generateWithForge(modulusLength: number): Promise<GeneratedRsaKeyPair> {
|
||||
const prng = forge.random.createInstance()
|
||||
prng.seedFileSync = (needed) => nativeRandomSeed(needed)
|
||||
prng.seedFile = (needed, callback) => callback(null, nativeRandomSeed(needed))
|
||||
return new Promise((resolve, reject) => {
|
||||
forge.pki.rsa.generateKeyPair({ bits: modulusLength, e: 0x10001, workers: 0, prng }, (error, keyPair) => {
|
||||
if (error) { reject(error); return }
|
||||
try {
|
||||
const privateKeyInfo = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(keyPair.privateKey))
|
||||
const modulus = keyPair.publicKey.n.toString(16).toUpperCase()
|
||||
const exponent = keyPair.publicKey.e.toString(16).toUpperCase()
|
||||
resolve({
|
||||
publicKeyPem: normalizePem(forge.pki.publicKeyToPem(keyPair.publicKey, 64)),
|
||||
privateKeyPem: normalizePem(forge.pki.privateKeyInfoToPem(privateKeyInfo, 64)),
|
||||
modulusHex: modulus.length % 2 ? `0${modulus}` : modulus,
|
||||
exponentHex: exponent.length % 2 ? `0${exponent}` : exponent,
|
||||
source: 'node-forge',
|
||||
})
|
||||
} catch (reason) {
|
||||
reject(reason)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateRsaKeyPair(modulusLength: number): Promise<GeneratedRsaKeyPair> {
|
||||
if (![2048, 3072, 4096].includes(modulusLength)) throw new Error('RSA 密钥长度必须是 2048、3072 或 4096 位')
|
||||
if (globalThis.crypto?.subtle) {
|
||||
try {
|
||||
return await generateWithWebCrypto(modulusLength)
|
||||
} catch {
|
||||
return generateWithForge(modulusLength)
|
||||
}
|
||||
}
|
||||
return generateWithForge(modulusLength)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
export type SyntaxLanguage = 'json' | 'xml'
|
||||
|
||||
export type SyntaxTokenType =
|
||||
| 'plain'
|
||||
| 'punctuation'
|
||||
| 'key'
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'literal'
|
||||
| 'tag'
|
||||
| 'attribute'
|
||||
| 'value'
|
||||
| 'comment'
|
||||
| 'meta'
|
||||
|
||||
export interface SyntaxToken {
|
||||
text: string
|
||||
type: SyntaxTokenType
|
||||
}
|
||||
|
||||
export const MAX_HIGHLIGHTED_SYNTAX_LENGTH = 100_000
|
||||
export const MAX_HIGHLIGHTED_SYNTAX_TOKENS = 10_000
|
||||
|
||||
function append(tokens: SyntaxToken[], text: string, type: SyntaxTokenType) {
|
||||
if (!text) return
|
||||
const previous = tokens.at(-1)
|
||||
if (previous?.type === type) previous.text += text
|
||||
else tokens.push({ text, type })
|
||||
}
|
||||
|
||||
export function tokenizeJson(value: string): SyntaxToken[] {
|
||||
const tokens: SyntaxToken[] = []
|
||||
const pattern = /"(?:\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b|[{}[\],:]/g
|
||||
let cursor = 0
|
||||
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
const index = match.index ?? 0
|
||||
append(tokens, value.slice(cursor, index), 'plain')
|
||||
const text = match[0]
|
||||
let type: SyntaxTokenType = 'punctuation'
|
||||
if (text.startsWith('"')) {
|
||||
type = value.slice(index + text.length).match(/^\s*:/) ? 'key' : 'string'
|
||||
} else if (/^-?\d/.test(text)) {
|
||||
type = 'number'
|
||||
} else if (/^(true|false|null)$/.test(text)) {
|
||||
type = 'literal'
|
||||
}
|
||||
append(tokens, text, type)
|
||||
cursor = index + text.length
|
||||
}
|
||||
append(tokens, value.slice(cursor), 'plain')
|
||||
return tokens
|
||||
}
|
||||
|
||||
function findMarkupEnd(value: string, start: number): number {
|
||||
let quote = ''
|
||||
let bracketDepth = 0
|
||||
for (let index = start + 1; index < value.length; index += 1) {
|
||||
const character = value[index]
|
||||
if (quote) {
|
||||
if (character === quote) quote = ''
|
||||
continue
|
||||
}
|
||||
if (character === '"' || character === "'") quote = character
|
||||
else if (character === '[') bracketDepth += 1
|
||||
else if (character === ']') bracketDepth = Math.max(0, bracketDepth - 1)
|
||||
else if (character === '>' && bracketDepth === 0) return index + 1
|
||||
}
|
||||
return value.length
|
||||
}
|
||||
|
||||
function tokenizeTag(value: string, tokens: SyntaxToken[]) {
|
||||
let cursor = 0
|
||||
const closing = value.startsWith('</')
|
||||
const openingLength = closing ? 2 : 1
|
||||
append(tokens, value.slice(0, openingLength), 'punctuation')
|
||||
cursor = openingLength
|
||||
|
||||
const name = value.slice(cursor).match(/^[A-Za-z_][\w:.-]*/)?.[0]
|
||||
if (name) {
|
||||
append(tokens, name, 'tag')
|
||||
cursor += name.length
|
||||
}
|
||||
|
||||
while (cursor < value.length) {
|
||||
const remaining = value.slice(cursor)
|
||||
const whitespace = remaining.match(/^\s+/)?.[0]
|
||||
if (whitespace) {
|
||||
append(tokens, whitespace, 'plain')
|
||||
cursor += whitespace.length
|
||||
continue
|
||||
}
|
||||
if (remaining.startsWith('/>')) {
|
||||
append(tokens, '/>', 'punctuation')
|
||||
cursor += 2
|
||||
continue
|
||||
}
|
||||
if (remaining.startsWith('>')) {
|
||||
append(tokens, '>', 'punctuation')
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
if (remaining.startsWith('=')) {
|
||||
append(tokens, '=', 'punctuation')
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
const quoted = remaining.match(/^(?:"[^"]*"|'[^']*')/)?.[0]
|
||||
if (quoted) {
|
||||
append(tokens, quoted, 'value')
|
||||
cursor += quoted.length
|
||||
continue
|
||||
}
|
||||
const attribute = remaining.match(/^[A-Za-z_][\w:.-]*/)?.[0]
|
||||
if (attribute) {
|
||||
append(tokens, attribute, closing ? 'tag' : 'attribute')
|
||||
cursor += attribute.length
|
||||
continue
|
||||
}
|
||||
append(tokens, remaining[0], 'plain')
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
|
||||
export function tokenizeXml(value: string): SyntaxToken[] {
|
||||
const tokens: SyntaxToken[] = []
|
||||
let cursor = 0
|
||||
while (cursor < value.length) {
|
||||
const markupStart = value.indexOf('<', cursor)
|
||||
if (markupStart < 0) {
|
||||
append(tokens, value.slice(cursor), 'plain')
|
||||
break
|
||||
}
|
||||
append(tokens, value.slice(cursor, markupStart), 'plain')
|
||||
|
||||
if (value.startsWith('<!--', markupStart)) {
|
||||
const end = value.indexOf('-->', markupStart + 4)
|
||||
const next = end < 0 ? value.length : end + 3
|
||||
append(tokens, value.slice(markupStart, next), 'comment')
|
||||
cursor = next
|
||||
continue
|
||||
}
|
||||
if (value.startsWith('<![CDATA[', markupStart)) {
|
||||
const end = value.indexOf(']]>', markupStart + 9)
|
||||
const next = end < 0 ? value.length : end + 3
|
||||
append(tokens, value.slice(markupStart, next), 'meta')
|
||||
cursor = next
|
||||
continue
|
||||
}
|
||||
if (value.startsWith('<?', markupStart)) {
|
||||
const end = value.indexOf('?>', markupStart + 2)
|
||||
const next = end < 0 ? value.length : end + 2
|
||||
append(tokens, value.slice(markupStart, next), 'meta')
|
||||
cursor = next
|
||||
continue
|
||||
}
|
||||
|
||||
const next = findMarkupEnd(value, markupStart)
|
||||
const markup = value.slice(markupStart, next)
|
||||
if (/^<!/i.test(markup)) append(tokens, markup, 'meta')
|
||||
else tokenizeTag(markup, tokens)
|
||||
cursor = next
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
export function tokenizeSyntax(value: string, language: SyntaxLanguage): SyntaxToken[] {
|
||||
return language === 'json' ? tokenizeJson(value) : tokenizeXml(value)
|
||||
}
|
||||
|
||||
export function tokenizeSyntaxForDisplay(value: string, language: SyntaxLanguage): SyntaxToken[] | undefined {
|
||||
if (value.length > MAX_HIGHLIGHTED_SYNTAX_LENGTH) return undefined
|
||||
const tokens = tokenizeSyntax(value, language)
|
||||
return tokens.length <= MAX_HIGHLIGHTED_SYNTAX_TOKENS ? tokens : undefined
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { toolById, toolComponents, tools } from '@/tools/registry'
|
||||
|
||||
const toolRoutes: RouteRecordRaw[] = tools.map((tool) => ({
|
||||
path: `/tools/${tool.id}`,
|
||||
name: tool.id,
|
||||
component: toolComponents[tool.id],
|
||||
meta: {
|
||||
title: tool.title,
|
||||
categoryId: tool.category,
|
||||
toolId: tool.id,
|
||||
},
|
||||
}))
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/tools/ascii-hex' },
|
||||
...toolRoutes,
|
||||
{
|
||||
path: '/setting',
|
||||
name: 'setting',
|
||||
component: () => import('@/views/SettingView.vue'),
|
||||
meta: { title: '工具管理' },
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/tools/ascii-hex' },
|
||||
],
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.toolId && !toolById.has(String(to.meta.toolId))) return '/tools/ascii-hex'
|
||||
return true
|
||||
})
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--canvas: #ffffff;
|
||||
--sidebar: #f5f6f7;
|
||||
--panel: #f8f9fa;
|
||||
--surface: #ffffff;
|
||||
--text: #15191c;
|
||||
--muted: #68727a;
|
||||
--faint: #929ba1;
|
||||
--border: #e1e5e8;
|
||||
--border-strong: #cbd2d7;
|
||||
--primary: #1d2428;
|
||||
--primary-hover: #30383d;
|
||||
--primary-text: #ffffff;
|
||||
--accent: #0f766e;
|
||||
--accent-hover: #0b5f59;
|
||||
--accent-soft: #e7f3f1;
|
||||
--success: #2f7d57;
|
||||
--success-soft: #edf7f1;
|
||||
--warning: #a75e14;
|
||||
--warning-soft: #fff7e8;
|
||||
--danger: #bd3d38;
|
||||
--danger-soft: #fff0ef;
|
||||
--info-soft: #eef4f7;
|
||||
--syntax-key: #0969da;
|
||||
--syntax-string: #087a32;
|
||||
--syntax-number: #a04400;
|
||||
--syntax-literal: #8250df;
|
||||
--syntax-tag: #0550ae;
|
||||
--syntax-attribute: #9a4b00;
|
||||
--syntax-value: #087a32;
|
||||
--syntax-comment: #768078;
|
||||
--syntax-meta: #8250df;
|
||||
color: var(--text);
|
||||
background: var(--canvas);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--canvas: #111315;
|
||||
--sidebar: #171a1d;
|
||||
--panel: #1b1f23;
|
||||
--surface: #202429;
|
||||
--text: #edf0f2;
|
||||
--muted: #9da7ad;
|
||||
--faint: #707a80;
|
||||
--border: #30363b;
|
||||
--border-strong: #424a50;
|
||||
--primary: #e9edef;
|
||||
--primary-hover: #ffffff;
|
||||
--primary-text: #141719;
|
||||
--accent: #4db6aa;
|
||||
--accent-hover: #70c6bd;
|
||||
--accent-soft: #173c38;
|
||||
--success: #64bc88;
|
||||
--success-soft: #173326;
|
||||
--warning: #e0a15d;
|
||||
--warning-soft: #382918;
|
||||
--danger: #ed7770;
|
||||
--danger-soft: #3b201f;
|
||||
--info-soft: #18272e;
|
||||
--syntax-key: #79c0ff;
|
||||
--syntax-string: #7ee787;
|
||||
--syntax-number: #ffa657;
|
||||
--syntax-literal: #d2a8ff;
|
||||
--syntax-tag: #7ee787;
|
||||
--syntax-attribute: #79c0ff;
|
||||
--syntax-value: #ffa657;
|
||||
--syntax-comment: #8b949e;
|
||||
--syntax-meta: #d2a8ff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
body { background: var(--canvas); color: var(--text); }
|
||||
button, input, select, textarea { font: inherit; letter-spacing: 0; }
|
||||
button, a, input, select, textarea { outline-color: var(--accent); }
|
||||
button { color: inherit; }
|
||||
|
||||
.app-layout { height: 100dvh; display: flex; overflow: hidden; background: var(--canvas); }
|
||||
.app-sidebar { width: 236px; flex: 0 0 auto; display: flex; flex-direction: column; background: var(--sidebar); border-right: 1px solid var(--border); z-index: 30; }
|
||||
.brand-row { min-height: 66px; padding: 0 18px; display: flex; align-items: center; gap: 11px; border-bottom: 1px solid var(--border); }
|
||||
.brand-mark { width: 30px; height: 30px; border-radius: 7px; display: grid; place-items: center; color: var(--primary-text); background: var(--primary); }
|
||||
.brand-row strong { display: block; font-size: 14px; font-weight: 600; }
|
||||
.brand-row small { display: block; margin-top: 1px; color: var(--faint); font-size: 11px; }
|
||||
.sidebar-search { height: 36px; margin: 17px 14px 10px; padding: 0 10px; display: flex; align-items: center; gap: 8px; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.sidebar-search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.sidebar-search input { width: 100%; min-width: 0; color: var(--text); background: transparent; border: 0; outline: 0; font-size: 12px; }
|
||||
.sidebar-search input::placeholder { color: var(--faint); }
|
||||
.tool-navigation { padding: 0 9px 18px; overflow-y: auto; }
|
||||
.tool-navigation section { margin: 0; }
|
||||
.tool-navigation h2 { margin: 0; padding: 13px 10px 6px; color: var(--faint); font-size: 10px; font-weight: 600; }
|
||||
.tool-navigation a { position: relative; min-height: 35px; padding: 0 10px; display: flex; align-items: center; gap: 9px; color: var(--muted); text-decoration: none; border: 1px solid transparent; border-radius: 6px; }
|
||||
.tool-navigation a:hover { color: var(--text); background: var(--surface); }
|
||||
.tool-navigation a.is-active { color: var(--text); background: var(--surface); border-color: var(--border); box-shadow: 0 1px 2px rgba(20, 24, 28, .04); }
|
||||
.tool-navigation a.is-active::before { position: absolute; left: -1px; width: 3px; height: 18px; content: ''; background: var(--accent); border-radius: 0 2px 2px 0; }
|
||||
.tool-navigation a span { min-width: 0; overflow-wrap: anywhere; font-size: 13px; }
|
||||
.tool-navigation a small { margin-left: auto; color: var(--faint); font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.empty-search { padding: 20px 10px; color: var(--muted); font-size: 12px; }
|
||||
.sidebar-local { min-height: 48px; margin-top: auto; padding: 0 18px; display: flex; align-items: center; gap: 7px; color: var(--muted); border-top: 1px solid var(--border); font-size: 11px; }
|
||||
|
||||
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--canvas); }
|
||||
.app-main > .tool-page, .app-main > .setting-page { flex: 1 1 auto; min-height: 0; }
|
||||
.topbar { min-height: 66px; flex: 0 0 66px; padding: 0 28px; display: flex; align-items: center; gap: 9px; color: var(--muted); border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.topbar strong { color: var(--text); font-weight: 600; }
|
||||
.topbar-spacer { flex: 1; }
|
||||
.icon-button { width: 32px; height: 32px; padding: 0; display: grid; place-items: center; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 6px; cursor: pointer; }
|
||||
.icon-button:hover { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.mobile-menu { display: none; }
|
||||
.menu-scrim { display: none; }
|
||||
|
||||
.tool-page, .setting-page { width: 100%; max-width: 1120px; padding: 32px 32px 40px; overflow-y: auto; }
|
||||
.tool-heading, .setting-heading { margin-bottom: 25px; display: flex; align-items: flex-start; gap: 20px; }
|
||||
.tool-category, .setting-category { margin: 0 0 5px; color: var(--accent); font-size: 11px; font-weight: 600; }
|
||||
.tool-heading h1, .setting-heading h1 { margin: 0; color: var(--text); font-size: 24px; line-height: 1.2; font-weight: 600; }
|
||||
.tool-description, .setting-description { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.local-state { margin-left: auto; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; white-space: nowrap; }
|
||||
.local-state svg { color: var(--success); }
|
||||
|
||||
.mode-tabs { width: fit-content; min-height: 36px; padding: 3px; display: flex; align-items: center; gap: 2px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; }
|
||||
.mode-tab { min-height: 28px; padding: 0 10px; color: var(--muted); background: transparent; border: 0; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
||||
.mode-tab:hover { color: var(--text); }
|
||||
.mode-tab.is-active { color: var(--text); background: var(--surface); box-shadow: 0 0 0 1px var(--border); }
|
||||
.tool-controls { margin-bottom: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.control-spacer, .action-spacer { flex: 1; }
|
||||
.compact-field { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
.compact-field select, .select-input, .text-input { min-height: 34px; color: var(--text); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px; }
|
||||
.compact-field select, .select-input { padding: 0 30px 0 10px; }
|
||||
.text-input { width: 100%; padding: 0 10px; }
|
||||
.text-input:focus, .select-input:focus, .compact-field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.check-field { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); cursor: pointer; font-size: 12px; }
|
||||
.check-field input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
|
||||
|
||||
.editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) 38px minmax(0, 1fr); gap: 13px; align-items: center; }
|
||||
.editor-panel { min-width: 0; display: block; overflow: hidden; background: var(--surface); border: 1px solid var(--border-strong); border-radius: 7px; }
|
||||
.editor-panel:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.editor-header { min-height: 38px; padding: 0 12px; display: flex; align-items: center; gap: 8px; color: var(--muted); background: var(--panel); border-bottom: 1px solid var(--border); font-size: 11px; }
|
||||
.editor-header strong { color: var(--text); font-weight: 600; }
|
||||
.editor-meta { margin-left: auto; color: var(--faint); }
|
||||
.editor-panel textarea { width: 100%; min-height: 184px; padding: 13px; display: block; resize: vertical; color: var(--text); background: var(--surface); border: 0; outline: 0; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.6; }
|
||||
.editor-panel textarea::placeholder, .key-input::placeholder { color: var(--faint); }
|
||||
.syntax-viewer { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border-strong); border-radius: 7px; }
|
||||
.syntax-viewer pre { min-height: 290px; max-height: 520px; margin: 0; padding: 13px; overflow: auto; color: var(--text); background: var(--surface); font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.6; white-space: pre; tab-size: 2; }
|
||||
.syntax-viewer code { font: inherit; }
|
||||
.syntax-key { color: var(--syntax-key); }
|
||||
.syntax-string { color: var(--syntax-string); }
|
||||
.syntax-number { color: var(--syntax-number); }
|
||||
.syntax-literal { color: var(--syntax-literal); font-weight: 600; }
|
||||
.syntax-tag { color: var(--syntax-tag); }
|
||||
.syntax-attribute { color: var(--syntax-attribute); }
|
||||
.syntax-value { color: var(--syntax-value); }
|
||||
.syntax-comment { color: var(--syntax-comment); font-style: italic; }
|
||||
.syntax-meta { color: var(--syntax-meta); }
|
||||
.swap-button { width: 32px; height: 32px; padding: 0; display: grid; place-items: center; justify-self: center; color: var(--muted); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 50%; cursor: pointer; }
|
||||
.swap-button:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.swap-button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
|
||||
.action-row { min-height: 36px; margin-top: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.action-button { min-height: 34px; padding: 0 12px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--text); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; }
|
||||
.action-button:hover { border-color: var(--text); }
|
||||
.action-button.primary { color: var(--primary-text); background: var(--primary); border-color: var(--primary); }
|
||||
.action-button.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
.action-button.ghost { color: var(--muted); background: transparent; border-color: transparent; }
|
||||
.action-button.ghost:hover { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.action-button.danger { color: var(--danger); }
|
||||
.action-button:disabled, .action-button.disabled { cursor: not-allowed; opacity: .5; }
|
||||
.privacy-note { min-height: 42px; margin-top: 18px; padding: 9px 13px; display: flex; align-items: center; gap: 9px; color: var(--muted); background: var(--info-soft); border-radius: 6px; font-size: 11px; }
|
||||
.privacy-note strong { color: var(--text); }
|
||||
.status-message { min-height: 38px; margin-top: 12px; padding: 9px 11px; display: flex; align-items: flex-start; gap: 8px; border-radius: 6px; font-size: 12px; }
|
||||
.status-message.error { color: var(--danger); background: var(--danger-soft); }
|
||||
.status-message.success { color: var(--success); background: var(--success-soft); }
|
||||
.status-message.warning { color: var(--warning); background: var(--warning-soft); }
|
||||
.app-footer { flex: 0 0 auto; min-height: 38px; padding: 10px 28px; color: var(--faint); border-top: 1px solid var(--border); text-align: center; font-size: 11px; line-height: 1.4; }
|
||||
|
||||
.field-stack { margin-bottom: 16px; display: grid; gap: 7px; }
|
||||
.field-stack > label, .field-label { color: var(--muted); font-size: 12px; font-weight: 600; }
|
||||
.key-input { width: 100%; min-height: 64px; padding: 10px 12px; resize: vertical; color: var(--text); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 7px; outline: 0; font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.key-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.field-help { color: var(--faint); font-size: 11px; }
|
||||
.byte-count { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.byte-count.is-valid { color: var(--success); }
|
||||
.byte-count.is-invalid { color: var(--danger); }
|
||||
|
||||
.single-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
|
||||
.single-editor-layout .editor-panel textarea { min-height: 290px; }
|
||||
|
||||
.upload-zone { min-height: 170px; padding: 24px; display: grid; place-items: center; text-align: center; color: var(--muted); background: var(--panel); border: 1px dashed var(--border-strong); border-radius: 7px; cursor: pointer; }
|
||||
.upload-zone:hover, .upload-zone.is-dragging { color: var(--text); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.upload-zone input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.sr-only-file { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
|
||||
.upload-zone strong { display: block; margin-top: 8px; color: var(--text); font-size: 13px; }
|
||||
.upload-zone span { display: block; margin-top: 3px; font-size: 11px; }
|
||||
.image-workspace { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(0, 1.2fr); gap: 22px; align-items: start; }
|
||||
.image-preview { aspect-ratio: 1; display: grid; place-items: center; overflow: hidden; background-color: var(--panel); background-image: linear-gradient(45deg, var(--border) 25%, transparent 25%), linear-gradient(-45deg, var(--border) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--border) 75%), linear-gradient(-45deg, transparent 75%, var(--border) 75%); background-size: 18px 18px; background-position: 0 0, 0 9px, 9px -9px, -9px 0; border: 1px solid var(--border); border-radius: 7px; }
|
||||
.image-preview img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.image-options h2 { margin: 0 0 10px; font-size: 13px; font-weight: 600; }
|
||||
.size-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
||||
.size-option { min-height: 42px; padding: 0 10px; display: flex; align-items: center; gap: 8px; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
|
||||
.size-option:has(input:checked) { color: var(--text); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.size-option input { margin: 0; accent-color: var(--accent); }
|
||||
.file-meta { margin-top: 12px; color: var(--muted); font-size: 11px; }
|
||||
.compact-upload { min-height: 138px; }
|
||||
.firmware-summary { min-height: 58px; margin-bottom: 16px; padding: 10px 12px; display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; }
|
||||
.firmware-summary > div { min-width: 0; margin-right: auto; }
|
||||
.firmware-summary strong, .firmware-summary span { display: block; overflow-wrap: anywhere; }
|
||||
.firmware-summary strong { font-size: 12px; font-weight: 600; }
|
||||
.firmware-summary span { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
.crc-config { display: grid; grid-template-columns: minmax(190px, 1.35fr) repeat(4, minmax(110px, 1fr)) auto auto; gap: 10px; align-items: end; }
|
||||
.parameter-field { min-width: 0; display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 600; }
|
||||
.parameter-field .select-input, .parameter-field .text-input { width: 100%; font-size: 12px; font-weight: 400; }
|
||||
.crc-check { min-height: 34px; white-space: nowrap; }
|
||||
.crc-results { margin-top: 16px; }
|
||||
.crc-total { min-height: 58px; padding: 10px 13px; display: flex; align-items: center; gap: 14px; color: var(--muted); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); border-radius: 7px; font-size: 11px; }
|
||||
.crc-total strong { margin-left: auto; color: var(--accent); font-family: "SFMono-Regular", Consolas, monospace; font-size: 20px; font-weight: 700; }
|
||||
.result-table-wrap { margin-top: 10px; overflow-x: auto; border: 1px solid var(--border); border-radius: 7px; }
|
||||
.result-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 11px; }
|
||||
.result-table th, .result-table td { height: 38px; padding: 0 11px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.result-table th { color: var(--faint); background: var(--panel); font-weight: 600; }
|
||||
.result-table td { color: var(--muted); }
|
||||
.result-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.crc-value { color: var(--text) !important; font-family: "SFMono-Regular", Consolas, monospace; font-weight: 600; }
|
||||
.fixed-parameter { min-height: 34px; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; }
|
||||
.fixed-parameter strong { color: var(--text); font-family: "SFMono-Regular", Consolas, monospace; font-weight: 600; }
|
||||
.rsa-generate-row { margin-top: 0; margin-bottom: 18px; }
|
||||
.rsa-output-tabs { margin: 0 0 10px; }
|
||||
.rsa-parameter-format { margin: 0 0 10px; }
|
||||
.rsa-parameter-grid { display: grid; gap: 14px; }
|
||||
.rsa-copy-row { min-height: 30px; margin-top: 4px; justify-content: flex-end; }
|
||||
.random-controls { margin-bottom: 14px; display: grid; grid-template-columns: minmax(120px, 180px) minmax(310px, max-content) auto; gap: 12px; align-items: end; }
|
||||
.random-format-field .mode-tabs { min-width: 310px; }
|
||||
.random-check { min-height: 34px; white-space: nowrap; }
|
||||
|
||||
.setting-page { max-width: 980px; }
|
||||
.setting-heading-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||
.setting-list { display: grid; gap: 22px; }
|
||||
.setting-loading { min-height: 180px; display: grid; place-items: center; color: var(--muted); font-size: 13px; }
|
||||
.setting-category-block { min-width: 0; }
|
||||
.setting-category-header { min-height: 38px; display: flex; align-items: center; gap: 9px; color: var(--muted); border-bottom: 1px solid var(--border); }
|
||||
.setting-category-header h2 { margin: 0; color: var(--text); font-size: 12px; font-weight: 600; }
|
||||
.setting-category-header small { color: var(--faint); }
|
||||
.setting-category-header .row-actions { margin-left: auto; }
|
||||
.setting-tool-list { padding-top: 8px; display: grid; gap: 7px; }
|
||||
.setting-tool-row { min-height: 52px; padding: 0 11px; display: flex; align-items: center; gap: 10px; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 7px; }
|
||||
.setting-tool-row.is-dragging { opacity: .45; }
|
||||
.drag-handle { color: var(--faint); cursor: grab; }
|
||||
.tool-row-icon { width: 28px; height: 28px; display: grid; place-items: center; color: var(--accent); background: var(--accent-soft); border-radius: 6px; }
|
||||
.setting-tool-row strong { min-width: 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.setting-tool-row .select-input { width: 130px; margin-left: auto; font-size: 11px; }
|
||||
.row-actions { display: flex; gap: 3px; }
|
||||
.row-action { width: 30px; height: 30px; padding: 0; display: grid; place-items: center; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 5px; cursor: pointer; }
|
||||
.row-action:hover:not(:disabled) { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.row-action:disabled { opacity: .3; cursor: default; }
|
||||
.save-state { margin-top: 14px; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.app-sidebar { position: fixed; inset: 0 auto 0 0; transform: translateX(-100%); transition: transform .18s ease; box-shadow: 14px 0 32px rgba(20, 24, 28, .14); }
|
||||
.menu-open .app-sidebar { transform: translateX(0); }
|
||||
.menu-scrim { position: fixed; inset: 0; z-index: 20; display: block; background: rgba(10, 14, 17, .38); border: 0; }
|
||||
.mobile-menu { display: grid; }
|
||||
.topbar { padding: 0 16px; }
|
||||
.tool-page, .setting-page { max-width: none; padding: 26px 18px 34px; }
|
||||
.editor-grid { grid-template-columns: 1fr; }
|
||||
.swap-button { transform: rotate(90deg); }
|
||||
.single-editor-layout, .image-workspace { grid-template-columns: 1fr; }
|
||||
.image-preview { max-width: 320px; width: 100%; margin: 0 auto; }
|
||||
.crc-config { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.preset-field { grid-column: 1 / -1; }
|
||||
.random-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.random-format-field { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.tool-heading, .setting-heading { display: block; }
|
||||
.local-state, .setting-heading-actions { margin: 10px 0 0; }
|
||||
.tool-heading h1, .setting-heading h1 { font-size: 22px; }
|
||||
.tool-page, .setting-page { padding: 22px 14px 30px; }
|
||||
.topbar { min-height: 60px; flex-basis: 60px; }
|
||||
.topbar > span:first-of-type, .topbar > svg { display: none; }
|
||||
.mode-tabs { width: 100%; }
|
||||
.mode-tab { flex: 1; }
|
||||
.random-format-field .mode-tabs { min-width: 0; }
|
||||
.compact-field { width: 100%; justify-content: space-between; }
|
||||
.compact-field select { flex: 1; }
|
||||
.editor-panel textarea { min-height: 162px; }
|
||||
.single-editor-layout .editor-panel textarea { min-height: 220px; }
|
||||
.syntax-viewer pre { min-height: 220px; }
|
||||
.action-spacer, .control-spacer { display: none; }
|
||||
.action-button.ghost { padding-left: 8px; padding-right: 8px; }
|
||||
.size-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.setting-tool-row { padding: 9px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.setting-tool-row .select-input { width: calc(100% - 76px); margin-left: 38px; order: 5; }
|
||||
.setting-tool-row .row-actions { margin-left: auto; }
|
||||
.firmware-summary { align-items: flex-start; flex-wrap: wrap; }
|
||||
.firmware-summary > div { width: 100%; }
|
||||
.crc-total { align-items: flex-start; flex-direction: column; }
|
||||
.crc-total strong { margin-left: 0; font-size: 18px; overflow-wrap: anywhere; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, Play, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { bytesToHex, parseHex, textToBytes } from '@/lib/bytes'
|
||||
import { aes128Cmac } from '@/lib/cmac'
|
||||
|
||||
type MessageMode = 'hex' | 'text'
|
||||
const keyInput = ref('')
|
||||
const messageMode = ref<MessageMode>('hex')
|
||||
const message = ref('')
|
||||
const output = ref('')
|
||||
const uppercase = ref(true)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const messageModes = [{ value: 'hex' as const, label: 'Hex 消息' }, { value: 'text' as const, label: 'UTF-8 文本' }]
|
||||
|
||||
const keyState = computed(() => {
|
||||
if (!keyInput.value.trim()) return { bytes: 0, valid: false }
|
||||
try {
|
||||
const bytes = parseHex(keyInput.value).length
|
||||
return { bytes, valid: bytes === 16 }
|
||||
}
|
||||
catch { return { bytes: null, valid: false } }
|
||||
})
|
||||
|
||||
function clearResult() {
|
||||
output.value = ''
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch([keyInput, message, messageMode, uppercase], clearResult)
|
||||
|
||||
function calculate() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
const key = parseHex(keyInput.value, 16)
|
||||
const data = messageMode.value === 'hex' ? parseHex(message.value) : textToBytes(message.value)
|
||||
output.value = bytesToHex(aes128Cmac(key, data), '', uppercase.value)
|
||||
success.value = 'CMAC 计算完成'
|
||||
} catch (reason) {
|
||||
output.value = ''
|
||||
error.value = reason instanceof Error ? reason.message : 'CMAC 计算失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try { await copyText(output.value); success.value = '结果已复制'; error.value = '' }
|
||||
catch { error.value = '复制失败' }
|
||||
}
|
||||
function clearAll() { keyInput.value = ''; message.value = ''; clearResult() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="AES-128-CMAC" description="使用 128 位密钥计算消息认证码,结果按 RFC 4493 规则生成。">
|
||||
<div class="field-stack">
|
||||
<label for="cmac-key">128 位密钥</label>
|
||||
<textarea id="cmac-key" v-model="keyInput" class="key-input" rows="2" spellcheck="false" placeholder="例如:0x00 0x11 0x22 … 或 00112233445566778899AABBCCDDEEFF" />
|
||||
<div class="field-help">
|
||||
支持连续 Hex、空格、换行、英文/中文逗号及每字节可选的 0x 前缀。
|
||||
<span class="byte-count" :class="keyState.valid ? 'is-valid' : 'is-invalid'">{{ keyState.bytes === null ? '格式无效' : `${keyState.bytes}/16 字节` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="messageMode" :options="messageModes" label="消息格式" />
|
||||
<span class="control-spacer" />
|
||||
<label class="check-field"><input v-model="uppercase" type="checkbox" />大写结果</label>
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="message" label="消息" :format="messageMode === 'hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="`${message.length} 字符`" placeholder="输入待认证消息;空消息也可计算" />
|
||||
<span />
|
||||
<TextEditor v-model="output" label="CMAC 结果" format="128 位 Hex" :meta="output ? '16 字节' : ''" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="calculate"><Play :size="15" />计算 CMAC</button>
|
||||
<button class="action-button" type="button" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制结果</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, ArrowLeftRight, CheckCircle2, Clipboard, Copy, Play, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText, pasteText } from '@/lib/browser'
|
||||
import { bytesToHex, bytesToText, parseHex, textToBytes } from '@/lib/bytes'
|
||||
|
||||
type Mode = 'text-to-hex' | 'hex-to-text'
|
||||
const mode = ref<Mode>('text-to-hex')
|
||||
const input = ref('Hello, Web Tools')
|
||||
const output = ref('')
|
||||
const delimiter = ref<' ' | '' | ','>(' ')
|
||||
const uppercase = ref(true)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const modes = [
|
||||
{ value: 'text-to-hex' as const, label: '文本 → Hex' },
|
||||
{ value: 'hex-to-text' as const, label: 'Hex → 文本' },
|
||||
]
|
||||
const inputMeta = computed(() => `${input.value.length} 字符`)
|
||||
const outputMeta = computed(() => `${output.value.length} 字符`)
|
||||
|
||||
function clearResult() {
|
||||
output.value = ''
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch([input, mode, delimiter, uppercase], clearResult)
|
||||
|
||||
function convert() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
output.value = mode.value === 'text-to-hex'
|
||||
? bytesToHex(textToBytes(input.value), delimiter.value, uppercase.value)
|
||||
: bytesToText(parseHex(input.value))
|
||||
success.value = '转换完成'
|
||||
} catch (reason) {
|
||||
output.value = ''
|
||||
error.value = reason instanceof Error ? reason.message : '转换失败'
|
||||
}
|
||||
}
|
||||
|
||||
function swap() {
|
||||
input.value = output.value
|
||||
output.value = ''
|
||||
mode.value = mode.value === 'text-to-hex' ? 'hex-to-text' : 'text-to-hex'
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try {
|
||||
await copyText(output.value)
|
||||
success.value = '结果已复制'
|
||||
error.value = ''
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '复制失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function pasteInput() {
|
||||
try {
|
||||
input.value = await pasteText()
|
||||
error.value = ''
|
||||
} catch {
|
||||
error.value = '无法读取剪贴板,请检查浏览器权限'
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
input.value = ''
|
||||
clearResult()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="编码转换" title="ASCII / Hex" description="在 UTF-8 文本与十六进制字节之间转换。">
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="mode" :options="modes" label="转换方向" />
|
||||
<span class="control-spacer" />
|
||||
<label v-if="mode === 'text-to-hex'" class="compact-field">分隔符
|
||||
<select v-model="delimiter">
|
||||
<option value=" ">空格</option>
|
||||
<option value="">无</option>
|
||||
<option value=",">逗号</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="mode === 'text-to-hex'" class="check-field"><input v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="input" label="输入" :format="mode === 'text-to-hex' ? 'UTF-8 文本' : 'Hex 字节'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<button class="swap-button" type="button" aria-label="交换输入输出" @click="swap"><ArrowLeftRight :size="16" /></button>
|
||||
<TextEditor v-model="output" label="结果" :format="mode === 'text-to-hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="outputMeta" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="convert"><Play :size="15" />转换</button>
|
||||
<button class="action-button" type="button" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" @click="pasteInput"><Clipboard :size="15" />粘贴</button>
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, ArrowLeftRight, CheckCircle2, Clipboard, Copy, Play, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText, pasteText } from '@/lib/browser'
|
||||
import { bytesToText, decodeBase64, encodeBase64, formatBytes, textToBytes } from '@/lib/bytes'
|
||||
|
||||
type Mode = 'encode' | 'decode'
|
||||
type DecodeOutputMode = 'text' | 'hex' | 'byte-array'
|
||||
const mode = ref<Mode>('encode')
|
||||
const decodeOutputMode = ref<DecodeOutputMode>('text')
|
||||
const input = ref('Hello, Web Tools')
|
||||
const output = ref('')
|
||||
const decodedBytes = shallowRef<Uint8Array>()
|
||||
const urlSafe = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const modes = [{ value: 'encode' as const, label: '编码' }, { value: 'decode' as const, label: '解码' }]
|
||||
const decodeOutputModes = [
|
||||
{ value: 'text' as const, label: 'UTF-8 文本' },
|
||||
{ value: 'hex' as const, label: '连续 Hex' },
|
||||
{ value: 'byte-array' as const, label: '字节数组' },
|
||||
]
|
||||
const inputMeta = computed(() => `${input.value.length} 字符`)
|
||||
const outputMeta = computed(() => decodedBytes.value && mode.value === 'decode'
|
||||
? `${decodedBytes.value.length} 字节 · ${output.value.length} 字符`
|
||||
: `${output.value.length} 字符`)
|
||||
const outputFormat = computed(() => {
|
||||
if (mode.value === 'encode') return 'Base64'
|
||||
if (decodeOutputMode.value === 'text') return 'UTF-8 文本'
|
||||
return decodeOutputMode.value === 'hex' ? '连续 Hex' : '字节数组'
|
||||
})
|
||||
|
||||
function renderDecodedOutput() {
|
||||
if (!decodedBytes.value) return
|
||||
error.value = ''
|
||||
try {
|
||||
if (decodeOutputMode.value === 'text') output.value = bytesToText(decodedBytes.value)
|
||||
else output.value = formatBytes(decodedBytes.value, {
|
||||
format: decodeOutputMode.value === 'hex' ? 'continuous-hex' : 'byte-array',
|
||||
})
|
||||
} catch (reason) {
|
||||
output.value = ''
|
||||
error.value = reason instanceof Error ? reason.message : '输出格式转换失败'
|
||||
}
|
||||
}
|
||||
|
||||
function clearConversion() {
|
||||
output.value = ''
|
||||
decodedBytes.value = undefined
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch(decodeOutputMode, renderDecodedOutput)
|
||||
watch([input, mode, urlSafe], clearConversion)
|
||||
|
||||
function convert() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
if (mode.value === 'encode') {
|
||||
decodedBytes.value = undefined
|
||||
output.value = encodeBase64(textToBytes(input.value), urlSafe.value)
|
||||
} else {
|
||||
decodedBytes.value = decodeBase64(input.value, urlSafe.value)
|
||||
renderDecodedOutput()
|
||||
if (error.value) return
|
||||
}
|
||||
success.value = '转换完成'
|
||||
} catch (reason) {
|
||||
output.value = ''
|
||||
decodedBytes.value = undefined
|
||||
error.value = reason instanceof Error ? reason.message : '转换失败'
|
||||
}
|
||||
}
|
||||
|
||||
function swap() {
|
||||
if (mode.value === 'decode' && decodeOutputMode.value !== 'text') return
|
||||
input.value = output.value
|
||||
output.value = ''
|
||||
decodedBytes.value = undefined
|
||||
mode.value = mode.value === 'encode' ? 'decode' : 'encode'
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try { await copyText(output.value); success.value = '结果已复制'; error.value = '' }
|
||||
catch { error.value = '复制失败' }
|
||||
}
|
||||
async function pasteInput() {
|
||||
try { input.value = await pasteText(); error.value = '' }
|
||||
catch { error.value = '无法读取剪贴板,请检查浏览器权限' }
|
||||
}
|
||||
function clearAll() { input.value = ''; clearConversion() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="编码转换" title="Base64" description="编码、解码并校验 Base64 内容。">
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="mode" :options="modes" label="转换模式" />
|
||||
<span class="control-spacer" />
|
||||
<label class="check-field"><input v-model="urlSafe" type="checkbox" />URL 安全模式</label>
|
||||
</div>
|
||||
<div v-if="mode === 'decode'" class="tool-controls base64-output-controls">
|
||||
<ModeTabs v-model="decodeOutputMode" :options="decodeOutputModes" label="解码输出格式" />
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="input" label="输入" :format="mode === 'encode' ? 'UTF-8 文本' : 'Base64'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<button class="swap-button" type="button" aria-label="交换输入输出" :disabled="mode === 'decode' && decodeOutputMode !== 'text'" :title="mode === 'decode' && decodeOutputMode !== 'text' ? '仅 UTF-8 文本结果可交换' : ''" @click="swap"><ArrowLeftRight :size="16" /></button>
|
||||
<TextEditor v-model="output" label="结果" :format="outputFormat" :meta="outputMeta" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="convert"><Play :size="15" />转换</button>
|
||||
<button class="action-button" type="button" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" @click="pasteInput"><Clipboard :size="15" />粘贴</button>
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, Calculator, CheckCircle2, Copy, FileUp, Trash2, Upload } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { CRC_PRESETS, type CrcParameters } from '@/lib/crc'
|
||||
import type { CrcBlockResult, CrcWorkerRequest, CrcWorkerResponse } from '@/lib/crcWorker'
|
||||
import { MAX_FIRMWARE_FILE_SIZE, type FirmwareFormat } from '@/lib/firmware'
|
||||
|
||||
interface FirmwareData {
|
||||
fileName: string
|
||||
fileSize: number
|
||||
format: FirmwareFormat
|
||||
blockCount: number
|
||||
totalBytes: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
const defaultPreset = CRC_PRESETS[0]
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const firmware = shallowRef<FirmwareData>()
|
||||
const dragging = ref(false)
|
||||
const selectedPreset = ref(defaultPreset.id)
|
||||
const width = ref(defaultPreset.width)
|
||||
const polynomial = ref(defaultPreset.polynomial.toString(16).toUpperCase())
|
||||
const initialValue = ref(defaultPreset.initialValue.toString(16).toUpperCase())
|
||||
const xorOut = ref(defaultPreset.xorOut.toString(16).toUpperCase())
|
||||
const inputReflected = ref(defaultPreset.inputReflected)
|
||||
const outputReflected = ref(defaultPreset.outputReflected)
|
||||
const combinedResult = ref('')
|
||||
const blockResults = ref<CrcBlockResult[]>([])
|
||||
const parsing = ref(false)
|
||||
const calculating = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
let worker: Worker | undefined
|
||||
let requestSequence = 0
|
||||
let activeParseRequest: number | undefined
|
||||
let activeCalculationRequest: number | undefined
|
||||
|
||||
const activeParameterName = computed(() => CRC_PRESETS.find((preset) => preset.id === selectedPreset.value)?.name ?? '自定义参数')
|
||||
|
||||
function formatAddress(address: number): string {
|
||||
return `0x${address.toString(16).toUpperCase().padStart(8, '0')}`
|
||||
}
|
||||
|
||||
function applyPreset() {
|
||||
const preset = CRC_PRESETS.find((item) => item.id === selectedPreset.value)
|
||||
if (!preset) return
|
||||
width.value = preset.width
|
||||
polynomial.value = preset.polynomial.toString(16).toUpperCase()
|
||||
initialValue.value = preset.initialValue.toString(16).toUpperCase()
|
||||
xorOut.value = preset.xorOut.toString(16).toUpperCase()
|
||||
inputReflected.value = preset.inputReflected
|
||||
outputReflected.value = preset.outputReflected
|
||||
invalidateResults()
|
||||
}
|
||||
|
||||
function markCustom() {
|
||||
selectedPreset.value = 'custom'
|
||||
invalidateResults()
|
||||
}
|
||||
|
||||
function parseParameter(value: string, label: string): bigint {
|
||||
const normalized = value.trim().replace(/^0x/i, '')
|
||||
if (!normalized || !/^[0-9a-f]+$/i.test(normalized)) throw new Error(`${label} 必须是十六进制整数`)
|
||||
return BigInt(`0x${normalized}`)
|
||||
}
|
||||
|
||||
function getParameters(): CrcParameters {
|
||||
return {
|
||||
width: width.value,
|
||||
polynomial: parseParameter(polynomial.value, 'Poly'),
|
||||
initialValue: parseParameter(initialValue.value, 'Init'),
|
||||
inputReflected: inputReflected.value,
|
||||
outputReflected: outputReflected.value,
|
||||
xorOut: parseParameter(xorOut.value, 'XorOut'),
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
combinedResult.value = ''
|
||||
blockResults.value = []
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
function invalidateResults() {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
clearResults()
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function destroyWorker() {
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
activeParseRequest = undefined
|
||||
activeCalculationRequest = undefined
|
||||
parsing.value = false
|
||||
calculating.value = false
|
||||
}
|
||||
|
||||
function createWorker(): Worker {
|
||||
const nextWorker = new Worker(new URL('../workers/crc.worker.ts', import.meta.url), { type: 'module' })
|
||||
nextWorker.onmessage = (event: MessageEvent<CrcWorkerResponse>) => {
|
||||
if (nextWorker !== worker) return
|
||||
const response = event.data
|
||||
if (response.type === 'parsed') {
|
||||
if (response.id !== activeParseRequest) return
|
||||
activeParseRequest = undefined
|
||||
parsing.value = false
|
||||
firmware.value = {
|
||||
fileName: pendingFileName,
|
||||
fileSize: pendingFileSize,
|
||||
format: response.format,
|
||||
totalBytes: response.totalBytes,
|
||||
blockCount: response.blockCount,
|
||||
warnings: response.warnings,
|
||||
}
|
||||
success.value = `已解析 ${response.blockCount} 个数据块`
|
||||
return
|
||||
}
|
||||
if (response.type === 'calculated') {
|
||||
if (response.id !== activeCalculationRequest) return
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
combinedResult.value = response.combinedResult
|
||||
blockResults.value = response.blockResults
|
||||
success.value = `${activeCalculationName} 计算完成`
|
||||
return
|
||||
}
|
||||
if (response.operation === 'parse' && response.id === activeParseRequest) {
|
||||
activeParseRequest = undefined
|
||||
parsing.value = false
|
||||
firmware.value = undefined
|
||||
error.value = response.error
|
||||
} else if (response.operation === 'calculate' && response.id === activeCalculationRequest) {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
error.value = response.error
|
||||
}
|
||||
}
|
||||
nextWorker.onerror = () => {
|
||||
if (nextWorker !== worker) return
|
||||
destroyWorker()
|
||||
firmware.value = undefined
|
||||
clearResults()
|
||||
error.value = 'CRC 后台线程异常终止,请重新选择文件'
|
||||
}
|
||||
worker = nextWorker
|
||||
return nextWorker
|
||||
}
|
||||
|
||||
let pendingFileName = ''
|
||||
let pendingFileSize = 0
|
||||
let activeCalculationName = ''
|
||||
|
||||
function selectFile(selected?: File) {
|
||||
if (!selected) return
|
||||
destroyWorker()
|
||||
firmware.value = undefined
|
||||
clearResults()
|
||||
error.value = ''
|
||||
if (selected.size === 0) { error.value = '文件为空'; return }
|
||||
if (selected.size > MAX_FIRMWARE_FILE_SIZE) { error.value = '文件不能超过 64 MiB'; return }
|
||||
pendingFileName = selected.name
|
||||
pendingFileSize = selected.size
|
||||
const id = ++requestSequence
|
||||
activeParseRequest = id
|
||||
parsing.value = true
|
||||
const request: CrcWorkerRequest = { type: 'parse', id, file: selected }
|
||||
createWorker().postMessage(request)
|
||||
}
|
||||
|
||||
function onFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const selected = input.files?.[0]
|
||||
input.value = ''
|
||||
selectFile(selected)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
void selectFile(event.dataTransfer?.files[0])
|
||||
}
|
||||
|
||||
function calculate() {
|
||||
invalidateResults()
|
||||
if (!firmware.value) { error.value = '请先选择 BIN 或 Intel HEX 文件'; return }
|
||||
try {
|
||||
const parameters = getParameters()
|
||||
if (!worker) throw new Error('文件数据已失效,请重新选择文件')
|
||||
const id = ++requestSequence
|
||||
activeCalculationRequest = id
|
||||
activeCalculationName = activeParameterName.value
|
||||
calculating.value = true
|
||||
const request: CrcWorkerRequest = { type: 'calculate', id, parameters }
|
||||
worker.postMessage(request)
|
||||
} catch (reason) {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
error.value = reason instanceof Error ? reason.message : 'CRC 计算失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try { await copyText(combinedResult.value); success.value = '总 CRC 已复制'; error.value = '' }
|
||||
catch { error.value = '复制失败' }
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
destroyWorker()
|
||||
invalidateResults()
|
||||
firmware.value = undefined
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(destroyWorker)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="校验计算" title="CRC" description="解析 BIN 或 Intel HEX 有效数据,并使用预设或自定义参数计算 CRC。">
|
||||
<label v-if="!firmware" class="upload-zone compact-upload" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<input ref="fileInput" type="file" accept=".hex,.ihx,.bin,application/octet-stream" :disabled="parsing" @change="onFileInput" />
|
||||
<span><FileUp :size="28" aria-hidden="true" /><strong>{{ parsing ? '正在解析文件…' : '选择或拖入 HEX / BIN 文件' }}</strong><span>Intel HEX 会校验记录并识别多个数据块</span></span>
|
||||
</label>
|
||||
<div v-else class="firmware-summary">
|
||||
<div><strong>{{ firmware.fileName }}</strong><span>{{ firmware.format }} · {{ firmware.totalBytes.toLocaleString() }} 有效字节 · {{ firmware.blockCount }} 个 block</span></div>
|
||||
<label class="action-button"><Upload :size="15" />更换文件<input class="sr-only-file" type="file" accept=".hex,.ihx,.bin,application/octet-stream" @change="onFileInput" /></label>
|
||||
<button class="action-button" type="button" @click="clearFile"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
|
||||
<div class="crc-config">
|
||||
<label class="parameter-field preset-field"><span>参数预设</span>
|
||||
<select v-model="selectedPreset" class="select-input" :disabled="calculating" @change="applyPreset">
|
||||
<option v-for="preset in CRC_PRESETS" :key="preset.id" :value="preset.id">{{ preset.name }}</option>
|
||||
<option value="custom">自定义参数</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="parameter-field"><span>Width</span>
|
||||
<select v-model.number="width" class="select-input" :disabled="calculating" @change="markCustom"><option :value="8">8</option><option :value="16">16</option><option :value="32">32</option><option :value="64">64</option></select>
|
||||
</label>
|
||||
<label class="parameter-field"><span>Poly (Hex)</span><input v-model="polynomial" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>Init (Hex)</span><input v-model="initialValue" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>XorOut (Hex)</span><input v-model="xorOut" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="check-field crc-check"><input v-model="inputReflected" type="checkbox" :disabled="calculating" @change="markCustom" />RefIn</label>
|
||||
<label class="check-field crc-check"><input v-model="outputReflected" type="checkbox" :disabled="calculating" @change="markCustom" />RefOut</label>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" :disabled="!firmware || parsing || calculating" @click="calculate"><Calculator :size="15" />{{ calculating ? '正在计算…' : '计算 CRC' }}</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!combinedResult" @click="copyResult"><Copy :size="15" />复制总 CRC</button>
|
||||
</div>
|
||||
|
||||
<div v-if="combinedResult" class="crc-results">
|
||||
<div class="crc-total"><span>总有效数据 · 按地址升序拼接,不填充空洞</span><strong>{{ combinedResult }}</strong></div>
|
||||
<div class="result-table-wrap">
|
||||
<table class="result-table">
|
||||
<thead><tr><th>Block</th><th>起始地址</th><th>结束地址</th><th>字节数</th><th>CRC</th></tr></thead>
|
||||
<tbody><tr v-for="(block, index) in blockResults" :key="block.startAddress"><td>#{{ index + 1 }}</td><td>{{ formatAddress(block.startAddress) }}</td><td>{{ formatAddress(block.endAddress) }}</td><td>{{ block.length.toLocaleString() }}</td><td class="crc-value">{{ block.crc }}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="firmware?.warnings.length" class="status-message warning"><AlertCircle :size="16" />{{ firmware.warnings.join(';') }}</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, Minimize2, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import SyntaxViewer from '@/components/SyntaxViewer.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
|
||||
const input = ref('{"name":"Web Tools","local":true,"tools":["Hex","CMAC","Base64"]}')
|
||||
const output = ref('')
|
||||
const indent = ref('2')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
function clearResult() {
|
||||
output.value = ''
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch([input, indent], clearResult)
|
||||
|
||||
function parseInput() {
|
||||
if (!input.value.trim()) throw new Error('请输入 JSON 内容')
|
||||
return JSON.parse(input.value) as unknown
|
||||
}
|
||||
function format() {
|
||||
try { output.value = JSON.stringify(parseInput(), null, indent.value === 'tab' ? '\t' : Number(indent.value)); error.value = ''; success.value = 'JSON 格式正确' }
|
||||
catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'JSON 无效' }
|
||||
}
|
||||
function minify() {
|
||||
try { output.value = JSON.stringify(parseInput()); error.value = ''; success.value = 'JSON 已压缩' }
|
||||
catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'JSON 无效' }
|
||||
}
|
||||
function validate() {
|
||||
try { parseInput(); error.value = ''; success.value = 'JSON 格式正确' }
|
||||
catch (reason) { success.value = ''; error.value = reason instanceof Error ? reason.message : 'JSON 无效' }
|
||||
}
|
||||
async function copyResult() { try { await copyText(output.value); success.value = '结果已复制' } catch { error.value = '复制失败' } }
|
||||
function clearAll() { input.value = ''; clearResult() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="格式化" title="JSON" description="格式化、压缩并校验 JSON 字符串。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">缩进
|
||||
<select v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option><option value="tab">Tab</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="single-editor-layout">
|
||||
<TextEditor v-model="input" label="输入" format="JSON" :meta="`${input.length} 字符`" placeholder="粘贴 JSON 字符串" :rows="14" />
|
||||
<SyntaxViewer :value="output" label="结果" language="json" format="JSON" :meta="`${output.length} 字符`" />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="format"><Sparkles :size="15" />格式化</button>
|
||||
<button class="action-button" type="button" @click="minify"><Minimize2 :size="15" />压缩</button>
|
||||
<button class="action-button" type="button" @click="validate"><CheckCircle2 :size="15" />校验</button>
|
||||
<button class="action-button" type="button" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Download, ImagePlus, Trash2, Upload } from '@lucide/vue'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { downloadBytes } from '@/lib/browser'
|
||||
import { imageToIco } from '@/lib/ico'
|
||||
import { MAX_PNG_FILE_SIZE, parsePngHeader, PNG_HEADER_LENGTH, type PngMetadata } from '@/lib/png'
|
||||
|
||||
const availableSizes = [16, 24, 32, 48, 64, 128, 256]
|
||||
const selectedSizes = ref([16, 32, 48, 64, 128, 256])
|
||||
const previewImage = ref<HTMLImageElement>()
|
||||
const file = ref<File>()
|
||||
const metadata = ref<PngMetadata>()
|
||||
const previewUrl = ref('')
|
||||
const dimensions = ref('')
|
||||
const imageReady = ref(false)
|
||||
const dragging = ref(false)
|
||||
const converting = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
let stateVersion = 0
|
||||
|
||||
function cleanupUrl() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
async function selectFile(selected?: File) {
|
||||
if (!selected) return
|
||||
const selectionVersion = ++stateVersion
|
||||
cleanupUrl()
|
||||
file.value = undefined
|
||||
metadata.value = undefined
|
||||
previewImage.value = undefined
|
||||
dimensions.value = ''
|
||||
imageReady.value = false
|
||||
converting.value = false
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
if (selected.size > MAX_PNG_FILE_SIZE) throw new Error('PNG 文件不能超过 32 MiB')
|
||||
const header = new Uint8Array(await selected.slice(0, PNG_HEADER_LENGTH).arrayBuffer())
|
||||
if (selectionVersion !== stateVersion) return
|
||||
metadata.value = parsePngHeader(header, selected.size)
|
||||
file.value = selected
|
||||
dimensions.value = `${metadata.value.width} × ${metadata.value.height}px`
|
||||
previewUrl.value = URL.createObjectURL(selected)
|
||||
} catch (reason) {
|
||||
if (selectionVersion !== stateVersion) return
|
||||
error.value = reason instanceof Error ? reason.message : '无法读取 PNG 图片'
|
||||
}
|
||||
}
|
||||
|
||||
function onFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const selected = input.files?.[0]
|
||||
input.value = ''
|
||||
void selectFile(selected)
|
||||
}
|
||||
|
||||
function imageLoaded(event: Event) {
|
||||
const image = event.target as HTMLImageElement
|
||||
if (image.src !== previewUrl.value || !metadata.value) return
|
||||
if (image.naturalWidth !== metadata.value.width || image.naturalHeight !== metadata.value.height) {
|
||||
failCurrentImage('PNG 文件头与实际图片尺寸不一致')
|
||||
return
|
||||
}
|
||||
previewImage.value = image
|
||||
imageReady.value = true
|
||||
dimensions.value = `${image.naturalWidth} × ${image.naturalHeight}px`
|
||||
}
|
||||
|
||||
function failCurrentImage(message: string) {
|
||||
stateVersion += 1
|
||||
cleanupUrl()
|
||||
file.value = undefined
|
||||
metadata.value = undefined
|
||||
previewImage.value = undefined
|
||||
dimensions.value = ''
|
||||
imageReady.value = false
|
||||
converting.value = false
|
||||
success.value = ''
|
||||
error.value = message
|
||||
}
|
||||
|
||||
function imageFailed(event: Event) {
|
||||
const image = event.target as HTMLImageElement
|
||||
if (image.src === previewUrl.value) failCurrentImage('无法读取 PNG 图片')
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
void selectFile(event.dataTransfer?.files[0])
|
||||
}
|
||||
|
||||
function toggleSize(size: number) {
|
||||
if (converting.value) return
|
||||
selectedSizes.value = selectedSizes.value.includes(size)
|
||||
? selectedSizes.value.filter((current) => current !== size)
|
||||
: [...selectedSizes.value, size].sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
async function convert() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
if (!previewImage.value || !file.value || !imageReady.value) { error.value = '请先选择并等待 PNG 图片读取完成'; return }
|
||||
if (selectedSizes.value.length === 0) { error.value = '至少选择一个 ICO 尺寸'; return }
|
||||
const conversionVersion = stateVersion
|
||||
const sourceImage = previewImage.value
|
||||
const sourceFile = file.value
|
||||
const sizes = [...selectedSizes.value]
|
||||
converting.value = true
|
||||
try {
|
||||
const ico = await imageToIco(sourceImage, sizes)
|
||||
if (conversionVersion !== stateVersion) return
|
||||
downloadBytes(ico, `${sourceFile.name.replace(/\.png$/i, '') || 'icon'}.ico`, 'image/x-icon')
|
||||
success.value = `已生成包含 ${sizes.length} 个尺寸的 ICO`
|
||||
} catch (reason) {
|
||||
if (conversionVersion !== stateVersion) return
|
||||
error.value = reason instanceof Error ? reason.message : 'ICO 生成失败'
|
||||
} finally {
|
||||
if (conversionVersion === stateVersion) converting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
stateVersion += 1
|
||||
cleanupUrl()
|
||||
file.value = undefined
|
||||
metadata.value = undefined
|
||||
previewImage.value = undefined
|
||||
dimensions.value = ''
|
||||
imageReady.value = false
|
||||
dragging.value = false
|
||||
converting.value = false
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stateVersion += 1
|
||||
cleanupUrl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="图像处理" title="PNG → ICO" description="在浏览器本地缩放 PNG,并生成包含多个尺寸的 ICO 文件。">
|
||||
<label v-if="!file" class="upload-zone" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<input type="file" accept="image/png,.png" @change="onFileInput" />
|
||||
<span><ImagePlus :size="28" aria-hidden="true" /><strong>选择或拖入 PNG 图片</strong><span>透明背景会保留,文件不会上传</span></span>
|
||||
</label>
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-preview"><img :key="previewUrl" :src="previewUrl" alt="待转换 PNG 预览" @load="imageLoaded" @error="imageFailed" /></div>
|
||||
<div class="image-options">
|
||||
<h2>ICO 尺寸</h2>
|
||||
<div class="size-grid">
|
||||
<label v-for="size in availableSizes" :key="size" class="size-option">
|
||||
<input type="checkbox" :checked="selectedSizes.includes(size)" :disabled="converting" @change="toggleSize(size)" />{{ size }} × {{ size }}
|
||||
</label>
|
||||
</div>
|
||||
<p class="file-meta">{{ file.name }} · {{ dimensions }} · {{ (file.size / 1024).toFixed(1) }} KB</p>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" :disabled="converting || !imageReady" @click="convert"><Download :size="15" />{{ converting ? '正在生成…' : imageReady ? '生成并下载 ICO' : '正在读取图片…' }}</button>
|
||||
<label class="action-button" :class="{ disabled: converting }" :aria-disabled="converting"><Upload :size="15" />更换图片<input class="sr-only-file" type="file" accept="image/png,.png" :disabled="converting" @change="onFileInput" /></label>
|
||||
<button class="action-button" type="button" :disabled="converting" @click="clearFile"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, Dices, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { formatBytes, type ByteDisplayFormat } from '@/lib/bytes'
|
||||
import { secureRandomBytes } from '@/lib/random'
|
||||
|
||||
const length = ref(8)
|
||||
const outputFormat = ref<ByteDisplayFormat>('continuous-hex')
|
||||
const uppercase = ref(true)
|
||||
const bytes = shallowRef<Uint8Array>()
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const outputFormats = [
|
||||
{ value: 'continuous-hex' as const, label: '连续 Hex' },
|
||||
{ value: 'spaced-hex' as const, label: '空格 Hex' },
|
||||
{ value: 'byte-array' as const, label: '字节数组' },
|
||||
]
|
||||
const output = computed(() => bytes.value ? formatBytes(bytes.value, {
|
||||
format: outputFormat.value,
|
||||
uppercase: uppercase.value,
|
||||
}) : '')
|
||||
const formatLabel = computed(() => outputFormats.find((item) => item.value === outputFormat.value)?.label ?? 'Hex')
|
||||
|
||||
function generate() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
bytes.value = secureRandomBytes(length.value)
|
||||
success.value = `已使用安全随机源生成 ${length.value * 8} 位随机数据`
|
||||
} catch (reason) {
|
||||
bytes.value = undefined
|
||||
error.value = reason instanceof Error ? reason.message : '随机数生成失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try { await copyText(output.value); success.value = '随机数已复制'; error.value = '' }
|
||||
catch { error.value = '复制失败' }
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
bytes.value = undefined
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch(length, clearAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="安全随机数" description="使用浏览器密码学随机源生成指定字节长度的随机数据。">
|
||||
<div class="random-controls">
|
||||
<label class="parameter-field"><span>字节长度</span><input v-model.number="length" class="text-input" type="number" min="1" max="65536" step="1" /></label>
|
||||
<div class="parameter-field random-format-field"><span>输出格式</span><ModeTabs v-model="outputFormat" :options="outputFormats" label="输出格式" /></div>
|
||||
<label class="check-field random-check"><input v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
</div>
|
||||
<TextEditor :model-value="output" label="随机数" :format="formatLabel" :meta="`${bytes?.length ?? 0} 字节 · ${output.length} 字符`" readonly :rows="11" />
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="generate"><Dices :size="15" />生成随机数</button>
|
||||
<button class="action-button" type="button" :disabled="!output" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制结果</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, KeyRound, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { formatBytes, parseHex } from '@/lib/bytes'
|
||||
import { generateRsaKeyPair, type GeneratedRsaKeyPair } from '@/lib/rsa'
|
||||
|
||||
type OutputMode = 'public' | 'private' | 'parameters'
|
||||
type ParameterFormat = 'hex' | 'byte-array'
|
||||
const modulusLength = ref(2048)
|
||||
const outputMode = ref<OutputMode>('public')
|
||||
const parameterFormat = ref<ParameterFormat>('hex')
|
||||
const result = shallowRef<GeneratedRsaKeyPair>()
|
||||
const generating = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const outputModes = [
|
||||
{ value: 'public' as const, label: '公钥 PEM' },
|
||||
{ value: 'private' as const, label: '私钥 PEM' },
|
||||
{ value: 'parameters' as const, label: 'N / E 参数' },
|
||||
]
|
||||
const parameterFormats = [
|
||||
{ value: 'hex' as const, label: '连续 Hex' },
|
||||
{ value: 'byte-array' as const, label: '字节数组' },
|
||||
]
|
||||
|
||||
const pemOutput = computed(() => {
|
||||
if (!result.value) return ''
|
||||
if (outputMode.value === 'public') return result.value.publicKeyPem
|
||||
if (outputMode.value === 'private') return result.value.privateKeyPem
|
||||
return ''
|
||||
})
|
||||
|
||||
const outputFormat = computed(() => outputMode.value === 'public' ? 'SPKI' : 'PKCS#8')
|
||||
const parameterFormatLabel = computed(() => parameterFormat.value === 'hex' ? '连续 Hex' : '字节数组')
|
||||
const modulusOutput = computed(() => formatParameter(result.value?.modulusHex ?? ''))
|
||||
const exponentOutput = computed(() => formatParameter(result.value?.exponentHex ?? ''))
|
||||
const modulusBytes = computed(() => result.value ? parseHex(result.value.modulusHex).length : 0)
|
||||
const exponentBytes = computed(() => result.value ? parseHex(result.value.exponentHex).length : 0)
|
||||
|
||||
function formatParameter(hex: string): string {
|
||||
if (!hex) return ''
|
||||
return formatBytes(parseHex(hex), {
|
||||
format: parameterFormat.value === 'hex' ? 'continuous-hex' : 'byte-array',
|
||||
})
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
generating.value = true
|
||||
try {
|
||||
result.value = await generateRsaKeyPair(modulusLength.value)
|
||||
outputMode.value = 'public'
|
||||
success.value = `RSA 密钥对已生成 · ${result.value.source}`
|
||||
} catch (reason) {
|
||||
result.value = undefined
|
||||
error.value = reason instanceof Error ? reason.message : 'RSA 密钥生成失败'
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyValue(value: string, label: string) {
|
||||
try { await copyText(value); success.value = `${label}已复制`; error.value = '' }
|
||||
catch { error.value = '复制失败' }
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
result.value = undefined
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
|
||||
watch(modulusLength, clearAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="RSA 密钥对" description="使用安全随机源生成 RSA 密钥对,并导出标准 PEM 或公钥 N/E 参数。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">密钥长度
|
||||
<select v-model.number="modulusLength" :disabled="generating"><option :value="2048">2048 位</option><option :value="3072">3072 位</option><option :value="4096">4096 位</option></select>
|
||||
</label>
|
||||
<span class="fixed-parameter">Public exponent <strong>65537 · 0x010001</strong></span>
|
||||
</div>
|
||||
<div class="action-row rsa-generate-row">
|
||||
<button class="action-button primary" type="button" :disabled="generating" @click="generate"><KeyRound :size="15" />{{ generating ? '正在生成…' : '生成密钥对' }}</button>
|
||||
<button class="action-button" type="button" :disabled="!result || generating" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
<template v-if="result">
|
||||
<div class="tool-controls rsa-output-tabs">
|
||||
<ModeTabs v-model="outputMode" :options="outputModes" label="输出格式" />
|
||||
<span class="control-spacer" />
|
||||
<button v-if="outputMode !== 'parameters'" class="action-button ghost" type="button" @click="copyValue(pemOutput, '当前结果')"><Copy :size="15" />复制当前结果</button>
|
||||
</div>
|
||||
<template v-if="outputMode === 'parameters'">
|
||||
<div class="tool-controls rsa-parameter-format">
|
||||
<ModeTabs v-model="parameterFormat" :options="parameterFormats" label="参数格式" />
|
||||
</div>
|
||||
<div class="rsa-parameter-grid">
|
||||
<section>
|
||||
<TextEditor :model-value="modulusOutput" label="模数 N" :format="parameterFormatLabel" :meta="`${modulusBytes} 字节 · ${modulusOutput.length} 字符`" readonly :rows="14" />
|
||||
<div class="action-row rsa-copy-row"><button class="action-button ghost" type="button" @click="copyValue(modulusOutput, 'N 参数')"><Copy :size="15" />复制 N</button></div>
|
||||
</section>
|
||||
<section>
|
||||
<TextEditor :model-value="exponentOutput" label="指数 E" :format="parameterFormatLabel" :meta="`${exponentBytes} 字节 · ${exponentOutput.length} 字符`" readonly :rows="4" />
|
||||
<div class="action-row rsa-copy-row"><button class="action-button ghost" type="button" @click="copyValue(exponentOutput, 'E 参数')"><Copy :size="15" />复制 E</button></div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<TextEditor v-else :model-value="pemOutput" label="生成结果" :format="outputFormat" :meta="`${pemOutput.length} 字符`" readonly :rows="16" />
|
||||
</template>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, Minimize2, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import xmlFormatter from 'xml-formatter'
|
||||
import SyntaxViewer from '@/components/SyntaxViewer.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
|
||||
const input = ref('<tools><tool id="hex">ASCII / Hex</tool><tool id="cmac">AES-128-CMAC</tool></tools>')
|
||||
const output = ref('')
|
||||
const indent = ref('2')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
function clearResult() {
|
||||
output.value = ''
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch([input, indent], clearResult)
|
||||
|
||||
function validateXml() {
|
||||
if (!input.value.trim()) throw new Error('请输入 XML 内容')
|
||||
const document = new DOMParser().parseFromString(input.value, 'application/xml')
|
||||
const parserError = document.querySelector('parsererror')
|
||||
if (parserError) throw new Error(parserError.textContent?.split('\n')[0] || 'XML 格式无效')
|
||||
}
|
||||
function format() {
|
||||
try {
|
||||
validateXml()
|
||||
output.value = xmlFormatter(input.value, { indentation: ' '.repeat(Number(indent.value)), lineSeparator: '\n', strictMode: true })
|
||||
error.value = ''; success.value = 'XML 格式正确'
|
||||
} catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
function minify() {
|
||||
try { validateXml(); output.value = xmlFormatter.minify(input.value, { strictMode: true }); error.value = ''; success.value = 'XML 已压缩' }
|
||||
catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
function validate() {
|
||||
try { validateXml(); error.value = ''; success.value = 'XML 格式正确' }
|
||||
catch (reason) { success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
async function copyResult() { try { await copyText(output.value); success.value = '结果已复制' } catch { error.value = '复制失败' } }
|
||||
function clearAll() { input.value = ''; clearResult() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="格式化" title="XML" description="格式化、压缩并校验 XML 字符串。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">缩进
|
||||
<select v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="single-editor-layout">
|
||||
<TextEditor v-model="input" label="输入" format="XML" :meta="`${input.length} 字符`" placeholder="粘贴 XML 字符串" :rows="14" />
|
||||
<SyntaxViewer :value="output" label="结果" language="xml" format="XML" :meta="`${output.length} 字符`" />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="format"><Sparkles :size="15" />格式化</button>
|
||||
<button class="action-button" type="button" @click="minify"><Minimize2 :size="15" />压缩</button>
|
||||
<button class="action-button" type="button" @click="validate"><CheckCircle2 :size="15" />校验</button>
|
||||
<button class="action-button" type="button" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
Binary,
|
||||
Braces,
|
||||
Code2,
|
||||
Dices,
|
||||
FileCode2,
|
||||
Image,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
} from '@lucide/vue'
|
||||
import { defineAsyncComponent, type Component } from 'vue'
|
||||
import catalogData from '@shared/tool-catalog.json'
|
||||
import type { CatalogCategory, CatalogTool } from '@/types'
|
||||
|
||||
export const iconComponents: Record<string, Component> = {
|
||||
BadgeCheck,
|
||||
Binary,
|
||||
Braces,
|
||||
Code2,
|
||||
Dices,
|
||||
FileCode2,
|
||||
Image,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
}
|
||||
|
||||
export const categories = catalogData.categories as CatalogCategory[]
|
||||
export const tools = (catalogData.tools as CatalogTool[]).map((tool) => {
|
||||
const iconComponent = iconComponents[tool.icon]
|
||||
if (!iconComponent) throw new Error(`工具 ${tool.id} 使用了未注册图标 ${tool.icon}`)
|
||||
return { ...tool, iconComponent }
|
||||
})
|
||||
|
||||
export const categoryById = new Map(categories.map((category) => [category.id, category]))
|
||||
export const toolById = new Map(tools.map((tool) => [tool.id, tool]))
|
||||
|
||||
export const toolComponents: Record<string, Component> = {
|
||||
'ascii-hex': defineAsyncComponent(() => import('./AsciiHexTool.vue')),
|
||||
base64: defineAsyncComponent(() => import('./Base64Tool.vue')),
|
||||
'aes-cmac': defineAsyncComponent(() => import('./AesCmacTool.vue')),
|
||||
'rsa-key-pair': defineAsyncComponent(() => import('./RsaKeyPairTool.vue')),
|
||||
random: defineAsyncComponent(() => import('./RandomTool.vue')),
|
||||
crc: defineAsyncComponent(() => import('./CrcTool.vue')),
|
||||
json: defineAsyncComponent(() => import('./JsonTool.vue')),
|
||||
xml: defineAsyncComponent(() => import('./XmlTool.vue')),
|
||||
'png-ico': defineAsyncComponent(() => import('./PngIcoTool.vue')),
|
||||
}
|
||||
|
||||
export function assertToolRegistryConsistency() {
|
||||
const categoryIds = new Set<string>()
|
||||
for (const category of categories) {
|
||||
if (categoryIds.has(category.id)) throw new Error(`工具分类重复:${category.id}`)
|
||||
categoryIds.add(category.id)
|
||||
}
|
||||
|
||||
const toolIds = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
if (toolIds.has(tool.id)) throw new Error(`工具 ID 重复:${tool.id}`)
|
||||
if (!categoryIds.has(tool.category)) throw new Error(`工具 ${tool.id} 使用了不存在的分类 ${tool.category}`)
|
||||
toolIds.add(tool.id)
|
||||
}
|
||||
|
||||
const componentIds = Object.keys(toolComponents)
|
||||
const missingComponents = [...toolIds].filter((id) => !(id in toolComponents))
|
||||
const unknownComponents = componentIds.filter((id) => !toolIds.has(id))
|
||||
if (missingComponents.length || unknownComponents.length) {
|
||||
throw new Error(`工具目录与懒加载组件不一致:缺少 [${missingComponents.join(', ')}],多余 [${unknownComponents.join(', ')}]`)
|
||||
}
|
||||
}
|
||||
|
||||
assertToolRegistryConsistency()
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export interface CatalogCategory {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface CatalogTool {
|
||||
id: string
|
||||
title: string
|
||||
category: string
|
||||
description: string
|
||||
icon: string
|
||||
iconComponent?: Component
|
||||
}
|
||||
|
||||
export interface NavigationCategory {
|
||||
id: string
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
export interface NavigationConfig {
|
||||
version: 1
|
||||
categories: NavigationCategory[]
|
||||
}
|
||||
|
||||
export interface ModeOption<T extends string = string> {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, ArrowDown, ArrowUp, CheckCircle2, GripVertical, RotateCcw, Save } from '@lucide/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { categories, categoryById, toolById } from '@/tools/registry'
|
||||
import { useNavigation } from '@/composables/useNavigation'
|
||||
import {
|
||||
cloneNavigation,
|
||||
moveCategoryBy,
|
||||
moveToolBy,
|
||||
moveToolToCategory,
|
||||
relocateTool,
|
||||
reorderCategory,
|
||||
} from '@/lib/navigationOrder'
|
||||
import type { NavigationConfig } from '@/types'
|
||||
|
||||
const { navigation, loadError, loadNavigation, resetLocalNavigation, saveNavigation } = useNavigation()
|
||||
const draft = ref<NavigationConfig>(resetLocalNavigation())
|
||||
const original = ref('')
|
||||
const ready = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const draggedTool = ref<{ categoryIndex: number; toolIndex: number }>()
|
||||
const draggedCategory = ref<number>()
|
||||
|
||||
const dirty = computed(() => ready.value && JSON.stringify(draft.value) !== original.value)
|
||||
|
||||
function cloneCurrent() {
|
||||
draft.value = cloneNavigation(navigation.value as NavigationConfig)
|
||||
original.value = JSON.stringify(draft.value)
|
||||
}
|
||||
|
||||
function moveCategory(index: number, delta: number) {
|
||||
draft.value = moveCategoryBy(draft.value, index, delta)
|
||||
}
|
||||
|
||||
function moveTool(categoryIndex: number, toolIndex: number, delta: number) {
|
||||
draft.value = moveToolBy(draft.value, categoryIndex, toolIndex, delta)
|
||||
}
|
||||
|
||||
function changeToolCategory(toolId: string, sourceCategoryIndex: number, targetCategoryId: string) {
|
||||
draft.value = moveToolToCategory(draft.value, toolId, sourceCategoryIndex, targetCategoryId)
|
||||
}
|
||||
|
||||
function startToolDrag(categoryIndex: number, toolIndex: number) {
|
||||
draggedCategory.value = undefined
|
||||
draggedTool.value = { categoryIndex, toolIndex }
|
||||
}
|
||||
|
||||
function startCategoryDrag(categoryIndex: number) {
|
||||
draggedTool.value = undefined
|
||||
draggedCategory.value = categoryIndex
|
||||
}
|
||||
|
||||
function dropTool(targetCategoryIndex: number, targetToolIndex: number) {
|
||||
const source = draggedTool.value
|
||||
draggedTool.value = undefined
|
||||
if (!source) return
|
||||
draft.value = relocateTool(draft.value, source, { categoryIndex: targetCategoryIndex, toolIndex: targetToolIndex })
|
||||
}
|
||||
|
||||
function dropCategory(targetIndex: number) {
|
||||
const sourceIndex = draggedCategory.value
|
||||
draggedCategory.value = undefined
|
||||
if (sourceIndex === undefined || sourceIndex === targetIndex) return
|
||||
draft.value = reorderCategory(draft.value, sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function restoreDefaults() {
|
||||
if (!ready.value) return
|
||||
draft.value = resetLocalNavigation()
|
||||
error.value = ''
|
||||
success.value = '已恢复默认顺序,保存后生效'
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!ready.value || !dirty.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
const result = await saveNavigation(cloneNavigation(draft.value))
|
||||
draft.value = cloneNavigation(result)
|
||||
original.value = JSON.stringify(result)
|
||||
success.value = '导航顺序已保存'
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadNavigation()
|
||||
cloneCurrent()
|
||||
if (loadError.value) error.value = loadError.value
|
||||
ready.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="setting-page">
|
||||
<header class="setting-heading">
|
||||
<div>
|
||||
<p class="setting-category">工具管理</p>
|
||||
<h1>导航顺序</h1>
|
||||
<p class="setting-description">拖动分类或工具调整顺序,也可以用方向按钮精确移动。</p>
|
||||
</div>
|
||||
<div class="setting-heading-actions">
|
||||
<button class="action-button" type="button" :disabled="!ready || saving" @click="restoreDefaults"><RotateCcw :size="15" />恢复默认</button>
|
||||
<button class="action-button primary" type="button" :disabled="!ready || saving || !dirty" @click="save"><Save :size="15" />{{ saving ? '保存中…' : '保存顺序' }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!ready" class="setting-loading" role="status">正在加载导航配置…</div>
|
||||
<div v-else class="setting-list">
|
||||
<section
|
||||
v-for="(navigationCategory, categoryIndex) in draft.categories"
|
||||
:key="navigationCategory.id"
|
||||
class="setting-category-block"
|
||||
@dragover.prevent
|
||||
@drop="draggedTool ? dropTool(categoryIndex, navigationCategory.tools.length) : dropCategory(categoryIndex)"
|
||||
>
|
||||
<header class="setting-category-header" draggable="true" @dragstart="startCategoryDrag(categoryIndex)" @dragend="draggedCategory = undefined">
|
||||
<GripVertical class="drag-handle" :size="16" aria-hidden="true" />
|
||||
<h2>{{ categoryById.get(navigationCategory.id)?.title }}</h2>
|
||||
<small>{{ navigationCategory.tools.length }} 个工具</small>
|
||||
<span class="row-actions">
|
||||
<button class="row-action" type="button" aria-label="分类上移" :disabled="categoryIndex === 0" @click="moveCategory(categoryIndex, -1)"><ArrowUp :size="15" /></button>
|
||||
<button class="row-action" type="button" aria-label="分类下移" :disabled="categoryIndex === draft.categories.length - 1" @click="moveCategory(categoryIndex, 1)"><ArrowDown :size="15" /></button>
|
||||
</span>
|
||||
</header>
|
||||
<div class="setting-tool-list">
|
||||
<div
|
||||
v-for="(toolId, toolIndex) in navigationCategory.tools"
|
||||
:key="toolId"
|
||||
class="setting-tool-row"
|
||||
:class="{ 'is-dragging': draggedTool?.categoryIndex === categoryIndex && draggedTool?.toolIndex === toolIndex }"
|
||||
draggable="true"
|
||||
@dragstart="startToolDrag(categoryIndex, toolIndex)"
|
||||
@dragend="draggedTool = undefined"
|
||||
@dragover.prevent
|
||||
@drop.stop="dropTool(categoryIndex, toolIndex)"
|
||||
>
|
||||
<GripVertical class="drag-handle" :size="16" aria-hidden="true" />
|
||||
<span class="tool-row-icon"><component :is="toolById.get(toolId)?.iconComponent" :size="15" /></span>
|
||||
<strong>{{ toolById.get(toolId)?.title }}</strong>
|
||||
<select class="select-input" :value="navigationCategory.id" aria-label="所属分类" @change="changeToolCategory(toolId, categoryIndex, ($event.target as HTMLSelectElement).value)">
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">{{ category.title }}</option>
|
||||
</select>
|
||||
<span class="row-actions">
|
||||
<button class="row-action" type="button" aria-label="工具上移" :disabled="toolIndex === 0" @click="moveTool(categoryIndex, toolIndex, -1)"><ArrowUp :size="15" /></button>
|
||||
<button class="row-action" type="button" aria-label="工具下移" :disabled="toolIndex === navigationCategory.tools.length - 1" @click="moveTool(categoryIndex, toolIndex, 1)"><ArrowDown :size="15" /></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error save-state"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success save-state"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { calculateCrcReport, type CrcWorkerRequest, type CrcWorkerResponse } from '@/lib/crcWorker'
|
||||
import { getFirmwareFormat, MAX_FIRMWARE_FILE_SIZE, parseFirmwareContent } from '@/lib/firmware'
|
||||
import type { DataBlock } from '@/lib/intelHex'
|
||||
|
||||
const workerScope = globalThis as unknown as {
|
||||
onmessage: ((event: MessageEvent<CrcWorkerRequest>) => void) | null
|
||||
postMessage(message: CrcWorkerResponse): void
|
||||
}
|
||||
|
||||
let firmwareBlocks: DataBlock[] | undefined
|
||||
|
||||
workerScope.onmessage = async (event) => {
|
||||
const request = event.data
|
||||
try {
|
||||
if (request.type === 'parse') {
|
||||
if (request.file.size === 0) throw new Error('文件为空')
|
||||
if (request.file.size > MAX_FIRMWARE_FILE_SIZE) throw new Error('文件不能超过 64 MiB')
|
||||
const format = getFirmwareFormat(request.file.name)
|
||||
const content = format === 'Intel HEX'
|
||||
? await request.file.text()
|
||||
: new Uint8Array(await request.file.arrayBuffer())
|
||||
const parsed = parseFirmwareContent(request.file.name, content)
|
||||
firmwareBlocks = parsed.blocks
|
||||
workerScope.postMessage({
|
||||
type: 'parsed',
|
||||
id: request.id,
|
||||
format: parsed.format,
|
||||
totalBytes: parsed.totalBytes,
|
||||
blockCount: parsed.blocks.length,
|
||||
warnings: parsed.warnings,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!firmwareBlocks) throw new Error('请先选择 BIN 或 Intel HEX 文件')
|
||||
workerScope.postMessage({ type: 'calculated', id: request.id, ...calculateCrcReport(firmwareBlocks, request.parameters) })
|
||||
} catch (reason) {
|
||||
workerScope.postMessage({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
operation: request.type,
|
||||
error: reason instanceof Error ? reason.message : 'CRC 计算失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
bytesToHex,
|
||||
bytesToText,
|
||||
decodeBase64,
|
||||
encodeBase64,
|
||||
formatBytes,
|
||||
parseHex,
|
||||
textToBytes,
|
||||
} from '../src/lib/bytes'
|
||||
|
||||
describe('parseHex', () => {
|
||||
it('accepts contiguous hex, separators and optional 0x prefixes', () => {
|
||||
const expected = '00112233445566778899AABBCCDDEEFF'
|
||||
expect(bytesToHex(parseHex(expected), '')).toBe(expected)
|
||||
expect(bytesToHex(parseHex('00 11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF'), '')).toBe(expected)
|
||||
expect(bytesToHex(parseHex('00,11,22,33,44,55,66,77,88,99,AA,BB,CC,DD,EE,FF'), '')).toBe(expected)
|
||||
expect(bytesToHex(parseHex('0x00 0x11 0x22 0x33 0x44 0x55 0x66 0x77 0x88 0x99 0xAA 0xBB 0xCC 0xDD 0xEE 0xFF'), '')).toBe(expected)
|
||||
expect(bytesToHex(parseHex(`0x${expected}`), '')).toBe(expected)
|
||||
})
|
||||
|
||||
it('reports invalid characters, odd length and exact byte count', () => {
|
||||
expect(() => parseHex('GG')).toThrow('无效')
|
||||
expect(() => parseHex('10x2')).toThrow('无效')
|
||||
expect(() => parseHex('AA0xBB')).toThrow('无效')
|
||||
expect(() => parseHex('ABC')).toThrow('偶数')
|
||||
expect(() => parseHex('0011', 16)).toThrow('当前为 2 字节')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UTF-8 and Base64', () => {
|
||||
it('round-trips Unicode text', () => {
|
||||
const source = '你好,Web Tools'
|
||||
expect(bytesToText(textToBytes(source))).toBe(source)
|
||||
expect(bytesToText(decodeBase64(encodeBase64(textToBytes(source))))).toBe(source)
|
||||
})
|
||||
|
||||
it('formats decoded binary as continuous hex and source-code bytes', () => {
|
||||
const bytes = decodeBase64('SJ87vWNeyh907uXD4bRteQ==')
|
||||
expect(formatBytes(bytes)).toBe('489F3BBD635ECA1F74EEE5C3E1B46D79')
|
||||
expect(formatBytes(bytes, { format: 'byte-array', uppercase: false })).toBe('0x48, 0x9f, 0x3b, 0xbd, 0x63, 0x5e, 0xca, 0x1f, 0x74, 0xee, 0xe5, 0xc3, 0xe1, 0xb4, 0x6d, 0x79')
|
||||
})
|
||||
|
||||
it('supports URL-safe Base64 without padding', () => {
|
||||
const bytes = Uint8Array.from([251, 255, 239])
|
||||
expect(encodeBase64(bytes, true)).toBe('-__v')
|
||||
expect(decodeBase64('-__v', true)).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('rejects invalid Base64', () => {
|
||||
expect(() => decodeBase64('abcde')).toThrow('长度')
|
||||
expect(() => decodeBase64('ab*c')).toThrow('无效')
|
||||
expect(() => decodeBase64('Zh==')).toThrow('无效')
|
||||
expect(() => decodeBase64('Zm9=')).toThrow('无效')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import defaultNavigation from '../shared/default-navigation.json'
|
||||
import catalog from '../shared/tool-catalog.json'
|
||||
import {
|
||||
assertToolRegistryConsistency,
|
||||
categories,
|
||||
iconComponents,
|
||||
toolComponents,
|
||||
tools,
|
||||
} from '../src/tools/registry'
|
||||
|
||||
describe('tool catalog consistency', () => {
|
||||
it('keeps catalog categories, icons and lazy components registered', () => {
|
||||
expect(() => assertToolRegistryConsistency()).not.toThrow()
|
||||
expect(new Set(categories.map(({ id }) => id)).size).toBe(categories.length)
|
||||
expect(new Set(tools.map(({ id }) => id)).size).toBe(tools.length)
|
||||
expect(tools.every((tool) => Boolean(iconComponents[tool.icon]))).toBe(true)
|
||||
expect(Object.keys(toolComponents).sort()).toEqual(tools.map(({ id }) => id).sort())
|
||||
})
|
||||
|
||||
it('lists every catalog category and tool exactly once in default navigation', () => {
|
||||
const navigationCategoryIds = defaultNavigation.categories.map(({ id }) => id)
|
||||
const navigationToolIds = defaultNavigation.categories.flatMap(({ tools: ids }) => ids)
|
||||
expect(navigationCategoryIds).toEqual(catalog.categories.map(({ id }) => id))
|
||||
expect(navigationToolIds.sort()).toEqual(catalog.tools.map(({ id }) => id).sort())
|
||||
expect(new Set(navigationToolIds).size).toBe(navigationToolIds.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bytesToHex, parseHex } from '../src/lib/bytes'
|
||||
import { aes128Cmac } from '../src/lib/cmac'
|
||||
|
||||
describe('AES-128-CMAC RFC 4493 vectors', () => {
|
||||
const key = parseHex('2B7E151628AED2A6ABF7158809CF4F3C')
|
||||
const vectors = [
|
||||
['', 'BB1D6929E95937287FA37D129B756746'],
|
||||
['6BC1BEE22E409F96E93D7E117393172A', '070A16B46B4D4144F79BDD9DD04A287C'],
|
||||
['6BC1BEE22E409F96E93D7E117393172AAE2D8A57', '7D85449EA6EA19C823A7BF78837DFADE'],
|
||||
['6BC1BEE22E409F96E93D7E117393172AAE2D8A571E03AC9C9EB76FAC45AF8E5130C81C46A35CE411', 'DFA66747DE9AE63030CA32611497C827'],
|
||||
]
|
||||
|
||||
it.each(vectors)('matches the expected tag', (message, expected) => {
|
||||
expect(bytesToHex(aes128Cmac(key, parseHex(message)), '')).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calculateCrc, calculateCrcChunks, CRC_PRESETS, formatCrc } from '../src/lib/crc'
|
||||
import { calculateCrcReport } from '../src/lib/crcWorker'
|
||||
import { getFirmwareFormat, parseFirmwareContent } from '../src/lib/firmware'
|
||||
import { concatenateBlocks, parseIntelHex } from '../src/lib/intelHex'
|
||||
import { textToBytes } from '../src/lib/bytes'
|
||||
|
||||
function intelHexRecord(address: number, data: number[]): string {
|
||||
const bytes = [data.length, address >> 8, address & 0xff, 0, ...data]
|
||||
const checksum = (-bytes.reduce((sum, byte) => sum + byte, 0)) & 0xff
|
||||
return `:${[...bytes, checksum].map((byte) => byte.toString(16).padStart(2, '0')).join('').toUpperCase()}`
|
||||
}
|
||||
|
||||
describe('CRC', () => {
|
||||
it('matches standard check values for 123456789', () => {
|
||||
const input = textToBytes('123456789')
|
||||
for (const preset of CRC_PRESETS) {
|
||||
expect(calculateCrc(input, preset), preset.name).toBe(preset.check)
|
||||
expect(formatCrc(preset.check, preset.width)).toHaveLength(Math.ceil(preset.width / 4))
|
||||
}
|
||||
})
|
||||
|
||||
it('supports CRC-64 / ECMA-182 parameters', () => {
|
||||
expect(calculateCrc(textToBytes('123456789'), {
|
||||
width: 64,
|
||||
polynomial: 0x42f0e1eba9ea3693n,
|
||||
initialValue: 0n,
|
||||
inputReflected: false,
|
||||
outputReflected: false,
|
||||
xorOut: 0n,
|
||||
})).toBe(0x6c40df5f0b497347n)
|
||||
})
|
||||
|
||||
it('supports reflected CRC-64 / XZ parameters', () => {
|
||||
expect(calculateCrc(textToBytes('123456789'), {
|
||||
width: 64,
|
||||
polynomial: 0x42f0e1eba9ea3693n,
|
||||
initialValue: 0xffffffffffffffffn,
|
||||
inputReflected: true,
|
||||
outputReflected: true,
|
||||
xorOut: 0xffffffffffffffffn,
|
||||
})).toBe(0x995dc9bbdf1939fan)
|
||||
})
|
||||
|
||||
it('calculates chunked data as one continuous byte stream', () => {
|
||||
const preset = CRC_PRESETS[0]
|
||||
const chunks = [textToBytes('123'), textToBytes('456'), textToBytes('789')]
|
||||
expect(calculateCrcChunks(chunks, preset)).toBe(calculateCrc(textToBytes('123456789'), preset))
|
||||
})
|
||||
|
||||
it('reports both the combined CRC and each block CRC', () => {
|
||||
const parameters = CRC_PRESETS.find((preset) => preset.id === 'crc-32-iso-hdlc')!
|
||||
const blocks = [
|
||||
{ startAddress: 0x1000, data: textToBytes('1234') },
|
||||
{ startAddress: 0x2000, data: textToBytes('56789') },
|
||||
]
|
||||
const report = calculateCrcReport(blocks, parameters)
|
||||
expect(report.combinedResult).toBe(formatCrc(parameters.check, parameters.width))
|
||||
expect(report.blockResults).toEqual([
|
||||
{ startAddress: 0x1000, endAddress: 0x1003, length: 4, crc: formatCrc(calculateCrc(blocks[0].data, parameters), 32) },
|
||||
{ startAddress: 0x2000, endAddress: 0x2004, length: 5, crc: formatCrc(calculateCrc(blocks[1].data, parameters), 32) },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Intel HEX', () => {
|
||||
it('validates records and separates non-contiguous data into blocks', () => {
|
||||
const parsed = parseIntelHex(':0400000001020304F2\n:02001000AABB89\n:00000001FF\n')
|
||||
expect(parsed.totalBytes).toBe(6)
|
||||
expect(parsed.blocks).toHaveLength(2)
|
||||
expect(parsed.blocks[0]).toEqual({ startAddress: 0, data: Uint8Array.from([1, 2, 3, 4]) })
|
||||
expect(parsed.blocks[1]).toEqual({ startAddress: 0x10, data: Uint8Array.from([0xaa, 0xbb]) })
|
||||
expect(concatenateBlocks(parsed.blocks)).toEqual(Uint8Array.from([1, 2, 3, 4, 0xaa, 0xbb]))
|
||||
})
|
||||
|
||||
it('supports extended addresses and reports a missing EOF record', () => {
|
||||
const parsed = parseIntelHex(':020000040001F9\n:040000001122334452\n')
|
||||
expect(parsed.blocks[0].startAddress).toBe(0x10000)
|
||||
expect(parsed.warnings).toEqual(['文件缺少 EOF 记录,已按现有数据计算'])
|
||||
})
|
||||
|
||||
it('rejects invalid checksums and conflicting overlapping data', () => {
|
||||
expect(() => parseIntelHex(':0400000001020304F3\n:00000001FF')).toThrow('校验和错误')
|
||||
expect(() => parseIntelHex(':020000000102FB\n:02000100FF03FB\n:00000001FF')).toThrow('数据冲突')
|
||||
})
|
||||
|
||||
it('merges out-of-order and identical overlapping records without changing bytes', () => {
|
||||
const parsed = parseIntelHex([
|
||||
intelHexRecord(2, [3, 4]),
|
||||
intelHexRecord(0, [1, 2, 3]),
|
||||
':00000001FF',
|
||||
].join('\n'))
|
||||
expect(parsed.blocks).toEqual([{ startAddress: 0, data: Uint8Array.from([1, 2, 3, 4]) }])
|
||||
})
|
||||
|
||||
it('enforces configurable data-record and block limits', () => {
|
||||
const records = [intelHexRecord(0, [1]), intelHexRecord(2, [2]), ':00000001FF'].join('\n')
|
||||
expect(() => parseIntelHex(records, { maxDataRecords: 1 })).toThrow('数据记录不能超过 1 条')
|
||||
expect(() => parseIntelHex(records, { maxBlocks: 1 })).toThrow('数据块不能超过 1 个')
|
||||
expect(() => parseIntelHex(records, { maxBlocks: 0 })).toThrow('必须是正整数')
|
||||
})
|
||||
})
|
||||
|
||||
describe('firmware parsing', () => {
|
||||
it('detects supported extensions case-insensitively', () => {
|
||||
expect(getFirmwareFormat('firmware.HEX')).toBe('Intel HEX')
|
||||
expect(getFirmwareFormat('firmware.ihx')).toBe('Intel HEX')
|
||||
expect(getFirmwareFormat('firmware.bin')).toBe('BIN')
|
||||
expect(() => getFirmwareFormat('firmware.txt')).toThrow('请选择')
|
||||
})
|
||||
|
||||
it('parses Intel HEX and BIN content with strict content types', () => {
|
||||
const hex = parseFirmwareContent('firmware.hex', `${intelHexRecord(0, [0xaa, 0xbb])}\n:00000001FF`)
|
||||
expect(hex.totalBytes).toBe(2)
|
||||
expect(hex.blocks[0].data).toEqual(Uint8Array.from([0xaa, 0xbb]))
|
||||
|
||||
const bin = parseFirmwareContent('firmware.bin', Uint8Array.from([1, 2, 3]))
|
||||
expect(bin).toMatchObject({ format: 'BIN', totalBytes: 3, warnings: [] })
|
||||
expect(() => parseFirmwareContent('firmware.hex', Uint8Array.of(1))).toThrow('必须是文本')
|
||||
expect(() => parseFirmwareContent('firmware.bin', '')).toThrow('必须是二进制')
|
||||
expect(() => parseFirmwareContent('firmware.bin', new Uint8Array())).toThrow('文件为空')
|
||||
})
|
||||
})
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
:0400000001020304F2
|
||||
:02001000AABB89
|
||||
:00000001FF
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeIcoSizes, packIco } from '../src/lib/ico'
|
||||
import {
|
||||
MAX_PNG_DIMENSION,
|
||||
MAX_PNG_FILE_SIZE,
|
||||
MAX_PNG_PIXELS,
|
||||
parsePngHeader,
|
||||
PNG_HEADER_LENGTH,
|
||||
PNG_SIGNATURE,
|
||||
} from '../src/lib/png'
|
||||
|
||||
function pngHeader(width: number, height: number): Uint8Array {
|
||||
const bytes = new Uint8Array(PNG_HEADER_LENGTH)
|
||||
bytes.set(PNG_SIGNATURE)
|
||||
const view = new DataView(bytes.buffer)
|
||||
view.setUint32(8, 13, false)
|
||||
bytes.set([0x49, 0x48, 0x44, 0x52], 12)
|
||||
view.setUint32(16, width, false)
|
||||
view.setUint32(20, height, false)
|
||||
bytes.set([8, 6, 0, 0, 0], 24)
|
||||
return bytes
|
||||
}
|
||||
|
||||
describe('packIco', () => {
|
||||
it('writes a valid ICO directory with PNG frame offsets', () => {
|
||||
const first = Uint8Array.from([1, 2, 3])
|
||||
const second = Uint8Array.from([4, 5, 6, 7])
|
||||
const output = packIco([{ size: 32, data: first }, { size: 256, data: second }])
|
||||
const view = new DataView(output.buffer)
|
||||
|
||||
expect(view.getUint16(0, true)).toBe(0)
|
||||
expect(view.getUint16(2, true)).toBe(1)
|
||||
expect(view.getUint16(4, true)).toBe(2)
|
||||
expect(view.getUint8(6)).toBe(32)
|
||||
expect(view.getUint8(22)).toBe(0)
|
||||
expect(view.getUint32(14, true)).toBe(3)
|
||||
expect(view.getUint32(18, true)).toBe(38)
|
||||
expect(view.getUint32(34, true)).toBe(41)
|
||||
expect(Array.from(output.slice(38))).toEqual([1, 2, 3, 4, 5, 6, 7])
|
||||
})
|
||||
|
||||
it('rejects missing or invalid frames', () => {
|
||||
expect(() => packIco([])).toThrow('至少')
|
||||
expect(() => packIco([{ size: 512, data: Uint8Array.of(1) }])).toThrow('尺寸无效')
|
||||
})
|
||||
|
||||
it('normalizes sizes before allocating canvases', () => {
|
||||
expect(normalizeIcoSizes([256, 16, 16, 32])).toEqual([16, 32, 256])
|
||||
expect(() => normalizeIcoSizes([])).toThrow('至少')
|
||||
expect(() => normalizeIcoSizes([0])).toThrow('尺寸无效')
|
||||
expect(() => normalizeIcoSizes([16.5])).toThrow('尺寸无效')
|
||||
expect(() => normalizeIcoSizes(Array.from({ length: 256 }, () => 16))).toThrow('数量过多')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PNG header validation', () => {
|
||||
it('reads valid IHDR dimensions', () => {
|
||||
expect(parsePngHeader(pngHeader(512, 256))).toEqual({ width: 512, height: 256, pixels: 131_072 })
|
||||
})
|
||||
|
||||
it('rejects invalid signatures, truncated headers and missing IHDR chunks', () => {
|
||||
expect(() => parsePngHeader(new Uint8Array(PNG_HEADER_LENGTH))).toThrow('有效的 PNG')
|
||||
expect(() => parsePngHeader(pngHeader(1, 1).slice(0, 24), PNG_HEADER_LENGTH)).toThrow('文件头不完整')
|
||||
const missingIhdr = pngHeader(1, 1)
|
||||
missingIhdr[12] = 0
|
||||
expect(() => parsePngHeader(missingIhdr)).toThrow('IHDR')
|
||||
})
|
||||
|
||||
it('enforces compressed size, dimensions and decoded pixel limits', () => {
|
||||
expect(() => parsePngHeader(pngHeader(1, 1), MAX_PNG_FILE_SIZE + 1)).toThrow('32 MiB')
|
||||
expect(() => parsePngHeader(pngHeader(MAX_PNG_DIMENSION + 1, 1))).toThrow('宽高')
|
||||
expect(() => parsePngHeader(pngHeader(8_000, 4_001))).toThrow('总像素')
|
||||
expect(() => parsePngHeader(pngHeader(0, 1))).toThrow('尺寸无效')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeNavigation,
|
||||
validateNavigation,
|
||||
writeJsonAtomic,
|
||||
} from '../server/navigation.mjs'
|
||||
|
||||
const temporaryDirectories = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
const catalog = {
|
||||
categories: [{ id: 'encoding' }, { id: 'crypto' }],
|
||||
tools: [
|
||||
{ id: 'hex', category: 'encoding' },
|
||||
{ id: 'base64', category: 'encoding' },
|
||||
{ id: 'cmac', category: 'crypto' },
|
||||
],
|
||||
}
|
||||
const defaults = {
|
||||
version: 1,
|
||||
categories: [
|
||||
{ id: 'encoding', tools: ['hex', 'base64'] },
|
||||
{ id: 'crypto', tools: ['cmac'] },
|
||||
],
|
||||
}
|
||||
|
||||
describe('navigation config', () => {
|
||||
it('preserves custom order and appends tools added in a later release', () => {
|
||||
const current = { version: 1, categories: [{ id: 'encoding', tools: ['base64'] }] }
|
||||
expect(normalizeNavigation(current, catalog, defaults)).toEqual({
|
||||
version: 1,
|
||||
categories: [
|
||||
{ id: 'encoding', tools: ['base64', 'hex'] },
|
||||
{ id: 'crypto', tools: ['cmac'] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate or incomplete configurations', () => {
|
||||
expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex', 'hex'] }] }, catalog)).toThrow('重复')
|
||||
expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] }, catalog)).toThrow('全部')
|
||||
})
|
||||
|
||||
it('writes valid JSON through an atomic replacement', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-navigation-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const filePath = path.join(directory, 'navigation.json')
|
||||
await writeJsonAtomic(filePath, defaults)
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(defaults)
|
||||
})
|
||||
|
||||
it('closes the handle and removes the temporary file when writing fails', async () => {
|
||||
const calls = []
|
||||
const failure = new Error('disk full')
|
||||
const operations = {
|
||||
mkdir: async () => undefined,
|
||||
open: async (temporaryPath) => ({
|
||||
writeFile: async () => { throw failure },
|
||||
sync: async () => undefined,
|
||||
close: async () => { calls.push('close') },
|
||||
temporaryPath,
|
||||
}),
|
||||
rename: async () => { calls.push('rename') },
|
||||
unlink: async (temporaryPath) => { calls.push(`unlink:${temporaryPath}`) },
|
||||
}
|
||||
await expect(writeJsonAtomic('/virtual/navigation.json', defaults, operations)).rejects.toBe(failure)
|
||||
expect(calls[0]).toBe('close')
|
||||
expect(calls[1]).toMatch(/^unlink:\/virtual\/navigation\.json\..+\.tmp$/)
|
||||
expect(calls).not.toContain('rename')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
moveCategoryBy,
|
||||
moveToolBy,
|
||||
moveToolToCategory,
|
||||
relocateTool,
|
||||
reorderCategory,
|
||||
} from '../src/lib/navigationOrder'
|
||||
import type { NavigationConfig } from '../src/types'
|
||||
|
||||
const navigation: NavigationConfig = {
|
||||
version: 1,
|
||||
categories: [
|
||||
{ id: 'one', tools: ['a', 'b', 'c'] },
|
||||
{ id: 'two', tools: ['d', 'e'] },
|
||||
{ id: 'three', tools: [] },
|
||||
],
|
||||
}
|
||||
|
||||
describe('navigation ordering', () => {
|
||||
it('moves categories upward and downward without mutating the input', () => {
|
||||
expect(moveCategoryBy(navigation, 0, 1).categories.map(({ id }) => id)).toEqual(['two', 'one', 'three'])
|
||||
expect(reorderCategory(navigation, 2, 0).categories.map(({ id }) => id)).toEqual(['three', 'one', 'two'])
|
||||
expect(navigation.categories.map(({ id }) => id)).toEqual(['one', 'two', 'three'])
|
||||
})
|
||||
|
||||
it('moves tools upward and downward within a category', () => {
|
||||
expect(moveToolBy(navigation, 0, 0, 1).categories[0].tools).toEqual(['b', 'a', 'c'])
|
||||
expect(moveToolBy(navigation, 0, 2, -1).categories[0].tools).toEqual(['a', 'c', 'b'])
|
||||
})
|
||||
|
||||
it('moves tools across categories and appends them at the end', () => {
|
||||
const crossList = relocateTool(navigation, { categoryIndex: 0, toolIndex: 1 }, { categoryIndex: 1, toolIndex: 1 })
|
||||
expect(crossList.categories[0].tools).toEqual(['a', 'c'])
|
||||
expect(crossList.categories[1].tools).toEqual(['d', 'b', 'e'])
|
||||
|
||||
const appended = moveToolToCategory(navigation, 'a', 0, 'three')
|
||||
expect(appended.categories[0].tools).toEqual(['b', 'c'])
|
||||
expect(appended.categories[2].tools).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('clamps end insertion and ignores invalid source positions', () => {
|
||||
expect(relocateTool(navigation, { categoryIndex: 0, toolIndex: 0 }, { categoryIndex: 1, toolIndex: 99 }).categories[1].tools).toEqual(['d', 'e', 'a'])
|
||||
expect(relocateTool(navigation, { categoryIndex: 9, toolIndex: 0 }, { categoryIndex: 1, toolIndex: 0 })).toEqual(navigation)
|
||||
expect(moveCategoryBy(navigation, 0, -1)).toEqual(navigation)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatBytes } from '../src/lib/bytes'
|
||||
import { secureRandomBytes, type RandomSource } from '../src/lib/random'
|
||||
|
||||
describe('secure random bytes', () => {
|
||||
it('uses the supplied cryptographic source and preserves requested length', () => {
|
||||
let next = 0
|
||||
const source: RandomSource = {
|
||||
getRandomValues<T extends ArrayBufferView>(array: T): T {
|
||||
const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength)
|
||||
bytes.forEach((_, index) => { bytes[index] = next; next += 1 })
|
||||
return array
|
||||
},
|
||||
}
|
||||
expect(secureRandomBytes(4, source)).toEqual(Uint8Array.from([0, 1, 2, 3]))
|
||||
expect(() => secureRandomBytes(0, source)).toThrow('1 至 65536')
|
||||
})
|
||||
|
||||
it('formats the same bytes for OpenSSL and source-code styles', () => {
|
||||
const bytes = Uint8Array.from([0x0a, 0xbc, 0xff])
|
||||
expect(formatBytes(bytes)).toBe('0ABCFF')
|
||||
expect(formatBytes(bytes, { format: 'spaced-hex' })).toBe('0A BC FF')
|
||||
expect(formatBytes(bytes, { format: 'byte-array', uppercase: false })).toBe('0x0a, 0xbc, 0xff')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatBytes, parseHex } from '../src/lib/bytes'
|
||||
import { base64UrlToHex, formatPem, generateRsaKeyPair } from '../src/lib/rsa'
|
||||
|
||||
describe('RSA key generation', () => {
|
||||
it('formats DER and JWK integer values', () => {
|
||||
expect(formatPem(Uint8Array.from([0, 1, 2, 3]), 'PUBLIC KEY')).toBe('-----BEGIN PUBLIC KEY-----\nAAECAw==\n-----END PUBLIC KEY-----\n')
|
||||
expect(base64UrlToHex('AQAB')).toBe('010001')
|
||||
})
|
||||
|
||||
it('generates an exportable 2048-bit key pair using a cryptographic provider', async () => {
|
||||
const result = await generateRsaKeyPair(2048)
|
||||
expect(result.publicKeyPem).toMatch(/^-----BEGIN PUBLIC KEY-----/)
|
||||
expect(result.privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/)
|
||||
expect(result.modulusHex).toHaveLength(512)
|
||||
expect(result.exponentHex).toBe('010001')
|
||||
expect(formatBytes(parseHex(result.exponentHex), { format: 'byte-array' })).toBe('0x01, 0x00, 0x01')
|
||||
expect(['Web Crypto', 'node-forge']).toContain(result.source)
|
||||
}, 20_000)
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createWebToolsServer, MAX_JSON_BODY_SIZE } from '../server/index.mjs'
|
||||
|
||||
const temporaryDirectories = []
|
||||
const servers = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))))
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
const catalog = {
|
||||
categories: [{ id: 'encoding' }, { id: 'crypto' }],
|
||||
tools: [
|
||||
{ id: 'hex', category: 'encoding' },
|
||||
{ id: 'cmac', category: 'crypto' },
|
||||
],
|
||||
}
|
||||
const defaults = {
|
||||
version: 1,
|
||||
categories: [
|
||||
{ id: 'encoding', tools: ['hex'] },
|
||||
{ id: 'crypto', tools: ['cmac'] },
|
||||
],
|
||||
}
|
||||
|
||||
async function createFixtureServer() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-server-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const staticRoot = path.join(directory, 'dist')
|
||||
const dataDir = path.join(directory, 'data')
|
||||
await writeFile(path.join(directory, 'placeholder'), '')
|
||||
await mkdir(staticRoot, { recursive: true })
|
||||
await writeFile(path.join(staticRoot, 'index.html'), '<!doctype html><title>Web Tools</title>')
|
||||
await writeFile(path.join(staticRoot, 'app.js'), 'console.log("ok")')
|
||||
const server = await createWebToolsServer({ staticRoot, dataDir, catalog, defaults, logger: { error: () => undefined } })
|
||||
servers.push(server)
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
return { baseUrl: `http://127.0.0.1:${address.port}`, dataDir }
|
||||
}
|
||||
|
||||
describe('HTTP server', () => {
|
||||
it('serves direct SPA navigation but returns 404 for missing assets', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const page = await fetch(`${baseUrl}/tools/base64`, { headers: { Accept: 'text/html' } })
|
||||
expect(page.status).toBe(200)
|
||||
expect(await page.text()).toContain('<title>Web Tools</title>')
|
||||
|
||||
const asset = await fetch(`${baseUrl}/assets/definitely-missing.js`)
|
||||
expect(asset.status).toBe(404)
|
||||
expect(asset.headers.get('content-type')).toContain('text/plain')
|
||||
|
||||
const extensionlessAsset = await fetch(`${baseUrl}/assets/chunk`, { headers: { Accept: 'application/javascript' } })
|
||||
expect(extensionlessAsset.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns no-store JSON health and API responses', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const health = await fetch(`${baseUrl}/healthz`)
|
||||
expect(await health.json()).toEqual({ status: 'ok' })
|
||||
expect(health.headers.get('cache-control')).toBe('no-store')
|
||||
|
||||
const missingApi = await fetch(`${baseUrl}/api/missing`)
|
||||
expect(missingApi.status).toBe(404)
|
||||
expect(missingApi.headers.get('cache-control')).toBe('no-store')
|
||||
})
|
||||
|
||||
it('creates, reads and updates navigation through the API', async () => {
|
||||
const { baseUrl, dataDir } = await createFixtureServer()
|
||||
const initial = await fetch(`${baseUrl}/api/navigation`)
|
||||
expect(await initial.json()).toEqual(defaults)
|
||||
|
||||
const updated = { version: 1, categories: [...defaults.categories].reverse() }
|
||||
const saved = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
})
|
||||
expect(saved.status).toBe(200)
|
||||
expect(await saved.json()).toEqual(updated)
|
||||
expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual(updated)
|
||||
})
|
||||
|
||||
it('rejects invalid, oversized and cross-site navigation writes', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const invalid = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{invalid',
|
||||
})
|
||||
expect(invalid.status).toBe(400)
|
||||
|
||||
const oversized = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: ' '.repeat(MAX_JSON_BODY_SIZE + 1),
|
||||
})
|
||||
expect(oversized.status).toBe(400)
|
||||
expect((await oversized.json()).error).toContain('过大')
|
||||
|
||||
const crossSite = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'cross-site' },
|
||||
body: JSON.stringify(defaults),
|
||||
})
|
||||
expect(crossSite.status).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_HIGHLIGHTED_SYNTAX_LENGTH,
|
||||
MAX_HIGHLIGHTED_SYNTAX_TOKENS,
|
||||
tokenizeJson,
|
||||
tokenizeSyntaxForDisplay,
|
||||
tokenizeXml,
|
||||
} from '../src/lib/syntax'
|
||||
|
||||
describe('syntax tokenizers', () => {
|
||||
it('classifies JSON tokens without changing content', () => {
|
||||
const source = '{\n "name": "Web Tools",\n "count": 3,\n "local": true,\n "value": null\n}'
|
||||
const tokens = tokenizeJson(source)
|
||||
expect(tokens.map((token) => token.text).join('')).toBe(source)
|
||||
expect(tokens.some((token) => token.type === 'key' && token.text === '"name"')).toBe(true)
|
||||
expect(tokens.some((token) => token.type === 'string' && token.text === '"Web Tools"')).toBe(true)
|
||||
expect(tokens.some((token) => token.type === 'number' && token.text === '3')).toBe(true)
|
||||
expect(tokens.some((token) => token.type === 'literal' && token.text === 'true')).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies XML tags, attributes, values and comments without changing content', () => {
|
||||
const source = '<?xml version="1.0"?>\n<!-- tools -->\n<tools><tool id="hex">ASCII</tool></tools>'
|
||||
const tokens = tokenizeXml(source)
|
||||
expect(tokens.map((token) => token.text).join('')).toBe(source)
|
||||
expect(tokens.some((token) => token.type === 'tag' && token.text === 'tool')).toBe(true)
|
||||
expect(tokens.some((token) => token.type === 'attribute' && token.text === 'id')).toBe(true)
|
||||
expect(tokens.some((token) => token.type === 'value' && token.text === '"hex"')).toBe(true)
|
||||
expect(tokens.some((token) => token.type === 'comment')).toBe(true)
|
||||
expect(tokens.some((token) => token.type === 'meta')).toBe(true)
|
||||
})
|
||||
|
||||
it('uses plain text for results that would create excessive DOM nodes', () => {
|
||||
expect(tokenizeSyntaxForDisplay('{"ok":true}', 'json')).toBeDefined()
|
||||
expect(tokenizeSyntaxForDisplay('x'.repeat(MAX_HIGHLIGHTED_SYNTAX_LENGTH + 1), 'json')).toBeUndefined()
|
||||
const tokenHeavy = Array.from({ length: Math.floor(MAX_HIGHLIGHTED_SYNTAX_TOKENS / 2) + 1 }, () => '<a/>').join('')
|
||||
expect(tokenHeavy.length).toBeLessThan(MAX_HIGHLIGHTED_SYNTAX_LENGTH)
|
||||
expect(tokenizeSyntaxForDisplay(tokenHeavy, 'xml')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@shared/*": ["shared/*"]
|
||||
},
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2023"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@shared': fileURLToPath(new URL('./shared', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8081',
|
||||
'/healthz': 'http://127.0.0.1:8081',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@shared': fileURLToPath(new URL('./shared', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.{ts,mjs}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user