first commit
Some checks failed
Some checks failed
This commit is contained in:
5
.cnb.yml
Normal file
5
.cnb.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
include:
|
||||||
|
- https://cnb.cool/ilay1678/templates/-/blob/main/ci/docker.cnb.yml
|
||||||
|
- https://cnb.cool/ilay1678/templates/-/blob/main/ci/docker.ghcr.yml
|
||||||
|
- https://cnb.cool/ilay1678/templates/-/blob/main/ci/sync_to_github.yml
|
||||||
|
- https://cnb.cool/ilay1678/templates/-/blob/main/ci/ide.node.yml
|
||||||
14
.cnb/web_trigger.yml
Normal file
14
.cnb/web_trigger.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
branch:
|
||||||
|
# 如下按钮在分支名以 release 开头的分支详情页面显示
|
||||||
|
- reg: "^main"
|
||||||
|
buttons:
|
||||||
|
- name: 构建
|
||||||
|
# 如存在,则将作为流水线 title,否则流水线使用默认 title
|
||||||
|
description: 构建docker镜像
|
||||||
|
event: web_trigger # 触发的 CI 事件名
|
||||||
|
# 权限控制,不配置则有仓库写权限的用户可触发构建
|
||||||
|
# 如果配置,则需要有仓库写权限,并且满足 roles 或 users 其中之一才有权限触发构建
|
||||||
|
permissions:
|
||||||
|
# roles 和 users 配置其中之一或都配置均可,二者满足其一即可
|
||||||
|
roles:
|
||||||
|
- owner
|
||||||
20
.dockerignore
Normal file
20
.dockerignore
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
build-and-push.sh
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.wrangler
|
||||||
|
.dev.vars
|
||||||
|
.vercel
|
||||||
|
.open-next
|
||||||
|
.cursor
|
||||||
|
.ai_memory
|
||||||
|
.claude
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 测试点生成 - 阿里云通义大模型(可选)
|
||||||
|
# 配置后将优先使用 AI 生成测试用例,失败时自动回退到本地智能算法
|
||||||
|
ENABLE_AI=true
|
||||||
|
DASHSCOPE_API_KEY=sk-xxxxxxxxxxxxxxxx
|
||||||
|
# 可选,默认 qwen-max
|
||||||
|
# QWEN_MODEL=qwen-max
|
||||||
51
.github/workflows/clean-up.yml
vendored
Normal file
51
.github/workflows/clean-up.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Delete old workflow runs
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
del_runs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.repository.fork == false
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Delete workflow runs
|
||||||
|
uses: Mattraks/delete-workflow-runs@v2
|
||||||
|
with:
|
||||||
|
token: ${{ github.token }}
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
retain_days: 30
|
||||||
|
keep_minimum_runs: 6
|
||||||
|
|
||||||
|
# Points to a recent commit instead of `main` to avoid supply chain attacks. (The latest tag is very old.)
|
||||||
|
- name: 🎟 Get GitHub App token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
id: get-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Delete workflow runs for current repo
|
||||||
|
uses: Mattraks/delete-workflow-runs@v2
|
||||||
|
with:
|
||||||
|
token: ${{ github.token }}
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
retain_days: 7
|
||||||
|
keep_minimum_runs: 6
|
||||||
|
|
||||||
|
- name: Delete deployment
|
||||||
|
uses: strumwolf/delete-deployment-environment@v2.3.0
|
||||||
|
with:
|
||||||
|
token: ${{ steps.get-token.outputs.token }}
|
||||||
|
environment: Preview
|
||||||
|
onlyRemoveDeployments: true
|
||||||
|
|
||||||
|
- name: Delete MAIN deployment
|
||||||
|
uses: strumwolf/delete-deployment-environment@v2.3.0
|
||||||
|
with:
|
||||||
|
token: ${{ steps.get-token.outputs.token }}
|
||||||
|
environment: Production
|
||||||
|
onlyRemoveDeployments: true
|
||||||
75
.github/workflows/docker-build.yml
vendored
Normal file
75
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- 'README.md'
|
||||||
|
- '.cnb.yml'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.github/workflows/clean-up.yml'
|
||||||
|
- '.cnb/web_trigger.yml'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- 'README.md'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.cnb.yml'
|
||||||
|
- '.github/workflows/clean-up.yml'
|
||||||
|
- '.cnb/web_trigger.yml'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.repository.fork == false
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log into GHCR
|
||||||
|
uses: docker/login-action@master
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: set lower case owner name
|
||||||
|
run: |
|
||||||
|
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
|
||||||
|
env:
|
||||||
|
OWNER: "${{ github.repository_owner }}"
|
||||||
|
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/${{ env.OWNER_LC }}/i-tools:latest
|
||||||
|
${{ vars.DOCKERHUB_USERNAME }}/i-tools:latest
|
||||||
|
|
||||||
|
- name: Post build cleanup
|
||||||
|
run: docker builder prune --force
|
||||||
23
.github/workflows/sync-to-cnb.yml
vendored
Normal file
23
.github/workflows/sync-to-cnb.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Sync to CNB
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.repository.fork == false
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Sync to CNB Repository
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v ${{ github.workspace }}:${{ github.workspace }} \
|
||||||
|
-w ${{ github.workspace }} \
|
||||||
|
-e PLUGIN_TARGET_URL="https://cnb.cool/ilay1678/i-tools.git" \
|
||||||
|
-e PLUGIN_AUTH_TYPE="https" \
|
||||||
|
-e PLUGIN_USERNAME="cnb" \
|
||||||
|
-e PLUGIN_PASSWORD=${{ secrets.CNB_TOKEN }} \
|
||||||
|
-e PLUGIN_SYNC_MODE="rebase" \
|
||||||
|
tencentcom/git-sync
|
||||||
36
.github/workflows/sync.yml
vendored
Normal file
36
.github/workflows/sync.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Upstream Sync
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 4 * * *" # At 12PM UTC+8
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync_latest_from_upstream:
|
||||||
|
name: Sync latest commits from upstream repo
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.repository != 'iLay1678/i-tools' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Step 1: run a standard checkout action
|
||||||
|
- name: Checkout target repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Step 2: run the sync action
|
||||||
|
- name: Sync upstream changes
|
||||||
|
id: sync
|
||||||
|
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1
|
||||||
|
with:
|
||||||
|
upstream_sync_repo: iLay1678/i-tools
|
||||||
|
upstream_sync_branch: main
|
||||||
|
target_sync_branch: main
|
||||||
|
target_repo_token: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Sync check
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork."
|
||||||
|
exit 1
|
||||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# wrangler files
|
||||||
|
.wrangler
|
||||||
|
.dev.vars
|
||||||
|
.vercel
|
||||||
|
.next
|
||||||
|
.open-next
|
||||||
|
.spec-workflow
|
||||||
|
.claude
|
||||||
|
.ai_memory
|
||||||
1
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
20.18.0
|
||||||
250
DEPLOY.md
Normal file
250
DEPLOY.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# 信奥工具箱 - 服务器部署指南
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
1. **服务器环境**
|
||||||
|
- Linux 系统(推荐 Ubuntu 20.04+ 或 CentOS 7+)
|
||||||
|
- Docker 已安装(版本 20.10+)
|
||||||
|
- Docker Compose 已安装(可选,推荐)
|
||||||
|
|
||||||
|
2. **网络访问**
|
||||||
|
- 能够访问阿里云镜像仓库:`registry.cn-hangzhou.aliyuncs.com`
|
||||||
|
- 服务器端口开放(默认 3000 端口)
|
||||||
|
|
||||||
|
## 快速部署
|
||||||
|
|
||||||
|
### 方法一:使用 Docker Compose(推荐)
|
||||||
|
|
||||||
|
1. **登录阿里云镜像仓库**
|
||||||
|
```bash
|
||||||
|
docker login --username=<您的用户名> registry.cn-hangzhou.aliyuncs.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **上传部署文件到服务器**
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `deploy.sh`(可选)
|
||||||
|
|
||||||
|
3. **执行部署**
|
||||||
|
```bash
|
||||||
|
# 使用部署脚本
|
||||||
|
chmod +x deploy.sh
|
||||||
|
./deploy.sh --compose
|
||||||
|
|
||||||
|
# 或直接使用 docker-compose
|
||||||
|
docker-compose pull
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **验证部署**
|
||||||
|
```bash
|
||||||
|
# 查看容器状态
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 访问服务
|
||||||
|
curl http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法二:使用 Docker 命令
|
||||||
|
|
||||||
|
1. **登录阿里云镜像仓库**
|
||||||
|
```bash
|
||||||
|
docker login --username=<您的用户名> registry.cn-hangzhou.aliyuncs.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **使用部署脚本**
|
||||||
|
```bash
|
||||||
|
chmod +x deploy.sh
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **或手动执行**
|
||||||
|
```bash
|
||||||
|
# 拉取镜像
|
||||||
|
docker pull registry.cn-hangzhou.aliyuncs.com/nick-x86/i-tools:latest
|
||||||
|
|
||||||
|
# 停止并删除旧容器(如果存在)
|
||||||
|
docker stop i-tools 2>/dev/null || true
|
||||||
|
docker rm i-tools 2>/dev/null || true
|
||||||
|
|
||||||
|
# 启动新容器
|
||||||
|
docker run -d \
|
||||||
|
--name i-tools \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
registry.cn-hangzhou.aliyuncs.com/nick-x86/i-tools:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 端口配置
|
||||||
|
|
||||||
|
默认端口为 `3000`,如需修改:
|
||||||
|
|
||||||
|
**Docker Compose 方式:**
|
||||||
|
编辑 `docker-compose.yml`,修改 `ports` 配置:
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "8080:3000" # 主机端口:容器端口
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker 命令方式:**
|
||||||
|
```bash
|
||||||
|
./deploy.sh -p 8080
|
||||||
|
# 或
|
||||||
|
docker run -d --name i-tools -p 8080:3000 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
可以在 `docker-compose.yml` 或 `docker run` 命令中添加环境变量:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
# 添加其他环境变量
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用操作
|
||||||
|
|
||||||
|
### 查看日志
|
||||||
|
```bash
|
||||||
|
# Docker Compose
|
||||||
|
docker-compose logs -f i-tools
|
||||||
|
|
||||||
|
# Docker 命令
|
||||||
|
docker logs -f i-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重启服务
|
||||||
|
```bash
|
||||||
|
# Docker Compose
|
||||||
|
docker-compose restart i-tools
|
||||||
|
|
||||||
|
# Docker 命令
|
||||||
|
docker restart i-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停止服务
|
||||||
|
```bash
|
||||||
|
# Docker Compose
|
||||||
|
docker-compose stop i-tools
|
||||||
|
|
||||||
|
# Docker 命令
|
||||||
|
docker stop i-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新部署
|
||||||
|
```bash
|
||||||
|
# 使用部署脚本(推荐)
|
||||||
|
./deploy.sh --compose
|
||||||
|
|
||||||
|
# 或手动更新
|
||||||
|
docker-compose pull
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看容器状态
|
||||||
|
```bash
|
||||||
|
# Docker Compose
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Docker 命令
|
||||||
|
docker ps | grep i-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nginx 反向代理配置(可选)
|
||||||
|
|
||||||
|
如果需要通过域名访问,可以配置 Nginx 反向代理:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 容器无法启动
|
||||||
|
```bash
|
||||||
|
# 查看详细日志
|
||||||
|
docker logs i-tools
|
||||||
|
|
||||||
|
# 检查端口是否被占用
|
||||||
|
netstat -tulpn | grep 3000
|
||||||
|
# 或
|
||||||
|
lsof -i :3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 无法访问服务
|
||||||
|
1. 检查防火墙设置
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo ufw allow 3000
|
||||||
|
|
||||||
|
# CentOS/RHEL
|
||||||
|
sudo firewall-cmd --add-port=3000/tcp --permanent
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 检查容器是否运行
|
||||||
|
```bash
|
||||||
|
docker ps | grep i-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 检查端口映射
|
||||||
|
```bash
|
||||||
|
docker port i-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 镜像拉取失败
|
||||||
|
1. 确认已登录镜像仓库
|
||||||
|
```bash
|
||||||
|
docker login registry.cn-hangzhou.aliyuncs.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 检查网络连接
|
||||||
|
```bash
|
||||||
|
ping registry.cn-hangzhou.aliyuncs.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 资源限制
|
||||||
|
|
||||||
|
默认配置的资源限制:
|
||||||
|
- CPU: 0.5-1 核心
|
||||||
|
- 内存: 256MB-512MB
|
||||||
|
|
||||||
|
如需调整,编辑 `docker-compose.yml` 中的 `deploy.resources` 部分。
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
1. **使用非 root 用户运行容器**(已在 Dockerfile 中配置)
|
||||||
|
2. **定期更新镜像**:`docker-compose pull && docker-compose up -d`
|
||||||
|
3. **配置防火墙**:只开放必要端口
|
||||||
|
4. **使用 HTTPS**:通过 Nginx 配置 SSL 证书
|
||||||
|
5. **监控日志**:定期检查容器日志
|
||||||
|
|
||||||
|
## 联系支持
|
||||||
|
|
||||||
|
如遇到问题,请检查:
|
||||||
|
1. Docker 版本是否符合要求
|
||||||
|
2. 服务器资源是否充足
|
||||||
|
3. 网络连接是否正常
|
||||||
|
4. 查看容器日志获取详细错误信息
|
||||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
# 提高超时与重试,避免内网/跨平台构建时 npm 拉包 EIDLETIMEOUT
|
||||||
|
RUN npm config set fetch-timeout 300000 && npm config set fetch-retries 5 && npm ci
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs
|
||||||
|
RUN adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
126
README.md
Normal file
126
README.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<h1 align="center">i-Tools | 信奥工具箱</h1>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/iLay1678/i-tools)
|
||||||
|

|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>简约、高效、现代化的在线信奥工具集合</strong><br/>
|
||||||
|
基于 Next.js 16 + Tailwind CSS 4 + TypeScript 构建
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## ✨ 特性
|
||||||
|
|
||||||
|
- **现代化设计**:精美的 UI,支持明亮/暗黑模式自适应。
|
||||||
|
- **高性能**:基于 React Server Components 和静态生成。
|
||||||
|
- **纯前端处理**:大部分数据处理(如格式化、转换)在本地浏览器完成,保护隐私。
|
||||||
|
- **全能工具库**:涵盖开发、文本、编码、加密、转换、生成、生活等 40+ 款实用工具。
|
||||||
|
|
||||||
|
## 🛠️ 工具列表
|
||||||
|
|
||||||
|
### 👨💻 开发工具 (Dev Tools)
|
||||||
|
1. **JSON 格式化** (`/json-formatter`): 格式化、压缩、验证、Diff 对比。
|
||||||
|
2. **YAML 格式化** (`/yaml-formatter`): YAML 校验与美化。
|
||||||
|
3. **HTML 格式化** (`/html-formatter`): HTML 代码美化。
|
||||||
|
4. **SQL 格式化** (`/sql-formatter`): 支持多种数据库方言的 SQL 美化。
|
||||||
|
5. **文本 Diff 对比** (`/diff`): Monaco Editor 驱动并排代码差异对比。
|
||||||
|
6. **Markdown 编辑器** (`/markdown`): 实时预览、GFM 支持、导出 Markdown。
|
||||||
|
7. **HTML 转义** (`/html-escape`): HTML 实体编码/解码。
|
||||||
|
|
||||||
|
### 📝 文本工具 (Text Tools)
|
||||||
|
1. **文字格式化** (`/text-formatter`): 中英文盘古之白空格、符号修正。
|
||||||
|
2. **大小写转换** (`/case-converter`): 驼峰、蛇形、大写、小写互转。
|
||||||
|
3. **Lorem Ipsum** (`/lorem-ipsum`): 生成乱数假文。
|
||||||
|
|
||||||
|
### 🔐 加密与编码 (Crypto & Encoding)
|
||||||
|
1. **Base64 编解码** (`/base64`): 文本与 Base64 互转。
|
||||||
|
2. **URL 编解码** (`/url-encode`): URL 参数编码处理。
|
||||||
|
3. **Unicode 转换** (`/unicode`): Unicode 编码转换。
|
||||||
|
4. **Base32/Base58** (`/base32`, `/base58`): 其他常用编码。
|
||||||
|
5. **MD5/SHA 哈希** (`/hash`): 计算 MD5, SHA1, SHA256, SHA512。
|
||||||
|
6. **Bcrypt 哈希** (`/bcrypt`): 生成与验证 Bcrypt 密码哈希。
|
||||||
|
7. **AES/DES 加密** (`/aes-des`): 对称加密解密工具。
|
||||||
|
8. **JWT 解码** (`/jwt`): JWT Token 解析查看。
|
||||||
|
|
||||||
|
### 🎨 图像与可视化 (Visual)
|
||||||
|
1. **二维码生成** (`/qrcode`): 自定义颜色、Logo 的二维码生成。
|
||||||
|
2. **条形码生成** (`/barcode`): 生成 EAN, UPC, Code128 等条形码。
|
||||||
|
3. **ASCII 艺术** (`/ascii-art`): 文字转字符画。
|
||||||
|
4. **图片转像素画** (`/image-to-pixel`): 图片像素化风格转换。
|
||||||
|
5. **图片 Base64** (`/image-base64`): 图片文件与 Base64 字符串互转。
|
||||||
|
|
||||||
|
### 🧮 转换与计算 (Converters & Calc)
|
||||||
|
1. **时间戳转换** (`/timestamp`): Unix 时间戳与日期互转。
|
||||||
|
2. **进制转换器** (`/radix-converter`): 二/八/十/十六进制任意互转。
|
||||||
|
3. **IP 进制转换** (`/ip-radix`): IP 地址与整数/二进制转换。
|
||||||
|
4. **IP 子网计算** (`/ip-calc`): CIDR 子网划分计算。
|
||||||
|
5. **CSV/JSON 互转** (`/csv-json`): 数据格式互相转换。
|
||||||
|
|
||||||
|
### 🎲 生成与随机 (Generation)
|
||||||
|
1. **UUID 生成** (`/uuid`): 批量生成 Version 1/4 UUID。
|
||||||
|
2. **随机密码生成** (`/random-string`): 高强度随机密码生成器。
|
||||||
|
3. **大转盘抽奖** (`/wheel`): 随机决策工具。
|
||||||
|
4. **随机分组** (`/random-group`): 名单随机分组工具。
|
||||||
|
5. **抛硬币** (`/coin-flip`): 简单的概率工具。
|
||||||
|
|
||||||
|
### 🔧 实用工具 (Utilities)
|
||||||
|
1. **Cron 解析** (`/cron`): Cron 表达式翻译与执行时间预测。
|
||||||
|
2. **正则测试** (`/regex`): 正则表达式实时测试。
|
||||||
|
3. **颜色选择器** (`/color-picker`): HEX, RGB, HSL 转换与拾色。
|
||||||
|
4. **UA 解析** (`/user-agent`): 解析 User-Agent 字符串详情。
|
||||||
|
5. **键盘按键检测** (`/keyboard`): KeyCode 与按键事件查看。
|
||||||
|
6. **挪车码牌** (`/move-car`): 生成微信挪车通知码。
|
||||||
|
7. **番茄钟/秒表/倒计时**: 时间管理三件套。
|
||||||
|
8. **阿里云盘 TV Token**: 扫码获取 Token。
|
||||||
|
|
||||||
|
## 💻 技术栈
|
||||||
|
|
||||||
|
- **框架**: [Next.js 16](https://nextjs.org/) (App Directory)
|
||||||
|
- **语言**: [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
- **样式**: [Tailwind CSS 4](https://tailwindcss.com/)
|
||||||
|
- **UI 组件**: [Radix UI](https://www.radix-ui.com/) + [Lucide Icons](https://lucide.dev/)
|
||||||
|
- **Linting**: [Oxc (Oxlint)](https://github.com/oxc-project/oxc) - 高性能 Linter,替代 ESLint。
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 开发
|
||||||
|
|
||||||
|
1. 克隆项目
|
||||||
|
```bash
|
||||||
|
git clone https://git.istudy.xin/yangfan/i-tools.git
|
||||||
|
cd i-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 安装依赖
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 启动开发服务器
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 代码检查 (使用 Oxc)
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 部署
|
||||||
|
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --name=i-tools -d -p 3000:3000 registry.cn-hangzhou.aliyuncs.com/nick-x86/i-tools:1.0.8
|
||||||
|
```
|
||||||
154
app/aes-des/page.tsx
Normal file
154
app/aes-des/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import CryptoJS from "crypto-js";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Shield, Lock, Unlock, Copy } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const ALGORITHMS = [
|
||||||
|
{ value: "AES", label: "AES" },
|
||||||
|
{ value: "DES", label: "DES" },
|
||||||
|
{ value: "TripleDES", label: "TripleDES" },
|
||||||
|
{ value: "Rabbit", label: "Rabbit" },
|
||||||
|
{ value: "RC4", label: "RC4" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AesDesPage() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [algorithm, setAlgorithm] = useState("AES");
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
|
||||||
|
const handleProcess = (mode: "encrypt" | "decrypt") => {
|
||||||
|
if (!input) return;
|
||||||
|
if (!key) {
|
||||||
|
toast.error("请输入密钥");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result = "";
|
||||||
|
const algo = (CryptoJS as any)[algorithm];
|
||||||
|
|
||||||
|
if (mode === "encrypt") {
|
||||||
|
result = algo.encrypt(input, key).toString();
|
||||||
|
toast.success("加密成功");
|
||||||
|
} else {
|
||||||
|
const bytes = algo.decrypt(input, key);
|
||||||
|
result = bytes.toString(CryptoJS.enc.Utf8);
|
||||||
|
if (!result) throw new Error("解密失败(可能是密钥错误)");
|
||||||
|
toast.success("解密成功");
|
||||||
|
}
|
||||||
|
setOutput(result);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("处理失败: " + e.message);
|
||||||
|
setOutput("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-red-500 to-rose-600 shadow-lg">
|
||||||
|
<Shield className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">对称加密/解密</h1>
|
||||||
|
<p className="text-muted-foreground">支持 AES, DES, RC4 等常用对称加密算法</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-12">
|
||||||
|
<Card className="md:col-span-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>文本内容</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>输入文本 (明文或密文)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="请输入..."
|
||||||
|
className="min-h-37.5 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button onClick={() => handleProcess("encrypt")} className="flex-1 gap-2">
|
||||||
|
<Lock className="h-4 w-4" /> 加密
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleProcess("decrypt")} variant="secondary" className="flex-1 gap-2">
|
||||||
|
<Unlock className="h-4 w-4" /> 解密
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-4 border-t">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>处理结果</Label>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(output)} disabled={!output}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" /> 复制
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
readOnly
|
||||||
|
value={output}
|
||||||
|
placeholder="结果将显示在这里..."
|
||||||
|
className="min-h-37.5 font-mono bg-muted/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-4 h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>算法</Label>
|
||||||
|
<Select value={algorithm} onValueChange={setAlgorithm}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ALGORITHMS.map(a => (
|
||||||
|
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>密钥 (Passphrase)</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
placeholder="请输入加密/解密密钥"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
安全性提示:所有计算均在本地浏览器执行,密钥不会发送到服务器。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
338
app/alipan-tv-token/page.tsx
Normal file
338
app/alipan-tv-token/page.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
CloudDownload,
|
||||||
|
Copy,
|
||||||
|
LogIn,
|
||||||
|
Loader2,
|
||||||
|
Info,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface StatusResponse {
|
||||||
|
status: "LoginSuccess" | "ScanSuccess" | "LoginFailed" | "QRCodeExpired" | "WaitLogin";
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlipanTvToken() {
|
||||||
|
const [hasGenerated, setHasGenerated] = useState(false);
|
||||||
|
const [authUrl, setAuthUrl] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [hasAccessToken, setHasAccessToken] = useState(false);
|
||||||
|
const [hasRefreshToken, setHasRefreshToken] = useState(false);
|
||||||
|
const [authorizing, setAuthorizing] = useState(false);
|
||||||
|
const [isNoticeOpen, setIsNoticeOpen] = useState(false);
|
||||||
|
const [accessToken, setAccessToken] = useState("");
|
||||||
|
const [refreshToken, setRefreshToken] = useState("");
|
||||||
|
const [currentSid, setCurrentSid] = useState("");
|
||||||
|
const [currentHost, setCurrentHost] = useState("");
|
||||||
|
|
||||||
|
const checkTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const getCurrentHost = () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return `${window.location.protocol}//${window.location.host}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
async function generateAuthUrl() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await fetch("/api/alipan-tv-token/generate_qr", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
setCurrentSid(data.sid);
|
||||||
|
setAuthUrl(`https://www.alipan.com/o/oauth/authorize?sid=${data.sid}`);
|
||||||
|
} catch {
|
||||||
|
toast.error("初始化失败,请检查网络");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNotice() {
|
||||||
|
setIsNoticeOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkStatus(sid: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/alipan-tv-token/check_status/${sid}`);
|
||||||
|
const data: StatusResponse = await response.json();
|
||||||
|
if (data.status === "LoginSuccess") {
|
||||||
|
setAccessToken(data.access_token);
|
||||||
|
setRefreshToken(data.refresh_token);
|
||||||
|
setHasAccessToken(!!data.access_token);
|
||||||
|
setHasRefreshToken(!!data.refresh_token);
|
||||||
|
setAuthorizing(false);
|
||||||
|
toast.success("登录成功");
|
||||||
|
} else if (data.status === "ScanSuccess") {
|
||||||
|
checkTimer.current = setTimeout(() => checkStatus(sid), 2000);
|
||||||
|
} else if (data.status === "LoginFailed") {
|
||||||
|
setAuthorizing(false);
|
||||||
|
toast.error("登录失败,请刷新页面重试");
|
||||||
|
} else if (data.status === "QRCodeExpired") {
|
||||||
|
setAuthorizing(false);
|
||||||
|
toast.error("链接过期,请刷新页面重试");
|
||||||
|
} else {
|
||||||
|
// WaitLogin
|
||||||
|
checkTimer.current = setTimeout(() => checkStatus(sid), 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("检查状态时出错:", error);
|
||||||
|
toast.error("发生错误,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, name: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(`${name} 已复制`);
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuth = (url: string) => {
|
||||||
|
setAuthorizing(true);
|
||||||
|
window.open(url, "_blank");
|
||||||
|
|
||||||
|
if (currentSid) {
|
||||||
|
if (checkTimer.current) {
|
||||||
|
clearTimeout(checkTimer.current);
|
||||||
|
}
|
||||||
|
checkTimer.current = setTimeout(() => checkStatus(currentSid), 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentHost(getCurrentHost());
|
||||||
|
setIsNoticeOpen(true);
|
||||||
|
if (!hasGenerated) {
|
||||||
|
generateAuthUrl();
|
||||||
|
setHasGenerated(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (checkTimer.current) {
|
||||||
|
clearTimeout(checkTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [hasGenerated]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-teal-500 to-teal-600 shadow-lg">
|
||||||
|
<CloudDownload className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">阿里云盘TV Token</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
获取阿里云盘TV端的授权令牌,解锁高速下载
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Left: Tokens */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-base font-medium">访问令牌</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1"
|
||||||
|
disabled={!hasAccessToken}
|
||||||
|
onClick={() => copyToClipboard(accessToken, "访问令牌")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={accessToken}
|
||||||
|
readOnly
|
||||||
|
rows={4}
|
||||||
|
placeholder="授权成功后,访问令牌将显示在这里..."
|
||||||
|
className="font-mono resize-none bg-muted/50 text-xs"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-base font-medium">刷新令牌</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1"
|
||||||
|
disabled={!hasRefreshToken}
|
||||||
|
onClick={() => copyToClipboard(refreshToken, "刷新令牌")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={refreshToken}
|
||||||
|
readOnly
|
||||||
|
rows={3}
|
||||||
|
placeholder="刷新令牌将显示在这里..."
|
||||||
|
className="font-mono resize-none bg-muted/50 text-xs"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Auth Action */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<LogIn className="h-4 w-4" />
|
||||||
|
授权操作
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!hasAccessToken && !hasRefreshToken && (
|
||||||
|
<div className="py-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p>正在获取授权链接...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full gap-2 text-lg h-14"
|
||||||
|
onClick={() => handleAuth(authUrl)}
|
||||||
|
disabled={authorizing}
|
||||||
|
>
|
||||||
|
{authorizing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
授权中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="h-5 w-5" />
|
||||||
|
开始授权登录
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(hasAccessToken || hasRefreshToken) && (
|
||||||
|
<div className="py-8 flex flex-col items-center gap-4 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<div className="h-16 w-16 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||||
|
<CloudDownload className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">已成功获取令牌</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-medium">API 路由</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<span className="text-sm font-medium">OAuth 令牌链接:</span>
|
||||||
|
<div className="p-3 bg-muted rounded-md break-all text-xs font-mono">
|
||||||
|
{currentHost}/api/oauth/alipan/token
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">功能说明</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>本工具帮助获取阿里云盘TV版的刷新令牌</li>
|
||||||
|
<li>TV接口可绕过三方应用权益包的速率限制</li>
|
||||||
|
<li>需要SVIP会员才能享受高速下载</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">使用步骤</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>点击"开始授权登录"按钮</li>
|
||||||
|
<li>在弹出的页面中使用阿里云盘APP扫码</li>
|
||||||
|
<li>授权成功后令牌会自动显示</li>
|
||||||
|
<li>复制令牌到对应的播放软件中使用</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Alert className="bg-amber-50 dark:bg-amber-950/20 text-amber-900 dark:text-amber-200 border-amber-200 dark:border-amber-800">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>温馨提示</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
TV接口能绕过三方应用权益包的速率限制,但需要SVIP会员才能享受高速下载。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={isNoticeOpen} onOpenChange={setIsNoticeOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>使用说明</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription className="py-4">
|
||||||
|
本工具能帮助你一键获取「阿里云盘TV版」的刷新令牌,完全免费。
|
||||||
|
<br /><br />
|
||||||
|
<strong>注意:</strong> TV接口能绕过三方应用权益包的速率限制,但前提你得是SVIP。
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href="https://www.alipan.com/cpx/member?userCode=MjAyNTk2" target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
开通会员
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={closeNotice}>
|
||||||
|
我知道了
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
app/api/alipan-tv-token/check_status/[sid]/route.ts
Normal file
89
app/api/alipan-tv-token/check_status/[sid]/route.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { decrypt, getParams } from '@/utils/decode'
|
||||||
|
|
||||||
|
interface QrCodeStatus {
|
||||||
|
status: 'WaitLogin' | 'LoginSuccess' | 'QRCodeExpired' | 'ScanSuccess' | 'LoginFailed'
|
||||||
|
authCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenResponseEncrypt {
|
||||||
|
data: {
|
||||||
|
ciphertext: string
|
||||||
|
iv: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenRequest {
|
||||||
|
akv: string
|
||||||
|
apv: string
|
||||||
|
b: string
|
||||||
|
d: string
|
||||||
|
m: string
|
||||||
|
mac: string
|
||||||
|
n: string
|
||||||
|
t: number
|
||||||
|
wifiMac: string
|
||||||
|
code?: string
|
||||||
|
'Content-Type': string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ sid: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { sid } = await params
|
||||||
|
|
||||||
|
const statusResponse = await fetch(`https://openapi.alipan.com/oauth/qrcode/${sid}/status`)
|
||||||
|
|
||||||
|
if (!statusResponse.ok) {
|
||||||
|
throw new Error('Failed to check status')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusData: QrCodeStatus = await statusResponse.json()
|
||||||
|
|
||||||
|
if (statusData.status === 'LoginSuccess' && statusData.authCode) {
|
||||||
|
try {
|
||||||
|
const t = Math.floor(Date.now() / 1000)
|
||||||
|
const sendData: TokenRequest = {
|
||||||
|
...getParams(t),
|
||||||
|
code: statusData.authCode,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
} as TokenRequest
|
||||||
|
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
Object.entries(sendData).map(([k, v]) => [k, String(v)])
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokenResponse = await fetch('https://api.extscreen.com/aliyundrive/v3/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(sendData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
throw new Error('Failed to get token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResult: TokenResponseEncrypt = await tokenResponse.json()
|
||||||
|
const plainData = decrypt(tokenResult.data.ciphertext, tokenResult.data.iv, t)
|
||||||
|
const tokenInfo = JSON.parse(plainData)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
status: 'LoginSuccess',
|
||||||
|
refresh_token: tokenInfo.refresh_token,
|
||||||
|
access_token: tokenInfo.access_token
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
return Response.json({ status: 'LoginFailed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(statusData)
|
||||||
|
} catch (error: any) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/api/alipan-tv-token/generate_qr/route.ts
Normal file
38
app/api/alipan-tv-token/generate_qr/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
interface ApiResponse<T> {
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QrCodeData {
|
||||||
|
qrCodeUrl: string
|
||||||
|
sid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.extscreen.com/aliyundrive/qrcode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
scopes: ["user:base", "file:all:read", "file:all:write"].join(','),
|
||||||
|
width: 500,
|
||||||
|
height: 500,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to generate QR code')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ApiResponse<QrCodeData> = await response.json()
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
qr_link: result.data.qrCodeUrl,
|
||||||
|
sid: result.data.sid
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/api/oauth/alipan/token/route.ts
Normal file
94
app/api/oauth/alipan/token/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { decrypt, getParams } from '@/utils/decode'
|
||||||
|
|
||||||
|
interface TokenResponseEncrypt {
|
||||||
|
data: {
|
||||||
|
ciphertext: string
|
||||||
|
iv: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshToken(refreshTokenValue: string) {
|
||||||
|
const t = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
const sendData = {
|
||||||
|
...getParams(t),
|
||||||
|
refresh_token: refreshTokenValue,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
Object.entries(sendData).map(([k, v]) => [k, String(v)])
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokenResponse = await fetch('https://api.extscreen.com/aliyundrive/v3/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(sendData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
throw new Error('Failed to refresh token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData: TokenResponseEncrypt = await tokenResponse.json()
|
||||||
|
const plainData = decrypt(tokenData.data.ciphertext, tokenData.data.iv, t)
|
||||||
|
const tokenInfo = JSON.parse(plainData)
|
||||||
|
|
||||||
|
return tokenInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { refresh_token } = await request.json()
|
||||||
|
const tokenInfo = await refreshToken(refresh_token)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
token_type: 'Bearer',
|
||||||
|
access_token: tokenInfo.access_token,
|
||||||
|
refresh_token: tokenInfo.refresh_token,
|
||||||
|
expires_in: tokenInfo.expires_in
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
code: 500,
|
||||||
|
message: error.message,
|
||||||
|
data: null
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const refresh_ui = searchParams.get('refresh_ui')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (!refresh_ui) {
|
||||||
|
return Response.json({
|
||||||
|
refresh_token: '',
|
||||||
|
access_token: '',
|
||||||
|
text: 'refresh_ui parameter is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenInfo = await refreshToken(refresh_ui)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
refresh_token: tokenInfo.refresh_token,
|
||||||
|
access_token: tokenInfo.access_token,
|
||||||
|
text: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
return Response.json({
|
||||||
|
refresh_token: '',
|
||||||
|
access_token: '',
|
||||||
|
text: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/api/testcase-generator/download/[taskId]/route.ts
Normal file
42
app/api/testcase-generator/download/[taskId]/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { getTask, getZipPathForTask } from "@/lib/testcase-backend";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ taskId: string }> }
|
||||||
|
) {
|
||||||
|
const { taskId } = await params;
|
||||||
|
if (!taskId) {
|
||||||
|
return Response.json({ error: "缺少 taskId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = getTask(taskId);
|
||||||
|
if (!task) {
|
||||||
|
return Response.json({ error: "任务未找到" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.status !== "completed") {
|
||||||
|
return Response.json({ error: "任务尚未完成" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipPath = getZipPathForTask(taskId);
|
||||||
|
if (!zipPath) {
|
||||||
|
return Response.json({ error: "文件未找到" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = await fs.readFile(zipPath);
|
||||||
|
const filename = `${task.englishName}_testcases.zip`;
|
||||||
|
return new Response(buffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/zip",
|
||||||
|
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[testcase] download read error:", e);
|
||||||
|
return Response.json({ error: "文件读取失败" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/api/testcase-generator/generate/route.ts
Normal file
75
app/api/testcase-generator/generate/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import {
|
||||||
|
createTask,
|
||||||
|
getEstimatedTime,
|
||||||
|
findRunningTaskByEnglishName,
|
||||||
|
processTask,
|
||||||
|
} from "@/lib/testcase-backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
englishName,
|
||||||
|
description,
|
||||||
|
standardCode,
|
||||||
|
serviceLevel = "standard",
|
||||||
|
testCaseCount: rawCount,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!title || !englishName || !description || !standardCode) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "请填写必填字段:题目标题、题目英文名、题目描述、C++标准程序" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z][a-z0-9_]*$/.test(String(englishName).trim())) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "题目英文名格式错误:只能包含小写字母、数字和下划线,必须以字母开头" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCaseCount = Math.min(100, Math.max(1, parseInt(String(rawCount), 10) || 10));
|
||||||
|
|
||||||
|
const existing = findRunningTaskByEnglishName(String(englishName));
|
||||||
|
if (existing) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "相同英文名的任务正在处理中,请稍后再试或使用不同的英文名" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = "task_" + Date.now() + "_" + Math.random().toString(36).slice(2, 11);
|
||||||
|
createTask(taskId, {
|
||||||
|
title: String(title).trim(),
|
||||||
|
englishName: String(englishName).trim().toLowerCase(),
|
||||||
|
description: String(description).trim(),
|
||||||
|
standardCode: String(standardCode).trim(),
|
||||||
|
testCaseCount,
|
||||||
|
serviceLevel: String(serviceLevel),
|
||||||
|
});
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
processTask(taskId).catch((err) => {
|
||||||
|
console.error(`[testcase] processTask error:`, err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const estimated_time = getEstimatedTime(String(serviceLevel), testCaseCount);
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
task_id: taskId,
|
||||||
|
message: "任务已创建,正在处理中...",
|
||||||
|
estimated_time,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "创建任务时发生错误,请重试:" + message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/api/testcase-generator/task_status/[taskId]/route.ts
Normal file
37
app/api/testcase-generator/task_status/[taskId]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { getTask, getEstimatedTime } from "@/lib/testcase-backend";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ taskId: string }> }
|
||||||
|
) {
|
||||||
|
const { taskId } = await params;
|
||||||
|
if (!taskId) {
|
||||||
|
return Response.json({ error: "缺少 taskId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = getTask(taskId);
|
||||||
|
if (!task) {
|
||||||
|
return Response.json({ error: "任务未找到" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningTime = Math.floor((Date.now() - task.createdAt.getTime()) / 1000);
|
||||||
|
const estimated_time = getEstimatedTime(task.serviceLevel, task.testCaseCount);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
task_id: task.id,
|
||||||
|
status: task.status,
|
||||||
|
progress: task.progress,
|
||||||
|
title: task.title,
|
||||||
|
english_name: task.englishName,
|
||||||
|
test_case_count: task.testCaseCount,
|
||||||
|
generated_cases: task.generatedCases ?? 0,
|
||||||
|
service_level: task.serviceLevel,
|
||||||
|
created_at: task.createdAt,
|
||||||
|
updated_at: task.updatedAt,
|
||||||
|
running_time: runningTime,
|
||||||
|
estimated_time,
|
||||||
|
error_message: task.errorMessage ?? undefined,
|
||||||
|
currentMessage: task.currentMessage ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
142
app/ascii-art/page.tsx
Normal file
142
app/ascii-art/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import figlet from "figlet";
|
||||||
|
// @ts-ignore
|
||||||
|
import standardFont from "figlet/importable-fonts/Standard.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import ghostFont from "figlet/importable-fonts/Ghost.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import slantFont from "figlet/importable-fonts/Slant.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import bubbleFont from "figlet/importable-fonts/Bubble.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import bannerFont from "figlet/importable-fonts/Banner.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import bigFont from "figlet/importable-fonts/Big.js";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Terminal, Copy } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
|
||||||
|
// Register fonts
|
||||||
|
figlet.parseFont("Standard", standardFont);
|
||||||
|
figlet.parseFont("Ghost", ghostFont);
|
||||||
|
figlet.parseFont("Slant", slantFont);
|
||||||
|
figlet.parseFont("Bubble", bubbleFont);
|
||||||
|
figlet.parseFont("Banner", bannerFont);
|
||||||
|
figlet.parseFont("Big", bigFont);
|
||||||
|
|
||||||
|
const FONTS = ["Standard", "Ghost", "Slant", "Bubble", "Banner", "Big"];
|
||||||
|
|
||||||
|
export default function AsciiArtPage() {
|
||||||
|
const [text, setText] = useState("i-Tools");
|
||||||
|
const [font, setFont] = useState("Standard");
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
figlet.text(
|
||||||
|
text,
|
||||||
|
{
|
||||||
|
font: font as any,
|
||||||
|
horizontalLayout: "default",
|
||||||
|
verticalLayout: "default",
|
||||||
|
width: 80,
|
||||||
|
whitespaceBreak: true,
|
||||||
|
},
|
||||||
|
function (err, data) {
|
||||||
|
if (err) {
|
||||||
|
console.log("Something went wrong...");
|
||||||
|
console.dir(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOutput(data || "");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [text, font]);
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(output);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-emerald-600 to-green-700 shadow-lg">
|
||||||
|
<Terminal className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">ASCII 艺术生成</h1>
|
||||||
|
<p className="text-muted-foreground">将文本转换为复古的字符画风格</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">设置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>输入文本</Label>
|
||||||
|
<Input
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Type something..."
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>字体风格</Label>
|
||||||
|
<Select value={font} onValueChange={setFont}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FONTS.map(f => (
|
||||||
|
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>预览结果</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" onClick={copyToClipboard} disabled={!output}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="relative rounded-lg bg-black p-4 overflow-x-auto">
|
||||||
|
<pre className="text-green-500 font-mono text-sm leading-none whitespace-pre select-all">
|
||||||
|
{output || "Generating..."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
app/barcode/page.tsx
Normal file
168
app/barcode/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Barcode from "react-barcode";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Barcode as BarcodeIcon, Download, RotateCcw } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
export default function BarcodePage() {
|
||||||
|
const [value, setValue] = useState("123456789012");
|
||||||
|
const [format, setFormat] = useState("CODE128");
|
||||||
|
const [width, setWidth] = useState(2);
|
||||||
|
const [height, setHeight] = useState(100);
|
||||||
|
const [displayValue, setDisplayValue] = useState(true);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const svg = document.querySelector("#barcode-svg svg");
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
const svgData = new XMLSerializer().serializeToString(svg);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
ctx?.drawImage(img, 0, 0);
|
||||||
|
const pngFile = canvas.toDataURL("image/png");
|
||||||
|
const downloadLink = document.createElement("a");
|
||||||
|
downloadLink.download = `barcode-${value}.png`;
|
||||||
|
downloadLink.href = pngFile;
|
||||||
|
downloadLink.click();
|
||||||
|
toast.success("条形码已下载");
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = "data:image/svg+xml;base64," + btoa(svgData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-zinc-700 to-slate-800 shadow-lg">
|
||||||
|
<BarcodeIcon className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">条形码生成</h1>
|
||||||
|
<p className="text-muted-foreground">生成 EAN, UPC, CODE128 等多种格式条形码</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-12">
|
||||||
|
<Card className="md:col-span-8 flex flex-col items-center justify-center min-h-125 p-8">
|
||||||
|
<div id="barcode-svg" className="p-8 bg-white rounded-xl shadow-xs border border-slate-100 overflow-x-auto max-w-full">
|
||||||
|
{value ? (
|
||||||
|
<Barcode
|
||||||
|
value={value}
|
||||||
|
format={format as any}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
displayValue={displayValue}
|
||||||
|
background="#ffffff"
|
||||||
|
lineColor="#000000"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground text-sm">请输入内容以生成条形码</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex gap-4">
|
||||||
|
<Button onClick={handleDownload} disabled={!value}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
下载 PNG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-4 h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>配置选项</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>条码内容</Label>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="例如:123456789"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>格式</Label>
|
||||||
|
<Select value={format} onValueChange={setFormat}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="CODE128">CODE128 (默认)</SelectItem>
|
||||||
|
<SelectItem value="CODE39">CODE39</SelectItem>
|
||||||
|
<SelectItem value="EAN13">EAN13</SelectItem>
|
||||||
|
<SelectItem value="UPC">UPC</SelectItem>
|
||||||
|
<SelectItem value="ITF14">ITF14</SelectItem>
|
||||||
|
<SelectItem value="MSI">MSI</SelectItem>
|
||||||
|
<SelectItem value="pharmacode">Pharmacode</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>宽度: {width}</Label>
|
||||||
|
<Input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="4"
|
||||||
|
step="0.5"
|
||||||
|
value={width}
|
||||||
|
onChange={(e) => setWidth(parseFloat(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>高度: {height}</Label>
|
||||||
|
<Input
|
||||||
|
type="range"
|
||||||
|
min="30"
|
||||||
|
max="200"
|
||||||
|
value={height}
|
||||||
|
onChange={(e) => setHeight(parseInt(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="displayValue"
|
||||||
|
checked={displayValue}
|
||||||
|
onChange={(e) => setDisplayValue(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
|
aria-label="显示文字"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="displayValue" className="cursor-pointer">显示文字</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={() => setValue("")} className="w-full">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" /> 重置
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 p-4 rounded-lg text-sm text-muted-foreground">
|
||||||
|
💡 注意:某些格式(如 EAN13)对输入内容的长度和校验位有特殊要求,如果不符合规范可能无法显示。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
app/base32/page.tsx
Normal file
155
app/base32/page.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Binary, Copy, Trash2, ArrowUpDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// RFC 4648 Base32 alphabet
|
||||||
|
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
const ALPHABET_MAP = ALPHABET.split('').reduce((map, char, index) => {
|
||||||
|
map[char] = index;
|
||||||
|
return map;
|
||||||
|
}, {} as { [key: string]: number });
|
||||||
|
|
||||||
|
export default function Base32Page() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const encode = () => {
|
||||||
|
try {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(input);
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
let output = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
value = (value << 8) | data[i];
|
||||||
|
bits += 8;
|
||||||
|
while (bits >= 5) {
|
||||||
|
output += ALPHABET[(value >>> (bits - 5)) & 31];
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0) {
|
||||||
|
output += ALPHABET[(value << (5 - bits)) & 31];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Padding
|
||||||
|
while (output.length % 8 !== 0) {
|
||||||
|
output += "=";
|
||||||
|
}
|
||||||
|
|
||||||
|
setInput(output);
|
||||||
|
toast.success("已编码为 Base32");
|
||||||
|
} catch {
|
||||||
|
toast.error("编码失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decode = () => {
|
||||||
|
try {
|
||||||
|
let val = input.toUpperCase().replace(/=+$/, "");
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
let index = 0;
|
||||||
|
const output = new Uint8Array((val.length * 5) / 8 | 0);
|
||||||
|
|
||||||
|
for (let i = 0; i < val.length; i++) {
|
||||||
|
if (!(val[i] in ALPHABET_MAP)) throw new Error("Invalid character");
|
||||||
|
value = (value << 5) | ALPHABET_MAP[val[i]];
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8) {
|
||||||
|
output[index++] = (value >>> (bits - 8)) & 0xFF;
|
||||||
|
bits -= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
setInput(decoder.decode(output));
|
||||||
|
toast.success("已解码为文本");
|
||||||
|
} catch {
|
||||||
|
toast.error("解码失败:无效的 Base32 字符串");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => setInput("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-orange-500 to-red-600 shadow-lg">
|
||||||
|
<Binary className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Base32 编解码</h1>
|
||||||
|
<p className="text-muted-foreground">RFC 4648 标准 Base32 文本转换</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base font-medium">输入/输出文本</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(input)} disabled={!input}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAll} disabled={!input} className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
placeholder="请输入需要编解码的内容..."
|
||||||
|
className="min-h-62.5 font-mono text-base resize-y"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Button onClick={encode} size="lg" className="flex-1 gap-2">
|
||||||
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
|
编码 (Encode)
|
||||||
|
</Button>
|
||||||
|
<Button onClick={decode} size="lg" variant="outline" className="flex-1 gap-2">
|
||||||
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
|
解码 (Decode)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> Base32 说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>Base32 使用 32 个字符的集合(A-Z 和 2-7)来表示二进制数据。它常用于无需区分大小写的文件系统或人工输入的场景。</p>
|
||||||
|
<p>本工具遵循 RFC 4648 标准。</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
app/base58/page.tsx
Normal file
171
app/base58/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Binary, Copy, Trash2, ArrowUpDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// Base58 alphabet (Bitcoin style)
|
||||||
|
const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
const ALPHABET_MAP = ALPHABET.split('').reduce((map, char, index) => {
|
||||||
|
map[char] = index;
|
||||||
|
return map;
|
||||||
|
}, {} as { [key: string]: number });
|
||||||
|
|
||||||
|
export default function Base58Page() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: Text -> Bytes
|
||||||
|
const textToBytes = (text: string) => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return encoder.encode(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: Bytes -> Text
|
||||||
|
const bytesToText = (bytes: Uint8Array) => {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(bytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encode
|
||||||
|
const encode = () => {
|
||||||
|
if (!input) return;
|
||||||
|
const bytes = textToBytes(input);
|
||||||
|
if (bytes.length === 0) return "";
|
||||||
|
|
||||||
|
let digits = [0];
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
for (let j = 0; j < digits.length; j++) digits[j] <<= 8;
|
||||||
|
digits[0] += bytes[i];
|
||||||
|
let carry = 0;
|
||||||
|
for (let j = 0; j < digits.length; ++j) {
|
||||||
|
digits[j] += carry;
|
||||||
|
carry = (digits[j] / 58) | 0;
|
||||||
|
digits[j] %= 58;
|
||||||
|
}
|
||||||
|
while (carry) {
|
||||||
|
digits.push(carry % 58);
|
||||||
|
carry = (carry / 58) | 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal with leading zeros
|
||||||
|
for (let i = 0; i < bytes.length && bytes[i] === 0; i++) digits.push(0);
|
||||||
|
|
||||||
|
const result = digits.reverse().map(d => ALPHABET[d]).join("");
|
||||||
|
setInput(result);
|
||||||
|
toast.success("已编码为 Base58");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decode
|
||||||
|
const decode = () => {
|
||||||
|
if (!input) return;
|
||||||
|
const bytes = [0];
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const c = input[i];
|
||||||
|
if (!(c in ALPHABET_MAP)) {
|
||||||
|
toast.error("无效的 Base58 字符");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let j = 0; j < bytes.length; j++) bytes[j] *= 58;
|
||||||
|
bytes[0] += ALPHABET_MAP[c];
|
||||||
|
let carry = 0;
|
||||||
|
for (let j = 0; j < bytes.length; ++j) {
|
||||||
|
bytes[j] += carry;
|
||||||
|
carry = bytes[j] >> 8;
|
||||||
|
bytes[j] &= 0xff;
|
||||||
|
}
|
||||||
|
while (carry) {
|
||||||
|
bytes.push(carry & 0xff);
|
||||||
|
carry >>= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal with leading zeros (represented by '1' in Base58 Bitcoin)
|
||||||
|
for (let i = 0; i < input.length && input[i] === '1'; i++) bytes.push(0);
|
||||||
|
|
||||||
|
const result = bytesToText(new Uint8Array(bytes.reverse()));
|
||||||
|
setInput(result);
|
||||||
|
toast.success("已解码为文本");
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => setInput("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-amber-500 to-orange-600 shadow-lg">
|
||||||
|
<Binary className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Base58 编解码</h1>
|
||||||
|
<p className="text-muted-foreground">比特币风格 Base58 文本转换</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base font-medium">输入/输出文本</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(input)} disabled={!input}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAll} disabled={!input} className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
placeholder="请输入需要编解码的内容..."
|
||||||
|
className="min-h-[250px] font-mono text-base resize-y"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Button onClick={encode} size="lg" className="flex-1 gap-2">
|
||||||
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
|
编码 (Encode)
|
||||||
|
</Button>
|
||||||
|
<Button onClick={decode} size="lg" variant="outline" className="flex-1 gap-2">
|
||||||
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
|
解码 (Decode)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> Base58 说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>Base58 是一种基于文本的二进制编码格式。它类似于 Base64,但去除了容易混淆的字符(0, O, I, l)以及非字母数字字符(+, /),使其更适合人工识别和复制。</p>
|
||||||
|
<p>本工具使用比特币标准的字母表:<code>123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz</code></p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
app/base64/page.tsx
Normal file
195
app/base64/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Lock, ArrowRightLeft, Copy, Eraser } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
||||||
|
export default function Base64Page() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
const [mode, setMode] = useState<"encode" | "decode">("encode");
|
||||||
|
|
||||||
|
const handleEncode = () => {
|
||||||
|
if (!input) {
|
||||||
|
toast.warning("请输入要编码的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const encoded = btoa(unescape(encodeURIComponent(input)));
|
||||||
|
setOutput(encoded);
|
||||||
|
toast.success("编码成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("编码失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecode = () => {
|
||||||
|
if (!input) {
|
||||||
|
toast.warning("请输入要解码的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(escape(atob(input)));
|
||||||
|
setOutput(decoded);
|
||||||
|
toast.success("解码成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("解码失败,请检查输入是否为有效的 Base64 字符串");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConvert = () => {
|
||||||
|
if (mode === "encode") {
|
||||||
|
handleEncode();
|
||||||
|
} else {
|
||||||
|
handleDecode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (!output) {
|
||||||
|
toast.warning("没有可复制的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(output);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
setInput("");
|
||||||
|
setOutput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const swapInputOutput = () => {
|
||||||
|
setInput(output);
|
||||||
|
setOutput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-emerald-500 to-emerald-600 shadow-lg">
|
||||||
|
<Lock className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Base64 编解码</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Base64 编码与解码转换工具,支持中文
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 bg-muted/50 p-4 rounded-lg border">
|
||||||
|
<Tabs value={mode} onValueChange={(v) => setMode(v as "encode" | "decode")}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="encode">编码</TabsTrigger>
|
||||||
|
<TabsTrigger value="decode">解码</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-auto sm:ml-0">
|
||||||
|
<Button onClick={handleConvert} className="gap-2">
|
||||||
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
|
{mode === "encode" ? "编码" : "解码"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={swapInputOutput} className="gap-2">
|
||||||
|
<ArrowRightLeft className="h-4 w-4 rotate-90" />
|
||||||
|
交换
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={clearAll} className="gap-2 text-destructive hover:text-destructive">
|
||||||
|
<Eraser className="h-4 w-4" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium">
|
||||||
|
{mode === "encode" ? "原始文本" : "Base64 字符串"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 min-h-75">
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
mode === "encode"
|
||||||
|
? "请输入要编码的文本..."
|
||||||
|
: "请输入要解码的 Base64 字符串..."
|
||||||
|
}
|
||||||
|
className="h-full min-h-75 font-mono resize-none"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-3 flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base font-medium">
|
||||||
|
{mode === "encode" ? "Base64 结果" : "解码结果"}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 min-h-75">
|
||||||
|
<Textarea
|
||||||
|
value={output}
|
||||||
|
readOnly
|
||||||
|
placeholder="转换结果将显示在这里..."
|
||||||
|
className="h-full min-h-75 font-mono resize-none bg-muted/50"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>Base64 是一种基于 64 个可打印字符来表示二进制数据的编码方式</li>
|
||||||
|
<li>常用于在 URL、Cookie 中传输少量二进制数据</li>
|
||||||
|
<li>本工具支持中文编码和解码</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>编码后的数据比原始数据大约 33%</li>
|
||||||
|
<li>Base64 不是加密算法,仅是一种编码方式</li>
|
||||||
|
<li>点击"交换"可将输出结果作为新的输入</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
app/bcrypt/page.tsx
Normal file
172
app/bcrypt/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Lock, CheckCircle, XCircle, Copy, Hash } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
|
||||||
|
export default function BcryptPage() {
|
||||||
|
const [plainText, setPlainText] = useState("");
|
||||||
|
const [saltRounds, setSaltRounds] = useState(10);
|
||||||
|
const [hashResult, setHashResult] = useState("");
|
||||||
|
|
||||||
|
const [verifyText, setVerifyText] = useState("");
|
||||||
|
const [verifyHash, setVerifyHash] = useState("");
|
||||||
|
const [isMatch, setIsMatch] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!plainText) return;
|
||||||
|
try {
|
||||||
|
const hash = await bcrypt.hash(plainText, saltRounds);
|
||||||
|
setHashResult(hash);
|
||||||
|
toast.success("哈希生成成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("生成失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerify = async () => {
|
||||||
|
if (!verifyText || !verifyHash) return;
|
||||||
|
try {
|
||||||
|
const match = await bcrypt.compare(verifyText, verifyHash);
|
||||||
|
setIsMatch(match);
|
||||||
|
if (match) {
|
||||||
|
toast.success("验证成功:匹配");
|
||||||
|
} else {
|
||||||
|
toast.error("验证失败:不匹配");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsMatch(false);
|
||||||
|
toast.error("验证过程出错,请检查哈希格式");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-slate-600 to-zinc-700 shadow-lg">
|
||||||
|
<Lock className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Bcrypt 哈希生成与验证</h1>
|
||||||
|
<p className="text-muted-foreground">生成安全的 Bcrypt 密码哈希或验证明文与哈希是否匹配</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Generate Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Hash className="h-5 w-5 text-primary" />
|
||||||
|
生成哈希
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>明文密码</Label>
|
||||||
|
<Input
|
||||||
|
value={plainText}
|
||||||
|
onChange={(e) => setPlainText(e.target.value)}
|
||||||
|
placeholder="请输入要加密的文本"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>Salt Rounds (强度): {saltRounds}</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">越高越慢</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[saltRounds]}
|
||||||
|
onValueChange={(v) => setSaltRounds(v[0])}
|
||||||
|
min={4}
|
||||||
|
max={16}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleGenerate} disabled={!plainText} className="w-full">
|
||||||
|
生成哈希
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hashResult && (
|
||||||
|
<div className="mt-4 p-3 bg-muted rounded-md break-all font-mono text-sm relative group">
|
||||||
|
{hashResult}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => copyToClipboard(hashResult)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Verify Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-emerald-500" />
|
||||||
|
验证哈希
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>明文密码</Label>
|
||||||
|
<Input
|
||||||
|
value={verifyText}
|
||||||
|
onChange={(e) => setVerifyText(e.target.value)}
|
||||||
|
placeholder="请输入明文"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Bcrypt 哈希值</Label>
|
||||||
|
<Input
|
||||||
|
value={verifyHash}
|
||||||
|
onChange={(e) => setVerifyHash(e.target.value)}
|
||||||
|
placeholder="$2a$10$..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleVerify} disabled={!verifyText || !verifyHash} variant="secondary" className="w-full">
|
||||||
|
验证匹配
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isMatch !== null && (
|
||||||
|
<div className={`mt-4 p-4 rounded-md flex items-center justify-center gap-2 font-bold ${isMatch ? 'bg-emerald-500/10 text-emerald-600' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{isMatch ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-5 w-5" /> 验证通过:匹配
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle className="h-5 w-5" /> 验证失败:不匹配
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
app/case-converter/page.tsx
Normal file
166
app/case-converter/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CaseSensitive, Copy, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function CaseConverterPage() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toUpper = () => setInput(input.toUpperCase());
|
||||||
|
const toLower = () => setInput(input.toLowerCase());
|
||||||
|
|
||||||
|
const toTitleCase = () => {
|
||||||
|
const result = input
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[\s_-]+/)
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
setInput(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toCamelCase = () => {
|
||||||
|
const words = input.toLowerCase().split(/[\s_-]+/);
|
||||||
|
const result = words[0] + words.slice(1).map(word => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
||||||
|
setInput(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPascalCase = () => {
|
||||||
|
const result = input
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[\s_-]+/)
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join("");
|
||||||
|
setInput(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSnakeCase = () => {
|
||||||
|
const result = input
|
||||||
|
.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
|
||||||
|
?.map(x => x.toLowerCase())
|
||||||
|
.join("_") || "";
|
||||||
|
setInput(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toKebabCase = () => {
|
||||||
|
const result = input
|
||||||
|
.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
|
||||||
|
?.map(x => x.toLowerCase())
|
||||||
|
.join("-") || "";
|
||||||
|
setInput(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => setInput("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-violet-500 to-purple-600 shadow-lg">
|
||||||
|
<CaseSensitive className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">文本大小写转换</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
大写、小写、驼峰、蛇形等格式快速转换
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base font-medium">输入文本</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(input)} disabled={!input}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAll} disabled={!input} className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
placeholder="请在此输入或粘贴需要转换的文本..."
|
||||||
|
className="min-h-50 font-mono text-base resize-y"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-medium">转换选项</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button type="button" disabled={!input} onClick={toUpper} variant="secondary" className="flex-1 min-w-35">
|
||||||
|
UPPER CASE (大写)
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={!input} onClick={toLower} variant="secondary" className="flex-1 min-w-35">
|
||||||
|
lower case (小写)
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={!input} onClick={toTitleCase} variant="secondary" className="flex-1 min-w-35">
|
||||||
|
Title Case (词首大写)
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={!input} onClick={toCamelCase} variant="secondary" className="flex-1 min-w-35">
|
||||||
|
camelCase (小驼峰)
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={!input} onClick={toPascalCase} variant="secondary" className="flex-1 min-w-35">
|
||||||
|
PascalCase (大驼峰)
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={!input} onClick={toSnakeCase} variant="secondary" className="flex-1 min-w-35">
|
||||||
|
snake_case (蛇形)
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={!input} onClick={toKebabCase} variant="secondary" className="flex-1 min-w-35">
|
||||||
|
kebab-case (短横线)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 常见转换说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-sm">Camel Case</p>
|
||||||
|
<p className="text-xs text-muted-foreground">第一个单词小写,后续单词首字母大写,如 `helloWorld`。</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-sm">Snake Case</p>
|
||||||
|
<p className="text-xs text-muted-foreground">所有字母小写,单词间用下划线连接,如 `hello_world`。</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-sm">Kebab Case</p>
|
||||||
|
<p className="text-xs text-muted-foreground">所有字母小写,单词间用连字符连接,如 `hello-world`。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
app/coin-flip/page.tsx
Normal file
87
app/coin-flip/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CircleDollarSign, RotateCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function CoinFlipPage() {
|
||||||
|
const [flipping, setFlipping] = useState(false);
|
||||||
|
const [result, setResult] = useState<"HEADS" | "TAILS" | null>(null);
|
||||||
|
|
||||||
|
const flip = () => {
|
||||||
|
if (flipping) return;
|
||||||
|
setFlipping(true);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
// Random outcome
|
||||||
|
const outcome = Math.random() > 0.5 ? "HEADS" : "TAILS";
|
||||||
|
|
||||||
|
// Simulate animation time
|
||||||
|
setTimeout(() => {
|
||||||
|
setResult(outcome);
|
||||||
|
setFlipping(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-amber-500 to-yellow-600 shadow-lg">
|
||||||
|
<CircleDollarSign className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">抛硬币</h1>
|
||||||
|
<p className="text-muted-foreground">随机正反面决策工具</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 space-y-12">
|
||||||
|
<div className="relative perspective-[1000px]">
|
||||||
|
<div className={cn(
|
||||||
|
"w-48 h-48 relative transition-transform duration-1000 transform-style-3d",
|
||||||
|
flipping && "animate-spin-slow-3d", // We need to define this animation or simulate it
|
||||||
|
!flipping && result === "HEADS" && "rotate-y-0",
|
||||||
|
!flipping && result === "TAILS" && "rotate-y-180",
|
||||||
|
)}>
|
||||||
|
{/* Front (Heads) */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute w-full h-full rounded-full bg-amber-400 border-4 border-amber-600 shadow-xl flex items-center justify-center backface-hidden",
|
||||||
|
// If result is tails, and not flipping, this side is hidden.
|
||||||
|
// But simplified: Just use CSS transforms.
|
||||||
|
)}>
|
||||||
|
<span className="text-5xl font-bold text-amber-800">正面</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back (Tails) */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute w-full h-full rounded-full bg-slate-300 border-4 border-slate-500 shadow-xl flex items-center justify-center backface-hidden rotate-y-180"
|
||||||
|
)}>
|
||||||
|
<span className="text-5xl font-bold text-slate-700">反面</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="h-8">
|
||||||
|
{result && !flipping && (
|
||||||
|
<span className="text-2xl font-bold animate-in fade-in zoom-in duration-300">
|
||||||
|
{result === "HEADS" ? "正面 (HEADS)" : "反面 (TAILS)"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{flipping && (
|
||||||
|
<span className="text-lg text-muted-foreground animate-pulse">
|
||||||
|
正在翻转...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="lg" onClick={flip} disabled={flipping} className="gap-2 text-lg px-8">
|
||||||
|
<RotateCw className={cn("h-5 w-5", flipping && "animate-spin")} />
|
||||||
|
{flipping ? "翻转中..." : "开始抛硬币"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/color-picker/page.tsx
Normal file
153
app/color-picker/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Palette, Copy, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function ColorPickerPage() {
|
||||||
|
const [color, setColor] = useState("#4F46E5");
|
||||||
|
const [formats, setFormats] = useState({
|
||||||
|
hex: "#4F46E5",
|
||||||
|
rgb: "rgb(79, 70, 229)",
|
||||||
|
hsl: "hsl(243, 75%, 59%)"
|
||||||
|
});
|
||||||
|
|
||||||
|
const hexToRgb = (hex: string) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rgbToHsl = (r: number, g: number, b: number) => {
|
||||||
|
r /= 255; g /= 255; b /= 255;
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||||
|
let h = 0, 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); break;
|
||||||
|
case g: h = (b - r) / d + 2; break;
|
||||||
|
case b: h = (r - g) / d + 4; break;
|
||||||
|
}
|
||||||
|
h /= 6;
|
||||||
|
}
|
||||||
|
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const rgb = hexToRgb(color);
|
||||||
|
if (rgb) {
|
||||||
|
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||||
|
setFormats({
|
||||||
|
hex: color.toUpperCase(),
|
||||||
|
rgb: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
|
||||||
|
hsl: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [color]);
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast.success(`已复制: ${text}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const randomColor = () => {
|
||||||
|
const randomHex = '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6, '0');
|
||||||
|
setColor(randomHex.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-pink-500 to-rose-600 shadow-lg">
|
||||||
|
<Palette className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">颜色选择器</h1>
|
||||||
|
<p className="text-muted-foreground">HEX、RGB、HSL 颜色格式互转</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base">选择颜色</CardTitle>
|
||||||
|
<Button variant="ghost" size="icon" onClick={randomColor}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div
|
||||||
|
className="w-full h-48 rounded-xl shadow-inner border border-white/20 transition-colors duration-200 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => setColor(e.target.value.toUpperCase())}
|
||||||
|
className="opacity-0 w-full h-full cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none font-mono font-bold text-2xl drop-shadow-md"
|
||||||
|
style={{ color: (hexToRgb(color)?.r || 0) * 0.299 + (hexToRgb(color)?.g || 0) * 0.587 + (hexToRgb(color)?.b || 0) * 0.114 > 186 ? 'black' : 'white' }}
|
||||||
|
>
|
||||||
|
{color}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label>手动输入 HEX</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (/^#[0-9A-F]{0,6}$/i.test(val)) setColor(val);
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">数值转换</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<FormatItem label="HEX" value={formats.hex} onCopy={() => copyToClipboard(formats.hex)} />
|
||||||
|
<FormatItem label="RGB" value={formats.rgb} onCopy={() => copyToClipboard(formats.rgb)} />
|
||||||
|
<FormatItem label="HSL" value={formats.hsl} onCopy={() => copyToClipboard(formats.hsl)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormatItem({ label, value, onCopy }: { label: string; value: string; onCopy: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input readOnly value={value} className="font-mono bg-muted/30" />
|
||||||
|
<Button variant="outline" size="icon" onClick={onCopy}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/components/ClientLayout.tsx
Normal file
54
app/components/ClientLayout.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Github, Home } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function ClientLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen font-sans antialiased flex flex-col">
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="container flex h-16 max-w-screen-xl items-center mx-auto px-4">
|
||||||
|
<div className="mr-8 hidden md:flex">
|
||||||
|
<Link href="/" className="mr-6 flex items-center space-x-2 transition-colors hover:text-primary/90">
|
||||||
|
<span className="hidden font-bold sm:inline-block text-xl tracking-tight">
|
||||||
|
信奥工具箱
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||||
|
<nav className="flex items-center space-x-6 text-sm font-medium">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="transition-colors hover:text-primary text-foreground/80 flex items-center gap-2 group"
|
||||||
|
>
|
||||||
|
<div className="p-1 rounded-md group-hover:bg-accent">
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span>首页</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1">
|
||||||
|
<div className="container max-w-screen-xl mx-auto py-8 px-4 lg:py-12">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="py-6 border-t bg-muted/30">
|
||||||
|
<div className="container max-w-screen-xl mx-auto px-4 flex flex-col items-center justify-center gap-4">
|
||||||
|
<p className="text-balance text-center text-sm leading-loose text-muted-foreground">
|
||||||
|
智创未来 © {new Date().getFullYear()} 信奥工具箱
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
app/components/Loading.tsx
Normal file
97
app/components/Loading.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkPageLoaded = () => {
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
setLoading(false)
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', checkPageLoaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('load', checkPageLoaded)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!loading) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="loading-overlay">
|
||||||
|
<div className="loading-container">
|
||||||
|
<div className="loading-wave">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div className="loading-text">加载中...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wave {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 40px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wave > div {
|
||||||
|
width: 8px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(45deg, #60a5fa, #3b82f6);
|
||||||
|
border-radius: 4px;
|
||||||
|
animation: wave 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wave > div:nth-child(2) { animation-delay: 0.1s; }
|
||||||
|
.loading-wave > div:nth-child(3) { animation-delay: 0.2s; }
|
||||||
|
.loading-wave > div:nth-child(4) { animation-delay: 0.3s; }
|
||||||
|
.loading-wave > div:nth-child(5) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: #3b82f6;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wave {
|
||||||
|
0%, 100% { transform: scaleY(0.5); }
|
||||||
|
50% { transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
app/components/ui/alert.tsx
Normal file
59
app/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
50
app/components/ui/avatar.tsx
Normal file
50
app/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
36
app/components/ui/badge.tsx
Normal file
36
app/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
57
app/components/ui/button.tsx
Normal file
57
app/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
213
app/components/ui/calendar.tsx
Normal file
213
app/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"bg-popover absolute inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"w-[--cell-size] select-none",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-muted-foreground select-none text-[0.8rem]",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"bg-accent rounded-l-md",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
76
app/components/ui/card.tsx
Normal file
76
app/components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
30
app/components/ui/checkbox.tsx
Normal file
30
app/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("grid place-content-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
122
app/components/ui/dialog.tsx
Normal file
122
app/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
178
app/components/ui/form.tsx
Normal file
178
app/components/ui/form.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormItem>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message ?? "") : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
22
app/components/ui/input.tsx
Normal file
22
app/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
26
app/components/ui/label.tsx
Normal file
26
app/components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
33
app/components/ui/popover.tsx
Normal file
33
app/components/ui/popover.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
159
app/components/ui/select.tsx
Normal file
159
app/components/ui/select.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
31
app/components/ui/separator.tsx
Normal file
31
app/components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
28
app/components/ui/slider.tsx
Normal file
28
app/components/ui/slider.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
))
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
32
app/components/ui/sonner.tsx
Normal file
32
app/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
position="top-center"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
29
app/components/ui/switch.tsx
Normal file
29
app/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
55
app/components/ui/tabs.tsx
Normal file
55
app/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
22
app/components/ui/textarea.tsx
Normal file
22
app/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
32
app/components/ui/tooltip.tsx
Normal file
32
app/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
156
app/countdown/page.tsx
Normal file
156
app/countdown/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Hourglass, Play, Pause, RotateCcw, BellRing } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function CountdownPage() {
|
||||||
|
const [inputMinutes, setInputMinutes] = useState("5");
|
||||||
|
const [timeLeft, setTimeLeft] = useState(300); // 5 minutes in seconds
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [finished, setFinished] = useState(false);
|
||||||
|
const timerRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (running && timeLeft > 0) {
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setTimeLeft((prev) => prev - 1);
|
||||||
|
}, 1000);
|
||||||
|
} else if (timeLeft === 0 && running) {
|
||||||
|
setRunning(false);
|
||||||
|
setFinished(true);
|
||||||
|
toast.success("时间到!", {
|
||||||
|
icon: <BellRing className="text-primary" />,
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
// Try to play a subtle beep if browser allowed
|
||||||
|
try {
|
||||||
|
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
const oscillator = audioCtx.createOscillator();
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(440, audioCtx.currentTime);
|
||||||
|
oscillator.connect(audioCtx.destination);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(audioCtx.currentTime + 0.5);
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
return () => clearInterval(timerRef.current);
|
||||||
|
}, [running, timeLeft]);
|
||||||
|
|
||||||
|
const startTimer = () => {
|
||||||
|
if (timeLeft === 0) {
|
||||||
|
const secs = parseInt(inputMinutes) * 60;
|
||||||
|
if (isNaN(secs) || secs <= 0) {
|
||||||
|
toast.error("请输入有效的分钟数");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeLeft(secs);
|
||||||
|
}
|
||||||
|
setRunning(true);
|
||||||
|
setFinished(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setRunning(false);
|
||||||
|
setFinished(false);
|
||||||
|
const secs = parseInt(inputMinutes) * 60 || 300;
|
||||||
|
setTimeLeft(secs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = timeLeft / (parseInt(inputMinutes) * 60 || 300);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-amber-500 to-orange-600 shadow-lg">
|
||||||
|
<Hourglass className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">倒数计时器</h1>
|
||||||
|
<p className="text-muted-foreground">设置并开始倒计时提醒</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-md mx-auto space-y-8">
|
||||||
|
<Card className="p-8 flex flex-col items-center space-y-8">
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
{/* Circular Progress SVG */}
|
||||||
|
<svg className="w-64 h-64 transform -rotate-90">
|
||||||
|
<circle
|
||||||
|
cx="128" cy="128" r="120"
|
||||||
|
stroke="currentColor" strokeWidth="8"
|
||||||
|
fill="transparent" className="text-muted/30"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="128" cy="128" r="120"
|
||||||
|
stroke="currentColor" strokeWidth="8"
|
||||||
|
fill="transparent"
|
||||||
|
strokeDasharray={2 * Math.PI * 120}
|
||||||
|
strokeDashoffset={2 * Math.PI * 120 * (1 - progress)}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="text-primary transition-all duration-1000 ease-linear"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute text-6xl font-mono font-bold tabular-nums",
|
||||||
|
finished && "animate-bounce text-primary"
|
||||||
|
)}>
|
||||||
|
{formatTime(timeLeft)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 w-full">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<span className="text-[10px] uppercase font-bold text-muted-foreground">分钟</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={inputMinutes}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputMinutes(e.target.value);
|
||||||
|
if (!running) setTimeLeft(parseInt(e.target.value) * 60 || 0);
|
||||||
|
}}
|
||||||
|
disabled={running}
|
||||||
|
className="text-center font-bold text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-5">
|
||||||
|
{!running ? (
|
||||||
|
<Button size="lg" className="h-12 w-12 rounded-full p-0" onClick={startTimer}>
|
||||||
|
<Play className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="lg" variant="outline" className="h-12 w-12 rounded-full p-0" onClick={() => setRunning(false)}>
|
||||||
|
<Pause className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="lg" variant="secondary" className="h-12 w-12 rounded-full p-0" onClick={handleReset}>
|
||||||
|
<RotateCcw className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{finished && (
|
||||||
|
<div className="p-4 rounded-xl bg-primary/10 border border-primary/20 text-center animate-in zoom-in duration-500">
|
||||||
|
<p className="font-bold text-primary flex items-center justify-center gap-2">
|
||||||
|
<BellRing className="h-5 w-5" />
|
||||||
|
时间到!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
app/counter/page.tsx
Normal file
79
app/counter/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Hash, Plus, Minus, RotateCcw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export default function CounterPage() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-zinc-500 to-slate-600 shadow-lg">
|
||||||
|
<Hash className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">计数器</h1>
|
||||||
|
<p className="text-muted-foreground">简单的数值计数工具</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 max-w-md mx-auto">
|
||||||
|
<Card className="text-center">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>当前数值</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
<div className="text-8xl font-bold tracking-tighter tabular-nums text-primary transition-all duration-200 scale-100 hover:scale-105">
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCount(prev => prev - step)}
|
||||||
|
className="h-16 w-16 rounded-full border-2"
|
||||||
|
>
|
||||||
|
<Minus className="h-8 w-8" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setCount(prev => prev + step)}
|
||||||
|
className="h-16 w-16 rounded-full shadow-lg hover:shadow-xl hover:-translate-y-1 transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="h-8 w-8" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-4 pt-4 border-t">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">步长:</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={step}
|
||||||
|
onChange={(e) => setStep(Number(e.target.value) || 1)}
|
||||||
|
className="w-20 text-center h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCount(0)}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-1" />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
app/cron/page.tsx
Normal file
116
app/cron/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Timer, Info } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function CronPage() {
|
||||||
|
const [expression, setExpression] = useState("*/5 * * * *");
|
||||||
|
|
||||||
|
const parts = expression.split(/\s+/);
|
||||||
|
const labels = ["分钟", "小时", "日期", "月份", "星期"];
|
||||||
|
|
||||||
|
const decodePart = (part: string, type: string) => {
|
||||||
|
if (!part) return "-";
|
||||||
|
if (part === "*") return `每${type}`;
|
||||||
|
if (part.includes("/")) {
|
||||||
|
const [start, step] = part.split("/");
|
||||||
|
return `每隔 ${step} ${type}${start !== "*" ? ` (从第 ${start} 开始)` : ""}`;
|
||||||
|
}
|
||||||
|
if (part.includes("-")) {
|
||||||
|
return `从 ${part.replace("-", " 到 ")} ${type}`;
|
||||||
|
}
|
||||||
|
if (part.includes(",")) {
|
||||||
|
return `第 ${part} ${type}`;
|
||||||
|
}
|
||||||
|
return `第 ${part} ${type}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-orange-500 to-red-600 shadow-lg">
|
||||||
|
<Timer className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Cron 表达式解析</h1>
|
||||||
|
<p className="text-muted-foreground">解析并说明 Cron 计划任务表达式</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">输入表达式</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Input
|
||||||
|
value={expression}
|
||||||
|
onChange={(e) => setExpression(e.target.value)}
|
||||||
|
placeholder="如: */5 * * * *"
|
||||||
|
className="font-mono text-lg"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => toast.success("解析成功")}>解析</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-5 gap-4">
|
||||||
|
{labels.map((label, i) => (
|
||||||
|
<Card key={label} className="bg-muted/30 border-none">
|
||||||
|
<CardContent className="pt-6 text-center space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground font-bold">{label}</p>
|
||||||
|
<p className="font-mono text-xl font-bold text-primary">{parts[i] || "*"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{decodePart(parts[i], label)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
解析说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="p-4 rounded-lg bg-primary/5 border border-primary/10">
|
||||||
|
<p className="text-lg">
|
||||||
|
该表达式意味着:
|
||||||
|
<span className="font-bold text-primary ml-2">
|
||||||
|
{parts.length >= 5
|
||||||
|
? labels.map((l, i) => decodePart(parts[i], l)).join(",")
|
||||||
|
: "请输入完整的 5 位 Cron 表达式"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm text-muted-foreground">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-bold text-foreground">*</p>
|
||||||
|
<p>匹配该字段的所有值</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-bold text-foreground">/n</p>
|
||||||
|
<p>指定数值增量 (每隔 n)</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-bold text-foreground">-</p>
|
||||||
|
<p>指定数值范围 (从 x 到 y)</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-bold text-foreground">,</p>
|
||||||
|
<p>指定多个值</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
app/csv-json/page.tsx
Normal file
144
app/csv-json/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FileSpreadsheet, Copy, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function CsvJsonPage() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const csvToJson = () => {
|
||||||
|
try {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
const lines = input.trim().split('\n');
|
||||||
|
if (lines.length < 2) {
|
||||||
|
toast.error("请输入至少包含标题和一行数据的 CSV");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = lines[0].split(',').map(h => h.trim());
|
||||||
|
const result = lines.slice(1).map(line => {
|
||||||
|
const values = line.split(',');
|
||||||
|
const obj: any = {};
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
obj[header] = values[i]?.trim() || "";
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
|
||||||
|
setOutput(JSON.stringify(result, null, 2));
|
||||||
|
toast.success("转换成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("CSV 格式错误");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonToCsv = () => {
|
||||||
|
try {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
const data = JSON.parse(input);
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
toast.error("JSON 必须是包含对象的数组");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const csvLines = [
|
||||||
|
headers.join(','),
|
||||||
|
...data.map(row => headers.map(h => row[h]).join(','))
|
||||||
|
];
|
||||||
|
|
||||||
|
setOutput(csvLines.join('\n'));
|
||||||
|
toast.success("转换成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("JSON 格式错误或不是对象数组");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
setInput("");
|
||||||
|
setOutput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-green-500 to-emerald-600 shadow-lg">
|
||||||
|
<FileSpreadsheet className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">CSV / JSON 互转</h1>
|
||||||
|
<p className="text-muted-foreground">表格数据与 JSON 格式相互转换</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base font-medium">输入区域</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAll} className="text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
placeholder="请在此粘贴 CSV (逗号分隔) 或 JSON 数组..."
|
||||||
|
className="min-h-75 font-mono text-sm resize-none bg-muted/30"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button onClick={csvToJson} className="flex-1 gap-2">
|
||||||
|
CSV → JSON
|
||||||
|
</Button>
|
||||||
|
<Button onClick={jsonToCsv} variant="outline" className="flex-1 gap-2">
|
||||||
|
JSON → CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base font-medium">输出结果</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(output)} disabled={!output}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
readOnly
|
||||||
|
placeholder="转换结果将显示在这里..."
|
||||||
|
className="min-h-87 font-mono text-sm resize-none bg-muted/30"
|
||||||
|
value={output}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">使用说明</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>1. <strong>CSV → JSON</strong>: 第一行将被视为标题(Key),后续行为数据内容。</p>
|
||||||
|
<p>2. <strong>JSON → CSV</strong>: JSON 必须是一个对象数组,第一个对象的属性名将作为 CSV 的标题。</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
app/diff/page.tsx
Normal file
174
app/diff/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { DiffEditor } from "@monaco-editor/react";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Diff, RotateCcw, ArrowRightLeft } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ value: "plaintext", label: "纯文本" },
|
||||||
|
{ value: "json", label: "JSON" },
|
||||||
|
{ value: "javascript", label: "JavaScript" },
|
||||||
|
{ value: "typescript", label: "TypeScript" },
|
||||||
|
{ value: "html", label: "HTML" },
|
||||||
|
{ value: "css", label: "CSS" },
|
||||||
|
{ value: "sql", label: "SQL" },
|
||||||
|
{ value: "xml", label: "XML" },
|
||||||
|
{ value: "yaml", label: "YAML" },
|
||||||
|
{ value: "markdown", label: "Markdown" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DiffPage() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [original, setOriginal] = useState("");
|
||||||
|
const [modified, setModified] = useState("");
|
||||||
|
const [language, setLanguage] = useState("plaintext");
|
||||||
|
// Default to side-by-side view (false means side-by-side in Monaco Diff Editor options usually, wait, let's just control options)
|
||||||
|
const [renderSideBySide, setRenderSideBySide] = useState(true);
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
if (confirm("确定要清空所有内容吗?")) {
|
||||||
|
setOriginal("");
|
||||||
|
setModified("");
|
||||||
|
toast.info("已清空");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwap = () => {
|
||||||
|
const temp = original;
|
||||||
|
setOriginal(modified);
|
||||||
|
setModified(temp);
|
||||||
|
toast.success("已交换原始内容和修改内容");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-slate-500 to-zinc-600 shadow-lg">
|
||||||
|
<Diff className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">文本 Diff 对比</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
比较两段文本的差异,支持代码高亮
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Language Selector */}
|
||||||
|
<Select value={language} onValueChange={setLanguage}>
|
||||||
|
<SelectTrigger className="w-35">
|
||||||
|
<SelectValue placeholder="选择语言" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LANGUAGES.map((lang) => (
|
||||||
|
<SelectItem key={lang.value} value={lang.value}>
|
||||||
|
{lang.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<Button
|
||||||
|
variant={renderSideBySide ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRenderSideBySide(true)}
|
||||||
|
className="hidden sm:flex"
|
||||||
|
>
|
||||||
|
左右对比
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={!renderSideBySide ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRenderSideBySide(false)}
|
||||||
|
className="hidden sm:flex"
|
||||||
|
>
|
||||||
|
行内对比
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Editor Area */}
|
||||||
|
<Card className="flex-1 h-150 flex flex-col overflow-hidden">
|
||||||
|
<CardHeader className="py-3 px-4 border-b bg-muted/30 flex flex-row items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-red-500/50"></span>
|
||||||
|
原始内容
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500/50"></span>
|
||||||
|
修改内容
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" onClick={handleSwap} title="交换内容">
|
||||||
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={handleClear} title="清空">
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 flex-1 relative h-full">
|
||||||
|
<DiffEditor
|
||||||
|
height="100%"
|
||||||
|
language={language}
|
||||||
|
original={original}
|
||||||
|
modified={modified}
|
||||||
|
onMount={() => {
|
||||||
|
// Determine initial values if needed, but state is controlled slightly differently in DiffEditor
|
||||||
|
// actually DiffEditor is better uncontrolled for values usually or we need to manage models.
|
||||||
|
// But @monaco-editor/react handles `original` and `modified` props updates well.
|
||||||
|
// Listening to changes is a bit more complex if we want 2-way binding,
|
||||||
|
// but for a diff tool, usually users paste into it.
|
||||||
|
// Alternatively, we can use the modifiedModel to get content changes if we really needed
|
||||||
|
// but for a simple comparison tool, just passing props is often enough IF we provide a way to input.
|
||||||
|
// WAIT. The DiffEditor is often read-only for the comparison result, OR editable.
|
||||||
|
// By default originalEditable: false.
|
||||||
|
|
||||||
|
// Let's set originalEditable: true so users can paste into both sides.
|
||||||
|
}}
|
||||||
|
theme={theme === 'dark' ? "vs-dark" : "light"}
|
||||||
|
options={{
|
||||||
|
renderSideBySide: renderSideBySide,
|
||||||
|
originalEditable: true, // Allow editing left side
|
||||||
|
readOnly: false, // Allow editing right side
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
fontSize: 14,
|
||||||
|
wordWrap: "on",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay hints if empty?
|
||||||
|
Monaco editor keeps state internally.
|
||||||
|
If we want to bind state, we might need a standard Editor first to input?
|
||||||
|
No, DiffEditor with `originalEditable: true` is fine for direct input.
|
||||||
|
*/}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 text-sm text-muted-foreground">
|
||||||
|
💡 提示:您可以直接在左右两侧编辑器中粘贴或输入文本进行通过。上方工具栏可切换语言高亮和对比模式。
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
app/globals.css
Normal file
166
app/globals.css
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--spacing-100: 25rem;
|
||||||
|
--spacing-125: 31.25rem;
|
||||||
|
--spacing-150: 37.5rem;
|
||||||
|
--spacing-175: 43.75rem;
|
||||||
|
--spacing-35: 8.75rem;
|
||||||
|
--spacing-45: 11.25rem;
|
||||||
|
--spacing-50: 12.5rem;
|
||||||
|
--spacing-75: 18.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.21 0.006 285.885);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.92 0.004 286.32);
|
||||||
|
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
}
|
||||||
|
.dark body {
|
||||||
|
background-image: radial-gradient(#1f2937 1px, transparent 1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.perspective-\[1000px\] {
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
.transform-style-3d {
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
.backface-hidden {
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
.rotate-y-0 {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
.rotate-y-180 {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
.animate-spin-slow-3d {
|
||||||
|
animation: spin-slow-3d 3s infinite linear;
|
||||||
|
}
|
||||||
|
@keyframes spin-slow-3d {
|
||||||
|
from {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotateY(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
337
app/hash/page.tsx
Normal file
337
app/hash/page.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Lock, FileDigit, Copy, Eraser, Zap } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
||||||
|
interface HashResult {
|
||||||
|
md5: string;
|
||||||
|
sha1: string;
|
||||||
|
sha256: string;
|
||||||
|
sha512: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HashPage() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [results, setResults] = useState<HashResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// MD5 Implementation preserved from original
|
||||||
|
const calculateMD5 = async (str: string): Promise<string> => {
|
||||||
|
const md5 = (string: string) => {
|
||||||
|
const rotateLeft = (x: number, n: number) => (x << n) | (x >>> (32 - n));
|
||||||
|
const addUnsigned = (x: number, y: number) => {
|
||||||
|
const x4 = x & 0x80000000;
|
||||||
|
const y4 = y & 0x80000000;
|
||||||
|
const x8 = x & 0x40000000;
|
||||||
|
const y8 = y & 0x40000000;
|
||||||
|
const result = (x & 0x3fffffff) + (y & 0x3fffffff);
|
||||||
|
if (x8 & y8) return result ^ 0x80000000 ^ x4 ^ y4;
|
||||||
|
if (x8 | y8) {
|
||||||
|
if (result & 0x40000000) return result ^ 0xc0000000 ^ x4 ^ y4;
|
||||||
|
else return result ^ 0x40000000 ^ x4 ^ y4;
|
||||||
|
} else {
|
||||||
|
return result ^ x4 ^ y4;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const F = (x: number, y: number, z: number) => (x & y) | (~x & z);
|
||||||
|
const G = (x: number, y: number, z: number) => (x & z) | (y & ~z);
|
||||||
|
const H = (x: number, y: number, z: number) => x ^ y ^ z;
|
||||||
|
const I = (x: number, y: number, z: number) => y ^ (x | ~z);
|
||||||
|
|
||||||
|
const FF = (a: number, b: number, c: number, d: number, x: number, s: number, ac: number) =>
|
||||||
|
addUnsigned(rotateLeft(addUnsigned(addUnsigned(a, F(b, c, d)), addUnsigned(x, ac)), s), b);
|
||||||
|
const GG = (a: number, b: number, c: number, d: number, x: number, s: number, ac: number) =>
|
||||||
|
addUnsigned(rotateLeft(addUnsigned(addUnsigned(a, G(b, c, d)), addUnsigned(x, ac)), s), b);
|
||||||
|
const HH = (a: number, b: number, c: number, d: number, x: number, s: number, ac: number) =>
|
||||||
|
addUnsigned(rotateLeft(addUnsigned(addUnsigned(a, H(b, c, d)), addUnsigned(x, ac)), s), b);
|
||||||
|
const II = (a: number, b: number, c: number, d: number, x: number, s: number, ac: number) =>
|
||||||
|
addUnsigned(rotateLeft(addUnsigned(addUnsigned(a, I(b, c, d)), addUnsigned(x, ac)), s), b);
|
||||||
|
|
||||||
|
const convertToWordArray = (str: string) => {
|
||||||
|
let lWordCount;
|
||||||
|
const lMessageLength = str.length;
|
||||||
|
const lNumberOfWords = (((lMessageLength + 8) - ((lMessageLength + 8) % 64)) / 64 + 1) * 16;
|
||||||
|
const lWordArray = Array(lNumberOfWords - 1).fill(0);
|
||||||
|
let lBytePosition = 0;
|
||||||
|
let lByteCount = 0;
|
||||||
|
while (lByteCount < lMessageLength) {
|
||||||
|
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
|
||||||
|
lBytePosition = (lByteCount % 4) * 8;
|
||||||
|
lWordArray[lWordCount] = lWordArray[lWordCount] | (str.charCodeAt(lByteCount) << lBytePosition);
|
||||||
|
lByteCount++;
|
||||||
|
}
|
||||||
|
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
|
||||||
|
lBytePosition = (lByteCount % 4) * 8;
|
||||||
|
lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition);
|
||||||
|
lWordArray[lNumberOfWords - 2] = lMessageLength << 3;
|
||||||
|
lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29;
|
||||||
|
return lWordArray;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wordToHex = (lValue: number) => {
|
||||||
|
let result = "";
|
||||||
|
for (let lCount = 0; lCount <= 3; lCount++) {
|
||||||
|
const lByte = (lValue >>> (lCount * 8)) & 255;
|
||||||
|
result += ("0" + lByte.toString(16)).slice(-2);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const utf8Encode = (str: string) => {
|
||||||
|
return unescape(encodeURIComponent(str));
|
||||||
|
};
|
||||||
|
|
||||||
|
const x = convertToWordArray(utf8Encode(string));
|
||||||
|
let a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476;
|
||||||
|
|
||||||
|
for (let k = 0; k < x.length; k += 16) {
|
||||||
|
const AA = a, BB = b, CC = c, DD = d;
|
||||||
|
a = FF(a, b, c, d, x[k], 7, 0xd76aa478);
|
||||||
|
d = FF(d, a, b, c, x[k + 1], 12, 0xe8c7b756);
|
||||||
|
c = FF(c, d, a, b, x[k + 2], 17, 0x242070db);
|
||||||
|
b = FF(b, c, d, a, x[k + 3], 22, 0xc1bdceee);
|
||||||
|
a = FF(a, b, c, d, x[k + 4], 7, 0xf57c0faf);
|
||||||
|
d = FF(d, a, b, c, x[k + 5], 12, 0x4787c62a);
|
||||||
|
c = FF(c, d, a, b, x[k + 6], 17, 0xa8304613);
|
||||||
|
b = FF(b, c, d, a, x[k + 7], 22, 0xfd469501);
|
||||||
|
a = FF(a, b, c, d, x[k + 8], 7, 0x698098d8);
|
||||||
|
d = FF(d, a, b, c, x[k + 9], 12, 0x8b44f7af);
|
||||||
|
c = FF(c, d, a, b, x[k + 10], 17, 0xffff5bb1);
|
||||||
|
b = FF(b, c, d, a, x[k + 11], 22, 0x895cd7be);
|
||||||
|
a = FF(a, b, c, d, x[k + 12], 7, 0x6b901122);
|
||||||
|
d = FF(d, a, b, c, x[k + 13], 12, 0xfd987193);
|
||||||
|
c = FF(c, d, a, b, x[k + 14], 17, 0xa679438e);
|
||||||
|
b = FF(b, c, d, a, x[k + 15], 22, 0x49b40821);
|
||||||
|
a = GG(a, b, c, d, x[k + 1], 5, 0xf61e2562);
|
||||||
|
d = GG(d, a, b, c, x[k + 6], 9, 0xc040b340);
|
||||||
|
c = GG(c, d, a, b, x[k + 11], 14, 0x265e5a51);
|
||||||
|
b = GG(b, c, d, a, x[k], 20, 0xe9b6c7aa);
|
||||||
|
a = GG(a, b, c, d, x[k + 5], 5, 0xd62f105d);
|
||||||
|
d = GG(d, a, b, c, x[k + 10], 9, 0x2441453);
|
||||||
|
c = GG(c, d, a, b, x[k + 15], 14, 0xd8a1e681);
|
||||||
|
b = GG(b, c, d, a, x[k + 4], 20, 0xe7d3fbc8);
|
||||||
|
a = GG(a, b, c, d, x[k + 9], 5, 0x21e1cde6);
|
||||||
|
d = GG(d, a, b, c, x[k + 14], 9, 0xc33707d6);
|
||||||
|
c = GG(c, d, a, b, x[k + 3], 14, 0xf4d50d87);
|
||||||
|
b = GG(b, c, d, a, x[k + 8], 20, 0x455a14ed);
|
||||||
|
a = GG(a, b, c, d, x[k + 13], 5, 0xa9e3e905);
|
||||||
|
d = GG(d, a, b, c, x[k + 2], 9, 0xfcefa3f8);
|
||||||
|
c = GG(c, d, a, b, x[k + 7], 14, 0x676f02d9);
|
||||||
|
b = GG(b, c, d, a, x[k + 12], 20, 0x8d2a4c8a);
|
||||||
|
a = HH(a, b, c, d, x[k + 5], 4, 0xfffa3942);
|
||||||
|
d = HH(d, a, b, c, x[k + 8], 11, 0x8771f681);
|
||||||
|
c = HH(c, d, a, b, x[k + 11], 16, 0x6d9d6122);
|
||||||
|
b = HH(b, c, d, a, x[k + 14], 23, 0xfde5380c);
|
||||||
|
a = HH(a, b, c, d, x[k + 1], 4, 0xa4beea44);
|
||||||
|
d = HH(d, a, b, c, x[k + 4], 11, 0x4bdecfa9);
|
||||||
|
c = HH(c, d, a, b, x[k + 7], 16, 0xf6bb4b60);
|
||||||
|
b = HH(b, c, d, a, x[k + 10], 23, 0xbebfbc70);
|
||||||
|
a = HH(a, b, c, d, x[k + 13], 4, 0x289b7ec6);
|
||||||
|
d = HH(d, a, b, c, x[k], 11, 0xeaa127fa);
|
||||||
|
c = HH(c, d, a, b, x[k + 3], 16, 0xd4ef3085);
|
||||||
|
b = HH(b, c, d, a, x[k + 6], 23, 0x4881d05);
|
||||||
|
a = HH(a, b, c, d, x[k + 9], 4, 0xd9d4d039);
|
||||||
|
d = HH(d, a, b, c, x[k + 12], 11, 0xe6db99e5);
|
||||||
|
c = HH(c, d, a, b, x[k + 15], 16, 0x1fa27cf8);
|
||||||
|
b = HH(b, c, d, a, x[k + 2], 23, 0xc4ac5665);
|
||||||
|
a = II(a, b, c, d, x[k], 6, 0xf4292244);
|
||||||
|
d = II(d, a, b, c, x[k + 7], 10, 0x432aff97);
|
||||||
|
c = II(c, d, a, b, x[k + 14], 15, 0xab9423a7);
|
||||||
|
b = II(b, c, d, a, x[k + 5], 21, 0xfc93a039);
|
||||||
|
a = II(a, b, c, d, x[k + 12], 6, 0x655b59c3);
|
||||||
|
d = II(d, a, b, c, x[k + 3], 10, 0x8f0ccc92);
|
||||||
|
c = II(c, d, a, b, x[k + 10], 15, 0xffeff47d);
|
||||||
|
b = II(b, c, d, a, x[k + 1], 21, 0x85845dd1);
|
||||||
|
a = II(a, b, c, d, x[k + 8], 6, 0x6fa87e4f);
|
||||||
|
d = II(d, a, b, c, x[k + 15], 10, 0xfe2ce6e0);
|
||||||
|
c = II(c, d, a, b, x[k + 6], 15, 0xa3014314);
|
||||||
|
b = II(b, c, d, a, x[k + 13], 21, 0x4e0811a1);
|
||||||
|
a = II(a, b, c, d, x[k + 4], 6, 0xf7537e82);
|
||||||
|
d = II(d, a, b, c, x[k + 11], 10, 0xbd3af235);
|
||||||
|
c = II(c, d, a, b, x[k + 2], 15, 0x2ad7d2bb);
|
||||||
|
b = II(b, c, d, a, x[k + 9], 21, 0xeb86d391);
|
||||||
|
a = addUnsigned(a, AA);
|
||||||
|
b = addUnsigned(b, BB);
|
||||||
|
c = addUnsigned(c, CC);
|
||||||
|
d = addUnsigned(d, DD);
|
||||||
|
}
|
||||||
|
return wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d);
|
||||||
|
};
|
||||||
|
return md5(str);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateHash = async () => {
|
||||||
|
if (!input) {
|
||||||
|
toast.warning("请输入要计算哈希的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(input);
|
||||||
|
|
||||||
|
const [sha1, sha256, sha512] = await Promise.all([
|
||||||
|
crypto.subtle.digest("SHA-1", data),
|
||||||
|
crypto.subtle.digest("SHA-256", data),
|
||||||
|
crypto.subtle.digest("SHA-512", data),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const md5Hash = await calculateMD5(input);
|
||||||
|
|
||||||
|
const arrayBufferToHex = (buffer: ArrayBuffer): string => {
|
||||||
|
return Array.from(new Uint8Array(buffer))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
setResults({
|
||||||
|
md5: md5Hash,
|
||||||
|
sha1: arrayBufferToHex(sha1),
|
||||||
|
sha256: arrayBufferToHex(sha256),
|
||||||
|
sha512: arrayBufferToHex(sha512),
|
||||||
|
});
|
||||||
|
toast.success("计算完成");
|
||||||
|
} catch {
|
||||||
|
toast.error("计算失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, name: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(`${name} 已复制`);
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
setInput("");
|
||||||
|
setResults(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HashResultItem = ({ label, value }: { label: string; value: string }) => (
|
||||||
|
<div className="rounded-lg bg-muted p-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm">{label}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 gap-1"
|
||||||
|
onClick={() => copyToClipboard(value, label)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
<span className="text-xs">复制</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<code className="block break-all font-mono text-sm leading-relaxed bg-background/50 p-2 rounded border">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-red-500 to-rose-600 shadow-lg">
|
||||||
|
<FileDigit className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Hash 计算</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
计算文本的 MD5、SHA1、SHA256、SHA512 哈希值
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="h-full flex flex-col">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||||
|
<CardTitle className="text-base font-medium">输入文本</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={calculateHash} disabled={loading || !input} className="gap-2" size="sm">
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
{loading ? "计算中..." : "计算哈希"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={clearAll} className="h-8 w-8 text-destructive">
|
||||||
|
<Eraser className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 min-h-100">
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="请输入要计算哈希值的文本..."
|
||||||
|
className="h-full min-h-75 resize-none font-mono"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
哈希结果
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1">
|
||||||
|
{results ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<HashResultItem label="MD5 (32位)" value={results.md5} />
|
||||||
|
<HashResultItem label="SHA1 (40位)" value={results.sha1} />
|
||||||
|
<HashResultItem label="SHA256 (64位)" value={results.sha256} />
|
||||||
|
<HashResultItem label="SHA512 (128位)" value={results.sha512} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full min-h-75 text-muted-foreground">
|
||||||
|
<FileDigit className="h-16 w-16 mb-4 opacity-20" />
|
||||||
|
<p>输入文本后点击计算哈希</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>MD5 产生 128 位(16 字节)哈希值,已不推荐用于安全场景</li>
|
||||||
|
<li>SHA1 产生 160 位(20 字节)哈希值</li>
|
||||||
|
<li>SHA256 产生 256 位(32 字节)哈希值,安全性较高</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>SHA512 产生 512 位(64 字节)哈希值,安全性最高</li>
|
||||||
|
<li>哈希函数是单向的,无法从哈希值反推原文</li>
|
||||||
|
<li>相同输入总是产生相同输出</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
app/html-escape/page.tsx
Normal file
113
app/html-escape/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Braces, Copy, Trash2, ArrowUpDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function HtmlEscapePage() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHtml = () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = input;
|
||||||
|
setInput(div.innerHTML);
|
||||||
|
toast.success("转义成功");
|
||||||
|
};
|
||||||
|
|
||||||
|
const unescapeHtml = () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = input;
|
||||||
|
setInput(div.textContent || "");
|
||||||
|
toast.success("反转义成功");
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => setInput("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-emerald-500 to-teal-600 shadow-lg">
|
||||||
|
<Braces className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">HTML 转义</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
对 HTML 实体字符进行编码和解码转换
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base font-medium">输入/输出文本</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(input)} disabled={!input}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAll} disabled={!input} className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
placeholder="请输入需要处理的 HTML 代码或转义字符串..."
|
||||||
|
className="min-h-[250px] font-mono text-base resize-y"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Button onClick={escapeHtml} size="lg" className="flex-1 gap-2">
|
||||||
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
|
HTML 转义 (Escape)
|
||||||
|
</Button>
|
||||||
|
<Button onClick={unescapeHtml} size="lg" variant="outline" className="flex-1 gap-2">
|
||||||
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
|
HTML 反转义 (Unescape)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 什么是 HTML 转义?
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>HTML 转义是将 HTML 预留字符转换为 HTML 实体。例如,将 `<` 转换为 `&lt;`。</p>
|
||||||
|
<p>这通常用于在网页上安全地显示代码片段,防止浏览器将其解析为实际的 HTML 标签,或者为了防止跨站脚本(XSS)攻击。</p>
|
||||||
|
<p className="font-mono bg-muted p-2 rounded-md inline-block">
|
||||||
|
< → &lt;<br />
|
||||||
|
> → &gt;<br />
|
||||||
|
& → &amp;<br />
|
||||||
|
" → &quot;
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
app/html-formatter/page.tsx
Normal file
155
app/html-formatter/page.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Editor from "@monaco-editor/react";
|
||||||
|
import { html_beautify } from "js-beautify";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FileCode, Copy, RotateCcw, ArrowRight } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function HtmlFormatterPage() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
const [indentSize, setIndentSize] = useState("2");
|
||||||
|
const [wrapLineLength, setWrapLineLength] = useState("80");
|
||||||
|
|
||||||
|
const handleFormat = () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
try {
|
||||||
|
const formatted = html_beautify(input, {
|
||||||
|
indent_size: parseInt(indentSize),
|
||||||
|
wrap_line_length: parseInt(wrapLineLength),
|
||||||
|
preserve_newlines: true,
|
||||||
|
indent_inner_html: true,
|
||||||
|
});
|
||||||
|
setOutput(formatted);
|
||||||
|
toast.success("HTML 格式化成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("HTML 格式化失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-orange-500 to-red-600 shadow-lg">
|
||||||
|
<FileCode className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">HTML 格式化</h1>
|
||||||
|
<p className="text-muted-foreground">美化和清理 HTML 代码</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 flex flex-wrap gap-6 items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>缩进大小</Label>
|
||||||
|
<Select value={indentSize} onValueChange={setIndentSize}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="2">2 空格</SelectItem>
|
||||||
|
<SelectItem value="4">4 空格</SelectItem>
|
||||||
|
<SelectItem value="8">8 空格</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>换行宽度</Label>
|
||||||
|
<Select value={wrapLineLength} onValueChange={setWrapLineLength}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">不换行</SelectItem>
|
||||||
|
<SelectItem value="80">80 字符</SelectItem>
|
||||||
|
<SelectItem value="120">120 字符</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleFormat} className="gap-2">
|
||||||
|
格式化 <ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Input */}
|
||||||
|
<Card className="flex flex-col min-h-150">
|
||||||
|
<CardHeader className="py-3 px-4 border-b flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-medium">输入 HTML</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setInput("")}>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 flex-1 h-125">
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="html"
|
||||||
|
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||||
|
value={input}
|
||||||
|
onChange={(value) => setInput(value || "")}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
wordWrap: "on"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Output */}
|
||||||
|
<Card className="flex flex-col min-h-150">
|
||||||
|
<CardHeader className="py-3 px-4 border-b flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-medium">结果</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(output)} disabled={!output}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 flex-1 bg-muted/30 h-125">
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="html"
|
||||||
|
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||||
|
value={output}
|
||||||
|
options={{
|
||||||
|
readOnly: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
wordWrap: "on"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
app/image-base64/page.tsx
Normal file
148
app/image-base64/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ImagePlus, Copy, Trash2, Download, Upload, FileImage } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function ImageBase64Page() {
|
||||||
|
const [imageBase64, setImageBase64] = useState("");
|
||||||
|
const [preview, setPreview] = useState("");
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
toast.error("请选择有效的图片文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const result = event.target?.result as string;
|
||||||
|
setImageBase64(result);
|
||||||
|
setPreview(result);
|
||||||
|
toast.success("图片已转为 Base64");
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBase64Change = (val: string) => {
|
||||||
|
setImageBase64(val);
|
||||||
|
if (val.startsWith("data:image/")) {
|
||||||
|
setPreview(val);
|
||||||
|
} else {
|
||||||
|
setPreview("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (!imageBase64) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(imageBase64);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadImage = () => {
|
||||||
|
if (!preview) return;
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = preview;
|
||||||
|
link.download = "downloaded_image";
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-purple-500 to-indigo-600 shadow-lg">
|
||||||
|
<ImagePlus className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">图片 Base64 互转</h1>
|
||||||
|
<p className="text-muted-foreground">图片文件与 Base64 字符串相互转换</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">图片转 Base64</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col items-center justify-center border-2 border-dashed rounded-xl p-8 bg-muted/30 hover:bg-muted/50 transition-colors relative">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Upload className="h-10 w-10 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground">点击或拖拽图片文件到此处</p>
|
||||||
|
<p className="text-xs text-muted-foreground/60 mt-1">支持 JPG, PNG, GIF, WebP 等</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base">Base64 字符串</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={copyToClipboard} disabled={!imageBase64}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => {setImageBase64(""); setPreview("");}} disabled={!imageBase64} className="text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
placeholder="在此输入 Base64 字符串 (包含 data:image/... 前缀)..."
|
||||||
|
className="min-h-[250px] font-mono text-xs break-all"
|
||||||
|
value={imageBase64}
|
||||||
|
onChange={(e) => handleBase64Change(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="flex flex-col h-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base">预览结果</CardTitle>
|
||||||
|
{preview && (
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadImage}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
下载图片
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex items-center justify-center bg-muted/20 rounded-b-xl p-6 min-h-[400px]">
|
||||||
|
{preview ? (
|
||||||
|
<div className="relative group max-w-full">
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt="Preview"
|
||||||
|
className="max-w-full max-h-[600px] rounded-lg shadow-lg object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground italic space-y-2">
|
||||||
|
<FileImage className="h-12 w-12 mx-auto opacity-10" />
|
||||||
|
<p>等待图片上传或 Base64 输入</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
app/image-to-pixel/page.tsx
Normal file
156
app/image-to-pixel/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { ImageIcon, Upload, Download, RefreshCw } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function ImageToPixelPage() {
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [pixelSize, setPixelSize] = useState(10);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
setImageSrc(event.target?.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (imageSrc && canvasRef.current) {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.src = imageSrc;
|
||||||
|
img.onload = () => {
|
||||||
|
// Setup canvas size
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
|
||||||
|
// Draw original image
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
// Pixelate
|
||||||
|
if (pixelSize > 1) {
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
|
||||||
|
// Disable image smoothing for pixelation effect
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
// Calculate smaller dimensions
|
||||||
|
const sw = w / pixelSize;
|
||||||
|
const sh = h / pixelSize;
|
||||||
|
|
||||||
|
// Draw small image
|
||||||
|
ctx.drawImage(canvas, 0, 0, w, h, 0, 0, sw, sh);
|
||||||
|
|
||||||
|
// Draw back scaled up
|
||||||
|
ctx.drawImage(canvas, 0, 0, sw, sh, 0, 0, w, h);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [imageSrc, pixelSize]);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (canvasRef.current) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = "pixel-art.png";
|
||||||
|
link.href = canvasRef.current.toDataURL();
|
||||||
|
link.click();
|
||||||
|
toast.success("图片已下载");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-pink-500 to-rose-600 shadow-lg">
|
||||||
|
<ImageIcon className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">图片转像素画</h1>
|
||||||
|
<p className="text-muted-foreground">将普通图片转换为像素艺术风格</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
|
<Card className="lg:col-span-8 flex flex-col items-center justify-center min-h-100 p-6 bg-muted/20">
|
||||||
|
{imageSrc ? (
|
||||||
|
<div className="relative max-w-full overflow-hidden shadow-xl rounded-lg border-4 border-white/50">
|
||||||
|
<canvas ref={canvasRef} className="max-w-full h-auto block" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="h-32 w-32 bg-muted rounded-full flex items-center justify-center mx-auto border-4 border-dashed border-muted-foreground/30">
|
||||||
|
<ImageIcon className="h-12 w-12 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">请选择一张图片</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">支持 JPG, PNG, WEBP 格式</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
上传图片
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
accept="image/*"
|
||||||
|
aria-label="上传图片"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-4 h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>调整与导出</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>像素点大小: {pixelSize}px</Label>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[pixelSize]}
|
||||||
|
onValueChange={(v) => setPixelSize(v[0])}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
step={1}
|
||||||
|
disabled={!imageSrc}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
数值越大,像素颗粒感越强,画面越抽象。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button onClick={handleDownload} disabled={!imageSrc} className="w-full">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
下载结果
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setImageSrc(null)} disabled={!imageSrc} className="w-full">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
重新上传
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
app/ip-calc/page.tsx
Normal file
135
app/ip-calc/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Network, Search, Calculator } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function IpCalcPage() {
|
||||||
|
const [ip, setIp] = useState("192.168.1.1");
|
||||||
|
const [mask, setMask] = useState("24");
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
|
||||||
|
const calculate = () => {
|
||||||
|
try {
|
||||||
|
const parts = ip.split('.').map(Number);
|
||||||
|
if (parts.length !== 4 || parts.some(p => p < 0 || p > 255 || isNaN(p))) {
|
||||||
|
toast.error("无效的 IP 地址");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskNum = parseInt(mask);
|
||||||
|
if (isNaN(maskNum) || maskNum < 0 || maskNum > 32) {
|
||||||
|
toast.error("无效的掩码 (0-32)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert IP to 32-bit number
|
||||||
|
const ipInt = (parts[0] << 24) >>> 0 | (parts[1] << 16) >>> 0 | (parts[2] << 8) >>> 0 | parts[3] >>> 0;
|
||||||
|
|
||||||
|
// Calculate Mask
|
||||||
|
const maskInt = maskNum === 0 ? 0 : (~0 << (32 - maskNum)) >>> 0;
|
||||||
|
|
||||||
|
// Network & Broadcast
|
||||||
|
const netInt = (ipInt & maskInt) >>> 0;
|
||||||
|
const broadInt = (netInt | ~maskInt) >>> 0;
|
||||||
|
|
||||||
|
const intToIp = (i: number) => [
|
||||||
|
(i >>> 24) & 0xFF,
|
||||||
|
(i >>> 16) & 0xFF,
|
||||||
|
(i >>> 8) & 0xFF,
|
||||||
|
i & 0xFF
|
||||||
|
].join('.');
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
address: ip,
|
||||||
|
netmask: intToIp(maskInt),
|
||||||
|
wildcard: intToIp(~maskInt >>> 0),
|
||||||
|
network: intToIp(netInt),
|
||||||
|
broadcast: intToIp(broadInt),
|
||||||
|
hostMin: intToIp(netInt + 1),
|
||||||
|
hostMax: intToIp(broadInt - 1),
|
||||||
|
hosts: maskNum >= 31 ? 0 : Math.pow(2, 32 - maskNum) - 2,
|
||||||
|
cidr: `/${maskNum}`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
toast.error("计算出错");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-blue-500 to-indigo-600 shadow-lg">
|
||||||
|
<Network className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">IP 地址计算</h1>
|
||||||
|
<p className="text-muted-foreground">子网掩码、网络地址、可用主机范围计算</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">网络配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>IP 地址</Label>
|
||||||
|
<Input value={ip} onChange={(e) => setIp(e.target.value)} placeholder="如: 192.168.1.1" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>掩码位 (CIDR)</Label>
|
||||||
|
<Input value={mask} onChange={(e) => setMask(e.target.value)} placeholder="如: 24" type="number" />
|
||||||
|
</div>
|
||||||
|
<Button onClick={calculate} className="w-full gap-2 mt-2">
|
||||||
|
<Calculator className="h-4 w-4" />
|
||||||
|
立即计算
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">计算结果</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!result ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<Search className="h-12 w-12 mb-2 opacity-20" />
|
||||||
|
<p>请输入配置并点击计算</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-8">
|
||||||
|
<ResultItem label="网络地址" value={result.network} cidr={result.cidr} />
|
||||||
|
<ResultItem label="广播地址" value={result.broadcast} />
|
||||||
|
<ResultItem label="子网掩码" value={result.netmask} />
|
||||||
|
<ResultItem label="反掩码 (Wildcard)" value={result.wildcard} />
|
||||||
|
<ResultItem label="可用主机最小" value={result.hostMin} />
|
||||||
|
<ResultItem label="可用主机最大" value={result.hostMax} />
|
||||||
|
<ResultItem label="可用主机数" value={result.hosts.toLocaleString()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultItem({ label, value, cidr }: { label: string; value: string; cidr?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-lg font-semibold">{value}</span>
|
||||||
|
{cidr && <span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">{cidr}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
app/ip-radix/page.tsx
Normal file
201
app/ip-radix/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Network, Copy } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// --- Helper Functions ---
|
||||||
|
|
||||||
|
// IPv4
|
||||||
|
const ipv4ToInt = (ip: string) =>
|
||||||
|
ip.split('.').reduce((acc, octet) => (acc << 8n) + BigInt(parseInt(octet, 10)), 0n);
|
||||||
|
|
||||||
|
const intToIpv4 = (int: bigint) =>
|
||||||
|
[(int >> 24n) & 0xffn, (int >> 16n) & 0xffn, (int >> 8n) & 0xffn, int & 0xffn].join('.');
|
||||||
|
|
||||||
|
// IPv6 (simplified)
|
||||||
|
const ipv6ToBigInt = (ip: string) => {
|
||||||
|
// Expansion of ::
|
||||||
|
if(ip.includes('::')) {
|
||||||
|
const parts = ip.split('::');
|
||||||
|
const left = parts[0].split(':').filter(Boolean);
|
||||||
|
const right = parts[1].split(':').filter(Boolean);
|
||||||
|
const missing = 8 - (left.length + right.length);
|
||||||
|
const expanded = [...left, ...Array(missing).fill('0'), ...right];
|
||||||
|
ip = expanded.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = ip.split(':');
|
||||||
|
let result = 0n;
|
||||||
|
for (const part of parts) {
|
||||||
|
result = (result << 16n) + BigInt(parseInt(part || '0', 16));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bigIntToIpv6 = (int: bigint) => {
|
||||||
|
let parts = [];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
parts.unshift((int & 0xffffn).toString(16));
|
||||||
|
int >>= 16n;
|
||||||
|
}
|
||||||
|
// Simple compression (not full RFC 5952 compliant but good enough for display)
|
||||||
|
return parts.join(':').replace(/(^|:)0(:0)*:0(:|$)/, '::');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function IpRadixPage() {
|
||||||
|
const [ip, setIp] = useState("192.168.1.1");
|
||||||
|
const [type, setType] = useState<"IPv4" | "IPv6">("IPv4");
|
||||||
|
|
||||||
|
const [decimal, setDecimal] = useState("");
|
||||||
|
const [hex, setHex] = useState("");
|
||||||
|
const [binary, setBinary] = useState("");
|
||||||
|
|
||||||
|
const [errorV4, setErrorV4] = useState("");
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ip.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let val: bigint;
|
||||||
|
if (ip.includes(':')) {
|
||||||
|
setType("IPv6");
|
||||||
|
val = ipv6ToBigInt(ip);
|
||||||
|
} else {
|
||||||
|
setType("IPv4");
|
||||||
|
// Basic IPv4 validation
|
||||||
|
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
|
||||||
|
if (/^\d+$/.test(ip)) {
|
||||||
|
// Treat as decimal input
|
||||||
|
val = BigInt(ip);
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid Format");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val = ipv4ToInt(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorV4("");
|
||||||
|
setDecimal(val.toString());
|
||||||
|
setHex("0x" + val.toString(16).toUpperCase());
|
||||||
|
setBinary(val.toString(2));
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
setDecimal("---");
|
||||||
|
setHex("---");
|
||||||
|
setBinary("---");
|
||||||
|
setErrorV4("无效的 IP 地址格式");
|
||||||
|
}
|
||||||
|
}, [ip]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-cyan-500 to-blue-600 shadow-lg">
|
||||||
|
<Network className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">IP 进制转换</h1>
|
||||||
|
<p className="text-muted-foreground">IPv4/IPv6 地址与十进制、十六进制、二进制互转</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>输入 IP 地址</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>IPv4, IPv6 或 整数值</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={ip}
|
||||||
|
onChange={(e) => setIp(e.target.value)}
|
||||||
|
placeholder="例如: 192.168.1.1 或 2001:db8::1"
|
||||||
|
className="font-mono text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errorV4 && <p className="text-sm text-destructive">{errorV4}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
自动识别 IPv4 / IPv6 格式。支持输入十进制整数反向转换。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base text-muted-foreground">标准格式 ({type})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="p-4 bg-muted/40 rounded-lg font-mono text-lg break-all flex justify-between items-start">
|
||||||
|
<span>{type === 'IPv4' ? intToIpv4(BigInt(decimal || 0)) : bigIntToIpv6(BigInt(decimal || 0))}</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 ml-2" onClick={() => copyToClipboard(type === 'IPv4' ? intToIpv4(BigInt(decimal || 0)) : bigIntToIpv6(BigInt(decimal || 0)))}>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base text-muted-foreground">十进制 (Decimal)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="p-4 bg-muted/40 rounded-lg font-mono text-lg break-all flex justify-between items-start">
|
||||||
|
<span>{decimal}</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 ml-2" onClick={() => copyToClipboard(decimal)}>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base text-muted-foreground">十六进制 (Hex)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="p-4 bg-muted/40 rounded-lg font-mono text-lg break-all flex justify-between items-start">
|
||||||
|
<span>{hex}</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 ml-2" onClick={() => copyToClipboard(hex)}>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base text-muted-foreground">二进制 (Binary)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="p-4 bg-muted/40 rounded-lg font-mono text-sm break-all flex justify-between items-start max-h-40 overflow-y-auto">
|
||||||
|
<span>{binary}</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 ml-2" onClick={() => copyToClipboard(binary)}>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
318
app/json-formatter/page.tsx
Normal file
318
app/json-formatter/page.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Code, Minimize2, CheckCircle, Copy, FilePlus, Eraser, Check, XCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Mode = 'format' | 'compress' | 'validate';
|
||||||
|
|
||||||
|
export default function JsonFormatterPage() {
|
||||||
|
const [mode, setMode] = useState<Mode>('format');
|
||||||
|
const [inputJson, setInputJson] = useState('');
|
||||||
|
const [outputJson, setOutputJson] = useState('');
|
||||||
|
const [isValid, setIsValid] = useState<boolean | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const [charCount, setCharCount] = useState(0);
|
||||||
|
|
||||||
|
const validateJson = useCallback((jsonStr: string) => {
|
||||||
|
if (!jsonStr.trim()) {
|
||||||
|
setIsValid(null);
|
||||||
|
setErrorMsg('');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
setIsValid(true);
|
||||||
|
setErrorMsg('');
|
||||||
|
return parsed;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : '未知错误';
|
||||||
|
setErrorMsg(msg);
|
||||||
|
setIsValid(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatJson = useCallback(() => {
|
||||||
|
const parsed = validateJson(inputJson);
|
||||||
|
if (parsed !== null) {
|
||||||
|
const formatted = JSON.stringify(parsed, null, 2);
|
||||||
|
setOutputJson(formatted);
|
||||||
|
toast.success('格式化成功');
|
||||||
|
}
|
||||||
|
}, [inputJson, validateJson]);
|
||||||
|
|
||||||
|
const compressJson = useCallback(() => {
|
||||||
|
const parsed = validateJson(inputJson);
|
||||||
|
if (parsed !== null) {
|
||||||
|
const compressed = JSON.stringify(parsed);
|
||||||
|
setOutputJson(compressed);
|
||||||
|
toast.success('压缩成功');
|
||||||
|
}
|
||||||
|
}, [inputJson, validateJson]);
|
||||||
|
|
||||||
|
const validateOnly = useCallback(() => {
|
||||||
|
validateJson(inputJson);
|
||||||
|
if (inputJson.trim() && isValid) {
|
||||||
|
toast.success('JSON 格式正确');
|
||||||
|
}
|
||||||
|
}, [inputJson, validateJson, isValid]);
|
||||||
|
|
||||||
|
const handleAction = useCallback(() => {
|
||||||
|
switch (mode) {
|
||||||
|
case 'format':
|
||||||
|
formatJson();
|
||||||
|
break;
|
||||||
|
case 'compress':
|
||||||
|
compressJson();
|
||||||
|
break;
|
||||||
|
case 'validate':
|
||||||
|
validateOnly();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [mode, formatJson, compressJson, validateOnly]);
|
||||||
|
|
||||||
|
const copyToClipboard = useCallback(async (text: string, type: string) => {
|
||||||
|
if (!text) {
|
||||||
|
toast.warning('没有可复制的内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(`${type}已复制`);
|
||||||
|
} catch {
|
||||||
|
toast.error('复制失败');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
setInputJson('');
|
||||||
|
setOutputJson('');
|
||||||
|
setIsValid(null);
|
||||||
|
setErrorMsg('');
|
||||||
|
setCharCount(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadExample = useCallback(() => {
|
||||||
|
const example = {
|
||||||
|
name: "信奥工具箱",
|
||||||
|
version: "1.0.0",
|
||||||
|
features: ["JSON格式化", "压缩", "验证"],
|
||||||
|
config: {
|
||||||
|
theme: "cyan",
|
||||||
|
language: "zh-CN"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const exampleStr = JSON.stringify(example, null, 2);
|
||||||
|
setInputJson(exampleStr);
|
||||||
|
setCharCount(exampleStr.length);
|
||||||
|
validateJson(exampleStr);
|
||||||
|
}, [validateJson]);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback((value: string) => {
|
||||||
|
setInputJson(value);
|
||||||
|
setCharCount(value.length);
|
||||||
|
if (value.trim()) {
|
||||||
|
validateJson(value);
|
||||||
|
} else {
|
||||||
|
setIsValid(null);
|
||||||
|
setErrorMsg('');
|
||||||
|
}
|
||||||
|
}, [validateJson]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-cyan-500 to-blue-600 shadow-lg">
|
||||||
|
<Code className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">JSON 格式化工具</h1>
|
||||||
|
<p className="text-muted-foreground">格式化JSON,使其更易读</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tabs value={mode} onValueChange={(v) => setMode(v as Mode)} className="w-75">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="format">格式化</TabsTrigger>
|
||||||
|
<TabsTrigger value="compress">压缩</TabsTrigger>
|
||||||
|
<TabsTrigger value="validate">验证</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-wrap items-center gap-4 p-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleAction}
|
||||||
|
disabled={!inputJson.trim()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{mode === 'format' && <Code className="h-4 w-4" />}
|
||||||
|
{mode === 'compress' && <Minimize2 className="h-4 w-4" />}
|
||||||
|
{mode === 'validate' && <CheckCircle className="h-4 w-4" />}
|
||||||
|
{mode === 'format' ? '格式化' : mode === 'compress' ? '压缩' : '验证'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={compressJson}
|
||||||
|
disabled={!inputJson.trim() || isValid === false}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Minimize2 className="h-4 w-4" />
|
||||||
|
压缩
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={validateOnly}
|
||||||
|
disabled={!inputJson.trim()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
验证
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="ghost" onClick={loadExample} className="gap-2">
|
||||||
|
<FilePlus className="h-4 w-4" />
|
||||||
|
加载示例
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="ghost" onClick={clearAll} className="gap-2 text-destructive hover:text-destructive/90 hover:bg-destructive/10">
|
||||||
|
<Eraser className="h-4 w-4" />
|
||||||
|
清空内容
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Input Panel */}
|
||||||
|
<Card className="flex flex-col h-full">
|
||||||
|
<CardHeader className="py-3 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
|
JSON 输入
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => copyToClipboard(inputJson, '输入内容')}>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>复制输入</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</CardTitle>
|
||||||
|
{isValid !== null && (
|
||||||
|
<Badge variant={isValid ? "default" : "destructive"} className={isValid ? "bg-emerald-500 hover:bg-emerald-600" : ""}>
|
||||||
|
{isValid ? <Check className="h-3 w-3 mr-1" /> : <XCircle className="h-3 w-3 mr-1" />}
|
||||||
|
{isValid ? '格式正确' : '格式错误'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 p-0 relative">
|
||||||
|
<Textarea
|
||||||
|
value={inputJson}
|
||||||
|
onChange={(e) => handleInputChange(e.target.value)}
|
||||||
|
placeholder="请输入JSON数据..."
|
||||||
|
className="min-h-125 h-full border-0 rounded-none focus-visible:ring-0 resize-none font-mono text-sm leading-relaxed p-4 bg-transparent"
|
||||||
|
/>
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-destructive/10 text-destructive text-xs p-2 border-t border-destructive/20">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<div className="p-2 border-t bg-muted/30 text-xs text-muted-foreground flex justify-end">
|
||||||
|
字符数: {charCount}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Output Panel */}
|
||||||
|
<Card className="flex flex-col h-full">
|
||||||
|
<CardHeader className="py-3 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
|
格式化结果
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => copyToClipboard(outputJson, '结果')}>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>复制结果</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 p-0 bg-muted/30">
|
||||||
|
<Textarea
|
||||||
|
value={outputJson}
|
||||||
|
readOnly
|
||||||
|
placeholder="处理结果将显示在这里..."
|
||||||
|
className="min-h-125 h-full border-0 rounded-none focus-visible:ring-0 resize-none font-mono text-sm leading-relaxed p-4 bg-transparent"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Info */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">格式化模式</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>美化JSON结构,增加缩进</li>
|
||||||
|
<li>提高可读性</li>
|
||||||
|
<li>便于调试和查看</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">压缩模式</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>移除所有空白字符</li>
|
||||||
|
<li>减小文件大小</li>
|
||||||
|
<li>适合生产环境</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">验证模式</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>检查JSON语法正确性</li>
|
||||||
|
<li>显示详细统计信息</li>
|
||||||
|
<li>提供错误诊断</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 p-3 bg-muted rounded-md text-xs text-muted-foreground">
|
||||||
|
💡 提示:支持复杂的嵌套结构,包括对象、数组、字符串、数字、布尔值和null值。
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
app/jwt/page.tsx
Normal file
166
app/jwt/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { KeySquare, Copy, Trash2, ShieldCheck, AlertCircle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function JwtDecoderPage() {
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [decoded, setDecoded] = useState<{
|
||||||
|
header: any;
|
||||||
|
payload: any;
|
||||||
|
error: string | null;
|
||||||
|
}>({
|
||||||
|
header: null,
|
||||||
|
payload: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const base64UrlDecode = (str: string) => {
|
||||||
|
try {
|
||||||
|
// Replace non-url compatible chars with base64 standard chars
|
||||||
|
str = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
// Pad with =
|
||||||
|
while (str.length % 4) {
|
||||||
|
str += '=';
|
||||||
|
}
|
||||||
|
return decodeURIComponent(atob(str).split('').map(function(c) {
|
||||||
|
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||||
|
}).join(''));
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid base64 encoding");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeJwt = (input: string) => {
|
||||||
|
setToken(input);
|
||||||
|
if (!input.trim()) {
|
||||||
|
setDecoded({ header: null, payload: null, error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = input.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
setDecoded({ header: null, payload: null, error: "无效的 JWT 格式(必须包含三个由点分隔的部分)" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const header = JSON.parse(base64UrlDecode(parts[0]));
|
||||||
|
const payload = JSON.parse(base64UrlDecode(parts[1]));
|
||||||
|
setDecoded({ header, payload, error: null });
|
||||||
|
} catch {
|
||||||
|
setDecoded({ header: null, payload: null, error: "解码失败:请检查输入是否为有效的 JWT" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (data: any) => {
|
||||||
|
if (!data) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
||||||
|
toast.success("已复制 JSON 内容");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
setToken("");
|
||||||
|
setDecoded({ header: null, payload: null, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-purple-500 to-indigo-600 shadow-lg">
|
||||||
|
<KeySquare className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">JWT 解码器</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
解析 JSON Web Token 的 Header 和 Payload 内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Input Section */}
|
||||||
|
<Card className="lg:row-span-2">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base font-medium text-purple-600">Encoded (输入 Token)</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAll} className="text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
placeholder="请在此粘贴 JWT Token..."
|
||||||
|
className="min-h-100 font-mono text-sm break-all resize-none bg-muted/30"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => decodeJwt(e.target.value)}
|
||||||
|
/>
|
||||||
|
{decoded.error && (
|
||||||
|
<div className="mt-4 p-4 rounded-lg bg-destructive/10 text-destructive flex items-start gap-3 text-sm">
|
||||||
|
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||||
|
<span>{decoded.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Output Section - Header */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base font-medium text-blue-600">Header (头部)</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(decoded.header)} disabled={!decoded.header}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-lg bg-slate-950 p-4 overflow-x-auto min-h-25">
|
||||||
|
<pre className="text-sm font-mono text-blue-400">
|
||||||
|
{decoded.header ? JSON.stringify(decoded.header, null, 2) : "// 等待输入..."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Output Section - Payload */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base font-medium text-emerald-600">Payload (负载)</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(decoded.payload)} disabled={!decoded.payload}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-lg bg-slate-950 p-4 overflow-x-auto min-h-50">
|
||||||
|
<pre className="text-sm font-mono text-emerald-400">
|
||||||
|
{decoded.payload ? JSON.stringify(decoded.payload, null, 2) : "// 等待输入..."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Note */}
|
||||||
|
<Card className="border-emerald-200/50 bg-emerald-50/30 dark:bg-emerald-500/5">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-emerald-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-emerald-900 dark:text-emerald-400">安全提示</p>
|
||||||
|
<p className="text-sm text-emerald-800/80 dark:text-emerald-400/80">
|
||||||
|
解码过程完全在您的浏览器本地进行,Token 不会被发送到任何服务器。请放心使用。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
app/keyboard/page.tsx
Normal file
114
app/keyboard/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Keyboard, Info, RotateCcw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function KeyboardPage() {
|
||||||
|
const [lastEvent, setLastEvent] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
e.preventDefault(); // Prevent standard browser actions
|
||||||
|
setLastEvent({
|
||||||
|
key: e.key === " " ? "Space" : e.key,
|
||||||
|
code: e.code,
|
||||||
|
keyCode: e.keyCode,
|
||||||
|
altKey: e.altKey,
|
||||||
|
ctrlKey: e.ctrlKey,
|
||||||
|
shiftKey: e.shiftKey,
|
||||||
|
metaKey: e.metaKey,
|
||||||
|
location: e.location,
|
||||||
|
repeat: e.repeat
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-zinc-700 to-zinc-900 shadow-lg">
|
||||||
|
<Keyboard className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">键盘按键检测</h1>
|
||||||
|
<p className="text-muted-foreground">实时检测并显示键盘按键的详细事件属性</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 space-y-8">
|
||||||
|
{!lastEvent ? (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="w-64 h-64 rounded-3xl border-4 border-dashed border-muted flex items-center justify-center animate-pulse">
|
||||||
|
<span className="text-muted-foreground font-medium text-lg px-8">请按下键盘上的任意按键</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute -inset-1 bg-linear-to-r from-primary to-purple-600 rounded-3xl blur-md opacity-25 group-hover:opacity-50 transition duration-1000"></div>
|
||||||
|
<div className="relative w-64 h-64 rounded-3xl bg-card border-2 border-primary flex flex-col items-center justify-center shadow-2xl">
|
||||||
|
<span className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-widest">Key</span>
|
||||||
|
<span className="text-7xl font-bold text-primary">{lastEvent.key}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full max-w-2xl">
|
||||||
|
<DetailCard label="Code" value={lastEvent.code} />
|
||||||
|
<DetailCard label="KeyCode" value={lastEvent.keyCode} />
|
||||||
|
<DetailCard label="Location" value={lastEvent.location} />
|
||||||
|
<DetailCard label="Repeat" value={lastEvent.repeat ? "Yes" : "No"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-4">
|
||||||
|
<ModifierTag active={lastEvent.ctrlKey} label="Ctrl" />
|
||||||
|
<ModifierTag active={lastEvent.shiftKey} label="Shift" />
|
||||||
|
<ModifierTag active={lastEvent.altKey} label="Alt" />
|
||||||
|
<ModifierTag active={lastEvent.metaKey} label="Win / Cmd" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="ghost" onClick={() => setLastEvent(null)} className="text-muted-foreground">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
重置检测
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-muted/30 border-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
什么是 KeyCode?
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
<p>虽然 `keyCode` 在现代 Web 开发中已被废弃,推荐使用 `key` 和 `code`,但许多旧系统和特定的底层交互仍在使用它。`key` 代表字符本身,而 `code` 代表键盘上的物理位置。</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailCard({ label, value }: { label: string; value: any }) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 rounded-xl bg-card border flex flex-col items-center justify-center space-y-1">
|
||||||
|
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-tighter">{label}</span>
|
||||||
|
<span className="font-mono font-bold text-lg">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModifierTag({ active, label }: { active: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`px-4 py-2 rounded-full border-2 font-bold transition-all duration-300 ${
|
||||||
|
active ? "bg-primary border-primary text-primary-foreground scale-110 shadow-lg" : "border-muted text-muted-foreground opacity-30"
|
||||||
|
}`}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
app/layout.tsx
Normal file
40
app/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
import ClientLayout from './components/ClientLayout'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: '信奥工具箱 - 在线工具集合',
|
||||||
|
description: '信奥工具箱提供各种实用的在线工具,包括JSON格式化、文本处理、编码转换、二维码生成等。',
|
||||||
|
keywords: '在线工具,JSON格式化,二维码生成器,文本处理,编码工具,信奥工具箱',
|
||||||
|
authors: [{ name: '信奥工具箱' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用系统字体,避免构建时依赖外部网络
|
||||||
|
// import { Inter } from "next/font/google";
|
||||||
|
// const inter = Inter({
|
||||||
|
// subsets: ["latin"],
|
||||||
|
// display: "swap",
|
||||||
|
// fallback: ["system-ui", "arial"],
|
||||||
|
// adjustFontFallback: true,
|
||||||
|
// });
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body className="font-sans antialiased">
|
||||||
|
<ClientLayout>
|
||||||
|
{children}
|
||||||
|
</ClientLayout>
|
||||||
|
<Toaster />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
469
app/lib/testcase-backend/generator.ts
Normal file
469
app/lib/testcase-backend/generator.ts
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import path from "path";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import os from "os";
|
||||||
|
import { executeStandardCode } from "./runner";
|
||||||
|
import { createZipFile } from "./zip";
|
||||||
|
|
||||||
|
const OUTPUT_BASE = path.join(os.tmpdir(), "i-tools-testcase");
|
||||||
|
|
||||||
|
interface Constraint {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConstraint(text: string, variable: string): Constraint | null {
|
||||||
|
const escapedVariable = variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const patterns = [
|
||||||
|
new RegExp(`(-?\\d+(?:\\.\\d+)?)\\s*[≤<=]\\s*${escapedVariable}\\s*[≤<=]\\s*(-?\\d+(?:\\.\\d+)?)`, "i"),
|
||||||
|
new RegExp(`(-?\\d+)\\s*[≤<=]\\s*${escapedVariable}\\s*[≤<=]\\s*(-?\\d+)\\s*\\^\\s*(-?\\d+)`, "i"),
|
||||||
|
new RegExp(`(-?[\\d,]+)\\s*[≤<=]\\s*${escapedVariable}\\s*[≤<=]\\s*(-?[\\d,]+)`, "i"),
|
||||||
|
new RegExp(`${escapedVariable}\\s*[∈∊]\\s*\\[\\s*(-?\\d+(?:\\.\\d+)?)\\s*,\\s*(-?\\d+(?:\\.\\d+)?)\\s*\\]`, "i"),
|
||||||
|
new RegExp(`${escapedVariable}.*?(?:范围|取值).*?(-?\\d+(?:\\.\\d+)?).*?(?:到|至|-|~).*?(-?\\d+(?:\\.\\d+)?)`, "i"),
|
||||||
|
new RegExp(`(-?\\d+(?:\\.\\d+)?)\\s*[<≤<=]+\\s*${escapedVariable}\\s*[<≤<=]+\\s*(-?\\d+(?:\\.\\d+)?)`, "i"),
|
||||||
|
new RegExp(`${escapedVariable}\\s*[≤<=]\\s*(-?\\d+(?:\\.\\d+)?).*?${escapedVariable}\\s*[≥>=]\\s*(-?\\d+(?:\\.\\d+)?)`, "i"),
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
let min: number, max: number;
|
||||||
|
if (match.length === 4 && match[3]) {
|
||||||
|
min = parseFloat(match[1]);
|
||||||
|
max = parseFloat(match[2]) * Math.pow(10, parseFloat(match[3]));
|
||||||
|
} else if (pattern.source.includes("≤.*≥")) {
|
||||||
|
max = parseFloat(match[1].replace(/,/g, ""));
|
||||||
|
min = parseFloat(match[2].replace(/,/g, ""));
|
||||||
|
} else {
|
||||||
|
min = parseFloat(match[1].replace(/,/g, ""));
|
||||||
|
max = parseFloat(match[2].replace(/,/g, ""));
|
||||||
|
}
|
||||||
|
if (min > max) [min, max] = [max, min];
|
||||||
|
return { min: Math.floor(min), max: Math.floor(max) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAllConstraints(description: string): Record<string, Constraint> {
|
||||||
|
const constraints: Record<string, Constraint> = {};
|
||||||
|
const commonVariables = [
|
||||||
|
"n", "m", "k", "q", "t", "l", "r", "x", "y", "z",
|
||||||
|
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
|
||||||
|
"a_i", "b_i", "c_i", "A_i", "B_i", "C_i",
|
||||||
|
"s", "S", "w", "W", "h", "H",
|
||||||
|
"数组长度", "序列长度", "字符串长度",
|
||||||
|
"序列中的数", "数组中的数", "元素", "值",
|
||||||
|
"权重", "代价", "距离", "时间", "长度", "宽度", "高度",
|
||||||
|
];
|
||||||
|
for (const variable of commonVariables) {
|
||||||
|
const c = parseConstraint(description, variable);
|
||||||
|
if (c) constraints[variable] = c;
|
||||||
|
}
|
||||||
|
const rangePatterns = [
|
||||||
|
/(\d+)\s*[≤<=]\s*.*?[≤<=]\s*(\d+)/g,
|
||||||
|
/(\d+)\s*到\s*(\d+)/g,
|
||||||
|
/(\d+)\s*~\s*(\d+)/g,
|
||||||
|
/\[(\d+),\s*(\d+)\]/g,
|
||||||
|
];
|
||||||
|
for (const pattern of rangePatterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(description)) !== null) {
|
||||||
|
const min = parseInt(match[1], 10);
|
||||||
|
const max = parseInt(match[2], 10);
|
||||||
|
if (min < max) {
|
||||||
|
const key = `range_${min}_${max}`;
|
||||||
|
if (!constraints[key]) constraints[key] = { min, max };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return constraints;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateValueInRange(min: number, max: number, caseNumber: number, totalCases = 10): number {
|
||||||
|
const range = max - min;
|
||||||
|
if (caseNumber === 1) return min;
|
||||||
|
if (caseNumber === 2) return min + 1;
|
||||||
|
if (caseNumber === totalCases) return max;
|
||||||
|
if (caseNumber === totalCases - 1) return max - 1;
|
||||||
|
if (caseNumber === 3) return min + Math.floor(range * 0.1);
|
||||||
|
if (caseNumber === 4) return min + Math.floor(range * 0.25);
|
||||||
|
if (caseNumber === 5) return min + Math.floor(range * 0.5);
|
||||||
|
if (caseNumber === 6) return min + Math.floor(range * 0.75);
|
||||||
|
if (caseNumber === 7) return min + Math.floor(range * 0.9);
|
||||||
|
const segments = [0.05, 0.15, 0.35, 0.65, 0.85, 0.95];
|
||||||
|
const seg = segments[caseNumber % segments.length];
|
||||||
|
const value = min + Math.floor(range * (seg + (Math.random() * 0.1 - 0.05)));
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateArrayInput(
|
||||||
|
caseNumber: number,
|
||||||
|
nConstraint: Constraint | null,
|
||||||
|
valueConstraint: Constraint | null,
|
||||||
|
totalCases: number
|
||||||
|
): string {
|
||||||
|
const maxN = nConstraint?.max ?? 1000;
|
||||||
|
const minN = nConstraint?.min ?? 1;
|
||||||
|
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||||
|
const maxVal = valueConstraint?.max ?? 1000;
|
||||||
|
const minVal = valueConstraint?.min ?? -1000;
|
||||||
|
let result = `${n}\n`;
|
||||||
|
const values: number[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
values.push(generateValueInRange(minVal, maxVal, i + 1, n));
|
||||||
|
}
|
||||||
|
result += values.join(" ");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateGraphInput(
|
||||||
|
caseNumber: number,
|
||||||
|
nConstraint: Constraint | null,
|
||||||
|
totalCases: number
|
||||||
|
): string {
|
||||||
|
const maxN = Math.min(nConstraint?.max ?? 1000, 1000);
|
||||||
|
const minN = nConstraint?.min ?? 3;
|
||||||
|
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||||
|
const m = Math.min(Math.floor((n * (n - 1)) / 2), Math.floor(n * Math.log(n)));
|
||||||
|
let result = `${n} ${m}\n`;
|
||||||
|
const edges = new Set<string>();
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
let u: number, v: number;
|
||||||
|
do {
|
||||||
|
u = Math.floor(Math.random() * n) + 1;
|
||||||
|
v = Math.floor(Math.random() * n) + 1;
|
||||||
|
} while (u === v || edges.has(`${Math.min(u, v)}-${Math.max(u, v)}`));
|
||||||
|
edges.add(`${Math.min(u, v)}-${Math.max(u, v)}`);
|
||||||
|
result += `${u} ${v}\n`;
|
||||||
|
}
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateStringInput(
|
||||||
|
caseNumber: number,
|
||||||
|
nConstraint: Constraint | null,
|
||||||
|
totalCases: number
|
||||||
|
): string {
|
||||||
|
const maxN = nConstraint?.max ?? 100;
|
||||||
|
const minN = nConstraint?.min ?? 1;
|
||||||
|
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||||
|
const chars = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
let str = "";
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
str += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMatrixInput(
|
||||||
|
caseNumber: number,
|
||||||
|
nConstraint: Constraint | null,
|
||||||
|
valueConstraint: Constraint | null,
|
||||||
|
totalCases: number
|
||||||
|
): string {
|
||||||
|
const maxN = Math.min(nConstraint?.max ?? 100, 100);
|
||||||
|
const minN = nConstraint?.min ?? 2;
|
||||||
|
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||||
|
const m = n;
|
||||||
|
const maxVal = valueConstraint?.max ?? 100;
|
||||||
|
const minVal = valueConstraint?.min ?? 0;
|
||||||
|
let result = `${n} ${m}\n`;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const row: number[] = [];
|
||||||
|
for (let j = 0; j < m; j++) {
|
||||||
|
row.push(Math.floor(Math.random() * (maxVal - minVal + 1)) + minVal);
|
||||||
|
}
|
||||||
|
result += row.join(" ") + "\n";
|
||||||
|
}
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTreeInput(
|
||||||
|
caseNumber: number,
|
||||||
|
nConstraint: Constraint | null,
|
||||||
|
totalCases: number
|
||||||
|
): string {
|
||||||
|
const maxN = Math.min(nConstraint?.max ?? 1000, 10000);
|
||||||
|
const minN = Math.max(nConstraint?.min ?? 2, 2);
|
||||||
|
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||||
|
let result = `${n}\n`;
|
||||||
|
const edges: [number, number][] = [];
|
||||||
|
for (let i = 2; i <= n; i++) {
|
||||||
|
const parent = Math.floor(Math.random() * (i - 1)) + 1;
|
||||||
|
edges.push([parent, i]);
|
||||||
|
}
|
||||||
|
for (let i = edges.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[edges[i], edges[j]] = [edges[j], edges[i]];
|
||||||
|
}
|
||||||
|
for (const [u, v] of edges) {
|
||||||
|
result += `${u} ${v}\n`;
|
||||||
|
}
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateQueryInput(
|
||||||
|
caseNumber: number,
|
||||||
|
nConstraint: Constraint | null,
|
||||||
|
mConstraint: Constraint | null,
|
||||||
|
valueConstraint: Constraint | null,
|
||||||
|
totalCases: number
|
||||||
|
): string {
|
||||||
|
const maxN = nConstraint?.max ?? 1000;
|
||||||
|
const minN = nConstraint?.min ?? 1;
|
||||||
|
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||||
|
const maxM = mConstraint?.max ?? 100;
|
||||||
|
const minM = mConstraint?.min ?? 1;
|
||||||
|
const m = generateValueInRange(minM, maxM, caseNumber, totalCases);
|
||||||
|
const maxVal = valueConstraint?.max ?? 1000;
|
||||||
|
const minVal = valueConstraint?.min ?? 1;
|
||||||
|
let result = `${n} ${m}\n`;
|
||||||
|
const values: number[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
values.push(generateValueInRange(minVal, maxVal, i + 1, n));
|
||||||
|
}
|
||||||
|
result += values.join(" ") + "\n";
|
||||||
|
const queries: number[] = [];
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
queries.push(generateValueInRange(minVal, maxVal, i + 1, m));
|
||||||
|
}
|
||||||
|
result += queries.join(" ");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateBinarySearchInput(
|
||||||
|
caseNumber: number,
|
||||||
|
description: string,
|
||||||
|
totalCases: number
|
||||||
|
): string {
|
||||||
|
const nConstraint = parseConstraint(description, "n") ?? { min: 5, max: 100 };
|
||||||
|
const mConstraint = parseConstraint(description, "m") ?? { min: 1, max: 10 };
|
||||||
|
const valueConstraint = parseConstraint(description, "a_i") ?? parseConstraint(description, "q") ?? { min: 0, max: 100 };
|
||||||
|
let n: number, m: number;
|
||||||
|
switch (caseNumber) {
|
||||||
|
case 1:
|
||||||
|
n = Math.min(10, nConstraint.max);
|
||||||
|
m = Math.min(5, mConstraint.max);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
n = Math.min(100, Math.floor(nConstraint.max * 0.1));
|
||||||
|
m = Math.min(20, Math.floor(mConstraint.max * 0.2));
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
n = nConstraint.min;
|
||||||
|
m = mConstraint.min;
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
n = Math.min(1000, nConstraint.max);
|
||||||
|
m = Math.min(100, mConstraint.max);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
n = generateValueInRange(nConstraint.min, Math.min(500, nConstraint.max), caseNumber, totalCases);
|
||||||
|
m = generateValueInRange(mConstraint.min, Math.min(50, mConstraint.max), caseNumber, totalCases);
|
||||||
|
}
|
||||||
|
const sequence: number[] = [];
|
||||||
|
let currentValue = valueConstraint.min;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (i > 0 && Math.random() < 0.7) {
|
||||||
|
const inc = Math.floor(Math.random() * 5) + 1;
|
||||||
|
currentValue = Math.min(currentValue + inc, valueConstraint.max);
|
||||||
|
}
|
||||||
|
sequence.push(currentValue);
|
||||||
|
}
|
||||||
|
const queries: number[] = [];
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
if (i < m / 2 && sequence.length > 0) {
|
||||||
|
queries.push(sequence[Math.floor(Math.random() * sequence.length)]!);
|
||||||
|
} else {
|
||||||
|
queries.push(
|
||||||
|
Math.random() < 0.5 && sequence.length > 0
|
||||||
|
? sequence[Math.floor(Math.random() * sequence.length)]!
|
||||||
|
: generateValueInRange(valueConstraint.min, valueConstraint.max, i, m)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${n} ${m}\n${sequence.join(" ")}\n${queries.join(" ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMockInput(description: string, caseNumber: number, totalCases = 10): string {
|
||||||
|
const desc = description.toLowerCase();
|
||||||
|
const allConstraints = parseAllConstraints(description);
|
||||||
|
|
||||||
|
if (desc.includes("单调不减") && desc.includes("查找") && desc.includes("询问")) {
|
||||||
|
return generateBinarySearchInput(caseNumber, description, totalCases);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nConstraint = allConstraints["n"] ?? allConstraints["N"] ?? parseConstraint(description, "n");
|
||||||
|
const mConstraint = allConstraints["m"] ?? allConstraints["M"] ?? parseConstraint(description, "m");
|
||||||
|
const valueConstraint =
|
||||||
|
allConstraints["序列中的数"] ?? allConstraints["数组中的数"] ?? allConstraints["元素"] ?? allConstraints["值"] ??
|
||||||
|
parseConstraint(description, "序列中的数") ?? parseConstraint(description, "value");
|
||||||
|
|
||||||
|
let effectiveN = nConstraint;
|
||||||
|
if (!effectiveN) {
|
||||||
|
const rangeKeys = Object.keys(allConstraints).filter((k) => k.startsWith("range_"));
|
||||||
|
if (rangeKeys.length > 0) {
|
||||||
|
const maxRange = rangeKeys.reduce((a, b) =>
|
||||||
|
(allConstraints[b]?.max ?? 0) > (allConstraints[a]?.max ?? 0) ? b : a
|
||||||
|
);
|
||||||
|
effectiveN = allConstraints[maxRange] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desc.includes("序列") || desc.includes("数组") || desc.includes("array")) {
|
||||||
|
return generateArrayInput(caseNumber, effectiveN ?? null, valueConstraint ?? null, totalCases);
|
||||||
|
}
|
||||||
|
if (desc.includes("图") || desc.includes("graph")) {
|
||||||
|
return generateGraphInput(caseNumber, effectiveN ?? null, totalCases);
|
||||||
|
}
|
||||||
|
if (desc.includes("字符串") || desc.includes("string")) {
|
||||||
|
return generateStringInput(caseNumber, effectiveN ?? null, totalCases);
|
||||||
|
}
|
||||||
|
if (desc.includes("矩阵") || desc.includes("matrix")) {
|
||||||
|
return generateMatrixInput(caseNumber, effectiveN ?? null, valueConstraint ?? null, totalCases);
|
||||||
|
}
|
||||||
|
if (desc.includes("树") || desc.includes("tree")) {
|
||||||
|
return generateTreeInput(caseNumber, effectiveN ?? null, totalCases);
|
||||||
|
}
|
||||||
|
if (desc.includes("询问") || desc.includes("query")) {
|
||||||
|
return generateQueryInput(caseNumber, effectiveN ?? null, mConstraint ?? null, valueConstraint ?? null, totalCases);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxN = effectiveN?.max ?? 100;
|
||||||
|
const minN = effectiveN?.min ?? 1;
|
||||||
|
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||||
|
const keys = Object.keys(allConstraints);
|
||||||
|
if (keys.length > 1) {
|
||||||
|
let result = `${n}\n`;
|
||||||
|
for (const key of keys.slice(1, 3)) {
|
||||||
|
if (key !== "n" && key !== "N" && !key.startsWith("range_")) {
|
||||||
|
const c = allConstraints[key];
|
||||||
|
if (c) result += `${generateValueInRange(c.min, c.max, caseNumber, totalCases)}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
|
return `${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
|
export interface FallbackParams {
|
||||||
|
englishName: string;
|
||||||
|
description: string;
|
||||||
|
standardCode: string;
|
||||||
|
testCaseCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FallbackResult {
|
||||||
|
testCases: { input: string; output: string }[];
|
||||||
|
outputDirName: string;
|
||||||
|
zipFileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressCallback = (progress: number, message: string, generatedCount: number) => void;
|
||||||
|
|
||||||
|
export async function generateFallbackTestCases(
|
||||||
|
params: FallbackParams,
|
||||||
|
progressCallback?: ProgressCallback
|
||||||
|
): Promise<FallbackResult> {
|
||||||
|
const randomSuffix = Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 7);
|
||||||
|
const outputDirName = `${params.englishName}_${randomSuffix}`;
|
||||||
|
const outputDir = path.join(OUTPUT_BASE, outputDirName);
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const testCases: { input: string; output: string }[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= params.testCaseCount; i++) {
|
||||||
|
if (progressCallback) {
|
||||||
|
const progress = 0.3 + (i / params.testCaseCount) * 0.6;
|
||||||
|
progressCallback(progress, `正在生成第 ${i}/${params.testCaseCount} 组测试用例...`, i - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputData = generateMockInput(params.description, i, params.testCaseCount);
|
||||||
|
let outputData: string | null = null;
|
||||||
|
|
||||||
|
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
|
||||||
|
try {
|
||||||
|
outputData = await executeStandardCode(params.standardCode, inputData);
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
if (retry === MAX_RETRIES) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outputData) continue;
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(outputDir, `${params.englishName}${i}.in`), inputData);
|
||||||
|
await fs.writeFile(path.join(outputDir, `${params.englishName}${i}.out`), outputData);
|
||||||
|
testCases.push({ input: inputData, output: outputData });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(0.9, "正在创建ZIP压缩包...", testCases.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipFileName = `${params.englishName}_${randomSuffix}.zip`;
|
||||||
|
const zipPath = path.join(OUTPUT_BASE, zipFileName);
|
||||||
|
await createZipFile(outputDir, zipPath, params.englishName);
|
||||||
|
|
||||||
|
return { testCases, outputDirName, zipFileName };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOutputBase(): string {
|
||||||
|
return OUTPUT_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 生成的用例:用标程跑出 output,写入 .in/.out 并打包 */
|
||||||
|
export interface AITestCaseInput {
|
||||||
|
input: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAIGeneratedCases(
|
||||||
|
params: FallbackParams,
|
||||||
|
aiCases: AITestCaseInput[],
|
||||||
|
progressCallback?: ProgressCallback
|
||||||
|
): Promise<FallbackResult> {
|
||||||
|
const randomSuffix = Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 7);
|
||||||
|
const outputDirName = `${params.englishName}_${randomSuffix}`;
|
||||||
|
const outputDir = path.join(OUTPUT_BASE, outputDirName);
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const testCases: { input: string; output: string }[] = [];
|
||||||
|
const total = aiCases.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
if (progressCallback) {
|
||||||
|
const progress = 0.3 + ((i + 1) / total) * 0.6;
|
||||||
|
progressCallback(progress, `正在处理第 ${i + 1}/${total} 组测试用例...`, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputData = aiCases[i]!.input;
|
||||||
|
let outputData: string | null = null;
|
||||||
|
|
||||||
|
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
|
||||||
|
try {
|
||||||
|
outputData = await executeStandardCode(params.standardCode, inputData);
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
if (retry === MAX_RETRIES) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outputData) continue;
|
||||||
|
|
||||||
|
const oneBased = i + 1;
|
||||||
|
await fs.writeFile(path.join(outputDir, `${params.englishName}${oneBased}.in`), inputData);
|
||||||
|
await fs.writeFile(path.join(outputDir, `${params.englishName}${oneBased}.out`), outputData);
|
||||||
|
testCases.push({ input: inputData, output: outputData });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(0.9, "正在创建ZIP压缩包...", testCases.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipFileName = `${params.englishName}_${randomSuffix}.zip`;
|
||||||
|
const zipPath = path.join(OUTPUT_BASE, zipFileName);
|
||||||
|
await createZipFile(outputDir, zipPath, params.englishName);
|
||||||
|
|
||||||
|
return { testCases, outputDirName, zipFileName };
|
||||||
|
}
|
||||||
9
app/lib/testcase-backend/index.ts
Normal file
9
app/lib/testcase-backend/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { getTask, setTask, createTask, getEstimatedTime, findRunningTaskByEnglishName } from "./store";
|
||||||
|
export type { Task } from "./types";
|
||||||
|
export type { CreateTaskParams } from "./types";
|
||||||
|
export { validateStandardCode, executeStandardCode } from "./runner";
|
||||||
|
export { generateFallbackTestCases, getOutputBase } from "./generator";
|
||||||
|
export type { FallbackParams, FallbackResult, ProgressCallback } from "./generator";
|
||||||
|
export { processTask, getZipPathForTask } from "./process";
|
||||||
|
export { generateTestCases, isQwenAvailable } from "./qwen-service";
|
||||||
|
export type { AITestCase, GenerateTestCasesParams } from "./qwen-service";
|
||||||
156
app/lib/testcase-backend/process.ts
Normal file
156
app/lib/testcase-backend/process.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { getTask, setTask } from "./store";
|
||||||
|
import { validateStandardCode } from "./runner";
|
||||||
|
import { generateFallbackTestCases, runAIGeneratedCases, getOutputBase } from "./generator";
|
||||||
|
import { generateTestCases, isQwenAvailable } from "./qwen-service";
|
||||||
|
|
||||||
|
function formatErrorMessage(err: unknown): string {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if ((err as Error & { code?: string }).code === "COMPILATION_ERROR") {
|
||||||
|
return `C++程序编译失败: ${message}`;
|
||||||
|
}
|
||||||
|
if ((err as Error & { code?: string }).code === "EXECUTION_ERROR") {
|
||||||
|
return `程序执行错误: ${message}`;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTaskProgress(
|
||||||
|
taskId: string,
|
||||||
|
status: "pending" | "running" | "completed" | "failed",
|
||||||
|
progress: number,
|
||||||
|
message: string,
|
||||||
|
generatedCount?: number
|
||||||
|
): void {
|
||||||
|
const task = getTask(taskId);
|
||||||
|
if (!task) return;
|
||||||
|
task.status = status;
|
||||||
|
task.progress = Math.min(100, Math.max(0, progress));
|
||||||
|
task.updatedAt = new Date();
|
||||||
|
task.currentMessage = message;
|
||||||
|
if (generatedCount !== undefined) task.generatedCases = generatedCount;
|
||||||
|
setTask(taskId, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processTask(taskId: string): Promise<void> {
|
||||||
|
const task = getTask(taskId);
|
||||||
|
if (!task) {
|
||||||
|
console.error(`任务不存在: ${taskId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateTaskProgress(taskId, "running", 10, "正在初始化生成环境...");
|
||||||
|
|
||||||
|
await validateStandardCode(task.standardCode);
|
||||||
|
updateTaskProgress(taskId, "running", 20, "标准程序验证通过,开始生成测试数据...");
|
||||||
|
|
||||||
|
let result: { testCases: { input: string; output: string }[]; outputDirName: string; zipFileName: string };
|
||||||
|
|
||||||
|
if (isQwenAvailable()) {
|
||||||
|
try {
|
||||||
|
updateTaskProgress(taskId, "running", 22, "正在调用阿里云大模型生成测试用例...", 0);
|
||||||
|
const aiCases = await generateTestCases({
|
||||||
|
title: task.title,
|
||||||
|
englishName: task.englishName,
|
||||||
|
description: task.description,
|
||||||
|
standardCode: task.standardCode,
|
||||||
|
testCaseCount: task.testCaseCount,
|
||||||
|
serviceLevel: task.serviceLevel,
|
||||||
|
});
|
||||||
|
if (aiCases.length > 0) {
|
||||||
|
updateTaskProgress(taskId, "running", 25, "AI 生成完成,正在用标程计算输出...", 0);
|
||||||
|
result = await runAIGeneratedCases(
|
||||||
|
{
|
||||||
|
englishName: task.englishName,
|
||||||
|
description: task.description,
|
||||||
|
standardCode: task.standardCode,
|
||||||
|
testCaseCount: task.testCaseCount,
|
||||||
|
},
|
||||||
|
aiCases,
|
||||||
|
(progress, message, generatedCount) => {
|
||||||
|
updateTaskProgress(
|
||||||
|
taskId,
|
||||||
|
"running",
|
||||||
|
25 + Math.floor(progress * 65),
|
||||||
|
message,
|
||||||
|
generatedCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error("AI 未返回有效用例");
|
||||||
|
}
|
||||||
|
} catch (aiErr) {
|
||||||
|
console.warn("AI 生成失败,使用备用算法:", aiErr);
|
||||||
|
updateTaskProgress(taskId, "running", 20, "使用智能算法生成测试用例...", 0);
|
||||||
|
result = await generateFallbackTestCases(
|
||||||
|
{
|
||||||
|
englishName: task.englishName,
|
||||||
|
description: task.description,
|
||||||
|
standardCode: task.standardCode,
|
||||||
|
testCaseCount: task.testCaseCount,
|
||||||
|
},
|
||||||
|
(progress, message, generatedCount) => {
|
||||||
|
updateTaskProgress(
|
||||||
|
taskId,
|
||||||
|
"running",
|
||||||
|
20 + Math.floor(progress * 70),
|
||||||
|
message,
|
||||||
|
generatedCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = await generateFallbackTestCases(
|
||||||
|
{
|
||||||
|
englishName: task.englishName,
|
||||||
|
description: task.description,
|
||||||
|
standardCode: task.standardCode,
|
||||||
|
testCaseCount: task.testCaseCount,
|
||||||
|
},
|
||||||
|
(progress, message, generatedCount) => {
|
||||||
|
updateTaskProgress(
|
||||||
|
taskId,
|
||||||
|
"running",
|
||||||
|
20 + Math.floor(progress * 70),
|
||||||
|
message,
|
||||||
|
generatedCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTaskProgress(taskId, "running", 95, "正在打包文件...", result.testCases.length);
|
||||||
|
|
||||||
|
const finalTask = getTask(taskId);
|
||||||
|
if (finalTask) {
|
||||||
|
finalTask.status = "completed";
|
||||||
|
finalTask.progress = 100;
|
||||||
|
finalTask.updatedAt = new Date();
|
||||||
|
finalTask.completedAt = new Date();
|
||||||
|
finalTask.generatedCases = result.testCases.length;
|
||||||
|
finalTask.zipFileName = result.zipFileName;
|
||||||
|
finalTask.outputDirName = result.outputDirName;
|
||||||
|
setTask(taskId, finalTask);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`任务失败: ${taskId}`, err);
|
||||||
|
const finalTask = getTask(taskId);
|
||||||
|
if (finalTask) {
|
||||||
|
finalTask.status = "failed";
|
||||||
|
finalTask.progress = 0;
|
||||||
|
finalTask.errorMessage = formatErrorMessage(err);
|
||||||
|
finalTask.updatedAt = new Date();
|
||||||
|
finalTask.failedAt = new Date();
|
||||||
|
setTask(taskId, finalTask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZipPathForTask(taskId: string): string | null {
|
||||||
|
const task = getTask(taskId);
|
||||||
|
if (!task?.zipFileName) return null;
|
||||||
|
return path.join(getOutputBase(), task.zipFileName);
|
||||||
|
}
|
||||||
252
app/lib/testcase-backend/qwen-service.ts
Normal file
252
app/lib/testcase-backend/qwen-service.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* 阿里云通义大模型服务(DashScope API)
|
||||||
|
* 用于测试点生成的 AI 输入数据生成
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DASHSCOPE_BASE =
|
||||||
|
"https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation";
|
||||||
|
|
||||||
|
export interface AITestCase {
|
||||||
|
input: string;
|
||||||
|
expectedOutput?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateTestCasesParams {
|
||||||
|
title: string;
|
||||||
|
englishName: string;
|
||||||
|
description: string;
|
||||||
|
standardCode: string;
|
||||||
|
testCaseCount: number;
|
||||||
|
serviceLevel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKey(): string | undefined {
|
||||||
|
return process.env.DASHSCOPE_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModel(): string {
|
||||||
|
return process.env.QWEN_MODEL || "qwen-max";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAIEnabled(): boolean {
|
||||||
|
const key = getApiKey();
|
||||||
|
return (
|
||||||
|
process.env.ENABLE_AI === "true" &&
|
||||||
|
!!key &&
|
||||||
|
key !== "your_dashscope_api_key_here"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(params: GenerateTestCasesParams): string {
|
||||||
|
const { title, description, standardCode, testCaseCount } = params;
|
||||||
|
return `你是一个专业的信息学奥林匹克竞赛测试数据生成专家。请根据以下题目信息,生成 ${testCaseCount} 组测试输入数据。
|
||||||
|
|
||||||
|
## 题目信息
|
||||||
|
**标题**: ${title}
|
||||||
|
|
||||||
|
**题目描述**:
|
||||||
|
${description}
|
||||||
|
|
||||||
|
**标准程序** (仅作参考,输出将由标准程序计算):
|
||||||
|
\`\`\`cpp
|
||||||
|
${standardCode}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 要求
|
||||||
|
1. 严格按照题目描述中的**输入格式**生成,注意多行顺序
|
||||||
|
2. 所有数据必须满足题目约束(范围、单调性等)
|
||||||
|
3. 不要使用省略号、占位符或「此处省略」
|
||||||
|
4. 覆盖边界:最小值、最大值、小规模、大规模
|
||||||
|
5. 每组测试用例的输入数据要完整、可直接作为程序 stdin
|
||||||
|
|
||||||
|
## 输出格式(二选一)
|
||||||
|
|
||||||
|
**方式一 JSON**(推荐):
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"test_cases": [
|
||||||
|
{ "input": "第一组输入的完整内容,多行用\\n连接" },
|
||||||
|
{ "input": "第二组输入..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**方式二 文本**:
|
||||||
|
测试用例1:
|
||||||
|
[第一组输入的完整内容,多行按行写]
|
||||||
|
|
||||||
|
测试用例2:
|
||||||
|
[第二组输入...]
|
||||||
|
|
||||||
|
请生成 ${testCaseCount} 组测试输入数据:`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callAPI(prompt: string): Promise<string> {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("未配置 DASHSCOPE_API_KEY 环境变量");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(DASHSCOPE_BASE, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: getModel(),
|
||||||
|
input: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"你是信息学奥林匹克竞赛测试数据生成专家。根据题目描述生成测试输入,严格按要求的 JSON 或文本格式返回,不要省略或占位。",
|
||||||
|
},
|
||||||
|
{ role: "user", content: prompt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
max_tokens: 8000,
|
||||||
|
temperature: 0.3,
|
||||||
|
result_format: "message",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(180_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`DashScope API 错误 ${res.status}: ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
output?: { text?: string; choices?: Array<{ message?: { content?: string } }> };
|
||||||
|
};
|
||||||
|
const text =
|
||||||
|
data.output?.text ??
|
||||||
|
data.output?.choices?.[0]?.message?.content ??
|
||||||
|
"";
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processTestCases(raw: unknown[], expectedCount: number): AITestCase[] {
|
||||||
|
const result: AITestCase[] = [];
|
||||||
|
for (let i = 0; i < Math.min(raw.length, expectedCount); i++) {
|
||||||
|
const item = raw[i];
|
||||||
|
if (typeof item === "string" && item.trim()) {
|
||||||
|
result.push({ input: item.trim() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item && typeof item === "object" && "input" in item) {
|
||||||
|
const obj = item as { input?: unknown; expectedOutput?: unknown };
|
||||||
|
const input = obj.input;
|
||||||
|
if (typeof input === "string" && input.trim()) {
|
||||||
|
result.push({
|
||||||
|
input: input.trim().replace(/\\n/g, "\n"),
|
||||||
|
...(typeof obj.expectedOutput === "string" && obj.expectedOutput
|
||||||
|
? { expectedOutput: obj.expectedOutput }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSONResponse(aiResponse: string, expectedCount: number): AITestCase[] {
|
||||||
|
// 1) 直接解析整段
|
||||||
|
const trimmed = aiResponse.trim();
|
||||||
|
try {
|
||||||
|
const direct = JSON.parse(trimmed) as { test_cases?: unknown[]; testCases?: unknown[] };
|
||||||
|
const arr = direct.test_cases ?? direct.testCases;
|
||||||
|
if (Array.isArray(arr) && arr.length > 0) {
|
||||||
|
return processTestCases(arr, expectedCount);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) ```json ... ```
|
||||||
|
const jsonBlock = aiResponse.match(/```json\s*([\s\S]*?)\s*```/);
|
||||||
|
if (jsonBlock) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonBlock[1].trim()) as {
|
||||||
|
test_cases?: unknown[];
|
||||||
|
testCases?: unknown[];
|
||||||
|
};
|
||||||
|
const arr = data.test_cases ?? data.testCases;
|
||||||
|
if (Array.isArray(arr) && arr.length > 0) {
|
||||||
|
return processTestCases(arr, expectedCount);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTextResponse(text: string, expectedCount: number): AITestCase[] {
|
||||||
|
const cases: AITestCase[] = [];
|
||||||
|
const parts = text.split(/(?:测试用例|测试数据|Test\s*Case)\s*\d+\s*[::]?\s*\n/i);
|
||||||
|
for (let i = 1; i < parts.length && cases.length < expectedCount; i++) {
|
||||||
|
const block = parts[i]!.trim();
|
||||||
|
const nextCase = block.search(/(?:测试用例|测试数据|Test\s*Case)\s*\d+\s*[::]/i);
|
||||||
|
const content = nextCase >= 0 ? block.slice(0, nextCase).trim() : block;
|
||||||
|
if (content && !content.includes("...") && !content.includes("省略")) {
|
||||||
|
cases.push({ input: content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cases;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAIResponse(aiResponse: string, expectedCount: number): AITestCase[] {
|
||||||
|
const fromJson = parseJSONResponse(aiResponse, expectedCount);
|
||||||
|
if (fromJson.length > 0) return fromJson;
|
||||||
|
return parseTextResponse(aiResponse, expectedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateParsedCases(cases: AITestCase[]): AITestCase[] {
|
||||||
|
return cases.filter((c) => {
|
||||||
|
const input = c.input ?? "";
|
||||||
|
if (!input.trim()) return false;
|
||||||
|
if (input.includes("...") || input.includes("......") || input.includes("省略")) return false;
|
||||||
|
if (input.includes("占位符") || input.includes("placeholder")) return false;
|
||||||
|
if (/^\s*[{\[]/.test(input) && (input.includes('"input"') || input.includes("test_cases"))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isQwenAvailable(): boolean {
|
||||||
|
return isAIEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateTestCases(params: GenerateTestCasesParams): Promise<AITestCase[]> {
|
||||||
|
if (!isAIEnabled()) {
|
||||||
|
throw new Error("AI 未启用或未配置 DASHSCOPE_API_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRetries = 2;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
||||||
|
try {
|
||||||
|
const prompt = buildPrompt(params);
|
||||||
|
const response = await callAPI(prompt);
|
||||||
|
const parsed = parseAIResponse(response, params.testCaseCount);
|
||||||
|
const valid = validateParsedCases(parsed);
|
||||||
|
|
||||||
|
if (valid.length > 0) {
|
||||||
|
return valid.slice(0, params.testCaseCount);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
if (attempt <= maxRetries) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error("AI 生成测试用例失败");
|
||||||
|
}
|
||||||
65
app/lib/testcase-backend/runner.ts
Normal file
65
app/lib/testcase-backend/runner.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const CPP_STANDARD = process.env.CPP_STANDARD || "c++14";
|
||||||
|
const CPP_FLAGS = process.env.CPP_FLAGS || "-Wall -Wno-unused-variable";
|
||||||
|
|
||||||
|
export async function validateStandardCode(code: string): Promise<void> {
|
||||||
|
if (!code?.trim()) {
|
||||||
|
throw new Error("标准程序不能为空");
|
||||||
|
}
|
||||||
|
if (!code.includes("#include") || !code.includes("main")) {
|
||||||
|
throw new Error("标准程序必须包含 #include 和 main 函数");
|
||||||
|
}
|
||||||
|
|
||||||
|
const os = await import("os");
|
||||||
|
const tempDir = path.join(os.tmpdir(), "testcase_validate_" + Date.now());
|
||||||
|
await fs.mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cppFile = path.join(tempDir, "test.cpp");
|
||||||
|
const exeFile = path.join(tempDir, "test");
|
||||||
|
await fs.writeFile(cppFile, code);
|
||||||
|
const compileCommand = `g++ -o "${exeFile}" "${cppFile}" -std=${CPP_STANDARD} ${CPP_FLAGS}`;
|
||||||
|
await execAsync(compileCommand, { timeout: 10000 });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const e = new Error(`编译失败: ${message}`);
|
||||||
|
(e as Error & { code?: string }).code = "COMPILATION_ERROR";
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeStandardCode(code: string, input: string): Promise<string> {
|
||||||
|
const os = await import("os");
|
||||||
|
const tempDir = path.join(os.tmpdir(), "testcase_run_" + Date.now());
|
||||||
|
await fs.mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
const cppFile = path.join(tempDir, "solution.cpp");
|
||||||
|
const exeFile = path.join(tempDir, "solution");
|
||||||
|
const inputFile = path.join(tempDir, "input.txt");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(cppFile, code);
|
||||||
|
const compileCommand = `g++ -o "${exeFile}" "${cppFile}" -std=${CPP_STANDARD} ${CPP_FLAGS}`;
|
||||||
|
await execAsync(compileCommand);
|
||||||
|
await fs.writeFile(inputFile, input);
|
||||||
|
const executeCommand = `"${exeFile}" < "${inputFile}"`;
|
||||||
|
const { stdout, stderr } = await execAsync(executeCommand, { timeout: 10000 });
|
||||||
|
if (stderr) console.warn("C++ execution warning:", stderr);
|
||||||
|
return stdout.trim();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const e = new Error(`程序执行失败: ${message}`);
|
||||||
|
(e as Error & { code?: string }).code = "EXECUTION_ERROR";
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/lib/testcase-backend/store.ts
Normal file
52
app/lib/testcase-backend/store.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Task, CreateTaskParams } from "./types";
|
||||||
|
|
||||||
|
const tasks = new Map<string, Task>();
|
||||||
|
|
||||||
|
export function getTask(taskId: string): Task | undefined {
|
||||||
|
return tasks.get(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTask(taskId: string, task: Task): void {
|
||||||
|
tasks.set(taskId, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTask(taskId: string, params: CreateTaskParams): Task {
|
||||||
|
const task: Task = {
|
||||||
|
id: taskId,
|
||||||
|
status: "pending",
|
||||||
|
title: params.title.trim(),
|
||||||
|
englishName: params.englishName.trim().toLowerCase(),
|
||||||
|
description: params.description.trim(),
|
||||||
|
standardCode: params.standardCode.trim(),
|
||||||
|
testCaseCount: params.testCaseCount,
|
||||||
|
serviceLevel: params.serviceLevel || "standard",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
progress: 0,
|
||||||
|
errorMessage: null,
|
||||||
|
generatedCases: 0,
|
||||||
|
};
|
||||||
|
tasks.set(taskId, task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRunningTaskByEnglishName(englishName: string): Task | undefined {
|
||||||
|
const name = englishName.trim().toLowerCase();
|
||||||
|
for (const task of tasks.values()) {
|
||||||
|
if (task.englishName === name && (task.status === "pending" || task.status === "running")) {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEstimatedTime(serviceLevel: string, caseCount: number): number {
|
||||||
|
const baseTime = Math.ceil(caseCount / 2);
|
||||||
|
const multiplier: Record<string, number> = {
|
||||||
|
standard: 1.0,
|
||||||
|
domestic: 1.5,
|
||||||
|
pro: 1.2,
|
||||||
|
max: 2.0,
|
||||||
|
};
|
||||||
|
return Math.max(30, baseTime * (multiplier[serviceLevel] ?? 1.0));
|
||||||
|
}
|
||||||
29
app/lib/testcase-backend/types.ts
Normal file
29
app/lib/testcase-backend/types.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
status: "pending" | "running" | "completed" | "failed";
|
||||||
|
title: string;
|
||||||
|
englishName: string;
|
||||||
|
description: string;
|
||||||
|
standardCode: string;
|
||||||
|
testCaseCount: number;
|
||||||
|
serviceLevel: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
progress: number;
|
||||||
|
currentMessage?: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
generatedCases: number;
|
||||||
|
completedAt?: Date;
|
||||||
|
failedAt?: Date;
|
||||||
|
outputDirName?: string;
|
||||||
|
zipFileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskParams {
|
||||||
|
title: string;
|
||||||
|
englishName: string;
|
||||||
|
description: string;
|
||||||
|
standardCode: string;
|
||||||
|
testCaseCount: number;
|
||||||
|
serviceLevel: string;
|
||||||
|
}
|
||||||
31
app/lib/testcase-backend/zip.ts
Normal file
31
app/lib/testcase-backend/zip.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import archiver from "archiver";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export function createZipFile(
|
||||||
|
sourceDir: string,
|
||||||
|
zipPath: string,
|
||||||
|
englishName: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const output = fs.createWriteStream(zipPath);
|
||||||
|
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||||
|
|
||||||
|
output.on("close", () => resolve());
|
||||||
|
archive.on("error", reject);
|
||||||
|
archive.pipe(output);
|
||||||
|
|
||||||
|
if (englishName) {
|
||||||
|
const files = fs.readdirSync(sourceDir);
|
||||||
|
const targetFiles = files.filter(
|
||||||
|
(f) => f.startsWith(englishName) && (f.endsWith(".in") || f.endsWith(".out"))
|
||||||
|
);
|
||||||
|
for (const file of targetFiles) {
|
||||||
|
archive.file(path.join(sourceDir, file), { name: file });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
archive.directory(sourceDir, false);
|
||||||
|
}
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
6
app/lib/utils.ts
Normal file
6
app/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
108
app/lorem-ipsum/page.tsx
Normal file
108
app/lorem-ipsum/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Type, Copy, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const LOREM_TEXT = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||||
|
Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac tellus et risus vulputate vehicula. Donec lobortis risus a elit. Etiam tempor. Ut ullamcorper, ligula eu tempor congue, eros est euismod turpis, id tincidunt sapien risus a quam. Maecenas fermentum consequat mi. Donec fermentum. Pellentesque malesuada nulla a mi. Duis sapien sem, aliquet nec, commodo eget, consequat quis, neque. Aliquam faucibus, elit ut dictum aliquet, felis nisl adipiscing sapien, sed malesuada diam lacus eget erat. Cras mollis scelerisque nunc. Nullam arcu. Aliquam consequat. Curabitur augue lorem, dapibus quis, laoreet et, pretium ac, nisi.
|
||||||
|
Aenean magna nisl, mollis quis, molestie eu, feugiat in, orci. In hac habitasse platea dictumst. Fusce convallis metus id felis luctus adipiscing. Pellentesque egestas, neque sit amet convallis pulvinar, justo nulla eleifend augue, ac auctor orci leo non est. Quisque id mi. Ut tincidunt tincidunt erat. Etiam feugiat lorem non metus. Vestibulum dapibus nunc ac augue. Curabitur vestibulum aliquam leo. Praesent egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque fermentum. Maecenas adipiscing ante non diam sodales hendrerit.`;
|
||||||
|
|
||||||
|
export default function LoremIpsumPage() {
|
||||||
|
const [paragraphs, setParagraphs] = useState(3);
|
||||||
|
const [generated, setGenerated] = useState("");
|
||||||
|
|
||||||
|
const generate = () => {
|
||||||
|
const allParagraphs = LOREM_TEXT.split('\n');
|
||||||
|
let result: string[] = [];
|
||||||
|
|
||||||
|
// Simple logic: repeat paragraphs if requested count is larger than source
|
||||||
|
for (let i = 0; i < paragraphs; i++) {
|
||||||
|
result.push(allParagraphs[i % allParagraphs.length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setGenerated(result.join('\n\n'));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
generate();
|
||||||
|
}, [paragraphs]);
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (!generated) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(generated);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-zinc-500 to-stone-600 shadow-lg">
|
||||||
|
<Type className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Lorem Ipsum 生成器</h1>
|
||||||
|
<p className="text-muted-foreground">生成用于排版的拉丁文占位符文本</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-4">
|
||||||
|
<Card className="lg:col-span-1 h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">设置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>段落数量</Label>
|
||||||
|
<span className="text-sm font-mono bg-muted px-2 py-0.5 rounded">{paragraphs}</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[paragraphs]}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setParagraphs(v[0]);
|
||||||
|
// Auto regenerate on slider change for better UX?
|
||||||
|
// Or let user click? Let's auto-generate in useEffect if we wanted,
|
||||||
|
// but direct call is easier here to avoid loop issues.
|
||||||
|
// Actually, let's just update state and let user click generate,
|
||||||
|
// or use an effect. Effect is cleaner.
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
step={1}
|
||||||
|
className="py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={generate} className="w-full gap-2">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
重新生成
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-3">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base">生成结果</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={copyToClipboard}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="min-h-75 p-4 rounded-md bg-muted/30 whitespace-pre-wrap font-serif text-lg leading-relaxed text-muted-foreground">
|
||||||
|
{generated}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
app/markdown/page.tsx
Normal file
135
app/markdown/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Editor from "@monaco-editor/react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FileText, Download, Copy, Eye, Edit3, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
|
||||||
|
const DEFAULT_MARKDOWN = `# Welcome to Markdown Editor
|
||||||
|
|
||||||
|
This is a **live preview** editor. You can write your markdown on the left (or top), and see the result instantly.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [x] GFM Support (Tables, Tasks, Strikethrough)
|
||||||
|
- [x] Syntax Highlighting
|
||||||
|
- [x] Vertical/Horizontal Layout (Responsive)
|
||||||
|
|
||||||
|
### Code Example
|
||||||
|
|
||||||
|
\`\`\`javascript
|
||||||
|
function hello() {
|
||||||
|
console.log("Hello World!");
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Table Example
|
||||||
|
|
||||||
|
| Item | Price | Quantity |
|
||||||
|
|:-----|:-----:|:---------|
|
||||||
|
| Apple| $1.00 | 5 |
|
||||||
|
| Pear | $2.00 | 10 |
|
||||||
|
|
||||||
|
> "The best way to predict the future is to create it."
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function MarkdownPage() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [markdown, setMarkdown] = useState(DEFAULT_MARKDOWN);
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(markdown);
|
||||||
|
toast.success("Markdown 已复制");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadMarkdown = () => {
|
||||||
|
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "document.md";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("下载已开始");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 h-[calc(100vh-140px)] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b pb-4 shrink-0">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-blue-500 to-indigo-600 shadow-lg">
|
||||||
|
<FileText className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Markdown 编辑器</h1>
|
||||||
|
<p className="text-muted-foreground">实时预览、GFM 支持、所见即所得</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setMarkdown("")}>
|
||||||
|
<Trash2 className="h-4 w-4 lg:mr-2" />
|
||||||
|
<span className="hidden lg:inline">清空</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={copyToClipboard}>
|
||||||
|
<Copy className="h-4 w-4 lg:mr-2" />
|
||||||
|
<span className="hidden lg:inline">复制</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={downloadMarkdown}>
|
||||||
|
<Download className="h-4 w-4 lg:mr-2" />
|
||||||
|
<span className="hidden lg:inline">下载 .md</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 grid grid-rows-2 lg:grid-cols-2 lg:grid-rows-1 gap-4 lg:gap-6">
|
||||||
|
{/* Editor */}
|
||||||
|
<Card className="flex flex-col min-h-0 border-0 shadow-lg ring-1 ring-border">
|
||||||
|
<CardHeader className="py-2 px-4 border-b bg-muted/30 flex flex-row items-center space-y-0">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Edit3 className="h-4 w-4" /> 编辑
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 flex-1 min-h-0 relative">
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="markdown"
|
||||||
|
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||||
|
value={markdown}
|
||||||
|
onChange={(value) => setMarkdown(value || "")}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
wordWrap: "on",
|
||||||
|
padding: { top: 16 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<Card className="flex flex-col min-h-0 border-0 shadow-lg ring-1 ring-border overflow-hidden">
|
||||||
|
<CardHeader className="py-2 px-4 border-b bg-muted/30 flex flex-row items-center space-y-0">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Eye className="h-4 w-4" /> 预览
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 min-h-0 overflow-y-auto p-6 prose dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{markdown}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
app/move-car/display/page.tsx
Normal file
172
app/move-car/display/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Car, Phone, Bell, Info } from "lucide-react";
|
||||||
|
|
||||||
|
export default function MoveCarDisplay() {
|
||||||
|
const [plateNumber, setPlateNumber] = useState("");
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState("");
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [uid, setUid] = useState("");
|
||||||
|
const [newEnergy, setNewEnergy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
setPlateNumber(urlParams.get("plateNumber") || "");
|
||||||
|
setPhoneNumber(urlParams.get("phoneNumber") || "");
|
||||||
|
setToken(urlParams.get("token") || "");
|
||||||
|
setUid(urlParams.get("uid") || "");
|
||||||
|
setNewEnergy(urlParams.get("new") === "true");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const notifyOwner = () => {
|
||||||
|
const currentTime = new Date().getTime();
|
||||||
|
const lastNotifyTimeKey = "lastNotifyTime" + plateNumber;
|
||||||
|
const lastNotifyTime = localStorage.getItem(lastNotifyTimeKey);
|
||||||
|
const timeDifference = lastNotifyTime
|
||||||
|
? (currentTime - parseInt(lastNotifyTime)) / 1000
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (lastNotifyTime && timeDifference < 60) {
|
||||||
|
toast.warning("您已发送过通知,请1分钟后再次尝试。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = fetch("https://wxpusher.zjiecode.com/api/send/message", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
appToken: token,
|
||||||
|
content: "您好,有人需要您挪车,请及时处理。",
|
||||||
|
contentType: 1,
|
||||||
|
uids: [uid],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.code === 1000) {
|
||||||
|
localStorage.setItem(lastNotifyTimeKey, currentTime.toString());
|
||||||
|
return "通知已发送!";
|
||||||
|
} else {
|
||||||
|
throw new Error("通知发送失败");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: '正在发送通知...',
|
||||||
|
success: (data) => data,
|
||||||
|
error: '通知发送失败,请稍后重试',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const callNumber = () => {
|
||||||
|
window.location.href = "tel:" + phoneNumber;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-md mx-auto pt-4 px-2">
|
||||||
|
{/* 车牌展示区域 */}
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<div
|
||||||
|
className={`relative flex h-32 w-full items-center justify-center overflow-hidden rounded-lg border-[3px] shadow-xl ${
|
||||||
|
newEnergy
|
||||||
|
? "border-black/80 bg-linear-to-b from-[#F0F3F5] to-[#42C063] text-black"
|
||||||
|
: "border-white bg-[#003399] text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 装饰性反光效果 */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-linear-to-br from-white/20 to-transparent opacity-50" />
|
||||||
|
|
||||||
|
{/* 车牌边框内饰线 (仅蓝牌显示) */}
|
||||||
|
{!newEnergy && (
|
||||||
|
<div className="absolute inset-1 rounded-md border border-white/50" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10 flex items-center gap-1 font-mono text-5xl font-bold tracking-widest">
|
||||||
|
<span>{plateNumber.slice(0, 2)}</span>
|
||||||
|
{newEnergy ? (
|
||||||
|
<span className="mx-1 h-2 w-2 rounded-full bg-current opacity-20" />
|
||||||
|
) : (
|
||||||
|
<span className="mx-2 h-2 w-2 rounded-full bg-current opacity-80" />
|
||||||
|
)}
|
||||||
|
<span>{plateNumber.slice(2)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新能源标志水印 (仅绿牌显示) */}
|
||||||
|
{newEnergy && (
|
||||||
|
<div className="absolute -bottom-6 -right-6 rotate-12 opacity-10">
|
||||||
|
<Car className="h-24 w-24" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 车辆信息 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
车辆信息
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div className="text-muted-foreground">临时停靠</div>
|
||||||
|
<div className="col-span-2 font-medium">请多关照</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground">车牌号码</div>
|
||||||
|
<div className="col-span-2 font-medium font-mono">{plateNumber}</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground">联系电话</div>
|
||||||
|
<div className="col-span-2 font-medium font-mono">{phoneNumber}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<Card className="border-t-4 border-t-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-center">联系车主</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{uid && token && (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className={`w-full gap-2 text-lg h-14 ${
|
||||||
|
newEnergy
|
||||||
|
? "bg-emerald-600 hover:bg-emerald-700 shadow-emerald-200 dark:shadow-none"
|
||||||
|
: "bg-blue-600 hover:bg-blue-700 shadow-blue-200 dark:shadow-none"
|
||||||
|
} shadow-xl transition-all hover:scale-[1.02] active:scale-[0.98]`}
|
||||||
|
onClick={notifyOwner}
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
微信通知车主
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant={uid && token ? "outline" : "default"}
|
||||||
|
className={`w-full gap-2 text-lg h-14 ${!(uid && token) ? (newEnergy
|
||||||
|
? "bg-emerald-600 hover:bg-emerald-700 shadow-emerald-200 dark:shadow-none shadow-xl transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
: "bg-blue-600 hover:bg-blue-700 shadow-blue-200 dark:shadow-none shadow-xl transition-all hover:scale-[1.02] active:scale-[0.98]") : ""}`}
|
||||||
|
onClick={callNumber}
|
||||||
|
>
|
||||||
|
<Phone className="h-5 w-5" />
|
||||||
|
一键拨打电话
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 温馨提示 */}
|
||||||
|
<div className="px-4 py-2 bg-muted/50 rounded-lg text-xs text-center text-muted-foreground">
|
||||||
|
💡 温馨提示:请文明用车,合理停放。如需挪车,请耐心等待车主回应。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
app/move-car/page.tsx
Normal file
347
app/move-car/page.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
import { Car, MessageSquare, QrCode, Info, Copy, Zap, RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
plateNumber: z.string().min(1, "请输入车牌号"),
|
||||||
|
phoneNumber: z
|
||||||
|
.string()
|
||||||
|
.min(1, "请输入联系电话")
|
||||||
|
.regex(/^1[3-9]\d{9}$/, "请输入有效的手机号"),
|
||||||
|
token: z.string(),
|
||||||
|
uid: z.string(),
|
||||||
|
newEnergy: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export default function MoveCar() {
|
||||||
|
const [generatedUrl, setGeneratedUrl] = useState("");
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
plateNumber: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
token: "",
|
||||||
|
uid: "",
|
||||||
|
newEnergy: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (values: FormValues) => {
|
||||||
|
const url = new URL(window.location.href + "/display");
|
||||||
|
url.searchParams.append("plateNumber", values.plateNumber);
|
||||||
|
url.searchParams.append("phoneNumber", values.phoneNumber);
|
||||||
|
if (values.token) url.searchParams.append("token", values.token);
|
||||||
|
if (values.uid) url.searchParams.append("uid", values.uid);
|
||||||
|
if (values.newEnergy) url.searchParams.append("new", "true");
|
||||||
|
setGeneratedUrl(url.toString());
|
||||||
|
toast.success("码牌生成成功");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(generatedUrl);
|
||||||
|
toast.success("链接已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.reset();
|
||||||
|
setGeneratedUrl("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-amber-400 to-amber-600 shadow-lg">
|
||||||
|
<Car className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">挪车码牌生成器</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
生成专属挪车码牌,让他人轻松联系您,支持微信推送通知
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Left: Form */}
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Car className="h-4 w-4" />
|
||||||
|
车辆信息
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="plateNumber"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>车牌号</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="如:京A12345" {...field} className="text-lg" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phoneNumber"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>联系电话</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="如:13800138000" {...field} className="text-lg" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="newEnergy"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4 shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-base flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4 text-emerald-500" />
|
||||||
|
新能源车辆
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
勾选此项将在码牌上显示新能源标识
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
微信推送设置(可选)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Alert className="bg-blue-50/50 dark:bg-blue-950/20 text-blue-900 dark:text-blue-200 border-blue-200 dark:border-blue-800">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>提示</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
配置微信推送后,当有人扫码时您将收到通知
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-1">
|
||||||
|
Token
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>WxPusher的应用Token</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="应用Token" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="uid"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-1">
|
||||||
|
UID
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>用户的UID</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="用户UID" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground text-right">
|
||||||
|
需要微信推送?
|
||||||
|
<a
|
||||||
|
href="https://wxpusher.zjiecode.com/docs/#/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline ml-1"
|
||||||
|
>
|
||||||
|
查看配置文档 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" size="lg" className="flex-1 gap-2">
|
||||||
|
<QrCode className="h-4 w-4" />
|
||||||
|
生成挪车码牌
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="lg" onClick={resetForm} className="gap-2">
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Preview */}
|
||||||
|
<div>
|
||||||
|
<Card className="h-full flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<QrCode className="h-4 w-4" />
|
||||||
|
{generatedUrl ? "生成成功" : "预览区域"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col justify-center">
|
||||||
|
{generatedUrl ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Alert variant="default" className="border-emerald-200 bg-emerald-50 dark:bg-emerald-950/20 text-emerald-900 dark:text-emerald-200">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>码牌生成成功!</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
扫描二维码或点击链接查看码牌
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-4 p-8 bg-white rounded-xl shadow-sm border mx-auto">
|
||||||
|
<QRCodeSVG value={generatedUrl} size={200} />
|
||||||
|
<span className="text-sm text-muted-foreground">扫码查看码牌</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-sm font-medium">码牌链接:</span>
|
||||||
|
<div className="p-3 bg-muted rounded-md break-all text-xs font-mono">
|
||||||
|
{generatedUrl}
|
||||||
|
</div>
|
||||||
|
<Button onClick={copyUrl} className="w-full gap-2" variant="secondary">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
复制链接
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground space-y-4">
|
||||||
|
<QrCode className="h-16 w-16 opacity-20" />
|
||||||
|
<p>填写完信息后,二维码将在这里显示</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">功能特点</h4>
|
||||||
|
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1">
|
||||||
|
<li>快速生成专属挪车码牌</li>
|
||||||
|
<li>支持新能源车辆标识</li>
|
||||||
|
<li>可选微信推送功能</li>
|
||||||
|
<li>移动端友好的码牌展示</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">使用步骤</h4>
|
||||||
|
<ol className="list-decimal pl-4 text-xs text-muted-foreground space-y-1">
|
||||||
|
<li>填写车牌号和联系电话</li>
|
||||||
|
<li>选择是否为新能源车辆</li>
|
||||||
|
<li>可选配置微信推送</li>
|
||||||
|
<li>点击生成按钮获取码牌</li>
|
||||||
|
<li>将二维码放置在车内</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">微信推送配置</h4>
|
||||||
|
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1">
|
||||||
|
<li>访问 WxPusher 官网注册应用</li>
|
||||||
|
<li>获取应用 Token 和用户 UID</li>
|
||||||
|
<li>关注微信公众号绑定账号</li>
|
||||||
|
<li>配置后可收到扫码通知</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
667
app/page.tsx
Normal file
667
app/page.tsx
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Code,
|
||||||
|
Zap,
|
||||||
|
Clock,
|
||||||
|
Lock,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Hash,
|
||||||
|
Scissors,
|
||||||
|
QrCode,
|
||||||
|
FileText,
|
||||||
|
Car,
|
||||||
|
CloudDownload,
|
||||||
|
Search,
|
||||||
|
LayoutGrid,
|
||||||
|
Diff,
|
||||||
|
FileJson,
|
||||||
|
FileCode,
|
||||||
|
Database,
|
||||||
|
Braces,
|
||||||
|
Type,
|
||||||
|
Binary,
|
||||||
|
Languages,
|
||||||
|
ImageIcon,
|
||||||
|
Calculator,
|
||||||
|
FileSpreadsheet,
|
||||||
|
ImagePlus,
|
||||||
|
Network,
|
||||||
|
Barcode,
|
||||||
|
Terminal,
|
||||||
|
FerrisWheel,
|
||||||
|
CircleDollarSign,
|
||||||
|
Palette,
|
||||||
|
Timer,
|
||||||
|
Monitor,
|
||||||
|
Keyboard,
|
||||||
|
Volume2,
|
||||||
|
Users,
|
||||||
|
Tally5,
|
||||||
|
Hourglass,
|
||||||
|
Watch,
|
||||||
|
KeySquare,
|
||||||
|
ShieldAlert,
|
||||||
|
CaseSensitive,
|
||||||
|
Trophy,
|
||||||
|
TestTube,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// --- Data Definitions ---
|
||||||
|
|
||||||
|
interface Tool {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
color: string;
|
||||||
|
isPlanned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
tools: Tool[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories: Category[] = [
|
||||||
|
{
|
||||||
|
id: "oi-tools",
|
||||||
|
name: "信奥工具",
|
||||||
|
icon: <Trophy className="h-4 w-4" />,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "testcase-generator",
|
||||||
|
title: "测试点生成",
|
||||||
|
description: "生成算法竞赛测试用例数据",
|
||||||
|
icon: <TestTube className="h-6 w-6" />,
|
||||||
|
href: "/testcase-generator",
|
||||||
|
color: "text-purple-500",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dev-tools",
|
||||||
|
name: "开发工具",
|
||||||
|
icon: <Code className="h-4 w-4" />,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "json-formatter",
|
||||||
|
title: "JSON 格式化",
|
||||||
|
description: "JSON 数据格式化、压缩和验证",
|
||||||
|
icon: <Code className="h-6 w-6" />,
|
||||||
|
href: "/json-formatter",
|
||||||
|
color: "text-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "diff",
|
||||||
|
title: "文本 Diff 对比",
|
||||||
|
description: "使用 Monaco 显示文本差异",
|
||||||
|
icon: <Diff className="h-6 w-6" />,
|
||||||
|
href: "/diff",
|
||||||
|
color: "text-slate-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "yaml-formatter",
|
||||||
|
title: "YAML 格式化",
|
||||||
|
description: "YAML 数据格式化和验证",
|
||||||
|
icon: <FileJson className="h-6 w-6" />,
|
||||||
|
href: "/yaml-formatter",
|
||||||
|
color: "text-amber-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "html-formatter",
|
||||||
|
title: "HTML 格式化",
|
||||||
|
description: "HTML 代码格式化和美化",
|
||||||
|
icon: <FileCode className="h-6 w-6" />,
|
||||||
|
href: "/html-formatter",
|
||||||
|
color: "text-orange-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "markdown",
|
||||||
|
title: "Markdown 编辑",
|
||||||
|
description: "Markdown 实时预览和导出",
|
||||||
|
icon: <FileText className="h-6 w-6" />,
|
||||||
|
href: "/markdown",
|
||||||
|
color: "text-blue-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sql-formatter",
|
||||||
|
title: "SQL 格式化",
|
||||||
|
description: "SQL 语句格式化和美化",
|
||||||
|
icon: <Database className="h-6 w-6" />,
|
||||||
|
href: "/sql-formatter",
|
||||||
|
color: "text-indigo-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "html-escape",
|
||||||
|
title: "HTML 转义",
|
||||||
|
description: "HTML 实体编码/解码",
|
||||||
|
icon: <Braces className="h-6 w-6" />,
|
||||||
|
href: "/html-escape",
|
||||||
|
color: "text-emerald-600",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "text-tools",
|
||||||
|
name: "文本工具",
|
||||||
|
icon: <Scissors className="h-4 w-4" />,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "text-formatter",
|
||||||
|
title: "文字格式化",
|
||||||
|
description: "清理文本中的多余空格和格式",
|
||||||
|
icon: <Scissors className="h-6 w-6" />,
|
||||||
|
href: "/text-formatter",
|
||||||
|
color: "text-pink-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "case-converter",
|
||||||
|
title: "大小写转换",
|
||||||
|
description: "大写、小写、驼峰等格式转换",
|
||||||
|
icon: <CaseSensitive className="h-6 w-6" />,
|
||||||
|
href: "/case-converter",
|
||||||
|
color: "text-violet-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lorem-ipsum",
|
||||||
|
title: "Lorem Ipsum",
|
||||||
|
description: "占位文本生成工具",
|
||||||
|
icon: <Type className="h-6 w-6" />,
|
||||||
|
href: "/lorem-ipsum",
|
||||||
|
color: "text-zinc-500",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "encoding-tools",
|
||||||
|
name: "编码工具",
|
||||||
|
icon: <Binary className="h-4 w-4" />,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "base64",
|
||||||
|
title: "Base64 编解码",
|
||||||
|
description: "Base64 编码与解码转换工具",
|
||||||
|
icon: <Lock className="h-6 w-6" />,
|
||||||
|
href: "/base64",
|
||||||
|
color: "text-emerald-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "base58",
|
||||||
|
title: "Base58 编解码",
|
||||||
|
description: "常用于比特币地址等场景",
|
||||||
|
icon: <Binary className="h-6 w-6" />,
|
||||||
|
href: "/base58",
|
||||||
|
color: "text-amber-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "base32",
|
||||||
|
title: "Base32 编解码",
|
||||||
|
description: "常用于 TOTP 等场景",
|
||||||
|
icon: <Binary className="h-6 w-6" />,
|
||||||
|
href: "/base32",
|
||||||
|
color: "text-orange-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unicode",
|
||||||
|
title: "Unicode 转换",
|
||||||
|
description: "Unicode 字符与编码相互转换",
|
||||||
|
icon: <Languages className="h-6 w-6" />,
|
||||||
|
href: "/unicode",
|
||||||
|
color: "text-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "url-encode",
|
||||||
|
title: "URL 编解码",
|
||||||
|
description: "URL 参数编码与解码处理",
|
||||||
|
icon: <LinkIcon className="h-6 w-6" />,
|
||||||
|
href: "/url-encode",
|
||||||
|
color: "text-cyan-500",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "conversion-tools",
|
||||||
|
name: "转换工具",
|
||||||
|
icon: <Clock className="h-4 w-4" />,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "timestamp",
|
||||||
|
title: "时间戳转换",
|
||||||
|
description: "时间戳与日期时间互相转换",
|
||||||
|
icon: <Clock className="h-6 w-6" />,
|
||||||
|
href: "/timestamp",
|
||||||
|
color: "text-orange-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "image-to-pixel",
|
||||||
|
title: "图片转像素画",
|
||||||
|
description: "将图片转换为像素艺术风格",
|
||||||
|
icon: <ImageIcon className="h-6 w-6" />,
|
||||||
|
href: "/image-to-pixel",
|
||||||
|
color: "text-pink-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "radix-converter",
|
||||||
|
title: "进制转换器",
|
||||||
|
description: "二/八/十/十六进制互转",
|
||||||
|
icon: <Calculator className="h-6 w-6" />,
|
||||||
|
href: "/radix-converter",
|
||||||
|
color: "text-indigo-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "csv-json",
|
||||||
|
title: "CSV/JSON 互转",
|
||||||
|
description: "CSV 与 JSON 格式互相转换",
|
||||||
|
icon: <FileSpreadsheet className="h-6 w-6" />,
|
||||||
|
href: "/csv-json",
|
||||||
|
color: "text-green-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "image-base64",
|
||||||
|
title: "图片 Base64",
|
||||||
|
description: "图片与 Base64 字符串互转",
|
||||||
|
icon: <ImagePlus className="h-6 w-6" />,
|
||||||
|
href: "/image-base64",
|
||||||
|
color: "text-purple-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ip-radix",
|
||||||
|
title: "IP 地址转换",
|
||||||
|
description: "IP 在不同进制间相互转换",
|
||||||
|
icon: <Network className="h-6 w-6" />,
|
||||||
|
href: "/ip-radix",
|
||||||
|
color: "text-cyan-600",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "generation-tools",
|
||||||
|
name: "生成工具",
|
||||||
|
icon: <QrCode className="h-4 w-4" />,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "uuid",
|
||||||
|
title: "UUID 生成",
|
||||||
|
description: "生成 UUID/GUID 唯一标识符",
|
||||||
|
icon: <FileText className="h-6 w-6" />,
|
||||||
|
href: "/uuid",
|
||||||
|
color: "text-indigo-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "random-string",
|
||||||
|
title: "随机密码生成",
|
||||||
|
description: "生成安全的随机密码",
|
||||||
|
icon: <Zap className="h-6 w-6" />,
|
||||||
|
href: "/random-string",
|
||||||
|
color: "text-purple-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "qrcode",
|
||||||
|
title: "二维码生成",
|
||||||
|
description: "快速生成自定义样式的二维码",
|
||||||
|
icon: <QrCode className="h-6 w-6" />,
|
||||||
|
href: "/qrcode",
|
||||||
|
color: "text-green-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "barcode",
|
||||||
|
title: "条形码生成",
|
||||||
|
description: "生成各种格式的条形码",
|
||||||
|
icon: <Barcode className="h-6 w-6" />,
|
||||||
|
href: "/barcode",
|
||||||
|
color: "text-zinc-700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ascii-art",
|
||||||
|
title: "ASCII 艺术",
|
||||||
|
description: "将文本转换为字符艺术",
|
||||||
|
icon: <Terminal className="h-6 w-6" />,
|
||||||
|
href: "/ascii-art",
|
||||||
|
color: "text-emerald-700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wheel",
|
||||||
|
title: "大转盘抽奖",
|
||||||
|
description: "随机抽奖决策工具",
|
||||||
|
icon: <FerrisWheel className="h-6 w-6" />,
|
||||||
|
href: "/wheel",
|
||||||
|
color: "text-rose-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "coin-flip",
|
||||||
|
title: "抛硬币",
|
||||||
|
description: "随机正反面决策",
|
||||||
|
icon: <CircleDollarSign className="h-6 w-6" />,
|
||||||
|
href: "/coin-flip",
|
||||||
|
color: "text-amber-600",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "utility-tools",
|
||||||
|
name: "实用工具",
|
||||||
|
icon: <LayoutGrid className="h-4 w-4" />,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "color-picker",
|
||||||
|
title: "颜色选择器",
|
||||||
|
description: "HEX/RGB/HSL 格式互转",
|
||||||
|
icon: <Palette className="h-6 w-6" />,
|
||||||
|
href: "/color-picker",
|
||||||
|
color: "text-pink-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "regex",
|
||||||
|
title: "正则测试器",
|
||||||
|
description: "测试和调试正则表达式",
|
||||||
|
icon: <Code className="h-6 w-6" />,
|
||||||
|
href: "/regex",
|
||||||
|
color: "text-blue-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cron",
|
||||||
|
title: "Cron 解析器",
|
||||||
|
description: "解析和验证 Cron 表达式",
|
||||||
|
icon: <Timer className="h-6 w-6" />,
|
||||||
|
href: "/cron",
|
||||||
|
color: "text-orange-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-agent",
|
||||||
|
title: "UA 解析器",
|
||||||
|
description: "解析浏览器 UA 字符串",
|
||||||
|
icon: <Monitor className="h-6 w-6" />,
|
||||||
|
href: "/user-agent",
|
||||||
|
color: "text-slate-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "keyboard",
|
||||||
|
title: "键盘检测器",
|
||||||
|
description: "检测键盘按键事件详情",
|
||||||
|
icon: <Keyboard className="h-6 w-6" />,
|
||||||
|
href: "/keyboard",
|
||||||
|
color: "text-zinc-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tts",
|
||||||
|
title: "文字转语音",
|
||||||
|
description: "将文本转换为语音播放",
|
||||||
|
icon: <Volume2 className="h-6 w-6" />,
|
||||||
|
href: "/tts",
|
||||||
|
color: "text-cyan-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "random-group",
|
||||||
|
title: "随机分组",
|
||||||
|
description: "快速公平地生成随机团队",
|
||||||
|
icon: <Users className="h-6 w-6" />,
|
||||||
|
href: "/random-group",
|
||||||
|
color: "text-indigo-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scoreboard",
|
||||||
|
title: "记分板",
|
||||||
|
description: "红蓝双方比分记录",
|
||||||
|
icon: <Tally5 className="h-6 w-6" />,
|
||||||
|
href: "/scoreboard",
|
||||||
|
color: "text-rose-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pomodoro",
|
||||||
|
title: "番茄钟",
|
||||||
|
description: "番茄工作法计时器",
|
||||||
|
icon: <Timer className="h-6 w-6" />,
|
||||||
|
href: "/pomodoro",
|
||||||
|
color: "text-red-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "counter",
|
||||||
|
title: "计数器",
|
||||||
|
description: "简单的计数工具",
|
||||||
|
icon: <Hash className="h-6 w-6" />,
|
||||||
|
href: "/counter",
|
||||||
|
color: "text-zinc-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "countdown",
|
||||||
|
title: "倒数计时器",
|
||||||
|
description: "设置倒计时到时提醒",
|
||||||
|
icon: <Hourglass className="h-6 w-6" />,
|
||||||
|
href: "/countdown",
|
||||||
|
color: "text-amber-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stopwatch",
|
||||||
|
title: "秒表",
|
||||||
|
description: "精确计时支持记圈",
|
||||||
|
icon: <Watch className="h-6 w-6" />,
|
||||||
|
href: "/stopwatch",
|
||||||
|
color: "text-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ip-calc",
|
||||||
|
title: "IP 地址计算",
|
||||||
|
description: "子网掩码与网络划分计算",
|
||||||
|
icon: <Network className="h-6 w-6" />,
|
||||||
|
href: "/ip-calc",
|
||||||
|
color: "text-blue-600",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crypto-tools",
|
||||||
|
name: "加密工具",
|
||||||
|
icon: <Lock className="h-4 w-4" />,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "hash",
|
||||||
|
title: "MD5/SHA 哈希",
|
||||||
|
description: "计算 MD5、SHA1/256/512 哈希",
|
||||||
|
icon: <Hash className="h-6 w-6" />,
|
||||||
|
href: "/hash",
|
||||||
|
color: "text-rose-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jwt",
|
||||||
|
title: "JWT 解码器",
|
||||||
|
description: "解析和查看 JWT 内容",
|
||||||
|
icon: <KeySquare className="h-6 w-6" />,
|
||||||
|
href: "/jwt",
|
||||||
|
color: "text-purple-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "aes-des",
|
||||||
|
title: "AES/DES 加密",
|
||||||
|
description: "对称加密解密工具",
|
||||||
|
icon: <ShieldAlert className="h-6 w-6" />,
|
||||||
|
href: "/aes-des",
|
||||||
|
color: "text-red-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bcrypt",
|
||||||
|
title: "Bcrypt 哈希",
|
||||||
|
description: "生成和验证 Bcrypt 密码",
|
||||||
|
icon: <Lock className="h-6 w-6" />,
|
||||||
|
href: "/bcrypt",
|
||||||
|
color: "text-slate-700",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// id: "life-tools",
|
||||||
|
// name: "生活工具",
|
||||||
|
// icon: <Car className="h-4 w-4" />,
|
||||||
|
// tools: [
|
||||||
|
// {
|
||||||
|
// id: "move-car",
|
||||||
|
// title: "挪车码牌",
|
||||||
|
// description: "生成专属挪车码牌,支持微信推送",
|
||||||
|
// icon: <Car className="h-6 w-6" />,
|
||||||
|
// href: "/move-car",
|
||||||
|
// color: "text-yellow-500",
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// id: "alipan-tv-token",
|
||||||
|
// title: "阿里云盘 Token",
|
||||||
|
// description: "获取阿里云盘 TV 端授权令牌",
|
||||||
|
// icon: <CloudDownload className="h-6 w-6" />,
|
||||||
|
// href: "/alipan-tv-token",
|
||||||
|
// color: "text-teal-500",
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Main Layout Component ---
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [activeCategory, setActiveCategory] = useState("all");
|
||||||
|
|
||||||
|
// Flatten tools for easier filtering
|
||||||
|
const allTools = useMemo(() => {
|
||||||
|
return categories.flatMap((cat) =>
|
||||||
|
cat.tools.map((tool) => ({ ...tool, categoryId: cat.id }))
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter Logic
|
||||||
|
const filteredTools = useMemo(() => {
|
||||||
|
let tools = allTools;
|
||||||
|
|
||||||
|
// 1. Category Filter
|
||||||
|
if (activeCategory !== "all") {
|
||||||
|
tools = tools.filter((tool) => tool.categoryId === activeCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Search Filter
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
tools = tools.filter(
|
||||||
|
(tool) =>
|
||||||
|
tool.title.toLowerCase().includes(query) ||
|
||||||
|
tool.description.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}, [allTools, activeCategory, searchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-6 pb-10">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="flex flex-col items-center pt-8 pb-4 text-center space-y-4 md:pt-12 lg:pt-16 animate-in fade-in slide-in-from-bottom-5 duration-700">
|
||||||
|
<div className="space-y-2 max-w-3xl px-4">
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight sm:text-4xl md:text-5xl text-foreground">
|
||||||
|
信奥工具箱
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto max-w-175 text-muted-foreground text-base sm:text-lg">
|
||||||
|
简约、高效的在线信奥学习工具集合。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="w-full max-w-lg px-4 relative mt-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索工具,例如:JSON、二维码..."
|
||||||
|
className="w-full h-12 pl-12 pr-4 rounded-full border border-input bg-background/50 hover:bg-accent/50 focus:bg-background transition-all ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 shadow-xs text-base"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="container max-w-6xl mx-auto px-4 space-y-8">
|
||||||
|
{/* Category Navigation */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2 animate-in fade-in slide-in-from-bottom-6 duration-700 delay-100">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveCategory("all")}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
||||||
|
activeCategory === "all"
|
||||||
|
? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90"
|
||||||
|
: "bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => setActiveCategory(cat.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
||||||
|
activeCategory === cat.id
|
||||||
|
? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90"
|
||||||
|
: "bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.icon}
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tools Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-200">
|
||||||
|
{filteredTools.length > 0 ? (
|
||||||
|
filteredTools.map((tool) => (
|
||||||
|
<Link
|
||||||
|
key={tool.id}
|
||||||
|
href={tool.isPlanned ? "#" : tool.href}
|
||||||
|
className={`group block h-full ${tool.isPlanned ? "cursor-not-allowed" : ""}`}
|
||||||
|
onClick={(e) => tool.isPlanned && e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className={`relative h-full overflow-hidden rounded-xl border bg-card text-card-foreground transition-all duration-300 ${!tool.isPlanned && "hover:shadow-md hover:-translate-y-1 hover:border-primary/20 group-hover:bg-accent/5"}`}>
|
||||||
|
<div className="p-5 flex items-center gap-4 text-left h-full">
|
||||||
|
<div
|
||||||
|
className={`relative shrink-0 flex h-12 w-12 items-center justify-center rounded-xl ${tool.color} transition-transform duration-300 ${!tool.isPlanned && "group-hover:scale-110 group-hover:rotate-3"}`}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-current opacity-10 rounded-xl" />
|
||||||
|
{tool.icon}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-bold tracking-tight text-base">
|
||||||
|
{tool.title}
|
||||||
|
</h3>
|
||||||
|
{tool.isPlanned && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground font-medium">
|
||||||
|
敬请期待
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||||
|
{tool.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full py-16 text-center">
|
||||||
|
<div className="inline-flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||||
|
<Search className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">未找到相关工具</h3>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
换个关键词试试,或者浏览其他分类。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
app/pomodoro/page.tsx
Normal file
162
app/pomodoro/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Timer, Play, Pause, RotateCcw, Coffee, Zap } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Mode = "WORK" | "SHORT_BREAK" | "LONG_BREAK";
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
WORK: { time: 25 * 60, label: "工作", color: "text-rose-500", bg: "bg-rose-500" },
|
||||||
|
SHORT_BREAK: { time: 5 * 60, label: "短休", color: "text-emerald-500", bg: "bg-emerald-500" },
|
||||||
|
LONG_BREAK: { time: 15 * 60, label: "长休", color: "text-blue-500", bg: "bg-blue-500" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PomodoroPage() {
|
||||||
|
const [mode, setMode] = useState<Mode>("WORK");
|
||||||
|
const [timeLeft, setTimeLeft] = useState(CONFIG.WORK.time);
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const timerRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive && timeLeft > 0) {
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setTimeLeft((prev) => prev - 1);
|
||||||
|
}, 1000);
|
||||||
|
} else if (timeLeft === 0) {
|
||||||
|
setIsActive(false);
|
||||||
|
handleFinished();
|
||||||
|
} else {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
return () => clearInterval(timerRef.current);
|
||||||
|
}, [isActive, timeLeft]);
|
||||||
|
|
||||||
|
const handleFinished = () => {
|
||||||
|
const icon = mode === "WORK" ? <Coffee className="text-emerald-500" /> : <Zap className="text-rose-500" />;
|
||||||
|
const msg = mode === "WORK" ? "工作结束,休息一下吧!" : "休息结束,开始专注吧!";
|
||||||
|
toast.success(msg, { icon, duration: 5000 });
|
||||||
|
|
||||||
|
// Play sound
|
||||||
|
try {
|
||||||
|
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
const oscillator = audioCtx.createOscillator();
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(523.25, audioCtx.currentTime); // C5
|
||||||
|
oscillator.connect(audioCtx.destination);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(audioCtx.currentTime + 0.5);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTimer = () => setIsActive(!isActive);
|
||||||
|
|
||||||
|
const resetTimer = () => {
|
||||||
|
setIsActive(false);
|
||||||
|
setTimeLeft(CONFIG[mode].time);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeMode = (newMode: Mode) => {
|
||||||
|
setMode(newMode);
|
||||||
|
setIsActive(false);
|
||||||
|
setTimeLeft(CONFIG[newMode].time);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = timeLeft / CONFIG[mode].time;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-rose-500 to-orange-600 shadow-lg">
|
||||||
|
<Timer className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">番茄钟</h1>
|
||||||
|
<p className="text-muted-foreground">专注工作,科学休息 (Pomodoro Technique)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-md mx-auto space-y-8 pt-8">
|
||||||
|
<div className="flex justify-center gap-2 p-1 bg-muted rounded-full">
|
||||||
|
<ModeButton active={mode === "WORK"} label="工作" onClick={() => changeMode("WORK")} />
|
||||||
|
<ModeButton active={mode === "SHORT_BREAK"} label="短休" onClick={() => changeMode("SHORT_BREAK")} />
|
||||||
|
<ModeButton active={mode === "LONG_BREAK"} label="长休" onClick={() => changeMode("LONG_BREAK")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-8 flex flex-col items-center space-y-10 border-none shadow-2xl bg-card/50 backdrop-blur">
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<svg className="w-72 h-72 transform -rotate-90">
|
||||||
|
<circle
|
||||||
|
cx="144" cy="144" r="136"
|
||||||
|
stroke="currentColor" strokeWidth="4"
|
||||||
|
fill="transparent" className="text-muted/20"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="144" cy="144" r="136"
|
||||||
|
stroke="currentColor" strokeWidth="8"
|
||||||
|
fill="transparent"
|
||||||
|
strokeDasharray={2 * Math.PI * 136}
|
||||||
|
strokeDashoffset={2 * Math.PI * 136 * (1 - progress)}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className={cn("transition-all duration-1000 ease-linear", CONFIG[mode].color)}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute flex flex-col items-center">
|
||||||
|
<span className="text-7xl font-mono font-black tabular-nums tracking-tighter">
|
||||||
|
{formatTime(timeLeft)}
|
||||||
|
</span>
|
||||||
|
<span className={cn("text-xs font-bold uppercase tracking-widest mt-2", CONFIG[mode].color)}>
|
||||||
|
{CONFIG[mode].label}ING
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-14 w-14 rounded-full"
|
||||||
|
onClick={resetTimer}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className={cn("h-20 w-20 rounded-full shadow-xl hover:scale-105 active:scale-95 transition-all p-0", CONFIG[mode].bg, "text-white")}
|
||||||
|
onClick={toggleTimer}
|
||||||
|
>
|
||||||
|
{isActive ? <Pause className="h-10 w-10 fill-current" /> : <Play className="h-10 w-10 fill-current ml-1" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="h-14 w-14" /> {/* Spacer */}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeButton({ active, label, onClick }: { active: boolean, label: string, onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"px-6 py-2 rounded-full text-sm font-bold transition-all flex-1",
|
||||||
|
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
339
app/qrcode/page.tsx
Normal file
339
app/qrcode/page.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { QrCode, Download, Settings, Eye } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { QRCodeCanvas } from "qrcode.react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface QRConfig {
|
||||||
|
size: number;
|
||||||
|
icon: string;
|
||||||
|
iconSize: number;
|
||||||
|
fgColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
includeMargin: boolean;
|
||||||
|
level: "L" | "M" | "Q" | "H";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QRCodeGenerator() {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [config, setConfig] = useState<QRConfig>({
|
||||||
|
size: 200,
|
||||||
|
icon: "",
|
||||||
|
iconSize: 40,
|
||||||
|
fgColor: "#000000",
|
||||||
|
bgColor: "#ffffff",
|
||||||
|
includeMargin: true,
|
||||||
|
level: "M",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleConfigChange = (field: keyof QRConfig, value: any) => {
|
||||||
|
setConfig((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadQRCode = () => {
|
||||||
|
const canvas = document.getElementById("qr-code-canvas") as HTMLCanvasElement;
|
||||||
|
if (canvas) {
|
||||||
|
try {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = "qrcode.png";
|
||||||
|
link.href = canvas.toDataURL("image/png");
|
||||||
|
link.click();
|
||||||
|
toast.success("二维码下载成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("下载失败");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error("未找到二维码");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-emerald-500 to-green-600 shadow-lg">
|
||||||
|
<QrCode className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">二维码生成器</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
快速生成自定义二维码,支持多种样式配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Left: Configuration */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium">内容设置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="text">文本内容</Label>
|
||||||
|
<Textarea
|
||||||
|
id="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="请输入要生成二维码的文本内容,如:网址、文本、微信号等..."
|
||||||
|
maxLength={2953}
|
||||||
|
className="min-h-25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
样式配置
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="size">二维码大小 (px)</Label>
|
||||||
|
<Input
|
||||||
|
id="size"
|
||||||
|
type="number"
|
||||||
|
value={config.size}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleConfigChange(
|
||||||
|
"size",
|
||||||
|
parseInt(e.target.value) || 200
|
||||||
|
)
|
||||||
|
}
|
||||||
|
min={80}
|
||||||
|
max={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="iconSize">图标大小 (px)</Label>
|
||||||
|
<Input
|
||||||
|
id="iconSize"
|
||||||
|
type="number"
|
||||||
|
value={config.iconSize}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleConfigChange(
|
||||||
|
"iconSize",
|
||||||
|
parseInt(e.target.value) || 40
|
||||||
|
)
|
||||||
|
}
|
||||||
|
min={20}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="icon">中心图标地址 (可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="icon"
|
||||||
|
value={config.icon}
|
||||||
|
onChange={(e) => handleConfigChange("icon", e.target.value)}
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fgColor">二维码颜色</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="fgColor"
|
||||||
|
type="color"
|
||||||
|
value={config.fgColor}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleConfigChange("fgColor", e.target.value)
|
||||||
|
}
|
||||||
|
className="h-9 w-full p-1 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={config.fgColor}
|
||||||
|
onChange={(e) => handleConfigChange("fgColor", e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bgColor">背景颜色</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="bgColor"
|
||||||
|
type="color"
|
||||||
|
value={config.bgColor}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleConfigChange("bgColor", e.target.value)
|
||||||
|
}
|
||||||
|
className="h-9 w-full p-1 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={config.bgColor}
|
||||||
|
onChange={(e) => handleConfigChange("bgColor", e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>纠错等级</Label>
|
||||||
|
<Select
|
||||||
|
value={config.level}
|
||||||
|
onValueChange={(value: "L" | "M" | "Q" | "H") =>
|
||||||
|
handleConfigChange("level", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="L">L - 低 (7%)</SelectItem>
|
||||||
|
<SelectItem value="M">M - 中 (15%)</SelectItem>
|
||||||
|
<SelectItem value="Q">Q - 较高 (25%)</SelectItem>
|
||||||
|
<SelectItem value="H">H - 高 (30%)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="includeMargin"
|
||||||
|
checked={config.includeMargin}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleConfigChange("includeMargin", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="includeMargin">显示边框</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Preview */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
实时预览
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-80 bg-muted/30 rounded-lg p-8 border border-dashed">
|
||||||
|
{text ? (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||||
|
<QRCodeCanvas
|
||||||
|
id="qr-code-canvas"
|
||||||
|
value={text}
|
||||||
|
size={config.size}
|
||||||
|
fgColor={config.fgColor}
|
||||||
|
bgColor={config.bgColor}
|
||||||
|
level={config.level}
|
||||||
|
includeMargin={config.includeMargin}
|
||||||
|
imageSettings={
|
||||||
|
config.icon
|
||||||
|
? {
|
||||||
|
src: config.icon,
|
||||||
|
height: config.iconSize,
|
||||||
|
width: config.iconSize,
|
||||||
|
excavate: true,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
大小: {config.size}×{config.size}px
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground text-center">
|
||||||
|
<QrCode className="h-12 w-12 mx-auto mb-2 opacity-20" />
|
||||||
|
<p>请在左侧输入内容后查看预览</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{text && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium">下载选项</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
onClick={downloadQRCode}
|
||||||
|
className="w-full h-12 text-lg gap-2"
|
||||||
|
>
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
下载为 PNG
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">功能特点</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>支持文本、网址等多种内容</li>
|
||||||
|
<li>自定义二维码颜色和背景色</li>
|
||||||
|
<li>支持中心图标</li>
|
||||||
|
<li>多种纠错等级可选</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">使用技巧</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>纠错等级越高容错性越强</li>
|
||||||
|
<li>中心图标建议使用正方形</li>
|
||||||
|
<li>深色二维码配浅色背景效果更佳</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">应用场景</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>网站链接快速分享</li>
|
||||||
|
<li>联系方式信息传递</li>
|
||||||
|
<li>活动邀请码生成</li>
|
||||||
|
<li>产品信息展示</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
app/radix-converter/page.tsx
Normal file
168
app/radix-converter/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Calculator, Copy, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function RadixConverterPage() {
|
||||||
|
const [values, setValues] = useState({
|
||||||
|
bin: "",
|
||||||
|
oct: "",
|
||||||
|
dec: "",
|
||||||
|
hex: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUpdate = (val: string, radix: number) => {
|
||||||
|
if (val === "") {
|
||||||
|
setValues({ bin: "", oct: "", dec: "", hex: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decimal = parseInt(val, radix);
|
||||||
|
if (isNaN(decimal)) return;
|
||||||
|
|
||||||
|
setValues({
|
||||||
|
bin: decimal.toString(2),
|
||||||
|
oct: decimal.toString(8),
|
||||||
|
dec: decimal.toString(10),
|
||||||
|
hex: decimal.toString(16).toUpperCase()
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid input
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => setValues({ bin: "", oct: "", dec: "", hex: "" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-indigo-500 to-blue-600 shadow-lg">
|
||||||
|
<Calculator className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">进制转换器</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
二、八、十、十六进制数值相互转换
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base font-medium">转换面板</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAll} className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{/* Decimal */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dec">十进制 (Decimal)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="dec"
|
||||||
|
placeholder="输入十进制数值..."
|
||||||
|
value={values.dec}
|
||||||
|
onChange={(e) => handleUpdate(e.target.value, 10)}
|
||||||
|
className="font-mono text-lg"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => copyToClipboard(values.dec)}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hexadecimal */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="hex">十六进制 (Hexadecimal)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="hex"
|
||||||
|
placeholder="输入十六进制数值..."
|
||||||
|
value={values.hex}
|
||||||
|
onChange={(e) => handleUpdate(e.target.value, 16)}
|
||||||
|
className="font-mono text-lg"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => copyToClipboard(values.hex)}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Binary */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bin">二进制 (Binary)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="bin"
|
||||||
|
placeholder="输入二进制数值..."
|
||||||
|
value={values.bin}
|
||||||
|
onChange={(e) => handleUpdate(e.target.value, 2)}
|
||||||
|
className="font-mono text-lg"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => copyToClipboard(values.bin)}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Octal */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="oct">八进制 (Octal)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="oct"
|
||||||
|
placeholder="输入八进制数值..."
|
||||||
|
value={values.oct}
|
||||||
|
onChange={(e) => handleUpdate(e.target.value, 8)}
|
||||||
|
className="font-mono text-lg"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => copyToClipboard(values.oct)}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 进制转换规则
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-2">
|
||||||
|
<li><strong>二进制 (Binary):</strong> 基数为2,由数字0和1组成。</li>
|
||||||
|
<li><strong>八进制 (Octal):</strong> 基数为8,由数字0到7组成。</li>
|
||||||
|
<li><strong>十进制 (Decimal):</strong> 基数为10,是我们日常生活中最常用的计数方式。</li>
|
||||||
|
<li><strong>十六进制 (Hexadecimal):</strong> 基数为16,由0-9和A-F(代表10-15)组成。</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/random-group/page.tsx
Normal file
153
app/random-group/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Users, Shuffle, Copy } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function RandomGroupPage() {
|
||||||
|
const [namesText, setNamesText] = useState("");
|
||||||
|
const [groupCount, setGroupCount] = useState(2);
|
||||||
|
const [groups, setGroups] = useState<string[][]>([]);
|
||||||
|
|
||||||
|
const handleShuffle = () => {
|
||||||
|
const names = namesText
|
||||||
|
.split(/[,\n,]+/)
|
||||||
|
.map((n) => n.trim())
|
||||||
|
.filter((n) => n !== "");
|
||||||
|
|
||||||
|
if (names.length === 0) {
|
||||||
|
toast.error("请输入成员名单");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupCount <= 0) {
|
||||||
|
toast.error("分组数量必须大于 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle names
|
||||||
|
const shuffled = [...names].sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
|
// Create groups
|
||||||
|
const newGroups: string[][] = Array.from({ length: groupCount }, () => []);
|
||||||
|
shuffled.forEach((name, index) => {
|
||||||
|
newGroups[index % groupCount].push(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
setGroups(newGroups.filter(g => g.length > 0));
|
||||||
|
toast.success("随机分组完成");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatGroupsText = () => {
|
||||||
|
return groups
|
||||||
|
.map((group, index) => `小组 ${index + 1}:
|
||||||
|
${group.join(", ")}`)
|
||||||
|
.join("\n\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-indigo-500 to-blue-600 shadow-lg">
|
||||||
|
<Users className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">随机分组工具</h1>
|
||||||
|
<p className="text-muted-foreground">输入成员名单,公平地进行随机分组</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">名单与配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>成员名单 (支持换行或逗号分隔)</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="张三 李四 王五 赵六..."
|
||||||
|
className="min-h-50 font-mono"
|
||||||
|
value={namesText}
|
||||||
|
onChange={(e) => setNamesText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>要分成的组数</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={groupCount}
|
||||||
|
onChange={(e) => setGroupCount(parseInt(e.target.value) || 1)}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleShuffle} className="flex-1 gap-2">
|
||||||
|
<Shuffle className="h-4 w-4" />
|
||||||
|
立即随机分组
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base">分组结果</CardTitle>
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => copyToClipboard(formatGroupsText())}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制全部
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 overflow-y-auto max-h-125">
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-muted-foreground py-12 italic">
|
||||||
|
<Shuffle className="h-12 w-12 mb-2 opacity-10" />
|
||||||
|
<p>点击“立即随机分组”查看结果</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{groups.map((group, index) => (
|
||||||
|
<div key={index} className="p-4 rounded-lg bg-muted/50 border border-muted-foreground/10 space-y-2">
|
||||||
|
<div className="flex items-center justify-between border-b pb-1">
|
||||||
|
<span className="font-bold text-primary">第 {index + 1} 组</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{group.length} 人</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{group.map((name, ni) => (
|
||||||
|
<span key={ni} className="px-2 py-1 bg-background rounded border text-sm">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
435
app/random-string/page.tsx
Normal file
435
app/random-string/page.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Settings, RefreshCw, Copy, Shield, Zap } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface StringConfig {
|
||||||
|
length: number;
|
||||||
|
includeUppercase: boolean;
|
||||||
|
includeLowercase: boolean;
|
||||||
|
includeNumbers: boolean;
|
||||||
|
includeSymbols: boolean;
|
||||||
|
excludeSimilar: boolean;
|
||||||
|
customChars: string;
|
||||||
|
batchCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConfig: StringConfig = {
|
||||||
|
length: 16,
|
||||||
|
includeUppercase: true,
|
||||||
|
includeLowercase: true,
|
||||||
|
includeNumbers: true,
|
||||||
|
includeSymbols: false,
|
||||||
|
excludeSimilar: false,
|
||||||
|
customChars: "",
|
||||||
|
batchCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
const LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
const NUMBERS = "0123456789";
|
||||||
|
const SYMBOLS = "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||||||
|
const SIMILAR_CHARS = "0O1lI|";
|
||||||
|
|
||||||
|
export default function RandomStringGenerator() {
|
||||||
|
const [config, setConfig] = useState<StringConfig>(defaultConfig);
|
||||||
|
const [generatedStrings, setGeneratedStrings] = useState<string[]>([]);
|
||||||
|
const [currentString, setCurrentString] = useState("");
|
||||||
|
|
||||||
|
const handleConfigChange = (field: keyof StringConfig, value: any) => {
|
||||||
|
setConfig((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCharacterSet = useCallback(() => {
|
||||||
|
let chars = "";
|
||||||
|
|
||||||
|
if (config.customChars) {
|
||||||
|
chars = config.customChars;
|
||||||
|
} else {
|
||||||
|
if (config.includeUppercase) chars += UPPERCASE;
|
||||||
|
if (config.includeLowercase) chars += LOWERCASE;
|
||||||
|
if (config.includeNumbers) chars += NUMBERS;
|
||||||
|
if (config.includeSymbols) chars += SYMBOLS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.excludeSimilar && !config.customChars) {
|
||||||
|
chars = chars
|
||||||
|
.split("")
|
||||||
|
.filter((char) => !SIMILAR_CHARS.includes(char))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return chars;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const generateRandomString = useCallback(
|
||||||
|
(length: number, charset: string) => {
|
||||||
|
if (!charset) return "";
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
const charactersLength = charset.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += charset.charAt(Math.floor(Math.random() * charactersLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateStrings = useCallback(() => {
|
||||||
|
const charset = getCharacterSet();
|
||||||
|
|
||||||
|
if (!charset) {
|
||||||
|
toast.error("请至少选择一种字符类型");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStrings = [];
|
||||||
|
for (let i = 0; i < config.batchCount; i++) {
|
||||||
|
const randomStr = generateRandomString(config.length, charset);
|
||||||
|
newStrings.push(randomStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeneratedStrings(newStrings);
|
||||||
|
setCurrentString(newStrings[0] || "");
|
||||||
|
toast.success(`成功生成 ${newStrings.length} 个随机字符串`);
|
||||||
|
}, [config, getCharacterSet, generateRandomString]);
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(text)
|
||||||
|
.then(() => {
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("复制失败");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAllStrings = () => {
|
||||||
|
const allStrings = generatedStrings.join("\n");
|
||||||
|
copyToClipboard(allStrings);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStrengthInfo = () => {
|
||||||
|
const charset = getCharacterSet();
|
||||||
|
const entropy = Math.log2(Math.pow(charset.length, config.length));
|
||||||
|
|
||||||
|
let strength = "弱";
|
||||||
|
let color = "bg-red-500 hover:bg-red-600";
|
||||||
|
|
||||||
|
if (entropy >= 60) {
|
||||||
|
strength = "极强";
|
||||||
|
color = "bg-emerald-500 hover:bg-emerald-600";
|
||||||
|
} else if (entropy >= 40) {
|
||||||
|
strength = "强";
|
||||||
|
color = "bg-cyan-500 hover:bg-cyan-600";
|
||||||
|
} else if (entropy >= 25) {
|
||||||
|
strength = "中等";
|
||||||
|
color = "bg-amber-500 hover:bg-amber-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { strength, entropy: entropy.toFixed(1), color };
|
||||||
|
};
|
||||||
|
|
||||||
|
const strengthInfo = getStrengthInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-violet-500 to-purple-600 shadow-lg">
|
||||||
|
<Zap className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">随机字符串生成器</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
生成安全可靠的随机字符串,支持多种字符集配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Left: Configuration */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
基础设置
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="length">字符串长度</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="length"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
value={config.length}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleConfigChange("length", parseInt(e.target.value) || 1)
|
||||||
|
}
|
||||||
|
className="pr-8"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 top-2.5 text-xs text-muted-foreground">位</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="batchCount">生成数量</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="batchCount"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={config.batchCount}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleConfigChange("batchCount", parseInt(e.target.value) || 1)
|
||||||
|
}
|
||||||
|
className="pr-8"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 top-2.5 text-xs text-muted-foreground">个</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium">字符集选择</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="uppercase"
|
||||||
|
checked={config.includeUppercase}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleConfigChange("includeUppercase", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="uppercase">大写字母 (A-Z)</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="lowercase"
|
||||||
|
checked={config.includeLowercase}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleConfigChange("includeLowercase", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="lowercase">小写字母 (a-z)</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="numbers"
|
||||||
|
checked={config.includeNumbers}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleConfigChange("includeNumbers", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="numbers">数字 (0-9)</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="symbols"
|
||||||
|
checked={config.includeSymbols}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleConfigChange("includeSymbols", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="symbols">特殊符号</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 col-span-2">
|
||||||
|
<Checkbox
|
||||||
|
id="excludeSimilar"
|
||||||
|
checked={config.excludeSimilar}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleConfigChange("excludeSimilar", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="excludeSimilar">排除相似字符 (0O1lI|)</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="customChars">自定义字符集</Label>
|
||||||
|
<Input
|
||||||
|
id="customChars"
|
||||||
|
value={config.customChars}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleConfigChange("customChars", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="输入自定义字符集(将覆盖上述选择)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Generate & Result */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
安全强度
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">字符集大小</div>
|
||||||
|
<div className="text-xl font-bold">{getCharacterSet().length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">安全熵</div>
|
||||||
|
<div className="text-xl font-bold">{strengthInfo.entropy}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">强度等级</div>
|
||||||
|
<Badge className={strengthInfo.color}>{strengthInfo.strength}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium">生成控制</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Button
|
||||||
|
onClick={generateStrings}
|
||||||
|
className="w-full h-12 text-lg gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-5 w-5" />
|
||||||
|
生成随机字符串
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{generatedStrings.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(currentString)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
复制当前
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={copyAllStrings}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
复制全部
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{generatedStrings.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium">生成结果</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.batchCount === 1 ? (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={currentString}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-lg pr-12"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1 h-8 w-8"
|
||||||
|
onClick={() => copyToClipboard(currentString)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
value={generatedStrings.join("\n")}
|
||||||
|
readOnly
|
||||||
|
className="font-mono min-h-50"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground text-right">
|
||||||
|
生成时间: {new Date().toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">功能特点</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>支持多种字符集组合配置</li>
|
||||||
|
<li>可排除容易混淆的相似字符</li>
|
||||||
|
<li>支持自定义字符集</li>
|
||||||
|
<li>批量生成多个字符串</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">安全建议</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>密码长度建议至少12位以上</li>
|
||||||
|
<li>重要账户建议使用16位以上密码</li>
|
||||||
|
<li>包含多种字符类型提高安全性</li>
|
||||||
|
<li>定期更换重要账户密码</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">应用场景</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>生成安全密码</li>
|
||||||
|
<li>创建API密钥</li>
|
||||||
|
<li>生成验证码</li>
|
||||||
|
<li>创建随机标识符</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 p-3 bg-muted rounded-md text-xs text-muted-foreground">
|
||||||
|
💡 提示:生成的字符串完全在本地浏览器中创建,不会发送到任何服务器。
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
app/regex/page.tsx
Normal file
161
app/regex/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Code, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function RegexPage() {
|
||||||
|
const [pattern, setPattern] = useState("([a-zA-Z0-9._%-]+)@([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6})");
|
||||||
|
const [flags, setFlags] = useState({
|
||||||
|
global: true,
|
||||||
|
ignoreCase: false,
|
||||||
|
multiline: false
|
||||||
|
});
|
||||||
|
const [text, setText] = useState("My email is example@mail.com and work@company.org.");
|
||||||
|
const [matches, setMatches] = useState<any[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (!pattern) {
|
||||||
|
setMatches([]);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagStr = (flags.global ? 'g' : '') + (flags.ignoreCase ? 'i' : '') + (flags.multiline ? 'm' : '');
|
||||||
|
const regex = new RegExp(pattern, flagStr);
|
||||||
|
const allMatches = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
if (flags.global) {
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
allMatches.push(match);
|
||||||
|
if (match.index === regex.lastIndex) regex.lastIndex++; // Prevent infinite loop for zero-width matches
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match = regex.exec(text);
|
||||||
|
if (match) allMatches.push(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMatches(allMatches);
|
||||||
|
setError(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setMatches([]);
|
||||||
|
}
|
||||||
|
}, [pattern, flags, text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-blue-600 to-cyan-600 shadow-lg">
|
||||||
|
<Code className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">正则表达式测试</h1>
|
||||||
|
<p className="text-muted-foreground">测试和调试正则表达式,支持实时匹配预览</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">配置正则</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex gap-4 flex-col md:flex-row">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground font-mono">/</div>
|
||||||
|
<Input
|
||||||
|
value={pattern}
|
||||||
|
onChange={(e) => setPattern(e.target.value)}
|
||||||
|
className={cn("pl-6 pr-10 font-mono", error && "border-destructive focus-visible:ring-destructive")}
|
||||||
|
placeholder="输入正则表达式模式..."
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground font-mono">/</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 px-2">
|
||||||
|
<FlagToggle label="g" active={flags.global} onToggle={(v) => setFlags({...flags, global: v})} title="全局匹配" />
|
||||||
|
<FlagToggle label="i" active={flags.ignoreCase} onToggle={(v) => setFlags({...flags, ignoreCase: v})} title="忽略大小写" />
|
||||||
|
<FlagToggle label="m" active={flags.multiline} onToggle={(v) => setFlags({...flags, multiline: v})} title="多行模式" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">测试文本</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="请在此输入需要匹配的文本..."
|
||||||
|
className="min-h-50 font-mono leading-relaxed"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base">匹配结果 ({matches.length})</CardTitle>
|
||||||
|
{matches.length > 0 && <CheckCircle2 className="h-4 w-4 text-emerald-500" />}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 max-h-50 overflow-y-auto">
|
||||||
|
{matches.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm italic">
|
||||||
|
暂无匹配结果
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
matches.map((m, i) => (
|
||||||
|
<div key={i} className="p-3 rounded-md bg-muted/50 border space-y-1">
|
||||||
|
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||||
|
<span>Match {i + 1}</span>
|
||||||
|
<span>Index: {m.index}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-sm break-all">
|
||||||
|
{m[0]}
|
||||||
|
</div>
|
||||||
|
{m.length > 1 && (
|
||||||
|
<div className="pt-2 border-t mt-2 space-y-1">
|
||||||
|
{m.slice(1).map((group: string, gi: number) => (
|
||||||
|
<div key={gi} className="flex gap-2 text-xs">
|
||||||
|
<span className="text-muted-foreground font-mono">Group {gi + 1}:</span>
|
||||||
|
<span className="font-mono text-primary">{group || "(empty)"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlagToggle({ label, active, onToggle, title }: { label: string; active: boolean; onToggle: (v: boolean) => void; title: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1" title={title}>
|
||||||
|
<span className="text-[10px] font-bold text-muted-foreground font-mono">{label}</span>
|
||||||
|
<Switch checked={active} onCheckedChange={onToggle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
app/scoreboard/page.tsx
Normal file
130
app/scoreboard/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Tally5, Plus, Minus, RotateCcw, Edit2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function ScoreboardPage() {
|
||||||
|
const [teamA, setTeamA] = useState({ name: "红队", score: 0, color: "bg-rose-500", border: "border-rose-500" });
|
||||||
|
const [teamB, setTeamB] = useState({ name: "蓝队", score: 0, color: "bg-blue-500", border: "border-blue-500" });
|
||||||
|
const [editTeam, setEditTeam] = useState<"A" | "B" | null>(null);
|
||||||
|
const [tempName, setTempName] = useState("");
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setTeamA({ ...teamA, score: 0 });
|
||||||
|
setTeamB({ ...teamB, score: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwap = () => {
|
||||||
|
const temp = { ...teamA };
|
||||||
|
setTeamA({ ...teamB });
|
||||||
|
setTeamB(temp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveName = () => {
|
||||||
|
if (editTeam === "A") setTeamA({ ...teamA, name: tempName || teamA.name });
|
||||||
|
else if (editTeam === "B") setTeamB({ ...teamB, name: tempName || teamB.name });
|
||||||
|
setEditTeam(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-rose-500 to-blue-600 shadow-lg">
|
||||||
|
<Tally5 className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">记分板</h1>
|
||||||
|
<p className="text-muted-foreground">红蓝双方比分实时记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSwap}>交换位置</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleReset} className="text-destructive hover:text-destructive">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
重置比分
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 py-4">
|
||||||
|
<TeamDisplay
|
||||||
|
team={teamA}
|
||||||
|
onScoreChange={(val) => setTeamA({ ...teamA, score: Math.max(0, teamA.score + val) })}
|
||||||
|
onNameEdit={() => {
|
||||||
|
setEditTeam("A");
|
||||||
|
setTempName(teamA.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TeamDisplay
|
||||||
|
team={teamB}
|
||||||
|
onScoreChange={(val) => setTeamB({ ...teamB, score: Math.max(0, teamB.score + val) })}
|
||||||
|
onNameEdit={() => {
|
||||||
|
setEditTeam("B");
|
||||||
|
setTempName(teamB.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Modal */}
|
||||||
|
{editTeam && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<Card className="w-full max-w-sm shadow-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>修改队伍名称</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={tempName}
|
||||||
|
onChange={(e) => setTempName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
placeholder="输入名称..."
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && saveName()}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setEditTeam(null)}>取消</Button>
|
||||||
|
<Button onClick={saveName}>确定</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamDisplay({ team, onScoreChange, onNameEdit }: { team: any, onScoreChange: (v: number) => void, onNameEdit: () => void }) {
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col items-center justify-between py-12 space-y-10 relative overflow-hidden bg-card/50">
|
||||||
|
<div className={cn("absolute top-0 left-0 w-full h-3", team.color)} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 group cursor-pointer hover:opacity-80 transition-all" onClick={onNameEdit}>
|
||||||
|
<h2 className="text-4xl font-black tracking-tight">{team.name}</h2>
|
||||||
|
<Edit2 className="h-5 w-5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[14rem] font-black tracking-tighter tabular-nums leading-none select-none drop-shadow-sm">
|
||||||
|
{team.score}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button
|
||||||
|
className="h-24 w-24 rounded-3xl border-4 border-muted hover:bg-muted transition-colors flex items-center justify-center group"
|
||||||
|
onClick={() => onScoreChange(-1)}
|
||||||
|
>
|
||||||
|
<Minus className="h-12 w-12 text-muted-foreground group-hover:text-foreground" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn("h-24 w-24 rounded-3xl shadow-xl hover:scale-105 active:scale-95 transition-all flex items-center justify-center", team.color)}
|
||||||
|
onClick={() => onScoreChange(1)}
|
||||||
|
>
|
||||||
|
<Plus className="h-12 w-12 text-white fill-current" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
app/sql-formatter/page.tsx
Normal file
150
app/sql-formatter/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Editor from "@monaco-editor/react";
|
||||||
|
import { format as formatSql } from "sql-formatter";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Database, Copy, RotateCcw, ArrowRight } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const SQL_DIALECTS = [
|
||||||
|
{ value: "sql", label: "Standard SQL" },
|
||||||
|
{ value: "mysql", label: "MySQL" },
|
||||||
|
{ value: "postgresql", label: "PostgreSQL" },
|
||||||
|
{ value: "sqlite", label: "SQLite" },
|
||||||
|
{ value: "mariadb", label: "MariaDB" },
|
||||||
|
{ value: "plsql", label: "PL/SQL (Oracle)" },
|
||||||
|
{ value: "tsql", label: "T-SQL (SQL Server)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SqlFormatterPage() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
const [dialect, setDialect] = useState("sql");
|
||||||
|
|
||||||
|
const handleFormat = () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
try {
|
||||||
|
const formatted = formatSql(input, {
|
||||||
|
language: dialect as any,
|
||||||
|
keywordCase: "upper",
|
||||||
|
});
|
||||||
|
setOutput(formatted);
|
||||||
|
toast.success("SQL 格式化成功");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("SQL 格式化失败: " + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-indigo-500 to-purple-600 shadow-lg">
|
||||||
|
<Database className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">SQL 格式化</h1>
|
||||||
|
<p className="text-muted-foreground">支持多种数据库方言的 SQL 美化工具</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 flex flex-wrap gap-6 items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>数据库方言</Label>
|
||||||
|
<Select value={dialect} onValueChange={setDialect}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SQL_DIALECTS.map((d) => (
|
||||||
|
<SelectItem key={d.value} value={d.value}>
|
||||||
|
{d.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleFormat} className="gap-2">
|
||||||
|
格式化 <ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Input */}
|
||||||
|
<Card className="flex flex-col min-h-150">
|
||||||
|
<CardHeader className="py-3 px-4 border-b flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-medium">输入 SQL</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setInput("")}>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 flex-1 h-125">
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="sql"
|
||||||
|
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||||
|
value={input}
|
||||||
|
onChange={(value) => setInput(value || "")}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
wordWrap: "on"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Output */}
|
||||||
|
<Card className="flex flex-col min-h-150">
|
||||||
|
<CardHeader className="py-3 px-4 border-b flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-medium">结果</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(output)} disabled={!output}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 flex-1 bg-muted/30 h-125">
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="sql"
|
||||||
|
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||||
|
value={output}
|
||||||
|
options={{
|
||||||
|
readOnly: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
wordWrap: "on"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
app/stopwatch/page.tsx
Normal file
115
app/stopwatch/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Watch, Play, Pause, RotateCcw, Timer as TimerIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function StopwatchPage() {
|
||||||
|
const [time, setTime] = useState(0);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [laps, setLaps] = useState<number[]>([]);
|
||||||
|
const timerRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (running) {
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setTime((prev) => prev + 10);
|
||||||
|
}, 10);
|
||||||
|
} else {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
return () => clearInterval(timerRef.current);
|
||||||
|
}, [running]);
|
||||||
|
|
||||||
|
const formatTime = (ms: number) => {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
|
const milliseconds = Math.floor((ms % 1000) / 10);
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLap = () => {
|
||||||
|
setLaps([time, ...laps]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setRunning(false);
|
||||||
|
setTime(0);
|
||||||
|
setLaps([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-blue-500 to-indigo-600 shadow-lg">
|
||||||
|
<Watch className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">秒表</h1>
|
||||||
|
<p className="text-muted-foreground">精确计时工具,支持计次功能</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card className="flex flex-col items-center justify-center py-12 space-y-8 h-fit">
|
||||||
|
<div className="text-7xl md:text-8xl font-mono font-bold tracking-tighter tabular-nums text-primary">
|
||||||
|
{formatTime(time)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant={running ? "outline" : "default"}
|
||||||
|
className="w-32 h-14 text-lg rounded-full"
|
||||||
|
onClick={() => setRunning(!running)}
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<><Pause className="mr-2 h-5 w-5" /> 暂停</>
|
||||||
|
) : (
|
||||||
|
<><Play className="mr-2 h-5 w-5" /> 开始</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-32 h-14 text-lg rounded-full"
|
||||||
|
onClick={running ? handleLap : handleReset}
|
||||||
|
disabled={time === 0}
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<><TimerIcon className="mr-2 h-5 w-5" /> 计次</>
|
||||||
|
) : (
|
||||||
|
<><RotateCcw className="mr-2 h-5 w-5" /> 重置</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="h-[400px] flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center justify-between">
|
||||||
|
计次记录
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">共 {laps.length} 次</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 overflow-y-auto space-y-2 pr-2">
|
||||||
|
{laps.length === 0 ? (
|
||||||
|
<div className="h-full flex items-center justify-center text-muted-foreground italic">
|
||||||
|
暂无记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
laps.map((lap, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-muted/50 animate-in slide-in-from-top-2 duration-300">
|
||||||
|
<span className="text-xs font-bold text-muted-foreground">LAP {laps.length - i}</span>
|
||||||
|
<span className="font-mono font-bold">{formatTime(lap)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
585
app/testcase-generator/page.tsx
Normal file
585
app/testcase-generator/page.tsx
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
import { TestTube, Loader2, Download, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const API_PREFIX = "/api/testcase-generator";
|
||||||
|
|
||||||
|
interface GenerateRequest {
|
||||||
|
title: string;
|
||||||
|
englishName: string;
|
||||||
|
description: string;
|
||||||
|
standardCode: string;
|
||||||
|
serviceLevel: string;
|
||||||
|
testCaseCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskStatusResponse {
|
||||||
|
task_id: string;
|
||||||
|
status: "pending" | "running" | "completed" | "failed";
|
||||||
|
progress?: number;
|
||||||
|
title?: string;
|
||||||
|
english_name?: string;
|
||||||
|
test_case_count?: number;
|
||||||
|
generated_cases?: number;
|
||||||
|
error_message?: string;
|
||||||
|
currentMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompletedResult {
|
||||||
|
title?: string;
|
||||||
|
english_name?: string;
|
||||||
|
test_case_count?: number;
|
||||||
|
file_size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TestcaseGeneratorPage() {
|
||||||
|
const [problemTitle, setProblemTitle] = useState("");
|
||||||
|
const [problemSlug, setProblemSlug] = useState("");
|
||||||
|
const [problemDescription, setProblemDescription] = useState("");
|
||||||
|
const [stdProgram, setStdProgram] = useState("");
|
||||||
|
const [serviceLevel, setServiceLevel] = useState<"standard" | "pro">("standard");
|
||||||
|
const [testCaseCount, setTestCaseCount] = useState(10);
|
||||||
|
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [progressOpen, setProgressOpen] = useState(false);
|
||||||
|
const [progressTitle, setProgressTitle] = useState("正在生成测试数据");
|
||||||
|
const [progressMessage, setProgressMessage] = useState("请稍候...");
|
||||||
|
const [progressPercent, setProgressPercent] = useState(0);
|
||||||
|
const [progressTaskId, setProgressTaskId] = useState("");
|
||||||
|
const [progressGenerated, setProgressGenerated] = useState(0);
|
||||||
|
const [progressTotal, setProgressTotal] = useState(0);
|
||||||
|
const [progressRunningTime, setProgressRunningTime] = useState(0);
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const startTimeRef = useRef(0);
|
||||||
|
|
||||||
|
const [resultModalOpen, setResultModalOpen] = useState(false);
|
||||||
|
const [resultData, setResultData] = useState<CompletedResult & { task_id?: string } | null>(null);
|
||||||
|
const [errorBlock, setErrorBlock] = useState<{ title: string; message: string } | null>(null);
|
||||||
|
|
||||||
|
const slugPattern = /^[a-z][a-z0-9_]*$/;
|
||||||
|
|
||||||
|
const showProgress = useCallback(() => {
|
||||||
|
setProgressOpen(true);
|
||||||
|
setProgressPercent(0);
|
||||||
|
setProgressGenerated(0);
|
||||||
|
setProgressTotal(0);
|
||||||
|
setProgressRunningTime(0);
|
||||||
|
setProgressTaskId("");
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hideProgress = useCallback(() => {
|
||||||
|
setProgressOpen(false);
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearTimeout(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateProgress = useCallback(
|
||||||
|
(opts: {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
progress?: number;
|
||||||
|
taskId?: string;
|
||||||
|
generatedCount?: number;
|
||||||
|
totalCount?: number;
|
||||||
|
}) => {
|
||||||
|
if (opts.title !== undefined) setProgressTitle(opts.title);
|
||||||
|
if (opts.message !== undefined) setProgressMessage(opts.message);
|
||||||
|
if (opts.progress !== undefined) setProgressPercent(Math.min(100, Math.max(0, opts.progress)));
|
||||||
|
if (opts.taskId !== undefined) setProgressTaskId(opts.taskId);
|
||||||
|
if (opts.generatedCount !== undefined) setProgressGenerated(opts.generatedCount);
|
||||||
|
if (opts.totalCount !== undefined) setProgressTotal(opts.totalCount);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pollTaskStatus = useCallback(
|
||||||
|
async (taskId: string, problemTitle: string, estimatedTimeSec: number) => {
|
||||||
|
const tick = () => {
|
||||||
|
setProgressRunningTime(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
||||||
|
};
|
||||||
|
const interval = setInterval(tick, 1000);
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_PREFIX}/task_status/${taskId}`);
|
||||||
|
const data: TaskStatusResponse = await res.json();
|
||||||
|
updateProgress({
|
||||||
|
progress: data.progress ?? 0,
|
||||||
|
generatedCount: data.generated_cases ?? 0,
|
||||||
|
totalCount: data.test_case_count ?? 0,
|
||||||
|
message: data.currentMessage || "正在处理...",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.status === "completed") {
|
||||||
|
clearInterval(interval);
|
||||||
|
hideProgress();
|
||||||
|
setIsGenerating(false);
|
||||||
|
setResultData({
|
||||||
|
title: data.title ?? problemTitle,
|
||||||
|
english_name: data.english_name,
|
||||||
|
test_case_count: data.test_case_count,
|
||||||
|
file_size: "计算中...",
|
||||||
|
task_id: taskId,
|
||||||
|
});
|
||||||
|
setResultModalOpen(true);
|
||||||
|
setErrorBlock(null);
|
||||||
|
toast.success("测试数据生成完成!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === "failed") {
|
||||||
|
clearInterval(interval);
|
||||||
|
hideProgress();
|
||||||
|
setIsGenerating(false);
|
||||||
|
setErrorBlock({
|
||||||
|
title: "测试数据生成失败",
|
||||||
|
message: data.error_message || "未知错误",
|
||||||
|
});
|
||||||
|
toast.error("测试数据生成失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pollTimerRef.current = setTimeout(check, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
clearInterval(interval);
|
||||||
|
hideProgress();
|
||||||
|
setIsGenerating(false);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error("获取任务状态失败: " + msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await check();
|
||||||
|
},
|
||||||
|
[updateProgress, hideProgress]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
const title = problemTitle.trim();
|
||||||
|
const slug = problemSlug.trim();
|
||||||
|
const description = problemDescription.trim();
|
||||||
|
const code = stdProgram.trim();
|
||||||
|
|
||||||
|
if (!title || !slug || !description || !code) {
|
||||||
|
toast.error("请填写所有必填字段");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!slugPattern.test(slug)) {
|
||||||
|
toast.error("题目英文名只能包含小写字母、数字和下划线,且必须以字母开头");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = Math.min(50, Math.max(1, testCaseCount));
|
||||||
|
if (count !== testCaseCount) setTestCaseCount(count);
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
setErrorBlock(null);
|
||||||
|
showProgress();
|
||||||
|
updateProgress({ title: "正在生成测试数据", message: "正在初始化...", taskId: "" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: GenerateRequest = {
|
||||||
|
title,
|
||||||
|
englishName: slug,
|
||||||
|
description,
|
||||||
|
standardCode: code,
|
||||||
|
serviceLevel,
|
||||||
|
testCaseCount: count,
|
||||||
|
};
|
||||||
|
const res = await fetch(`${API_PREFIX}/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.error || result.message || "生成请求失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success && result.task_id) {
|
||||||
|
const estimatedTime = result.estimated_time ?? 60;
|
||||||
|
updateProgress({
|
||||||
|
taskId: result.task_id,
|
||||||
|
title: `正在生成「${title}」的测试数据`,
|
||||||
|
message: `预计需要 ${Math.ceil(estimatedTime / 60)} 分钟,请耐心等待...`,
|
||||||
|
totalCount: count,
|
||||||
|
});
|
||||||
|
await pollTaskStatus(result.task_id, title, estimatedTime);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || "未返回任务 ID");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
hideProgress();
|
||||||
|
setIsGenerating(false);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error("生成失败: " + msg);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
problemTitle,
|
||||||
|
problemSlug,
|
||||||
|
problemDescription,
|
||||||
|
stdProgram,
|
||||||
|
serviceLevel,
|
||||||
|
testCaseCount,
|
||||||
|
showProgress,
|
||||||
|
updateProgress,
|
||||||
|
hideProgress,
|
||||||
|
pollTaskStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCancelGeneration = useCallback(() => {
|
||||||
|
hideProgress();
|
||||||
|
setIsGenerating(false);
|
||||||
|
toast.info("生成已取消");
|
||||||
|
}, [hideProgress]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback((taskId: string) => {
|
||||||
|
if (!taskId) return;
|
||||||
|
const url = `${API_PREFIX}/download/${taskId}`;
|
||||||
|
window.open(url, "_blank");
|
||||||
|
toast.success("开始下载测试数据");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseResultModal = useCallback(() => {
|
||||||
|
setResultModalOpen(false);
|
||||||
|
setResultData(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runningTimeStr =
|
||||||
|
progressRunningTime >= 60
|
||||||
|
? `${Math.floor(progressRunningTime / 60)}分${progressRunningTime % 60}秒`
|
||||||
|
: `${progressRunningTime}秒`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-3xl mx-auto">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold flex items-center justify-center gap-2">
|
||||||
|
<TestTube className="h-8 w-8 text-purple-500" />
|
||||||
|
测试数据生成
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
为信息学奥林匹克竞赛生成高质量测试数据
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 题目信息 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>题目信息</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
题目标题 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="例如:A+B Problem"
|
||||||
|
value={problemTitle}
|
||||||
|
onChange={(e) => setProblemTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">简短描述题目名称</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
题目英文名 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="例如:aplusb"
|
||||||
|
value={problemSlug}
|
||||||
|
onChange={(e) => setProblemSlug(e.target.value.toLowerCase())}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
用作文件名前缀,只能包含小写字母、数字和下划线,必须以字母开头
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
题目描述 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder={`请输入完整的题目描述,包括:
|
||||||
|
- 题目背景和要求
|
||||||
|
- 输入格式
|
||||||
|
- 输出格式
|
||||||
|
- 数据范围和约束条件
|
||||||
|
- 样例输入输出`}
|
||||||
|
className="min-h-[200px] resize-y"
|
||||||
|
value={problemDescription}
|
||||||
|
onChange={(e) => setProblemDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
详细描述题目要求,包括输入输出格式、数据范围等
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 标准程序 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
标准程序 (C++) <span className="text-destructive">*</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Label>C++ 标准程序代码</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder={`请输入 C++ 标准程序代码,例如:
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
using namespace std;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
int a, b;
|
||||||
|
cin >> a >> b;
|
||||||
|
cout << a + b << endl;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
注意:从标准输入读取数据,结果输出到标准输出。`}
|
||||||
|
className="min-h-[280px] font-mono text-sm resize-y"
|
||||||
|
value={stdProgram}
|
||||||
|
onChange={(e) => setStdProgram(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:bg-blue-950/30 dark:border-blue-800 p-4 text-sm text-blue-900 dark:text-blue-200">
|
||||||
|
<p className="font-semibold mb-2">标程说明</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 opacity-90">
|
||||||
|
<li>标程用于为生成的输入数据 (.in) 生成正确的输出数据 (.out)</li>
|
||||||
|
<li>请确保代码逻辑正确,能处理边界情况</li>
|
||||||
|
<li>系统将使用 g++ 编译您的代码</li>
|
||||||
|
<li>运行限制:内存 512MB,栈 512MB,时间 30 秒/测试点</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 生成配置 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>生成配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>测试点数量</Label>
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={testCaseCount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTestCaseCount(parseInt(e.target.value, 10) || 10)
|
||||||
|
}
|
||||||
|
className="w-28"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
建议 1–50 个测试点,数量越多生成时间越长
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>生成模式</Label>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setServiceLevel("standard")}
|
||||||
|
className={`flex items-start gap-3 rounded-xl border-2 p-5 text-left transition-all ${
|
||||||
|
serviceLevel === "standard"
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-muted-foreground/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="service_level"
|
||||||
|
checked={serviceLevel === "standard"}
|
||||||
|
readOnly
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">标准版</p>
|
||||||
|
<p className="text-sm text-muted-foreground">基础测试数据生成</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setServiceLevel("pro")}
|
||||||
|
className={`flex items-start gap-3 rounded-xl border-2 p-5 text-left transition-all ${
|
||||||
|
serviceLevel === "pro"
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-muted-foreground/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="service_level"
|
||||||
|
checked={serviceLevel === "pro"}
|
||||||
|
readOnly
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">专业版</p>
|
||||||
|
<p className="text-sm text-muted-foreground">高质量智能生成</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 提交 */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
生成中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"开始生成"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误信息块 */}
|
||||||
|
{errorBlock && (
|
||||||
|
<Card className="border-destructive/50 bg-destructive/5">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="rounded-full bg-destructive/10 p-3">
|
||||||
|
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-semibold text-destructive">{errorBlock.title}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{errorBlock.message}</p>
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-400 mt-4">
|
||||||
|
建议:检查题目描述与标准程序是否完整正确,稍后重试或联系技术支持。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 进度遮罩 */}
|
||||||
|
{progressOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||||
|
<Card className="w-full max-w-md shadow-xl">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="rounded-full bg-primary/10 p-3">
|
||||||
|
<Loader2 className="h-6 w-6 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{progressTitle}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{progressMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>进度</span>
|
||||||
|
<span>{Math.round(progressPercent)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1 mb-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>任务 ID</span>
|
||||||
|
<span className="font-mono">{progressTaskId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>已生成</span>
|
||||||
|
<span>
|
||||||
|
{progressGenerated}/{progressTotal}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>运行时间</span>
|
||||||
|
<span>{runningTimeStr}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="w-full" onClick={handleCancelGeneration}>
|
||||||
|
取消生成
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 结果弹窗 */}
|
||||||
|
<Dialog open={resultModalOpen} onOpenChange={(open) => !open && handleCloseResultModal()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>生成完成</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{resultData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 dark:bg-green-950/30 dark:border-green-800 p-4">
|
||||||
|
<CheckCircle2 className="h-10 w-10 text-green-600 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-800 dark:text-green-200">
|
||||||
|
测试数据已成功生成并打包为 ZIP 文件
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">题目名称</span>
|
||||||
|
<span className="font-medium">{resultData.title ?? "—"}</span>
|
||||||
|
<span className="text-muted-foreground">测试点数量</span>
|
||||||
|
<span className="font-medium">{resultData.test_case_count ?? "—"} 个</span>
|
||||||
|
<span className="text-muted-foreground">生成时间</span>
|
||||||
|
<span className="font-medium">{new Date().toLocaleString("zh-CN")}</span>
|
||||||
|
<span className="text-muted-foreground">文件大小</span>
|
||||||
|
<span className="font-medium">{resultData.file_size ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="gap-2 flex-1"
|
||||||
|
onClick={() => resultData.task_id && handleDownload(resultData.task_id)}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
下载测试数据
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleCloseResultModal}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
app/text-formatter/page.tsx
Normal file
346
app/text-formatter/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { Scissors, Copy, Eraser, FileText, CheckCircle, Trash2, Paintbrush } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function TextFormatterPage() {
|
||||||
|
const [inputText, setInputText] = useState("");
|
||||||
|
const [outputText, setOutputText] = useState("");
|
||||||
|
const [removeSpaces, setRemoveSpaces] = useState(true);
|
||||||
|
const [removeLineBreaks, setRemoveLineBreaks] = useState(true);
|
||||||
|
const [removeExtraWhitespace, setRemoveExtraWhitespace] = useState(true);
|
||||||
|
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
originalChars: 0,
|
||||||
|
originalLines: 0,
|
||||||
|
formattedChars: 0,
|
||||||
|
formattedLines: 0,
|
||||||
|
spacesRemoved: 0,
|
||||||
|
lineBreaksRemoved: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calculateStats = useCallback((original: string, formatted: string) => {
|
||||||
|
const originalChars = original.length;
|
||||||
|
const originalLines = original.split("\n").length;
|
||||||
|
const formattedChars = formatted.length;
|
||||||
|
const formattedLines = formatted.split("\n").length;
|
||||||
|
|
||||||
|
const originalSpaces = (original.match(/\s/g) || []).length;
|
||||||
|
const formattedSpaces = (formatted.match(/\s/g) || []).length;
|
||||||
|
const spacesRemoved = originalSpaces - formattedSpaces;
|
||||||
|
|
||||||
|
const lineBreaksRemoved = Math.max(0, originalLines - formattedLines);
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
originalChars,
|
||||||
|
originalLines,
|
||||||
|
formattedChars,
|
||||||
|
formattedLines,
|
||||||
|
spacesRemoved,
|
||||||
|
lineBreaksRemoved,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatText = useCallback(() => {
|
||||||
|
if (!inputText.trim()) {
|
||||||
|
toast.warning("请输入需要格式化的文本");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatted = inputText;
|
||||||
|
|
||||||
|
if (removeLineBreaks) {
|
||||||
|
formatted = formatted.replace(/\r?\n/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeSpaces) {
|
||||||
|
formatted = formatted.replace(/\s+/g, "");
|
||||||
|
} else if (removeExtraWhitespace) {
|
||||||
|
formatted = formatted.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
setOutputText(formatted);
|
||||||
|
calculateStats(inputText, formatted);
|
||||||
|
toast.success("文本格式化完成");
|
||||||
|
}, [
|
||||||
|
inputText,
|
||||||
|
removeSpaces,
|
||||||
|
removeLineBreaks,
|
||||||
|
removeExtraWhitespace,
|
||||||
|
calculateStats,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const copyResult = useCallback(async () => {
|
||||||
|
if (!outputText) {
|
||||||
|
toast.warning("没有可复制的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(outputText);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
}, [outputText]);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
setInputText("");
|
||||||
|
setOutputText("");
|
||||||
|
setStats({
|
||||||
|
originalChars: 0,
|
||||||
|
originalLines: 0,
|
||||||
|
formattedChars: 0,
|
||||||
|
formattedLines: 0,
|
||||||
|
spacesRemoved: 0,
|
||||||
|
lineBreaksRemoved: 0,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const quickClean = useCallback(() => {
|
||||||
|
if (!inputText.trim()) {
|
||||||
|
toast.warning("请输入需要格式化的文本");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = inputText
|
||||||
|
.replace(/\r?\n/g, "")
|
||||||
|
.replace(/\t/g, "")
|
||||||
|
.replace(/\s+/g, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
setOutputText(formatted);
|
||||||
|
calculateStats(inputText, formatted);
|
||||||
|
toast.success("快速清理完成");
|
||||||
|
}, [inputText, calculateStats]);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback((value: string) => {
|
||||||
|
setInputText(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-pink-500 to-rose-600 shadow-lg">
|
||||||
|
<Scissors className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">文字格式化工具</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
去除复制文本的格式、空格和换行,还原纯净文字内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options Panel */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium">格式化选项</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="removeSpaces"
|
||||||
|
checked={removeSpaces}
|
||||||
|
onCheckedChange={setRemoveSpaces}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="removeSpaces">移除所有空格</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="removeLineBreaks"
|
||||||
|
checked={removeLineBreaks}
|
||||||
|
onCheckedChange={setRemoveLineBreaks}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="removeLineBreaks">移除换行符</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="removeExtraWhitespace"
|
||||||
|
checked={removeExtraWhitespace}
|
||||||
|
onCheckedChange={setRemoveExtraWhitespace}
|
||||||
|
disabled={removeSpaces}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="removeExtraWhitespace" className={removeSpaces ? "text-muted-foreground" : ""}>
|
||||||
|
移除多余空白
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-wrap items-center gap-4 p-4">
|
||||||
|
<Button onClick={formatText} disabled={!inputText.trim()} className="gap-2">
|
||||||
|
<Paintbrush className="h-4 w-4" />
|
||||||
|
格式化文本
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={quickClean}
|
||||||
|
disabled={!inputText.trim()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Scissors className="h-4 w-4" />
|
||||||
|
快速清理
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={copyResult}
|
||||||
|
disabled={!outputText}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
复制结果
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={clearAll}
|
||||||
|
className="gap-2 text-destructive hover:text-destructive/90 hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Eraser className="h-4 w-4" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stats Panel */}
|
||||||
|
{(inputText || outputText) && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 flex flex-col items-center justify-center text-center space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
原始字符数
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">{stats.originalChars}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 flex flex-col items-center justify-center text-center space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
格式化后
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-emerald-500">{stats.formattedChars}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 flex flex-col items-center justify-center text-center space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
移除空格
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-cyan-500">{stats.spacesRemoved}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 flex flex-col items-center justify-center text-center space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Scissors className="h-3 w-3" />
|
||||||
|
移除换行
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-violet-500">{stats.lineBreaksRemoved}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center justify-between">
|
||||||
|
<span>输入文本</span>
|
||||||
|
{inputText.trim() && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{stats.originalChars} 字符
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 p-0">
|
||||||
|
<Textarea
|
||||||
|
value={inputText}
|
||||||
|
onChange={(e) => handleInputChange(e.target.value)}
|
||||||
|
placeholder="请粘贴需要格式化的文本..."
|
||||||
|
className="min-h-[400px] border-0 rounded-none focus-visible:ring-0 resize-none font-mono text-sm leading-relaxed p-4"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center justify-between">
|
||||||
|
<span>格式化结果</span>
|
||||||
|
{outputText && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">
|
||||||
|
{stats.formattedChars} 字符
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={copyResult}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 p-0 bg-muted/30">
|
||||||
|
<Textarea
|
||||||
|
value={outputText}
|
||||||
|
readOnly
|
||||||
|
placeholder="格式化后的纯文本将显示在这里..."
|
||||||
|
className="min-h-[400px] border-0 rounded-none focus-visible:ring-0 resize-none font-mono text-sm leading-relaxed p-4 bg-transparent"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">主要功能</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li><strong>去除格式:</strong>清除文本中的各种格式信息</li>
|
||||||
|
<li><strong>移除空格:</strong>删除文字间的所有空格字符</li>
|
||||||
|
<li><strong>移除换行:</strong>去除文本中的换行符,合并为单行</li>
|
||||||
|
<li><strong>统计分析:</strong>显示处理前后的字符数变化</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">使用场景</h4>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>处理从Word、PDF复制的文本</li>
|
||||||
|
<li>清理网页复制的带格式文本</li>
|
||||||
|
<li>去除邮件内容中的多余换行</li>
|
||||||
|
<li>整理聊天记录或文档片段</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
app/timestamp/page.tsx
Normal file
258
app/timestamp/page.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Clock, ArrowLeftRight, Copy, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export default function TimestampPage() {
|
||||||
|
const [timestamp, setTimestamp] = useState("");
|
||||||
|
const [dateTime, setDateTime] = useState<string>("");
|
||||||
|
const [unit, setUnit] = useState<"seconds" | "milliseconds">("seconds");
|
||||||
|
const [currentTimestamp, setCurrentTimestamp] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize date time with current time
|
||||||
|
setDateTime(dayjs().format("YYYY-MM-DDTHH:mm:ss"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateCurrent = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
setCurrentTimestamp(
|
||||||
|
unit === "seconds" ? Math.floor(now / 1000).toString() : now.toString()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
updateCurrent();
|
||||||
|
const timer = setInterval(updateCurrent, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [unit]);
|
||||||
|
|
||||||
|
const handleUnitChange = (newUnit: "seconds" | "milliseconds") => {
|
||||||
|
if (timestamp && !isNaN(Number(timestamp))) {
|
||||||
|
const ts = Number(timestamp);
|
||||||
|
if (unit === "seconds" && newUnit === "milliseconds") {
|
||||||
|
setTimestamp((ts * 1000).toString());
|
||||||
|
} else if (unit === "milliseconds" && newUnit === "seconds") {
|
||||||
|
setTimestamp(Math.floor(ts / 1000).toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUnit(newUnit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timestampToDate = () => {
|
||||||
|
if (!timestamp) {
|
||||||
|
toast.warning("请输入时间戳");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ts = parseInt(timestamp);
|
||||||
|
if (isNaN(ts)) {
|
||||||
|
toast.error("无效的时间戳");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const date = unit === "seconds" ? dayjs.unix(ts) : dayjs(ts);
|
||||||
|
if (!date.isValid()) {
|
||||||
|
toast.error("无效的时间戳");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDateTime(date.format("YYYY-MM-DDTHH:mm:ss"));
|
||||||
|
toast.success("转换成功");
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateToTimestamp = () => {
|
||||||
|
if (!dateTime) {
|
||||||
|
toast.warning("请选择日期时间");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const date = dayjs(dateTime);
|
||||||
|
const ts = unit === "seconds" ? date.unix() : date.valueOf();
|
||||||
|
setTimestamp(ts.toString());
|
||||||
|
toast.success("转换成功");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNow = () => {
|
||||||
|
const now = dayjs();
|
||||||
|
setDateTime(now.format("YYYY-MM-DDTHH:mm:ss"));
|
||||||
|
const ts = unit === "seconds" ? now.unix() : now.valueOf();
|
||||||
|
setTimestamp(ts.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-amber-500 to-orange-600 shadow-lg">
|
||||||
|
<Clock className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">时间戳转换</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Unix 时间戳与日期时间互相转换
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Timestamp */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base font-medium">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
当前时间戳
|
||||||
|
</CardTitle>
|
||||||
|
<Tabs
|
||||||
|
value={unit}
|
||||||
|
onValueChange={(v) => handleUnitChange(v as "seconds" | "milliseconds")}
|
||||||
|
className="w-45"
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2 h-8">
|
||||||
|
<TabsTrigger value="seconds" className="text-xs">秒(s)</TabsTrigger>
|
||||||
|
<TabsTrigger value="milliseconds" className="text-xs">毫秒(ms)</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="font-mono text-3xl font-bold text-primary tracking-wider">
|
||||||
|
{currentTimestamp}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(currentTimestamp)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Timestamp to Date */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-medium">时间戳 → 日期时间</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
placeholder={`输入时间戳 (${unit === "seconds" ? "秒" : "毫秒"})`}
|
||||||
|
value={timestamp}
|
||||||
|
onChange={(e) => setTimestamp(e.target.value)}
|
||||||
|
className="pr-20 font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-1 top-1 h-7 text-xs text-muted-foreground hover:text-primary"
|
||||||
|
onClick={() => setTimestamp(currentTimestamp)}
|
||||||
|
>
|
||||||
|
使用当前
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={timestampToDate} className="w-full gap-2">
|
||||||
|
<ArrowLeftRight className="h-4 w-4" />
|
||||||
|
转换为日期时间
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Date to Timestamp */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-medium">日期时间 → 时间戳</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
step="1"
|
||||||
|
value={dateTime}
|
||||||
|
onChange={(e) => setDateTime(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={dateToTimestamp} className="w-full gap-2">
|
||||||
|
<ArrowLeftRight className="h-4 w-4" />
|
||||||
|
转换为时间戳
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result Display */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base font-medium">转换结果</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={setNow} className="gap-2 h-8">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
设为当前时间
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-lg bg-muted p-4 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
时间戳 ({unit === "seconds" ? "秒" : "毫秒"})
|
||||||
|
</p>
|
||||||
|
<div className="font-mono text-lg font-semibold truncate">
|
||||||
|
{timestamp || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted p-4 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
日期时间
|
||||||
|
</p>
|
||||||
|
<div className="font-mono text-lg font-semibold truncate">
|
||||||
|
{dateTime ? dayjs(dateTime).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>Unix 时间戳是从 1970-01-01 00:00:00 UTC 开始的秒数</li>
|
||||||
|
<li>支持秒级和毫秒级时间戳转换</li>
|
||||||
|
<li>JavaScript 通常使用毫秒级时间戳</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>后端语言(如 PHP、Python)通常使用秒级时间戳</li>
|
||||||
|
<li>点击"使用当前"可快速填入当前时间戳</li>
|
||||||
|
<li>支持复制当前实时时间戳</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
app/tts/page.tsx
Normal file
215
app/tts/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Volume2, Play, Square, Settings2, Info } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function TtsPage() {
|
||||||
|
const [text, setText] = useState("欢迎使用信奥工具箱,这是一个基于浏览器 Web Speech API 实现的文字转语音工具。您可以自由调节语速、音调和音量。");
|
||||||
|
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
|
||||||
|
const [selectedVoice, setSelectedVoice] = useState<string>("");
|
||||||
|
const [rate, setRate] = useState(1);
|
||||||
|
const [pitch, setPitch] = useState(1);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [isSpeaking, setIsActive] = useState(false);
|
||||||
|
const synth = useRef<SpeechSynthesis | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
synth.current = window.speechSynthesis;
|
||||||
|
|
||||||
|
const loadVoices = () => {
|
||||||
|
const availableVoices = synth.current?.getVoices() || [];
|
||||||
|
setVoices(availableVoices);
|
||||||
|
|
||||||
|
// Default to a Chinese voice if available, otherwise first one
|
||||||
|
const zhVoice = availableVoices.find(v => v.lang.includes("zh")) || availableVoices[0];
|
||||||
|
if (zhVoice && !selectedVoice) {
|
||||||
|
setSelectedVoice(zhVoice.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadVoices();
|
||||||
|
if (synth.current && synth.current.onvoiceschanged !== undefined) {
|
||||||
|
synth.current.onvoiceschanged = loadVoices;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (synth.current) {
|
||||||
|
synth.current.cancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const speak = () => {
|
||||||
|
if (!synth.current || !text) return;
|
||||||
|
|
||||||
|
if (synth.current.speaking) {
|
||||||
|
synth.current.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
|
const voice = voices.find(v => v.name === selectedVoice);
|
||||||
|
if (voice) utterance.voice = voice;
|
||||||
|
|
||||||
|
utterance.rate = rate;
|
||||||
|
utterance.pitch = pitch;
|
||||||
|
utterance.volume = volume;
|
||||||
|
|
||||||
|
utterance.onstart = () => setIsActive(true);
|
||||||
|
utterance.onend = () => setIsActive(false);
|
||||||
|
utterance.onerror = () => {
|
||||||
|
setIsActive(false);
|
||||||
|
toast.error("播放出错");
|
||||||
|
};
|
||||||
|
|
||||||
|
synth.current.speak(utterance);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (synth.current) {
|
||||||
|
synth.current.cancel();
|
||||||
|
setIsActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-cyan-500 to-blue-600 shadow-lg">
|
||||||
|
<Volume2 className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">文字转语音 (TTS)</h1>
|
||||||
|
<p className="text-muted-foreground">基于浏览器内置 API,无需安装插件</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">输入文本</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
placeholder="请在此输入想要朗读的文字..."
|
||||||
|
className="min-h-75 text-lg leading-relaxed"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button onClick={speak} size="lg" className="flex-1 gap-2 h-14 text-lg shadow-lg hover:scale-[1.02] active:scale-[0.98] transition-all" disabled={isSpeaking}>
|
||||||
|
<Play className="h-5 w-5 fill-current" />
|
||||||
|
开始朗读
|
||||||
|
</Button>
|
||||||
|
{isSpeaking && (
|
||||||
|
<Button onClick={stop} size="lg" variant="destructive" className="h-14 w-14 rounded-full p-0 shadow-lg animate-pulse">
|
||||||
|
<Square className="h-5 w-5 fill-current" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
语音设置
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
{/* Voice Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>选择音库 ({voices.length})</Label>
|
||||||
|
<Select value={selectedVoice} onValueChange={setSelectedVoice}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="加载中..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{voices.map((v) => (
|
||||||
|
<SelectItem key={v.name} value={v.name}>
|
||||||
|
{v.name} ({v.lang})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rate */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>语速 (Rate)</Label>
|
||||||
|
<span className="text-xs font-mono bg-muted px-1.5 rounded">{rate}x</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[rate]}
|
||||||
|
onValueChange={(v) => setRate(v[0])}
|
||||||
|
min={0.5}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pitch */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>音调 (Pitch)</Label>
|
||||||
|
<span className="text-xs font-mono bg-muted px-1.5 rounded">{pitch}</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[pitch]}
|
||||||
|
onValueChange={(v) => setPitch(v[0])}
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>音量 (Volume)</Label>
|
||||||
|
<span className="text-xs font-mono bg-muted px-1.5 rounded">{Math.round(volume * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[volume]}
|
||||||
|
onValueChange={(v) => setVolume(v[0])}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-xs text-muted-foreground grid md:grid-cols-2 gap-4">
|
||||||
|
<ul className="list-disc pl-4 space-y-1">
|
||||||
|
<li>文字转语音使用浏览器内置的 Web Speech API,无需安装额外软件</li>
|
||||||
|
<li>支持多种语言,具体取决于您的操作系统和浏览器</li>
|
||||||
|
</ul>
|
||||||
|
<ul className="list-disc pl-4 space-y-1">
|
||||||
|
<li>可调节语速、音调和音量以获得最佳效果</li>
|
||||||
|
<li>推荐使用 Chrome 或 Edge 浏览器以获得最佳体验</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
app/unicode/page.tsx
Normal file
113
app/unicode/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Languages, Copy, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function UnicodePage() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toUnicode = () => {
|
||||||
|
const result = input.split('').map(char => {
|
||||||
|
const code = char.charCodeAt(0).toString(16).toUpperCase();
|
||||||
|
return "\\u" + ("0000" + code).slice(-4);
|
||||||
|
}).join("");
|
||||||
|
setInput(result);
|
||||||
|
toast.success("已转换为 Unicode");
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromUnicode = () => {
|
||||||
|
try {
|
||||||
|
const result = input.replace(/\\u([0-9a-fA-F]{4})/g, (match, grp) => {
|
||||||
|
return String.fromCharCode(parseInt(grp, 16));
|
||||||
|
});
|
||||||
|
setInput(result);
|
||||||
|
toast.success("已从 Unicode 还原");
|
||||||
|
} catch {
|
||||||
|
toast.error("解析失败,请检查格式");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => setInput("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-blue-500 to-indigo-600 shadow-lg">
|
||||||
|
<Languages className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Unicode 转换</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Unicode 字符与 \uXXXX 编码之间的相互转换
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base font-medium">文本区域</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(input)} disabled={!input}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAll} disabled={!input} className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
placeholder="请输入普通文本或 Unicode 编码 (如 \u4F60\u597D)..."
|
||||||
|
className="min-h-62.5 font-mono text-base resize-y"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Button onClick={toUnicode} size="lg" className="flex-1 gap-2">
|
||||||
|
文本 → Unicode
|
||||||
|
</Button>
|
||||||
|
<Button onClick={fromUnicode} size="lg" variant="outline" className="flex-1 gap-2">
|
||||||
|
Unicode → 文本
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>本工具支持标准的 `\uXXXX` 格式 Unicode 编码转换。</li>
|
||||||
|
<li><strong>文本 → Unicode:</strong> 将中文字符、符号等转换为对应的 16 进制 Unicode 编码。</li>
|
||||||
|
<li><strong>Unicode → 文本:</strong> 将 `\uXXXX` 格式的字符串还原为可读文本。</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
app/url-encode/page.tsx
Normal file
193
app/url-encode/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link, ArrowLeftRight, Copy, Eraser } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function UrlEncodePage() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
const [mode, setMode] = useState<"encode" | "decode">("encode");
|
||||||
|
|
||||||
|
const handleEncode = () => {
|
||||||
|
if (!input) {
|
||||||
|
toast.warning("请输入要编码的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const encoded = encodeURIComponent(input);
|
||||||
|
setOutput(encoded);
|
||||||
|
toast.success("编码成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("编码失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecode = () => {
|
||||||
|
if (!input) {
|
||||||
|
toast.warning("请输入要解码的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(input);
|
||||||
|
setOutput(decoded);
|
||||||
|
toast.success("解码成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("解码失败,请检查输入是否为有效的 URL 编码字符串");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConvert = () => {
|
||||||
|
if (mode === "encode") {
|
||||||
|
handleEncode();
|
||||||
|
} else {
|
||||||
|
handleDecode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (!output) {
|
||||||
|
toast.warning("没有可复制的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(output);
|
||||||
|
toast.success("已复制到剪贴板");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
setInput("");
|
||||||
|
setOutput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const swapInputOutput = () => {
|
||||||
|
setInput(output);
|
||||||
|
setOutput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-blue-500 to-blue-600 shadow-lg">
|
||||||
|
<Link className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">URL 编解码</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
URL 参数编码与解码,处理特殊字符
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-wrap items-center gap-4 p-4">
|
||||||
|
<Tabs
|
||||||
|
value={mode}
|
||||||
|
onValueChange={(v) => setMode(v as "encode" | "decode")}
|
||||||
|
className="w-50"
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="encode">编码</TabsTrigger>
|
||||||
|
<TabsTrigger value="decode">解码</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<Button onClick={handleConvert} className="gap-2">
|
||||||
|
<ArrowLeftRight className="h-4 w-4" />
|
||||||
|
{mode === "encode" ? "编码" : "解码"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={swapInputOutput} className="gap-2">
|
||||||
|
<ArrowLeftRight className="h-4 w-4 rotate-90" />
|
||||||
|
交换
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={clearAll} className="gap-2">
|
||||||
|
<Eraser className="h-4 w-4" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-medium">
|
||||||
|
{mode === "encode" ? "原始文本" : "URL 编码字符串"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
mode === "encode"
|
||||||
|
? "请输入要编码的文本,如:你好 世界"
|
||||||
|
: "请输入要解码的 URL 编码字符串,如:%E4%BD%A0%E5%A5%BD"
|
||||||
|
}
|
||||||
|
className="min-h-75 font-mono text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base font-medium">
|
||||||
|
{mode === "encode" ? "URL 编码结果" : "解码结果"}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={output}
|
||||||
|
readOnly
|
||||||
|
placeholder="转换结果将显示在这里..."
|
||||||
|
className="min-h-75 font-mono text-sm bg-muted/50 resize-none"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="text-xl">💡</span> 使用说明
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>URL 编码用于处理 URL 中的特殊字符和非 ASCII 字符</li>
|
||||||
|
<li>空格会被编码为 %20</li>
|
||||||
|
<li>中文字符会被编码为 UTF-8 格式的百分号编码</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ul className="list-disc pl-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>使用 encodeURIComponent 进行编码</li>
|
||||||
|
<li>保留字符如 - _ . ! ~ * ' ( ) 不会被编码</li>
|
||||||
|
<li>常用于构建 URL 查询参数</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
app/user-agent/page.tsx
Normal file
135
app/user-agent/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Monitor, Globe, Cpu, Info } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function UAPage() {
|
||||||
|
const [ua, setUa] = useState("");
|
||||||
|
const [info, setInfo] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentUA = navigator.userAgent;
|
||||||
|
setUa(currentUA);
|
||||||
|
parseUA(currentUA);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parseUA = (userAgent: string) => {
|
||||||
|
if (!userAgent) return;
|
||||||
|
|
||||||
|
const browser = {
|
||||||
|
name: "未知浏览器",
|
||||||
|
version: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
const os = {
|
||||||
|
name: "未知操作系统",
|
||||||
|
version: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple Browser Detection
|
||||||
|
if (userAgent.indexOf("Edg") > -1) { browser.name = "Edge"; }
|
||||||
|
else if (userAgent.indexOf("Chrome") > -1) { browser.name = "Chrome"; }
|
||||||
|
else if (userAgent.indexOf("Firefox") > -1) { browser.name = "Firefox"; }
|
||||||
|
else if (userAgent.indexOf("Safari") > -1) { browser.name = "Safari"; }
|
||||||
|
|
||||||
|
// Simple OS Detection
|
||||||
|
if (userAgent.indexOf("Windows") > -1) { os.name = "Windows"; }
|
||||||
|
else if (userAgent.indexOf("Mac OS") > -1) { os.name = "macOS"; }
|
||||||
|
else if (userAgent.indexOf("Android") > -1) { os.name = "Android"; }
|
||||||
|
else if (userAgent.indexOf("iPhone") > -1 || userAgent.indexOf("iPad") > -1) { os.name = "iOS"; }
|
||||||
|
else if (userAgent.indexOf("Linux") > -1) { os.name = "Linux"; }
|
||||||
|
|
||||||
|
setInfo({ browser, os });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetToMine = () => {
|
||||||
|
const currentUA = navigator.userAgent;
|
||||||
|
setUa(currentUA);
|
||||||
|
parseUA(currentUA);
|
||||||
|
toast.success("已重置为当前浏览器 UA");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center space-x-4 border-b pb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-slate-600 to-slate-800 shadow-lg">
|
||||||
|
<Monitor className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">User-Agent 解析</h1>
|
||||||
|
<p className="text-muted-foreground">解析浏览器 User-Agent 字符串详细信息</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base">UA 字符串</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" onClick={resetToMine}>使用我的 UA</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={ua}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUa(e.target.value);
|
||||||
|
parseUA(e.target.value);
|
||||||
|
}}
|
||||||
|
className="font-mono min-h-25"
|
||||||
|
placeholder="请在此粘贴 User-Agent 字符串..."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{info && (
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4 text-blue-500" />
|
||||||
|
浏览器信息
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b pb-2">
|
||||||
|
<span className="text-muted-foreground">内核/名称</span>
|
||||||
|
<span className="font-bold">{info.browser.name}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Cpu className="h-4 w-4 text-orange-500" />
|
||||||
|
操作系统
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b pb-2">
|
||||||
|
<span className="text-muted-foreground">系统名称</span>
|
||||||
|
<span className="font-bold">{info.os.name}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
什么是 User-Agent?
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
User-Agent (UA) 是一个包含在 HTTP 请求头中的字符串,它向服务器标识用户所使用的操作系统、浏览器及其版本、渲染引擎等信息。服务器可以根据不同的 UA 返回适配的页面内容。
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
app/utils/decode.ts
Normal file
64
app/utils/decode.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import CryptoES from 'crypto-es';
|
||||||
|
|
||||||
|
const decrypt = function (ciphertext: string, iv: string, t: number): string {
|
||||||
|
try {
|
||||||
|
const key = generateKey(t);
|
||||||
|
const decrypted = CryptoES.AES.decrypt(ciphertext, CryptoES.enc.Utf8.parse(key), {
|
||||||
|
iv: CryptoES.enc.Hex.parse(iv),
|
||||||
|
mode: CryptoES.mode.CBC,
|
||||||
|
padding: CryptoES.pad.Pkcs7
|
||||||
|
});
|
||||||
|
const dec = CryptoES.enc.Utf8.stringify(decrypted).toString();
|
||||||
|
return dec;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Decryption failed", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function h(charArray: string[], modifier: number): string {
|
||||||
|
const uniqueChars = Array.from(new Set(charArray));
|
||||||
|
const numericModifier = Number(modifier.toString().slice(7));
|
||||||
|
const transformedString = uniqueChars.map(char => {
|
||||||
|
const charCode = char.charCodeAt(0);
|
||||||
|
let newCharCode = Math.abs(charCode - (numericModifier % 127) - 1);
|
||||||
|
if (newCharCode < 33) {
|
||||||
|
newCharCode += 33;
|
||||||
|
}
|
||||||
|
return String.fromCharCode(newCharCode);
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
return transformedString;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParams(t: number): Record<string, string | number> {
|
||||||
|
return {
|
||||||
|
'akv': '2.8.1496', // apk_version_name 版本号
|
||||||
|
'apv': '1.3.6', // 内部版本号
|
||||||
|
'b': 'XiaoMi', // 手机品牌
|
||||||
|
'd': 'e87a4d5f4f28d7a17d73c524eaa8ac37', // 设备id 可随机生成
|
||||||
|
'm': '23046RP50C', // 手机型号
|
||||||
|
'mac': '', // mac地址
|
||||||
|
'n': '23046RP50C', // 手机型号
|
||||||
|
't': t, // 时间戳
|
||||||
|
'wifiMac': '020000000000', // wifiMac地址
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateKey = function (t: number): string {
|
||||||
|
const params = getParams(t);
|
||||||
|
const sortedKeys = Object.keys(params).sort();
|
||||||
|
let concatenatedParams = "";
|
||||||
|
|
||||||
|
sortedKeys.forEach(key => {
|
||||||
|
if (key !== "t") {
|
||||||
|
concatenatedParams += params[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyArray = concatenatedParams.split("");
|
||||||
|
const hashedKey = h(keyArray, t);
|
||||||
|
return CryptoES.MD5(hashedKey).toString(CryptoES.enc.Hex);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { decrypt, getParams };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user