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