5 Commits

Author SHA1 Message Date
renjue e397d03850 i18n 2026-02-05 11:49:42 +08:00
renjue ada0dfa3cc 修改REMOTE_DIR 2026-02-03 12:30:15 +08:00
renjue 2a07fd950f 编解码增加zlib 2026-02-02 16:19:44 +08:00
rose_cat707 8e5eea02f1 init 2026-02-02 00:09:20 +08:00
rose_cat707 cae4d9eb05 Initial commit 2026-01-17 05:04:25 +00:00
69 changed files with 3452 additions and 8591 deletions
-13
View File
@@ -1,13 +0,0 @@
node_modules
dist
dist-ssr
.git
.gitea
.cursor
*.log
.DS_Store
.vscode
.idea
coverage
*.md
!README.md
-11
View File
@@ -1,11 +0,0 @@
# 站点标题(浏览器标签、导航栏)
VITE_APP_TITLE=ToolBox
# 备案号(留空则不显示;Docker 运行时可用 APP_ICP= 隐藏)
# VITE_APP_ICP=
# Google Analytics 测量 ID(留空则不加载,避免向 Google 上报访问数据)
# VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
# 今日诗词 SDK 地址(留空则不加载,避免向第三方上报访问数据)
# VITE_JINRISHICI_SDK_URL=https://example.com/sdk.js
-182
View File
@@ -1,182 +0,0 @@
# 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
@@ -1,3 +0,0 @@
GITEA_INSTANCE_URL=https://git.example.com
GITEA_RUNNER_REGISTRATION_TOKEN=从仓库_Settings_Actions_Runners_复制
GITEA_RUNNER_NAME=go-vue-ci-runner
-183
View File
@@ -1,183 +0,0 @@
# 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`(默认)。
**推荐写法**(与 Prism 等仓库一致,由 runner `github_mirror` 拉取 Action):
```yaml
uses: actions/checkout@v4
```
也可写 Gitea 镜像绝对 URL(不依赖 runner 配置):
```yaml
uses: https://gitea.com/actions/checkout@v4
```
勿写 `https://github.com/actions/checkout@v4`:会绕过 `github_mirror` 直连 GitHub,内网 runner 易报 `unexpected EOF`。
修改 `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 挂载,具体路径取决于你的安装方式。
-33
View File
@@ -1,33 +0,0 @@
# 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: 'https://gitea.com' # 推荐:配合 uses: actions/checkout@v4
# 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
@@ -1,22 +0,0 @@
# 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:
-263
View File
@@ -1,263 +0,0 @@
# 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:
workflow_dispatch:
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: 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
NODE_VERSION=20.20.2
ARCH=$(uname -m | sed 's/x86_64/x64/; s/aarch64/arm64/')
TAR="node-v${NODE_VERSION}-linux-${ARCH}.tar.gz"
if ! command -v node >/dev/null 2>&1 || ! node -v | grep -qE "v20\."; then
downloaded=0
for url in \
"https://npmmirror.com/mirrors/node/v${NODE_VERSION}/${TAR}" \
"https://mirrors.aliyun.com/nodejs-release/v${NODE_VERSION}/${TAR}" \
"https://nodejs.org/dist/v${NODE_VERSION}/${TAR}"; do
echo "trying ${url}"
if curl -fsSL --connect-timeout 20 --retry 3 --retry-delay 5 --max-time 600 \
"$url" -o /tmp/node.tar.gz; then
downloaded=1
break
fi
done
[ "$downloaded" -eq 1 ] || { echo "failed to download Node ${NODE_VERSION}" >&2; exit 1; }
rm -rf /usr/local/node
mkdir -p /usr/local/node
tar -xzf /tmp/node.tar.gz -C /usr/local/node --strip-components=1
export PATH="/usr/local/node/bin:${PATH}"
fi
node -v
npm -v
# act-runner 工作区 volume 可能残留旧 lockfile,强制与当前 commit 一致
git checkout HEAD -- package-lock.json package.json .npmrc
echo "commit: $(git rev-parse --short HEAD)"
echo "lockfile sample:"
grep -m3 '"resolved"' package-lock.json || true
if grep -qE 'artifactory|xiaohongshu' package-lock.json; then
echo "ERROR: package-lock.json 仍含内网源,请检查 checkout" >&2
exit 1
fi
# 清除 runner 注入的内网 npm 配置,使用独立缓存目录
unset NPM_CONFIG_REGISTRY NPM_REGISTRY npm_config_registry NODE_AUTH_TOKEN 2>/dev/null || true
export CI=true
export NPM_CONFIG_USERCONFIG=/tmp/toolbox-npmrc
export NPM_CONFIG_GLOBALCONFIG=/tmp/toolbox-npmrc-global
export npm_config_cache=/tmp/toolbox-npm-cache
export npm_config_progress=true
export npm_config_loglevel=info
export npm_config_registry=https://registry.npmmirror.com
export npm_config_esbuild_binary_host_mirror=https://npmmirror.com/mirrors/esbuild
printf '%s\n' 'registry=https://registry.npmmirror.com' > /tmp/toolbox-npmrc-global
printf '%s\n' \
'registry=https://registry.npmmirror.com' \
'esbuild_binary_host_mirror=https://npmmirror.com/mirrors/esbuild' \
'always-auth=false' \
> /tmp/toolbox-npmrc
rm -rf node_modules /tmp/toolbox-npm-cache
mkdir -p /tmp/toolbox-npm-cache
npm cache clean --force
echo "npm registry: $(npm config get registry)"
echo "=== npm ci ==="
npm ci --userconfig=/tmp/toolbox-npmrc --registry=https://registry.npmmirror.com
echo "=== npm test ==="
npm run test:run -- --pool=forks --maxWorkers=2 --reporter=verbose
- 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
+1 -4
View File
@@ -24,7 +24,4 @@ dist-ssr
*.sw?
.cursor
coverage
.env
.env.*
!.env.example
/package-lock.json
-2
View File
@@ -1,2 +0,0 @@
registry=https://registry.npmmirror.com
esbuild_binary_host_mirror=https://npmmirror.com/mirrors/esbuild
-26
View File
@@ -1,26 +0,0 @@
# 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
@@ -1,47 +0,0 @@
# 贡献指南
感谢你对 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 中披露细节
-65
View File
@@ -1,65 +0,0 @@
# 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=ToolBox
ARG VITE_APP_ICP=
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
ENV npm_config_registry=https://registry.npmmirror.com \
npm_config_esbuild_binary_host_mirror=https://npmmirror.com/mirrors/esbuild
COPY package.json package-lock.json .npmrc ./
RUN npm ci --userconfig=/src/.npmrc --registry=https://registry.npmmirror.com
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
@@ -1,21 +0,0 @@
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
@@ -1,19 +0,0 @@
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.
+69 -110
View File
@@ -1,139 +1,98 @@
# ToolBox
# 🛠️ 工具箱 - 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
- Node.js >= 16.0.0
- npm 或 yarn 或 pnpm
### 安装与运行
### 安装步骤
1. 克隆项目或下载源码
```bash
git clone <repository-url>
cd Toolbox
```
2. 安装依赖
```bash
git clone https://git.rc707blog.top/rose_cat707/ToolBox.git
cd ToolBox
npm install
# 或
yarn install
# 或
pnpm install
```
## 🚀 运行
### 开发模式
```bash
npm run dev
# 或
yarn dev
# 或
pnpm dev
```
开发服务器默认 `http://localhost:3000`
开发服务器将在 `http://localhost:3000` 启动,并自动在浏览器中打开
### 构建
### 构建生产版本
```bash
npm run build # 产物输出到 dist/
npm run preview # 预览生产构建
npm run build
# 或
yarn build
# 或
pnpm build
```
### 测试
构建产物将输出到 `dist` 目录。
### 预览生产构建
```bash
npm run test:run
npm run preview
# 或
yarn preview
# 或
pnpm preview
```
## 环境变量
站点标题与备案号可通过环境变量配置,复制 `.env.example``.env` 后修改:
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `VITE_APP_TITLE` | 站点标题(导航栏、浏览器标签) | `ToolBox` |
| `VITE_APP_ICP` | 备案号(留空则不显示) | (空) |
| `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/
Toolbox/
├── src/
│ ├── views/ # 页面组件
│ ├── utils/ # 可测试的业务逻辑
├── composables/ # Vue composables
│ ├── components/ # 公共组件
── router/ # 路由
├── tests/ # Vitest 单元测试
├── deploy/ # Nginx 配置
├── Dockerfile
── .gitea/workflows/ # CI 流水线
│ ├── views/ # 页面组件
│ ├── router/ # 路由配置
│ └── index.js
│ ├── App.vue # 组件
── main.js # 应用入口
│ └── style.css # 全局样式
├── index.html # HTML 模板
├── vite.config.js # Vite 配置
── package.json # 项目配置
└── README.md # 项目说明
```
## 添加新工具
## 🔧 添加新工具
1.`src/views/` 创建 Vue 组件
2.`src/router/index.js` 添加路由
3.`src/App.vue` 导航栏添加入口
4.`src/views/Home.vue``tools` 数组中注册
5. 如有纯逻辑,提取到 `src/utils/` 并补充测试
要添加新的工具页面,只需:
## 自部署说明
1.`src/views/` 目录下创建新的 Vue 组件
2.`src/router/index.js` 中添加路由配置:
- **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`
```javascript
{
path: '/your-tool',
name: 'YourTool',
component: () => import('../views/YourTool.vue')
}
```
## 第三方依赖
主要依赖及许可证见 [NOTICE](./NOTICE)。Font Awesome 图标需保留其版权声明。
## 贡献
欢迎提交 Issue 和 Pull Request,请参阅 [CONTRIBUTING.md](./CONTRIBUTING.md)。
## 安全
发现安全漏洞请私下报告,详见 [SECURITY.md](./SECURITY.md)。
## 许可证
[MIT License](./LICENSE) © 2026 renjue
3.`src/App.vue` 的导航栏中添加链接
4.`src/views/Home.vue``tools` 数组中添加工具信息
-32
View File
@@ -1,32 +0,0 @@
# 安全策略
## 支持的版本
| 版本 | 支持状态 |
|--------|----------|
| 最新版 | ✅ |
| 旧版本 | ❌ |
## 报告漏洞
如果你发现了安全漏洞,**请勿在公开 Issue 中披露**。
请通过以下方式私下报告:
- 在 [Gitea Issues](https://git.rc707blog.top/rose_cat707/ToolBox/issues) 提交说明(标题注明 `[SECURITY]`,勿公开漏洞细节)
- 或在 Gitea 仓库中使用 **Private Security Advisory**(若已启用)
报告时请尽量包含:
- 漏洞类型与影响范围
- 复现步骤
- 受影响版本
- 可能的修复建议(如有)
我们会在合理时间内确认收到,并在修复后公开致谢(除非你希望匿名)。
## 安全注意事项(自部署)
- 本项目为纯前端静态应用,Docker 镜像使用 Nginx 提供静态文件
- **Google Analytics** 与 **今日诗词 SDK** 默认关闭,需通过环境变量显式启用(见 README)
- `deploy.sh` 通过命令行参数传递 SSH 密码,请勿在脚本或日志中硬编码凭据
-36
View File
@@ -1,36 +0,0 @@
#!/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="ToolBox"
fi
if [ -z "${APP_ICP+x}" ]; then
APP_ICP=""
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
@@ -1,21 +0,0 @@
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;
}
}
+10 -2
View File
@@ -3,8 +3,16 @@
<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>
<title>RC707的工具箱</title>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-C2H4BGZJBD"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-C2H4BGZJBD');
</script>
</head>
<body>
<div id="app"></div>
-3945
View File
File diff suppressed because it is too large Load Diff
+4 -20
View File
@@ -1,40 +1,24 @@
{
"name": "toolbox",
"version": "1.0.0",
"description": "A Vue-based online toolbox: JSON, diff, encode/decode, and more",
"description": "A Vue-based toolbox application",
"license": "MIT",
"type": "module",
"author": "rc_707@outlook.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"
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"fflate": "^0.8.2",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-i18n": "^9.14.4",
"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"
"vite": "^5.0.0"
}
}
-1
View File
@@ -1 +0,0 @@
window.__SITE_CONFIG__ = window.__SITE_CONFIG__ || {};
+196 -16
View File
@@ -2,20 +2,55 @@
<div class="app-container">
<nav class="navbar">
<div class="nav-content">
<router-link to="/" class="logo">
<h1>{{ siteTitle }}</h1>
<router-link :to="localePath(currentPathLocale, '')" class="logo">
<h1>{{ t('app.title') }}</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 class="nav-right">
<div class="nav-links">
<router-link :to="localePath(currentPathLocale, '')" class="nav-link">{{ t('nav.home') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'json-formatter')" class="nav-link">{{ t('nav.json') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'comparator')" class="nav-link">{{ t('nav.comparator') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'encoder-decoder')" class="nav-link">{{ t('nav.encoderDecoder') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'variable-name')" class="nav-link">{{ t('nav.variableName') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'qr-code')" class="nav-link">{{ t('nav.qrCode') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'timestamp-converter')" class="nav-link">{{ t('nav.timestamp') }}</router-link>
<router-link :to="localePath(currentPathLocale, 'color-converter')" class="nav-link">{{ t('nav.color') }}</router-link>
</div>
</div>
</div>
<div ref="localeDropRef" class="nav-locale">
<button
type="button"
class="locale-trigger"
:aria-expanded="localeOpen"
aria-haspopup="listbox"
aria-label="选择语言"
@click="localeOpen = !localeOpen"
>
{{ currentLocaleLabel }}
</button>
<Transition name="locale-drop">
<div
v-show="localeOpen"
class="locale-dropdown"
role="listbox"
aria-label="语言选项"
>
<button
v-for="opt in localeOptions"
:key="opt.pathLocale"
type="button"
role="option"
:aria-selected="currentPathLocale === opt.pathLocale"
class="locale-option"
:class="{ active: currentPathLocale === opt.pathLocale }"
@click="selectLocale(opt.pathLocale)"
>
{{ opt.label }}
</button>
</div>
</Transition>
</div>
</nav>
<main class="main-content">
<router-view />
@@ -24,9 +59,55 @@
</template>
<script setup>
import { siteConfig } from './config/site.js'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { localePath } from './router'
const siteTitle = siteConfig.title
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const localeOpen = ref(false)
const localeDropRef = ref(null)
const currentPathLocale = computed(() => route.params.locale || 'zh')
const localeOptions = [
{ pathLocale: 'zh', label: '简体中文' },
{ pathLocale: 'zh-tw', label: '繁體中文' },
{ pathLocale: 'en', label: 'English' },
]
const currentLocaleLabel = computed(() => {
const opt = localeOptions.find(o => o.pathLocale === currentPathLocale.value)
return opt ? opt.label : '简体中文'
})
function switchLocale(newPathLocale) {
if (newPathLocale === currentPathLocale.value) return
const pathWithoutLocale = route.path.replace(/^\/[^/]+/, '') || ''
const segment = pathWithoutLocale.startsWith('/') ? pathWithoutLocale.slice(1) : pathWithoutLocale
router.push(localePath(newPathLocale, segment))
}
function selectLocale(pathLocale) {
switchLocale(pathLocale)
localeOpen.value = false
}
function onClickOutside(e) {
if (localeDropRef.value && !localeDropRef.value.contains(e.target)) {
localeOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', onClickOutside)
})
</script>
<style scoped>
@@ -44,6 +125,9 @@ const siteTitle = siteConfig.title
top: 0;
z-index: 100;
min-height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-content {
@@ -54,6 +138,8 @@ const siteTitle = siteConfig.title
justify-content: space-between;
align-items: center;
min-height: 40px;
flex: 1;
min-width: 0;
}
.logo {
@@ -70,6 +156,12 @@ const siteTitle = siteConfig.title
letter-spacing: -0.02em;
}
.nav-right {
display: flex;
align-items: center;
gap: 0;
}
.nav-links {
display: flex;
gap: 0;
@@ -99,12 +191,84 @@ const siteTitle = siteConfig.title
background: #f5f5f5;
}
.nav-link.router-link-active {
.nav-link.router-link-exact-active {
color: #ffffff;
background: #1a1a1a;
border-bottom-color: #1a1a1a;
}
.nav-locale {
position: relative;
flex-shrink: 0;
margin-right: 0;
padding-right: 1rem;
}
.locale-trigger {
min-width: 5.5rem;
padding: 0.35rem 0.6rem;
font-size: 0.8125rem;
font-weight: 500;
color: #333;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
}
.locale-trigger:hover {
background: #f5f5f5;
}
.locale-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 8.5rem;
padding: 4px;
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
z-index: 200;
}
.locale-option {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: #333;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: background-color 0.15s;
}
.locale-option:hover {
background: #f5f5f5;
}
.locale-option.active {
background: #1a1a1a;
color: #fff;
}
.locale-drop-enter-active,
.locale-drop-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.locale-drop-enter-from,
.locale-drop-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.main-content {
flex: 1;
width: 100%;
@@ -127,20 +291,36 @@ const siteTitle = siteConfig.title
font-size: 1.125rem;
}
.nav-links {
.navbar {
flex-wrap: wrap;
}
.nav-content {
flex: 1 1 100%;
}
.nav-right {
flex-wrap: wrap;
justify-content: flex-start;
width: 100%;
}
.nav-links {
flex-wrap: wrap;
justify-content: flex-start;
}
.nav-link {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
.nav-locale {
padding-right: 0.75rem;
}
.main-content {
padding: 1rem;
}
}
</style>
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>
+19 -10
View File
@@ -9,14 +9,14 @@
<!-- 年月导航 -->
<div class="date-header">
<div class="nav-buttons">
<button @click="prevYear" class="nav-btn" title="上一年">«</button>
<button @click="prevMonth" class="nav-btn" title="上一月"></button>
<button @click="prevYear" class="nav-btn" :title="t('dateTimePicker.prevYear')">«</button>
<button @click="prevMonth" class="nav-btn" :title="t('dateTimePicker.prevMonth')"></button>
</div>
<div
v-if="!isEditingMonthYear"
@click="startEditingMonthYear"
class="current-month-year editable"
title="点击输入年月"
:title="t('dateTimePicker.clickInputMonthYear')"
>
{{ currentViewDate.getFullYear() }} - {{ String(currentViewDate.getMonth() + 1).padStart(2, '0') }}
</div>
@@ -27,18 +27,18 @@
@keyup.enter="confirmMonthYear"
@keyup.esc="cancelEditingMonthYear"
class="month-year-input"
placeholder="YYYY-MM"
:placeholder="t('dateTimePicker.placeholderMonthYear')"
ref="monthYearInputRef"
/>
<div class="nav-buttons">
<button @click="nextMonth" class="nav-btn" title="下一月"></button>
<button @click="nextYear" class="nav-btn" title="下一年">»</button>
<button @click="nextMonth" class="nav-btn" :title="t('dateTimePicker.nextMonth')"></button>
<button @click="nextYear" class="nav-btn" :title="t('dateTimePicker.nextYear')">»</button>
</div>
</div>
<!-- 星期标题 -->
<div class="weekdays">
<div class="weekday" v-for="day in ['日', '一', '二', '三', '四', '五', '六']" :key="day">
<div class="weekday" v-for="(day, idx) in weekdays" :key="idx">
{{ day }}
</div>
</div>
@@ -62,12 +62,12 @@
</div>
<!-- 此刻按钮 -->
<button @click="selectNow" class="now-btn">此刻</button>
<button @click="selectNow" class="now-btn">{{ t('dateTimePicker.now') }}</button>
</div>
<!-- 右侧时间选择区域 -->
<div class="time-picker-section">
<div class="time-header">选择时间</div>
<div class="time-header">{{ t('dateTimePicker.selectTime') }}</div>
<div class="time-selectors">
<!-- 小时选择 -->
@@ -117,7 +117,7 @@
</div>
<!-- 确定按钮 -->
<button @click="confirmSelection" class="confirm-btn">确定</button>
<button @click="confirmSelection" class="confirm-btn">{{ t('dateTimePicker.confirm') }}</button>
</div>
</div>
</div>
@@ -132,6 +132,15 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
const { t, tm } = useI18n()
const weekdays = computed(() => {
const msg = tm('dateTimePicker')
const w = msg?.weekdays
return Array.isArray(w) ? w : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
})
const props = defineProps({
modelValue: {
-51
View File
@@ -1,51 +0,0 @@
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
@@ -1,62 +0,0 @@
const DEFAULTS = {
title: 'ToolBox',
icp: '',
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()
},
}
+17
View File
@@ -0,0 +1,17 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import zhTW from './locales/zh-TW'
import en from './locales/en'
export const supportedLocales = ['zh-CN', 'zh-TW', 'en']
export const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'zh-TW': zhTW,
en,
},
})
+293
View File
@@ -0,0 +1,293 @@
export default {
nav: {
home: 'Home',
json: 'JSON',
comparator: 'Compare',
encoderDecoder: 'Encode/Decode',
variableName: 'Variable',
qrCode: 'QR Code',
timestamp: 'Timestamp',
color: 'Color',
},
app: {
title: "RC707's Toolbox",
titleJson: "RC707's Toolbox - JSON",
titleComparator: "RC707's Toolbox - Compare",
titleEncoderDecoder: "RC707's Toolbox - Encode/Decode",
titleVariableName: "RC707's Toolbox - Variable",
titleQrCode: "RC707's Toolbox - QR Code",
titleTimestamp: "RC707's Toolbox - Timestamp",
titleColor: "RC707's Toolbox - Color",
},
home: {
heroToday: 'Today is {date}',
toolJson: 'JSON',
toolJsonDesc: 'Format, validate and beautify JSON',
toolComparator: 'Compare',
toolComparatorDesc: 'Text and JSON comparison',
toolEncoderDecoder: 'Encode/Decode',
toolEncoderDecoderDesc: 'Encoding/decoding tools',
toolVariableName: 'Variable',
toolVariableNameDesc: 'Variable name format conversion',
toolQrCode: 'QR Code',
toolQrCodeDesc: 'Generate QR codes',
toolTimestamp: 'Timestamp',
toolTimestampDesc: 'Timestamp and date string conversion',
toolColor: 'Color',
toolColorDesc: 'Color format conversion',
},
locale: {
zhCN: '简',
zhTW: '繁',
en: 'EN',
},
common: {
close: 'Close',
copy: 'Copy',
paste: 'Paste',
clear: 'Clear',
history: 'History',
noHistory: 'No history yet',
copied: 'Copied to clipboard',
copyFailed: 'Copy failed: ',
clipboardEmpty: 'Clipboard is empty',
pasteHint: 'Press Ctrl+V or Cmd+V to paste',
pasteFailed: 'Cannot access editor, please paste manually',
cleared: 'Cleared',
},
json: {
editor: 'Editor',
maxSize: '(max 5MB)',
tree: 'Tree',
placeholder: 'Enter or paste JSON, e.g. {"name":"toolbox","version":1.0}',
jsonPathPlaceholder: 'Enter JSONPath, e.g. $.key.subkey',
jsonPathFilter: 'JSONPath filter',
clearFilter: 'Clear filter',
copyFilterResult: 'Copy filter result',
expandAll: 'Expand all',
collapseAll: 'Collapse all',
format: 'Format',
minify: 'Minify',
escape: 'Escape',
unescape: 'Unescape',
emptyState: 'Enter or paste JSON on the left, tree view will show on the right',
noMatchedNodes: 'No matching nodes',
noContentToCopy: 'Nothing to copy',
copiedCount: 'Copied {count} match(es)',
contentOverLimit: 'Content exceeds 5MB limit, truncated',
pleaseInputJson: 'Please enter JSON',
inputOverLimit: 'Input exceeds 5MB limit, cannot format',
outputOverLimit: 'Formatted output exceeds 5MB limit',
formatSuccess: 'Formatted',
jsonError: 'JSON error: ',
minifyOverLimit: 'Input exceeds 5MB limit, cannot minify',
minifyOutputOverLimit: 'Minified output exceeds 5MB limit',
minifySuccess: 'Minified',
escapeOverLimit: 'Input exceeds 5MB limit, cannot escape',
escapeOutputOverLimit: 'Escaped output exceeds 5MB limit',
escapeSuccess: 'Escaped',
escapeFailed: 'Escape failed: ',
unescapeOverLimit: 'Input exceeds 5MB limit, cannot unescape',
unescapeOutputOverLimit: 'Unescaped output exceeds 5MB limit',
unescapeSuccess: 'Unescaped',
unescapeFormatSuccess: 'Unescaped and formatted',
unescapeFailed: 'Unescape failed: ',
editorEmpty: 'Editor is empty, nothing to copy',
pasteOverLimit: 'Pasted content exceeds 5MB limit, truncated',
},
encoder: {
input: 'Input',
output: 'Output',
encode: 'Encode',
decode: 'Decode',
base64: 'Base64',
url: 'URL',
unicode: 'Unicode',
zlib: 'Zlib',
titleBase64: 'Base64 encode',
titleUrl: 'URL encode',
titleUnicode: 'Unicode encode',
titleZlib: 'Zlib compress/decompress',
copyOutput: 'Copy output',
inputPlaceholder: 'Enter text to encode or decode',
outputPlaceholder: 'Encoded or decoded result will appear here',
pleaseInputEncode: 'Please enter text to encode',
encodeSuccess: 'Encoded',
encodeFailed: 'Encode failed: ',
pleaseInputDecode: 'Please enter string to decode',
decodeSuccess: 'Decoded',
decodeFailed: 'Decode failed: invalid {type} string',
inputEmptyCopy: 'Input is empty, nothing to copy',
copiedInput: 'Input copied to clipboard',
outputEmptyCopy: 'Output is empty, nothing to copy',
copiedOutput: 'Output copied to clipboard',
manualPaste: 'Please paste manually',
},
variable: {
placeholder: 'Enter variable name (any format)',
camelCase: 'camelCase',
pascalCase: 'PascalCase',
snakeCase: 'snake_case',
kebabCase: 'kebab-case',
constantCase: 'CONSTANT_CASE',
copyLabel: 'Copy {label}',
empty: '—',
noContentToCopy: 'Nothing to copy',
copiedLabel: '{label} copied to clipboard',
},
qr: {
inputPlaceholder: 'Enter content for QR code',
generate: 'Generate QR code',
download: 'Download',
copyImage: 'Copy image',
qrCode: 'QR code',
pleaseInput: 'Please enter content for QR code',
generateSuccess: 'QR code generated',
generateFailed: 'Failed to generate: ',
noQrToDownload: 'No QR code to download',
downloadSuccess: 'Downloaded',
downloadFailed: 'Download failed: ',
noQrToCopy: 'No QR code to copy',
copyImageFailed: 'Copy failed, use download instead',
},
timestamp: {
dateToTs: 'Date → ({tz}) Timestamp:',
tsToDate: 'Timestamp → ({tz}) Date',
currentTs: 'Current timestamp:',
placeholderTs: 'Enter timestamp',
selectDateTime: 'Select date & time',
resetData: 'Reset',
resume: 'Resume',
pause: 'Pause',
seconds: 'Seconds',
milliseconds: 'Milliseconds',
nanoseconds: 'Nanoseconds',
datePlaceholderSeconds: 'Format: yyyy-MM-dd HH:mm:ss',
datePlaceholderMs: 'Format: yyyy-MM-dd HH:mm:ss.SSS',
datePlaceholderNs: 'Format: yyyy-MM-dd HH:mm:ss.SSSSSSSSS',
dataReset: 'Data reset',
invalidNs: 'Please enter a valid nanosecond timestamp',
invalidNumber: 'Please enter a valid number',
invalidTs: 'Invalid timestamp',
convertFailed: 'Convert failed: ',
invalidDateFormat: 'Invalid date format',
noContentToCopy: 'Nothing to copy',
copyFailed: 'Copy failed',
},
color: {
rgb: 'RGB',
hex: 'Hex',
hsl: 'HSL',
copyRgb: 'Copy RGB',
pasteRgb: 'Paste RGB',
copyHex: 'Copy hex',
pasteHex: 'Paste hex',
copyHsl: 'Copy HSL',
pasteHsl: 'Paste HSL',
placeholderRgb: '0-255',
placeholderHex: 'FFFFFF',
placeholderH: '0-360',
placeholderSL: '0-100',
reset: 'Reset',
random: 'Random',
rgbPasted: 'RGB pasted',
rgbOutOfRange: 'RGB out of range (0-255)',
hslPasted: 'HSL pasted',
hslOutOfRange: 'HSL out of range (H: 0-360, S/L: 0-100)',
rgbCopied: 'RGB copied to clipboard',
hexCopied: 'Hex copied to clipboard',
hslCopied: 'HSL copied to clipboard',
pasteSuccess: 'Pasted',
invalidRgb: 'Clipboard is not valid RGB',
invalidHex: 'Clipboard is not valid hex',
invalidHsl: 'Clipboard is not valid HSL',
manualPaste: 'Please paste into input manually',
pasteFailed: 'Paste failed',
},
comparator: {
textCompare: 'Text',
jsonCompare: 'JSON',
lineMode: 'By line',
charMode: 'By char',
ignoreListOrder: 'Ignore list order',
compare: 'Compare',
startCompare: 'Compare',
textA: 'Text A',
textB: 'Text B',
maxSize: '(max {size})',
placeholderA: 'Enter or paste content A',
placeholderB: 'Enter or paste content B',
result: 'Result',
same: 'Same:',
insert: 'Insert:',
delete: 'Delete:',
modify: 'Modify:',
fullscreen: 'Fullscreen',
exitFullscreen: 'Exit fullscreen',
left: 'Left',
right: 'Right',
contentOverLimit: 'Content exceeds {size} limit, truncated',
emptyCopy: '{side} is empty, nothing to copy',
copiedSide: '{side} copied to clipboard',
pasteOverLimit: 'Paste exceeds {size} limit, truncated',
pleaseInput: 'Please enter content to compare',
inputOverLimit: 'Input exceeds {size} limit',
largeArrayHint: 'Large array detected, comparison may take a while...',
compareDone: 'Done',
noResultToCopy: 'No result to copy',
resultCopied: 'Result copied to clipboard',
},
dateTimePicker: {
prevYear: 'Prev year',
prevMonth: 'Prev month',
nextMonth: 'Next month',
nextYear: 'Next year',
clickInputMonthYear: 'Click to input year-month',
placeholderMonthYear: 'YYYY-MM',
weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
now: 'Now',
selectTime: 'Select time',
confirm: 'OK',
cancel: 'Cancel',
},
timestampTz: {
'UTC-12:00': 'Baker Island',
'UTC-11:00': 'Samoa',
'UTC-10:00': 'Hawaii',
'UTC-09:30': 'Marquesas',
'UTC-09:00': 'Alaska',
'UTC-08:00': 'Los Angeles',
'UTC-07:00': 'Denver',
'UTC-06:00': 'Chicago',
'UTC-05:00': 'New York',
'UTC-04:00': 'Caracas',
'UTC-03:30': 'Newfoundland',
'UTC-03:00': 'Buenos Aires',
'UTC-02:00': 'Mid-Atlantic',
'UTC-01:00': 'Azores',
'UTC+00:00': 'London',
'UTC+01:00': 'Paris',
'UTC+02:00': 'Cairo',
'UTC+03:00': 'Moscow',
'UTC+03:30': 'Tehran',
'UTC+04:00': 'Dubai',
'UTC+04:30': 'Kabul',
'UTC+05:00': 'Islamabad',
'UTC+05:30': 'New Delhi',
'UTC+05:45': 'Kathmandu',
'UTC+06:00': 'Dhaka',
'UTC+06:30': 'Yangon',
'UTC+07:00': 'Bangkok',
'UTC+08:00': 'Beijing',
'UTC+08:45': 'Eucla',
'UTC+09:00': 'Tokyo',
'UTC+09:30': 'Adelaide',
'UTC+10:00': 'Sydney',
'UTC+10:30': 'Lord Howe',
'UTC+11:00': 'Noumea',
'UTC+12:00': 'Auckland',
'UTC+12:45': 'Chatham',
'UTC+13:00': 'Samoa',
'UTC+14:00': 'Kiribati',
},
}
+293
View File
@@ -0,0 +1,293 @@
export default {
nav: {
home: '首页',
json: 'JSON',
comparator: '对比',
encoderDecoder: '编解码',
variableName: '变量名',
qrCode: '二维码',
timestamp: '时间戳',
color: '颜色',
},
app: {
title: 'RC707的工具箱',
titleJson: 'RC707的工具箱-JSON',
titleComparator: 'RC707的工具箱-对比',
titleEncoderDecoder: 'RC707的工具箱-编解码',
titleVariableName: 'RC707的工具箱-变量名',
titleQrCode: 'RC707的工具箱-二维码',
titleTimestamp: 'RC707的工具箱-时间戳',
titleColor: 'RC707的工具箱-颜色',
},
home: {
heroToday: '今天是{date}',
toolJson: 'JSON',
toolJsonDesc: '格式化、验证和美化JSON数据',
toolComparator: '对比',
toolComparatorDesc: '文本和JSON对比工具',
toolEncoderDecoder: '编解码',
toolEncoderDecoderDesc: '编码/解码工具',
toolVariableName: '变量名',
toolVariableNameDesc: '变量名格式转换',
toolQrCode: '二维码',
toolQrCodeDesc: '生成二维码',
toolTimestamp: '时间戳',
toolTimestampDesc: '时间戳与时间字符串相互转换',
toolColor: '颜色',
toolColorDesc: '颜色格式转换',
},
locale: {
zhCN: '简体',
zhTW: '繁中',
en: 'EN',
},
common: {
close: '关闭',
copy: '复制',
paste: '粘贴',
clear: '清空',
history: '历史记录',
noHistory: '暂无历史记录',
copied: '已复制到剪贴板',
copyFailed: '复制失败:',
clipboardEmpty: '剪贴板内容为空',
pasteHint: '请按 Ctrl+V 或 Cmd+V 粘贴内容',
pasteFailed: '无法访问编辑器,请手动粘贴内容',
cleared: '已清空',
},
json: {
editor: '编辑器',
maxSize: '(最大 5MB)',
tree: '树形',
placeholder: '请输入或粘贴JSON数据,例如:{"name":"工具箱","version":1.0}',
jsonPathPlaceholder: '输入 JSONPath,例如: $.key.subkey',
jsonPathFilter: 'JSONPath 筛选',
clearFilter: '清除筛选',
copyFilterResult: '复制筛选结果',
expandAll: '展开全部',
collapseAll: '折叠全部',
format: '格式化',
minify: '压缩',
escape: '转义',
unescape: '取消转义',
emptyState: '在左侧输入或粘贴JSON数据,右侧将实时显示树形结构',
noMatchedNodes: '未找到匹配的节点',
noContentToCopy: '没有可复制的结果',
copiedCount: '已复制 {count} 个匹配结果',
contentOverLimit: '内容已超过 5MB 限制,已自动截断',
pleaseInputJson: '请输入JSON数据',
inputOverLimit: '输入内容超过 5MB 限制,无法格式化',
outputOverLimit: '格式化后的内容超过 5MB 限制,无法显示',
formatSuccess: '格式化成功',
jsonError: 'JSON格式错误:',
minifyOverLimit: '输入内容超过 5MB 限制,无法压缩',
minifyOutputOverLimit: '压缩后的内容超过 5MB 限制,无法显示',
minifySuccess: '压缩成功',
escapeOverLimit: '输入内容超过 5MB 限制,无法转义',
escapeOutputOverLimit: '转义后的内容超过 5MB 限制,无法显示',
escapeSuccess: '转义成功',
escapeFailed: '转义失败:',
unescapeOverLimit: '输入内容超过 5MB 限制,无法取消转义',
unescapeOutputOverLimit: '取消转义后的内容超过 5MB 限制,无法显示',
unescapeSuccess: '取消转义成功',
unescapeFormatSuccess: '取消转义并格式化成功',
unescapeFailed: '取消转义失败:',
editorEmpty: '编辑器内容为空,无法复制',
pasteOverLimit: '粘贴内容已超过 5MB 限制,已自动截断',
},
encoder: {
input: '输入',
output: '输出',
encode: '编码',
decode: '解码',
base64: 'Base64',
url: 'URL',
unicode: 'Unicode',
zlib: 'Zlib',
titleBase64: 'Base64编码',
titleUrl: 'URL编码',
titleUnicode: 'Unicode编码',
titleZlib: 'Zlib 压缩/解压',
copyOutput: '复制输出',
inputPlaceholder: '请输入要编码或解码的文本',
outputPlaceholder: '编码或解码结果将显示在这里',
pleaseInputEncode: '请输入要编码的文本',
encodeSuccess: '编码成功',
encodeFailed: '编码失败:',
pleaseInputDecode: '请输入要解码的字符串',
decodeSuccess: '解码成功',
decodeFailed: '解码失败:请检查输入是否为有效的{type}编码字符串',
inputEmptyCopy: '输入内容为空,无法复制',
copiedInput: '已复制输入到剪贴板',
outputEmptyCopy: '输出内容为空,无法复制',
copiedOutput: '已复制输出到剪贴板',
manualPaste: '请手动粘贴内容',
},
variable: {
placeholder: '请输入变量名(支持任意格式)',
camelCase: '小驼峰 (camelCase)',
pascalCase: '大驼峰 (PascalCase)',
snakeCase: '下划线 (snake_case)',
kebabCase: '横线 (kebab-case)',
constantCase: '常量 (CONSTANT_CASE)',
copyLabel: '复制{label}',
empty: '—',
noContentToCopy: '没有可复制的内容',
copiedLabel: '{label}已复制到剪贴板',
},
qr: {
inputPlaceholder: '请输入要生成二维码的内容',
generate: '生成二维码',
download: '下载',
copyImage: '复制图片',
qrCode: '二维码',
pleaseInput: '请输入要生成二维码的内容',
generateSuccess: '二维码生成成功',
generateFailed: '生成二维码失败:',
noQrToDownload: '没有可下载的二维码',
downloadSuccess: '下载成功',
downloadFailed: '下载失败:',
noQrToCopy: '没有可复制的二维码',
copyImageFailed: '复制失败,请使用下载功能',
},
timestamp: {
dateToTs: '日期 → ({tz}) 时间戳:',
tsToDate: '时间戳 → ({tz}) 日期',
currentTs: '当前时间戳:',
placeholderTs: '请输入时间戳',
selectDateTime: '选择日期时间',
resetData: '重置数据',
resume: '继续',
pause: '暂停',
seconds: '秒',
milliseconds: '毫秒',
nanoseconds: '纳秒',
datePlaceholderSeconds: '格式:yyyy-MM-dd HH:mm:ss',
datePlaceholderMs: '格式:yyyy-MM-dd HH:mm:ss.SSS',
datePlaceholderNs: '格式:yyyy-MM-dd HH:mm:ss.SSSSSSSSS',
dataReset: '数据已重置',
invalidNs: '请输入有效的纳秒级时间戳',
invalidNumber: '请输入有效的数字',
invalidTs: '无效的时间戳',
convertFailed: '转换失败:',
invalidDateFormat: '无效的时间格式',
noContentToCopy: '没有可复制的内容',
copyFailed: '复制失败',
},
color: {
rgb: 'RGB',
hex: '十六进制',
hsl: 'HSL',
copyRgb: '复制RGB',
pasteRgb: '粘贴RGB',
copyHex: '复制十六进制',
pasteHex: '粘贴十六进制',
copyHsl: '复制HSL',
pasteHsl: '粘贴HSL',
placeholderRgb: '0-255',
placeholderHex: 'FFFFFF',
placeholderH: '0-360',
placeholderSL: '0-100',
reset: '重置',
random: '随机颜色',
rgbPasted: 'RGB已粘贴并解析',
rgbOutOfRange: 'RGB值超出范围(0-255',
hslPasted: 'HSL已粘贴并解析',
hslOutOfRange: 'HSL值超出范围(H: 0-360, S/L: 0-100',
rgbCopied: 'RGB已复制到剪贴板',
hexCopied: '十六进制已复制到剪贴板',
hslCopied: 'HSL已复制到剪贴板',
pasteSuccess: '粘贴成功',
invalidRgb: '剪贴板内容不是有效的RGB格式',
invalidHex: '剪贴板内容不是有效的十六进制格式',
invalidHsl: '剪贴板内容不是有效的HSL格式',
manualPaste: '请手动粘贴到输入框',
pasteFailed: '粘贴失败:请手动粘贴到输入框',
},
comparator: {
textCompare: '文本对比',
jsonCompare: 'JSON对比',
lineMode: '行维度',
charMode: '字符维度',
ignoreListOrder: '忽略列表顺序',
compare: '对比',
startCompare: '开始对比',
textA: '文本 A',
textB: '文本 B',
maxSize: '(最大 {size})',
placeholderA: '请输入或粘贴要对比的内容 A',
placeholderB: '请输入或粘贴要对比的内容 B',
result: '对比结果',
same: '相同:',
insert: '插入:',
delete: '删除:',
modify: '修改:',
fullscreen: '全屏展示',
exitFullscreen: '退出全屏',
left: '左侧',
right: '右侧',
contentOverLimit: '内容已超过 {size} 限制,已自动截断',
emptyCopy: '{side}内容为空,无法复制',
copiedSide: '已复制{side}内容到剪贴板',
pasteOverLimit: '粘贴内容已超过 {size} 限制,已自动截断',
pleaseInput: '请输入要对比的内容',
inputOverLimit: '输入内容超过 {size} 限制,请减小输入大小',
largeArrayHint: '检测到大型数组,对比可能需要较长时间,请耐心等待...',
compareDone: '对比完成',
noResultToCopy: '没有对比结果可复制',
resultCopied: '已复制对比结果到剪贴板',
},
dateTimePicker: {
prevYear: '上一年',
prevMonth: '上一月',
nextMonth: '下一月',
nextYear: '下一年',
clickInputMonthYear: '点击输入年月',
placeholderMonthYear: 'YYYY-MM',
weekdays: ['日', '一', '二', '三', '四', '五', '六'],
now: '此刻',
selectTime: '选择时间',
confirm: '确定',
cancel: '取消',
},
timestampTz: {
'UTC-12:00': '贝克岛',
'UTC-11:00': '萨摩亚',
'UTC-10:00': '夏威夷',
'UTC-09:30': '马克萨斯群岛',
'UTC-09:00': '阿拉斯加',
'UTC-08:00': '洛杉矶',
'UTC-07:00': '丹佛',
'UTC-06:00': '芝加哥',
'UTC-05:00': '纽约',
'UTC-04:00': '加拉加斯',
'UTC-03:30': '纽芬兰',
'UTC-03:00': '布宜诺斯艾利斯',
'UTC-02:00': '大西洋中部',
'UTC-01:00': '亚速尔群岛',
'UTC+00:00': '伦敦',
'UTC+01:00': '巴黎',
'UTC+02:00': '开罗',
'UTC+03:00': '莫斯科',
'UTC+03:30': '德黑兰',
'UTC+04:00': '迪拜',
'UTC+04:30': '喀布尔',
'UTC+05:00': '伊斯兰堡',
'UTC+05:30': '新德里',
'UTC+05:45': '加德满都',
'UTC+06:00': '达卡',
'UTC+06:30': '仰光',
'UTC+07:00': '曼谷',
'UTC+08:00': '北京',
'UTC+08:45': '尤克拉',
'UTC+09:00': '东京',
'UTC+09:30': '阿德莱德',
'UTC+10:00': '悉尼',
'UTC+10:30': '豪勋爵岛',
'UTC+11:00': '新喀里多尼亚',
'UTC+12:00': '奥克兰',
'UTC+12:45': '查塔姆群岛',
'UTC+13:00': '萨摩亚',
'UTC+14:00': '基里巴斯',
},
}
+293
View File
@@ -0,0 +1,293 @@
export default {
nav: {
home: '首頁',
json: 'JSON',
comparator: '對比',
encoderDecoder: '編解碼',
variableName: '變數名',
qrCode: '二維碼',
timestamp: '時間戳',
color: '顏色',
},
app: {
title: 'RC707的工具箱',
titleJson: 'RC707的工具箱-JSON',
titleComparator: 'RC707的工具箱-對比',
titleEncoderDecoder: 'RC707的工具箱-編解碼',
titleVariableName: 'RC707的工具箱-變數名',
titleQrCode: 'RC707的工具箱-二維碼',
titleTimestamp: 'RC707的工具箱-時間戳',
titleColor: 'RC707的工具箱-顏色',
},
home: {
heroToday: '今天是{date}',
toolJson: 'JSON',
toolJsonDesc: '格式化、驗證和美化JSON資料',
toolComparator: '對比',
toolComparatorDesc: '文字和JSON對比工具',
toolEncoderDecoder: '編解碼',
toolEncoderDecoderDesc: '編碼/解碼工具',
toolVariableName: '變數名',
toolVariableNameDesc: '變數名格式轉換',
toolQrCode: '二維碼',
toolQrCodeDesc: '產生二維碼',
toolTimestamp: '時間戳',
toolTimestampDesc: '時間戳與時間字串相互轉換',
toolColor: '顏色',
toolColorDesc: '顏色格式轉換',
},
locale: {
zhCN: '簡體',
zhTW: '繁中',
en: 'EN',
},
common: {
close: '關閉',
copy: '複製',
paste: '貼上',
clear: '清空',
history: '歷史記錄',
noHistory: '暫無歷史記錄',
copied: '已複製到剪貼簿',
copyFailed: '複製失敗:',
clipboardEmpty: '剪貼簿內容為空',
pasteHint: '請按 Ctrl+V 或 Cmd+V 貼上內容',
pasteFailed: '無法存取編輯器,請手動貼上內容',
cleared: '已清空',
},
json: {
editor: '編輯器',
maxSize: '(最大 5MB)',
tree: '樹形',
placeholder: '請輸入或貼上JSON資料,例如:{"name":"工具箱","version":1.0}',
jsonPathPlaceholder: '輸入 JSONPath,例如: $.key.subkey',
jsonPathFilter: 'JSONPath 篩選',
clearFilter: '清除篩選',
copyFilterResult: '複製篩選結果',
expandAll: '展開全部',
collapseAll: '摺疊全部',
format: '格式化',
minify: '壓縮',
escape: '轉義',
unescape: '取消轉義',
emptyState: '在左側輸入或貼上JSON資料,右側將即時顯示樹形結構',
noMatchedNodes: '未找到符合的節點',
noContentToCopy: '沒有可複製的結果',
copiedCount: '已複製 {count} 個符合結果',
contentOverLimit: '內容已超過 5MB 限制,已自動截斷',
pleaseInputJson: '請輸入JSON資料',
inputOverLimit: '輸入內容超過 5MB 限制,無法格式化',
outputOverLimit: '格式化後的內容超過 5MB 限制,無法顯示',
formatSuccess: '格式化成功',
jsonError: 'JSON格式錯誤:',
minifyOverLimit: '輸入內容超過 5MB 限制,無法壓縮',
minifyOutputOverLimit: '壓縮後的內容超過 5MB 限制,無法顯示',
minifySuccess: '壓縮成功',
escapeOverLimit: '輸入內容超過 5MB 限制,無法轉義',
escapeOutputOverLimit: '轉義後的內容超過 5MB 限制,無法顯示',
escapeSuccess: '轉義成功',
escapeFailed: '轉義失敗:',
unescapeOverLimit: '輸入內容超過 5MB 限制,無法取消轉義',
unescapeOutputOverLimit: '取消轉義後的內容超過 5MB 限制,無法顯示',
unescapeSuccess: '取消轉義成功',
unescapeFormatSuccess: '取消轉義並格式化成功',
unescapeFailed: '取消轉義失敗:',
editorEmpty: '編輯器內容為空,無法複製',
pasteOverLimit: '貼上內容已超過 5MB 限制,已自動截斷',
},
encoder: {
input: '輸入',
output: '輸出',
encode: '編碼',
decode: '解碼',
base64: 'Base64',
url: 'URL',
unicode: 'Unicode',
zlib: 'Zlib',
titleBase64: 'Base64編碼',
titleUrl: 'URL編碼',
titleUnicode: 'Unicode編碼',
titleZlib: 'Zlib 壓縮/解壓',
copyOutput: '複製輸出',
inputPlaceholder: '請輸入要編碼或解碼的文字',
outputPlaceholder: '編碼或解碼結果將顯示在這裡',
pleaseInputEncode: '請輸入要編碼的文字',
encodeSuccess: '編碼成功',
encodeFailed: '編碼失敗:',
pleaseInputDecode: '請輸入要解碼的字串',
decodeSuccess: '解碼成功',
decodeFailed: '解碼失敗:請檢查輸入是否為有效的{type}編碼字串',
inputEmptyCopy: '輸入內容為空,無法複製',
copiedInput: '已複製輸入到剪貼簿',
outputEmptyCopy: '輸出內容為空,無法複製',
copiedOutput: '已複製輸出到剪貼簿',
manualPaste: '請手動貼上內容',
},
variable: {
placeholder: '請輸入變數名(支援任意格式)',
camelCase: '小駝峰 (camelCase)',
pascalCase: '大駝峰 (PascalCase)',
snakeCase: '底線 (snake_case)',
kebabCase: '橫線 (kebab-case)',
constantCase: '常數 (CONSTANT_CASE)',
copyLabel: '複製{label}',
empty: '—',
noContentToCopy: '沒有可複製的內容',
copiedLabel: '{label}已複製到剪貼簿',
},
qr: {
inputPlaceholder: '請輸入要產生二維碼的內容',
generate: '產生二維碼',
download: '下載',
copyImage: '複製圖片',
qrCode: '二維碼',
pleaseInput: '請輸入要產生二維碼的內容',
generateSuccess: '二維碼產生成功',
generateFailed: '產生二維碼失敗:',
noQrToDownload: '沒有可下載的二維碼',
downloadSuccess: '下載成功',
downloadFailed: '下載失敗:',
noQrToCopy: '沒有可複製的二維碼',
copyImageFailed: '複製失敗,請使用下載功能',
},
timestamp: {
dateToTs: '日期 → ({tz}) 時間戳:',
tsToDate: '時間戳 → ({tz}) 日期',
currentTs: '目前時間戳:',
placeholderTs: '請輸入時間戳',
selectDateTime: '選擇日期時間',
resetData: '重設資料',
resume: '繼續',
pause: '暫停',
seconds: '秒',
milliseconds: '毫秒',
nanoseconds: '奈秒',
datePlaceholderSeconds: '格式:yyyy-MM-dd HH:mm:ss',
datePlaceholderMs: '格式:yyyy-MM-dd HH:mm:ss.SSS',
datePlaceholderNs: '格式:yyyy-MM-dd HH:mm:ss.SSSSSSSSS',
dataReset: '資料已重設',
invalidNs: '請輸入有效的奈秒級時間戳',
invalidNumber: '請輸入有效的數字',
invalidTs: '無效的時間戳',
convertFailed: '轉換失敗:',
invalidDateFormat: '無效的時間格式',
noContentToCopy: '沒有可複製的內容',
copyFailed: '複製失敗',
},
color: {
rgb: 'RGB',
hex: '十六進位',
hsl: 'HSL',
copyRgb: '複製RGB',
pasteRgb: '貼上RGB',
copyHex: '複製十六進位',
pasteHex: '貼上十六進位',
copyHsl: '複製HSL',
pasteHsl: '貼上HSL',
placeholderRgb: '0-255',
placeholderHex: 'FFFFFF',
placeholderH: '0-360',
placeholderSL: '0-100',
reset: '重設',
random: '隨機顏色',
rgbPasted: 'RGB已貼上並解析',
rgbOutOfRange: 'RGB值超出範圍(0-255',
hslPasted: 'HSL已貼上並解析',
hslOutOfRange: 'HSL值超出範圍(H: 0-360, S/L: 0-100',
rgbCopied: 'RGB已複製到剪貼簿',
hexCopied: '十六進位已複製到剪貼簿',
hslCopied: 'HSL已複製到剪貼簿',
pasteSuccess: '貼上成功',
invalidRgb: '剪貼簿內容不是有效的RGB格式',
invalidHex: '剪貼簿內容不是有效的十六進位格式',
invalidHsl: '剪貼簿內容不是有效的HSL格式',
manualPaste: '請手動貼上到輸入框',
pasteFailed: '貼上失敗:請手動貼上到輸入框',
},
comparator: {
textCompare: '文字對比',
jsonCompare: 'JSON對比',
lineMode: '行維度',
charMode: '字元維度',
ignoreListOrder: '忽略列表順序',
compare: '對比',
startCompare: '開始對比',
textA: '文字 A',
textB: '文字 B',
maxSize: '(最大 {size})',
placeholderA: '請輸入或貼上要對比的內容 A',
placeholderB: '請輸入或貼上要對比的內容 B',
result: '對比結果',
same: '相同:',
insert: '插入:',
delete: '刪除:',
modify: '修改:',
fullscreen: '全螢幕展示',
exitFullscreen: '結束全螢幕',
left: '左側',
right: '右側',
contentOverLimit: '內容已超過 {size} 限制,已自動截斷',
emptyCopy: '{side}內容為空,無法複製',
copiedSide: '已複製{side}內容到剪貼簿',
pasteOverLimit: '貼上內容已超過 {size} 限制,已自動截斷',
pleaseInput: '請輸入要對比的內容',
inputOverLimit: '輸入內容超過 {size} 限制,請減小輸入大小',
largeArrayHint: '偵測到大型陣列,對比可能需要較長時間,請耐心等候...',
compareDone: '對比完成',
noResultToCopy: '沒有對比結果可複製',
resultCopied: '已複製對比結果到剪貼簿',
},
dateTimePicker: {
prevYear: '上一年',
prevMonth: '上一月',
nextMonth: '下一月',
nextYear: '下一年',
clickInputMonthYear: '點擊輸入年月',
placeholderMonthYear: 'YYYY-MM',
weekdays: ['日', '一', '二', '三', '四', '五', '六'],
now: '此刻',
selectTime: '選擇時間',
confirm: '確定',
cancel: '取消',
},
timestampTz: {
'UTC-12:00': '貝克島',
'UTC-11:00': '薩摩亞',
'UTC-10:00': '夏威夷',
'UTC-09:30': '馬克薩斯群島',
'UTC-09:00': '阿拉斯加',
'UTC-08:00': '洛杉磯',
'UTC-07:00': '丹佛',
'UTC-06:00': '芝加哥',
'UTC-05:00': '紐約',
'UTC-04:00': '加拉加斯',
'UTC-03:30': '紐芬蘭',
'UTC-03:00': '布宜諾斯艾利斯',
'UTC-02:00': '大西洋中部',
'UTC-01:00': '亞速爾群島',
'UTC+00:00': '倫敦',
'UTC+01:00': '巴黎',
'UTC+02:00': '開羅',
'UTC+03:00': '莫斯科',
'UTC+03:30': '德黑蘭',
'UTC+04:00': '迪拜',
'UTC+04:30': '喀布爾',
'UTC+05:00': '伊斯蘭堡',
'UTC+05:30': '新德里',
'UTC+05:45': '加德滿都',
'UTC+06:00': '達卡',
'UTC+06:30': '仰光',
'UTC+07:00': '曼谷',
'UTC+08:00': '北京',
'UTC+08:45': '尤克拉',
'UTC+09:00': '東京',
'UTC+09:30': '阿德萊德',
'UTC+10:00': '悉尼',
'UTC+10:30': '豪勳爵島',
'UTC+11:00': '新喀里多尼亞',
'UTC+12:00': '奧克蘭',
'UTC+12:45': '查塔姆群島',
'UTC+13:00': '薩摩亞',
'UTC+14:00': '吉里巴斯',
},
}
+3 -5
View File
@@ -1,12 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import AppRoot from './AppRoot.vue'
import router from './router'
import { initAnalytics } from './utils/analytics.js'
import { i18n } from './i18n'
import './style.css'
// 引入 Font Awesome 6.4
import '@fortawesome/fontawesome-free/css/all.css'
initAnalytics()
createApp(App).use(router).mount('#app')
createApp(AppRoot).use(router).use(i18n).mount('#app')
+96 -65
View File
@@ -1,82 +1,113 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import { getPageTitle } from '../config/site.js'
import { i18n } from '../i18n'
// 路径中的 locale 与 i18n locale 的映射
export const PATH_LOCALE_TO_I18N = {
zh: 'zh-CN',
'zh-tw': 'zh-TW',
en: 'en',
}
export const I18N_TO_PATH_LOCALE = {
'zh-CN': 'zh',
'zh-TW': 'zh-tw',
en: 'en',
}
export const PATH_LOCALES = ['zh', 'zh-tw', 'en']
const localePath = (locale, path = '') => {
const base = path.startsWith('/') ? path.slice(1) : path
return base ? `/${locale}/${base}` : `/${locale}`
}
const routes = [
// 无前缀路径重定向到简体中文
{ path: '/', redirect: '/zh' },
{ path: '/json-formatter', redirect: '/zh/json-formatter' },
{ path: '/comparator', redirect: '/zh/comparator' },
{ path: '/encoder-decoder', redirect: '/zh/encoder-decoder' },
{ path: '/variable-name', redirect: '/zh/variable-name' },
{ path: '/qr-code', redirect: '/zh/qr-code' },
{ path: '/timestamp-converter', redirect: '/zh/timestamp-converter' },
{ path: '/color-converter', redirect: '/zh/color-converter' },
// 带语言前缀的路由(App 作为父级以获取 locale)
{
path: '/',
name: 'Home',
component: Home,
meta: {
titleSuffix: null
}
path: '/:locale(zh|zh-tw|en)',
component: () => import('../App.vue'),
children: [
{
path: '',
name: 'Home',
component: Home,
meta: { titleKey: 'app.title' },
},
{
path: 'json-formatter',
name: 'JsonFormatter',
component: () => import('../views/JsonFormatter.vue'),
meta: { titleKey: 'app.titleJson' },
},
{
path: 'comparator',
name: 'Comparator',
component: () => import('../views/Comparator.vue'),
meta: { titleKey: 'app.titleComparator' },
},
{
path: 'encoder-decoder',
name: 'EncoderDecoder',
component: () => import('../views/EncoderDecoder.vue'),
meta: { titleKey: 'app.titleEncoderDecoder' },
},
{
path: 'variable-name',
name: 'VariableNameConverter',
component: () => import('../views/VariableNameConverter.vue'),
meta: { titleKey: 'app.titleVariableName' },
},
{
path: 'qr-code',
name: 'QRCodeGenerator',
component: () => import('../views/QRCodeGenerator.vue'),
meta: { titleKey: 'app.titleQrCode' },
},
{
path: 'timestamp-converter',
name: 'TimestampConverter',
component: () => import('../views/TimestampConverter.vue'),
meta: { titleKey: 'app.titleTimestamp' },
},
{
path: 'color-converter',
name: 'ColorConverter',
component: () => import('../views/ColorConverter.vue'),
meta: { titleKey: 'app.titleColor' },
},
],
},
{
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
routes,
})
router.beforeEach((to, from, next) => {
document.title = getPageTitle(to.meta.titleSuffix)
const pathLocale = to.params.locale
if (pathLocale && PATH_LOCALE_TO_I18N[pathLocale]) {
const i18nLocale = PATH_LOCALE_TO_I18N[pathLocale]
if (i18n.global.locale.value !== i18nLocale) {
i18n.global.locale.value = i18nLocale
}
}
const key = to.meta.titleKey
if (key) {
document.title = i18n.global.t(key)
}
next()
})
export { localePath }
export default router
-2
View File
@@ -1,5 +1,3 @@
@import './styles/line-number-editor.css';
* {
margin: 0;
padding: 0;
-44
View File
@@ -1,44 +0,0 @@
.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
@@ -1,18 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,87 +0,0 @@
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
@@ -1,31 +0,0 @@
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
@@ -1,3 +0,0 @@
export * from './html.js'
export * from './textDiff.js'
export * from './jsonCompare.js'
-771
View File
@@ -1,771 +0,0 @@
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
@@ -1,375 +0,0 @@
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
@@ -1,117 +0,0 @@
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
@@ -1,142 +0,0 @@
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
@@ -1,74 +0,0 @@
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
@@ -1,85 +0,0 @@
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('_')
}
+54 -51
View File
@@ -8,7 +8,7 @@
<i v-else class="fas fa-circle-info"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<button @click="closeToast" class="toast-close-btn" :title="t('common.close')">
<i class="fas fa-xmark"></i>
</button>
</div>
@@ -17,12 +17,12 @@
<!-- 左侧侧栏历史记录 -->
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
<div class="sidebar-header">
<h3>历史记录</h3>
<h3>{{ t('common.history') }}</h3>
<button @click="toggleSidebar" class="close-btn">×</button>
</div>
<div class="sidebar-content">
<div v-if="historyList.length === 0" class="empty-history">
暂无历史记录
{{ t('common.noHistory') }}
</div>
<div
v-for="(item, index) in historyList"
@@ -50,12 +50,12 @@
<!-- RGB输入 -->
<div class="input-group">
<div class="input-header">
<label class="input-label">RGB</label>
<label class="input-label">{{ t('color.rgb') }}</label>
<div class="copy-paste-buttons">
<button @click="copyRgb" class="copy-btn" title="复制RGB">
<button @click="copyRgb" class="copy-btn" :title="t('color.copyRgb')">
<i class="far fa-copy"></i>
</button>
<button @click="pasteRgb" class="paste-btn" title="粘贴RGB">
<button @click="pasteRgb" class="paste-btn" :title="t('color.pasteRgb')">
<i class="far fa-paste"></i>
</button>
</div>
@@ -71,7 +71,7 @@
min="0"
max="255"
class="rgb-input"
placeholder="0-255"
:placeholder="t('color.placeholderRgb')"
/>
</div>
<div class="rgb-item">
@@ -84,7 +84,7 @@
min="0"
max="255"
class="rgb-input"
placeholder="0-255"
:placeholder="t('color.placeholderRgb')"
/>
</div>
<div class="rgb-item">
@@ -97,7 +97,7 @@
min="0"
max="255"
class="rgb-input"
placeholder="0-255"
:placeholder="t('color.placeholderRgb')"
/>
</div>
</div>
@@ -106,12 +106,12 @@
<!-- 十六进制输入 -->
<div class="input-group">
<div class="input-header">
<label class="input-label">十六进制</label>
<label class="input-label">{{ t('color.hex') }}</label>
<div class="copy-paste-buttons">
<button @click="copyHex" class="copy-btn" title="复制十六进制">
<button @click="copyHex" class="copy-btn" :title="t('color.copyHex')">
<i class="far fa-copy"></i>
</button>
<button @click="pasteHex" class="paste-btn" title="粘贴十六进制">
<button @click="pasteHex" class="paste-btn" :title="t('color.pasteHex')">
<i class="far fa-paste"></i>
</button>
</div>
@@ -123,7 +123,7 @@
@input="handleHexInput"
type="text"
class="hex-input"
placeholder="FFFFFF"
:placeholder="t('color.placeholderHex')"
maxlength="6"
/>
</div>
@@ -132,12 +132,12 @@
<!-- HSL输入 -->
<div class="input-group">
<div class="input-header">
<label class="input-label">HSL</label>
<label class="input-label">{{ t('color.hsl') }}</label>
<div class="copy-paste-buttons">
<button @click="copyHsl" class="copy-btn" title="复制HSL">
<button @click="copyHsl" class="copy-btn" :title="t('color.copyHsl')">
<i class="far fa-copy"></i>
</button>
<button @click="pasteHsl" class="paste-btn" title="粘贴HSL">
<button @click="pasteHsl" class="paste-btn" :title="t('color.pasteHsl')">
<i class="far fa-paste"></i>
</button>
</div>
@@ -153,7 +153,7 @@
min="0"
max="360"
class="hsl-input"
placeholder="0-360"
:placeholder="t('color.placeholderH')"
/>
</div>
<div class="hsl-item">
@@ -166,7 +166,7 @@
min="0"
max="100"
class="hsl-input"
placeholder="0-100"
:placeholder="t('color.placeholderSL')"
/>
<span class="hsl-unit">%</span>
</div>
@@ -180,7 +180,7 @@
min="0"
max="100"
class="hsl-input"
placeholder="0-100"
:placeholder="t('color.placeholderSL')"
/>
<span class="hsl-unit">%</span>
</div>
@@ -199,8 +199,8 @@
<!-- 操作按钮 -->
<div class="action-buttons">
<button @click="resetColor" class="action-btn reset-btn">重置</button>
<button @click="randomColor" class="action-btn random-btn">随机颜色</button>
<button @click="resetColor" class="action-btn reset-btn">{{ t('color.reset') }}</button>
<button @click="randomColor" class="action-btn random-btn">{{ t('color.random') }}</button>
</div>
</div>
</div>
@@ -210,6 +210,9 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const rgb = ref({ r: 255, g: 255, b: 255 })
const hex = ref('FFFFFF')
@@ -407,9 +410,9 @@ function handleRgbPaste(event) {
// 保存到历史记录
saveToHistory()
showToast('RGB已粘贴并解析', 'info', 2000)
showToast(t('color.rgbPasted'), 'info', 2000)
} else {
showToast('RGB值超出范围(0-255', 'error')
showToast(t('color.rgbOutOfRange'), 'error')
}
}
// 如果不是RGB格式,允许默认粘贴行为(粘贴单个数字)
@@ -527,9 +530,9 @@ function handleHslPaste(event) {
// 保存到历史记录
saveToHistory()
showToast('HSL已粘贴并解析', 'info', 2000)
showToast(t('color.hslPasted'), 'info', 2000)
} else {
showToast('HSL值超出范围(H: 0-360, S/L: 0-100', 'error')
showToast(t('color.hslOutOfRange'), 'error')
}
}
// 如果不是HSL格式,允许默认粘贴行为(粘贴单个数字)
@@ -588,16 +591,16 @@ async function copyRgb() {
const rgbText = `rgb(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b})`
try {
await navigator.clipboard.writeText(rgbText)
showToast('RGB已复制到剪贴板', 'info', 2000)
showToast(t('color.rgbCopied'), 'info', 2000)
} catch (err) {
showToast('复制失败:' + err.message)
showToast(t('common.copyFailed') + err.message)
}
}
// 处理粘贴的文本(通用函数)
function processPastedText(text, type) {
if (!text || !text.trim()) {
showToast('剪贴板内容为空')
showToast(t('common.clipboardEmpty'))
return false
}
@@ -616,11 +619,11 @@ function processPastedText(text, type) {
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
rgb.value = { r, g, b }
handleRgbInput()
showToast('粘贴成功', 'info', 2000)
showToast(t('color.pasteSuccess'), 'info', 2000)
return true
}
}
showToast('剪贴板内容不是有效的RGB格式')
showToast(t('color.invalidRgb'))
return false
} else if (type === 'hex') {
// 移除#号并转换为大写
@@ -630,16 +633,16 @@ function processPastedText(text, type) {
if (/^[0-9A-F]{6}$/.test(text)) {
hex.value = text
handleHexInput()
showToast('粘贴成功', 'info', 2000)
showToast(t('color.pasteSuccess'), 'info', 2000)
return true
} else if (/^[0-9A-F]{3}$/.test(text)) {
// 支持3位十六进制
hex.value = text.split('').map(c => c + c).join('')
handleHexInput()
showToast('粘贴成功', 'info', 2000)
showToast(t('color.pasteSuccess'), 'info', 2000)
return true
}
showToast('剪贴板内容不是有效的十六进制格式')
showToast(t('color.invalidHex'))
return false
} else if (type === 'hsl') {
// 支持格式:hsl(0, 0%, 100%) 或 0, 0%, 100%
@@ -654,11 +657,11 @@ function processPastedText(text, type) {
if (h >= 0 && h <= 360 && s >= 0 && s <= 100 && l >= 0 && l <= 100) {
hsl.value = { h, s, l }
handleHslInput()
showToast('粘贴成功', 'info', 2000)
showToast(t('color.pasteSuccess'), 'info', 2000)
return true
}
}
showToast('剪贴板内容不是有效的HSL格式')
showToast(t('color.invalidHsl'))
return false
}
return false
@@ -677,9 +680,9 @@ async function pasteRgb() {
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
showToast(t('common.pasteHint'), 'info', 3000)
} else {
showToast('粘贴失败:请手动粘贴到输入框', 'error')
showToast(t('color.pasteFailed'), 'error')
}
}
} else {
@@ -688,9 +691,9 @@ async function pasteRgb() {
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
showToast(t('common.pasteHint'), 'info', 3000)
} else {
showToast('请手动粘贴到输入框', 'error')
showToast(t('color.manualPaste'), 'error')
}
}
}
@@ -700,9 +703,9 @@ async function copyHex() {
const hexText = `#${hex.value}`
try {
await navigator.clipboard.writeText(hexText)
showToast('十六进制已复制到剪贴板', 'info', 2000)
showToast(t('color.hexCopied'), 'info', 2000)
} catch (err) {
showToast('复制失败:' + err.message)
showToast(t('common.copyFailed') + err.message)
}
}
@@ -719,9 +722,9 @@ async function pasteHex() {
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
showToast(t('common.pasteHint'), 'info', 3000)
} else {
showToast('粘贴失败:请手动粘贴到输入框', 'error')
showToast(t('color.pasteFailed'), 'error')
}
}
} else {
@@ -730,9 +733,9 @@ async function pasteHex() {
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
showToast(t('common.pasteHint'), 'info', 3000)
} else {
showToast('请手动粘贴到输入框', 'error')
showToast(t('color.manualPaste'), 'error')
}
}
}
@@ -742,9 +745,9 @@ async function copyHsl() {
const hslText = `hsl(${hsl.value.h}, ${hsl.value.s}%, ${hsl.value.l}%)`
try {
await navigator.clipboard.writeText(hslText)
showToast('HSL已复制到剪贴板', 'info', 2000)
showToast(t('color.hslCopied'), 'info', 2000)
} catch (err) {
showToast('复制失败:' + err.message)
showToast(t('common.copyFailed') + err.message)
}
}
@@ -761,9 +764,9 @@ async function pasteHsl() {
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
showToast(t('common.pasteHint'), 'info', 3000)
} else {
showToast('粘贴失败:请手动粘贴到输入框', 'error')
showToast(t('color.pasteFailed'), 'error')
}
}
} else {
@@ -772,9 +775,9 @@ async function pasteHsl() {
pasteInputValue.value = ''
if (pasteInputRef.value) {
pasteInputRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
showToast(t('common.pasteHint'), 'info', 3000)
} else {
showToast('请手动粘贴到输入框', 'error')
showToast(t('color.manualPaste'), 'error')
}
}
}
+1505 -99
View File
File diff suppressed because it is too large Load Diff
+131 -97
View File
@@ -8,7 +8,7 @@
<i v-else class="fas fa-circle-info"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<button @click="closeToast" class="toast-close-btn" :title="t('common.close')">
<i class="fas fa-xmark"></i>
</button>
</div>
@@ -18,12 +18,12 @@
<!-- 左侧侧栏历史记录 -->
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
<div class="sidebar-header">
<h3>历史记录</h3>
<h3>{{ t('common.history') }}</h3>
<button @click="toggleSidebar" class="close-btn">×</button>
</div>
<div class="sidebar-content">
<div v-if="historyList.length === 0" class="empty-history">
暂无历史记录
{{ t('common.noHistory') }}
</div>
<div
v-for="(item, index) in historyList"
@@ -32,14 +32,8 @@
@click="loadHistory(item)"
>
<div class="history-header">
<span class="history-type">{{ item.type === 'encode' ? '编码' : '解码' }}</span>
<span class="history-encoding">{{
item.encodingType === 'base64' ? 'Base64' :
item.encodingType === 'url' ? 'URL' :
item.encodingType === 'unicode' ? 'Unicode' :
item.encodingType === 'zlib' ? 'Zlib' :
item.encodingType
}}</span>
<span class="history-type">{{ item.type === 'encode' ? t('encoder.encode') : t('encoder.decode') }}</span>
<span class="history-encoding">{{ encodingTypeLabel(item.encodingType) }}</span>
<span class="history-time">{{ formatTime(item.time) }}</span>
</div>
<div class="history-preview">{{ truncateText(item.input, 50) }}</div>
@@ -53,69 +47,67 @@
<div class="left-panel" :style="{ width: leftPanelWidth + '%' }">
<div class="panel-toolbar">
<div class="view-tabs">
<button class="view-tab active">输入</button>
<button class="view-tab active">{{ t('encoder.input') }}</button>
</div>
<div class="toolbar-actions">
<div class="encoding-type-selector">
<button
@click="encodingType = 'base64'"
:class="['type-btn', { active: encodingType === 'base64' }]"
title="Base64编码"
:title="t('encoder.titleBase64')"
>
Base64
{{ t('encoder.base64') }}
</button>
<button
@click="encodingType = 'url'"
:class="['type-btn', { active: encodingType === 'url' }]"
title="URL编码"
:title="t('encoder.titleUrl')"
>
URL
{{ t('encoder.url') }}
</button>
<button
@click="encodingType = 'unicode'"
:class="['type-btn', { active: encodingType === 'unicode' }]"
title="Unicode编码"
:title="t('encoder.titleUnicode')"
>
Unicode
{{ t('encoder.unicode') }}
</button>
<button
@click="encodingType = 'zlib'"
:class="['type-btn', { active: encodingType === 'zlib' }]"
title="Zlib 压缩/解压"
:title="t('encoder.titleZlib')"
>
Zlib
{{ t('encoder.zlib') }}
</button>
</div>
<button @click="copyInputToClipboard" class="toolbar-icon-btn" title="复制">
<button @click="copyInputToClipboard" class="toolbar-icon-btn" :title="t('common.copy')">
<i class="far fa-copy"></i>
</button>
<button @click="pasteFromClipboard" class="toolbar-icon-btn" title="粘贴">
<button @click="pasteFromClipboard" class="toolbar-icon-btn" :title="t('common.paste')">
<i class="far fa-paste"></i>
</button>
<button @click="encode" class="toolbar-icon-btn" title="编码">
<button @click="encode" class="toolbar-icon-btn" :title="t('encoder.encode')">
<i class="fa-solid fa-code"></i>
</button>
<button @click="decode" class="toolbar-icon-btn" title="解码">
<button @click="decode" class="toolbar-icon-btn" :title="t('encoder.decode')">
<svg viewBox="150 -50 1100 1250" xmlns="http://www.w3.org/2000/svg" width="17" height="17"><path d="M285.352637 0.003641h111.956538v114.687184h-111.956538v282.621991a110.682235 110.682235 0 0 1-33.313896 81.282266 110.682235 110.682235 0 0 1-81.282266 33.313896 110.682235 110.682235 0 0 1 81.282266 33.313897 110.682235 110.682235 0 0 1 33.313896 81.282266v282.621991h111.956538v114.687184h-111.956538a188.050574 188.050574 0 0 1-80.007964-40.049493 93.570179 93.570179 0 0 1-34.67922-74.637691v-226.643722a110.682235 110.682235 0 0 0-33.313896-81.282267 110.682235 110.682235 0 0 0-81.282267-33.313896H0v-111.956537h55.978269a110.682235 110.682235 0 0 0 81.282266-33.313897 110.682235 110.682235 0 0 0 33.313896-81.282266V114.690825A113.776969 113.776969 0 0 1 285.261616 0.003641z m794.61835 0a113.776969 113.776969 0 0 1 114.687184 114.687184v226.643722a113.776969 113.776969 0 0 0 114.687185 114.687184H1365.323624v111.956538h-55.978268a113.776969 113.776969 0 0 0-114.687185 114.687184v226.643722a113.776969 113.776969 0 0 1-114.687184 114.687184h-111.956537V909.309175h111.956537V626.687184a113.776969 113.776969 0 0 1 114.687184-114.687184 113.776969 113.776969 0 0 1-114.687184-114.687184V114.690825h-111.956537V0.003641h111.956537zM682.661812 682.665453a54.612945 54.612945 0 0 1 55.978269 55.978269 58.799937 58.799937 0 0 1-16.019797 41.323795 54.612945 54.612945 0 0 1-80.007965 0 58.799937 58.799937 0 0 1-16.019797-41.323795 54.612945 54.612945 0 0 1 55.978269-55.978269z m-226.643721 0a54.612945 54.612945 0 0 1 55.978268 55.978269 58.799937 58.799937 0 0 1-16.019797 41.323795 52.246384 52.246384 0 0 1-40.049493 17.294099 59.164024 59.164024 0 0 1-58.708916-58.708916 52.246384 52.246384 0 0 1 17.294099-40.049493 58.799937 58.799937 0 0 1 41.505839-15.837754z m453.287443 0a58.799937 58.799937 0 0 1 41.323795 16.019797 52.246384 52.246384 0 0 1 17.294099 40.049493 59.164024 59.164024 0 0 1-58.708916 58.708916 52.246384 52.246384 0 0 1-40.049493-17.294099 58.799937 58.799937 0 0 1-16.019797-41.323795 54.612945 54.612945 0 0 1 55.978269-55.978269z" fill="#666666" p-id="26339"></path></svg>
</button>
<button @click="clearAll" class="toolbar-icon-btn" title="清空">
<button @click="clearAll" class="toolbar-icon-btn" :title="t('common.clear')">
<i class="far fa-trash-can"></i>
</button>
</div>
</div>
<div ref="inputContainerRef" class="editor-container">
<div class="editor-body">
<div class="line-numbers">
<div v-for="n in inputLineCount" :key="n" class="line-number">{{ n }}</div>
</div>
<textarea
ref="inputEditorRef"
v-model="inputText"
@input="updateInputLineCount"
placeholder="请输入要编码或解码的文本"
class="text-editor"
></textarea>
<div class="editor-container">
<div class="line-numbers">
<div v-for="n in inputLineCount" :key="n" class="line-number">{{ n }}</div>
</div>
<textarea
ref="inputEditorRef"
v-model="inputText"
@input="updateInputLineCount"
:placeholder="t('encoder.inputPlaceholder')"
class="text-editor"
></textarea>
</div>
<div class="sidebar-toggle">
<button @click="toggleSidebar" class="toggle-btn">
@@ -134,27 +126,24 @@
<div class="right-panel" :style="{ width: rightPanelWidth + '%' }">
<div class="panel-toolbar">
<div class="view-tabs">
<button class="view-tab active">输出</button>
<button class="view-tab active">{{ t('encoder.output') }}</button>
</div>
<div class="toolbar-actions">
<button @click="copyOutputToClipboard" class="toolbar-icon-btn" title="复制输出">
<button @click="copyOutputToClipboard" class="toolbar-icon-btn" :title="t('encoder.copyOutput')">
<i class="far fa-copy"></i>
</button>
</div>
</div>
<div ref="outputContainerRef" class="editor-container">
<div class="editor-body">
<div class="line-numbers">
<div v-for="n in outputLineCount" :key="n" class="line-number">{{ n }}</div>
</div>
<textarea
ref="outputEditorRef"
v-model="outputText"
readonly
class="text-editor output-editor"
placeholder="编码或解码结果将显示在这里"
></textarea>
<div class="editor-container">
<div class="line-numbers">
<div v-for="n in outputLineCount" :key="n" class="line-number">{{ n }}</div>
</div>
<textarea
v-model="outputText"
readonly
class="text-editor output-editor"
:placeholder="t('encoder.outputPlaceholder')"
></textarea>
</div>
</div>
</div>
@@ -163,9 +152,19 @@
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { ref, watch, onMounted, onUnmounted } from 'vue'
const { t } = useI18n()
function encodingTypeLabel(type) {
if (type === 'base64') return t('encoder.base64')
if (type === 'url') return t('encoder.url')
if (type === 'unicode') return t('encoder.unicode')
if (type === 'zlib') return t('encoder.zlib')
return type
}
import { zlibSync, decompressSync } from 'fflate'
import { useLineNumberEditor } from '../composables/useLineNumberEditor'
const inputText = ref('')
const outputText = ref('')
@@ -173,20 +172,9 @@ const encodingType = ref('base64') // 'base64'、'url'、'unicode' 或 'zlib'
const leftPanelWidth = ref(50)
const rightPanelWidth = ref(50)
const isResizing = ref(false)
const {
containerRef: inputContainerRef,
editorRef: inputEditorRef,
lineCount: inputLineCount,
updateLineCount: updateInputLineCount,
initEditor: initInputEditor,
} = useLineNumberEditor(() => inputText.value)
const {
containerRef: outputContainerRef,
editorRef: outputEditorRef,
lineCount: outputLineCount,
updateLineCount: updateOutputLineCount,
initEditor: initOutputEditor,
} = useLineNumberEditor(() => outputText.value)
const inputLineCount = ref(1)
const outputLineCount = ref(1)
const inputEditorRef = ref(null)
const sidebarOpen = ref(false)
// 历史记录
@@ -222,7 +210,25 @@ const closeToast = () => {
toastMessage.value = ''
}
// 监听输出变化,同步行号
// 更新输入行号
const updateInputLineCount = () => {
if (inputText.value) {
inputLineCount.value = inputText.value.split('\n').length
} else {
inputLineCount.value = 1
}
}
// 更新输出行号
const updateOutputLineCount = () => {
if (outputText.value) {
outputLineCount.value = outputText.value.split('\n').length
} else {
outputLineCount.value = 1
}
}
// 监听输出变化
watch(() => outputText.value, () => {
updateOutputLineCount()
})
@@ -338,7 +344,7 @@ const decodeUnicode = (text) => {
const encode = () => {
if (!inputText.value.trim()) {
showToast('请输入要编码的文本')
showToast(t('encoder.pleaseInputEncode'))
return
}
try {
@@ -365,16 +371,16 @@ const encode = () => {
output: result
})
showToast('编码成功', 'info', 2000)
showToast(t('encoder.encodeSuccess'), 'info', 2000)
} catch (e) {
showToast('编码失败:' + e.message)
showToast(t('encoder.encodeFailed') + e.message)
outputText.value = ''
}
}
const decode = () => {
if (!inputText.value.trim()) {
showToast('请输入要解码的字符串')
showToast(t('encoder.pleaseInputDecode'))
return
}
try {
@@ -401,14 +407,10 @@ const decode = () => {
output: result
})
showToast('解码成功', 'info', 2000)
showToast(t('encoder.decodeSuccess'), 'info', 2000)
} catch (e) {
const typeName = encodingType.value === 'base64' ? 'Base64' :
encodingType.value === 'url' ? 'URL' :
encodingType.value === 'unicode' ? 'Unicode' :
encodingType.value === 'zlib' ? 'Zlib' :
encodingType.value
showToast(`解码失败:请检查输入是否为有效的${typeName}编码字符串`)
const typeName = encodingTypeLabel(encodingType.value)
showToast(t('encoder.decodeFailed', { type: typeName }))
outputText.value = ''
}
}
@@ -416,36 +418,36 @@ const decode = () => {
const clearAll = () => {
inputText.value = ''
outputText.value = ''
updateInputLineCount()
updateOutputLineCount()
showToast('已清空', 'info', 2000)
inputLineCount.value = 1
outputLineCount.value = 1
showToast(t('common.cleared'), 'info', 2000)
}
// 复制输入到剪贴板
const copyInputToClipboard = async () => {
if (!inputText.value.trim()) {
showToast('输入内容为空,无法复制')
showToast(t('encoder.inputEmptyCopy'))
return
}
try {
await navigator.clipboard.writeText(inputText.value)
showToast('已复制输入到剪贴板', 'info', 2000)
showToast(t('encoder.copiedInput'), 'info', 2000)
} catch (e) {
showToast('复制失败:' + e.message)
showToast(t('common.copyFailed') + e.message)
}
}
// 复制输出到剪贴板
const copyOutputToClipboard = async () => {
if (!outputText.value.trim()) {
showToast('输出内容为空,无法复制')
showToast(t('encoder.outputEmptyCopy'))
return
}
try {
await navigator.clipboard.writeText(outputText.value)
showToast('已复制输出到剪贴板', 'info', 2000)
showToast(t('encoder.copiedOutput'), 'info', 2000)
} catch (e) {
showToast('复制失败:' + e.message)
showToast(t('common.copyFailed') + e.message)
}
}
@@ -458,20 +460,20 @@ const pasteFromClipboard = async () => {
inputText.value = text
updateInputLineCount()
} else {
showToast('剪贴板内容为空')
showToast(t('common.clipboardEmpty'))
}
} catch (e) {
if (inputEditorRef.value) {
inputEditorRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
showToast(t('common.pasteHint'), 'info', 3000)
} else {
showToast('无法访问编辑器,请手动粘贴内容')
showToast(t('encoder.manualPaste'))
}
}
} else {
if (inputEditorRef.value) {
inputEditorRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
showToast(t('common.pasteHint'), 'info', 3000)
}
}
}
@@ -605,8 +607,8 @@ const truncateText = (text, maxLength) => {
}
onMounted(() => {
initInputEditor()
initOutputEditor()
updateInputLineCount()
updateOutputLineCount()
loadHistoryList()
})
@@ -1016,10 +1018,39 @@ onUnmounted(() => {
font-size: 14px;
}
.editor-container {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
background: #ffffff;
}
.line-numbers {
position: absolute;
left: 0;
top: 0;
bottom: 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;
z-index: 1;
}
.line-number {
line-height: 1.6;
height: 22.4px;
}
.text-editor {
flex: 1;
width: 0;
min-width: 0;
width: 100%;
padding: 1rem 1rem 1rem 3rem;
border: none;
font-family: 'Courier New', monospace;
@@ -1029,8 +1060,6 @@ onUnmounted(() => {
background: #ffffff;
color: #1a1a1a;
line-height: 1.6;
overflow: hidden;
box-sizing: border-box;
}
.text-editor:focus {
@@ -1134,6 +1163,11 @@ onUnmounted(() => {
font-size: 0.75rem;
}
.line-numbers {
width: 32px;
font-size: 12px;
}
.text-editor {
padding-left: 2.5rem;
}
+37 -51
View File
@@ -2,78 +2,64 @@
<div class="home-container">
<div class="hero-section">
<h2 class="hero-title">
今天是{{ `${new Date().getFullYear()}${new Date().getMonth() + 1}${new Date().getDate()}` }}
{{ t('home.heroToday', { date: heroDate }) }}
</h2>
<p v-if="jinrishiciSdkUrl" id="jinrishici-sentence" class="hero-subtitle"></p>
<p 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"
:to="localePath(currentPathLocale, 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 class="footer-section">
<p class="icp-info">苏ICP备2022013040号-1</p>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { siteConfig, getJinrishiciSdkUrl } from '../config/site.js'
import { onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { localePath } from '../router'
const siteIcp = siteConfig.icp
const jinrishiciSdkUrl = getJinrishiciSdkUrl()
const { t, locale } = useI18n()
const route = useRoute()
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: '颜色格式转换'
const currentPathLocale = computed(() => route.params.locale || 'zh')
const heroDate = computed(() => {
const d = new Date()
const y = d.getFullYear()
const m = d.getMonth() + 1
const day = d.getDate()
if (locale.value === 'en') {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
])
onMounted(() => {
if (!jinrishiciSdkUrl) return
return `${y}${m}${day}`
})
const wordsScript = document.createElement('script')
wordsScript.charset = 'utf-8'
wordsScript.src = jinrishiciSdkUrl
document.body.appendChild(wordsScript)
const tools = computed(() => [
{ path: 'json-formatter', title: t('home.toolJson'), description: t('home.toolJsonDesc') },
{ path: 'comparator', title: t('home.toolComparator'), description: t('home.toolComparatorDesc') },
{ path: 'encoder-decoder', title: t('home.toolEncoderDecoder'), description: t('home.toolEncoderDecoderDesc') },
{ path: 'variable-name', title: t('home.toolVariableName'), description: t('home.toolVariableNameDesc') },
{ path: 'qr-code', title: t('home.toolQrCode'), description: t('home.toolQrCodeDesc') },
{ path: 'timestamp-converter', title: t('home.toolTimestamp'), description: t('home.toolTimestampDesc') },
{ path: 'color-converter', title: t('home.toolColor'), description: t('home.toolColorDesc') },
])
onMounted(() => {
const words_script = document.createElement('script')
words_script.charset = 'utf-8'
words_script.src = 'https://sdk.jinrishici.com/v2/browser/jinrishici.js'
document.body.appendChild(words_script)
})
</script>
+292 -89
View File
@@ -8,7 +8,7 @@
<i v-else class="fas fa-circle-info"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<button @click="closeToast" class="toast-close-btn" :title="t('common.close')">
<i class="fas fa-xmark"></i>
</button>
</div>
@@ -17,12 +17,12 @@
<!-- 左侧侧栏历史记录 -->
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
<div class="sidebar-header">
<h3>历史记录</h3>
<h3>{{ t('common.history') }}</h3>
<button @click="toggleSidebar" class="close-btn">×</button>
</div>
<div class="sidebar-content">
<div v-if="historyList.length === 0" class="empty-history">
暂无历史记录
{{ t('common.noHistory') }}
</div>
<div
v-for="(item, index) in historyList"
@@ -42,47 +42,45 @@
<div class="left-panel" :style="{ width: leftPanelWidth + '%' }">
<div class="panel-toolbar">
<div class="view-tabs">
<button class="view-tab active">编辑器 <span class="size-limit">(最大 5MB)</span></button>
<button class="view-tab active">{{ t('json.editor') }} <span class="size-limit">{{ t('json.maxSize') }}</span></button>
</div>
<div class="toolbar-actions">
<button @click="copyToClipboard" class="toolbar-icon-btn" title="复制">
<button @click="copyToClipboard" class="toolbar-icon-btn" :title="t('common.copy')">
<i class="far fa-copy"></i>
</button>
<button @click="pasteFromClipboard" class="toolbar-icon-btn" title="粘贴">
<button @click="pasteFromClipboard" class="toolbar-icon-btn" :title="t('common.paste')">
<i class="far fa-paste"></i>
</button>
<button @click="clearAll" class="toolbar-icon-btn" title="清空">
<button @click="clearAll" class="toolbar-icon-btn" :title="t('common.clear')">
<i class="far fa-trash-can"></i>
</button>
<button @click="formatJson" class="toolbar-icon-btn" title="格式化">
<button @click="formatJson" class="toolbar-icon-btn" :title="t('json.format')">
<i class="fas fa-align-left"></i>
</button>
<button @click="minifyJson" class="toolbar-icon-btn" title="压缩">
<button @click="minifyJson" class="toolbar-icon-btn" :title="t('json.minify')">
<i class="fas fa-down-left-and-up-right-to-center"></i>
</button>
<button @click="escapeJson" class="toolbar-icon-btn" title="转义">
<button @click="escapeJson" class="toolbar-icon-btn" :title="t('json.escape')">
<i class="fas fa-code"></i>
</button>
<button @click="unescapeJson" class="toolbar-icon-btn" title="取消转义">
<button @click="unescapeJson" class="toolbar-icon-btn" :title="t('json.unescape')">
<svg viewBox="150 -50 1100 1250" xmlns="http://www.w3.org/2000/svg" width="17" height="17"><path d="M285.352637 0.003641h111.956538v114.687184h-111.956538v282.621991a110.682235 110.682235 0 0 1-33.313896 81.282266 110.682235 110.682235 0 0 1-81.282266 33.313896 110.682235 110.682235 0 0 1 81.282266 33.313897 110.682235 110.682235 0 0 1 33.313896 81.282266v282.621991h111.956538v114.687184h-111.956538a188.050574 188.050574 0 0 1-80.007964-40.049493 93.570179 93.570179 0 0 1-34.67922-74.637691v-226.643722a110.682235 110.682235 0 0 0-33.313896-81.282267 110.682235 110.682235 0 0 0-81.282267-33.313896H0v-111.956537h55.978269a110.682235 110.682235 0 0 0 81.282266-33.313897 110.682235 110.682235 0 0 0 33.313896-81.282266V114.690825A113.776969 113.776969 0 0 1 285.261616 0.003641z m794.61835 0a113.776969 113.776969 0 0 1 114.687184 114.687184v226.643722a113.776969 113.776969 0 0 0 114.687185 114.687184H1365.323624v111.956538h-55.978268a113.776969 113.776969 0 0 0-114.687185 114.687184v226.643722a113.776969 113.776969 0 0 1-114.687184 114.687184h-111.956537V909.309175h111.956537V626.687184a113.776969 113.776969 0 0 1 114.687184-114.687184 113.776969 113.776969 0 0 1-114.687184-114.687184V114.690825h-111.956537V0.003641h111.956537zM682.661812 682.665453a54.612945 54.612945 0 0 1 55.978269 55.978269 58.799937 58.799937 0 0 1-16.019797 41.323795 54.612945 54.612945 0 0 1-80.007965 0 58.799937 58.799937 0 0 1-16.019797-41.323795 54.612945 54.612945 0 0 1 55.978269-55.978269z m-226.643721 0a54.612945 54.612945 0 0 1 55.978268 55.978269 58.799937 58.799937 0 0 1-16.019797 41.323795 52.246384 52.246384 0 0 1-40.049493 17.294099 59.164024 59.164024 0 0 1-58.708916-58.708916 52.246384 52.246384 0 0 1 17.294099-40.049493 58.799937 58.799937 0 0 1 41.505839-15.837754z m453.287443 0a58.799937 58.799937 0 0 1 41.323795 16.019797 52.246384 52.246384 0 0 1 17.294099 40.049493 59.164024 59.164024 0 0 1-58.708916 58.708916 52.246384 52.246384 0 0 1-40.049493-17.294099 58.799937 58.799937 0 0 1-16.019797-41.323795 54.612945 54.612945 0 0 1 55.978269-55.978269z" fill="#666666" p-id="26339"></path></svg>
</button>
</div>
</div>
<div ref="editorContainerRef" class="editor-container">
<div class="editor-body">
<div class="line-numbers">
<div v-for="n in lineCount" :key="n" class="line-number">{{ n }}</div>
</div>
<textarea
ref="jsonEditorRef"
v-model="inputJson"
@paste="handlePaste"
@input="updateLineCount"
@focus="adjustEditorHeight"
placeholder='请输入或粘贴JSON数据,例如:{"name":"工具箱","version":1.0}'
class="json-editor"
></textarea>
<div class="line-numbers">
<div v-for="n in lineCount" :key="n" class="line-number">{{ n }}</div>
</div>
<textarea
ref="jsonEditorRef"
v-model="inputJson"
@paste="handlePaste"
@input="updateLineCount"
@focus="adjustTextareaHeight"
:placeholder="t('json.placeholder')"
class="json-editor"
></textarea>
</div>
<div class="sidebar-toggle">
<button @click="toggleSidebar" class="toggle-btn">
@@ -101,7 +99,7 @@
<div class="right-panel" :style="{ width: rightPanelWidth + '%' }">
<div class="panel-toolbar">
<div class="view-tabs">
<button class="view-tab active">树形</button>
<button class="view-tab active">{{ t('json.tree') }}</button>
</div>
<div class="toolbar-actions">
<div class="jsonpath-input-wrapper">
@@ -111,15 +109,15 @@
@focus="showJsonPathHistory = true"
@blur="handleJsonPathBlur"
type="text"
placeholder="输入 JSONPath,例如: $.key.subkey"
:placeholder="t('json.jsonPathPlaceholder')"
class="jsonpath-input"
title="JSONPath 筛选"
:title="t('json.jsonPathFilter')"
/>
<button
v-if="jsonPathQuery"
@click="clearJsonPath"
class="jsonpath-clear-btn"
title="清除筛选"
:title="t('json.clearFilter')"
>
<i class="fas fa-xmark"></i>
</button>
@@ -142,14 +140,14 @@
v-if="jsonPathQuery && jsonPathQuery.trim() && matchedNodes.length > 0"
@click="copyMatchedResults"
class="toolbar-icon-btn"
title="复制筛选结果"
:title="t('json.copyFilterResult')"
>
<i class="far fa-copy"></i>
</button>
<button @click="expandAll" class="toolbar-icon-btn" title="展开全部">
<button @click="expandAll" class="toolbar-icon-btn" :title="t('json.expandAll')">
<i class="fas fa-chevron-down"></i>
</button>
<button @click="collapseAll" class="toolbar-icon-btn" title="折叠全部">
<button @click="collapseAll" class="toolbar-icon-btn" :title="t('json.collapseAll')">
<i class="fas fa-chevron-up"></i>
</button>
</div>
@@ -157,7 +155,7 @@
<div class="tree-container">
<div class="tree-content">
<div v-if="!parsedData" class="empty-state">
在左侧输入或粘贴JSON数据右侧将实时显示树形结构
{{ t('json.emptyState') }}
</div>
<!-- JSONPath 筛选时直接显示匹配的节点列表 -->
<div v-else-if="jsonPathQuery && jsonPathQuery.trim() && matchedNodes.length > 0" class="matched-nodes-list">
@@ -176,7 +174,7 @@
/>
</div>
<div v-else-if="jsonPathQuery && jsonPathQuery.trim() && matchedNodes.length === 0" class="empty-state">
未找到匹配的节点
{{ t('json.noMatchedNodes') }}
</div>
<!-- 没有筛选时显示完整的树形结构 -->
<JsonTreeNode
@@ -199,8 +197,10 @@
<script setup>
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import JsonTreeNode from '../components/JsonTreeNode.vue'
import { useLineNumberEditor } from '../composables/useLineNumberEditor'
const { t } = useI18n()
// 最大输入限制:5MB(JSON格式化工具的限制)
// 主要考虑因素:
@@ -215,18 +215,10 @@ const leftPanelWidth = ref(50)
const rightPanelWidth = ref(50)
const isResizing = ref(false)
const expandedNodes = ref(new Set())
const lineCount = ref(1)
const treeLineCount = ref(1)
const {
containerRef: editorContainerRef,
editorRef: jsonEditorRef,
lineCount,
updateLineCount,
adjustEditorHeight,
resetEditorScroll,
initEditor,
} = useLineNumberEditor(() => inputJson.value, {
onBeforeUpdate: () => applyInputLimit(),
})
const jsonEditorRef = ref(null)
const editorContainerRef = ref(null)
// JSONPath 筛选
const jsonPathQuery = ref('')
@@ -700,7 +692,7 @@ const handleJsonPathBlur = () => {
// 复制筛选结果
const copyMatchedResults = async () => {
if (!matchedNodes.value || matchedNodes.value.length === 0) {
showToast('没有可复制的结果', 'error', 2000)
showToast(t('json.noContentToCopy'), 'error', 2000)
return
}
@@ -711,9 +703,9 @@ const copyMatchedResults = async () => {
// 复制到剪贴板
await navigator.clipboard.writeText(jsonString)
showToast(`已复制 ${matchedNodes.value.length} 个匹配结果`, 'info', 2000)
showToast(t('json.copiedCount', { count: matchedNodes.value.length }), 'info', 2000)
} catch (e) {
showToast('复制失败:' + e.message, 'error', 3000)
showToast(t('common.copyFailed') + e.message, 'error', 3000)
}
}
@@ -732,10 +724,133 @@ const truncateToMaxBytes = (str, maxBytes) => {
const applyInputLimit = () => {
if (getByteLength(inputJson.value) <= MAX_INPUT_BYTES) return
inputJson.value = truncateToMaxBytes(inputJson.value, MAX_INPUT_BYTES)
showToast('内容已超过 5MB 限制,已自动截断', 'info', 3000)
showToast(t('json.contentOverLimit'), 'info', 3000)
updateLineCount()
}
// 更新行号
const updateLineCount = () => {
applyInputLimit()
if (inputJson.value) {
lineCount.value = inputJson.value.split('\n').length
} else {
lineCount.value = 1
}
// 更新textarea高度以适应内容
adjustTextareaHeight()
}
// 同步行号容器的滚动位置
let rafId = null
const syncLineNumbersScroll = () => {
if (rafId) {
cancelAnimationFrame(rafId)
}
rafId = requestAnimationFrame(() => {
if (editorContainerRef.value) {
const lineNumbers = editorContainerRef.value.querySelector('.line-numbers')
if (lineNumbers && editorContainerRef.value) {
// 同步滚动位置:当容器向下滚动时,行号容器也需要向下移动相同的距离
const scrollTop = editorContainerRef.value.scrollTop
// 方法1: 使用 transform(优先)
const transformValue = `translate3d(0, ${scrollTop}px, 0)`
// 直接设置,不使用 removeProperty,避免闪烁
lineNumbers.style.transform = transformValue
lineNumbers.style.webkitTransform = transformValue
// 方法2: 同时使用 top 作为备用(如果 transform 不工作)
// 当容器滚动时,行号需要跟随内容移动
// 由于行号是绝对定位 top: 0,当内容向上滚动 scrollTop 时,行号也需要向上移动 scrollTop
// 所以设置 top 为负值
lineNumbers.style.top = `${-scrollTop}px`
}
}
rafId = null
})
}
// 创建一个持续同步的函数,用于调试
let syncInterval = null
const startContinuousSync = () => {
if (syncInterval) {
clearInterval(syncInterval)
}
// 每50ms检查一次滚动位置并同步(作为备用方案)
// 使用更短的间隔确保及时同步
syncInterval = setInterval(() => {
if (editorContainerRef.value) {
syncLineNumbersScroll()
}
}, 50)
}
const stopContinuousSync = () => {
if (syncInterval) {
clearInterval(syncInterval)
syncInterval = null
}
}
// 调整textarea高度以适应内容
const adjustTextareaHeight = () => {
if (jsonEditorRef.value && editorContainerRef.value) {
// 重置高度为auto以获取正确的scrollHeight
jsonEditorRef.value.style.height = 'auto'
// 设置高度为内容的实际高度(scrollHeight已经包含了padding
const scrollHeight = jsonEditorRef.value.scrollHeight
// 确保至少有一行的高度(包括padding)
const paddingTop = 16 // 1rem = 16px
const paddingBottom = 16 // 1rem = 16px
const lineHeight = 22.4 // 14px * 1.6
const minHeight = paddingTop + lineHeight + paddingBottom
const newHeight = Math.max(scrollHeight, minHeight)
jsonEditorRef.value.style.height = newHeight + 'px'
// 同步调整行号容器的高度,根据实际行数计算
const lineNumbers = editorContainerRef.value.querySelector('.line-numbers')
if (lineNumbers) {
// 先保存当前的 transform 值,避免被重置
const currentTransform = lineNumbers.style.transform || lineNumbers.style.webkitTransform || ''
// 根据实际行数计算行号容器的高度
// padding-top + (行数 * 行高) + padding-bottom
const calculatedHeight = paddingTop + (lineCount.value * lineHeight) + paddingBottom
// 使用计算出的高度和scrollHeight中的较大值,确保完全覆盖
const finalHeight = Math.max(calculatedHeight, newHeight)
// 只设置高度相关的样式,不影响 transform
lineNumbers.style.setProperty('height', `${finalHeight}px`, 'important')
lineNumbers.style.setProperty('max-height', 'none', 'important')
lineNumbers.style.setProperty('min-height', `${finalHeight}px`, 'important')
lineNumbers.style.setProperty('overflow', 'visible', 'important')
// 恢复 transform 值(如果有的话)
if (currentTransform) {
lineNumbers.style.setProperty('transform', currentTransform, 'important')
lineNumbers.style.setProperty('-webkit-transform', currentTransform, 'important')
}
}
// 延迟同步滚动位置,确保高度设置完成后再同步
setTimeout(() => {
syncLineNumbersScroll()
}, 0)
}
}
// 重置编辑器滚动位置到顶部
const resetEditorScroll = () => {
// 重置容器的滚动位置(因为滚动是在editor-container上)
if (editorContainerRef.value) {
editorContainerRef.value.scrollTop = 0
}
// 同时也重置textarea的滚动位置(以防万一)
if (jsonEditorRef.value) {
jsonEditorRef.value.scrollTop = 0
}
}
// 获取所有路径
const getAllPaths = (obj, prefix = 'root') => {
const paths = []
@@ -764,7 +879,14 @@ const getAllPaths = (obj, prefix = 'root') => {
// 监听输入变化,实时更新树形结构
watch(inputJson, () => {
// 先应用大小限制
applyInputLimit()
updateLineCount()
// 使用nextTick确保DOM更新后再调整高度
setTimeout(() => {
adjustTextareaHeight()
}, 0)
if (inputJson.value.trim()) {
// 检查大小,如果超过限制则不解析(避免性能问题)
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
@@ -793,13 +915,13 @@ watch(inputJson, () => {
// 格式化JSON
const formatJson = () => {
if (!inputJson.value.trim()) {
showToast('请输入JSON数据')
showToast(t('json.pleaseInputJson'))
return
}
// 检查输入大小
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
showToast(`输入内容超过 5MB 限制,无法格式化`, 'error', 4000)
showToast(t('json.inputOverLimit'), 'error', 4000)
return
}
@@ -809,29 +931,29 @@ const formatJson = () => {
// 检查格式化后的大小
if (getByteLength(formatted) > MAX_INPUT_BYTES) {
showToast('格式化后的内容超过 5MB 限制,无法显示', 'error', 4000)
showToast(t('json.outputOverLimit'), 'error', 4000)
return
}
inputJson.value = formatted
updateLineCount()
resetEditorScroll()
showToast('格式化成功', 'info', 2000)
showToast(t('json.formatSuccess'), 'info', 2000)
} catch (e) {
showToast('JSON格式错误:' + e.message)
showToast(t('json.jsonError') + e.message)
}
}
// 压缩JSON
const minifyJson = () => {
if (!inputJson.value.trim()) {
showToast('请输入JSON数据')
showToast(t('json.pleaseInputJson'))
return
}
// 检查输入大小
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
showToast(`输入内容超过 5MB 限制,无法压缩`, 'error', 4000)
showToast(t('json.minifyOverLimit'), 'error', 4000)
return
}
@@ -841,29 +963,29 @@ const minifyJson = () => {
// 检查压缩后的大小(压缩后应该更小,但为了安全还是检查)
if (getByteLength(minified) > MAX_INPUT_BYTES) {
showToast('压缩后的内容超过 5MB 限制,无法显示', 'error', 4000)
showToast(t('json.minifyOutputOverLimit'), 'error', 4000)
return
}
inputJson.value = minified
updateLineCount()
resetEditorScroll()
showToast('压缩成功', 'info', 2000)
showToast(t('json.minifySuccess'), 'info', 2000)
} catch (e) {
showToast('JSON格式错误:' + e.message)
showToast(t('json.jsonError') + e.message)
}
}
// 转义JSON
const escapeJson = () => {
if (!inputJson.value.trim()) {
showToast('请输入JSON数据')
showToast(t('json.pleaseInputJson'))
return
}
// 检查输入大小
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
showToast(`输入内容超过 5MB 限制,无法转义`, 'error', 4000)
showToast(t('json.escapeOverLimit'), 'error', 4000)
return
}
@@ -923,7 +1045,7 @@ const escapeJson = () => {
const escaped = JSON.stringify(jsonToEscape)
// 检查转义后的大小
if (getByteLength(escaped) > MAX_INPUT_BYTES) {
showToast('转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
showToast(t('json.escapeOutputOverLimit'), 'error', 4000)
return
}
inputJson.value = escaped
@@ -932,28 +1054,28 @@ const escapeJson = () => {
// 最后检查一次大小(防止前面的分支没有检查)
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
showToast('转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
showToast(t('json.escapeOutputOverLimit'), 'error', 4000)
return
}
updateLineCount()
resetEditorScroll()
showToast('转义成功', 'info', 2000)
showToast(t('json.escapeSuccess'), 'info', 2000)
} catch (e) {
showToast('转义失败:' + e.message)
showToast(t('json.escapeFailed') + e.message)
}
}
// 取消转义JSON
const unescapeJson = () => {
if (!inputJson.value.trim()) {
showToast('请输入JSON数据')
showToast(t('json.pleaseInputJson'))
return
}
// 检查输入大小
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
showToast(`输入内容超过 5MB 限制,无法取消转义`, 'error', 4000)
showToast(t('json.unescapeOverLimit'), 'error', 4000)
return
}
@@ -1003,25 +1125,25 @@ const unescapeJson = () => {
const formatted = JSON.stringify(parsed, null, 2)
// 检查格式化后的大小
if (getByteLength(formatted) > MAX_INPUT_BYTES) {
showToast('取消转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
showToast(t('json.unescapeOutputOverLimit'), 'error', 4000)
return
}
inputJson.value = formatted
updateLineCount()
resetEditorScroll()
showToast('取消转义并格式化成功', 'info', 2000)
showToast(t('json.unescapeFormatSuccess'), 'info', 2000)
return
} catch (e) {
// 如果解析失败,说明只是普通字符串,保持原样
// 检查字符串大小
if (getByteLength(unescaped) > MAX_INPUT_BYTES) {
showToast('取消转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
showToast(t('json.unescapeOutputOverLimit'), 'error', 4000)
return
}
inputJson.value = unescaped
updateLineCount()
resetEditorScroll()
showToast('取消转义成功', 'info', 2000)
showToast(t('json.unescapeSuccess'), 'info', 2000)
return
}
}
@@ -1031,42 +1153,42 @@ const unescapeJson = () => {
const formatted = JSON.stringify(unescaped, null, 2)
// 检查格式化后的大小
if (getByteLength(formatted) > MAX_INPUT_BYTES) {
showToast('取消转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
showToast(t('json.unescapeOutputOverLimit'), 'error', 4000)
return
}
inputJson.value = formatted
updateLineCount()
resetEditorScroll()
showToast('取消转义并格式化成功', 'info', 2000)
showToast(t('json.unescapeFormatSuccess'), 'info', 2000)
} else {
// 其他类型(数字、布尔值等),转换为字符串
const result = String(unescaped)
// 检查结果大小
if (getByteLength(result) > MAX_INPUT_BYTES) {
showToast('取消转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
showToast(t('json.unescapeOutputOverLimit'), 'error', 4000)
return
}
inputJson.value = result
updateLineCount()
resetEditorScroll()
showToast('取消转义成功', 'info', 2000)
showToast(t('json.unescapeSuccess'), 'info', 2000)
}
} catch (e) {
showToast('取消转义失败:' + e.message)
showToast(t('json.unescapeFailed') + e.message)
}
}
// 复制到剪贴板
const copyToClipboard = async () => {
if (!inputJson.value.trim()) {
showToast('编辑器内容为空,无法复制')
showToast(t('json.editorEmpty'))
return
}
try {
await navigator.clipboard.writeText(inputJson.value)
showToast('已复制到剪贴板', 'info', 2000)
showToast(t('common.copied'), 'info', 2000)
} catch (e) {
showToast('复制失败:' + e.message)
showToast(t('common.copyFailed') + e.message)
}
}
@@ -1080,7 +1202,7 @@ const pasteFromClipboard = async () => {
// 检查大小限制
if (getByteLength(text) > MAX_INPUT_BYTES) {
text = truncateToMaxBytes(text, MAX_INPUT_BYTES)
showToast('粘贴内容已超过 5MB 限制,已自动截断', 'info', 3000)
showToast(t('json.pasteOverLimit'), 'info', 3000)
}
inputJson.value = text
updateLineCount()
@@ -1097,7 +1219,7 @@ const pasteFromClipboard = async () => {
// 如果不是有效JSON,不保存到历史记录
}
} else {
showToast('剪贴板内容为空')
showToast(t('common.clipboardEmpty'))
}
return
} catch (e) {
@@ -1109,9 +1231,9 @@ const pasteFromClipboard = async () => {
// 让现有的 handlePaste 方法处理粘贴逻辑
if (jsonEditorRef.value) {
jsonEditorRef.value.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
showToast(t('common.pasteHint'), 'info', 3000)
} else {
showToast('无法访问编辑器,请手动粘贴内容')
showToast(t('encoder.manualPaste'))
}
}
@@ -1119,9 +1241,9 @@ const pasteFromClipboard = async () => {
const clearAll = () => {
inputJson.value = ''
expandedNodes.value.clear()
lineCount.value = 1
treeLineCount.value = 1
updateLineCount()
showToast('已清空', 'info', 2000)
showToast(t('common.cleared'), 'info', 2000)
}
// 处理粘贴事件
@@ -1135,7 +1257,7 @@ const handlePaste = async (event) => {
event.preventDefault()
const truncated = truncateToMaxBytes(pastedText, MAX_INPUT_BYTES)
inputJson.value = truncated
showToast('粘贴内容已超过 5MB 限制,已自动截断', 'info', 3000)
showToast(t('json.pasteOverLimit'), 'info', 3000)
updateLineCount()
return
}
@@ -1300,12 +1422,44 @@ const stopResize = () => {
onMounted(() => {
loadHistoryList()
loadJsonPathHistory()
initEditor()
// 初始化textarea高度
adjustTextareaHeight()
// 监听容器滚动事件,同步行号位置
if (editorContainerRef.value) {
// 监听滚动事件 - 使用 capture 模式确保能捕获到事件
editorContainerRef.value.addEventListener('scroll', syncLineNumbersScroll, {
passive: true,
capture: false
})
// 也监听wheel事件,确保鼠标滚轮滚动时也能同步
editorContainerRef.value.addEventListener('wheel', () => {
setTimeout(syncLineNumbersScroll, 0)
}, { passive: true })
// 初始同步
setTimeout(() => {
syncLineNumbersScroll()
// 启动持续同步(作为备用方案)
startContinuousSync()
}, 100)
}
})
onUnmounted(() => {
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
// 清理滚动事件监听器
if (editorContainerRef.value) {
editorContainerRef.value.removeEventListener('scroll', syncLineNumbersScroll)
}
// 清理动画帧
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
// 停止持续同步
stopContinuousSync()
})
</script>
@@ -1762,10 +1916,54 @@ onUnmounted(() => {
border-radius: 0 0 4px 4px;
}
.editor-container {
flex: 1;
display: flex;
position: relative;
overflow-y: auto;
overflow-x: hidden;
background: #ffffff;
min-height: 0;
max-height: 100%;
}
.line-numbers {
position: absolute;
left: 0;
top: 0;
width: 40px;
padding-top: 1rem;
padding-bottom: 1rem;
padding-left: 0.5rem;
padding-right: 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;
z-index: 1;
pointer-events: none;
/* 确保行号容器可以超出容器高度 */
overflow: visible;
max-height: none !important;
/* 移除初始 transform,让 JavaScript 完全控制 */
transform: none;
will-change: top, transform;
}
.line-number {
line-height: 1.6;
height: 22.4px;
display: flex;
align-items: center;
justify-content: flex-end;
}
.json-editor {
flex: 1;
width: 0;
min-width: 0;
width: 100%;
padding: 1rem 1rem 1rem 3rem;
border: none;
font-family: 'Courier New', monospace;
@@ -1900,6 +2098,11 @@ onUnmounted(() => {
font-size: 0.8125rem;
}
.line-numbers {
width: 32px;
font-size: 12px;
}
.json-editor {
padding-left: 2.5rem;
}
+20 -18
View File
@@ -8,7 +8,7 @@
<i v-else class="fas fa-circle-check"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<button @click="closeToast" class="toast-close-btn" :title="t('common.close')">
<i class="fas fa-xmark"></i>
</button>
</div>
@@ -18,12 +18,12 @@
<!-- 左侧侧栏历史记录 -->
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
<div class="sidebar-header">
<h3>历史记录</h3>
<h3>{{ t('common.history') }}</h3>
<button @click="toggleSidebar" class="close-btn">×</button>
</div>
<div class="sidebar-content">
<div v-if="historyList.length === 0" class="empty-history">
暂无历史记录
{{ t('common.noHistory') }}
</div>
<div
v-for="(item, index) in historyList"
@@ -47,30 +47,30 @@
<textarea
v-model="inputText"
@keydown.enter.prevent="generateQRCode"
placeholder="请输入要生成二维码的内容"
:placeholder="t('qr.inputPlaceholder')"
class="input-textarea"
rows="4"
></textarea>
</div>
<button @click="generateQRCode" class="generate-btn">
<i class="fas fa-qrcode"></i>
生成二维码
{{ t('qr.generate') }}
</button>
</div>
<!-- 二维码显示区域 -->
<div v-if="qrCodeDataUrl" class="qr-display-section">
<div class="qr-code-wrapper">
<img :src="qrCodeDataUrl" alt="二维码" class="qr-code-image" />
<img :src="qrCodeDataUrl" :alt="t('qr.qrCode')" class="qr-code-image" />
</div>
<div class="qr-actions">
<button @click="downloadQRCode" class="action-btn">
<i class="fas fa-download"></i>
下载
{{ t('qr.download') }}
</button>
<button @click="copyQRCodeImage" class="action-btn">
<i class="far fa-copy"></i>
复制图片
{{ t('qr.copyImage') }}
</button>
</div>
</div>
@@ -90,8 +90,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
const { t } = useI18n()
// 输入文本
const inputText = ref('')
// 二维码数据URL
@@ -135,7 +138,7 @@ const closeToast = () => {
// 生成二维码
const generateQRCode = async () => {
if (!inputText.value.trim()) {
showToast('请输入要生成二维码的内容', 'error')
showToast(t('qr.pleaseInput'), 'error')
return
}
@@ -155,9 +158,9 @@ const generateQRCode = async () => {
// 保存到历史记录
saveToHistory(inputText.value.trim())
showToast('二维码生成成功', 'success', 2000)
showToast(t('qr.generateSuccess'), 'success', 2000)
} catch (error) {
showToast('生成二维码失败:' + error.message, 'error')
showToast(t('qr.generateFailed') + error.message, 'error')
qrCodeDataUrl.value = ''
}
}
@@ -165,7 +168,7 @@ const generateQRCode = async () => {
// 下载二维码
const downloadQRCode = () => {
if (!qrCodeDataUrl.value) {
showToast('没有可下载的二维码', 'error')
showToast(t('qr.noQrToDownload'), 'error')
return
}
@@ -174,16 +177,16 @@ const downloadQRCode = () => {
link.download = `qrcode-${Date.now()}.png`
link.href = qrCodeDataUrl.value
link.click()
showToast('下载成功', 'success', 2000)
showToast(t('qr.downloadSuccess'), 'success', 2000)
} catch (error) {
showToast('下载失败:' + error.message, 'error')
showToast(t('qr.downloadFailed') + error.message, 'error')
}
}
// 复制二维码图片
const copyQRCodeImage = async () => {
if (!qrCodeDataUrl.value) {
showToast('没有可复制的二维码', 'error')
showToast(t('qr.noQrToCopy'), 'error')
return
}
@@ -199,10 +202,9 @@ const copyQRCodeImage = async () => {
})
])
showToast('已复制到剪贴板', 'success', 2000)
showToast(t('common.copied'), 'success', 2000)
} catch (error) {
// 降级方案:提示用户手动保存
showToast('复制失败,请使用下载功能', 'error')
showToast(t('qr.copyImageFailed'), 'error')
}
}
+48 -75
View File
@@ -25,7 +25,7 @@
<!-- 日期转换为时间戳 -->
<div class="conversion-row">
<div class="conversion-label">日期 ({{ timezoneLabel }}) 时间戳:</div>
<div class="conversion-label">{{ t('timestamp.dateToTs', { tz: timezoneLabel }) }}</div>
<div class="conversion-inputs">
<div class="input-with-calendar">
<input
@@ -35,7 +35,7 @@
:placeholder="getDatePlaceholder()"
class="input-field"
/>
<button @click="showDateTimePicker = true" class="calendar-btn" title="选择日期时间">
<button @click="showDateTimePicker = true" class="calendar-btn" :title="t('timestamp.selectDateTime')">
<i class="far fa-calendar"></i>
</button>
@@ -54,7 +54,7 @@
readonly
class="input-field readonly"
/>
<button @click="copyToClipboard(timestampOutput)" class="copy-btn" title="复制">
<button @click="copyToClipboard(timestampOutput)" class="copy-btn" :title="t('common.copy')">
<i class="far fa-copy"></i>
</button>
</div>
@@ -62,13 +62,13 @@
<!-- 时间戳转换为日期 -->
<div class="conversion-row">
<div class="conversion-label">时间戳 ({{ timezoneLabel }}) 日期</div>
<div class="conversion-label">{{ t('timestamp.tsToDate', { tz: timezoneLabel }) }}</div>
<div class="conversion-inputs">
<input
v-model="timestampInput"
@input="convertTimestampToDate"
type="text"
placeholder="请输入时间戳"
:placeholder="t('timestamp.placeholderTs')"
class="input-field"
/>
<span class="arrow"></span>
@@ -78,7 +78,7 @@
readonly
class="input-field readonly"
/>
<button @click="copyToClipboard(dateStringOutput)" class="copy-btn" title="复制">
<button @click="copyToClipboard(dateStringOutput)" class="copy-btn" :title="t('common.copy')">
<i class="far fa-copy"></i>
</button>
</div>
@@ -86,13 +86,13 @@
<!-- 当前时间戳显示与控制 -->
<div class="current-timestamp-row">
<div class="conversion-label">当前时间戳:</div>
<div class="conversion-label">{{ t('timestamp.currentTs') }}</div>
<div class="current-timestamp-controls">
<span class="current-timestamp-value">{{ currentTimestampDisplay }}</span>
<button @click="togglePause" class="control-btn-icon" :title="isPaused ? '继续' : '暂停'">
<button @click="togglePause" class="control-btn-icon" :title="isPaused ? t('timestamp.resume') : t('timestamp.pause')">
<i :class="isPaused ? 'fas fa-play' : 'fas fa-pause'"></i>
</button>
<button @click="resetData" class="control-btn-icon" title="重置数据">
<button @click="resetData" class="control-btn-icon" :title="t('timestamp.resetData')">
<i class="fas fa-rotate-right"></i>
</button>
</div>
@@ -113,7 +113,7 @@
<i v-else class="fas fa-circle-check"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<button @click="closeToast" class="toast-close-btn" :title="t('common.close')">
<i class="fas fa-xmark"></i>
</button>
</div>
@@ -123,56 +123,33 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import DateTimePicker from '@/components/DateTimePicker.vue'
const { t, tm } = useI18n()
// 精度选项
const precisionOptions = [
{ value: 'seconds', label: '秒' },
{ value: 'milliseconds', label: '毫秒' },
{ value: 'nanoseconds', label: '纳秒' }
const precisionOptions = computed(() => [
{ value: 'seconds', label: t('timestamp.seconds') },
{ value: 'milliseconds', label: t('timestamp.milliseconds') },
{ value: 'nanoseconds', label: t('timestamp.nanoseconds') },
])
const TIMEZONE_VALUES = [
'UTC-12:00', 'UTC-11:00', 'UTC-10:00', 'UTC-09:30', 'UTC-09:00', 'UTC-08:00', 'UTC-07:00', 'UTC-06:00',
'UTC-05:00', 'UTC-04:00', 'UTC-03:30', 'UTC-03:00', 'UTC-02:00', 'UTC-01:00', 'UTC+00:00', 'UTC+01:00',
'UTC+02:00', 'UTC+03:00', 'UTC+03:30', 'UTC+04:00', 'UTC+04:30', 'UTC+05:00', 'UTC+05:30', 'UTC+05:45',
'UTC+06:00', 'UTC+06:30', 'UTC+07:00', 'UTC+08:00', 'UTC+08:45', 'UTC+09:00', 'UTC+09:30', 'UTC+10:00',
'UTC+10:30', 'UTC+11:00', 'UTC+12:00', 'UTC+12:45', 'UTC+13:00', 'UTC+14:00',
]
// 所有时区选项
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 timezoneOptions = computed(() => {
const tzNames = tm('timestampTz') || {}
return TIMEZONE_VALUES.map(value => ({
value,
label: `${value} | ${tzNames[value] || value}`,
}))
})
// 当前时间相关
const currentTime = ref(new Date())
@@ -182,8 +159,8 @@ 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 tz = timezoneOptions.value.find(opt => opt.value === timezone.value)
return tz ? tz.label.split('|')[1].trim() : ''
})
// 时间戳转时间
@@ -234,13 +211,9 @@ const currentTimestampDisplay = computed(() => {
// 获取日期输入框的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'
}
if (timestampType.value === 'seconds') return t('timestamp.datePlaceholderSeconds')
if (timestampType.value === 'milliseconds') return t('timestamp.datePlaceholderMs')
return t('timestamp.datePlaceholderNs')
}
// 更新时间
@@ -265,7 +238,7 @@ const resetData = () => {
dateStringInput.value = ''
timestampOutput.value = ''
currentTime.value = new Date()
showToast('数据已重置', 'success')
showToast(t('timestamp.dataReset'), 'success')
}
// 时间戳转时间字符串
@@ -296,7 +269,7 @@ const convertTimestampToDate = () => {
nanoseconds = Number(timestampNs % BigInt(1000000))
} catch (error) {
dateStringOutput.value = ''
showToast('请输入有效的纳秒级时间戳', 'error')
showToast(t('timestamp.invalidNs'), 'error')
return
}
} else {
@@ -304,7 +277,7 @@ const convertTimestampToDate = () => {
if (isNaN(timestamp)) {
dateStringOutput.value = ''
showToast('请输入有效的数字', 'error')
showToast(t('timestamp.invalidNumber'), 'error')
return
}
@@ -325,7 +298,7 @@ const convertTimestampToDate = () => {
if (isNaN(date.getTime())) {
dateStringOutput.value = ''
showToast('无效的时间戳', 'error')
showToast(t('timestamp.invalidTs'), 'error')
return
}
@@ -348,7 +321,7 @@ const convertTimestampToDate = () => {
}
} catch (error) {
dateStringOutput.value = ''
showToast('转换失败:' + error.message, 'error')
showToast(t('timestamp.convertFailed') + error.message, 'error')
}
}
@@ -393,7 +366,7 @@ const convertDateToTimestamp = () => {
if (isNaN(date.getTime())) {
timestampOutput.value = ''
showToast('无效的时间格式', 'error')
showToast(t('timestamp.invalidDateFormat'), 'error')
return
}
@@ -408,7 +381,7 @@ const convertDateToTimestamp = () => {
}
} catch (error) {
timestampOutput.value = ''
showToast('转换失败:' + error.message, 'error')
showToast(t('timestamp.convertFailed') + error.message, 'error')
}
}
@@ -421,13 +394,13 @@ const handleDateTimeConfirm = (value) => {
// 复制到剪贴板
const copyToClipboard = async (text) => {
if (!text) {
showToast('没有可复制的内容', 'error')
showToast(t('timestamp.noContentToCopy'), 'error')
return
}
try {
await navigator.clipboard.writeText(text)
showToast('已复制到剪贴板', 'success')
showToast(t('common.copied'), 'success')
} catch (error) {
// 降级方案
const textArea = document.createElement('textarea')
@@ -438,9 +411,9 @@ const copyToClipboard = async (text) => {
textArea.select()
try {
document.execCommand('copy')
showToast('已复制到剪贴板', 'success')
showToast(t('common.copied'), 'success')
} catch (err) {
showToast('复制失败', 'error')
showToast(t('timestamp.copyFailed'), 'error')
}
document.body.removeChild(textArea)
}
+54 -44
View File
@@ -8,7 +8,7 @@
<i v-else class="fas fa-circle-check"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<button @click="closeToast" class="toast-close-btn" :title="t('common.close')">
<i class="fas fa-xmark"></i>
</button>
</div>
@@ -23,10 +23,10 @@
v-model="inputText"
@input="convertVariableName"
type="text"
placeholder="请输入变量名(支持任意格式)"
:placeholder="t('variable.placeholder')"
class="input-field"
/>
<button @click="clearInput" class="clear-btn" title="清空">
<button @click="clearInput" class="clear-btn" :title="t('common.clear')">
<i class="fas fa-xmark"></i>
</button>
</div>
@@ -36,7 +36,7 @@
<div class="output-section">
<div class="output-row">
<div
v-for="format in formats.slice(0, 3)"
v-for="format in formatList.slice(0, 3)"
:key="format.key"
class="output-item"
>
@@ -45,19 +45,19 @@
<button
@click="copyToClipboard(format.value, format.label)"
class="copy-btn"
:title="`复制${format.label}`"
:title="t('variable.copyLabel', { label: format.label })"
>
<i class="far fa-copy"></i>
</button>
</div>
<div class="output-value" :class="{ empty: !format.value }">
{{ format.value || '—' }}
{{ format.value || t('variable.empty') }}
</div>
</div>
</div>
<div class="output-row">
<div
v-for="format in formats.slice(3)"
v-for="format in formatList.slice(3)"
:key="format.key"
class="output-item"
>
@@ -66,13 +66,13 @@
<button
@click="copyToClipboard(format.value, format.label)"
class="copy-btn"
:title="`复制${format.label}`"
:title="t('variable.copyLabel', { label: format.label })"
>
<i class="far fa-copy"></i>
</button>
</div>
<div class="output-value" :class="{ empty: !format.value }">
{{ format.value || '—' }}
{{ format.value || t('variable.empty') }}
</div>
</div>
</div>
@@ -84,20 +84,38 @@
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
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 formatKeys = [
{ key: 'camelCase', labelKey: 'variable.camelCase' },
{ key: 'PascalCase', labelKey: 'variable.pascalCase' },
{ key: 'snake_case', labelKey: 'variable.snakeCase' },
{ key: 'kebab-case', labelKey: 'variable.kebabCase' },
{ key: 'CONSTANT_CASE', labelKey: 'variable.constantCase' },
]
const formatValues = ref({
camelCase: '',
PascalCase: '',
snake_case: '',
'kebab-case': '',
CONSTANT_CASE: '',
})
const formatList = computed(() =>
formatKeys.map(({ key, labelKey }) => ({
key,
label: t(labelKey),
value: formatValues.value[key],
}))
)
// 显示提示
const showToast = (message, type = 'success', duration = 3000) => {
@@ -212,33 +230,25 @@ const toConstantCase = (words) => {
// 转换变量名
const convertVariableName = () => {
const words = parseToWords(inputText.value)
if (words.length === 0) {
formats.value.forEach(format => {
format.value = ''
})
formatValues.value = {
camelCase: '',
PascalCase: '',
snake_case: '',
'kebab-case': '',
CONSTANT_CASE: '',
}
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
}
})
formatValues.value = {
camelCase: toCamelCase(words),
PascalCase: toPascalCase(words),
snake_case: toSnakeCase(words),
'kebab-case': toKebabCase(words),
CONSTANT_CASE: toConstantCase(words),
}
}
// 清空输入
@@ -249,16 +259,16 @@ const clearInput = () => {
// 复制到剪贴板
const copyToClipboard = async (text, label) => {
if (!text || text === '—') {
showToast('没有可复制的内容', 'error')
if (!text || text === t('variable.empty')) {
showToast(t('variable.noContentToCopy'), 'error')
return
}
try {
await navigator.clipboard.writeText(text)
showToast(`${label}已复制到剪贴板`, 'success', 2000)
showToast(t('variable.copiedLabel', { label }), 'success', 2000)
} catch (error) {
showToast('复制失败:' + error.message, 'error')
showToast(t('common.copyFailed') + error.message, 'error')
}
}
</script>
-30
View File
@@ -1,30 +0,0 @@
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
@@ -1,42 +0,0 @@
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
@@ -1,41 +0,0 @@
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
@@ -1,243 +0,0 @@
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
@@ -1,195 +0,0 @@
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
@@ -1,77 +0,0 @@
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
@@ -1,89 +0,0 @@
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
@@ -1,22 +0,0 @@
// @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
@@ -1,63 +0,0 @@
// @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('ToolBox')
expect(getSiteIcp()).toBe('')
})
it('builds page title with suffix', () => {
expect(getPageTitle('JSON')).toBe('ToolBox-JSON')
expect(getPageTitle(null)).toBe('ToolBox')
})
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
@@ -1,111 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1,72 +0,0 @@
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')
})
})
})
+11 -20
View File
@@ -1,26 +1,17 @@
import { defineConfig, loadEnv } from 'vite'
import { defineConfig } 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 || 'ToolBox'
}
if (process.env.VITE_APP_ICP === undefined) {
process.env.VITE_APP_ICP = env.VITE_APP_ICP ?? ''
}
return {
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
open: true
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
open: true
}
})
-14
View File
@@ -1,14 +0,0 @@
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'],
},
},
})