初始化 ToolBox 开发者工具箱。
CI / docker (push) Failing after 2m23s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-24 15:29:36 +08:00
commit ee2a2e6bb2
66 changed files with 17973 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules
dist
dist-ssr
.git
.gitea
.cursor
*.log
.DS_Store
.vscode
.idea
coverage
*.md
!README.md
+11
View File
@@ -0,0 +1,11 @@
# 站点标题(浏览器标签、导航栏)
VITE_APP_TITLE=RC707的工具箱
# 备案号(留空则不显示;Docker 运行时可用 APP_ICP= 隐藏)
VITE_APP_ICP=苏ICP备2022013040号-1
# Google Analytics 测量 ID(留空则不加载,避免向 Google 上报访问数据)
# VITE_GA_MEASUREMENT_ID=G-C2H4BGZJBD
# 今日诗词 SDK 地址(留空则不加载,避免向第三方上报访问数据)
# VITE_JINRISHICI_SDK_URL=https://sdk.jinrishici.com/v2/browser/jinrishici.js
+182
View File
@@ -0,0 +1,182 @@
# Gitea Actions CI 模板(Go + Vue
复制整个 `.gitea/` 目录到新仓库即可启用 CI:**可选 go test** + **Docker 构建/推送**Go 编译与 Vue 构建在 Dockerfile 内完成)。
## 项目约定
| 路径 | 说明 |
|------|------|
| `go.mod` | 仓库根目录 |
| `Dockerfile` | 仓库根目录,多阶段构建(含前端 `web/` |
| `web/` | 可选;由 Dockerfile 内 `npm run build` 处理,CI 不再单独构建 |
## Dockerfile 要求
工作流的 **Build image** 步骤在仓库根目录(或 Variable `DOCKERFILE` 指定路径)执行 `docker buildx build`**不会**在 CI 里单独装 Node / 跑 `npm`。因此 Dockerfile 必须能独立完成构建与(运行时)启动。
### 基本要求
| 项 | 要求 |
|----|------|
| 位置 | 默认仓库根目录 `Dockerfile`;其他路径用 Variable `DOCKERFILE` |
| 构建上下文 | 仓库根目录 `.`(整个仓库会被 `COPY` 进镜像) |
| Go 版本 | `go.mod``go` 行与 `golang` 基础镜像大版本一致 |
| 多架构 | `go build` 须使用 `GOARCH="$TARGETARCH"`buildx 注入);推荐 `CGO_ENABLED=0` |
| Go 模块代理 | **必须**在镜像内显式设置 `GOPROXY` / `GOSUMDB`(见下);容器内不会自动读取 `go.env` |
| 基础镜像加速 | 支持构建参数 `IMAGE_PREFIX`CI 默认 `docker.1panel.live/library/` |
| 前端(可选) | 存在 `web/` 时在 Dockerfile 内完成 `npm ci` + `npm run build` |
### CI 自动传入的构建参数
| 参数 | 默认 | 说明 |
|------|------|------|
| `IMAGE_PREFIX` | `docker.1panel.live/library/` | 基础镜像前缀;`hub` 表示 Docker Hub |
| `GOPROXY` | `https://goproxy.cn,direct` | 与 workflow / Variable 一致 |
| `GOSUMDB` | `sum.golang.google.cn` | checksum 数据库 |
buildx 还会注入 `TARGETARCH``BUILDPLATFORM` 等,无需在 workflow 里写。
### 推荐:Go + Vue 多阶段模板
```dockerfile
# syntax=docker/dockerfile:1
ARG IMAGE_PREFIX=
# 1) 前端(无 web/ 时可删整个 stage,并去掉 go-builder 里 COPY dist
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}node:20-alpine AS web-builder
WORKDIR /src/web
COPY web/package.json web/package-lock.json web/.npmrc ./
RUN npm ci
COPY web/ ./
RUN npm run build
# 2) Go 编译
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder
ARG TARGETARCH
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
# 若静态资源嵌入 GoCOPY --from=web-builder /src/web/dist/ ./path/to/static/
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" \
go build -ldflags="-w -s" -o /app ./cmd/yourapp
# 3) 运行镜像
FROM ${IMAGE_PREFIX}alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=go-builder /app /usr/local/bin/yourapp
ENTRYPOINT ["/usr/local/bin/yourapp"]
```
按项目调整:`./cmd/yourapp`、静态资源路径、运行用户、`EXPOSE` / `VOLUME` 等。
### 仅 Go(无前端)
删除 `web-builder` stage`go-builder` 中不要 `COPY` 前端产物;其余 `GOPROXY` / `TARGETARCH` / `IMAGE_PREFIX` 要求相同。
### 前端 npm 源(国内)
`web/.npmrc` 配置 registry,并在 Dockerfile 里与 `package.json` 一并 `COPY`
```ini
registry=https://registry.npmmirror.com
```
### 本地验证(与 CI 一致)
```bash
docker buildx build --platform linux/amd64 \
--build-arg IMAGE_PREFIX=docker.1panel.live/library/ \
--build-arg GOPROXY=https://goproxy.cn,direct \
-f Dockerfile -t myapp:test .
```
### 常见 Dockerfile 构建错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `proxy.golang.org` timeout | 镜像内未设 `ENV GOPROXY` | go-builder 阶段加 `ARG`/`ENV GOPROXY` |
| `node` / Vite 版本不符 | 基础镜像 Node 过旧 | 使用 `node:20-alpine` 及以上 |
| 某架构 build 失败 | 未使用 `TARGETARCH` | `GOARCH="$TARGETARCH"` |
| 拉基础镜像 timeout | Hub 不可达 | `--build-arg IMAGE_PREFIX=docker.1panel.live/library/` 或 1Panel 配镜像加速 |
## 一次性配置
1. **Runner**:部署 act_runner,标签含 `ubuntu-latest`,并挂载 `docker.sock`(见 [act-runner/README.md](act-runner/README.md))。
2. **Secret**:仓库 Settings → Actions → Secrets,添加 `REGISTRY_TOKEN`PAT`write:package` 权限)。
3. **Variable(推荐)**:若 runner 与 Gitea 同机、或 `gitea.server_url` 为内网地址,必须设置 `REGISTRY=你的公网域名`(如 `git.example.com`**不要**填 `172.17.0.1:13827`)。
4. **Gitea Registry**:服务端 `[packages] ENABLED = true``ROOT_URL` 正确;穿透场景建议 `PUBLIC_URL_DETECTION = never`Gitea 1.26+)。
`REGISTRY` 未设置时,会从 `GITEA_ROOT_URL``gitea.server_url` 推断;均为内网地址时 workflow 会提前失败并提示。
## 可选 Variables
仓库 Settings → Actions → Variables(留空则用默认值):
| 变量 | 默认 | 说明 |
|------|------|------|
| `REGISTRY` | 见下方推断顺序 | **公网** Registry 主机名,如 `git.example.com`(勿用内网 IP:13827 |
| `GITEA_ROOT_URL` | (空) | 当 `gitea.server_url` 为内网时,可设 `https://git.example.com/` |
| `IMAGE_NAME` | 仓库名小写 | 镜像名,非 owner/repo 全路径 |
| `DOCKERFILE` | `Dockerfile` | Dockerfile 路径 |
| `DOCKER_PLATFORMS` | `linux/amd64,linux/arm64` | push 时 buildx 平台 |
| `GO_TEST_SCOPE` | `./...` | `go test` 包路径 |
| `RUN_GO_TEST` | (空,即运行) | 设 `false` 跳过 go test,仅 Docker 构建 |
| `DOCKER_IMAGE_PREFIX` | `1panel` | 基础镜像前缀;可选 `hub` / `daocloud` |
| `GOPROXY` | `https://goproxy.cn,direct` | Go 模块代理 |
| `GOSUMDB` | `sum.golang.google.cn` | Go checksum 数据库 |
## 触发与镜像 tag
- **pull_request**go test(可关)+ 单架构 `docker build` 验证(不推送)
- **push main/master**go test + 多架构构建并推送 `:latest``:sha-xxxxxxx`
- **push tag v\***:额外推送 `:v1.2.3`
示例(仓库 `rose_cat707/Prism`Gitea 在 `git.example.com`):
```text
git.example.com/rose_cat707/prism:latest
git.example.com/rose_cat707/prism:sha-35b3b48
```
## 目录结构
```text
.gitea/
├── README.md # 本文件
├── workflows/
│ └── ci.yml # 主工作流
└── act-runner/ # runner 部署参考(可选)
├── README.md
├── config.yaml
├── docker-compose.yml
└── .env.example
```
## 本地验证 Registry
```bash
host=$(echo "https://你的-gitea-地址/" | sed -e 's|^https://||' -e 's|/.*||')
curl -s -D - "https://${host}/v2/" -o /dev/null | grep -i www-authenticate
docker pull "${host}/owner/image:latest" # 公开 Registry 无需 login
```
推送镜像(CI)仍需仓库 Secret `REGISTRY_TOKEN``write:package`)。仅拉取公开包不需要登录。
`realm` 应指向公网 Gitea 域名,而非 `127.0.0.1`
## 常见 Registry 错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `Get "https://172.17.0.1:13827/v2/"` HTTP/HTTPS | Variable `REGISTRY``gitea.server_url` 为内网地址 | 设 `REGISTRY=git.example.com` |
| token 指向 `127.0.0.1` | Gitea `realm` 配置错误 | `PUBLIC_URL_DETECTION=never` + 正确 `ROOT_URL` |
+3
View File
@@ -0,0 +1,3 @@
GITEA_INSTANCE_URL=https://git.example.com
GITEA_RUNNER_REGISTRATION_TOKEN=从仓库_Settings_Actions_Runners_复制
GITEA_RUNNER_NAME=go-vue-ci-runner
+177
View File
@@ -0,0 +1,177 @@
# act_runner 配置参考
工作流 `runs-on: ubuntu-latest``docker` job 额外使用 `catthehacker/ubuntu:act-22.04` 容器(含 Docker CLI)。
## 构建镜像必须:挂载 Docker Socket
`docker` job 需要访问宿主机 Docker 引擎。在 **runner 所在机器**`config.yaml` 中配置:
```yaml
container:
options: -v /var/run/docker.sock:/var/run/docker.sock
valid_volumes:
- /var/run/docker.sock
```
推荐 job 镜像(含 Node + Docker CLI):
```yaml
runner:
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
```
修改后 **重启 runner**(例如 `docker restart <runner容器>` 或重启 Gitea Runner 服务)。
## 1Panel 宿主机:配置 Docker 镜像加速(推荐)
Runner 通过 `docker.sock` 使用宿主机 Docker。在 **1Panel** 中配置加速器后,普通 `docker pull` 会走加速;**buildx 多架构构建**仍建议配合 workflow 内的 `IMAGE_PREFIX`(默认 `docker.1panel.live`)。
1. 登录 1Panel → **容器****配置**
2. **镜像加速地址** 填入:
```text
https://docker.1panel.live
```
3. 保存并 **重启 Docker**
验证(在 runner 宿主机):
```bash
docker info | grep -A5 'Registry Mirrors'
docker pull alpine:3.20
```
等价 `daemon.json`
```json
{
"registry-mirrors": ["https://docker.1panel.live"]
}
```
CI 工作流默认 `IMAGE_PREFIX=docker.1panel.live/library/`buildkit 拉基础镜像)。若 1Panel 加速不可用,可在仓库 Variables 设 `DOCKER_IMAGE_PREFIX=daocloud` 或 `hub`。
参考:[1Panel 容器配置文档](https://1panel.cn/docs/user_manual/containers/setting)
## 验证
在 runner 宿主机执行:
```bash
docker info
ls -l /var/run/docker.sock
```
## 从零部署 runner(可选)
```bash
cd .gitea/act-runner
cp .env.example .env # 填入 Registration Token
docker compose up -d
```
## GitHub Actions 镜像(替代 ghfast
日志里出现 `git clone 'https://ghfast.top/https://github.com/actions/checkout'` 说明 **runner 宿主机** 的 `config.yaml` 配置了 `github_mirror`,与仓库 workflow 无关。
在 runner 的 `config.yaml` 中修改(修改后重启 runner):
```yaml
runner:
github_mirror: 'https://gitea.com' # 推荐
```
常用替代方案:
| `github_mirror` 值 | 说明 |
|------------------|------|
| `''`(留空) | 直连 `github.com`,网络可达时最简单 |
| `https://gitea.com` | Gitea 官方 actions 镜像,国内较稳 |
| `https://gitclone.com/github.com` | 第三方 GitHub 克隆镜像 |
| `https://ghfast.top/https://github.com` | 部分环境需代理认证,易报 `Proxy Authentication Required` |
前提:Gitea `app.ini` 中 `[actions] DEFAULT_ACTIONS_URL = github`(默认)。
**不依赖 runner 镜像** 的写法(workflow 内写 GitHub 绝对 URL,直连 `github.com`):
```yaml
uses: https://github.com/actions/checkout@v4
```
本模板 `ci.yml` 已采用此写法。若 runner 配置了 `github_mirror`,写绝对 `https://github.com/...` 仍会直连 GitHub,不受镜像影响。
修改 `github_mirror` 后建议清理 runner 缓存目录(如 `/root/.cache/act`),否则旧缓存可能仍指向 ghfast。
## 常见错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `registry-1.docker.io` i/o timeout | Docker Hub 不可达 | 1Panel 配 `docker.1panel.live`Variable `DOCKER_IMAGE_PREFIX=1panel` |
| `ghfast.top` Proxy Authentication Required | runner `github_mirror` 指向 ghfast 且需代理认证 | 改 `github_mirror` 或删掉;见上文「GitHub Actions 镜像」 |
| `docker: command not found` | job 容器无 Docker CLI | 工作流已指定 act 镜像;或 runner 改用 catthehacker/ubuntu |
| `Cannot connect to Docker daemon` | 未挂载 docker.sock | 按上文修改 config.yaml 并重启 runner |
| `node not in PATH` | job 镜像无 Node | 标签映射改用 catthehacker/ubuntu:act-22.04 |
| `http: server gave HTTP response to HTTPS client` 且 token 指向 `127.0.0.1` | **Gitea Registry 配置/反代错误** | 见下文「Registry 登录失败」 |
## Registry 登录失败(127.0.0.1 / HTTP vs HTTPS
若 `docker login git.rc707blog.top` 报错类似:
```text
Get "https://127.0.0.1:xxxxx/v2/token?...": http: server gave HTTP response to HTTPS client
```
说明 Gitea 把 **Docker 认证 token 地址** 配成了本机内网地址,CI runner 访问不到。需在 **Gitea 服务器** 修复,而非改 workflow。
### 1. 检查 `app.ini`
```ini
[server]
ROOT_URL = https://git.example.com/
LOCAL_ROOT_URL = http://127.0.0.1:3000/
; 内网穿透 / 错误 Host 时(Gitea 1.26+):
; PUBLIC_URL_DETECTION = never
[packages]
ENABLED = true
```
`ROOT_URL` 必须与浏览器访问 Gitea 的 **HTTPS 外网地址** 完全一致(含末尾 `/`)。
### 2. 反向代理必须转发 `/v2` 并带上头
Container Registry 固定使用根路径 `/v2`。Nginx 示例:
```nginx
location / {
client_max_body_size 0;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
关键:`X-Forwarded-Proto: https` 和正确的 `Host`(与 Gitea `ROOT_URL` 域名一致)。
### 3. 在 runner 宿主机验证(修复后)
```bash
GITEA_HOST=git.example.com # 改成你的 Gitea 域名
curl -s -D - "https://${GITEA_HOST}/v2/" -o /dev/null | grep -i www-authenticate
echo "$REGISTRY_TOKEN" | docker login "${GITEA_HOST}" -u 你的用户名 --password-stdin
```
应返回 `401 Unauthorized`(正常,表示 registry 可达)且 `docker login` 显示 **Login Succeeded**。
参考:[Gitea 反向代理文档](https://docs.gitea.com/administration/reverse-proxies)
## Gitea Runner v0.6.x(个人 runner
1. 找到 runner 的配置文件或环境(安装目录 / docker compose
2. 确保 runner 进程能访问宿主机 `/var/run/docker.sock`
3. Runners 页标签含 `ubuntu-latest` 且状态 **空闲/在线**
若使用 Gitea 网页注册的个人 runnerdocker 模式),通常需在 runner 启动参数或 `config.yaml` 里加入 socket 挂载,具体路径取决于你的安装方式。
+34
View File
@@ -0,0 +1,34 @@
# act_runner 配置(可选参考部署)
# 工作流 runs-on: ubuntu-latest
log:
level: info
runner:
file: .runner
capacity: 2
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
# 拉取 uses: actions/checkout@v4 等 GitHub Action 时的镜像(替换 https://github.com
# 需 Gitea app.ini 中 [actions] DEFAULT_ACTIONS_URL = github
# 留空则直连 github.com;第三方镜像不稳定时可改用 workflow 绝对 URL(见 .gitea/README.md
#
github_mirror: '' # 直连 GitHub(本仓库 ci.yml 已用 github.com 绝对 URL
# github_mirror: 'https://gitea.com' # 可选:Gitea 官方 actions 镜像
# github_mirror: 'https://gitclone.com/github.com' # 第三方 GitHub 克隆镜像
# github_mirror: 'https://ghfast.top/https://github.com' # 需代理认证时易失败,不推荐
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
cache:
enabled: false
container:
network: bridge
privileged: false
options: -v /var/run/docker.sock:/var/run/docker.sock
valid_volumes:
- /var/run/docker.sock
+22
View File
@@ -0,0 +1,22 @@
# Gitea act_runner — 可选参考部署(标签 default,与工作流一致)
#
# 若已在 Gitea 注册个人/仓库 runner 且标签为 default,无需使用本目录。
services:
act-runner:
image: docker.io/gitea/act_runner:0.2.12
container_name: prism-act-runner
restart: unless-stopped
environment:
CONFIG_FILE: /config.yaml
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.rc707blog.top}
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-prism-ci-runner}
volumes:
- ./config.yaml:/config.yaml:ro
- act-runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
working_dir: /data
volumes:
act-runner-data:
+219
View File
@@ -0,0 +1,219 @@
# ToolBoxVue 前端 CIGitea Actions
#
# 单 jobnpm test + Docker 多架构构建/推送
# 约定:Dockerfile 在仓库根目录
#
# 前置:act_runnerubuntu-latest + docker.sock)、Secret REGISTRY_TOKEN
# VariablesREGISTRY、IMAGE_NAME、DOCKER_IMAGE_PREFIX、RUN_NPM_TEST 等
name: CI
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
env:
REGISTRY: ${{ vars.REGISTRY }}
GITEA_ROOT_URL: ${{ vars.GITEA_ROOT_URL }}
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
DOCKERFILE: ${{ vars.DOCKERFILE }}
DOCKER_PLATFORMS: ${{ vars.DOCKER_PLATFORMS }}
RUN_NPM_TEST: ${{ vars.RUN_NPM_TEST }}
DOCKER_IMAGE_PREFIX: ${{ vars.DOCKER_IMAGE_PREFIX }}
jobs:
docker:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-22.04
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Setup Docker
run: |
set -eux
if ! command -v docker >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io
fi
if [ ! -S /var/run/docker.sock ]; then
echo "ERROR: /var/run/docker.sock 未挂载到 job 容器。" >&2
echo "请在 act_runner config.yaml 中配置:" >&2
echo " container.options: -v /var/run/docker.sock:/var/run/docker.sock" >&2
echo " container.valid_volumes: [/var/run/docker.sock]" >&2
exit 1
fi
docker info
- name: Run npm tests
if: vars.RUN_NPM_TEST != 'false'
shell: bash
run: |
set -euo pipefail
if [ "${DOCKER_IMAGE_PREFIX:-}" = "hub" ] || [ "${DOCKER_IMAGE_PREFIX:-}" = "docker.io" ]; then
image_prefix=""
elif [ -z "${DOCKER_IMAGE_PREFIX:-}" ] || [ "${DOCKER_IMAGE_PREFIX}" = "1panel" ]; then
image_prefix="docker.1panel.live/library/"
elif [ "${DOCKER_IMAGE_PREFIX}" = "daocloud" ]; then
image_prefix="docker.m.daocloud.io/library/"
else
image_prefix="${DOCKER_IMAGE_PREFIX}"
[[ "${image_prefix}" == */ ]] || image_prefix="${image_prefix}/"
fi
node_image="${image_prefix}node:20-alpine"
echo "running npm test in ${node_image}"
docker run --rm \
-v "${PWD}:/src" \
-w /src \
"${node_image}" \
sh -c "npm install && npm run test:run"
- name: Resolve registry
id: reg
if: gitea.event_name == 'push'
shell: bash
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
run: |
set -euo pipefail
url_to_host() {
echo "$1" | sed -e 's|^https://||' -e 's|^http://||' -e 's|/.*||'
}
is_internal_host() {
local host="${1%%:*}"
case "$host" in
localhost|127.*|10.*|192.168.*) return 0 ;;
172.*)
local second
second=$(echo "$host" | cut -d. -f2)
[ "$second" -ge 16 ] && [ "$second" -le 31 ]
return
;;
*) return 1 ;;
esac
}
registry="${REGISTRY:-}"
if [ -z "$registry" ] && [ -n "${GITEA_ROOT_URL:-}" ]; then
registry=$(url_to_host "${GITEA_ROOT_URL}")
fi
if [ -z "$registry" ]; then
registry=$(url_to_host "${GITEA_SERVER_URL}")
fi
if is_internal_host "$registry"; then
echo "ERROR: Registry 主机 '${registry}' 是内网地址。" >&2
echo "请设置 Variable REGISTRY=公网 Gitea 域名(如 git.example.com)。" >&2
exit 1
fi
echo "registry host: ${registry}"
auth_header=""
if auth_header=$(curl -fsS -D - "https://${registry}/v2/" -o /dev/null 2>&1 | grep -i '^www-authenticate:'); then
if echo "$auth_header" | grep -qiE '127\.0\.0\.1|172\.(1[6-9]|2[0-9]|3[01])\.|localhost|:13827'; then
echo "ERROR: Registry token realm 指向内网地址:${auth_header}" >&2
exit 1
fi
fi
echo "host=${registry}" >> "${GITHUB_OUTPUT}"
- name: Log in to Container Registry
if: gitea.event_name == 'push'
shell: bash
run: |
set -euo pipefail
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login "${{ steps.reg.outputs.host }}" -u "${{ gitea.actor }}" --password-stdin
- name: Build image
shell: bash
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_REF: ${{ gitea.ref }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }}
GITEA_EVENT_NAME: ${{ gitea.event_name }}
REGISTRY_HOST: ${{ steps.reg.outputs.host }}
run: |
set -euo pipefail
dockerfile="${DOCKERFILE:-Dockerfile}"
if [ "${DOCKER_IMAGE_PREFIX:-}" = "hub" ] || [ "${DOCKER_IMAGE_PREFIX:-}" = "docker.io" ]; then
image_prefix=""
elif [ -z "${DOCKER_IMAGE_PREFIX:-}" ] || [ "${DOCKER_IMAGE_PREFIX}" = "1panel" ]; then
image_prefix="docker.1panel.live/library/"
elif [ "${DOCKER_IMAGE_PREFIX}" = "daocloud" ]; then
image_prefix="docker.m.daocloud.io/library/"
else
image_prefix="${DOCKER_IMAGE_PREFIX}"
[[ "${image_prefix}" == */ ]] || image_prefix="${image_prefix}/"
fi
echo "using IMAGE_PREFIX=${image_prefix:-<docker.io>}"
build_args=(--build-arg "IMAGE_PREFIX=${image_prefix}")
builder_id="gitea-buildx-${GITEA_REPOSITORY//\//-}"
if [ "${GITEA_EVENT_NAME}" = "push" ]; then
install_binfmt() {
for img in \
"docker.1panel.live/tonistiigi/binfmt:latest" \
"docker.m.daocloud.io/tonistiigi/binfmt:latest" \
"tonistiigi/binfmt:latest"; do
echo "trying binfmt image: ${img}"
if docker run --privileged --rm "${img}" --install all; then
return 0
fi
done
return 1
}
install_binfmt || true
registry="${REGISTRY_HOST}"
image_name="${IMAGE_NAME:-$(echo "${GITEA_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]')}"
platforms="${DOCKER_PLATFORMS:-linux/amd64,linux/arm64}"
image="${registry}/${GITEA_REPOSITORY_OWNER}/${image_name}"
tags="${image}:sha-${GITEA_SHA:0:7}"
case "${GITEA_REF}" in
refs/heads/main|refs/heads/master)
tags="${tags},${image}:latest"
;;
refs/tags/v*)
tags="${tags},${image}:${GITEA_REF#refs/tags/}"
;;
esac
docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}"
docker buildx inspect --bootstrap
tag_args=()
IFS=',' read -r -a tag_list <<< "$tags"
for t in "${tag_list[@]}"; do
tag_args+=(-t "$t")
done
docker buildx build \
--platform "${platforms}" \
-f "${dockerfile}" \
"${build_args[@]}" \
"${tag_args[@]}" \
--push \
.
else
# PR:仅单架构构建验证 Dockerfile,不推送
docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}"
docker buildx inspect --bootstrap
docker buildx build \
--platform linux/amd64 \
-f "${dockerfile}" \
"${build_args[@]}" \
--load \
.
fi
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.cursor
coverage
.env
.env.*
!.env.example
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+26
View File
@@ -0,0 +1,26 @@
# Changelog
本文件记录项目的 notable 变更,格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
## [Unreleased]
### Added
- Docker 多阶段构建与 Gitea CI 流水线
- Vitest 单元测试(146 用例)
- 开源文档:LICENSE、CONTRIBUTING、SECURITY、NOTICE
## [1.0.0] - 2026-06-24
### Added
- JSON 格式化、验证与树形视图
- 文本 / JSON 对比工具(行级、字符级 diff)
- Base64 / URL / Unicode / Zlib 编解码
- 变量名格式转换(camelCase、snake_case 等)
- 二维码生成
- 时间戳与时间字符串互转
- 颜色格式转换(RGB / HEX / HSL
[Unreleased]: https://git.rc707blog.top/rose_cat707/ToolBox/compare/v1.0.0...HEAD
[1.0.0]: https://git.rc707blog.top/rose_cat707/ToolBox/releases/tag/v1.0.0
+47
View File
@@ -0,0 +1,47 @@
# 贡献指南
感谢你对 ToolBox 的关注!欢迎通过 Issue 或 Pull Request 参与贡献。
## 开始之前
1. 搜索 [Issues](https://git.rc707blog.top/rose_cat707/ToolBox/issues),确认是否已有相关讨论
2. 较大改动请先开 Issue 说明方案,避免重复劳动
## 本地开发
```bash
git clone https://git.rc707blog.top/rose_cat707/ToolBox.git
cd ToolBox
npm install
npm run dev
```
## 代码规范
- 遵循项目现有风格,改动范围尽量小
- 业务逻辑优先放在 `src/utils/`,便于单元测试
- 不要提交 `node_modules``dist`、密钥或 `.env` 等敏感文件
## 测试
提交前请确保测试通过:
```bash
npm run test:run
npm run build
```
新增功能或修复 bug 时,请补充或更新 `tests/` 下对应测试。
## 提交 Pull Request
1. Fork 仓库并创建功能分支(如 `feat/xxx``fix/xxx`
2. 编写清晰的 commit message,说明「为什么」而不只是「改了什么」
3. 确保 CI 通过(npm test + Docker 构建)
4. 在 PR 描述中说明改动内容、测试方式
## 报告问题
- Bug:请提供复现步骤、期望行为与实际行为
- 功能建议:说明使用场景与预期效果
- 安全漏洞:请参见 [SECURITY.md](./SECURITY.md),勿在公开 Issue 中披露细节
+64
View File
@@ -0,0 +1,64 @@
# syntax=docker/dockerfile:1
# Vue 静态站点多阶段构建:Node 构建 + Nginx 运行
# CI 会通过 buildx 传入 IMAGE_PREFIX(见 .gitea/workflows/ci.yml
#
# 构建时环境变量(可选):
# VITE_APP_TITLE 站点标题
# VITE_APP_ICP 备案号
# VITE_GA_MEASUREMENT_ID Google Analytics(留空禁用)
# VITE_JINRISHICI_SDK_URL 今日诗词 SDK(留空禁用)
# 运行时环境变量(可选,覆盖构建值):
# APP_TITLE 站点标题
# APP_ICP 备案号(设为空字符串则不显示)
# APP_GA_MEASUREMENT_ID Google Analytics(设为空字符串则禁用)
# APP_JINRISHICI_SDK_URL 今日诗词 SDK(设为空字符串则禁用)
ARG IMAGE_PREFIX=
ARG VITE_APP_TITLE=RC707的工具箱
ARG VITE_APP_ICP=苏ICP备2022013040号-1
ARG VITE_GA_MEASUREMENT_ID=
ARG VITE_JINRISHICI_SDK_URL=
# ---------- 1) 前端构建 ----------
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}node:20-alpine AS web-builder
ARG VITE_APP_TITLE
ARG VITE_APP_ICP
ARG VITE_GA_MEASUREMENT_ID
ARG VITE_JINRISHICI_SDK_URL
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
ENV VITE_APP_ICP=${VITE_APP_ICP}
ENV VITE_GA_MEASUREMENT_ID=${VITE_GA_MEASUREMENT_ID}
ENV VITE_JINRISHICI_SDK_URL=${VITE_JINRISHICI_SDK_URL}
WORKDIR /src
COPY package.json package-lock.json .npmrc ./
RUN npm ci
COPY index.html vite.config.js ./
COPY public ./public
COPY src ./src
RUN npm run build
# ---------- 2) 运行镜像 ----------
FROM ${IMAGE_PREFIX}nginx:1.27-alpine
RUN apk add --no-cache tzdata \
&& rm -rf /var/cache/apk/*
ENV TZ=Asia/Shanghai
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY deploy/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
COPY --from=web-builder /src/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
ENTRYPOINT ["/docker-entrypoint.sh"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 renjue
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
ToolBox
Copyright (c) 2026 renjue
This product includes software developed by third parties:
- Vue.js — MIT License — https://github.com/vuejs/core
- Vue Router — MIT License — https://github.com/vuejs/router
- Vite — MIT License — https://github.com/vitejs/vite
- Vitest — MIT License — https://github.com/vitest-dev/vitest
- Font Awesome Free — Icons: CC BY 4.0, Fonts: SIL OFL 1.1 — https://fontawesome.com
- fflate — MIT License — https://github.com/101arrowz/fflate
- qrcode — MIT License — https://github.com/soldair/node-qrcode
Runtime third-party services (loaded at runtime, not bundled):
- Google Analytics — https://policies.google.com/privacy
- 今日诗词 SDK (jinrishici.com) — https://www.jinrishici.com/
See package licenses in node_modules for full dependency attribution.
+139
View File
@@ -0,0 +1,139 @@
# ToolBox
在线开发者工具箱:JSON 格式化、文本/JSON 对比、编解码、变量名转换、二维码、时间戳、颜色转换等。
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
## 功能
| 工具 | 路径 | 说明 |
|------|------|------|
| JSON | `/json-formatter` | 格式化、压缩、转义、JSONPath 筛选、树形视图 |
| 对比 | `/comparator` | 文本行/字符 diff、JSON 结构对比 |
| 编解码 | `/encoder-decoder` | Base64、URL、Unicode、Zlib |
| 变量名 | `/variable-name` | camelCase / PascalCase / snake_case 等 |
| 二维码 | `/qr-code` | 文本生成二维码 |
| 时间戳 | `/timestamp-converter` | 秒/毫秒/纳秒与时间字符串互转 |
| 颜色 | `/color-converter` | RGB / HEX / HSL 互转 |
## 快速开始
### 前置要求
- Node.js >= 18
- npm
### 安装与运行
```bash
git clone https://git.rc707blog.top/rose_cat707/ToolBox.git
cd ToolBox
npm install
npm run dev
```
开发服务器默认 `http://localhost:3000`
### 构建
```bash
npm run build # 产物输出到 dist/
npm run preview # 预览生产构建
```
### 测试
```bash
npm run test:run
```
## 环境变量
站点标题与备案号可通过环境变量配置,复制 `.env.example``.env` 后修改:
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `VITE_APP_TITLE` | 站点标题(导航栏、浏览器标签) | `RC707的工具箱` |
| `VITE_APP_ICP` | 备案号(留空则不显示) | `苏ICP备2022013040号-1` |
| `VITE_GA_MEASUREMENT_ID` | Google Analytics 测量 ID(留空禁用) | (空,不上报) |
| `VITE_JINRISHICI_SDK_URL` | 今日诗词 SDK 地址(留空禁用) | (空,不加载) |
### Docker
**构建时**指定(写入静态资源默认值):
```bash
docker build \
--build-arg VITE_APP_TITLE="我的工具箱" \
--build-arg VITE_GA_MEASUREMENT_ID="G-XXXXXXXXXX" \
-t toolbox:latest .
```
**运行时**覆盖(无需重新构建,容器启动时生成 `config.js`):
```bash
docker run --rm -p 8080:80 \
-e APP_TITLE="我的工具箱" \
-e APP_ICP="" \
-e APP_GA_MEASUREMENT_ID="" \
toolbox:latest
```
`APP_ICP` 设为空字符串时,首页不显示备案号。
## Docker 部署
```bash
docker build -t toolbox:latest .
docker run --rm -p 8080:80 toolbox:latest
```
访问 `http://localhost:8080`
CI 会在 push 到 main/master 时自动构建并推送镜像到 Gitea Container Registry,详见 [`.gitea/README.md`](./.gitea/README.md)。
## 项目结构
```
ToolBox/
├── src/
│ ├── views/ # 页面组件
│ ├── utils/ # 可测试的业务逻辑
│ ├── composables/ # Vue composables
│ ├── components/ # 公共组件
│ └── router/ # 路由
├── tests/ # Vitest 单元测试
├── deploy/ # Nginx 配置
├── Dockerfile
└── .gitea/workflows/ # CI 流水线
```
## 添加新工具
1.`src/views/` 创建 Vue 组件
2.`src/router/index.js` 添加路由
3.`src/App.vue` 导航栏添加入口
4.`src/views/Home.vue``tools` 数组中注册
5. 如有纯逻辑,提取到 `src/utils/` 并补充测试
## 自部署说明
- **Google Analytics**:通过 `VITE_GA_MEASUREMENT_ID` / `APP_GA_MEASUREMENT_ID` 启用;默认关闭,不向 Google 上报数据
- **今日诗词**:通过 `VITE_JINRISHICI_SDK_URL` / `APP_JINRISHICI_SDK_URL` 启用;默认关闭,不加载第三方 SDK
- **deploy.sh**:作者自用 SSH 部署脚本,内含固定远程路径,使用前请修改 `REMOTE_DIR`
## 第三方依赖
主要依赖及许可证见 [NOTICE](./NOTICE)。Font Awesome 图标需保留其版权声明。
## 贡献
欢迎提交 Issue 和 Pull Request,请参阅 [CONTRIBUTING.md](./CONTRIBUTING.md)。
## 安全
发现安全漏洞请私下报告,详见 [SECURITY.md](./SECURITY.md)。
## 许可证
[MIT License](./LICENSE) © 2026 renjue
+32
View File
@@ -0,0 +1,32 @@
# 安全策略
## 支持的版本
| 版本 | 支持状态 |
|--------|----------|
| 最新版 | ✅ |
| 旧版本 | ❌ |
## 报告漏洞
如果你发现了安全漏洞,**请勿在公开 Issue 中披露**。
请通过以下方式私下报告:
- 发送邮件至:**xufeng3@xiaohongshu.com**
- 或在 Gitea 仓库中使用 **Private Security Advisory**(若已启用)
报告时请尽量包含:
- 漏洞类型与影响范围
- 复现步骤
- 受影响版本
- 可能的修复建议(如有)
我们会在合理时间内确认收到,并在修复后公开致谢(除非你希望匿名)。
## 安全注意事项(自部署)
- 本项目为纯前端静态应用,Docker 镜像使用 Nginx 提供静态文件
- **Google Analytics** 与 **今日诗词 SDK** 默认关闭,需通过环境变量显式启用(见 README)
- `deploy.sh` 通过命令行参数传递 SSH 密码,请勿在脚本或日志中硬编码凭据
Executable
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -e
# 部署脚本:打包并通过 SSH 将构建产物同步到远程目录
# 用法: ./deploy.sh <服务器IP> <SSH端口> <登录账户> <登录密码> [sudo]
# 示例: ./deploy.sh 192.168.1.100 22 root mypassword
# 若远程目录无写权限,加第5个参数 sudo:先传到 /tmp,再通过 sudo 拷到目标(会用到同一密码作为 sudo 密码)
# 示例: ./deploy.sh 192.168.1.100 22 myuser mypassword sudo
if [ $# -lt 4 ]; then
echo "用法: $0 <服务器IP> <SSH端口> <登录账户> <登录密码> [sudo]"
echo "示例: $0 192.168.1.100 22 root mypassword"
echo "远程目录无写权限时加第5参数: $0 <IP> <端口> <用户> <密码> sudo"
exit 1
fi
HOST="$1"
PORT="$2"
USER="$3"
PASSWORD="$4"
USE_SUDO="${5:-}"
REMOTE_DIR="/root/caddy/site/tool"
REMOTE_TMP="/tmp/tool_deploy_$$"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DIST_DIR="${SCRIPT_DIR}/dist"
SSH_OPTS="-p ${PORT} -o StrictHostKeyChecking=accept-new -T"
echo ">>> 正在打包..."
cd "$SCRIPT_DIR"
npm run build
if [ ! -d "$DIST_DIR" ]; then
echo "错误: 构建产物目录 dist 不存在"
exit 1
fi
# 检查 sshpass 是否可用(用于非交互式传入密码)
if ! command -v sshpass &> /dev/null; then
echo "未找到 sshpass,请先安装:"
echo " macOS: brew install sshpass"
echo " Ubuntu: sudo apt-get install sshpass"
exit 1
fi
export SSHPASS="$PASSWORD"
if [ "$USE_SUDO" = "sudo" ]; then
echo ">>> 正在同步到远程临时目录 ${REMOTE_TMP}"
sshpass -e rsync -avz --delete \
-e "ssh ${SSH_OPTS}" \
"${DIST_DIR}/" \
"${USER}@${HOST}:${REMOTE_TMP}/"
echo ">>> 正在用 sudo 拷贝到目标目录 ${REMOTE_DIR}"
# 只执行一次 sudo(读一次密码),在子 shell 里完成 rsync 与清理,避免第二次 sudo 无密码
printf '%s\n' "$PASSWORD" | sshpass -e ssh ${SSH_OPTS} "${USER}@${HOST}" \
"sudo -S sh -c 'rsync -avz --delete ${REMOTE_TMP}/ ${REMOTE_DIR}/ && rm -rf ${REMOTE_TMP}'"
else
echo ">>> 正在通过 SSH 同步到 ${USER}@${HOST}:${PORT} -> ${REMOTE_DIR}"
sshpass -e rsync -avz --delete \
-e "ssh ${SSH_OPTS}" \
"${DIST_DIR}/" \
"${USER}@${HOST}:${REMOTE_DIR}/"
fi
unset SSHPASS
echo ">>> 部署完成"
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
set -e
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\r//g'
}
if [ -z "${APP_TITLE+x}" ]; then
APP_TITLE="RC707的工具箱"
fi
if [ -z "${APP_ICP+x}" ]; then
APP_ICP="苏ICP备2022013040号-1"
fi
TITLE_ESC=$(json_escape "$APP_TITLE")
ICP_ESC=$(json_escape "$APP_ICP")
CONFIG_BODY="title: \"${TITLE_ESC}\", icp: \"${ICP_ESC}\""
if [ -n "${APP_GA_MEASUREMENT_ID+x}" ]; then
GA_ESC=$(json_escape "$APP_GA_MEASUREMENT_ID")
CONFIG_BODY="${CONFIG_BODY}, gaId: \"${GA_ESC}\""
fi
if [ -n "${APP_JINRISHICI_SDK_URL+x}" ]; then
JRS_ESC=$(json_escape "$APP_JINRISHICI_SDK_URL")
CONFIG_BODY="${CONFIG_BODY}, jinrishiciSdkUrl: \"${JRS_ESC}\""
fi
cat > /usr/share/nginx/html/config.js <<EOF
window.__SITE_CONFIG__ = {
${CONFIG_BODY}
};
EOF
exec nginx -g 'daemon off;'
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
+14
View File
@@ -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">
<title>%VITE_APP_TITLE%</title>
<script src="/config.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+3949
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "toolbox",
"version": "1.0.0",
"description": "A Vue-based online toolbox: JSON, diff, encode/decode, and more",
"license": "MIT",
"type": "module",
"author": "renjue <xufeng3@xiaohongshu.com>",
"repository": {
"type": "git",
"url": "https://git.rc707blog.top/rose_cat707/ToolBox.git"
},
"homepage": "https://git.rc707blog.top/rose_cat707/ToolBox",
"bugs": {
"url": "https://git.rc707blog.top/rose_cat707/ToolBox/issues"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"fflate": "^0.8.2",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"@vitest/coverage-v8": "^4.1.9",
"jsdom": "^29.1.1",
"vite": "^5.0.0",
"vitest": "^4.1.9"
},
"engines": {
"node": ">=18.0.0"
}
}
+1
View File
@@ -0,0 +1 @@
window.__SITE_CONFIG__ = window.__SITE_CONFIG__ || {};
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="app-container">
<nav class="navbar">
<div class="nav-content">
<router-link to="/" class="logo">
<h1>{{ siteTitle }}</h1>
</router-link>
<div class="nav-links">
<router-link to="/" class="nav-link">首页</router-link>
<router-link to="/json-formatter" class="nav-link">JSON</router-link>
<router-link to="/comparator" class="nav-link">对比</router-link>
<router-link to="/encoder-decoder" class="nav-link">编解码</router-link>
<router-link to="/variable-name" class="nav-link">变量名</router-link>
<router-link to="/qr-code" class="nav-link">二维码</router-link>
<router-link to="/timestamp-converter" class="nav-link">时间戳</router-link>
<router-link to="/color-converter" class="nav-link">颜色</router-link>
</div>
</div>
</nav>
<main class="main-content">
<router-view />
</main>
</div>
</template>
<script setup>
import { siteConfig } from './config/site.js'
const siteTitle = siteConfig.title
</script>
<style scoped>
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background: #ffffff;
border-bottom: 1px solid #e5e5e5;
padding: 0;
position: sticky;
top: 0;
z-index: 100;
min-height: 40px;
}
.nav-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
min-height: 40px;
}
.logo {
text-decoration: none;
color: #1a1a1a;
padding-left: 0.5rem;
}
.logo h1 {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
letter-spacing: -0.02em;
}
.nav-links {
display: flex;
gap: 0;
align-items: center;
}
.nav-link {
text-decoration: none;
color: #666666;
font-weight: 500;
font-size: 0.875rem;
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.5;
}
.nav-link:hover {
color: #1a1a1a;
background: #f5f5f5;
}
.nav-link.router-link-active {
color: #ffffff;
background: #1a1a1a;
border-bottom-color: #1a1a1a;
}
.main-content {
flex: 1;
width: 100%;
padding: 1rem;
}
@media (max-width: 768px) {
.nav-content {
flex-direction: column;
gap: 0;
padding: 0.5rem 0.75rem;
}
.logo {
padding-left: 0;
margin-bottom: 0.5rem;
}
.logo h1 {
font-size: 1.125rem;
}
.nav-links {
flex-wrap: wrap;
justify-content: flex-start;
width: 100%;
}
.nav-link {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
.main-content {
padding: 1rem;
}
}
</style>
+818
View File
@@ -0,0 +1,818 @@
<template>
<div class="datetime-picker-wrapper">
<!-- 自定义日期时间选择器面板 -->
<Transition name="picker">
<div v-if="show" class="datetime-picker-panel" @click.stop>
<div class="picker-container">
<!-- 左侧日期选择区域 -->
<div class="date-picker-section">
<!-- 年月导航 -->
<div class="date-header">
<div class="nav-buttons">
<button @click="prevYear" class="nav-btn" title="上一年">«</button>
<button @click="prevMonth" class="nav-btn" title="上一月"></button>
</div>
<div
v-if="!isEditingMonthYear"
@click="startEditingMonthYear"
class="current-month-year editable"
title="点击输入年月"
>
{{ currentViewDate.getFullYear() }} - {{ String(currentViewDate.getMonth() + 1).padStart(2, '0') }}
</div>
<input
v-else
v-model="monthYearInput"
@blur="confirmMonthYear"
@keyup.enter="confirmMonthYear"
@keyup.esc="cancelEditingMonthYear"
class="month-year-input"
placeholder="YYYY-MM"
ref="monthYearInputRef"
/>
<div class="nav-buttons">
<button @click="nextMonth" class="nav-btn" title="下一月"></button>
<button @click="nextYear" class="nav-btn" title="下一年">»</button>
</div>
</div>
<!-- 星期标题 -->
<div class="weekdays">
<div class="weekday" v-for="day in ['日', '一', '二', '三', '四', '五', '六']" :key="day">
{{ day }}
</div>
</div>
<!-- 日期网格 -->
<div class="calendar-grid">
<div
v-for="(day, index) in getCalendarDays()"
:key="index"
@click="selectDate(day)"
:class="[
'calendar-day',
{ 'other-month': !day.isCurrentMonth },
{ 'today': isToday(day) },
{ 'selected': isSelected(day) }
]"
>
{{ day.date.getDate() }}
<span v-if="isToday(day) && !isSelected(day)" class="today-dot"></span>
</div>
</div>
<!-- 此刻按钮 -->
<button @click="selectNow" class="now-btn">此刻</button>
</div>
<!-- 右侧时间选择区域 -->
<div class="time-picker-section">
<div class="time-header">选择时间</div>
<div class="time-selectors">
<!-- 小时选择 -->
<div class="time-column">
<div class="time-list" ref="hourListRef">
<div
v-for="hour in generateTimeOptions('hour')"
:key="hour"
:data-value="hour"
@click="selectTime('hour', hour)"
:class="['time-item', { 'selected': selectedTime.hour === parseInt(hour) }]"
>
{{ hour }}
</div>
</div>
</div>
<!-- 分钟选择 -->
<div class="time-column">
<div class="time-list" ref="minuteListRef">
<div
v-for="minute in generateTimeOptions('minute')"
:key="minute"
:data-value="minute"
@click="selectTime('minute', minute)"
:class="['time-item', { 'selected': selectedTime.minute === parseInt(minute) }]"
>
{{ minute }}
</div>
</div>
</div>
<!-- 秒选择 -->
<div class="time-column">
<div class="time-list" ref="secondListRef">
<div
v-for="second in generateTimeOptions('second')"
:key="second"
:data-value="second"
@click="selectTime('second', second)"
:class="['time-item', { 'selected': selectedTime.second === parseInt(second) }]"
>
{{ second }}
</div>
</div>
</div>
</div>
<!-- 确定按钮 -->
<button @click="confirmSelection" class="confirm-btn">确定</button>
</div>
</div>
</div>
</Transition>
<!-- 遮罩层 -->
<Transition name="mask">
<div v-if="show" class="picker-mask" @click="close"></div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
precisionType: {
type: String,
default: 'milliseconds', // seconds, milliseconds, nanoseconds
validator: (value) => ['seconds', 'milliseconds', 'nanoseconds'].includes(value)
},
show: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'update:show', 'confirm'])
// 日期时间选择器状态
const currentViewDate = ref(new Date()) // 当前查看的年月
const selectedDate = ref(null) // 选中的日期
const selectedTime = ref({ hour: 0, minute: 0, second: 0 }) // 选中的时间
const hourListRef = ref(null)
const minuteListRef = ref(null)
const secondListRef = ref(null)
const isEditingMonthYear = ref(false) // 是否正在编辑年月
const monthYearInput = ref('') // 年月输入值
const monthYearInputRef = ref(null) // 年月输入框引用
// 日期选择功能
const getCalendarDays = () => {
const year = currentViewDate.value.getFullYear()
const month = currentViewDate.value.getMonth()
// 获取当月第一天和最后一天
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
// 获取第一天是星期几(0=周日)
const firstDayWeek = firstDay.getDay()
// 获取上个月的最后几天
const prevMonthLastDay = new Date(year, month, 0).getDate()
const days = []
// 添加上个月的日期
for (let i = firstDayWeek - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonthLastDay - i),
isCurrentMonth: false
})
}
// 添加当月的日期
for (let i = 1; i <= lastDay.getDate(); i++) {
days.push({
date: new Date(year, month, i),
isCurrentMonth: true
})
}
// 添加下个月的日期,补齐到42个(6行7列)
const remainingDays = 42 - days.length
for (let i = 1; i <= remainingDays; i++) {
days.push({
date: new Date(year, month + 1, i),
isCurrentMonth: false
})
}
return days
}
const prevYear = () => {
const date = new Date(currentViewDate.value)
date.setFullYear(date.getFullYear() - 1)
currentViewDate.value = date
}
const nextYear = () => {
const date = new Date(currentViewDate.value)
date.setFullYear(date.getFullYear() + 1)
currentViewDate.value = date
}
const prevMonth = () => {
const date = new Date(currentViewDate.value)
date.setMonth(date.getMonth() - 1)
currentViewDate.value = date
}
const nextMonth = () => {
const date = new Date(currentViewDate.value)
date.setMonth(date.getMonth() + 1)
currentViewDate.value = date
}
const selectDate = (day) => {
selectedDate.value = new Date(day.date)
// 滚动到选中的时间项
nextTick(() => {
scrollToSelected()
})
}
const isToday = (day) => {
const today = new Date()
return day.date.getDate() === today.getDate() &&
day.date.getMonth() === today.getMonth() &&
day.date.getFullYear() === today.getFullYear()
}
const isSelected = (day) => {
if (!selectedDate.value) return false
return day.date.getDate() === selectedDate.value.getDate() &&
day.date.getMonth() === selectedDate.value.getMonth() &&
day.date.getFullYear() === selectedDate.value.getFullYear()
}
// 时间选择功能
const generateTimeOptions = (type) => {
if (type === 'hour') {
return Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
} else {
return Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
}
}
const selectTime = (type, value) => {
selectedTime.value[type] = parseInt(value)
// 滚动到选中的项
nextTick(() => {
scrollToSelected()
})
}
const scrollToSelected = () => {
// 滚动小时列表
if (hourListRef.value) {
const hourItem = hourListRef.value.querySelector(`[data-value="${String(selectedTime.value.hour).padStart(2, '0')}"]`)
if (hourItem) {
hourItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 滚动分钟列表
if (minuteListRef.value) {
const minuteItem = minuteListRef.value.querySelector(`[data-value="${String(selectedTime.value.minute).padStart(2, '0')}"]`)
if (minuteItem) {
minuteItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 滚动秒列表
if (secondListRef.value) {
const secondItem = secondListRef.value.querySelector(`[data-value="${String(selectedTime.value.second).padStart(2, '0')}"]`)
if (secondItem) {
secondItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
}
// 面板控制功能
const close = () => {
emit('update:show', false)
isEditingMonthYear.value = false
monthYearInput.value = ''
}
const confirmSelection = () => {
if (!selectedDate.value) {
selectedDate.value = new Date()
}
const date = new Date(selectedDate.value)
date.setHours(selectedTime.value.hour)
date.setMinutes(selectedTime.value.minute)
date.setSeconds(selectedTime.value.second)
date.setMilliseconds(0)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
const milliseconds = String(date.getMilliseconds()).padStart(3, '0')
// 根据精度类型设置格式
let formattedDate = ''
if (props.precisionType === 'seconds') {
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} else if (props.precisionType === 'milliseconds') {
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`
} else {
// 纳秒级
const nanosecondsStr = '000000'
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}${nanosecondsStr}`
}
emit('update:modelValue', formattedDate)
emit('confirm', formattedDate)
close()
}
const selectNow = () => {
const now = new Date()
selectedDate.value = new Date(now)
currentViewDate.value = new Date(now)
selectedTime.value = {
hour: now.getHours(),
minute: now.getMinutes(),
second: now.getSeconds()
}
nextTick(() => {
scrollToSelected()
})
}
// 开始编辑年月
const startEditingMonthYear = () => {
isEditingMonthYear.value = true
const year = currentViewDate.value.getFullYear()
const month = String(currentViewDate.value.getMonth() + 1).padStart(2, '0')
monthYearInput.value = `${year}-${month}`
nextTick(() => {
if (monthYearInputRef.value) {
monthYearInputRef.value.focus()
monthYearInputRef.value.select()
}
})
}
// 确认年月输入
const confirmMonthYear = () => {
const input = monthYearInput.value.trim()
// 验证格式:YYYY-MM 或 YYYY-M
const match = input.match(/^(\d{4})-(\d{1,2})$/)
if (match) {
const year = parseInt(match[1])
const month = parseInt(match[2])
// 验证年份和月份范围
if (year >= 1000 && year <= 9999 && month >= 1 && month <= 12) {
const date = new Date(year, month - 1, 1)
currentViewDate.value = date
isEditingMonthYear.value = false
monthYearInput.value = ''
} else {
// 这里可以添加错误提示,但组件中不依赖父组件的 showToast
if (monthYearInputRef.value) {
monthYearInputRef.value.focus()
monthYearInputRef.value.select()
}
}
} else {
if (monthYearInputRef.value) {
monthYearInputRef.value.focus()
monthYearInputRef.value.select()
}
}
}
// 取消编辑年月
const cancelEditingMonthYear = () => {
isEditingMonthYear.value = false
monthYearInput.value = ''
}
// 监听 show 变化,初始化选择器
watch(() => props.show, (newVal) => {
if (newVal) {
// 如果输入框有值,尝试解析并设置到选择器
if (props.modelValue) {
try {
const dateStr = props.modelValue.trim()
let date = null
if (dateStr.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,3})?$/)) {
date = new Date(dateStr.replace(' ', 'T'))
} else if (dateStr.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?/)) {
date = new Date(dateStr)
} else {
date = new Date(dateStr)
}
if (!isNaN(date.getTime())) {
selectedDate.value = new Date(date)
currentViewDate.value = new Date(date)
selectedTime.value = {
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds()
}
}
} catch (error) {
// 如果解析失败,使用当前时间
const now = new Date()
selectedDate.value = new Date(now)
currentViewDate.value = new Date(now)
selectedTime.value = {
hour: now.getHours(),
minute: now.getMinutes(),
second: now.getSeconds()
}
}
} else {
// 如果输入框为空,使用当前时间
const now = new Date()
selectedDate.value = new Date(now)
currentViewDate.value = new Date(now)
selectedTime.value = {
hour: now.getHours(),
minute: now.getMinutes(),
second: now.getSeconds()
}
}
// 等待DOM更新后滚动到选中项
nextTick(() => {
scrollToSelected()
})
} else {
isEditingMonthYear.value = false
monthYearInput.value = ''
}
})
</script>
<style scoped>
.datetime-picker-wrapper {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
width: 100%;
}
/* 日期时间选择器面板 */
.picker-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 998;
}
.datetime-picker-panel {
position: relative;
z-index: 999;
width: 500px;
min-width: 500px;
background: #ffffff;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border: 1px solid #e5e5e5;
overflow: hidden;
}
.picker-container {
display: flex;
min-height: 320px;
width: 100%;
}
/* 左侧日期选择区域 */
.date-picker-section {
flex: 1;
min-width: 0;
padding: 0.75rem;
display: flex;
flex-direction: column;
border-right: 1px solid #e5e5e5;
}
.date-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.nav-buttons {
display: flex;
gap: 0.125rem;
}
.nav-btn {
padding: 0.125rem 0.375rem;
background: transparent;
border: 1px solid #d0d0d0;
border-radius: 3px;
color: #333333;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
min-width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-btn:hover {
background: #f5f5f5;
border-color: #1a1a1a;
}
.current-month-year {
font-size: 0.8125rem;
font-weight: 500;
color: #333333;
}
.current-month-year.editable {
cursor: pointer;
padding: 0.125rem 0.25rem;
border-radius: 3px;
transition: all 0.2s;
user-select: none;
}
.current-month-year.editable:hover {
background: #f5f5f5;
}
.month-year-input {
font-size: 0.8125rem;
font-weight: 500;
color: #333333;
border: 1px solid #1a1a1a;
border-radius: 3px;
padding: 0.125rem 0.25rem;
text-align: center;
width: 80px;
outline: none;
background: #ffffff;
}
.month-year-input:focus {
border-color: #1a1a1a;
box-shadow: 0 0 0 2px rgba(26, 26, 26, 0.1);
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
margin-bottom: 0.375rem;
}
.weekday {
width: 28px;
text-align: center;
font-size: 0.6875rem;
color: #666666;
font-weight: 500;
padding: 0.25rem 0;
margin: 0 auto;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
flex: 1;
}
.calendar-day {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #333333;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
position: relative;
padding: 0;
margin: 0 auto;
}
.calendar-day:hover {
background: #f5f5f5;
}
.calendar-day.other-month {
color: #999999;
}
.calendar-day.today {
font-weight: 500;
}
.calendar-day.selected {
background: #1a1a1a;
color: #ffffff;
}
.today-dot {
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
background: #1a1a1a;
border-radius: 50%;
}
.calendar-day.selected .today-dot {
background: #ffffff;
}
.now-btn {
margin-top: 0.5rem;
padding: 0.375rem 0.75rem;
background: #ffffff;
border: 1px solid #d0d0d0;
border-radius: 4px;
color: #666666;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
align-self: flex-start;
}
.now-btn:hover {
background: #f5f5f5;
border-color: #1a1a1a;
color: #1a1a1a;
}
/* 右侧时间选择区域 */
.time-picker-section {
flex: 0 0 160px;
min-width: 160px;
padding: 0.75rem;
display: flex;
flex-direction: column;
background: #fafafa;
}
.time-header {
font-size: 0.75rem;
font-weight: 500;
color: #333333;
margin-bottom: 0.5rem;
text-align: center;
}
.time-selectors {
display: flex;
gap: 0.25rem;
flex: 1;
overflow: hidden;
}
.time-column {
flex: 1;
display: flex;
flex-direction: column;
}
.time-list {
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
max-height: 240px;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.time-list::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.time-item {
padding: 0.25rem 0.375rem;
text-align: center;
font-size: 0.75rem;
color: #333333;
cursor: pointer;
transition: all 0.2s;
border-radius: 3px;
margin: 0.0625rem 0;
}
.time-item:hover {
background: #f0f0f0;
}
.time-item.selected {
background: #1a1a1a;
color: #ffffff;
font-weight: 500;
}
.confirm-btn {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: #1a1a1a;
border: none;
border-radius: 4px;
color: #ffffff;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
width: 100%;
}
.confirm-btn:hover {
background: #333333;
}
.confirm-btn:active {
transform: scale(0.98);
}
/* 过渡动画 */
.picker-enter-active,
.picker-leave-active {
transition: all 0.3s ease;
}
.picker-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.picker-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.mask-enter-active,
.mask-leave-active {
transition: opacity 0.3s ease;
}
.mask-enter-from,
.mask-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.datetime-picker-panel {
width: calc(100vw - 2rem);
min-width: calc(100vw - 2rem);
max-width: calc(100vw - 2rem);
left: 50%;
transform: translateX(-50%);
}
.picker-container {
flex-direction: column;
min-height: 280px;
}
.date-picker-section {
border-right: none;
border-bottom: 1px solid #e5e5e5;
}
.time-picker-section {
flex: 1;
min-width: 140px;
min-height: 250px;
}
.time-selectors {
max-height: 200px;
}
}
</style>
+396
View File
@@ -0,0 +1,396 @@
<template>
<div class="json-tree-node" :class="{ 'is-matched': isMatched, 'is-filtered': isFiltered }">
<div
class="node-line"
:class="{ 'has-children': hasChildren, 'is-expanded': isExpanded, 'is-matched': isMatched }"
@click="toggle"
>
<span v-if="hasChildren" class="expand-icon">
<i :class="isExpanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
</span>
<span v-else class="expand-placeholder"></span>
<span v-if="showPath && nodePath" class="node-path">{{ nodePath }}:</span>
<span class="node-key" v-if="key !== null">
<span class="key-name" :class="{ 'matched': isMatched }">{{ key }}</span>:
</span>
<span class="node-value" :class="[valueType, { 'matched': isMatched }]">
<span v-if="valueType === 'string'">"{{ displayValue }}"</span>
<span v-else>{{ displayValue }}</span>
</span>
</div>
<div v-if="hasChildren && isExpanded && shouldShowChildren" class="node-children">
<JsonTreeNode
v-for="(child, index) in filteredChildren"
:key="index"
:data="child.value"
:key-name="child.key"
:path="child.path"
:expanded="expanded"
:matched-paths="matchedPaths"
:json-path-query="jsonPathQuery"
@toggle="$emit('toggle', $event)"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
data: {
type: [Object, Array, String, Number, Boolean, null],
required: true
},
keyName: {
type: [String, Number],
default: null
},
path: {
type: String,
default: ''
},
expanded: {
type: Set,
required: true
},
matchedPaths: {
type: Set,
default: () => new Set()
},
jsonPathQuery: {
type: String,
default: ''
},
showPath: {
type: Boolean,
default: false
},
nodePath: {
type: String,
default: ''
}
})
const emit = defineEmits(['toggle'])
const key = computed(() => props.keyName)
const currentPath = computed(() => {
if (props.path === '' && props.keyName === null) {
return 'root'
}
if (props.path === '') {
return String(props.keyName)
}
if (props.path === 'root') {
if (typeof props.keyName === 'number') {
return `root[${props.keyName}]`
}
return `root.${props.keyName}`
}
if (typeof props.keyName === 'number') {
return `${props.path}[${props.keyName}]`
}
return `${props.path}.${props.keyName}`
})
const hasChildren = computed(() => {
return (
(typeof props.data === 'object' && props.data !== null) ||
Array.isArray(props.data)
)
})
const isExpanded = computed(() => {
return props.expanded.has(currentPath.value)
})
// 判断当前节点是否匹配 JSONPath
const isMatched = computed(() => {
if (!props.jsonPathQuery || !props.jsonPathQuery.trim()) return false
return props.matchedPaths.has(currentPath.value)
})
// 判断是否有筛选
const hasFilter = computed(() => {
return props.jsonPathQuery && props.jsonPathQuery.trim() !== ''
})
// 判断是否应该显示子节点
const shouldShowChildren = computed(() => {
if (!hasFilter.value) return true
// 如果节点匹配,显示子节点(展开匹配节点的内容)
if (isMatched.value) return true
// 如果当前节点是匹配节点的子节点,显示所有子节点
if (isChildOfMatched.value) return true
// 检查是否有子节点匹配
return hasMatchingChildren.value
})
// 检查是否有子节点匹配
const hasMatchingChildren = computed(() => {
if (!hasFilter.value) return true
if (!hasChildren.value) return false
const basePath = props.path === '' && props.keyName === null ? 'root' : currentPath.value
if (Array.isArray(props.data)) {
return props.data.some((item, index) => {
const childPath = `${basePath}[${index}]`
return props.matchedPaths.has(childPath) || checkChildrenMatch(item, childPath)
})
}
if (typeof props.data === 'object' && props.data !== null) {
return Object.keys(props.data).some(key => {
const childPath = basePath === 'root' ? `root.${key}` : `${basePath}.${key}`
return props.matchedPaths.has(childPath) || checkChildrenMatch(props.data[key], childPath)
})
}
return false
})
// 递归检查子节点是否匹配
const checkChildrenMatch = (obj, path) => {
if (props.matchedPaths.has(path)) return true
if (Array.isArray(obj)) {
return obj.some((item, index) => {
const childPath = `${path}[${index}]`
return props.matchedPaths.has(childPath) || checkChildrenMatch(item, childPath)
})
}
if (typeof obj === 'object' && obj !== null) {
return Object.keys(obj).some(key => {
const childPath = `${path}.${key}`
return props.matchedPaths.has(childPath) || checkChildrenMatch(obj[key], childPath)
})
}
return false
}
const valueType = computed(() => {
if (props.data === null) return 'null'
if (Array.isArray(props.data)) return 'array'
if (typeof props.data === 'object') return 'object'
return typeof props.data
})
const displayValue = computed(() => {
if (props.data === null) return 'null'
if (Array.isArray(props.data)) {
return `Array(${props.data.length})`
}
if (typeof props.data === 'object') {
const keys = Object.keys(props.data)
return `Object(${keys.length})`
}
if (typeof props.data === 'string') {
return props.data
}
return String(props.data)
})
const children = computed(() => {
if (!hasChildren.value) return []
const basePath = props.path === '' && props.keyName === null ? 'root' : currentPath.value
if (Array.isArray(props.data)) {
return props.data.map((item, index) => ({
key: index,
value: item,
path: basePath
}))
}
if (typeof props.data === 'object' && props.data !== null) {
return Object.keys(props.data).map(key => ({
key: key,
value: props.data[key],
path: basePath
}))
}
return []
})
// 筛选后的子节点
const filteredChildren = computed(() => {
if (!hasFilter.value) return children.value
// 如果当前节点匹配,显示所有子节点(不进行过滤)
if (isMatched.value) return children.value
// 如果当前节点是匹配节点的子节点,显示所有子节点(不进行过滤)
if (isChildOfMatched.value) return children.value
// 否则,只显示匹配的子节点或其子节点匹配的子节点
return children.value.filter(child => {
const childPath = child.path === 'root'
? (typeof child.key === 'number' ? `root[${child.key}]` : `root.${child.key}`)
: (typeof child.key === 'number' ? `${child.path}[${child.key}]` : `${child.path}.${child.key}`)
// 如果子节点匹配,显示
if (props.matchedPaths.has(childPath)) return true
// 如果子节点的子节点匹配,也显示
return checkChildrenMatch(child.value, childPath)
})
})
// 检查当前节点是否是匹配节点的子节点
const isChildOfMatched = computed(() => {
if (!hasFilter.value || isMatched.value) return false
// 检查当前路径是否以任何匹配路径开头(作为前缀)
for (const matchedPath of props.matchedPaths) {
if (matchedPath === 'root') continue
// 如果当前路径以匹配路径开头,且后面跟着 . 或 [,说明是子节点
const prefix = matchedPath + '.'
const prefixBracket = matchedPath + '['
if (currentPath.value.startsWith(prefix) || currentPath.value.startsWith(prefixBracket)) {
return true
}
}
return false
})
// 判断是否被筛选(有筛选但当前节点不匹配且没有匹配的子节点,且不是匹配节点的子节点)
const isFiltered = computed(() => {
if (!hasFilter.value) return false
if (isMatched.value) return false
if (isChildOfMatched.value) return false
return !hasMatchingChildren.value
})
const toggle = () => {
if (hasChildren.value) {
emit('toggle', currentPath.value)
}
}
</script>
<style scoped>
.json-tree-node {
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.8;
}
.node-line {
display: flex;
align-items: center;
padding: 2px 0;
cursor: default;
user-select: text;
}
.node-line.has-children {
cursor: pointer;
}
.node-line.has-children:hover {
background: #f0f0f0;
}
.expand-icon {
display: inline-block;
width: 16px;
text-align: center;
color: #666;
font-size: 10px;
margin-right: 4px;
}
.expand-icon i {
font-size: 10px;
}
.expand-placeholder {
display: inline-block;
width: 16px;
margin-right: 4px;
}
.node-path {
color: #666666;
font-size: 0.8125rem;
margin-right: 8px;
font-family: 'Courier New', monospace;
opacity: 0.7;
}
.node-key {
margin-right: 6px;
}
.key-name {
color: #881391;
font-weight: 500;
}
.node-value {
color: #1a1aa6;
}
.node-value.string {
color: #0b7500;
}
.node-value.number {
color: #1a1aa6;
}
.node-value.boolean {
color: #1a1aa6;
}
.node-value.null {
color: #808080;
}
.node-value.object {
color: #1a1aa6;
font-style: italic;
}
.node-value.array {
color: #1a1aa6;
font-style: italic;
}
.node-children {
margin-left: 20px;
border-left: 1px solid #e0e0e0;
padding-left: 8px;
}
/* 匹配的节点高亮样式 */
.json-tree-node.is-matched .node-line {
background: #fff9e6;
border-left: 3px solid #ff9800;
padding-left: 5px;
margin-left: -8px;
}
.json-tree-node.is-matched .key-name.matched {
color: #ff6f00;
font-weight: 600;
}
.json-tree-node.is-matched .node-value.matched {
color: #ff6f00;
font-weight: 500;
}
/* 被筛选掉的节点隐藏 */
.json-tree-node.is-filtered {
display: none;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
import { ref, nextTick } from 'vue'
const LINE_HEIGHT = 22.4
const PADDING = 16
export function countLines(text) {
return text ? text.split('\n').length : 1
}
export function useLineNumberEditor(getText, options = {}) {
const { onBeforeUpdate } = options
const containerRef = ref(null)
const editorRef = ref(null)
const lineCount = ref(1)
const adjustEditorHeight = () => {
const editor = editorRef.value
if (!editor) return
editor.style.height = 'auto'
const scrollHeight = editor.scrollHeight
const minHeight = PADDING + LINE_HEIGHT + PADDING
editor.style.height = `${Math.max(scrollHeight, minHeight)}px`
}
const updateLineCount = () => {
if (onBeforeUpdate) onBeforeUpdate()
lineCount.value = countLines(getText())
nextTick(adjustEditorHeight)
}
const resetEditorScroll = () => {
if (containerRef.value) containerRef.value.scrollTop = 0
if (editorRef.value) editorRef.value.scrollTop = 0
}
const initEditor = () => {
nextTick(updateLineCount)
}
return {
containerRef,
editorRef,
lineCount,
updateLineCount,
adjustEditorHeight,
resetEditorScroll,
initEditor,
}
}
+62
View File
@@ -0,0 +1,62 @@
const DEFAULTS = {
title: 'RC707的工具箱',
icp: '苏ICP备2022013040号-1',
gaId: '',
jinrishiciSdkUrl: '',
}
function getRuntimeConfig() {
if (typeof window !== 'undefined' && window.__SITE_CONFIG__) {
return window.__SITE_CONFIG__
}
return {}
}
function getConfigValue(runtimeKey, envKey, defaultValue) {
const runtime = getRuntimeConfig()
if (Object.prototype.hasOwnProperty.call(runtime, runtimeKey)) {
return runtime[runtimeKey]
}
const fromEnv = import.meta.env[envKey]
if (fromEnv != null) {
return fromEnv
}
return defaultValue
}
export function getSiteTitle() {
const value = getConfigValue('title', 'VITE_APP_TITLE', DEFAULTS.title)
return value || DEFAULTS.title
}
export function getSiteIcp() {
return getConfigValue('icp', 'VITE_APP_ICP', DEFAULTS.icp)
}
export function getGaMeasurementId() {
return getConfigValue('gaId', 'VITE_GA_MEASUREMENT_ID', DEFAULTS.gaId)
}
export function getJinrishiciSdkUrl() {
return getConfigValue('jinrishiciSdkUrl', 'VITE_JINRISHICI_SDK_URL', DEFAULTS.jinrishiciSdkUrl)
}
export function getPageTitle(suffix) {
const base = getSiteTitle()
return suffix ? `${base}-${suffix}` : base
}
export const siteConfig = {
get title() {
return getSiteTitle()
},
get icp() {
return getSiteIcp()
},
get gaMeasurementId() {
return getGaMeasurementId()
},
get jinrishiciSdkUrl() {
return getJinrishiciSdkUrl()
},
}
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { initAnalytics } from './utils/analytics.js'
import './style.css'
// 引入 Font Awesome 6.4
import '@fortawesome/fontawesome-free/css/all.css'
initAnalytics()
createApp(App).use(router).mount('#app')
+82
View File
@@ -0,0 +1,82 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import { getPageTitle } from '../config/site.js'
const routes = [
{
path: '/',
name: 'Home',
component: Home,
meta: {
titleSuffix: null
}
},
{
path: '/json-formatter',
name: 'JsonFormatter',
component: () => import('../views/JsonFormatter.vue'),
meta: {
titleSuffix: 'JSON'
}
},
{
path: '/comparator',
name: 'Comparator',
component: () => import('../views/Comparator.vue'),
meta: {
titleSuffix: '对比'
}
},
{
path: '/encoder-decoder',
name: 'EncoderDecoder',
component: () => import('../views/EncoderDecoder.vue'),
meta: {
titleSuffix: '编解码'
}
},
{
path: '/variable-name',
name: 'VariableNameConverter',
component: () => import('../views/VariableNameConverter.vue'),
meta: {
titleSuffix: '变量名'
}
},
{
path: '/qr-code',
name: 'QRCodeGenerator',
component: () => import('../views/QRCodeGenerator.vue'),
meta: {
titleSuffix: '二维码'
}
},
{
path: '/timestamp-converter',
name: 'TimestampConverter',
component: () => import('../views/TimestampConverter.vue'),
meta: {
titleSuffix: '时间戳'
}
},
{
path: '/color-converter',
name: 'ColorConverter',
component: () => import('../views/ColorConverter.vue'),
meta: {
titleSuffix: '颜色'
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
document.title = getPageTitle(to.meta.titleSuffix)
next()
})
export default router
+19
View File
@@ -0,0 +1,19 @@
@import './styles/line-number-editor.css';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #ffffff;
min-height: 100vh;
color: #000000;
}
#app {
min-height: 100vh;
}
+44
View File
@@ -0,0 +1,44 @@
.editor-container {
flex: 1;
display: block;
position: relative;
overflow-y: auto;
overflow-x: hidden;
background: #ffffff;
min-height: 0;
}
.editor-body {
display: flex;
align-items: flex-start;
min-height: 100%;
}
.editor-body > .line-numbers {
flex-shrink: 0;
width: 40px;
padding: 1rem 0.5rem;
background: #fafafa;
border-right: 1px solid #e5e5e5;
font-family: 'Courier New', monospace;
font-size: 14px;
color: #999999;
text-align: right;
user-select: none;
box-sizing: border-box;
}
.editor-body > .line-numbers .line-number {
line-height: 1.6;
height: 22.4px;
display: flex;
align-items: center;
justify-content: flex-end;
}
@media (max-width: 768px) {
.editor-body > .line-numbers {
width: 32px;
font-size: 12px;
}
}
+18
View File
@@ -0,0 +1,18 @@
import { getGaMeasurementId } from '../config/site.js'
export function initAnalytics() {
const measurementId = getGaMeasurementId()
if (!measurementId) return
window.dataLayer = window.dataLayer || []
window.gtag = function gtag() {
window.dataLayer.push(arguments)
}
window.gtag('js', new Date())
window.gtag('config', measurementId)
const script = document.createElement('script')
script.async = true
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`
document.head.appendChild(script)
}
+10
View File
@@ -0,0 +1,10 @@
export function getByteLength(str) {
return new TextEncoder().encode(str).length
}
export function truncateToMaxBytes(str, maxBytes) {
if (getByteLength(str) <= maxBytes) return str
let end = str.length
while (end > 0 && getByteLength(str.slice(0, end)) > maxBytes) end--
return str.slice(0, end)
}
+87
View File
@@ -0,0 +1,87 @@
export function rgbToHex(r, g, b) {
const toHex = (n) => {
const hex = Math.round(Math.max(0, Math.min(255, n))).toString(16)
return hex.length === 1 ? '0' + hex : hex
}
return (toHex(r) + toHex(g) + toHex(b)).toUpperCase()
}
export function rgbToHsl(r, g, b) {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h, s, l = (max + min) / 2
if (max === min) {
h = s = 0
} else {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6
break
case g:
h = ((b - r) / d + 2) / 6
break
case b:
h = ((r - g) / d + 4) / 6
break
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
}
}
export function hexToRgb(hex) {
const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: null
}
export function hslToRgb(h, s, l) {
h /= 360
s /= 100
l /= 100
let r, g, b
if (s === 0) {
r = g = b = l
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1 / 3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1 / 3)
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
}
}
+31
View File
@@ -0,0 +1,31 @@
export function escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export function highlightDiff(text, diffRanges) {
if (!diffRanges || diffRanges.length === 0) {
return escapeHtml(text)
}
let result = ''
let lastIndex = 0
diffRanges.forEach(range => {
if (range.start > lastIndex) {
result += escapeHtml(text.substring(lastIndex, range.start))
}
result += `<span class="diff-highlight">${escapeHtml(text.substring(range.start, range.end))}</span>`
lastIndex = range.end
})
if (lastIndex < text.length) {
result += escapeHtml(text.substring(lastIndex))
}
return result
}
+3
View File
@@ -0,0 +1,3 @@
export * from './html.js'
export * from './textDiff.js'
export * from './jsonCompare.js'
+771
View File
@@ -0,0 +1,771 @@
export function isNullOrUndefined(value) {
return value === undefined || value === null
}
export function isNotNullAndUndefined(value) {
return value !== undefined && value !== null
}
// 判断是否为基本类型(除字符串外)
export function isPrimitiveTypeOrNon(value) {
return isNullOrUndefined(value) ||
typeof value === 'number' ||
typeof value === 'boolean' ||
(typeof value === 'string' && false)
}
export function isPrimitiveType(value) {
return isNotNullAndUndefined(value) && (
typeof value === 'number' ||
typeof value === 'boolean' ||
(typeof value === 'string' && false)
)
}
// 判断是否为字符串
export function isStringTypeOrNon(value) {
return isNullOrUndefined(value) || typeof value === 'string'
}
export function isStringType(value) {
return isNotNullAndUndefined(value) && typeof value === 'string'
}
// 判断是否为map
export function isMapTypeOrNon(value) {
return isNullOrUndefined(value) || (typeof value === 'object' && !Array.isArray(value))
}
export function isMapType(value) {
return isNotNullAndUndefined(value) && typeof value === 'object' && !Array.isArray(value)
}
// 判断是否为list
export function isListTypeOrNon(value) {
return isNullOrUndefined(value) || Array.isArray(value)
}
export function isListType(value) {
return isNotNullAndUndefined(value) && Array.isArray(value)
}
// 判断是否为叶子节点(基本类型或字符串)
export function isLeafNodeOrNon(value) {
return isNullOrUndefined(value) ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'string'
}
// 计算字符串的最长公共子串长度
export function longestCommonSubstring(strA, strB) {
if (!strA || !strB) return 0
const m = strA.length
const n = strB.length
let maxLen = 0
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0))
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (strA[i - 1] === strB[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
maxLen = Math.max(maxLen, dp[i][j])
} else {
dp[i][j] = 0
}
}
}
return maxLen
}
// 计算字符串相似度(公共子串占全体的比例)
export function stringSimilarity(strA, strB) {
if (strA === strB) return {type: 'same', similarity: 1.0}
const maxLen = Math.max(strA.length, strB.length)
if (maxLen === 0) return {type: 'same', similarity: 1.0}
const lcsLen = longestCommonSubstring(strA, strB)
if (lcsLen === 0) {
return {type: 'different', similarity: 0.0}
}
const similarity = lcsLen / maxLen
return {type: 'similar', similarity}
}
// 节点对比结果类型
export const NodeComparisonResult = {
SAME: 'same', // 相同
SIMILAR: 'similar', // 相似
DIFFERENT: 'different' // 不同
}
// 对比两个JSON节点,返回对比结果和相似度
export function compareJsonNodes(nodeA, nodeB, ignoreOrder = false) {
const result = (type, similarity) => ({ type, similarity, nodeA, nodeB })
if (isNullOrUndefined(nodeA) && isNullOrUndefined(nodeB)) {
return nodeA === nodeB ? result(NodeComparisonResult.SAME, 1.0) : result(NodeComparisonResult.DIFFERENT, 0.0)
}
if (isNullOrUndefined(nodeA) || isNullOrUndefined(nodeB)) {
return result(NodeComparisonResult.DIFFERENT, 0.0)
}
if (isPrimitiveType(nodeA) && isPrimitiveType(nodeB)) {
return nodeA === nodeB ? result(NodeComparisonResult.SAME, 1.0) : result(NodeComparisonResult.DIFFERENT, 0.0)
}
if (isStringType(nodeA) && isStringType(nodeB)) {
const { type, similarity } = stringSimilarity(nodeA, nodeB)
return result(type, similarity)
}
if (typeof nodeA !== typeof nodeB) {
return result(NodeComparisonResult.DIFFERENT, 0.0)
}
if (isMapType(nodeA) && isMapType(nodeB)) return compareMaps(nodeA, nodeB, ignoreOrder)
if (isListType(nodeA) && isListType(nodeB)) {
// 根据参数选择是否忽略列表顺序
return ignoreOrder ? compareListsIgnoreOrder(nodeA, nodeB, ignoreOrder) : compareLists(nodeA, nodeB, ignoreOrder)
}
return result(NodeComparisonResult.DIFFERENT, 0.0)
}
// 对比Map(对象)
export function compareMaps(mapA, mapB, ignoreOrder = false) {
const keysA = Object.keys(mapA).sort()
const keysB = Object.keys(mapB).sort()
const setA = new Set(keysA)
const setB = new Set(keysB)
const allKeys = new Set([...keysA, ...keysB])
const comparisons = []
let allSame = true
let allDifferent = true
let allInsert = true
let allDelete = true
for (const key of allKeys) {
const hasKeyA = setA.has(key)
const hasKeyB = setB.has(key)
const valueComparison = compareJsonNodes(
hasKeyA ? mapA[key] : undefined,
hasKeyB ? mapB[key] : undefined,
ignoreOrder
)
if (hasKeyA && hasKeyB && valueComparison.type === NodeComparisonResult.DIFFERENT) {
valueComparison.type = NodeComparisonResult.SIMILAR
valueComparison.similarity = 0.5
}
if (valueComparison.type === NodeComparisonResult.SAME) {
allDifferent = false
} else if (valueComparison.type === NodeComparisonResult.SIMILAR) {
allSame = false
allDifferent = false
allInsert = false
allDelete = false
} else if (valueComparison.type === NodeComparisonResult.DIFFERENT) {
allSame = false
if (isNullOrUndefined(mapA) || hasKeyA) allInsert = false
if (isNullOrUndefined(mapB) || hasKeyB) allDelete = false
}
comparisons.push({ ...valueComparison, key })
}
const base = { nodeA: mapA, nodeB: mapB, children: comparisons }
if (allSame) {
return { type: NodeComparisonResult.SAME, similarity: 1.0, ...base }
}
if (allDifferent && !allInsert && !allDelete) {
return { type: NodeComparisonResult.DIFFERENT, similarity: 0.0, ...base }
}
const avgSimilarity = comparisons.length > 0
? comparisons.reduce((sum, c) => sum + c.similarity, 0) / comparisons.length
: 0
return { type: NodeComparisonResult.SIMILAR, similarity: avgSimilarity, ...base }
}
// 对比List(数组)- 忽略顺序,使用贪心算法寻找最佳匹配
export function compareListsIgnoreOrder(listA, listB, ignoreOrder = false) {
const n = listA.length
const m = listB.length
// 计算所有元素对的相似度矩阵
const similarityMatrix = []
for (let i = 0; i < n; i++) {
similarityMatrix[i] = []
for (let j = 0; j < m; j++) {
const comp = compareJsonNodes(listA[i], listB[j], ignoreOrder)
similarityMatrix[i][j] = comp
}
}
// 使用贪心算法找到最佳匹配(不考虑顺序)
// 每次选择相似度最高的未匹配对
const matches = []
const usedA = new Set()
const usedB = new Set()
// 创建所有可能的匹配对,按相似度降序排序
const allPairs = []
for (let i = 0; i < n; i++) {
for (let j = 0; j < m; j++) {
allPairs.push({
indexA: i,
indexB: j,
similarity: similarityMatrix[i][j].similarity,
comparison: similarityMatrix[i][j]
})
}
}
allPairs.sort((a, b) => b.similarity - a.similarity)
// 贪心匹配:选择相似度最高的未匹配对
for (const pair of allPairs) {
if (!usedA.has(pair.indexA) && !usedB.has(pair.indexB)) {
matches.push({
indexA: pair.indexA,
indexB: pair.indexB,
comparison: pair.comparison
})
usedA.add(pair.indexA)
usedB.add(pair.indexB)
}
}
// 添加未匹配的A中元素(删除)
for (let i = 0; i < n; i++) {
if (!usedA.has(i)) {
matches.push({
indexA: i,
indexB: undefined,
comparison: {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: listA[i],
nodeB: undefined
}
})
}
}
// 添加未匹配的B中元素(插入)
for (let j = 0; j < m; j++) {
if (!usedB.has(j)) {
matches.push({
indexA: undefined,
indexB: j,
comparison: {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: undefined,
nodeB: listB[j]
}
})
}
}
// 按原始顺序排序匹配结果(先A后B,保持展示的一致性)
matches.sort((a, b) => {
if (a.indexA !== undefined && b.indexA !== undefined) {
return a.indexA - b.indexA
}
if (a.indexA !== undefined) return -1
if (b.indexA !== undefined) return 1
if (a.indexB !== undefined && b.indexB !== undefined) {
return a.indexB - b.indexB
}
return 0
})
// 判断父节点类型
const totalElements = n + m
const matchedElements = matches.filter(m => m.indexA !== undefined && m.indexB !== undefined).length
const sameMatches = matches.filter(m =>
m.indexA !== undefined &&
m.indexB !== undefined &&
m.comparison.type === NodeComparisonResult.SAME
).length
const differentMatches = matches.filter(m =>
m.comparison.type === NodeComparisonResult.DIFFERENT
).length
if (totalElements === 0) {
return {
type: NodeComparisonResult.SAME,
similarity: 1.0,
nodeA: listA,
nodeB: listB,
matches
}
}
if (n !== 0 && m !== 0 && differentMatches === totalElements) {
return {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: listA,
nodeB: listB,
matches
}
}
if (sameMatches === matchedElements && matchedElements === totalElements) {
return {
type: NodeComparisonResult.SAME,
similarity: 1.0,
nodeA: listA,
nodeB: listB,
matches
}
}
// 计算加权平均相似度
const totalSimilarity = matches.reduce((sum, m) => sum + m.comparison.similarity, 0)
const avgSimilarity = totalElements > 0 ? totalSimilarity / totalElements : 0
return {
type: NodeComparisonResult.SIMILAR,
similarity: avgSimilarity,
nodeA: listA,
nodeB: listB,
matches
}
}
// 对比List(数组)- 寻找最佳匹配使相似度最高
export function compareLists(listA, listB, ignoreOrder = false) {
const n = listA.length
const m = listB.length
// 计算所有元素对的相似度矩阵
const similarityMatrix = []
for (let i = 0; i < n; i++) {
similarityMatrix[i] = []
for (let j = 0; j < m; j++) {
const comp = compareJsonNodes(listA[i], listB[j], ignoreOrder)
similarityMatrix[i][j] = comp
}
}
// 使用动态规划寻找最佳匹配(最大化总相似度)
// dp[i][j] 表示 listA[0..i-1] 和 listB[0..j-1] 的最佳匹配总相似度
const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0))
const path = Array(n + 1).fill(null).map(() => Array(m + 1).fill(null))
// 初始化边界情况:当j=0时,只能跳过A中的元素
for (let i = 1; i <= n; i++) {
path[i][0] = 'skipA'
}
// 初始化边界情况:当i=0时,只能跳过B中的元素
for (let j = 1; j <= m; j++) {
path[0][j] = 'skipB'
}
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
const matchSimilarity = similarityMatrix[i - 1][j - 1].similarity
const matchScore = dp[i - 1][j - 1] + matchSimilarity
const skipAScore = dp[i - 1][j]
const skipBScore = dp[i][j - 1]
if (matchScore >= skipAScore && matchScore >= skipBScore) {
dp[i][j] = matchScore
path[i][j] = 'match'
} else if (skipAScore >= skipBScore) {
dp[i][j] = skipAScore
path[i][j] = 'skipA'
} else {
dp[i][j] = skipBScore
path[i][j] = 'skipB'
}
}
}
// 回溯构建匹配结果
const matches = []
let i = n, j = m
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && path[i][j] === 'match') {
matches.unshift({
indexA: i - 1,
indexB: j - 1,
comparison: similarityMatrix[i - 1][j - 1]
})
i--
j--
} else if (i > 0 && path[i][j] === 'skipA') {
matches.unshift({
indexA: i - 1,
indexB: undefined,
comparison: {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: listA[i - 1],
nodeB: undefined
}
})
i--
} else if (j > 0) {
matches.unshift({
indexA: undefined,
indexB: j - 1,
comparison: {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: undefined,
nodeB: listB[j - 1]
}
})
j--
} else {
break
}
}
// 判断父节点类型
const totalElements = n + m
const matchedElements = matches.filter(m => m.indexA !== null && m.indexB !== null).length
const sameMatches = matches.filter(m =>
m.indexA !== undefined &&
m.indexB !== undefined &&
m.comparison.type === NodeComparisonResult.SAME
).length
const differentMatches = matches.filter(m =>
m.comparison.type === NodeComparisonResult.DIFFERENT
).length
if (totalElements === 0) {
return {
type: NodeComparisonResult.SAME,
similarity: 1.0,
nodeA: listA,
nodeB: listB,
matches
}
}
if (n !== 0 && m !== 0 && differentMatches === totalElements) {
return {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: listA,
nodeB: listB,
matches
}
}
if (sameMatches === matchedElements && matchedElements === totalElements) {
return {
type: NodeComparisonResult.SAME,
similarity: 1.0,
nodeA: listA,
nodeB: listB,
matches
}
}
// 计算加权平均相似度
const totalSimilarity = matches.reduce((sum, m) => sum + m.comparison.similarity, 0)
const avgSimilarity = totalElements > 0 ? totalSimilarity / totalElements : 0
return {
type: NodeComparisonResult.SIMILAR,
similarity: avgSimilarity,
nodeA: listA,
nodeB: listB,
matches
}
}
// 在展示行末尾添加/移除逗号的辅助
export function addTrailingComma(line, lastNotEmptyRef) {
if (line.content !== '') {
line.content += ','
lastNotEmptyRef.current = line
}
}
export function removeTrailingComma(lastNotEmptyRef) {
if (lastNotEmptyRef.current !== undefined) {
lastNotEmptyRef.current.content = lastNotEmptyRef.current.content.slice(0, -1)
}
}
// 将对比结果转换为展示格式
export function convertComparisonToDisplay(comparison, indent = 0) {
const indentStr = ' '.repeat(indent)
const childIndentStr = ' '.repeat(indent + 1)
const isLeaf = !comparison.children && !comparison.matches
// 根据展示规则确定类型
const { type, nodeA, nodeB } = comparison
let displayType = 'same'
if (type === NodeComparisonResult.DIFFERENT) {
if (nodeA !== undefined && nodeB === undefined) displayType = 'delete'
else if (nodeA === undefined && nodeB !== undefined) displayType = 'insert'
else displayType = 'different'
} else if (type === NodeComparisonResult.SIMILAR && isLeaf) {
displayType = 'modify'
}
// 处理 Map(对象)
if (!isLeaf && isMapType(nodeA) && isMapType(nodeB)) {
const leftLines = []
const rightLines = []
const sortedKeys = [...new Set(comparison.children.map(c => c.key).filter(Boolean))].sort()
const commaLeft = { current: undefined }
const commaRight = { current: undefined }
leftLines.push({ type: displayType, content: indentStr + '{' })
rightLines.push({ type: displayType, content: indentStr + '{' })
for (const key of sortedKeys) {
const child = comparison.children.find(c => c.key === key)
const childResult = convertComparisonToDisplay(child, indent + 1)
if (childResult.left.length === 0 && childResult.right.length === 0) continue
const leftFirst = childResult.left[0]?.content ?? null
const rightFirst = childResult.right[0]?.content ?? null
const formatFirst = (content) => (content === null || content === '' ? '' : content.trimStart())
leftLines.push({
type: childResult.left[0].type,
content: leftFirst === null || leftFirst === '' ? '' : `${childIndentStr}"${key}": ${formatFirst(leftFirst)}`
})
rightLines.push({
type: childResult.right[0].type,
content: rightFirst === null || rightFirst === '' ? '' : `${childIndentStr}"${key}": ${formatFirst(rightFirst)}`
})
const maxLength = Math.max(childResult.left.length, childResult.right.length)
for (let j = 1; j < maxLength; j++) {
leftLines.push(
j < childResult.left.length ? childResult.left[j] : { type: 'insert', content: '' }
)
rightLines.push(
j < childResult.right.length ? childResult.right[j] : { type: 'delete', content: '' }
)
}
addTrailingComma(leftLines[leftLines.length - 1], commaLeft)
addTrailingComma(rightLines[rightLines.length - 1], commaRight)
}
removeTrailingComma(commaLeft)
removeTrailingComma(commaRight)
leftLines.push({ type: displayType, content: indentStr + '}' })
rightLines.push({ type: displayType, content: indentStr + '}' })
return { left: leftLines, right: rightLines }
}
// 处理 List(数组)
if (!isLeaf && isListType(nodeA) && isListType(nodeB)) {
const leftLines = []
const rightLines = []
const commaLeft = { current: undefined }
const commaRight = { current: undefined }
leftLines.push({ type: displayType, content: indentStr + '[' })
rightLines.push({ type: displayType, content: indentStr + '[' })
for (const match of comparison.matches) {
const childResult = convertComparisonToDisplay(match.comparison, indent + 1)
if (childResult.left.length === 0 && childResult.right.length === 0) continue
const leftFirst = childResult.left[0]?.content
const rightFirst = childResult.right[0]?.content
const leftFirstLine = {
type: childResult.left[0].type,
content: leftFirst === undefined || leftFirst === '' ? '' : `${childIndentStr}${leftFirst.trimStart()}`
}
const rightFirstLine = {
type: childResult.right[0].type,
content: rightFirst === undefined || rightFirst === '' ? '' : `${childIndentStr}${rightFirst.trimStart()}`
}
leftLines.push(leftFirstLine)
rightLines.push(rightFirstLine)
let leftLastLine = leftFirstLine
let rightLastLine = rightFirstLine
const maxLength = Math.max(childResult.left.length, childResult.right.length)
for (let j = 1; j < maxLength; j++) {
const leftLine = j < childResult.left.length ? childResult.left[j] : { type: 'insert', content: '' }
const rightLine = j < childResult.right.length ? childResult.right[j] : { type: 'delete', content: '' }
if (leftLine.content !== '') leftLastLine = leftLine
if (rightLine.content !== '') rightLastLine = rightLine
leftLines.push(leftLine)
rightLines.push(rightLine)
}
addTrailingComma(leftLastLine, commaLeft)
addTrailingComma(rightLastLine, commaRight)
}
removeTrailingComma(commaLeft)
removeTrailingComma(commaRight)
leftLines.push({ type: displayType, content: indentStr + ']' })
rightLines.push({ type: displayType, content: indentStr + ']' })
return { left: leftLines, right: rightLines }
}
// 处理叶子节点
const leftLines = []
const rightLines = []
const leftData = nodeA !== undefined ? formatJsonData(nodeA, indent) : ['']
const rightData = nodeB !== undefined ? formatJsonData(nodeB, indent) : ['']
if (displayType === 'different') {
for (let i = 0; i < leftData.length; i++) {
leftLines.push({ type: 'delete', content: leftData[i] })
rightLines.push({ type: 'delete', content: '' })
}
for (let i = 0; i < rightData.length; i++) {
leftLines.push({ type: 'insert', content: '' })
rightLines.push({ type: 'insert', content: (i === 0 && rightData[i] !== '' ? indentStr : '') + rightData[i] })
}
} else {
const maxLineSize = Math.max(leftData.length, rightData.length)
for (let i = 0; i < maxLineSize; i++) {
const leftContent = i < leftData.length ? leftData[i] : undefined
const rightContent = i < rightData.length ? (i === 0 && rightData[i] !== '' ? indentStr : '') + rightData[i] : undefined
leftLines.push({ type: displayType, content: leftContent ?? '' })
rightLines.push({ type: displayType, content: rightContent ?? '' })
}
}
return { left: leftLines, right: rightLines }
}
// 格式化JSON值为字符串
export function formatJsonData(value, indent = 0) {
const indentStr = ' '.repeat(indent)
if (value === null) {
return ["null"]
}
if (typeof value === 'string') {
return [`"${value}"`]
}
if (typeof value === 'number' || typeof value === 'boolean') {
return [String(value)]
}
if (Array.isArray(value)) {
if (value.length === 0) return ['[]']
const lines = ["["]
for (let i = 0; i < value.length; i++) {
const itemLines = formatJsonData(value[i], indent + 1)
for (let j = 0; j < itemLines.length; j++) {
let content = j === 0 ? `${indentStr} ` : ''
content = content + itemLines[j]
if (i < value.length - 1 && j === itemLines.length - 1) {
content = content + ','
}
lines.push(content)
}
}
lines.push(`${indentStr}]`)
return lines
}
if (typeof value === 'object') {
const keys = Object.keys(value).sort()
if (keys.length === 0) return ['{}']
const lines = ['{']
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const val = value[key]
const itemLines = formatJsonData(val, indent + 1)
for (let j = 0; j < itemLines.length; j++) {
let content = j === 0 ? `${indentStr} "${key}": ` : ''
content = content + itemLines[j]
if (i < value.length - 1 && j === itemLines.length - 1) {
content = content + ','
}
lines.push(content)
}
}
lines.push(`${indentStr}}`)
return lines
}
return [String(value)]
}
// 递归计算统计信息
export function calculateStats(comparison) {
const stats = {same: 0, insert: 0, delete: 0, modify: 0}
const isLeaf = isLeafNodeOrNon(comparison.nodeA) || isLeafNodeOrNon(comparison.nodeB)
if (comparison.type === NodeComparisonResult.DIFFERENT) {
// 不同节点:根据展示规则,左侧显示为删除,右侧显示为插入
if (comparison.nodeA !== undefined && comparison.nodeB === undefined) {
stats.delete++
} else if (comparison.nodeA === undefined && comparison.nodeB !== undefined) {
stats.insert++
} else {
// 两个节点都存在但不同
stats.delete++ // 左侧
stats.insert++ // 右侧
}
} else if (comparison.type === NodeComparisonResult.SAME ||
comparison.type === NodeComparisonResult.SIMILAR) {
// 相同或相似节点
if (isLeaf) {
// 叶子节点:视作修改
if (comparison.type === NodeComparisonResult.SAME) {
stats.same++;
} else {
stats.modify++;
}
} else {
if (comparison.children) {
comparison.children.forEach(child => {
const childStats = calculateStats(child)
stats.same += childStats.same
stats.insert += childStats.insert
stats.delete += childStats.delete
stats.modify += childStats.modify
})
}
if (comparison.matches) {
comparison.matches.forEach(match => {
const matchStats = calculateStats(match.comparison)
stats.same += matchStats.same
stats.insert += matchStats.insert
stats.delete += matchStats.delete
stats.modify += matchStats.modify
})
}
}
}
return stats
}
export function compareJson(textA, textB, ignoreListOrder = false) {
try {
const jsonA = JSON.parse(textA)
const jsonB = JSON.parse(textB)
const comparison = compareJsonNodes(jsonA, jsonB, ignoreListOrder)
const displayResult = convertComparisonToDisplay(comparison, 0)
const stats = calculateStats(comparison)
const maxLines = Math.max(displayResult.left.length, displayResult.right.length)
const leftLines = []
const rightLines = []
for (let i = 0; i < maxLines; i++) {
const leftLine = displayResult.left[i] || { type: 'same', content: '' }
const rightLine = displayResult.right[i] || { type: 'same', content: '' }
leftLines.push({ type: leftLine.type, content: leftLine.content, lineNumber: i + 1 })
rightLines.push({ type: rightLine.type, content: rightLine.content, lineNumber: i + 1 })
}
return { left: leftLines, right: rightLines, stats }
} catch (e) {
throw new Error('JSON解析失败:' + e.message)
}
}
+375
View File
@@ -0,0 +1,375 @@
import { escapeHtml } from './html.js'
export function shouldMergeAsModify(lineA, lineB) {
// 如果两行完全相同,不应该到达这里
if (lineA === lineB) return false
// 对于JSON格式的行,检查是否是同一类型的结构
// 例如:都是键值对,但值不同;或者都是数组元素等
const trimmedA = lineA.trim()
const trimmedB = lineB.trim()
// 如果都是键值对格式("key": value),且键相同,认为是修改
const keyValuePattern = /^\s*"([^"]+)":\s*(.+)$/
const matchA = trimmedA.match(keyValuePattern)
const matchB = trimmedB.match(keyValuePattern)
if (matchA && matchB) {
// 如果键相同,认为是修改
if (matchA[1] === matchB[1]) {
return true
}
// 如果键不同,认为是删除+插入
return false
}
// 如果都是数组元素或对象结构,检查结构相似性
// 这里简化处理:如果行结构相似(都有相同的括号、引号等),可能是修改
const structureA = trimmedA.replace(/"[^"]*"/g, '"..."').replace(/\d+/g, '0')
const structureB = trimmedB.replace(/"[^"]*"/g, '"..."').replace(/\d+/g, '0')
// 如果结构相似度较高,认为是修改
// 这里使用简单的启发式:如果结构字符串的前几个字符相同
if (structureA.length > 0 && structureB.length > 0) {
const minLen = Math.min(structureA.length, structureB.length)
let samePrefix = 0
for (let k = 0; k < minLen && k < 10; k++) {
if (structureA[k] === structureB[k]) {
samePrefix++
} else {
break
}
}
// 如果前缀相似度超过50%,认为是修改
if (samePrefix / minLen > 0.5) {
return true
}
}
// 默认不合并为修改,分别显示为删除和插入
return false
}
export function computeDiff(arrA, arrB) {
const n = arrA.length
const m = arrB.length
// 使用动态规划计算LCS
const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0))
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (arrA[i - 1] === arrB[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
// 回溯找出所有匹配点。当同一字符在 B 中出现多次时,优先选 j 更小的(靠左匹配)
const matches = []
let i = n, j = m
while (i > 0 && j > 0) {
if (arrA[i - 1] === arrB[j - 1]) {
// 若 dp[i][j-1] === dp[i][j],说明不匹配 (i-1,j-1) 也能得到同样长的 LCS,可先 j-- 尝试更靠左的匹配
if (j > 1 && dp[i][j - 1] === dp[i][j]) {
j--
} else {
matches.unshift({x: i - 1, y: j - 1})
i--
j--
}
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--
} else {
j--
}
}
// 构建路径:每个匹配点都要加入,否则 compareTextByChar 会漏掉匹配(如 hello vs helloworld 的 o
const path = []
for (const match of matches) {
path.push({x: match.x, y: match.y})
}
if (path.length > 0) {
const last = path[path.length - 1]
if (last.x < n || last.y < m) {
path.push({x: n, y: m})
}
} else {
path.push({x: n, y: m})
}
return path
}
export function compareTextByLine(textA, textB) {
const linesA = textA.split('\n')
const linesB = textB.split('\n')
const n = linesA.length
const m = linesB.length
// 使用动态规划计算LCS
const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0))
const path = Array(n + 1).fill(null).map(() => Array(m + 1).fill(null))
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (linesA[i - 1] === linesB[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
path[i][j] = 'match'
} else if (dp[i - 1][j] > dp[i][j - 1]) {
dp[i][j] = dp[i - 1][j]
path[i][j] = 'delete'
} else {
dp[i][j] = dp[i][j - 1]
path[i][j] = 'insert'
}
}
}
// 回溯构建结果
const resultA = []
const resultB = []
let stats = {same: 0, insert: 0, delete: 0, modify: 0}
let i = n, j = m
let lineNumA = n, lineNumB = m
const pendingDelete = []
const pendingInsert = []
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && path[i][j] === 'match') {
// 处理待处理的删除和插入
// 只有在内容相似时才合并为修改
while (pendingDelete.length > 0 && pendingInsert.length > 0) {
const deleteLine = pendingDelete[0]
const insertLine = pendingInsert[0]
if (shouldMergeAsModify(deleteLine, insertLine)) {
resultA.unshift({type: 'modify', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'modify', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.modify++
} else {
// 不相似,分别显示为删除和插入
resultA.unshift({type: 'delete', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'delete', content: '', lineNumber: null})
stats.delete++
resultA.unshift({type: 'insert', content: '', lineNumber: null})
resultB.unshift({type: 'insert', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.insert++
}
}
// 处理剩余的删除
while (pendingDelete.length > 0) {
resultA.unshift({type: 'delete', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'delete', content: '', lineNumber: null})
stats.delete++
}
// 处理剩余的插入
while (pendingInsert.length > 0) {
resultA.unshift({type: 'insert', content: '', lineNumber: null})
resultB.unshift({type: 'insert', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.insert++
}
// 添加匹配的行
resultA.unshift({type: 'same', content: linesA[i - 1], lineNumber: lineNumA})
resultB.unshift({type: 'same', content: linesB[j - 1], lineNumber: lineNumB})
stats.same++
i--
j--
lineNumA--
lineNumB--
} else if (i > 0 && path[i][j] === 'delete') {
pendingDelete.unshift(linesA[i - 1])
i--
lineNumA--
} else if (j > 0 && path[i][j] === 'insert') {
pendingInsert.unshift(linesB[j - 1])
j--
lineNumB--
} else if (i > 0) {
pendingDelete.unshift(linesA[i - 1])
i--
lineNumA--
} else if (j > 0) {
pendingInsert.unshift(linesB[j - 1])
j--
lineNumB--
} else {
break
}
}
// 处理剩余的待处理项
while (pendingDelete.length > 0 && pendingInsert.length > 0) {
const deleteLine = pendingDelete[0]
const insertLine = pendingInsert[0]
if (shouldMergeAsModify(deleteLine, insertLine)) {
resultA.unshift({type: 'modify', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'modify', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.modify++
} else {
// 不相似,分别显示为删除和插入
resultA.unshift({type: 'delete', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'delete', content: '', lineNumber: null})
stats.delete++
resultA.unshift({type: 'insert', content: '', lineNumber: null})
resultB.unshift({type: 'insert', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.insert++
}
}
while (pendingDelete.length > 0) {
resultA.unshift({type: 'delete', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'delete', content: '', lineNumber: null})
stats.delete++
}
while (pendingInsert.length > 0) {
resultA.unshift({type: 'insert', content: '', lineNumber: null})
resultB.unshift({type: 'insert', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.insert++
}
return {left: resultA, right: resultB, stats}
}
export function compareTextByChar(textA, textB) {
const linesA = textA.split('\n')
const linesB = textB.split('\n')
const resultA = []
const resultB = []
let stats = {same: 0, insert: 0, delete: 0, modify: 0}
const maxLines = Math.max(linesA.length, linesB.length)
for (let i = 0; i < maxLines; i++) {
const lineA = linesA[i] || ''
const lineB = linesB[i] || ''
if (lineA === lineB) {
resultA.push({type: 'same', content: lineA, html: escapeHtml(lineA), lineNumber: i + 1})
resultB.push({type: 'same', content: lineB, html: escapeHtml(lineB), lineNumber: i + 1})
stats.same += lineA.length
} else {
// 按字符进行diff,统计按高亮单元(字符)计数
const charsA = lineA.split('')
const charsB = lineB.split('')
const charDiff = computeDiff(charsA, charsB)
let htmlA = ''
let htmlB = ''
let x = 0, y = 0
let hasDiff = false
for (let j = 0; j < charDiff.length; j++) {
const point = charDiff[j]
const nextPoint = charDiff[j + 1] || {x: charsA.length, y: charsB.length}
// 从 (x,y) 到匹配点 (point.x, point.y) 之前:只可能是差异(如 na vs aa 中 (0,0) 是修改不是相同)
while (x < point.x || y < point.y) {
if (x < point.x && y < point.y) {
htmlA += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsA[x])}</span>`
htmlB += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsB[y])}</span>`
stats.modify++
hasDiff = true
x++
y++
} else if (x < point.x) {
htmlA += `<span class="diff-highlight diff-highlight-delete">${escapeHtml(charsA[x])}</span>`
htmlB += ''
stats.delete++
hasDiff = true
x++
} else {
htmlA += ''
htmlB += `<span class="diff-highlight diff-highlight-insert">${escapeHtml(charsB[y])}</span>`
stats.insert++
hasDiff = true
y++
}
}
// 仅匹配点 (point.x, point.y) 为相同(终点 (n,m) 不是匹配点,不输出)
if (x === point.x && y === point.y && point.x < charsA.length && point.y < charsB.length) {
htmlA += escapeHtml(charsA[x])
htmlB += escapeHtml(charsB[y])
stats.same++
x++
y++
}
// 差异的字符:修改用黄色,仅左为删除(红),仅右为插入(蓝)
if (x < nextPoint.x && y < nextPoint.y) {
htmlA += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsA[x])}</span>`
htmlB += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsB[y])}</span>`
stats.modify++
hasDiff = true
x++
y++
} else if (x < nextPoint.x) {
htmlA += `<span class="diff-highlight diff-highlight-delete">${escapeHtml(charsA[x])}</span>`
htmlB += ''
stats.delete++
hasDiff = true
x++
} else if (y < nextPoint.y) {
htmlA += ''
htmlB += `<span class="diff-highlight diff-highlight-insert">${escapeHtml(charsB[y])}</span>`
stats.insert++
hasDiff = true
y++
}
}
// 处理剩余字符
while (x < charsA.length && y < charsB.length) {
if (charsA[x] === charsB[y]) {
htmlA += escapeHtml(charsA[x])
htmlB += escapeHtml(charsB[y])
stats.same++
} else {
htmlA += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsA[x])}</span>`
htmlB += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsB[y])}</span>`
stats.modify++
hasDiff = true
}
x++
y++
}
while (x < charsA.length) {
htmlA += `<span class="diff-highlight diff-highlight-delete">${escapeHtml(charsA[x])}</span>`
stats.delete++
hasDiff = true
x++
}
while (y < charsB.length) {
htmlB += `<span class="diff-highlight diff-highlight-insert">${escapeHtml(charsB[y])}</span>`
stats.insert++
hasDiff = true
y++
}
resultA.push({
type: hasDiff ? 'modify' : (lineA ? 'delete' : 'insert'),
content: lineA,
html: htmlA || escapeHtml(lineA),
lineNumber: i + 1,
inlineHighlight: hasDiff
})
resultB.push({
type: hasDiff ? 'modify' : (lineB ? 'insert' : 'delete'),
content: lineB,
html: htmlB || escapeHtml(lineB),
lineNumber: i + 1,
inlineHighlight: hasDiff
})
}
}
return {left: resultA, right: resultB, stats}
}
+117
View File
@@ -0,0 +1,117 @@
export function bytesToBase64(bytes) {
let binary = ''
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
return btoa(binary)
}
export function base64ToBytes(str) {
const clean = str.replace(/\s/g, '')
const binary = atob(clean)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return bytes
}
export function encodeBase64(text) {
return btoa(unescape(encodeURIComponent(text)))
}
export function decodeBase64(text) {
return decodeURIComponent(escape(atob(text)))
}
export function encodeUrl(text) {
return encodeURIComponent(text)
}
export function decodeUrl(text) {
return decodeURIComponent(text)
}
export function encodeUnicode(text) {
let result = ''
let i = 0
while (i < text.length) {
const codePoint = text.codePointAt(i)
if (codePoint > 0xFFFF) {
// 超出 BMP 的字符,使用代理对表示
const high = Math.floor((codePoint - 0x10000) / 0x400) + 0xD800
const low = ((codePoint - 0x10000) % 0x400) + 0xDC00
result += '\\u' + high.toString(16).toUpperCase().padStart(4, '0')
result += '\\u' + low.toString(16).toUpperCase().padStart(4, '0')
i += 2 // 代理对占用两个字符位置
} else {
// BMP 字符
result += '\\u' + codePoint.toString(16).toUpperCase().padStart(4, '0')
i++
}
}
return result
}
export function decodeUnicode(text) {
try {
let result = ''
let i = 0
while (i < text.length) {
// 匹配 \uXXXX 格式
if (text[i] === '\\' && i + 1 < text.length && text[i + 1] === 'u' && i + 5 < text.length) {
const hex = text.substring(i + 2, i + 6)
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
const code1 = parseInt(hex, 16)
// 检查是否是高代理(surrogate high
if (code1 >= 0xD800 && code1 <= 0xDBFF && i + 11 < text.length) {
// 检查下一个是否是低代理
if (text[i + 6] === '\\' && text[i + 7] === 'u') {
const hex2 = text.substring(i + 8, i + 12)
if (/^[0-9a-fA-F]{4}$/.test(hex2)) {
const code2 = parseInt(hex2, 16)
// 检查是否是低代理(surrogate low
if (code2 >= 0xDC00 && code2 <= 0xDFFF) {
// 组合代理对
const codePoint = 0x10000 + ((code1 - 0xD800) << 10) + (code2 - 0xDC00)
result += String.fromCodePoint(codePoint)
i += 12
continue
}
}
}
}
// 普通字符或单独的代理
result += String.fromCharCode(code1)
i += 6
continue
}
}
// 匹配 \UXXXXXXXX 格式(8位十六进制)
if (text[i] === '\\' && i + 1 < text.length && text[i + 1] === 'U' && i + 9 < text.length) {
const hex = text.substring(i + 2, i + 10)
if (/^[0-9a-fA-F]{8}$/.test(hex)) {
const code = parseInt(hex, 16)
if (code > 0x10FFFF) {
throw new Error('无效的 Unicode 码点:超出范围')
}
result += String.fromCodePoint(code)
i += 10
continue
}
}
// 普通字符
result += text[i]
i++
}
return result
} catch (e) {
throw new Error('Unicode 解码失败:' + e.message)
}
}
+142
View File
@@ -0,0 +1,142 @@
export function parseJsonPath(jsonPath) {
if (!jsonPath || !jsonPath.trim()) return null
const path = jsonPath.trim()
// 移除开头的 $ 或 $.
const normalizedPath = path.replace(/^\$\.?/, '')
if (!normalizedPath) return []
// 解析路径段:支持 .key 和 [index] 或 [*]
const segments = []
let current = normalizedPath
let i = 0
while (i < current.length) {
if (current[i] === '[') {
// 数组索引
const endIndex = current.indexOf(']', i)
if (endIndex === -1) break
const indexStr = current.substring(i + 1, endIndex)
if (indexStr === '*') {
segments.push({ type: 'wildcard', index: '*' })
} else {
const index = parseInt(indexStr, 10)
if (!isNaN(index)) {
segments.push({ type: 'index', index })
}
}
i = endIndex + 1
} else if (current[i] === '.') {
i++
} else {
// 对象键
let keyEnd = i
while (keyEnd < current.length && current[keyEnd] !== '.' && current[keyEnd] !== '[') {
keyEnd++
}
const key = current.substring(i, keyEnd)
if (key) {
segments.push({ type: 'key', key })
}
i = keyEnd
}
}
return segments
}
export function pathToJsonPath(path) {
if (path === 'root') return '$'
return '$' + path.replace(/^root/, '')
}
export function pathMatchesJsonPath(path, jsonPathSegments) {
if (!jsonPathSegments || jsonPathSegments.length === 0) return true
// 将路径转换为段数组
const pathSegments = []
const pathStr = path === 'root' ? '' : path.replace(/^root\.?/, '')
if (!pathStr) {
return jsonPathSegments.length === 0
}
// 解析路径段
let current = pathStr
let i = 0
while (i < current.length) {
if (current[i] === '[') {
const endIndex = current.indexOf(']', i)
if (endIndex === -1) break
const indexStr = current.substring(i + 1, endIndex)
const index = parseInt(indexStr, 10)
if (!isNaN(index)) {
pathSegments.push({ type: 'index', index })
}
i = endIndex + 1
} else if (current[i] === '.') {
i++
} else {
let keyEnd = i
while (keyEnd < current.length && current[keyEnd] !== '.' && current[keyEnd] !== '[') {
keyEnd++
}
const key = current.substring(i, keyEnd)
if (key) {
pathSegments.push({ type: 'key', key })
}
i = keyEnd
}
}
// 精确匹配:路径段数必须等于 JSONPath 段数
if (pathSegments.length !== jsonPathSegments.length) return false
for (let i = 0; i < jsonPathSegments.length; i++) {
const jsonSeg = jsonPathSegments[i]
const pathSeg = pathSegments[i]
if (!pathSeg) return false
if (jsonSeg.type === 'wildcard') {
// 通配符匹配任何索引
if (pathSeg.type !== 'index') return false
} else if (jsonSeg.type === 'index') {
if (pathSeg.type !== 'index' || pathSeg.index !== jsonSeg.index) return false
} else if (jsonSeg.type === 'key') {
if (pathSeg.type !== 'key' || pathSeg.key !== jsonSeg.key) return false
}
}
return true
}
export function getDataByPath(obj, path) {
if (path === 'root') return obj
const pathStr = path.replace(/^root\.?/, '')
let current = obj
let i = 0
while (i < pathStr.length && current !== undefined && current !== null) {
if (pathStr[i] === '[') {
const endIdx = pathStr.indexOf(']', i)
const idx = parseInt(pathStr.substring(i + 1, endIdx), 10)
current = current[idx]
i = endIdx + 1
} else if (pathStr[i] === '.') {
i++
} else {
let keyEnd = i
while (keyEnd < pathStr.length && pathStr[keyEnd] !== '.' && pathStr[keyEnd] !== '[') {
keyEnd++
}
const key = pathStr.substring(i, keyEnd)
current = current[key]
i = keyEnd
}
}
return current
}
+74
View File
@@ -0,0 +1,74 @@
export function parseTimestampInput(value, type) {
const timestampStr = value.trim()
if (!timestampStr) return { error: 'empty' }
if (type === 'nanoseconds') {
try {
const timestampNs = BigInt(timestampStr)
const timestampMs = Number(timestampNs / BigInt(1000000))
const nanoseconds = Number(timestampNs % BigInt(1000000))
return { timestampMs, nanoseconds }
} catch {
return { error: 'invalid_nanoseconds' }
}
}
const timestamp = parseInt(timestampStr, 10)
if (isNaN(timestamp)) return { error: 'invalid_number' }
if (type === 'seconds') {
const timestampMs = timestamp.toString().length <= 10 ? timestamp * 1000 : timestamp
return { timestampMs, nanoseconds: 0 }
}
return { timestampMs: timestamp, nanoseconds: 0 }
}
export function formatTimestampMs(timestampMs, type, nanoseconds = 0) {
const date = new Date(timestampMs)
if (isNaN(date.getTime())) return { error: 'invalid_date' }
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
const milliseconds = String(date.getMilliseconds()).padStart(3, '0')
if (type === 'seconds') {
return { value: `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` }
}
if (type === 'milliseconds') {
return { value: `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}` }
}
const nanosecondsStr = String(nanoseconds).padStart(6, '0')
return { value: `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}${nanosecondsStr}` }
}
export function parseDateString(dateStr) {
const trimmed = dateStr.trim()
if (!trimmed) return { error: 'empty' }
let date = null
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) {
date = new Date(trimmed.replace(' ', 'T'))
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+$/.test(trimmed)) {
date = new Date(trimmed.replace(' ', 'T'))
} else if (/^\d{4}\/\d{2}\/\d{2}/.test(trimmed)) {
date = new Date(trimmed)
} else {
date = new Date(trimmed)
}
if (!date || isNaN(date.getTime())) return { error: 'invalid_date' }
return { date }
}
export function dateToTimestamp(date, type) {
const ms = date.getTime()
if (type === 'seconds') return { value: String(Math.floor(ms / 1000)) }
if (type === 'milliseconds') return { value: String(ms) }
return { value: String(BigInt(ms) * BigInt(1000000)) }
}
+85
View File
@@ -0,0 +1,85 @@
export function parseToWords(text) {
if (!text || !text.trim()) {
return []
}
let processed = text.trim()
// 处理各种分隔符:空格、下划线、横线、驼峰
// 1. 先处理连续大写字母的情况:XMLHttpRequest -> XML Http Request
processed = processed.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
// 2. 先处理数字和字母的边界(必须在驼峰处理之前)
// 2.1 字母+数字+字母:temp2Detail -> temp 2 Detail
processed = processed.replace(/([a-zA-Z])(\d+)([a-zA-Z])/g, '$1 $2 $3')
// 2.2 字母+数字(后面跟着分隔符或结尾,但不是字母):item2 -> item 2
// 注意:这里不匹配后面跟着字母的情况(已由2.1处理)
processed = processed.replace(/([a-zA-Z])(\d+)(?=[_\-\s]|$)/g, '$1 $2')
// 2.3 数字+字母(在单词开头或前面是分隔符):2item -> 2 item
processed = processed.replace(/(\d+)([a-zA-Z])/g, '$1 $2')
// 3. 处理驼峰:camelCase -> camel Case(在数字处理之后)
processed = processed.replace(/([a-z])([A-Z])/g, '$1 $2')
// 4. 统一分隔符:下划线、横线、空格统一为空格
processed = processed.replace(/[_\-\s]+/g, ' ')
// 5. 分割并处理
let words = processed
.split(' ')
.filter(word => word.length > 0)
.map(word => {
// 转换为小写,保留字母和数字
return word.toLowerCase()
})
.filter(word => word.length > 0) // 允许纯数字
return words
}
// 转换单词首字母为大写(处理数字情况)
export function capitalizeWord(word) {
if (!word) return ''
// 如果单词是纯数字,直接返回
if (/^\d+$/.test(word)) return word
// 否则首字母大写
return word.charAt(0).toUpperCase() + word.slice(1)
}
// 转换为小驼峰 (camelCase)
export function toCamelCase(words) {
if (words.length === 0) return ''
const firstWord = words[0]
const restWords = words.slice(1).map(word => capitalizeWord(word))
return firstWord + restWords.join('')
}
// 转换为大驼峰 (PascalCase)
export function toPascalCase(words) {
if (words.length === 0) return ''
return words.map(word => capitalizeWord(word)).join('')
}
// 转换为下划线 (snake_case)
export function toSnakeCase(words) {
if (words.length === 0) return ''
return words.join('_')
}
// 转换为横线 (kebab-case)
export function toKebabCase(words) {
if (words.length === 0) return ''
return words.join('-')
}
// 转换为常量 (CONSTANT_CASE)
export function toConstantCase(words) {
if (words.length === 0) return ''
return words.map(word => word.toUpperCase()).join('_')
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
<template>
<div class="home-container">
<div class="hero-section">
<h2 class="hero-title">
今天是{{ `${new Date().getFullYear()}${new Date().getMonth() + 1}${new Date().getDate()}` }}
</h2>
<p v-if="jinrishiciSdkUrl" id="jinrishici-sentence" class="hero-subtitle"></p>
</div>
<div class="tools-grid">
<router-link
v-for="tool in tools"
:key="tool.path"
:to="tool.path"
class="tool-card"
>
<h3 class="tool-title">{{ tool.title }}</h3>
<p class="tool-description">{{ tool.description }}</p>
</router-link>
</div>
<div v-if="siteIcp" class="footer-section">
<p class="icp-info">{{ siteIcp }}</p>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { siteConfig, getJinrishiciSdkUrl } from '../config/site.js'
const siteIcp = siteConfig.icp
const jinrishiciSdkUrl = getJinrishiciSdkUrl()
const tools = ref([
{
path: '/json-formatter',
title: 'JSON',
description: '格式化、验证和美化JSON数据'
},
{
path: '/comparator',
title: '对比',
description: '文本和JSON对比工具'
},
{
path: '/encoder-decoder',
title: '编解码',
description: '编码/解码工具'
},
{
path: '/variable-name',
title: '变量名',
description: '变量名格式转换'
},
{
path: '/qr-code',
title: '二维码',
description: '生成二维码'
},
{
path: '/timestamp-converter',
title: '时间戳',
description: '时间戳与时间字符串相互转换'
},
{
path: '/color-converter',
title: '颜色',
description: '颜色格式转换'
}
])
onMounted(() => {
if (!jinrishiciSdkUrl) return
const wordsScript = document.createElement('script')
wordsScript.charset = 'utf-8'
wordsScript.src = jinrishiciSdkUrl
document.body.appendChild(wordsScript)
})
</script>
<style scoped>
.home-container {
width: 100%;
background: #ffffff;
}
.hero-section {
text-align: center;
padding: 3rem 0;
color: #1a1a1a;
}
.hero-title {
font-size: 3rem;
font-weight: 500;
margin-bottom: 1rem;
color: #1a1a1a;
}
.hero-subtitle {
font-size: 1.25rem;
color: #666666;
}
.tools-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.5rem;
margin: 2rem auto 0;
max-width: 1200px;
padding: 0 1rem;
}
.tool-card {
background: #ffffff;
border-radius: 6px;
padding: 2rem;
text-decoration: none;
color: #1a1a1a;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e5e5;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
flex: 0 0 calc(25% - 1.125rem);
max-width: calc(25% - 1.125rem);
min-width: 250px;
}
.tool-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-color: #d0d0d0;
background: #ffffff;
}
.tool-title {
font-size: 1.5rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: #1a1a1a;
}
.tool-description {
color: #666666;
line-height: 1.6;
font-size: 0.875rem;
}
@media (max-width: 1200px) {
.tool-card {
flex: 0 0 calc(33.333% - 1rem);
max-width: calc(33.333% - 1rem);
}
}
@media (max-width: 900px) {
.tool-card {
flex: 0 0 calc(50% - 0.75rem);
max-width: calc(50% - 0.75rem);
}
}
@media (max-width: 768px) {
.hero-title {
font-size: 2rem;
}
.hero-subtitle {
font-size: 1rem;
}
.tools-grid {
gap: 1rem;
max-width: 100%;
padding: 0;
}
.tool-card {
flex: 0 0 100%;
max-width: 100%;
padding: 1.5rem;
}
}
.footer-section {
text-align: center;
padding: 2rem 0;
margin-top: 3rem;
border-top: 1px solid #e5e5e5;
}
.icp-info {
color: #999999;
font-size: 0.875rem;
margin: 0;
}
</style>
File diff suppressed because it is too large Load Diff
+700
View File
@@ -0,0 +1,700 @@
<template>
<div class="tool-page">
<!-- 浮层提示 -->
<Transition name="toast">
<div v-if="toastMessage" class="toast-notification" :class="toastType">
<div class="toast-content">
<i v-if="toastType === 'error'" class="fas fa-circle-exclamation"></i>
<i v-else class="fas fa-circle-check"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<i class="fas fa-xmark"></i>
</button>
</div>
</Transition>
<div class="main-container">
<!-- 左侧侧栏历史记录 -->
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
<div class="sidebar-header">
<h3>历史记录</h3>
<button @click="toggleSidebar" class="close-btn">×</button>
</div>
<div class="sidebar-content">
<div v-if="historyList.length === 0" class="empty-history">
暂无历史记录
</div>
<div
v-for="(item, index) in historyList"
:key="index"
class="history-item"
@click="loadHistory(item.text)"
>
<div class="history-time">{{ formatTime(item.time) }}</div>
<div class="history-preview">{{ truncateText(item.text, 50) }}</div>
</div>
</div>
</div>
<!-- 主内容区域 -->
<div class="content-wrapper" :class="{ 'sidebar-pushed': sidebarOpen }">
<div class="container">
<div class="qr-card">
<!-- 输入区域 -->
<div class="input-section">
<div class="input-wrapper">
<textarea
v-model="inputText"
@keydown.enter.prevent="generateQRCode"
placeholder="请输入要生成二维码的内容"
class="input-textarea"
rows="4"
></textarea>
</div>
<button @click="generateQRCode" class="generate-btn">
<i class="fas fa-qrcode"></i>
生成二维码
</button>
</div>
<!-- 二维码显示区域 -->
<div v-if="qrCodeDataUrl" class="qr-display-section">
<div class="qr-code-wrapper">
<img :src="qrCodeDataUrl" alt="二维码" class="qr-code-image" />
</div>
<div class="qr-actions">
<button @click="downloadQRCode" class="action-btn">
<i class="fas fa-download"></i>
下载
</button>
<button @click="copyQRCodeImage" class="action-btn">
<i class="far fa-copy"></i>
复制图片
</button>
</div>
</div>
</div>
</div>
<!-- 侧栏切换按钮 -->
<div class="sidebar-toggle">
<button @click="toggleSidebar" class="toggle-btn">
{{ sidebarOpen ? '◀' : '▶' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import QRCode from 'qrcode'
// 输入文本
const inputText = ref('')
// 二维码数据URL
const qrCodeDataUrl = ref('')
// 侧栏状态
const sidebarOpen = ref(false)
// 历史记录
const historyList = ref([])
const STORAGE_KEY = 'qr-code-history'
const MAX_HISTORY = 20
// 提示消息
const toastMessage = ref('')
const toastType = ref('success')
let toastTimer = null
// 显示提示
const showToast = (message, type = 'success', duration = 3000) => {
toastMessage.value = message
toastType.value = type
if (toastTimer) {
clearTimeout(toastTimer)
}
toastTimer = setTimeout(() => {
toastMessage.value = ''
}, duration)
}
// 关闭提示
const closeToast = () => {
if (toastTimer) {
clearTimeout(toastTimer)
toastTimer = null
}
toastMessage.value = ''
}
// 生成二维码
const generateQRCode = async () => {
if (!inputText.value.trim()) {
showToast('请输入要生成二维码的内容', 'error')
return
}
try {
// 生成二维码
const dataUrl = await QRCode.toDataURL(inputText.value.trim(), {
width: 300,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
qrCodeDataUrl.value = dataUrl
// 保存到历史记录
saveToHistory(inputText.value.trim())
showToast('二维码生成成功', 'success', 2000)
} catch (error) {
showToast('生成二维码失败:' + error.message, 'error')
qrCodeDataUrl.value = ''
}
}
// 下载二维码
const downloadQRCode = () => {
if (!qrCodeDataUrl.value) {
showToast('没有可下载的二维码', 'error')
return
}
try {
const link = document.createElement('a')
link.download = `qrcode-${Date.now()}.png`
link.href = qrCodeDataUrl.value
link.click()
showToast('下载成功', 'success', 2000)
} catch (error) {
showToast('下载失败:' + error.message, 'error')
}
}
// 复制二维码图片
const copyQRCodeImage = async () => {
if (!qrCodeDataUrl.value) {
showToast('没有可复制的二维码', 'error')
return
}
try {
// 将 data URL 转换为 blob
const response = await fetch(qrCodeDataUrl.value)
const blob = await response.blob()
// 复制到剪贴板
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
])
showToast('已复制到剪贴板', 'success', 2000)
} catch (error) {
// 降级方案:提示用户手动保存
showToast('复制失败,请使用下载功能', 'error')
}
}
// 保存到历史记录
const saveToHistory = (text) => {
const historyItem = {
text: text,
time: Date.now()
}
// 从localStorage读取现有历史
let history = []
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
history = JSON.parse(stored)
}
} catch (e) {
// 读取历史记录失败,忽略错误
}
// 避免重复保存相同的记录
const lastHistory = history[0]
if (lastHistory && lastHistory.text === historyItem.text) {
return
}
// 添加到开头
history.unshift(historyItem)
// 限制最多20条
if (history.length > MAX_HISTORY) {
history = history.slice(0, MAX_HISTORY)
}
// 保存到localStorage
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
loadHistoryList()
} catch (e) {
// 保存历史记录失败,忽略错误
}
}
// 加载历史记录列表
const loadHistoryList = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
historyList.value = JSON.parse(stored)
}
} catch (e) {
// 加载历史记录失败,重置为空数组
historyList.value = []
}
}
// 加载历史记录
const loadHistory = (text) => {
inputText.value = text
generateQRCode()
}
// 切换侧栏
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
// 格式化时间
const formatTime = (timestamp) => {
const date = new Date(timestamp)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 截断文本
const truncateText = (text, maxLength) => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
onMounted(() => {
loadHistoryList()
})
</script>
<style scoped>
.tool-page {
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
margin: -1rem;
padding: 0;
background: #ffffff;
}
.main-container {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
background: #ffffff;
}
/* 侧栏样式 */
.sidebar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 300px;
background: #ffffff;
border-right: 1px solid #e5e5e5;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 10;
display: flex;
flex-direction: column;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
}
.sidebar-open {
transform: translateX(0);
}
.content-wrapper.sidebar-pushed {
margin-left: 300px;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid #e5e5e5;
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
}
.sidebar-header h3 {
margin: 0;
font-size: 1rem;
color: #1a1a1a;
font-weight: 500;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666666;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.close-btn:hover {
color: #1a1a1a;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
background: #ffffff;
}
.empty-history {
padding: 2rem;
text-align: center;
color: #999999;
font-size: 0.875rem;
}
.history-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
background: #ffffff;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #e5e5e5;
}
.history-item:hover {
background: #f5f5f5;
border-color: #d0d0d0;
}
.history-time {
font-size: 0.75rem;
color: #999999;
margin-bottom: 0.25rem;
}
.history-preview {
font-size: 0.875rem;
color: #1a1a1a;
word-break: break-all;
}
.content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
transition: margin-left 0.3s ease;
overflow-y: auto;
padding: 1rem;
}
.container {
max-width: 600px;
margin: 0 auto;
width: 100%;
}
.qr-card {
background: #ffffff;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid #e5e5e5;
}
.input-section {
margin-bottom: 2rem;
}
.input-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #333333;
margin-bottom: 0.5rem;
}
.input-wrapper {
margin-bottom: 1rem;
}
.input-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.9375rem;
font-family: inherit;
resize: vertical;
transition: all 0.2s;
box-sizing: border-box;
}
.input-textarea:focus {
outline: none;
border-color: #1a1a1a;
box-shadow: 0 0 0 3px rgba(26, 26, 26, 0.1);
}
.generate-btn {
width: 100%;
padding: 0.75rem 1.5rem;
background: #1a1a1a;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.generate-btn:hover {
background: #333333;
}
.generate-btn:active {
transform: scale(0.98);
}
.qr-display-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e5e5e5;
}
.qr-code-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
background: #fafafa;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.qr-code-image {
max-width: 100%;
height: auto;
display: block;
}
.qr-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.action-btn {
padding: 0.75rem 1.5rem;
background: #f5f5f5;
color: #333333;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-btn:hover {
background: #e5e5e5;
border-color: #1a1a1a;
color: #1a1a1a;
}
.action-btn:active {
transform: scale(0.98);
}
/* 侧栏切换按钮 */
.sidebar-toggle {
position: fixed;
left: 0;
bottom: 1rem;
z-index: 11;
}
.content-wrapper.sidebar-pushed .sidebar-toggle {
left: 300px;
}
.toggle-btn {
width: 32px;
height: 48px;
background: #1a1a1a;
color: #ffffff;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
}
.toggle-btn:hover {
background: #333333;
}
/* 提示消息样式 */
.toast-notification {
position: fixed;
top: 80px;
right: 20px;
background: #ffffff;
border: 1px solid #e5e5e5;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1rem 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
z-index: 1000;
min-width: 280px;
max-width: 400px;
}
.toast-notification.success {
border-left: 4px solid #10b981;
}
.toast-notification.error {
border-left: 4px solid #ef4444;
}
.toast-content {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
font-size: 0.875rem;
color: #333333;
}
.toast-content svg,
.toast-content i {
flex-shrink: 0;
}
.toast-notification.success .toast-content i {
color: #10b981;
}
.toast-notification.error .toast-content i {
color: #ef4444;
}
.toast-close-btn {
background: transparent;
border: none;
color: #666666;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
flex-shrink: 0;
}
.toast-close-btn:hover {
color: #1a1a1a;
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
@media (max-width: 768px) {
.qr-card {
padding: 1.5rem;
}
.sidebar {
width: 280px;
}
.content-wrapper.sidebar-pushed {
margin-left: 0;
}
.sidebar-toggle {
bottom: 0.5rem;
}
.toggle-btn {
width: 28px;
height: 40px;
font-size: 0.75rem;
}
.toast-notification {
right: 10px;
left: 10px;
max-width: none;
top: 60px;
}
}
</style>
+913
View File
@@ -0,0 +1,913 @@
<template>
<div class="timestamp-converter">
<div class="container">
<div class="conversion-card">
<!-- 精度选择器和时区选择器 -->
<div class="controls-row">
<div class="precision-selector">
<button
v-for="option in precisionOptions"
:key="option.value"
@click="timestampType = option.value; outputTimestampType = option.value"
:class="['precision-btn', { active: timestampType === option.value }]"
>
{{ option.label }}
</button>
</div>
<div class="timezone-selector">
<select v-model="timezone" class="timezone-select">
<option v-for="tz in timezoneOptions" :key="tz.value" :value="tz.value">
{{ tz.label }}
</option>
</select>
</div>
</div>
<!-- 日期转换为时间戳 -->
<div class="conversion-row">
<div class="conversion-label">日期 ({{ timezoneLabel }}) 时间戳:</div>
<div class="conversion-inputs">
<div class="input-with-calendar">
<input
v-model="dateStringInput"
@input="convertDateToTimestamp"
type="text"
:placeholder="getDatePlaceholder()"
class="input-field"
/>
<button @click="showDateTimePicker = true" class="calendar-btn" title="选择日期时间">
<i class="far fa-calendar"></i>
</button>
<!-- 日期时间选择器组件 -->
<DateTimePicker
v-model="dateStringInput"
v-model:show="showDateTimePicker"
:precision-type="outputTimestampType"
@confirm="handleDateTimeConfirm"
/>
</div>
<span class="arrow"></span>
<input
v-model="timestampOutput"
type="text"
readonly
class="input-field readonly"
/>
<button @click="copyToClipboard(timestampOutput)" class="copy-btn" title="复制">
<i class="far fa-copy"></i>
</button>
</div>
</div>
<!-- 时间戳转换为日期 -->
<div class="conversion-row">
<div class="conversion-label">时间戳 ({{ timezoneLabel }}) 日期</div>
<div class="conversion-inputs">
<input
v-model="timestampInput"
@input="convertTimestampToDate"
type="text"
placeholder="请输入时间戳"
class="input-field"
/>
<span class="arrow"></span>
<input
v-model="dateStringOutput"
type="text"
readonly
class="input-field readonly"
/>
<button @click="copyToClipboard(dateStringOutput)" class="copy-btn" title="复制">
<i class="far fa-copy"></i>
</button>
</div>
</div>
<!-- 当前时间戳显示与控制 -->
<div class="current-timestamp-row">
<div class="conversion-label">当前时间戳:</div>
<div class="current-timestamp-controls">
<span class="current-timestamp-value">{{ currentTimestampDisplay }}</span>
<button @click="togglePause" class="control-btn-icon" :title="isPaused ? '继续' : '暂停'">
<i :class="isPaused ? 'fas fa-play' : 'fas fa-pause'"></i>
</button>
<button @click="resetData" class="control-btn-icon" title="重置数据">
<i class="fas fa-rotate-right"></i>
</button>
</div>
</div>
</div>
</div>
<!-- 遮罩层 -->
<Transition name="mask">
<div v-if="showDateTimePicker" class="picker-mask" @click="closeDateTimePicker"></div>
</Transition>
<!-- 提示消息 -->
<Transition name="toast">
<div v-if="toastMessage" class="toast-notification" :class="toastType">
<div class="toast-content">
<i v-if="toastType === 'error'" class="fas fa-circle-exclamation"></i>
<i v-else class="fas fa-circle-check"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<i class="fas fa-xmark"></i>
</button>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import DateTimePicker from '@/components/DateTimePicker.vue'
// 精度选项
const precisionOptions = [
{ value: 'seconds', label: '秒' },
{ value: 'milliseconds', label: '毫秒' },
{ value: 'nanoseconds', label: '纳秒' }
]
// 所有时区选项
const timezoneOptions = [
{ value: 'UTC-12:00', label: 'UTC-12:00 | 贝克岛' },
{ value: 'UTC-11:00', label: 'UTC-11:00 | 萨摩亚' },
{ value: 'UTC-10:00', label: 'UTC-10:00 | 夏威夷' },
{ value: 'UTC-09:30', label: 'UTC-09:30 | 马克萨斯群岛' },
{ value: 'UTC-09:00', label: 'UTC-09:00 | 阿拉斯加' },
{ value: 'UTC-08:00', label: 'UTC-08:00 | 洛杉矶' },
{ value: 'UTC-07:00', label: 'UTC-07:00 | 丹佛' },
{ value: 'UTC-06:00', label: 'UTC-06:00 | 芝加哥' },
{ value: 'UTC-05:00', label: 'UTC-05:00 | 纽约' },
{ value: 'UTC-04:00', label: 'UTC-04:00 | 加拉加斯' },
{ value: 'UTC-03:30', label: 'UTC-03:30 | 纽芬兰' },
{ value: 'UTC-03:00', label: 'UTC-03:00 | 布宜诺斯艾利斯' },
{ value: 'UTC-02:00', label: 'UTC-02:00 | 大西洋中部' },
{ value: 'UTC-01:00', label: 'UTC-01:00 | 亚速尔群岛' },
{ value: 'UTC+00:00', label: 'UTC+00:00 | 伦敦' },
{ value: 'UTC+01:00', label: 'UTC+01:00 | 巴黎' },
{ value: 'UTC+02:00', label: 'UTC+02:00 | 开罗' },
{ value: 'UTC+03:00', label: 'UTC+03:00 | 莫斯科' },
{ value: 'UTC+03:30', label: 'UTC+03:30 | 德黑兰' },
{ value: 'UTC+04:00', label: 'UTC+04:00 | 迪拜' },
{ value: 'UTC+04:30', label: 'UTC+04:30 | 喀布尔' },
{ value: 'UTC+05:00', label: 'UTC+05:00 | 伊斯兰堡' },
{ value: 'UTC+05:30', label: 'UTC+05:30 | 新德里' },
{ value: 'UTC+05:45', label: 'UTC+05:45 | 加德满都' },
{ value: 'UTC+06:00', label: 'UTC+06:00 | 达卡' },
{ value: 'UTC+06:30', label: 'UTC+06:30 | 仰光' },
{ value: 'UTC+07:00', label: 'UTC+07:00 | 曼谷' },
{ value: 'UTC+08:00', label: 'UTC+08:00 | 北京' },
{ value: 'UTC+08:45', label: 'UTC+08:45 | 尤克拉' },
{ value: 'UTC+09:00', label: 'UTC+09:00 | 东京' },
{ value: 'UTC+09:30', label: 'UTC+09:30 | 阿德莱德' },
{ value: 'UTC+10:00', label: 'UTC+10:00 | 悉尼' },
{ value: 'UTC+10:30', label: 'UTC+10:30 | 豪勋爵岛' },
{ value: 'UTC+11:00', label: 'UTC+11:00 | 新喀里多尼亚' },
{ value: 'UTC+12:00', label: 'UTC+12:00 | 奥克兰' },
{ value: 'UTC+12:45', label: 'UTC+12:45 | 查塔姆群岛' },
{ value: 'UTC+13:00', label: 'UTC+13:00 | 萨摩亚' },
{ value: 'UTC+14:00', label: 'UTC+14:00 | 基里巴斯' }
]
// 当前时间相关
const currentTime = ref(new Date())
let timeInterval = null
const isPaused = ref(false)
// 时区相关
const timezone = ref('UTC+08:00')
const timezoneLabel = computed(() => {
const tz = timezoneOptions.find(opt => opt.value === timezone.value)
return tz ? tz.label.split('|')[1].trim() : '北京'
})
// 时间戳转时间
const timestampType = ref('milliseconds')
const timestampInput = ref('')
const dateStringOutput = ref('')
// 时间转时间戳
const dateStringInput = ref('')
const outputTimestampType = ref('milliseconds')
const timestampOutput = ref('')
// 日期时间选择器状态
const showDateTimePicker = ref(false)
// 提示消息
const toastMessage = ref('')
const toastType = ref('success')
// 计算当前秒级时间戳
const currentTimestampSeconds = computed(() => {
return Math.floor(currentTime.value.getTime() / 1000)
})
// 计算当前毫秒级时间戳
const currentTimestampMilliseconds = computed(() => {
return currentTime.value.getTime()
})
// 计算当前纳秒级时间戳
const currentTimestampNanoseconds = computed(() => {
// JavaScript Date 只能精确到毫秒,所以纳秒部分设为0
// 纳秒 = 毫秒 * 1000000
const ms = currentTime.value.getTime()
return BigInt(ms) * BigInt(1000000)
})
// 当前时间戳显示
const currentTimestampDisplay = computed(() => {
if (timestampType.value === 'seconds') {
return currentTimestampSeconds.value.toString()
} else if (timestampType.value === 'milliseconds') {
return currentTimestampMilliseconds.value.toString()
} else {
return currentTimestampNanoseconds.value.toString()
}
})
// 获取日期输入框的placeholder
const getDatePlaceholder = () => {
if (timestampType.value === 'seconds') {
return '格式:yyyy-MM-dd HH:mm:ss'
} else if (timestampType.value === 'milliseconds') {
return '格式:yyyy-MM-dd HH:mm:ss.SSS'
} else {
return '格式:yyyy-MM-dd HH:mm:ss.SSSSSSSSS'
}
}
// 更新时间
const updateCurrentTime = () => {
if (!isPaused.value) {
currentTime.value = new Date()
}
}
// 切换暂停/继续
const togglePause = () => {
isPaused.value = !isPaused.value
if (!isPaused.value) {
updateCurrentTime()
}
}
// 重置数据
const resetData = () => {
timestampInput.value = ''
dateStringOutput.value = ''
dateStringInput.value = ''
timestampOutput.value = ''
currentTime.value = new Date()
showToast('数据已重置', 'success')
}
// 时间戳转时间字符串
const convertTimestampToDate = () => {
if (!timestampInput.value.trim()) {
dateStringOutput.value = ''
return
}
try {
let timestampStr = timestampInput.value.trim()
if (!timestampStr) {
dateStringOutput.value = ''
return
}
let timestampMs = 0
let nanoseconds = 0
if (timestampType.value === 'nanoseconds') {
// 纳秒级时间戳,使用 BigInt 处理
try {
const timestampNs = BigInt(timestampStr)
// 转换为毫秒(纳秒 / 1000000
timestampMs = Number(timestampNs / BigInt(1000000))
// 获取纳秒部分(纳秒 % 1000000)
nanoseconds = Number(timestampNs % BigInt(1000000))
} catch (error) {
dateStringOutput.value = ''
showToast('请输入有效的纳秒级时间戳', 'error')
return
}
} else {
const timestamp = parseInt(timestampStr)
if (isNaN(timestamp)) {
dateStringOutput.value = ''
showToast('请输入有效的数字', 'error')
return
}
// 如果是秒级时间戳,转换为毫秒
if (timestampType.value === 'seconds') {
// 判断是否是秒级时间戳(通常小于13位数字)
if (timestamp.toString().length <= 10) {
timestampMs = timestamp * 1000
} else {
timestampMs = timestamp
}
} else {
timestampMs = timestamp
}
}
const date = new Date(timestampMs)
if (isNaN(date.getTime())) {
dateStringOutput.value = ''
showToast('无效的时间戳', 'error')
return
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
const milliseconds = String(date.getMilliseconds()).padStart(3, '0')
if (timestampType.value === 'seconds') {
dateStringOutput.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} else if (timestampType.value === 'milliseconds') {
dateStringOutput.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`
} else {
// 纳秒级:显示毫秒 + 纳秒部分
const nanosecondsStr = String(nanoseconds).padStart(6, '0')
dateStringOutput.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}${nanosecondsStr}`
}
} catch (error) {
dateStringOutput.value = ''
showToast('转换失败:' + error.message, 'error')
}
}
// 时间字符串转时间戳
const convertDateToTimestamp = () => {
if (!dateStringInput.value.trim()) {
timestampOutput.value = ''
return
}
try {
let dateStr = dateStringInput.value.trim()
// 尝试解析不同的时间格式
let date = null
// 处理纳秒级格式:yyyy-MM-dd HH:mm:ss.SSSSSSSSS9位纳秒)
if (dateStr.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{9}$/)) {
// 提取毫秒部分(前3位)和纳秒部分(后6位)
const parts = dateStr.split('.')
const baseTime = parts[0].replace(' ', 'T')
const nanoseconds = parts[1]
const milliseconds = nanoseconds.substring(0, 3)
date = new Date(baseTime + '.' + milliseconds)
}
// 格式1: yyyy-MM-dd HH:mm:ss 或 yyyy-MM-dd HH:mm:ss.SSS
else if (dateStr.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,3})?$/)) {
date = new Date(dateStr.replace(' ', 'T'))
}
// 格式2: yyyy-MM-ddTHH:mm:ss 或 yyyy-MM-ddTHH:mm:ss.SSS
else if (dateStr.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?/)) {
date = new Date(dateStr)
}
// 格式3: yyyy/MM/dd HH:mm:ss
else if (dateStr.match(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}/)) {
date = new Date(dateStr.replace(' ', 'T').replace(/\//g, '-'))
}
// 其他格式,直接尝试解析
else {
date = new Date(dateStr)
}
if (isNaN(date.getTime())) {
timestampOutput.value = ''
showToast('无效的时间格式', 'error')
return
}
if (outputTimestampType.value === 'seconds') {
timestampOutput.value = Math.floor(date.getTime() / 1000).toString()
} else if (outputTimestampType.value === 'milliseconds') {
timestampOutput.value = date.getTime().toString()
} else {
// 纳秒级:毫秒 * 1000000
const ms = date.getTime()
timestampOutput.value = (BigInt(ms) * BigInt(1000000)).toString()
}
} catch (error) {
timestampOutput.value = ''
showToast('转换失败:' + error.message, 'error')
}
}
// 处理日期时间选择器确认
const handleDateTimeConfirm = (value) => {
dateStringInput.value = value
convertDateToTimestamp()
}
// 复制到剪贴板
const copyToClipboard = async (text) => {
if (!text) {
showToast('没有可复制的内容', 'error')
return
}
try {
await navigator.clipboard.writeText(text)
showToast('已复制到剪贴板', 'success')
} catch (error) {
// 降级方案
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
showToast('已复制到剪贴板', 'success')
} catch (err) {
showToast('复制失败', 'error')
}
document.body.removeChild(textArea)
}
}
// 显示提示消息
const showToast = (message, type = 'success') => {
toastMessage.value = message
toastType.value = type
setTimeout(() => {
closeToast()
}, 3000)
}
// 关闭提示消息
const closeToast = () => {
toastMessage.value = ''
}
// 监听时间戳类型变化
const watchTimestampType = () => {
if (timestampInput.value) {
convertTimestampToDate()
}
}
// 监听输出时间戳类型变化
const watchOutputTimestampType = () => {
if (dateStringInput.value) {
convertDateToTimestamp()
}
}
// 监听时间戳类型变化
watch(timestampType, watchTimestampType)
watch(outputTimestampType, watchOutputTimestampType)
onMounted(() => {
updateCurrentTime()
timeInterval = setInterval(updateCurrentTime, 1000)
})
onUnmounted(() => {
if (timeInterval) {
clearInterval(timeInterval)
}
})
</script>
<style scoped>
.timestamp-converter {
width: 100%;
min-height: 100vh;
background: #f5f5f5;
padding: 2rem 1rem;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.conversion-card {
background: #ffffff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid #e5e5e5;
}
.controls-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.precision-selector {
display: flex;
gap: 0.5rem;
}
.precision-btn {
padding: 0.5rem 1rem;
background: #f5f5f5;
color: #333333;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.precision-btn:hover {
background: #e5e5e5;
}
.precision-btn.active {
background: #1a1a1a;
color: #ffffff;
border-color: #1a1a1a;
}
.timezone-selector {
display: flex;
align-items: center;
gap: 0.5rem;
}
.timezone-select {
padding: 0.5rem 0.75rem;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.875rem;
background: #ffffff;
cursor: pointer;
transition: all 0.2s;
}
.timezone-select:focus {
outline: none;
border-color: #1a1a1a;
}
.conversion-row {
margin-bottom: 1.5rem;
}
.conversion-label {
font-size: 0.875rem;
font-weight: 500;
color: #333333;
margin-bottom: 0.5rem;
}
.conversion-inputs {
display: flex;
gap: 0.75rem;
align-items: center;
}
.arrow {
font-size: 1.25rem;
color: #666666;
font-weight: 500;
flex-shrink: 0;
}
.current-timestamp-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid #e5e5e5;
}
.current-timestamp-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.current-timestamp-value {
font-family: 'Courier New', monospace;
font-size: 1rem;
color: #1a1a1a;
font-weight: 500;
min-width: 150px;
text-align: right;
}
.control-btn {
padding: 0.5rem 1rem;
background: #f5f5f5;
color: #333333;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.control-btn:hover {
background: #e5e5e5;
border-color: #1a1a1a;
}
.control-btn:active {
transform: scale(0.98);
}
.control-btn-icon {
padding: 0.375rem;
background: transparent;
border: 1px solid #d0d0d0;
border-radius: 6px;
color: #666666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
width: 2rem;
height: 2rem;
font-size: 0.875rem;
}
.control-btn-icon:hover {
background: #f5f5f5;
border-color: #1a1a1a;
color: #1a1a1a;
}
.control-btn-icon:active {
transform: scale(0.98);
}
.input-field {
flex: 1;
padding: 0.75rem;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.9375rem;
font-family: 'Courier New', monospace;
transition: all 0.2s;
}
.input-field:focus {
outline: none;
border-color: #1a1a1a;
box-shadow: 0 0 0 3px rgba(26, 26, 26, 0.1);
}
.input-field.readonly {
background: #f9f9f9;
cursor: not-allowed;
}
.action-btn {
padding: 0.75rem 1rem;
background: #1a1a1a;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.action-btn:hover {
background: #333333;
}
.action-btn:active {
transform: scale(0.98);
}
.copy-btn {
padding: 0.5rem;
background: transparent;
border: 1px solid #d0d0d0;
border-radius: 6px;
color: #666666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
.copy-btn:hover {
background: #f5f5f5;
border-color: #1a1a1a;
color: #1a1a1a;
}
.input-with-calendar {
flex: 1;
position: relative;
display: flex;
align-items: center;
min-width: 0;
}
.input-with-calendar .input-field {
flex: 1;
padding-right: 2.5rem;
min-width: 0;
}
.calendar-btn {
position: absolute;
right: 0.5rem;
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 6px;
color: #666666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
z-index: 1;
}
.calendar-btn:hover {
background: #f5f5f5;
color: #1a1a1a;
}
.input-with-calendar {
position: relative;
}
/* 提示消息样式 */
.toast-notification {
position: fixed;
top: 80px;
right: 20px;
background: #ffffff;
border: 1px solid #e5e5e5;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1rem 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
z-index: 1000;
min-width: 280px;
max-width: 400px;
}
.toast-notification.success {
border-left: 4px solid #10b981;
}
.toast-notification.error {
border-left: 4px solid #ef4444;
}
.toast-content {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
font-size: 0.875rem;
color: #333333;
}
.toast-content svg,
.toast-content i {
flex-shrink: 0;
}
.toast-notification.success .toast-content svg {
color: #10b981;
}
.toast-notification.error .toast-content svg {
color: #ef4444;
}
.toast-close-btn {
background: transparent;
border: none;
color: #666666;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
flex-shrink: 0;
}
.toast-close-btn:hover {
color: #1a1a1a;
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
@media (max-width: 768px) {
.timestamp-converter {
padding: 1rem 0.5rem;
}
.conversion-card {
padding: 1rem;
}
.controls-row {
flex-direction: column;
align-items: stretch;
}
.precision-selector {
width: 100%;
justify-content: space-between;
}
.precision-btn {
flex: 1;
}
.timezone-selector {
width: 100%;
}
.conversion-inputs {
flex-direction: column;
align-items: stretch;
}
.arrow {
transform: rotate(90deg);
}
.current-timestamp-row {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.current-timestamp-controls {
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
.current-timestamp-value {
text-align: left;
}
.control-btn-icon {
width: 2rem;
height: 2rem;
}
.toast-notification {
right: 10px;
left: 10px;
max-width: none;
}
}
</style>
+526
View File
@@ -0,0 +1,526 @@
<template>
<div class="variable-name-converter">
<!-- 浮层提示 -->
<Transition name="toast">
<div v-if="toastMessage" class="toast-notification" :class="toastType">
<div class="toast-content">
<i v-if="toastType === 'error'" class="fas fa-circle-exclamation"></i>
<i v-else class="fas fa-circle-check"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<i class="fas fa-xmark"></i>
</button>
</div>
</Transition>
<div class="container">
<div class="conversion-card">
<!-- 输入区域 -->
<div class="input-section">
<div class="input-wrapper">
<input
v-model="inputText"
@input="convertVariableName"
type="text"
placeholder="请输入变量名(支持任意格式)"
class="input-field"
/>
<button @click="clearInput" class="clear-btn" title="清空">
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
<!-- 输出区域 -->
<div class="output-section">
<div class="output-row">
<div
v-for="format in formats.slice(0, 3)"
:key="format.key"
class="output-item"
>
<div class="output-header">
<span class="output-label">{{ format.label }}</span>
<button
@click="copyToClipboard(format.value, format.label)"
class="copy-btn"
:title="`复制${format.label}`"
>
<i class="far fa-copy"></i>
</button>
</div>
<div class="output-value" :class="{ empty: !format.value }">
{{ format.value || '—' }}
</div>
</div>
</div>
<div class="output-row">
<div
v-for="format in formats.slice(3)"
:key="format.key"
class="output-item"
>
<div class="output-header">
<span class="output-label">{{ format.label }}</span>
<button
@click="copyToClipboard(format.value, format.label)"
class="copy-btn"
:title="`复制${format.label}`"
>
<i class="far fa-copy"></i>
</button>
</div>
<div class="output-value" :class="{ empty: !format.value }">
{{ format.value || '—' }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const inputText = ref('')
const toastMessage = ref('')
const toastType = ref('success')
let toastTimer = null
// 变量名格式定义
const formats = ref([
{ key: 'camelCase', label: '小驼峰 (camelCase)', value: '' },
{ key: 'PascalCase', label: '大驼峰 (PascalCase)', value: '' },
{ key: 'snake_case', label: '下划线 (snake_case)', value: '' },
{ key: 'kebab-case', label: '横线 (kebab-case)', value: '' },
{ key: 'CONSTANT_CASE', label: '常量 (CONSTANT_CASE)', value: '' }
])
// 显示提示
const showToast = (message, type = 'success', duration = 3000) => {
toastMessage.value = message
toastType.value = type
if (toastTimer) {
clearTimeout(toastTimer)
}
toastTimer = setTimeout(() => {
toastMessage.value = ''
}, duration)
}
// 关闭提示
const closeToast = () => {
if (toastTimer) {
clearTimeout(toastTimer)
toastTimer = null
}
toastMessage.value = ''
}
// 将输入文本解析为单词数组
const parseToWords = (text) => {
if (!text || !text.trim()) {
return []
}
let processed = text.trim()
// 处理各种分隔符:空格、下划线、横线、驼峰
// 1. 先处理连续大写字母的情况:XMLHttpRequest -> XML Http Request
processed = processed.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
// 2. 先处理数字和字母的边界(必须在驼峰处理之前)
// 2.1 字母+数字+字母:temp2Detail -> temp 2 Detail
processed = processed.replace(/([a-zA-Z])(\d+)([a-zA-Z])/g, '$1 $2 $3')
// 2.2 字母+数字(后面跟着分隔符或结尾,但不是字母):item2 -> item 2
// 注意:这里不匹配后面跟着字母的情况(已由2.1处理)
processed = processed.replace(/([a-zA-Z])(\d+)(?=[_\-\s]|$)/g, '$1 $2')
// 2.3 数字+字母(在单词开头或前面是分隔符):2item -> 2 item
processed = processed.replace(/(\d+)([a-zA-Z])/g, '$1 $2')
// 3. 处理驼峰:camelCase -> camel Case(在数字处理之后)
processed = processed.replace(/([a-z])([A-Z])/g, '$1 $2')
// 4. 统一分隔符:下划线、横线、空格统一为空格
processed = processed.replace(/[_\-\s]+/g, ' ')
// 5. 分割并处理
let words = processed
.split(' ')
.filter(word => word.length > 0)
.map(word => {
// 转换为小写,保留字母和数字
return word.toLowerCase()
})
.filter(word => word.length > 0) // 允许纯数字
return words
}
// 转换单词首字母为大写(处理数字情况)
const capitalizeWord = (word) => {
if (!word) return ''
// 如果单词是纯数字,直接返回
if (/^\d+$/.test(word)) return word
// 否则首字母大写
return word.charAt(0).toUpperCase() + word.slice(1)
}
// 转换为小驼峰 (camelCase)
const toCamelCase = (words) => {
if (words.length === 0) return ''
const firstWord = words[0]
const restWords = words.slice(1).map(word => capitalizeWord(word))
return firstWord + restWords.join('')
}
// 转换为大驼峰 (PascalCase)
const toPascalCase = (words) => {
if (words.length === 0) return ''
return words.map(word => capitalizeWord(word)).join('')
}
// 转换为下划线 (snake_case)
const toSnakeCase = (words) => {
if (words.length === 0) return ''
return words.join('_')
}
// 转换为横线 (kebab-case)
const toKebabCase = (words) => {
if (words.length === 0) return ''
return words.join('-')
}
// 转换为常量 (CONSTANT_CASE)
const toConstantCase = (words) => {
if (words.length === 0) return ''
return words.map(word => word.toUpperCase()).join('_')
}
// 转换变量名
const convertVariableName = () => {
const words = parseToWords(inputText.value)
if (words.length === 0) {
formats.value.forEach(format => {
format.value = ''
})
return
}
formats.value.forEach(format => {
switch (format.key) {
case 'camelCase':
format.value = toCamelCase(words)
break
case 'PascalCase':
format.value = toPascalCase(words)
break
case 'snake_case':
format.value = toSnakeCase(words)
break
case 'kebab-case':
format.value = toKebabCase(words)
break
case 'CONSTANT_CASE':
format.value = toConstantCase(words)
break
}
})
}
// 清空输入
const clearInput = () => {
inputText.value = ''
convertVariableName()
}
// 复制到剪贴板
const copyToClipboard = async (text, label) => {
if (!text || text === '—') {
showToast('没有可复制的内容', 'error')
return
}
try {
await navigator.clipboard.writeText(text)
showToast(`${label}已复制到剪贴板`, 'success', 2000)
} catch (error) {
showToast('复制失败:' + error.message, 'error')
}
}
</script>
<style scoped>
.variable-name-converter {
width: 100%;
min-height: 100vh;
background: #f5f5f5;
padding: 2rem 1rem;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.conversion-card {
background: #ffffff;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid #e5e5e5;
}
.input-section {
margin-bottom: 2rem;
}
.input-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #333333;
margin-bottom: 0.5rem;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-field {
flex: 1;
padding: 0.75rem;
padding-right: 2.5rem;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 0.9375rem;
font-family: 'Courier New', monospace;
transition: all 0.2s;
}
.input-field:focus {
outline: none;
border-color: #1a1a1a;
box-shadow: 0 0 0 3px rgba(26, 26, 26, 0.1);
}
.clear-btn {
position: absolute;
right: 0.5rem;
padding: 0.375rem;
background: transparent;
border: none;
border-radius: 4px;
color: #666666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
width: 24px;
height: 24px;
}
.clear-btn:hover {
background: #f5f5f5;
color: #1a1a1a;
}
.output-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.output-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.output-item {
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 1rem;
background: #fafafa;
transition: all 0.2s;
display: flex;
flex-direction: column;
}
.output-item:hover {
border-color: #d0d0d0;
background: #ffffff;
}
.output-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.output-label {
font-size: 0.875rem;
font-weight: 500;
color: #333333;
}
.copy-btn {
padding: 0.375rem;
background: transparent;
border: 1px solid #d0d0d0;
border-radius: 4px;
color: #666666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
width: 28px;
height: 28px;
}
.copy-btn:hover {
background: #f5f5f5;
border-color: #1a1a1a;
color: #1a1a1a;
}
.copy-btn:active {
transform: scale(0.98);
}
.copy-btn i {
font-size: 0.875rem;
}
.output-value {
font-family: 'Courier New', monospace;
font-size: 1rem;
color: #1a1a1a;
word-break: break-all;
min-height: 1.5rem;
padding: 0.5rem 0;
}
.output-value.empty {
color: #999999;
}
/* Toast通知样式 */
.toast-notification {
position: fixed;
top: 80px;
right: 20px;
background: #ffffff;
border: 1px solid #e5e5e5;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1rem 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
z-index: 1000;
min-width: 280px;
max-width: 400px;
}
.toast-notification.success {
border-left: 4px solid #10b981;
}
.toast-notification.error {
border-left: 4px solid #ef4444;
}
.toast-content {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
font-size: 0.875rem;
color: #333333;
}
.toast-content i {
flex-shrink: 0;
}
.toast-notification.success .toast-content i {
color: #10b981;
}
.toast-notification.error .toast-content i {
color: #ef4444;
}
.toast-close-btn {
background: transparent;
border: none;
color: #666666;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
flex-shrink: 0;
}
.toast-close-btn:hover {
color: #1a1a1a;
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
@media (max-width: 768px) {
.variable-name-converter {
padding: 1rem 0.5rem;
}
.conversion-card {
padding: 1.5rem;
}
.output-row {
grid-template-columns: 1fr;
}
.toast-notification {
right: 10px;
left: 10px;
max-width: none;
top: 60px;
}
}
</style>
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { getByteLength, truncateToMaxBytes } from '../../src/utils/byteUtils.js'
describe('byteUtils', () => {
it('counts ASCII bytes', () => {
expect(getByteLength('hello')).toBe(5)
expect(getByteLength('')).toBe(0)
})
it('counts UTF-8 multibyte characters', () => {
expect(getByteLength('中')).toBe(3)
expect(getByteLength('hello世界')).toBe(11)
})
it('returns original string when within limit', () => {
expect(truncateToMaxBytes('hello', 10)).toBe('hello')
})
it('truncates by bytes without breaking multibyte chars', () => {
const text = 'a'.repeat(10) + '中'
const truncated = truncateToMaxBytes(text, 11)
expect(getByteLength(truncated)).toBeLessThanOrEqual(11)
expect(truncated).not.toContain('中')
})
it('handles exact byte boundary', () => {
expect(truncateToMaxBytes('ab', 2)).toBe('ab')
expect(truncateToMaxBytes('abc', 2)).toBe('ab')
})
})
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { rgbToHex, rgbToHsl, hexToRgb, hslToRgb } from '../../src/utils/color.js'
describe('color conversion', () => {
it('converts RGB to hex', () => {
expect(rgbToHex(255, 0, 0)).toBe('FF0000')
expect(rgbToHex(0, 255, 0)).toBe('00FF00')
expect(rgbToHex(0, 0, 255)).toBe('0000FF')
})
it('pads single-digit hex values', () => {
expect(rgbToHex(1, 2, 3)).toBe('010203')
})
it('clamps RGB values to 0-255', () => {
expect(rgbToHex(300, -10, 128)).toBe('FF0080')
})
it('converts hex to RGB', () => {
expect(hexToRgb('FF0000')).toEqual({ r: 255, g: 0, b: 0 })
expect(hexToRgb('invalid')).toBeNull()
})
it('converts RGB to HSL and back (gray)', () => {
const hsl = rgbToHsl(128, 128, 128)
expect(hsl.s).toBe(0)
const rgb = hslToRgb(hsl.h, hsl.s, hsl.l)
expect(rgb.r).toBeCloseTo(128, 0)
expect(rgb.g).toBeCloseTo(128, 0)
expect(rgb.b).toBeCloseTo(128, 0)
})
it('converts pure red RGB <-> HSL', () => {
const hsl = rgbToHsl(255, 0, 0)
expect(hsl.h).toBe(0)
expect(hsl.s).toBe(100)
const rgb = hslToRgb(0, 100, 50)
expect(rgb.r).toBe(255)
expect(rgb.g).toBe(0)
expect(rgb.b).toBe(0)
})
})
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { escapeHtml, highlightDiff } from '../../../src/utils/comparator/html.js'
describe('comparator/html', () => {
describe('escapeHtml', () => {
it('escapes special HTML characters', () => {
expect(escapeHtml('<script>"\'&</script>')).toBe(
'&lt;script&gt;&quot;&#39;&amp;&lt;/script&gt;'
)
})
it('handles empty string', () => {
expect(escapeHtml('')).toBe('')
})
it('coerces non-string values', () => {
expect(escapeHtml(123)).toBe('123')
})
})
describe('highlightDiff', () => {
it('returns escaped text when no ranges', () => {
expect(highlightDiff('hello', [])).toBe('hello')
expect(highlightDiff('a<b', null)).toBe('a&lt;b')
})
it('wraps diff ranges with highlight span', () => {
const result = highlightDiff('hello', [{ start: 1, end: 4 }])
expect(result).toBe('h<span class="diff-highlight">ell</span>o')
})
it('handles multiple ranges', () => {
const result = highlightDiff('abcdef', [
{ start: 0, end: 1 },
{ start: 4, end: 6 },
])
expect(result).toContain('<span class="diff-highlight">a</span>bcd')
expect(result).toContain('<span class="diff-highlight">ef</span>')
})
})
})
+243
View File
@@ -0,0 +1,243 @@
import { describe, it, expect } from 'vitest'
import {
NodeComparisonResult,
compareJsonNodes,
compareJson,
stringSimilarity,
calculateStats,
formatJsonData,
} from '../../../src/utils/comparator/jsonCompare.js'
describe('comparator/jsonCompare', () => {
describe('stringSimilarity', () => {
it('returns same for identical strings', () => {
expect(stringSimilarity('hello', 'hello')).toEqual({ type: 'same', similarity: 1.0 })
})
it('returns same for two empty strings', () => {
expect(stringSimilarity('', '')).toEqual({ type: 'same', similarity: 1.0 })
})
it('returns different for completely unrelated strings', () => {
expect(stringSimilarity('abc', 'xyz')).toEqual({ type: 'different', similarity: 0.0 })
})
it('returns similar for partially matching strings', () => {
const result = stringSimilarity('hello', 'hallo')
expect(result.type).toBe('similar')
expect(result.similarity).toBeGreaterThan(0)
expect(result.similarity).toBeLessThan(1)
})
})
describe('compareJsonNodes', () => {
it('compares identical primitives', () => {
expect(compareJsonNodes(1, 1).type).toBe(NodeComparisonResult.SAME)
expect(compareJsonNodes(true, true).type).toBe(NodeComparisonResult.SAME)
expect(compareJsonNodes(false, false).type).toBe(NodeComparisonResult.SAME)
})
it('compares different numbers as DIFFERENT', () => {
expect(compareJsonNodes(1, 2).type).toBe(NodeComparisonResult.DIFFERENT)
})
it('treats null === null as SAME', () => {
expect(compareJsonNodes(null, null).type).toBe(NodeComparisonResult.SAME)
})
it('treats null vs undefined as DIFFERENT', () => {
expect(compareJsonNodes(null, undefined).type).toBe(NodeComparisonResult.DIFFERENT)
})
it('treats null vs value as DIFFERENT', () => {
expect(compareJsonNodes(null, 1).type).toBe(NodeComparisonResult.DIFFERENT)
expect(compareJsonNodes(1, null).type).toBe(NodeComparisonResult.DIFFERENT)
})
it('compares strings with similarity', () => {
const result = compareJsonNodes('hello', 'hallo')
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
})
it('compares different types as DIFFERENT', () => {
expect(compareJsonNodes(1, '1').type).toBe(NodeComparisonResult.DIFFERENT)
expect(compareJsonNodes([], {}).type).toBe(NodeComparisonResult.DIFFERENT)
})
it('compares empty objects as SAME', () => {
expect(compareJsonNodes({}, {}).type).toBe(NodeComparisonResult.SAME)
})
it('compares empty arrays as SAME', () => {
expect(compareJsonNodes([], []).type).toBe(NodeComparisonResult.SAME)
})
it('detects added key as SIMILAR map', () => {
const result = compareJsonNodes({ a: 1 }, { a: 1, b: 2 })
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
expect(result.children.some(c => c.key === 'b')).toBe(true)
})
it('detects removed key', () => {
const result = compareJsonNodes({ a: 1, b: 2 }, { a: 1 })
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
})
it('detects changed value for same key as SIMILAR', () => {
const result = compareJsonNodes({ name: 'Alice' }, { name: 'Bob' })
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
const nameComp = result.children.find(c => c.key === 'name')
expect(nameComp.type).toBe(NodeComparisonResult.SIMILAR)
})
it('compares nested objects', () => {
const a = { user: { name: 'Alice', age: 30 } }
const b = { user: { name: 'Alice', age: 31 } }
const result = compareJsonNodes(a, b)
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
})
})
describe('compareJsonNodes list order', () => {
it('preserves order by default (compareLists)', () => {
const result = compareJsonNodes([1, 2, 3], [1, 3, 2], false)
expect(result.type).not.toBe(NodeComparisonResult.SAME)
})
it('ignores order when ignoreOrder=true', () => {
const result = compareJsonNodes([1, 2, 3], [3, 2, 1], true)
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
expect(result.similarity).toBeGreaterThan(0)
expect(result.matches.filter(m => m.indexA !== undefined && m.indexB !== undefined)).toHaveLength(3)
})
it('matches reorderable arrays with same elements', () => {
const result = compareJsonNodes(
[{ id: 1 }, { id: 2 }],
[{ id: 2 }, { id: 1 }],
true
)
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
expect(result.matches.filter(m => m.indexA !== undefined && m.indexB !== undefined)).toHaveLength(2)
})
it('detects element addition in ordered list', () => {
const result = compareJsonNodes([1, 2], [1, 2, 3], false)
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
})
it('handles empty vs non-empty array', () => {
const result = compareJsonNodes([], [1])
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
})
})
describe('calculateStats', () => {
it('counts same leaf nodes', () => {
const comp = compareJsonNodes({ a: 1 }, { a: 1 })
const stats = calculateStats(comp)
expect(stats.same).toBeGreaterThan(0)
})
it('counts modify for changed string values', () => {
const comp = compareJsonNodes({ name: 'Alice' }, { name: 'Bob' })
const stats = calculateStats(comp)
expect(stats.modify).toBeGreaterThan(0)
})
it('counts insert/delete for added/removed keys', () => {
const comp = compareJsonNodes({ a: 1 }, { a: 1, b: 2 })
const stats = calculateStats(comp)
expect(stats.insert).toBeGreaterThan(0)
})
})
describe('formatJsonData', () => {
it('formats null', () => {
expect(formatJsonData(null)).toEqual(['null'])
})
it('formats string with quotes', () => {
expect(formatJsonData('hello')).toEqual(['"hello"'])
})
it('formats number and boolean', () => {
expect(formatJsonData(42)).toEqual(['42'])
expect(formatJsonData(true)).toEqual(['true'])
})
it('formats empty array and object', () => {
expect(formatJsonData([])).toEqual(['[]'])
expect(formatJsonData({})).toEqual(['{}'])
})
})
describe('compareJson (integration)', () => {
it('compares identical JSON text', () => {
const json = '{"a":1,"b":[1,2]}'
const result = compareJson(json, json)
expect(result.stats.same).toBeGreaterThan(0)
expect(result.left.length).toBe(result.right.length)
expect(result.left.every((l, i) => l.lineNumber === i + 1)).toBe(true)
})
it('throws on invalid JSON', () => {
expect(() => compareJson('{invalid', '{}')).toThrow('JSON解析失败')
expect(() => compareJson('{}', '[unclosed')).toThrow('JSON解析失败')
})
it('throws on empty invalid input', () => {
expect(() => compareJson('', '{}')).toThrow('JSON解析失败')
})
it('detects value change in JSON', () => {
const result = compareJson('{"name":"Alice"}', '{"name":"Bob"}')
expect(result.stats.modify).toBeGreaterThan(0)
})
it('respects ignoreListOrder option', () => {
const a = '{"items":[1,2,3]}'
const b = '{"items":[3,2,1]}'
const ordered = compareJson(a, b, false)
const ignored = compareJson(a, b, true)
expect(ignored.stats.same).toBeGreaterThanOrEqual(ordered.stats.same)
})
it('handles deeply nested JSON', () => {
const a = JSON.stringify({ level1: { level2: { level3: { value: 1 } } } })
const b = JSON.stringify({ level1: { level2: { level3: { value: 2 } } } })
const result = compareJson(a, b)
expect(result.stats.modify).toBeGreaterThan(0)
})
it('handles JSON with unicode', () => {
const a = '{"msg":"你好"}'
const b = '{"msg":"世界"}'
const result = compareJson(a, b)
expect(result.left.length).toBeGreaterThan(0)
})
it('handles boolean and null values', () => {
const a = '{"flag":true,"empty":null}'
const b = '{"flag":false,"empty":null}'
const result = compareJson(a, b)
expect(result.stats.modify).toBeGreaterThan(0)
})
it('handles array length mismatch', () => {
const result = compareJson('[1,2]', '[1,2,3]')
expect(result.stats.insert + result.stats.modify).toBeGreaterThan(0)
})
it('handles empty objects comparison', () => {
const result = compareJson('{}', '{}')
expect(result.left.length).toBe(result.right.length)
expect(result.left.every(l => l.type === 'same')).toBe(true)
})
it('handles whitespace in JSON parse (valid JSON)', () => {
const result = compareJson(' { "a" : 1 } ', '{ "a" : 1 }')
expect(result.stats.same).toBeGreaterThan(0)
})
})
})
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect } from 'vitest'
import {
computeDiff,
shouldMergeAsModify,
compareTextByLine,
compareTextByChar,
} from '../../../src/utils/comparator/textDiff.js'
describe('comparator/textDiff', () => {
describe('shouldMergeAsModify', () => {
it('returns false for identical lines', () => {
expect(shouldMergeAsModify('same', 'same')).toBe(false)
})
it('merges JSON key-value lines with same key', () => {
expect(shouldMergeAsModify(' "name": "Alice"', ' "name": "Bob"')).toBe(true)
})
it('does not merge JSON key-value lines with different keys', () => {
expect(shouldMergeAsModify(' "name": "Alice"', ' "age": 30')).toBe(false)
})
it('merges structurally similar lines (>50% prefix)', () => {
expect(shouldMergeAsModify(' { "a": 1 }', ' { "a": 2 }')).toBe(true)
})
it('does not merge completely different lines', () => {
expect(shouldMergeAsModify('foo', 'bar')).toBe(false)
})
})
describe('computeDiff', () => {
it('finds full match path', () => {
const path = computeDiff(['a', 'b'], ['a', 'b'])
expect(path.some(p => p.x === 0 && p.y === 0)).toBe(true)
expect(path.some(p => p.x === 1 && p.y === 1)).toBe(true)
})
it('prefers leftmost match for duplicate chars in B (hello vs helloworld)', () => {
const path = computeDiff('hello'.split(''), 'helloworld'.split(''))
const oMatch = path.find(p => {
const charA = 'hello'[p.x]
const charB = 'helloworld'[p.y]
return charA === 'o' && charB === 'o'
})
expect(oMatch).toBeDefined()
expect(oMatch.y).toBe(4)
})
it('handles empty arrays', () => {
const path = computeDiff([], [])
expect(path).toEqual([{ x: 0, y: 0 }])
})
it('handles one empty array', () => {
const path = computeDiff(['a'], [])
expect(path[path.length - 1]).toEqual({ x: 1, y: 0 })
})
})
describe('compareTextByLine', () => {
it('marks identical multi-line text as all same', () => {
const result = compareTextByLine('line1\nline2\nline3', 'line1\nline2\nline3')
expect(result.left.every(l => l.type === 'same')).toBe(true)
expect(result.right.every(l => l.type === 'same')).toBe(true)
expect(result.stats).toEqual({ same: 3, insert: 0, delete: 0, modify: 0 })
})
it('handles empty strings (single empty line)', () => {
const result = compareTextByLine('', '')
expect(result.stats.same).toBe(1)
})
it('detects pure insertion on right side', () => {
const result = compareTextByLine('a', 'a\nb')
expect(result.stats.insert).toBeGreaterThan(0)
const insertLine = result.right.find(l => l.type === 'insert')
expect(insertLine?.content).toBe('b')
})
it('detects pure deletion on right side', () => {
const result = compareTextByLine('a\nb', 'a')
expect(result.stats.delete).toBeGreaterThan(0)
const deleteLine = result.left.find(l => l.type === 'delete')
expect(deleteLine?.content).toBe('b')
})
it('merges similar JSON lines as modify', () => {
const result = compareTextByLine(
' "name": "Alice"',
' "name": "Bob"'
)
expect(result.stats.modify).toBe(1)
expect(result.left[0].type).toBe('modify')
expect(result.right[0].type).toBe('modify')
})
it('shows delete+insert for different JSON keys', () => {
const result = compareTextByLine(
' "name": "Alice"',
' "age": 30'
)
expect(result.stats.delete).toBe(1)
expect(result.stats.insert).toBe(1)
expect(result.stats.modify).toBe(0)
})
it('assigns line numbers correctly for same lines', () => {
const result = compareTextByLine('a\nb', 'a\nb')
expect(result.left[0].lineNumber).toBe(1)
expect(result.left[1].lineNumber).toBe(2)
})
it('sets null lineNumber for placeholder lines on opposite side', () => {
const result = compareTextByLine('only-left', 'only-right')
const leftInsert = result.left.find(l => l.type === 'insert')
const rightDelete = result.right.find(l => l.type === 'delete')
if (leftInsert) expect(leftInsert.lineNumber).toBeNull()
if (rightDelete) expect(rightDelete.lineNumber).toBeNull()
})
it('handles completely different single lines', () => {
const result = compareTextByLine('aaa', 'bbb')
expect(result.stats.delete + result.stats.insert + result.stats.modify).toBeGreaterThan(0)
})
it('handles trailing newline difference consistently', () => {
const result = compareTextByLine('a\n', 'a')
expect(result.left.length).toBe(result.right.length)
})
})
describe('compareTextByChar', () => {
it('marks identical lines as same with char count in stats', () => {
const result = compareTextByChar('hello', 'hello')
expect(result.left[0].type).toBe('same')
expect(result.stats.same).toBe(5)
})
it('highlights character differences inline', () => {
const result = compareTextByChar('abc', 'adc')
expect(result.left[0].inlineHighlight).toBe(true)
expect(result.left[0].html).toContain('diff-highlight')
})
it('handles empty vs non-empty line', () => {
const result = compareTextByChar('', 'hello')
expect(result.stats.insert).toBeGreaterThan(0)
})
it('handles insertion at end (hello vs helloworld)', () => {
const result = compareTextByChar('hello', 'helloworld')
expect(result.stats.insert).toBeGreaterThan(0)
expect(result.left[0].html).toContain('hello')
})
it('handles deletion (helloworld vs hello)', () => {
const result = compareTextByChar('helloworld', 'hello')
expect(result.stats.delete).toBeGreaterThan(0)
})
it('handles modify at start (na vs aa)', () => {
const result = compareTextByChar('na', 'aa')
expect(result.stats.delete).toBeGreaterThan(0)
expect(result.stats.insert).toBeGreaterThan(0)
expect(result.left[0].inlineHighlight).toBe(true)
})
it('aligns lines by index across multiline text', () => {
const result = compareTextByChar('a\nb', 'a\nc')
expect(result.left).toHaveLength(2)
expect(result.right).toHaveLength(2)
expect(result.left[0].type).toBe('same')
expect(result.left[1].type).toBe('modify')
})
it('handles extra lines on one side', () => {
const result = compareTextByChar('a', 'a\nb')
expect(result.left.length).toBe(2)
expect(result.left[1].content).toBe('')
expect(result.right[1].content).toBe('b')
})
it('escapes HTML in diff output', () => {
const result = compareTextByChar('<script>', '<script>')
expect(result.left[0].html).not.toContain('<script>')
expect(result.left[0].html).toContain('&lt;')
})
it('assigns sequential line numbers', () => {
const result = compareTextByChar('a\nb', 'a\nb')
expect(result.left.map(l => l.lineNumber)).toEqual([1, 2])
})
})
})
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest'
import {
encodeBase64,
decodeBase64,
encodeUrl,
decodeUrl,
encodeUnicode,
decodeUnicode,
bytesToBase64,
base64ToBytes,
} from '../../src/utils/encoder.js'
import { zlibSync, decompressSync } from 'fflate'
describe('encoder', () => {
describe('base64', () => {
it('encodes and decodes ASCII text', () => {
expect(decodeBase64(encodeBase64('hello'))).toBe('hello')
})
it('encodes and decodes unicode text', () => {
const text = '你好世界'
expect(decodeBase64(encodeBase64(text))).toBe(text)
})
})
describe('url', () => {
it('encodes and decodes URL components', () => {
expect(decodeUrl(encodeUrl('a b&c=1'))).toBe('a b&c=1')
})
})
describe('unicode escape', () => {
it('encodes BMP characters', () => {
expect(encodeUnicode('A')).toBe('\\u0041')
})
it('roundtrips BMP text', () => {
expect(decodeUnicode('\\u0048\\u0065\\u006C\\u006C\\u006F')).toBe('Hello')
})
it('encodes and decodes emoji (surrogate pair)', () => {
const emoji = '😀'
const encoded = encodeUnicode(emoji)
expect(decodeUnicode(encoded)).toBe(emoji)
})
it('decodes \\UXXXXXXXX format', () => {
expect(decodeUnicode('\\U0001F600')).toBe('😀')
})
it('throws on invalid unicode code point', () => {
expect(() => decodeUnicode('\\U00110000')).toThrow('Unicode 解码失败')
})
})
describe('binary helpers', () => {
it('roundtrips bytes through base64', () => {
const bytes = new Uint8Array([1, 2, 3, 255])
expect([...base64ToBytes(bytesToBase64(bytes))]).toEqual([...bytes])
})
it('strips whitespace when decoding base64', () => {
const bytes = new Uint8Array([72, 101, 108, 108, 111])
expect(new TextDecoder().decode(base64ToBytes('SGVs\nbG8='))).toBe('Hello')
})
})
describe('zlib roundtrip', () => {
it('compresses and decompresses text', () => {
const text = 'hello zlib compression test'
const compressed = zlibSync(new TextEncoder().encode(text))
const b64 = bytesToBase64(compressed)
const decompressed = new TextDecoder().decode(decompressSync(base64ToBytes(b64)))
expect(decompressed).toBe(text)
})
})
})
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest'
import {
parseJsonPath,
pathToJsonPath,
pathMatchesJsonPath,
getDataByPath,
} from '../../src/utils/jsonFormatter/jsonPath.js'
describe('jsonPath', () => {
describe('parseJsonPath', () => {
it('returns null for empty path', () => {
expect(parseJsonPath('')).toBeNull()
expect(parseJsonPath(' ')).toBeNull()
})
it('returns empty array for root $', () => {
expect(parseJsonPath('$')).toEqual([])
expect(parseJsonPath('$.')).toEqual([])
})
it('parses object keys', () => {
expect(parseJsonPath('$.name')).toEqual([{ type: 'key', key: 'name' }])
expect(parseJsonPath('$.user.name')).toEqual([
{ type: 'key', key: 'user' },
{ type: 'key', key: 'name' },
])
})
it('parses array index', () => {
expect(parseJsonPath('$.items[0]')).toEqual([
{ type: 'key', key: 'items' },
{ type: 'index', index: 0 },
])
})
it('parses wildcard index', () => {
expect(parseJsonPath('$.items[*]')).toEqual([
{ type: 'key', key: 'items' },
{ type: 'wildcard', index: '*' },
])
})
})
describe('pathToJsonPath', () => {
it('converts internal path to JSONPath', () => {
expect(pathToJsonPath('root')).toBe('$')
expect(pathToJsonPath('root.user.name')).toBe('$.user.name')
})
})
describe('pathMatchesJsonPath', () => {
it('matches exact key path', () => {
const segments = parseJsonPath('$.user.name')
expect(pathMatchesJsonPath('root.user.name', segments)).toBe(true)
expect(pathMatchesJsonPath('root.user.age', segments)).toBe(false)
})
it('requires exact segment count', () => {
const segments = parseJsonPath('$.user')
expect(pathMatchesJsonPath('root.user.name', segments)).toBe(false)
})
it('matches wildcard index', () => {
const segments = parseJsonPath('$.items[*]')
expect(pathMatchesJsonPath('root.items[2]', segments)).toBe(true)
expect(pathMatchesJsonPath('root.items.name', segments)).toBe(false)
})
it('returns true for empty segments (match all)', () => {
expect(pathMatchesJsonPath('root.any.path', [])).toBe(true)
})
})
describe('getDataByPath', () => {
const data = { user: { name: 'Alice', tags: ['a', 'b'] }, count: 3 }
it('gets nested object value', () => {
expect(getDataByPath(data, 'root.user.name')).toBe('Alice')
})
it('gets array element', () => {
expect(getDataByPath(data, 'root.user.tags[1]')).toBe('b')
})
it('returns undefined for missing path', () => {
expect(getDataByPath(data, 'root.missing')).toBeUndefined()
})
})
})
+22
View File
@@ -0,0 +1,22 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest'
import router from '../../src/router/index.js'
describe('router', () => {
it('registers all toolbox routes', () => {
const paths = router.getRoutes().map(r => r.path)
expect(paths).toContain('/')
expect(paths).toContain('/json-formatter')
expect(paths).toContain('/comparator')
expect(paths).toContain('/encoder-decoder')
expect(paths).toContain('/variable-name')
expect(paths).toContain('/qr-code')
expect(paths).toContain('/timestamp-converter')
expect(paths).toContain('/color-converter')
})
it('sets page title suffix via meta', () => {
const comparator = router.getRoutes().find(r => r.name === 'Comparator')
expect(comparator.meta.titleSuffix).toBe('对比')
})
})
+63
View File
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import {
getSiteTitle,
getSiteIcp,
getPageTitle,
getGaMeasurementId,
getJinrishiciSdkUrl,
} from '../../src/config/site.js'
import { initAnalytics } from '../../src/utils/analytics.js'
describe('site config', () => {
beforeEach(() => {
delete window.__SITE_CONFIG__
})
afterEach(() => {
delete window.__SITE_CONFIG__
})
it('uses default title and icp', () => {
expect(getSiteTitle()).toBe('RC707的工具箱')
expect(getSiteIcp()).toBe('苏ICP备2022013040号-1')
})
it('builds page title with suffix', () => {
expect(getPageTitle('JSON')).toBe('RC707的工具箱-JSON')
expect(getPageTitle(null)).toBe('RC707的工具箱')
})
it('prefers runtime config over defaults', () => {
window.__SITE_CONFIG__ = { title: '自定义工具箱', icp: '京ICP备12345678号' }
expect(getSiteTitle()).toBe('自定义工具箱')
expect(getSiteIcp()).toBe('京ICP备12345678号')
expect(getPageTitle('对比')).toBe('自定义工具箱-对比')
})
it('allows empty icp to hide footer', () => {
window.__SITE_CONFIG__ = { title: 'ToolBox', icp: '' }
expect(getSiteIcp()).toBe('')
})
it('disables third-party integrations by default', () => {
expect(getGaMeasurementId()).toBe('')
expect(getJinrishiciSdkUrl()).toBe('')
})
it('loads analytics only when configured', () => {
window.__SITE_CONFIG__ = { gaId: 'G-TEST123' }
initAnalytics()
expect(document.querySelector('script[src*="googletagmanager.com"]')).not.toBeNull()
expect(typeof window.gtag).toBe('function')
})
it('respects runtime privacy overrides', () => {
window.__SITE_CONFIG__ = {
gaId: 'G-RUNTIME',
jinrishiciSdkUrl: 'https://example.com/sdk.js',
}
expect(getGaMeasurementId()).toBe('G-RUNTIME')
expect(getJinrishiciSdkUrl()).toBe('https://example.com/sdk.js')
})
})
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import {
parseTimestampInput,
formatTimestampMs,
parseDateString,
dateToTimestamp,
} from '../../src/utils/timestamp.js'
describe('timestamp', () => {
// 2024-01-15 12:30:45 UTC+8 depends on local TZ - use fixed ms
const knownMs = 1705299045123
describe('parseTimestampInput', () => {
it('returns empty error for blank input', () => {
expect(parseTimestampInput('', 'seconds')).toEqual({ error: 'empty' })
})
it('parses seconds timestamp (10 digits)', () => {
const seconds = Math.floor(knownMs / 1000)
const result = parseTimestampInput(String(seconds), 'seconds')
expect(result.timestampMs).toBe(seconds * 1000)
})
it('parses milliseconds timestamp', () => {
const result = parseTimestampInput(String(knownMs), 'milliseconds')
expect(result.timestampMs).toBe(knownMs)
})
it('parses 13-digit value as milliseconds even in seconds mode', () => {
const result = parseTimestampInput(String(knownMs), 'seconds')
expect(result.timestampMs).toBe(knownMs)
})
it('parses nanoseconds timestamp', () => {
const ns = BigInt(knownMs) * BigInt(1000000) + BigInt(456789)
const result = parseTimestampInput(String(ns), 'nanoseconds')
expect(result.timestampMs).toBe(knownMs)
expect(result.nanoseconds).toBe(456789)
})
it('returns error for invalid number', () => {
expect(parseTimestampInput('abc', 'seconds')).toEqual({ error: 'invalid_number' })
})
it('returns error for invalid nanoseconds', () => {
expect(parseTimestampInput('not-a-bigint', 'nanoseconds')).toEqual({ error: 'invalid_nanoseconds' })
})
})
describe('formatTimestampMs', () => {
it('formats seconds precision without milliseconds', () => {
const result = formatTimestampMs(knownMs, 'seconds')
expect(result.value).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)
expect(result.value).not.toContain('.')
})
it('formats milliseconds precision', () => {
const result = formatTimestampMs(knownMs, 'milliseconds')
expect(result.value).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/)
})
it('formats nanoseconds precision with extra digits', () => {
const result = formatTimestampMs(knownMs, 'nanoseconds', 123456)
expect(result.value).toMatch(/\.\d{9}$/)
})
it('returns error for invalid date', () => {
expect(formatTimestampMs(NaN, 'seconds')).toEqual({ error: 'invalid_date' })
})
})
describe('parseDateString', () => {
it('returns empty error for blank', () => {
expect(parseDateString('')).toEqual({ error: 'empty' })
})
it('parses standard datetime format', () => {
const result = parseDateString('2024-06-15 10:30:00')
expect(result.date).toBeInstanceOf(Date)
expect(result.date.getFullYear()).toBe(2024)
})
it('parses datetime with milliseconds', () => {
const result = parseDateString('2024-06-15 10:30:00.123')
expect(result.date).toBeInstanceOf(Date)
})
it('returns error for invalid date string', () => {
expect(parseDateString('not-a-date')).toEqual({ error: 'invalid_date' })
})
})
describe('dateToTimestamp', () => {
const date = new Date(2024, 5, 15, 10, 30, 0)
it('converts to seconds', () => {
const result = dateToTimestamp(date, 'seconds')
expect(parseInt(result.value, 10)).toBe(Math.floor(date.getTime() / 1000))
})
it('converts to milliseconds', () => {
const result = dateToTimestamp(date, 'milliseconds')
expect(result.value).toBe(String(date.getTime()))
})
it('converts to nanoseconds', () => {
const result = dateToTimestamp(date, 'nanoseconds')
expect(result.value).toBe(String(BigInt(date.getTime()) * BigInt(1000000)))
})
})
})
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest'
import { countLines } from '../../src/composables/useLineNumberEditor.js'
describe('useLineNumberEditor.countLines', () => {
it('returns 1 for empty or whitespace-only text', () => {
expect(countLines('')).toBe(1)
expect(countLines(null)).toBe(1)
expect(countLines(undefined)).toBe(1)
})
it('counts single line without trailing newline', () => {
expect(countLines('hello')).toBe(1)
})
it('counts multiple lines', () => {
expect(countLines('a\nb\nc')).toBe(3)
expect(countLines('a\nb\n')).toBe(3)
})
})
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import {
parseToWords,
toCamelCase,
toPascalCase,
toSnakeCase,
toKebabCase,
toConstantCase,
} from '../../src/utils/variableName.js'
describe('variableName', () => {
describe('parseToWords', () => {
it('returns empty array for blank input', () => {
expect(parseToWords('')).toEqual([])
expect(parseToWords(' ')).toEqual([])
})
it('parses snake_case', () => {
expect(parseToWords('hello_world')).toEqual(['hello', 'world'])
})
it('parses kebab-case', () => {
expect(parseToWords('hello-world')).toEqual(['hello', 'world'])
})
it('parses camelCase', () => {
expect(parseToWords('helloWorld')).toEqual(['hello', 'world'])
})
it('parses PascalCase', () => {
expect(parseToWords('HelloWorld')).toEqual(['hello', 'world'])
})
it('parses consecutive uppercase (XMLHttpRequest)', () => {
expect(parseToWords('XMLHttpRequest')).toEqual(['xml', 'http', 'request'])
})
it('parses numbers in identifiers', () => {
expect(parseToWords('item2')).toEqual(['item', '2'])
expect(parseToWords('temp2Detail')).toEqual(['temp', '2', 'detail'])
})
})
describe('case conversions', () => {
const words = ['hello', 'world']
it('converts to camelCase', () => {
expect(toCamelCase(words)).toBe('helloWorld')
expect(toCamelCase([])).toBe('')
})
it('converts to PascalCase', () => {
expect(toPascalCase(words)).toBe('HelloWorld')
})
it('converts to snake_case', () => {
expect(toSnakeCase(words)).toBe('hello_world')
})
it('converts to kebab-case', () => {
expect(toKebabCase(words)).toBe('hello-world')
})
it('converts to CONSTANT_CASE', () => {
expect(toConstantCase(words)).toBe('HELLO_WORLD')
})
it('preserves numeric words in camelCase', () => {
expect(toCamelCase(['item', '2'])).toBe('item2')
})
})
})
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
if (!process.env.VITE_APP_TITLE) {
process.env.VITE_APP_TITLE = env.VITE_APP_TITLE || 'RC707的工具箱'
}
if (process.env.VITE_APP_ICP === undefined) {
process.env.VITE_APP_ICP = env.VITE_APP_ICP ?? '苏ICP备2022013040号-1'
}
return {
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
open: true
}
}
})
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'node',
include: ['tests/**/*.test.js'],
coverage: {
provider: 'v8',
include: ['src/utils/**/*.js', 'src/composables/**/*.js'],
},
},
})