Compare commits
137 Commits
8241e95508
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e6526b77aa | |||
| 780dbe075b | |||
| edf961eb7b | |||
| 285f4d07bc | |||
| f7fa3a46d2 | |||
| b0c8cfb024 | |||
| f86e3b4725 | |||
| c6a825c284 | |||
| c79de38d54 | |||
| a5b4302ab7 | |||
| 162f8fe040 | |||
| 2e4540095b | |||
| 42cf06c373 | |||
| 97187806e1 | |||
| d92202b6ab | |||
| 4ae7ed783f | |||
| 72bf4fee7c | |||
| daacab613a | |||
| 52b62694c3 | |||
| 36419f9bf2 | |||
| 5576135726 | |||
| 02da429358 | |||
| 71d768bd9e | |||
| 3b7c8bbcb2 | |||
| ccfd5be59f | |||
| 96aa507f77 | |||
| 95f614a51e | |||
| bd4091524e | |||
| 5b7f778551 | |||
| 38f64f770d | |||
| 2ee249f6f7 | |||
| e3698a8a60 | |||
| 618ac0bf8c | |||
| 0f58c4ef7c | |||
| 92e298ee4e | |||
| 3df33ae4ce | |||
| fd711a22c8 | |||
| d81cdfe789 | |||
| fd8b07b0d8 | |||
| 394727ccdb | |||
| 993c6d8870 | |||
| f35eb48480 | |||
| 8b25de05dc | |||
| afba56c177 | |||
| d37e2abf0d | |||
| 71dbb921ff | |||
| a38bb5d38a | |||
| 94c515b2de | |||
| 61b299ed15 | |||
| 94004069cb | |||
| 1c85a4abef | |||
| 7d39166ed5 | |||
| ff9401c24e | |||
| 4181e4bd71 | |||
| 5be2e91d0a | |||
| 4745f230b2 | |||
| bad2843bfb | |||
| cc2f715e77 | |||
| 6df5f6a8db | |||
| 6524911c31 | |||
| fcac29b7b0 | |||
| 2b24a66dbc | |||
| 92487eac92 | |||
| c1ab737894 | |||
| 3fdd504d01 | |||
| 98a9fc041f | |||
| 006886751f | |||
| 832e55363b | |||
| ea16dbb91d | |||
| fcfd2a9a9f | |||
| 02fc54df21 | |||
| f3113d787d | |||
| a607748df0 | |||
| 731d64b26d | |||
| b6a2c13f72 | |||
| 3dcd4b0581 | |||
| 330a8c128b | |||
| 8ec2f9f097 | |||
| bbf27538b2 | |||
| 56cf0e1de0 | |||
| 83476efe63 | |||
| 49359845c0 | |||
| 170c42cb52 | |||
| 67a427af49 | |||
| e65a2ded46 | |||
| de8761846a | |||
| b0ecc0a514 | |||
| 3d404dc6ab | |||
| f22be971a5 | |||
| e5da524504 | |||
| 2bc3fd541a | |||
| 82aec50e7a | |||
| 314406cd41 | |||
| 13635b2d9d | |||
| 3eb4d5ff80 | |||
| 601425c783 | |||
| cb705c0150 | |||
| 419b3c8625 | |||
| b5a460e440 | |||
| 109e5ec119 | |||
| 22f5197417 | |||
| 854273f6a4 | |||
| fad0450fd1 | |||
| 3ce7e7cee0 | |||
| 5637b002a5 | |||
| eed624e491 | |||
| ed9cbde7be | |||
| e020a53020 | |||
| 0db0f40ce3 | |||
| 8aa9b5694a | |||
| dabe5fbb2c | |||
| 61fa7be47d | |||
| 849a7219df | |||
| c6f47e0e10 | |||
| dbaee3d858 | |||
| 8ed24ed44b | |||
| 3d7e13cbc2 | |||
| db0cd8167d | |||
| 611602cfef | |||
| 804af6b1fc | |||
| 67eec9ebb4 | |||
| 6c1953a831 | |||
| df01c08734 | |||
| f3d2590a44 | |||
| 23385ce3ac | |||
| a65c46cb46 | |||
| 9f716728fa | |||
| 0fba9eac06 | |||
| b0b58333a4 | |||
| df79d6f74e | |||
| cf66053337 | |||
| 7cedf4fba3 | |||
| 09084eef21 | |||
| 8c7d415ae7 | |||
| b6c82e9bc9 | |||
| 6c40fde269 | |||
| 5db1e8b15a |
+762
-118
@@ -1,146 +1,790 @@
|
|||||||
name: Publish Release Assets
|
name: CI
|
||||||
run-name: Build and publish web assets and Docker images
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
- dev
|
||||||
- "apps/**"
|
- cicd
|
||||||
- "packages/**"
|
|
||||||
- "package.json"
|
|
||||||
- "turbo.json"
|
|
||||||
- ".gitea/workflows/*.yml"
|
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened]
|
branches:
|
||||||
paths:
|
- main
|
||||||
- "apps/**"
|
- dev
|
||||||
- "packages/**"
|
- cicd
|
||||||
- "package.json"
|
|
||||||
- "turbo.json"
|
|
||||||
- ".gitea/workflows/*.yml"
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Gitea
|
DOMAIN_URL: git.kxsw.us
|
||||||
GIT_USERNAME: ${{ vars.GIT_USERNAME }}
|
REPO: ${{ vars.REPO }}
|
||||||
GIT_PASSWORD: ${{ vars.GIT_PASSWORD }}
|
TELEGRAM_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
||||||
# Docker Hub
|
TELEGRAM_CHAT_ID: "-4940243803"
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_REGISTRY: registry.kxsw.us
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
DOCKER_BUILDKIT: 1
|
||||||
# Host SSH (用于上传构建产物)
|
DOCKER_API_VERSION: "1.44"
|
||||||
SSH_HOST: ${{ vars.SSH_HOST }}
|
# Host SSH - 根据分支动态选择
|
||||||
SSH_PORT: ${{ vars.SSH_PORT }}
|
SSH_HOST: ${{ github.ref_name == 'main' && vars.PRO_SSH_HOST || (github.ref_name == 'dev' && vars.DEV_SSH_HOST || vars.PRO_SSH_HOST) }}
|
||||||
SSH_USER: ${{ vars.SSH_USER }}
|
SSH_PORT: ${{ github.ref_name == 'main' && vars.PRO_SSH_PORT || (github.ref_name == 'dev' && vars.DEV_SSH_PORT || vars.PRO_SSH_PORT) }}
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_USER: ${{ github.ref_name == 'main' && vars.PRO_SSH_USER || (github.ref_name == 'dev' && vars.DEV_SSH_USER || vars.PRO_SSH_USER) }}
|
||||||
|
SSH_PASSWORD: ${{ github.ref_name == 'main' && vars.PRO_SSH_PASSWORD || (github.ref_name == 'dev' && vars.DEV_SSH_PASSWORD || vars.PRO_SSH_PASSWORD) }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
build:
|
||||||
name: Publish Release Assets
|
runs-on: ppanel-web02
|
||||||
runs-on: ubuntu-latest
|
container:
|
||||||
|
image: node:20
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
# 只有node支持版本号别名
|
||||||
|
node: ['20.15.1']
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout codebase
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
with:
|
||||||
bun-version: 'latest'
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Cache Bun dependencies
|
- name: 缓存服务健康检查
|
||||||
uses: actions/cache@v3
|
id: cache-health
|
||||||
with:
|
continue-on-error: true
|
||||||
path: |
|
|
||||||
~/.bun
|
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('**/bun.lockb') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-cache-
|
|
||||||
|
|
||||||
- name: Install deps
|
|
||||||
run: bun install --cache
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Run publish script
|
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/publish.sh
|
echo "检查缓存服务可用性..."
|
||||||
./scripts/publish.sh
|
|
||||||
|
|
||||||
- name: Upload tar.gz file to release
|
# 设置缓存可用性标志
|
||||||
if: ${{ gitea.event_name == 'release' }}
|
CACHE_AVAILABLE=true
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
out/ppanel-admin-web.tar.gz
|
|
||||||
out/ppanel-user-web.tar.gz
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
# 测试GitHub Actions缓存API
|
||||||
uses: docker/setup-qemu-action@v3
|
if ! curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${{ github.repository }}/actions/caches" > /dev/null 2>&1; then
|
||||||
|
echo "⚠️ GitHub Actions缓存服务不可用,将跳过缓存步骤"
|
||||||
|
CACHE_AVAILABLE=false
|
||||||
|
else
|
||||||
|
echo "✅ 缓存服务可用"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "CACHE_AVAILABLE=$CACHE_AVAILABLE" >> $GITHUB_ENV
|
||||||
|
echo "cache-available=$CACHE_AVAILABLE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: 缓存降级提示
|
||||||
|
if: env.CACHE_AVAILABLE == 'false'
|
||||||
|
run: |
|
||||||
|
echo "🔄 缓存服务不可用,构建将在无缓存模式下进行"
|
||||||
|
echo "⏱️ 这可能会增加构建时间,但不会影响构建结果"
|
||||||
|
echo "📦 所有依赖将重新下载和安装"
|
||||||
|
|
||||||
|
- name: Install system tools (jq, docker, curl)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
echo "Waiting for apt/dpkg locks (unattended-upgrades) to release..."
|
||||||
|
# Wait up to 300s for unattended-upgrades/apt/dpkg locks
|
||||||
|
end=$((SECONDS+300))
|
||||||
|
while true; do
|
||||||
|
LOCKS_BUSY=0
|
||||||
|
# If unattended-upgrades is running, mark busy
|
||||||
|
if pgrep -x unattended-upgrades >/dev/null 2>&1; then LOCKS_BUSY=1; fi
|
||||||
|
# If fuser exists, check common lock files
|
||||||
|
if command -v fuser >/dev/null 2>&1; then
|
||||||
|
if fuser /var/lib/dpkg/lock >/dev/null 2>&1 \
|
||||||
|
|| fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
|
||||||
|
|| fuser /var/lib/apt/lists/lock >/dev/null 2>&1; then
|
||||||
|
LOCKS_BUSY=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# Break if not busy
|
||||||
|
if [ "$LOCKS_BUSY" -eq 0 ]; then break; fi
|
||||||
|
# Timeout after ~5 minutes
|
||||||
|
if [ $SECONDS -ge $end ]; then
|
||||||
|
echo "Timeout waiting for apt/dpkg locks, proceeding with Dpkg::Lock::Timeout..."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Still waiting for locks..."; sleep 5
|
||||||
|
done
|
||||||
|
apt-get update -y -o Dpkg::Lock::Timeout=600
|
||||||
|
# 基础工具和GPG
|
||||||
|
apt-get install -y -o Dpkg::Lock::Timeout=600 jq curl ca-certificates gnupg
|
||||||
|
# 配置Docker官方源,安装新版CLI与Buildx插件(支持 API 1.44+)
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -y -o Dpkg::Lock::Timeout=600
|
||||||
|
apt-get install -y -o Dpkg::Lock::Timeout=600 docker-ce-cli docker-buildx-plugin
|
||||||
|
docker --version
|
||||||
|
jq --version
|
||||||
|
curl --version
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
run: |
|
||||||
|
# Check if buildx is available
|
||||||
|
if docker buildx version >/dev/null 2>&1; then
|
||||||
|
echo "Docker Buildx is available"
|
||||||
|
# Create builder if it doesn't exist
|
||||||
|
if ! docker buildx ls | grep -q "builder"; then
|
||||||
|
docker buildx create --name builder --driver docker-container
|
||||||
|
fi
|
||||||
|
# Use the builder
|
||||||
|
docker buildx use builder
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
else
|
||||||
|
echo "Docker Buildx not available, using regular docker build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
run: |
|
||||||
|
echo "=== Installing Bun ==="
|
||||||
|
echo "Current working directory: $(pwd)"
|
||||||
|
echo "Current user: $(whoami)"
|
||||||
|
echo "Home directory: $HOME"
|
||||||
|
|
||||||
- name: Install jq
|
# 设置Bun安装路径
|
||||||
run: sudo apt-get install -y jq
|
export BUN_INSTALL="$HOME/.bun"
|
||||||
|
echo "BUN_INSTALL=$BUN_INSTALL" >> $GITHUB_ENV
|
||||||
|
echo "PATH=$BUN_INSTALL/bin:${PATH}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Extract version from package.json
|
# 检查缓存是否存在
|
||||||
id: version
|
if [ -d "$BUN_INSTALL" ]; then
|
||||||
run: echo "PPANEL_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_ENV
|
echo "✅ Bun cache found at $BUN_INSTALL"
|
||||||
|
ls -la "$BUN_INSTALL" || true
|
||||||
|
else
|
||||||
|
echo "❌ No Bun cache found, will install fresh"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build and push Docker image for ppanel-admin-web
|
# 安装Bun
|
||||||
uses: docker/build-push-action@v6
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
|
# 验证安装
|
||||||
|
"$BUN_INSTALL/bin/bun" --version
|
||||||
|
echo "✅ Bun installed successfully"
|
||||||
|
|
||||||
|
- name: Configure npm registry (npmmirror) and canvas mirror
|
||||||
|
run: |
|
||||||
|
echo "registry=https://registry.npmmirror.com" >> .npmrc
|
||||||
|
echo "canvas_binary_host_mirror=https://registry.npmmirror.com/-/binary/canvas" >> .npmrc
|
||||||
|
|
||||||
|
- name: Install dependencies cache (Bun)
|
||||||
|
if: env.CACHE_AVAILABLE == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
context: .
|
path: ~/.bun
|
||||||
file: ./docker/ppanel-admin-web/Dockerfile
|
key: bun-${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('bun.lock') }}
|
||||||
platforms: linux/amd64,linux/arm64
|
restore-keys: |
|
||||||
push: true
|
bun-${{ runner.os }}-${{ matrix.node }}-
|
||||||
tags: |
|
bun-${{ runner.os }}-
|
||||||
${{ env.DOCKER_USERNAME }}/ppanel-admin-web:latest
|
|
||||||
${{ env.DOCKER_USERNAME }}/ppanel-admin-web:${{ env.PPANEL_VERSION }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image for ppanel-user-web
|
- name: Install dependencies cache (node_modules)
|
||||||
uses: docker/build-push-action@v6
|
if: env.CACHE_AVAILABLE == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
context: .
|
|
||||||
file: ./docker/ppanel-user-web/Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_USERNAME }}/ppanel-user-web:latest
|
|
||||||
${{ env.DOCKER_USERNAME }}/ppanel-user-web:${{ env.PPANEL_VERSION }}
|
|
||||||
|
|
||||||
- name: Upload to SSH server (if configured)
|
|
||||||
if: ${{ env.SSH_HOST != '' }}
|
|
||||||
uses: appleboy/ssh-action@v1
|
|
||||||
with:
|
|
||||||
host: ${{ env.SSH_HOST }}
|
|
||||||
username: ${{ env.SSH_USER }}
|
|
||||||
key: ${{ env.SSH_PRIVATE_KEY }}
|
|
||||||
port: ${{ env.SSH_PORT }}
|
|
||||||
script: |
|
|
||||||
mkdir -p ~/releases/${{ gitea.ref_name || 'latest' }}
|
|
||||||
|
|
||||||
- name: Upload files via SCP (if SSH configured)
|
|
||||||
if: ${{ env.SSH_HOST != '' }}
|
|
||||||
uses: appleboy/scp-action@v1
|
|
||||||
with:
|
|
||||||
host: ${{ env.SSH_HOST }}
|
|
||||||
username: ${{ env.SSH_USER }}
|
|
||||||
key: ${{ env.SSH_PRIVATE_KEY }}
|
|
||||||
port: ${{ env.SSH_PORT }}
|
|
||||||
source: "out/*.tar.gz"
|
|
||||||
target: "~/releases/${{ gitea.ref_name || 'latest' }}/"
|
|
||||||
|
|
||||||
- name: Upload artifacts to Gitea
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: ppanel-web-assets
|
|
||||||
path: |
|
path: |
|
||||||
out/ppanel-admin-web.tar.gz
|
node_modules
|
||||||
out/ppanel-user-web.tar.gz
|
apps/*/node_modules
|
||||||
|
packages/*/node_modules
|
||||||
|
key: node-modules-${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('bun.lock', 'package.json', 'apps/*/package.json', 'packages/*/package.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
node-modules-${{ runner.os }}-${{ matrix.node }}-
|
||||||
|
node-modules-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: 缓存状态检查和设置
|
||||||
|
run: |
|
||||||
|
echo "=== 缓存状态检查 ==="
|
||||||
|
echo "检查缓存恢复状态..."
|
||||||
|
|
||||||
|
# 检查各种缓存目录
|
||||||
|
echo "Bun缓存: $([ -d ~/.bun ] && echo '✅ 已发现' || echo '❌ 缺失')"
|
||||||
|
echo "node_modules: $([ -d node_modules ] && echo '✅ 已发现' || echo '❌ 缺失')"
|
||||||
|
echo "Turbo缓存: $([ -d .turbo ] && echo '✅ 已发现' || echo '❌ 缺失')"
|
||||||
|
|
||||||
|
# 显示缓存大小
|
||||||
|
if [ -d ~/.bun ]; then
|
||||||
|
echo "Bun缓存大小: $(du -sh ~/.bun 2>/dev/null || echo '未知')"
|
||||||
|
fi
|
||||||
|
if [ -d node_modules ]; then
|
||||||
|
echo "node_modules大小: $(du -sh node_modules 2>/dev/null || echo '未知')"
|
||||||
|
fi
|
||||||
|
if [ -d .turbo ]; then
|
||||||
|
echo "Turbo缓存大小: $(du -sh .turbo 2>/dev/null || echo '未知')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== 缓存设置 ==="
|
||||||
|
# 确保缓存目录存在且权限正确
|
||||||
|
mkdir -p ~/.bun ~/.cache .turbo
|
||||||
|
chmod -R 755 ~/.bun ~/.cache .turbo 2>/dev/null || true
|
||||||
|
|
||||||
|
# 设置Bun环境变量
|
||||||
|
echo "BUN_INSTALL_CACHE_DIR=$HOME/.cache/bun" >> $GITHUB_ENV
|
||||||
|
echo "BUN_INSTALL_BIN_DIR=$HOME/.bun/bin" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
echo "✅ 缓存目录已准备完成"
|
||||||
|
|
||||||
|
- name: Turborepo cache (.turbo)
|
||||||
|
if: env.CACHE_AVAILABLE == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json') }}-${{ hashFiles('apps//package.json') }}-${{ hashFiles('packages//package.json') }}-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
turbo-${{ runner.os }}-${{ hashFiles('turbo.json') }}-${{ hashFiles('apps//package.json') }}-${{ hashFiles('packages//package.json') }}-
|
||||||
|
turbo-${{ runner.os }}-${{ hashFiles('turbo.json') }}-
|
||||||
|
turbo-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: 安装依赖 (bun)
|
||||||
|
run: |
|
||||||
|
echo "=== 依赖安装调试信息 ==="
|
||||||
|
echo "当前目录: $(pwd)"
|
||||||
|
echo "Bun版本: $(bun --version)"
|
||||||
|
|
||||||
|
# 检查node_modules缓存状态
|
||||||
|
if [ -d "node_modules" ]; then
|
||||||
|
echo "✅ 发现node_modules缓存"
|
||||||
|
echo "node_modules大小: $(du -sh node_modules 2>/dev/null || echo '未知')"
|
||||||
|
else
|
||||||
|
echo "❌ 未发现node_modules缓存"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查bun.lock文件
|
||||||
|
if [ -f "bun.lock" ]; then
|
||||||
|
echo "✅ 发现bun.lock文件"
|
||||||
|
else
|
||||||
|
echo "❌ 未发现bun.lock文件"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== 开始安装依赖 ==="
|
||||||
|
echo "安装开始时间: $(date)"
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
echo "安装完成时间: $(date)"
|
||||||
|
|
||||||
|
echo "=== 依赖安装完成 ==="
|
||||||
|
echo "最终node_modules大小: $(du -sh node_modules 2>/dev/null || echo '未知')"
|
||||||
|
|
||||||
|
# 验证缓存效果
|
||||||
|
echo "=== 缓存效果验证 ==="
|
||||||
|
if [ -d "node_modules" ]; then
|
||||||
|
echo "✅ 依赖安装成功"
|
||||||
|
echo "包数量: $(ls node_modules | wc -l 2>/dev/null || echo '未知')"
|
||||||
|
else
|
||||||
|
echo "⚠️ 依赖可能未完全安装"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
- name: Decide build target (admin/user/both)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
COMMIT_MSG="${{ github.event.head_commit.message }}"
|
||||||
|
BUILD_TARGET="both"
|
||||||
|
if echo "$COMMIT_MSG" | grep -qi "\[admin-only\]"; then
|
||||||
|
BUILD_TARGET="admin"
|
||||||
|
elif echo "$COMMIT_MSG" | grep -qi "\[user-only\]"; then
|
||||||
|
BUILD_TARGET="user"
|
||||||
|
else
|
||||||
|
if git rev-parse HEAD^ >/dev/null 2>&1; then
|
||||||
|
RANGE="HEAD^..HEAD"
|
||||||
|
else
|
||||||
|
RANGE="$(git rev-list --max-parents=0 HEAD)..HEAD"
|
||||||
|
fi
|
||||||
|
CHANGED=$(git diff --name-only $RANGE || true)
|
||||||
|
ADMIN_MATCH=$(echo "$CHANGED" | grep -E '^(apps/admin/|docker/ppanel-admin-web/)' || true)
|
||||||
|
USER_MATCH=$(echo "$CHANGED" | grep -E '^(apps/user/|docker/ppanel-user-web/)' || true)
|
||||||
|
PACKAGE_MATCH=$(echo "$CHANGED" | grep -E '^(packages/|turbo.json|package.json|bun.lock)' || true)
|
||||||
|
if [ -n "$PACKAGE_MATCH" ]; then
|
||||||
|
BUILD_TARGET="both"
|
||||||
|
else
|
||||||
|
if [ -n "$ADMIN_MATCH" ] && [ -z "$USER_MATCH" ]; then BUILD_TARGET="admin"; fi
|
||||||
|
if [ -n "$USER_MATCH" ] && [ -z "$ADMIN_MATCH" ]; then BUILD_TARGET="user"; fi
|
||||||
|
if [ -n "$ADMIN_MATCH" ] && [ -n "$USER_MATCH" ]; then BUILD_TARGET="both"; fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "BUILD_TARGET=$BUILD_TARGET" >> $GITHUB_ENV
|
||||||
|
echo "Decided BUILD_TARGET=$BUILD_TARGET"
|
||||||
|
|
||||||
|
- name: Read version from package.json
|
||||||
|
run: |
|
||||||
|
if [ "$BUILD_TARGET" = "admin" ]; then
|
||||||
|
VERSION=$(jq -r .version apps/admin/package.json)
|
||||||
|
echo "使用 admin 应用版本: $VERSION"
|
||||||
|
elif [ "$BUILD_TARGET" = "user" ]; then
|
||||||
|
VERSION=$(jq -r .version apps/user/package.json)
|
||||||
|
echo "使用 user 应用版本: $VERSION"
|
||||||
|
else
|
||||||
|
# both 或其他情况使用根目录版本
|
||||||
|
VERSION=$(jq -r .version package.json)
|
||||||
|
echo "使用根目录版本: $VERSION"
|
||||||
|
fi
|
||||||
|
if [ "$VERSION" = "null" ] || [ -z "$VERSION" ] || [ "$VERSION" = "undefined" ]; then
|
||||||
|
echo "检测到版本为空,回退到根目录版本"
|
||||||
|
VERSION=$(jq -r .version package.json)
|
||||||
|
fi
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 根据分支动态设置API地址
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
|
echo "NEXT_PUBLIC_API_URL=https://api.airoport.co" >> $GITHUB_ENV
|
||||||
|
echo "为main分支设置生产环境API地址"
|
||||||
|
elif [ "${{ github.ref_name }}" = "dev" ]; then
|
||||||
|
echo "NEXT_PUBLIC_API_URL=https://api.kxsw.us" >> $GITHUB_ENV
|
||||||
|
echo "为dev分支设置开发环境API地址"
|
||||||
|
else
|
||||||
|
echo "NEXT_PUBLIC_API_URL=https://api.airoport.co" >> $GITHUB_ENV
|
||||||
|
echo "为其他分支设置默认API地址"
|
||||||
|
fi
|
||||||
|
echo "BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Cache Next.js build artifacts (.next/cache)
|
||||||
|
if: env.CACHE_AVAILABLE == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
apps/admin/.next/cache
|
||||||
|
apps/user/.next/cache
|
||||||
|
key: nextcache-${{ runner.os }}-${{ hashFiles('apps//package.json') }}-${{ hashFiles('packages//package.json') }}-${{ hashFiles('turbo.json') }}-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
nextcache-${{ runner.os }}-${{ hashFiles('apps//package.json') }}-${{ hashFiles('packages//package.json') }}-
|
||||||
|
nextcache-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Cache build outputs
|
||||||
|
if: env.CACHE_AVAILABLE == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
apps/admin/.next
|
||||||
|
apps/user/.next
|
||||||
|
apps/admin/dist
|
||||||
|
apps/user/dist
|
||||||
|
key: build-${{ runner.os }}-${{ hashFiles('apps//*.ts', 'apps//*.tsx', 'apps//*.js', 'apps//*.jsx') }}-${{ hashFiles('packages//*.ts', 'packages//*.tsx') }}-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
build-${{ runner.os }}-${{ hashFiles('apps//*.ts', 'apps//*.tsx', 'apps//*.js', 'apps//*.jsx') }}-
|
||||||
|
build-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Cache ESLint
|
||||||
|
if: env.CACHE_AVAILABLE == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
.eslintcache
|
||||||
|
apps/admin/.eslintcache
|
||||||
|
apps/user/.eslintcache
|
||||||
|
key: eslint-${{ runner.os }}-${{ hashFiles('.eslintrc*', 'apps//.eslintrc*', 'packages//.eslintrc*') }}-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
eslint-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Cache TypeScript
|
||||||
|
if: env.CACHE_AVAILABLE == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
.tsbuildinfo
|
||||||
|
apps/admin/.tsbuildinfo
|
||||||
|
apps/user/.tsbuildinfo
|
||||||
|
packages//.tsbuildinfo
|
||||||
|
key: typescript-${{ runner.os }}-${{ hashFiles('tsconfig*.json', 'apps//tsconfig*.json', 'packages//tsconfig*.json') }}-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
typescript-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: 构建管理面板
|
||||||
|
if: env.BUILD_TARGET == 'admin' || env.BUILD_TARGET == 'both'
|
||||||
|
run: bun run build --filter=ppanel-admin-web
|
||||||
|
|
||||||
|
- name: 构建用户面板
|
||||||
|
if: env.BUILD_TARGET == 'user' || env.BUILD_TARGET == 'both'
|
||||||
|
run: bun run build --filter=ppanel-user-web
|
||||||
|
|
||||||
|
- name: 构建并推送管理面板Docker镜像
|
||||||
|
if: env.BUILD_TARGET == 'admin' || env.BUILD_TARGET == 'both'
|
||||||
|
run: |
|
||||||
|
if docker buildx version >/dev/null 2>&1; then
|
||||||
|
echo "使用docker buildx进行优化构建"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--cache-from type=registry,ref=${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-admin-web:cache \
|
||||||
|
--cache-to type=registry,ref=${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-admin-web:cache,mode=max \
|
||||||
|
-f ./docker/ppanel-admin-web/Dockerfile \
|
||||||
|
-t ${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-admin-web:${{ env.VERSION }} \
|
||||||
|
--push .
|
||||||
|
else
|
||||||
|
echo "使用常规docker构建"
|
||||||
|
docker build -f ./docker/ppanel-admin-web/Dockerfile -t ${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-admin-web:${{ env.VERSION }} .
|
||||||
|
docker push ${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-admin-web:${{ env.VERSION }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 构建并推送用户面板Docker镜像
|
||||||
|
if: env.BUILD_TARGET == 'user' || env.BUILD_TARGET == 'both'
|
||||||
|
run: |
|
||||||
|
if docker buildx version >/dev/null 2>&1; then
|
||||||
|
echo "使用docker buildx进行优化构建"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--cache-from type=registry,ref=${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-user-web:cache \
|
||||||
|
--cache-to type=registry,ref=${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-user-web:cache,mode=max \
|
||||||
|
-f ./docker/ppanel-user-web/Dockerfile \
|
||||||
|
-t ${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-user-web:${{ env.VERSION }} \
|
||||||
|
--push .
|
||||||
|
else
|
||||||
|
echo "使用常规docker构建"
|
||||||
|
docker build -f ./docker/ppanel-user-web/Dockerfile -t ${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-user-web:${{ env.VERSION }} .
|
||||||
|
docker push ${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-user-web:${{ env.VERSION }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: SSH连接预检查
|
||||||
|
if: env.BUILD_TARGET == 'admin' || env.BUILD_TARGET == 'user' || env.BUILD_TARGET == 'both'
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ env.SSH_HOST }}
|
||||||
|
username: ${{ env.SSH_USER }}
|
||||||
|
password: ${{ env.SSH_PASSWORD }}
|
||||||
|
port: ${{ env.SSH_PORT }}
|
||||||
|
timeout: 300s
|
||||||
|
command_timeout: 600s
|
||||||
|
debug: true
|
||||||
|
script: |
|
||||||
|
echo "=== SSH连接测试 ==="
|
||||||
|
echo "连接时间: $(date)"
|
||||||
|
echo "服务器主机名: $(hostname)"
|
||||||
|
echo "当前用户: $(whoami)"
|
||||||
|
echo "系统信息: $(uname -a)"
|
||||||
|
echo "Docker版本: $(docker --version 2>/dev/null || echo 'Docker未安装')"
|
||||||
|
echo "✅ SSH连接成功"
|
||||||
|
|
||||||
|
- name: 部署管理面板到服务器
|
||||||
|
if: env.BUILD_TARGET == 'admin' || env.BUILD_TARGET == 'both'
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ env.SSH_HOST }}
|
||||||
|
username: ${{ env.SSH_USER }}
|
||||||
|
password: ${{ env.SSH_PASSWORD }}
|
||||||
|
port: ${{ env.SSH_PORT }}
|
||||||
|
timeout: 300s
|
||||||
|
command_timeout: 600s
|
||||||
|
script: |
|
||||||
|
echo "=== SSH变量调试信息 ==="
|
||||||
|
echo "DOCKER_REGISTRY: ${{ env.DOCKER_REGISTRY }}"
|
||||||
|
echo "VERSION: ${{ env.VERSION }}"
|
||||||
|
echo "NEXT_PUBLIC_API_URL: ${{ env.NEXT_PUBLIC_API_URL }}"
|
||||||
|
echo "BRANCH: ${{ env.BRANCH }}"
|
||||||
|
|
||||||
|
echo "=== 部署管理面板 ==="
|
||||||
|
|
||||||
|
# 网络连通性检查
|
||||||
|
echo "检查镜像服务器连通性..."
|
||||||
|
REGISTRY_HOST=$(echo "${{ env.DOCKER_REGISTRY }}" | sed 's|https\?://||' | cut -d'/' -f1)
|
||||||
|
echo "镜像仓库地址: $REGISTRY_HOST"
|
||||||
|
|
||||||
|
if ping -c 3 "$REGISTRY_HOST" > /dev/null 2>&1; then
|
||||||
|
echo "✅ 镜像服务器连通性正常"
|
||||||
|
else
|
||||||
|
echo "⚠️ 镜像服务器ping失败,但继续尝试拉取镜像"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查Docker登录状态
|
||||||
|
echo "检查Docker登录状态..."
|
||||||
|
if docker info > /dev/null 2>&1; then
|
||||||
|
echo "✅ Docker服务正常"
|
||||||
|
else
|
||||||
|
echo "❌ Docker服务异常"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 拉取镜像(带重试)
|
||||||
|
echo "拉取Docker镜像..."
|
||||||
|
for i in {1..3}; do
|
||||||
|
echo "尝试拉取镜像 ($i/3): ${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-admin-web:${{ env.VERSION }}"
|
||||||
|
if docker pull ${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-admin-web:${{ env.VERSION }}; then
|
||||||
|
echo "✅ 镜像拉取成功"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "❌ 镜像拉取失败,重试 $i/3"
|
||||||
|
echo "检查网络和镜像仓库状态..."
|
||||||
|
|
||||||
|
# 显示详细错误信息
|
||||||
|
echo "--- 网络诊断信息 ---"
|
||||||
|
echo "DNS解析测试:"
|
||||||
|
nslookup "$REGISTRY_HOST" || echo "DNS解析失败"
|
||||||
|
echo "网络连通性测试:"
|
||||||
|
ping -c 2 "$REGISTRY_HOST" || echo "ping失败"
|
||||||
|
echo "Docker镜像仓库连接测试:"
|
||||||
|
curl -I "https://$REGISTRY_HOST/v2/" 2>/dev/null || echo "仓库API访问失败"
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
if [ $i -eq 3 ]; then
|
||||||
|
echo "❌ 镜像拉取失败,部署终止"
|
||||||
|
echo "请检查:"
|
||||||
|
echo "1. 网络连接是否正常"
|
||||||
|
echo "2. 镜像仓库是否可访问"
|
||||||
|
echo "3. 镜像标签是否存在"
|
||||||
|
echo "4. Docker登录凭据是否正确"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 安全停止和移除容器
|
||||||
|
echo "检查现有容器状态..."
|
||||||
|
CONTAINER_NAME="ppanel-admin-web"
|
||||||
|
|
||||||
|
# 检查容器是否存在
|
||||||
|
if docker ps -aq -f name=$CONTAINER_NAME | grep -q .; then
|
||||||
|
echo "发现现有容器,开始清理..."
|
||||||
|
|
||||||
|
# 检查容器是否正在运行
|
||||||
|
if docker ps -q -f name=$CONTAINER_NAME | grep -q .; then
|
||||||
|
echo "停止运行中的容器..."
|
||||||
|
docker stop $CONTAINER_NAME --time=15 || true
|
||||||
|
sleep 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查容器是否正在被移除
|
||||||
|
echo "检查容器移除状态..."
|
||||||
|
for i in {1..15}; do
|
||||||
|
# 尝试获取容器状态
|
||||||
|
CONTAINER_STATUS=$(docker inspect $CONTAINER_NAME --format='{{.State.Status}}' 2>/dev/null || echo "not_found")
|
||||||
|
|
||||||
|
if [ "$CONTAINER_STATUS" = "not_found" ]; then
|
||||||
|
echo "✅ 容器已不存在"
|
||||||
|
break
|
||||||
|
elif [ "$CONTAINER_STATUS" = "removing" ]; then
|
||||||
|
echo "⏳ 容器正在移除中,等待完成... $i/15"
|
||||||
|
sleep 3
|
||||||
|
else
|
||||||
|
echo "尝试移除容器... $i/15"
|
||||||
|
if docker rm -f $CONTAINER_NAME 2>/dev/null; then
|
||||||
|
echo "✅ 容器移除成功"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "⚠️ 容器移除失败,重试..."
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 最后一次尝试强制清理
|
||||||
|
if [ $i -eq 15 ]; then
|
||||||
|
echo "🔧 执行强制清理..."
|
||||||
|
docker kill $CONTAINER_NAME 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
docker rm -f $CONTAINER_NAME 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "✅ 未发现现有容器"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "启动新容器..."
|
||||||
|
docker run -d \
|
||||||
|
--add-host api.airoport.co:103.150.215.40 \
|
||||||
|
--name ppanel-admin-web \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 3001:3000 \
|
||||||
|
-e NEXT_PUBLIC_API_URL=${{ env.NEXT_PUBLIC_API_URL }} \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-admin-web:${{ env.VERSION }}
|
||||||
|
|
||||||
|
# 验证容器启动
|
||||||
|
echo "验证容器启动状态..."
|
||||||
|
for i in {1..10}; do
|
||||||
|
if docker ps -q -f name=ppanel-admin-web | grep -q .; then
|
||||||
|
echo "✅ 管理面板部署成功"
|
||||||
|
docker ps -f name=ppanel-admin-web --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "等待容器启动... $i/10"
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "❌ 管理面板部署失败 - 容器未能正常启动"
|
||||||
|
docker logs ppanel-admin-web || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: 部署用户面板到服务器
|
||||||
|
if: env.BUILD_TARGET == 'user' || env.BUILD_TARGET == 'both'
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ env.SSH_HOST }}
|
||||||
|
username: ${{ env.SSH_USER }}
|
||||||
|
password: ${{ env.SSH_PASSWORD }}
|
||||||
|
port: ${{ env.SSH_PORT }}
|
||||||
|
timeout: 300s
|
||||||
|
command_timeout: 600s
|
||||||
|
debug: true
|
||||||
|
script: |
|
||||||
|
echo "=== SSH变量调试信息 ==="
|
||||||
|
echo "DOCKER_REGISTRY: ${{ env.DOCKER_REGISTRY }}"
|
||||||
|
echo "VERSION: ${{ env.VERSION }}"
|
||||||
|
echo "NEXT_PUBLIC_API_URL: ${{ env.NEXT_PUBLIC_API_URL }}"
|
||||||
|
echo "BRANCH: ${{ env.BRANCH }}"
|
||||||
|
|
||||||
|
echo "=== 部署用户面板 ==="
|
||||||
|
|
||||||
|
# 网络连通性检查
|
||||||
|
echo "检查镜像服务器连通性..."
|
||||||
|
REGISTRY_HOST=$(echo "${{ env.DOCKER_REGISTRY }}" | sed 's|https\?://||' | cut -d'/' -f1)
|
||||||
|
echo "镜像仓库地址: $REGISTRY_HOST"
|
||||||
|
|
||||||
|
if ping -c 3 "$REGISTRY_HOST" > /dev/null 2>&1; then
|
||||||
|
echo "✅ 镜像服务器连通性正常"
|
||||||
|
else
|
||||||
|
echo "⚠️ 镜像服务器ping失败,但继续尝试拉取镜像"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查Docker登录状态
|
||||||
|
echo "检查Docker登录状态..."
|
||||||
|
if docker info > /dev/null 2>&1; then
|
||||||
|
echo "✅ Docker服务正常"
|
||||||
|
else
|
||||||
|
echo "❌ Docker服务异常"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 拉取镜像(带重试)
|
||||||
|
echo "拉取Docker镜像..."
|
||||||
|
for i in {1..3}; do
|
||||||
|
echo "尝试拉取镜像 ($i/3): ${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-user-web:${{ env.VERSION }}"
|
||||||
|
if docker pull ${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-user-web:${{ env.VERSION }}; then
|
||||||
|
echo "✅ 镜像拉取成功"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "❌ 镜像拉取失败,重试 $i/3"
|
||||||
|
echo "检查网络和镜像仓库状态..."
|
||||||
|
|
||||||
|
# 显示详细错误信息
|
||||||
|
echo "--- 网络诊断信息 ---"
|
||||||
|
echo "DNS解析测试:"
|
||||||
|
nslookup "$REGISTRY_HOST" || echo "DNS解析失败"
|
||||||
|
echo "网络连通性测试:"
|
||||||
|
ping -c 2 "$REGISTRY_HOST" || echo "ping失败"
|
||||||
|
echo "Docker镜像仓库连接测试:"
|
||||||
|
curl -I "https://$REGISTRY_HOST/v2/" 2>/dev/null || echo "仓库API访问失败"
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
if [ $i -eq 3 ]; then
|
||||||
|
echo "❌ 镜像拉取失败,部署终止"
|
||||||
|
echo "请检查:"
|
||||||
|
echo "1. 网络连接是否正常"
|
||||||
|
echo "2. 镜像仓库是否可访问"
|
||||||
|
echo "3. 镜像标签是否存在"
|
||||||
|
echo "4. Docker登录凭据是否正确"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 安全停止和移除容器
|
||||||
|
echo "检查现有容器状态..."
|
||||||
|
CONTAINER_NAME="ppanel-user-web"
|
||||||
|
|
||||||
|
# 检查容器是否存在
|
||||||
|
if docker ps -aq -f name=$CONTAINER_NAME | grep -q .; then
|
||||||
|
echo "发现现有容器,开始清理..."
|
||||||
|
|
||||||
|
# 检查容器是否正在运行
|
||||||
|
if docker ps -q -f name=$CONTAINER_NAME | grep -q .; then
|
||||||
|
echo "停止运行中的容器..."
|
||||||
|
docker stop $CONTAINER_NAME --time=15 || true
|
||||||
|
sleep 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查容器是否正在被移除
|
||||||
|
echo "检查容器移除状态..."
|
||||||
|
for i in {1..15}; do
|
||||||
|
# 尝试获取容器状态
|
||||||
|
CONTAINER_STATUS=$(docker inspect $CONTAINER_NAME --format='{{.State.Status}}' 2>/dev/null || echo "not_found")
|
||||||
|
|
||||||
|
if [ "$CONTAINER_STATUS" = "not_found" ]; then
|
||||||
|
echo "✅ 容器已不存在"
|
||||||
|
break
|
||||||
|
elif [ "$CONTAINER_STATUS" = "removing" ]; then
|
||||||
|
echo "⏳ 容器正在移除中,等待完成... $i/15"
|
||||||
|
sleep 3
|
||||||
|
else
|
||||||
|
echo "尝试移除容器... $i/15"
|
||||||
|
if docker rm -f $CONTAINER_NAME 2>/dev/null; then
|
||||||
|
echo "✅ 容器移除成功"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "⚠️ 容器移除失败,重试..."
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 最后一次尝试强制清理
|
||||||
|
if [ $i -eq 15 ]; then
|
||||||
|
echo "🔧 执行强制清理..."
|
||||||
|
docker kill $CONTAINER_NAME 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
docker rm -f $CONTAINER_NAME 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "✅ 未发现现有容器"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "启动新容器..."
|
||||||
|
docker run -d \
|
||||||
|
--add-host api.airoport.co:103.150.215.40 \
|
||||||
|
--name ppanel-user-web \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 3002:3000 \
|
||||||
|
-e NEXT_PUBLIC_API_URL=${{ env.NEXT_PUBLIC_API_URL }} \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/ppanel/ppanel-user-web:${{ env.VERSION }}
|
||||||
|
|
||||||
|
# 验证容器启动
|
||||||
|
echo "验证容器启动状态..."
|
||||||
|
for i in {1..10}; do
|
||||||
|
if docker ps -q -f name=ppanel-user-web | grep -q .; then
|
||||||
|
echo "✅ 用户面板部署成功"
|
||||||
|
docker ps -f name=ppanel-user-web --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "等待容器启动... $i/10"
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "❌ 用户面板部署失败 - 容器未能正常启动"
|
||||||
|
docker logs ppanel-user-web || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# 步骤5: TG通知 (成功)
|
||||||
|
- name: 📱 发送成功通知到Telegram
|
||||||
|
if: success()
|
||||||
|
uses: appleboy/telegram-action@master
|
||||||
|
with:
|
||||||
|
token: ${{ env.TELEGRAM_BOT_TOKEN }}
|
||||||
|
to: ${{ env.TELEGRAM_CHAT_ID }}
|
||||||
|
message: |
|
||||||
|
✅ 部署成功!
|
||||||
|
|
||||||
|
📦 项目: ${{ github.repository }}
|
||||||
|
🌿 分支: ${{ github.ref_name }}
|
||||||
|
🔖 版本: ${{ env.VERSION }}
|
||||||
|
🎯 构建目标: ${{ env.BUILD_TARGET }}
|
||||||
|
🔗 API地址: ${{ env.NEXT_PUBLIC_API_URL }}
|
||||||
|
📝 提交: ${{ github.sha }}
|
||||||
|
👤 提交者: ${{ github.actor }}
|
||||||
|
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
||||||
|
|
||||||
|
🚀 服务已成功部署到生产环境
|
||||||
|
|
||||||
|
# 步骤5: TG通知 (失败)
|
||||||
|
- name: 📱 发送失败通知到Telegram
|
||||||
|
if: failure()
|
||||||
|
uses: appleboy/telegram-action@master
|
||||||
|
with:
|
||||||
|
token: ${{ env.TELEGRAM_BOT_TOKEN }}
|
||||||
|
to: ${{ env.TELEGRAM_CHAT_ID }}
|
||||||
|
message: |
|
||||||
|
❌ 部署失败!
|
||||||
|
|
||||||
|
📦 项目: ${{ github.repository }}
|
||||||
|
🌿 分支: ${{ github.ref_name }}
|
||||||
|
🔖 版本: ${{ env.VERSION }}
|
||||||
|
🎯 构建目标: ${{ env.BUILD_TARGET }}
|
||||||
|
📝 提交: ${{ github.sha }}
|
||||||
|
👤 提交者: ${{ github.actor }}
|
||||||
|
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
||||||
|
|
||||||
|
⚠️ 请检查构建日志获取详细信息
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Makefile for panel-web
|
||||||
|
|
||||||
|
# Docker Registry
|
||||||
|
REGISTRY_URL=registry.kxsw.us
|
||||||
|
|
||||||
|
# Image names
|
||||||
|
ADMIN_IMAGE_NAME=ppanel/ppanel-admin-web
|
||||||
|
USER_IMAGE_NAME=ppanel/ppanel-user-web
|
||||||
|
|
||||||
|
# Full image names with registry
|
||||||
|
ADMIN_IMAGE=${REGISTRY_URL}/${ADMIN_IMAGE_NAME}
|
||||||
|
USER_IMAGE=${REGISTRY_URL}/${USER_IMAGE_NAME}
|
||||||
|
|
||||||
|
# Version from package.json. Requires jq to be installed.
|
||||||
|
VERSION=$(shell jq -r .version package.json)
|
||||||
|
|
||||||
|
.PHONY: all
|
||||||
|
all: build ## Default target
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Build Targets
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: build-admin build-user ## Build both admin and user docker images
|
||||||
|
|
||||||
|
.PHONY: build-admin
|
||||||
|
build-admin: ## Build the admin docker image
|
||||||
|
@echo "Building admin image with tags ${ADMIN_IMAGE}:${VERSION} and ${ADMIN_IMAGE}:latest..."
|
||||||
|
docker build -f ./docker/ppanel-admin-web/Dockerfile -t ${ADMIN_IMAGE}:${VERSION} -t ${ADMIN_IMAGE}:latest .
|
||||||
|
@echo "Successfully built admin image."
|
||||||
|
|
||||||
|
.PHONY: build-user
|
||||||
|
build-user: ## Build the user docker image
|
||||||
|
@echo "Building user image with tags ${USER_IMAGE}:${VERSION} and ${USER_IMAGE}:latest..."
|
||||||
|
docker build -f ./docker/ppanel-user-web/Dockerfile -t ${USER_IMAGE}:${VERSION} -t ${USER_IMAGE}:latest .
|
||||||
|
@echo "Successfully built user image."
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Publish Targets
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
.PHONY: push
|
||||||
|
push: push-admin push-user ## Push both admin and user images to the registry
|
||||||
|
|
||||||
|
.PHONY: push-admin
|
||||||
|
push-admin: ## Push the admin image to the registry
|
||||||
|
@echo "Pushing admin image ${ADMIN_IMAGE}:${VERSION} and ${ADMIN_IMAGE}:latest..."
|
||||||
|
docker push ${ADMIN_IMAGE}:${VERSION}
|
||||||
|
docker push ${ADMIN_IMAGE}:latest
|
||||||
|
@echo "Successfully pushed admin image."
|
||||||
|
|
||||||
|
.PHONY: push-user
|
||||||
|
push-user: ## Push the user image to the registry
|
||||||
|
@echo "Pushing user image ${USER_IMAGE}:${VERSION} and ${USER_IMAGE}:latest..."
|
||||||
|
docker push ${USER_IMAGE}:${VERSION}
|
||||||
|
docker push ${USER_IMAGE}:latest
|
||||||
|
@echo "Successfully pushed user image."
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Run Targets
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
.PHONY: ensure-version
|
||||||
|
ensure-version:
|
||||||
|
@if [ -z "${VERSION}" ] || [ "${VERSION}" = "null" ]; then \
|
||||||
|
echo "ERROR: 未检测到版本号。请安装 jq 并在 panel-web/package.json 中设置有效的 version 字段。"; \
|
||||||
|
echo "提示:可以运行 'make version' 查看当前版本值。"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Other Targets
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
.PHONY: login
|
||||||
|
login: ## Login to the Docker registry
|
||||||
|
docker login ${REGISTRY_URL}
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo "Usage: make [target]"
|
||||||
|
@echo ""
|
||||||
|
@echo "Targets:"
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
.PHONY: version
|
||||||
|
version: ## Print project version from package.json
|
||||||
|
@echo "${VERSION}"
|
||||||
@@ -15,7 +15,7 @@ unset APP_NAME # 显式销毁变量
|
|||||||
1. 执行 `./scripts/publish-dev.sh` 构建产物在 out-dev文件夹
|
1. 执行 `./scripts/publish-dev.sh` 构建产物在 out-dev文件夹
|
||||||
2. 上传out/zip.gz到服务器
|
2. 上传out/zip.gz到服务器
|
||||||
3. tar -xzvf ppanel-user-web-dev.tar.gz 解压
|
3. tar -xzvf ppanel-user-web-dev.tar.gz 解压
|
||||||
4. tar -xzvf ppanel-user-web-dev.tar.gz
|
4. tar -xzvf ppanel-admin-web-dev.tar.gz
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# 第一次启动
|
# 第一次启动
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ NEXT_PUBLIC_DEFAULT_LANGUAGE=zh-CN
|
|||||||
|
|
||||||
# Site URL and API URL
|
# Site URL and API URL
|
||||||
NEXT_PUBLIC_SITE_URL=https://admin.ppanel.dev
|
NEXT_PUBLIC_SITE_URL=https://admin.ppanel.dev
|
||||||
NEXT_PUBLIC_API_URL=https://api.kxsw.us
|
NEXT_PUBLIC_API_URL=https://api.airoport.co
|
||||||
|
|
||||||
# Default Login User
|
# Default Login User
|
||||||
NEXT_PUBLIC_DEFAULT_USER_EMAIL=
|
NEXT_PUBLIC_DEFAULT_USER_EMAIL=
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
This is a PPanel admin web powered by PPanel
|
This is a PPanel admin web powered by PPanel
|
||||||
|
|
||||||
English
|
English ##
|
||||||
·
|
·
|
||||||
[Chinese](./README.zh-CN.md)
|
[Chinese](./README.zh-CN.md)
|
||||||
·
|
·
|
||||||
|
|||||||
@@ -20,16 +20,23 @@ import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
|
|||||||
import { formatDate } from '@workspace/ui/utils';
|
import { formatDate } from '@workspace/ui/utils';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { UserDetail } from './user-detail';
|
import { UserDetail } from './user-detail';
|
||||||
import UserForm from './user-form';
|
import UserForm from './user-form';
|
||||||
|
|
||||||
// 新的子组件,现在管理它自己的备注状态
|
// 为 RemarkForm 组件定义 props 类型
|
||||||
const RemarkForm = ({ onSave, initialRemark, CloseComponent }) => {
|
interface RemarkFormProps {
|
||||||
const [remark, setRemark] = useState(initialRemark);
|
initialRemark?: string | null;
|
||||||
|
onSave: (remark: string) => void;
|
||||||
|
CloseComponent: React.ComponentType<{ asChild?: boolean; children: React.ReactNode }>;
|
||||||
|
}
|
||||||
|
|
||||||
const handleInputChange = (event) => {
|
// 新的子组件,在管理它自己的备注状态
|
||||||
|
const RemarkForm: React.FC<RemarkFormProps> = ({ onSave, initialRemark, CloseComponent }) => {
|
||||||
|
const [remark, setRemark] = useState<string>(initialRemark ?? '');
|
||||||
|
|
||||||
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setRemark(event.target.value);
|
setRemark(event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ export function UserDetail({ id }: { id: number }) {
|
|||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<Button variant='link' className='p-0' asChild>
|
<Button variant='link' className='p-0' asChild>
|
||||||
<Link href={`/dashboard/user/${id}`}>
|
<Link href={`/dashboard/user/${id}`}>
|
||||||
{data?.auth_methods[0]?.auth_identifier || t('loading')}
|
{data?.auth_methods[0]?.auth_identifier
|
||||||
|
? `${data?.auth_methods[0]?.auth_identifier} (${data?.remark})`
|
||||||
|
: t('loading')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
|
|||||||
@@ -59,11 +59,6 @@ export default function Page() {
|
|||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
header: t('title'),
|
header: t('title'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: 'user_id',
|
|
||||||
header: t('user'),
|
|
||||||
cell: ({ row }) => <UserDetail id={row.original.user_id} />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: t('status.0'),
|
header: t('status.0'),
|
||||||
@@ -79,17 +74,22 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t(`status.${row.original.status}`)}
|
{row.original.status === 4 ? t(`status.${row.original.status}`) : t(`status.1`)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'user_id',
|
||||||
|
header: t('user'),
|
||||||
|
cell: ({ row }) => <UserDetail id={row.original.user_id} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'updated_at',
|
accessorKey: 'updated_at',
|
||||||
header: t('updatedAt'),
|
header: t('updatedAt'),
|
||||||
cell: ({ row }) => formatDate(row.getValue('updated_at')),
|
cell: ({ row }) => formatDate(row.getValue('updated_at')),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
params={[
|
/*params={[
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
placeholder: t('status.0'),
|
placeholder: t('status.0'),
|
||||||
@@ -100,11 +100,12 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]}
|
]}*/
|
||||||
request={async (pagination, filters) => {
|
request={async (pagination, filters) => {
|
||||||
const { data } = await getTicketList({
|
const { data } = await getTicketList({
|
||||||
...pagination,
|
...pagination,
|
||||||
...filters,
|
...filters,
|
||||||
|
issue_type: 1,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
list: data.data?.list || [],
|
list: data.data?.list || [],
|
||||||
@@ -166,11 +167,11 @@ export default function Page() {
|
|||||||
<div className={cn('bg-accent w-fit rounded-lg p-2 font-medium', {})}>
|
<div className={cn('bg-accent w-fit rounded-lg p-2 font-medium', {})}>
|
||||||
<div> 提现金额:{ticket?.title?.split('-')[1]}</div>
|
<div> 提现金额:{ticket?.title?.split('-')[1]}</div>
|
||||||
<div> 提现方式:{ticket?.description?.split('-')[0]}</div>
|
<div> 提现方式:{ticket?.description?.split('-')[0]}</div>
|
||||||
{ticket?.description?.split('-')[1].includes('data:image') ? (
|
{ticket?.description?.split('-')[1]?.includes('data:image') ? (
|
||||||
<div>
|
<div>
|
||||||
<div>收款码:</div>
|
<div>收款码:</div>
|
||||||
<NextImage
|
<NextImage
|
||||||
src={ticket?.description?.split('-')[1]}
|
src={ticket?.description?.split('-')[1]!}
|
||||||
width={300}
|
width={300}
|
||||||
height={300}
|
height={300}
|
||||||
className='!size-auto object-cover'
|
className='!size-auto object-cover'
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { getLocale, getMessages } from 'next-intl/server';
|
|||||||
import { PublicEnvScript } from 'next-runtime-env';
|
import { PublicEnvScript } from 'next-runtime-env';
|
||||||
import { unstable_noStore as noStore } from 'next/cache';
|
import { unstable_noStore as noStore } from 'next/cache';
|
||||||
// import { Geist, Geist_Mono } from 'next/font/google';
|
// import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import NextTopLoader from 'nextjs-toploader';
|
import NextTopLoader from 'nextjs-toploader';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
// const fontSans = Geist({
|
// const fontSans = Geist({
|
||||||
// subsets: ['latin'],
|
// subsets: ['latin'],
|
||||||
@@ -112,7 +114,7 @@ export default async function RootLayout({
|
|||||||
<body
|
<body
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
// ${fontSans.variable} ${fontMono.variable}
|
// ${fontSans.variable} ${fontMono.variable}
|
||||||
className={`size-full min-h-[calc(100dvh-env(safe-area-inset-top))] font-sans antialiased`}
|
className={`size-full min-h-[calc(100dvh-env(safe-area-inset-top))] ${inter.className} antialiased`}
|
||||||
>
|
>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<NextTopLoader showSpinner={false} />
|
<NextTopLoader showSpinner={false} />
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const navs = [
|
|||||||
{
|
{
|
||||||
title: 'Withdraw Ticket Management',
|
title: 'Withdraw Ticket Management',
|
||||||
url: '/dashboard/withdraw-ticket',
|
url: '/dashboard/withdraw-ticket',
|
||||||
icon: 'flat-color-icons:collaboration',
|
icon: 'flat-color-icons:debt',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Document Management',
|
title: 'Document Management',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"next": "^15.1.4",
|
"next": "15.3.6",
|
||||||
"next-intl": "^3.26.3",
|
"next-intl": "^3.26.3",
|
||||||
"next-runtime-env": "^3.2.2",
|
"next-runtime-env": "^3.2.2",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
|||||||
+1
@@ -864,6 +864,7 @@ declare namespace API {
|
|||||||
user_id?: number;
|
user_id?: number;
|
||||||
status?: number;
|
status?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
issue_type?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetTicketListRequest = {
|
type GetTicketListRequest = {
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ NEXT_PUBLIC_DEFAULT_LANGUAGE=zh-CN
|
|||||||
|
|
||||||
# Site URL and API URL
|
# Site URL and API URL
|
||||||
NEXT_PUBLIC_SITE_URL=https://user.ppanel.dev
|
NEXT_PUBLIC_SITE_URL=https://user.ppanel.dev
|
||||||
NEXT_PUBLIC_API_URL=https://api.kxsw.us
|
NEXT_PUBLIC_API_URL=https://api.airoport.co
|
||||||
NEXT_PUBLIC_CDN_URL=https://cdn.jsdelivr.net
|
NEXT_PUBLIC_CDN_URL=https://cdn.jsdelivr.net
|
||||||
|
|
||||||
# Home Page Settings
|
# Home Page Settings
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
|
|||||||
|
|
||||||
# Site URL and API URL
|
# Site URL and API URL
|
||||||
NEXT_PUBLIC_SITE_URL=https://user.ppanel.dev
|
NEXT_PUBLIC_SITE_URL=https://user.ppanel.dev
|
||||||
NEXT_PUBLIC_API_URL=https://api.kxsw.us
|
NEXT_PUBLIC_API_URL=https://airoport.co
|
||||||
NEXT_PUBLIC_CDN_URL=https://cdn.jsdelivr.net
|
NEXT_PUBLIC_CDN_URL=https://cdn.jsdelivr.net
|
||||||
|
|
||||||
# Home Page Settings
|
# Home Page Settings
|
||||||
|
|||||||
+45
-20
@@ -28,9 +28,11 @@ import {
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@workspace/airo-ui/components/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@workspace/airo-ui/components/popover';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@workspace/airo-ui/components/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@workspace/airo-ui/components/tabs';
|
||||||
import { differenceInDays } from '@workspace/airo-ui/utils';
|
import { differenceInDays } from '@workspace/airo-ui/utils';
|
||||||
|
import Link from 'next/link';
|
||||||
import { QRCodeCanvas } from 'qrcode.react';
|
import { QRCodeCanvas } from 'qrcode.react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||||
|
|
||||||
interface SubscribeCardProps {
|
interface SubscribeCardProps {
|
||||||
userSubscribeData: API.UserSubscribe;
|
userSubscribeData: API.UserSubscribe;
|
||||||
protocol: string[];
|
protocol: string[];
|
||||||
@@ -53,7 +55,7 @@ const SubscribeCard = (props: SubscribeCardProps) => {
|
|||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
setUserSubscribeProtocolCurrent(0);
|
setUserSubscribeProtocolCurrent(0);
|
||||||
}
|
}
|
||||||
}, [props.userSubscribeData.token, protocol]);
|
}, [props.userSubscribeData?.token, protocol]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-2 sm:space-y-4'>
|
<div className='space-y-2 sm:space-y-4'>
|
||||||
@@ -69,11 +71,15 @@ const SubscribeCard = (props: SubscribeCardProps) => {
|
|||||||
{t('totalTraffic')}
|
{t('totalTraffic')}
|
||||||
</p>
|
</p>
|
||||||
<p className='text-xs font-medium text-[#0F2C53] sm:text-base'>
|
<p className='text-xs font-medium text-[#0F2C53] sm:text-base'>
|
||||||
<Display
|
{userSubscribeData?.status === 1 ? (
|
||||||
type='traffic'
|
<Display
|
||||||
value={userSubscribeData.traffic}
|
type='traffic'
|
||||||
unlimited={!userSubscribeData.traffic}
|
value={userSubscribeData.traffic}
|
||||||
/>
|
unlimited={!userSubscribeData.traffic}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'0.00GB'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -81,9 +87,15 @@ const SubscribeCard = (props: SubscribeCardProps) => {
|
|||||||
{t('nextResetDays')}
|
{t('nextResetDays')}
|
||||||
</p>
|
</p>
|
||||||
<p className='text-xs font-medium text-[#0F2C53] sm:text-base'>
|
<p className='text-xs font-medium text-[#0F2C53] sm:text-base'>
|
||||||
{userSubscribeData.reset_time
|
{userSubscribeData?.status === 1 ? (
|
||||||
? differenceInDays(new Date(userSubscribeData.reset_time), new Date())
|
<>
|
||||||
: t('noReset')}
|
{userSubscribeData.reset_time
|
||||||
|
? differenceInDays(new Date(userSubscribeData.reset_time), new Date())
|
||||||
|
: t('noReset')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'N/A'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -91,17 +103,30 @@ const SubscribeCard = (props: SubscribeCardProps) => {
|
|||||||
{t('expirationDays')}
|
{t('expirationDays')}
|
||||||
</p>
|
</p>
|
||||||
<p className='text-xs font-medium text-[#0F2C53] sm:text-base'>
|
<p className='text-xs font-medium text-[#0F2C53] sm:text-base'>
|
||||||
{userSubscribeData.expire_time
|
{userSubscribeData?.status === 1 ? (
|
||||||
? differenceInDays(new Date(userSubscribeData.expire_time), new Date()) ||
|
<>
|
||||||
t('unknown')
|
{userSubscribeData.expire_time
|
||||||
: t('noLimit')}
|
? differenceInDays(new Date(userSubscribeData.expire_time), new Date()) ||
|
||||||
|
t('unknown')
|
||||||
|
: t('noLimit')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'N/A'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 订阅链接 */}
|
{/* 订阅链接 */}
|
||||||
<div className='rounded-[26px] bg-[#EAEAEA] p-2 sm:p-4'>
|
<div className='relative rounded-[26px] bg-[#EAEAEA] p-2 sm:p-4'>
|
||||||
|
{userSubscribeData?.status !== 1 && (
|
||||||
|
<div className='absolute inset-0 z-40 flex h-full w-full items-center justify-center rounded-[26px] border-4 border-[#D9D9D9] bg-white/50 backdrop-blur-[1px]'>
|
||||||
|
<AiroButton variant={'primary'} asChild>
|
||||||
|
<Link href={'/subscribe'}>{t('buySubscriptionNow')}</Link>
|
||||||
|
</AiroButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className='mb-3 flex flex-wrap justify-between gap-4'>
|
<div className='mb-3 flex flex-wrap justify-between gap-4'>
|
||||||
{props.protocol.length > 1 && (
|
{props.protocol.length > 1 && (
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -126,21 +151,21 @@ const SubscribeCard = (props: SubscribeCardProps) => {
|
|||||||
<div className={'mb-3 flex items-center justify-center gap-3'}>
|
<div className={'mb-3 flex items-center justify-center gap-3'}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'flex flex-1 items-center gap-1 rounded-full bg-[#BABABA] pl-1 sm:gap-2 sm:rounded-[16px] sm:pl-2'
|
'flex flex-1 items-center gap-1 rounded-full bg-[#BABABA] pl-1 sm:gap-2 sm:pl-2'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
value={userSubscribeProtocolCurrent}
|
value={userSubscribeProtocolCurrent}
|
||||||
onValueChange={setUserSubscribeProtocolCurrent}
|
onValueChange={setUserSubscribeProtocolCurrent}
|
||||||
>
|
>
|
||||||
<SelectTrigger className='h-auto w-auto flex-shrink-0 rounded-[16px] border-none bg-[#D9D9D9] px-2.5 py-0.5 text-[13px] font-medium text-[#0F2C53] shadow-none hover:bg-[#848484] focus:ring-0 sm:h-[35px] sm:rounded-[8px] sm:px-2 sm:py-1.5 [&>svg]:hidden'>
|
<SelectTrigger className='h-auto w-auto flex-shrink-0 rounded-[16px] border-none bg-[#D9D9D9] px-2.5 py-0.5 text-[13px] font-medium text-[#0F2C53] shadow-none hover:bg-[#848484] focus:ring-0 sm:h-[35px] sm:px-2 sm:py-1.5 [&>svg]:hidden'>
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
<div className='flex flex-col items-center justify-between text-[10px] sm:text-xs'>
|
<div className='flex flex-col items-center justify-between text-[10px] sm:text-xs'>
|
||||||
<div>
|
<div>
|
||||||
{t('subscriptionUrl')}
|
{t('subscriptionUrl')}
|
||||||
{userSubscribeProtocolCurrent + 1}
|
{userSubscribeProtocolCurrent + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className='-mt-0.5 h-0 w-0 scale-50 border-l-[3px] border-r-[3px] border-t-[3px] border-l-transparent border-r-transparent border-t-[#0F2C53] sm:scale-100'></div>
|
<div className='h-0 w-0 scale-50 border-l-[3px] border-r-[3px] border-t-[3px] border-l-transparent border-r-transparent border-t-[#0F2C53] sm:scale-100'></div>
|
||||||
</div>
|
</div>
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -158,7 +183,7 @@ const SubscribeCard = (props: SubscribeCardProps) => {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className='flex-1 rounded-full bg-white px-3 py-2 text-[10px] leading-tight text-[#225BA9] shadow-[inset_0px_0px_7.6px_0px_rgba(0,0,0,0.25)] sm:rounded-[16px]'>
|
<div className='flex-1 rounded-full bg-white px-3 py-2 text-[10px] leading-tight text-[#225BA9] shadow-[inset_0px_0px_7.6px_0px_rgba(0,0,0,0.25)]'>
|
||||||
<div className={'flex items-center gap-4 py-1'}>
|
<div className={'flex items-center gap-4 py-1'}>
|
||||||
<div className={'line-clamp-2 flex-1 break-all'}>
|
<div className={'line-clamp-2 flex-1 break-all'}>
|
||||||
{userSubscribeProtocol[userSubscribeProtocolCurrent]}
|
{userSubscribeProtocol[userSubscribeProtocolCurrent]}
|
||||||
@@ -203,7 +228,7 @@ const SubscribeCard = (props: SubscribeCardProps) => {
|
|||||||
<div className='flex justify-between gap-2'>
|
<div className='flex justify-between gap-2'>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<AiroButton variant='danger' className={'px-2 text-xs'}>
|
<AiroButton variant='primaryBlue' className={'px-2 text-xs'}>
|
||||||
{t('resetSubscription')}
|
{t('resetSubscription')}
|
||||||
</AiroButton>
|
</AiroButton>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
@@ -231,7 +256,7 @@ const SubscribeCard = (props: SubscribeCardProps) => {
|
|||||||
<Renewal
|
<Renewal
|
||||||
className='px-2 text-xs'
|
className='px-2 text-xs'
|
||||||
id={userSubscribeData.id}
|
id={userSubscribeData.id}
|
||||||
subscribe={userSubscribeData.subscribe}
|
subscribe={userSubscribeData.subscribe || {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import useGlobalStore from '@/config/use-global';
|
|||||||
import { getStat } from '@/services/common/common';
|
import { getStat } from '@/services/common/common';
|
||||||
import { queryUserSubscribe } from '@/services/user/user';
|
import { queryUserSubscribe } from '@/services/user/user';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
||||||
import { Button } from '@workspace/airo-ui/components/button';
|
import { Button } from '@workspace/airo-ui/components/button';
|
||||||
import { Card } from '@workspace/airo-ui/components/card';
|
import { Card } from '@workspace/airo-ui/components/card';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
@@ -23,9 +24,9 @@ import {
|
|||||||
PopupRef,
|
PopupRef,
|
||||||
} from '@/app/(main)/(content)/(user)/dashboard/components/Announcement/Popup';
|
} from '@/app/(main)/(content)/(user)/dashboard/components/Announcement/Popup';
|
||||||
import { Empty } from '@/components/empty';
|
import { Empty } from '@/components/empty';
|
||||||
|
import SvgIcon from '@/components/SvgIcon';
|
||||||
import { queryAnnouncement } from '@/services/user/announcement';
|
import { queryAnnouncement } from '@/services/user/announcement';
|
||||||
import { queryOrderList } from '@/services/user/order';
|
import { queryOrderList } from '@/services/user/order';
|
||||||
import { default as Airo_Empty } from '@workspace/airo-ui/custom-components/empty';
|
|
||||||
import { formatDate } from '@workspace/airo-ui/utils';
|
import { formatDate } from '@workspace/airo-ui/utils';
|
||||||
|
|
||||||
const platforms: (keyof API.ApplicationPlatform)[] = [
|
const platforms: (keyof API.ApplicationPlatform)[] = [
|
||||||
@@ -40,11 +41,13 @@ const platforms: (keyof API.ApplicationPlatform)[] = [
|
|||||||
export default function Content() {
|
export default function Content() {
|
||||||
const t = useTranslations('dashboard');
|
const t = useTranslations('dashboard');
|
||||||
|
|
||||||
const { data: userSubscribe = [], refetch } = useQuery({
|
const { data: userSubscribe = {}, refetch } = useQuery({
|
||||||
queryKey: ['queryUserSubscribe'],
|
queryKey: ['queryUserSubscribe'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await queryUserSubscribe();
|
const { data } = await queryUserSubscribe();
|
||||||
return data.data?.list || [];
|
const activeList = data.data?.list?.filter((v) => v.status === 1);
|
||||||
|
|
||||||
|
return activeList[0] ?? {};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,11 +94,126 @@ export default function Content() {
|
|||||||
|
|
||||||
const popupRef = useRef<PopupRef>(null);
|
const popupRef = useRef<PopupRef>(null);
|
||||||
const dialogRef = useRef<DialogRef>(null);
|
const dialogRef = useRef<DialogRef>(null);
|
||||||
|
|
||||||
|
function openPopupWindow(item) {
|
||||||
|
// features 字符串用于控制窗口的特性
|
||||||
|
const pageWidth = 600; // 弹出窗口的宽度
|
||||||
|
const pageHeight = 550; // 弹出窗口的高度
|
||||||
|
const {
|
||||||
|
availLeft, // 返回浏览器可用空间左边距离屏幕(系统桌面)左边界的距离。
|
||||||
|
availHeight, // 浏览器在显示屏上的可用高度,即当前屏幕高度
|
||||||
|
availWidth, // 浏览器在显示屏上的可用宽度,即当前屏幕宽度
|
||||||
|
} = window.screen;
|
||||||
|
const pageTop = (availHeight - pageHeight) / 2; // 窗口的垂直位置
|
||||||
|
let pageLeft = (availWidth - pageWidth) / 2; // 窗口的水平位置;
|
||||||
|
|
||||||
|
pageLeft += availLeft; // 加上屏幕偏移值
|
||||||
|
|
||||||
|
const features = `width=${pageWidth},height=${pageHeight},left=${pageLeft},top=${pageTop},toolbar=no,location=no,menubar=no`;
|
||||||
|
console.log(features);
|
||||||
|
window.open(item.download, item.title, features);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'grid grid-cols-1 gap-[10px] sm:gap-6 lg:grid-cols-2'}>
|
<div className={'grid grid-cols-1 gap-[10px] sm:gap-5 2xl:grid-cols-4'}>
|
||||||
|
{/* 快捷下载 Card */}
|
||||||
|
<Card className='rounded-[20px] border border-[#D9D9D9] p-6 shadow-[0px_0px_52.6px_1px_rgba(15,44,83,0.05)] 2xl:order-2 2xl:col-span-1'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h3 className='text-base font-medium text-[#666666] sm:text-xl'>
|
||||||
|
{t('quickDownloads')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className={'text-xs font-normal leading-[1.5] text-[#666]'}>{t('selectOS')}</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'mt-2.5 flex items-center justify-around rounded-2xl bg-[#EAEAEA] px-4 pb-3 pt-[18px] text-xs text-[#0F2C53]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: 'iOS',
|
||||||
|
icon: 'Group 77',
|
||||||
|
href: 'https://apps.apple.com/us/app/shadowrocket/id932747118?l=zh-Hans-CN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Mac',
|
||||||
|
icon: 'Group 77',
|
||||||
|
href: 'https://apps.apple.com/us/app/shadowrocket/id932747118?l=zh-Hans-CN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Win',
|
||||||
|
icon: 'Group 75',
|
||||||
|
href: 'https://down.airoport.xin/Hiddify-Windows-Setup-x64.msix',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Android',
|
||||||
|
icon: 'Group 75',
|
||||||
|
href: 'https://down.airoport.xin/Hiddify-Android-universal.apk',
|
||||||
|
},
|
||||||
|
].map((v) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={v.label}
|
||||||
|
href={v.href}
|
||||||
|
target={'_blank'}
|
||||||
|
className={'cursor-pointer text-center'}
|
||||||
|
>
|
||||||
|
<div className={''}>
|
||||||
|
<SvgIcon name={v.icon}></SvgIcon>
|
||||||
|
</div>
|
||||||
|
{v.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'mt-2.5 flex h-[37px] items-center justify-between rounded-full bg-[#EAEAEA] pl-4 text-[#666]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={'text-sm font-medium'}>{t('freeAppleID')}</span>
|
||||||
|
<AiroButton
|
||||||
|
className={'m-1'}
|
||||||
|
variant={'primary'}
|
||||||
|
onClick={() => {
|
||||||
|
openPopupWindow({
|
||||||
|
download: 'https://getsapp.net/StKNQY',
|
||||||
|
title: 'Shadowrocket',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('get')}
|
||||||
|
</AiroButton>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 我的订阅 Card */}
|
||||||
|
<Card className='rounded-[20px] border border-[#D9D9D9] p-6 shadow-[0px_0px_52.6px_1px_rgba(15,44,83,0.05)] 2xl:order-3 2xl:col-span-2'>
|
||||||
|
<div className=''>
|
||||||
|
<div className={'flex items-center justify-between'}>
|
||||||
|
<h3 className='text-base font-medium text-[#666666] sm:text-xl'>
|
||||||
|
{t('mySubscription')}
|
||||||
|
</h3>
|
||||||
|
<Link
|
||||||
|
href={'/document'}
|
||||||
|
className='border-0 bg-transparent p-0 text-sm font-semibold text-[#225BA9] shadow-none outline-0 hover:bg-transparent sm:font-normal'
|
||||||
|
>
|
||||||
|
{t('beginnerTutorial')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SubScribeCard
|
||||||
|
userSubscribeData={userSubscribe}
|
||||||
|
protocol={data?.protocol || []}
|
||||||
|
refetch={refetch}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 账户概况 Card */}
|
{/* 账户概况 Card */}
|
||||||
<Card className='rounded-[20px] border border-[#D9D9D9] p-6 shadow-[0px_0px_52.6px_1px_rgba(15,44,83,0.05)]'>
|
<Card className='rounded-[20px] border border-[#D9D9D9] p-6 shadow-[0px_0px_52.6px_1px_rgba(15,44,83,0.05)] 2xl:order-1 2xl:col-span-1'>
|
||||||
<div className='mb-1'>
|
<div className='mb-1'>
|
||||||
<h3 className='text-base font-medium text-[#666666] sm:text-xl'>
|
<h3 className='text-base font-medium text-[#666666] sm:text-xl'>
|
||||||
{t('accountOverview')}
|
{t('accountOverview')}
|
||||||
@@ -107,7 +225,7 @@ export default function Content() {
|
|||||||
|
|
||||||
<div className='mb-3 sm:mb-3.5'>
|
<div className='mb-3 sm:mb-3.5'>
|
||||||
<span className='text-2xl font-medium text-[#091B33]'>
|
<span className='text-2xl font-medium text-[#091B33]'>
|
||||||
{userSubscribe?.length > 0 && userSubscribe[0]?.status === 1 && orderData
|
{userSubscribe?.status === 1 && orderData
|
||||||
? orderData?.quantity === 1
|
? orderData?.quantity === 1
|
||||||
? t('annualMonthPlanUser')
|
? t('annualMonthPlanUser')
|
||||||
: t('annualYearPlanUser')
|
: t('annualYearPlanUser')
|
||||||
@@ -120,7 +238,7 @@ export default function Content() {
|
|||||||
<span className='text-sm font-light text-[#666666]'>{t('accountBalance')}</span>
|
<span className='text-sm font-light text-[#666666]'>{t('accountBalance')}</span>
|
||||||
<Recharge
|
<Recharge
|
||||||
className={
|
className={
|
||||||
'min-w-[50px] border-0 bg-transparent p-0 text-sm font-normal text-[#225BA9] shadow-none outline-0 hover:bg-transparent'
|
'min-w-[50px] border-0 bg-transparent p-0 text-sm font-semibold text-[#225BA9] shadow-none outline-0 hover:bg-transparent'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,101 +249,118 @@ export default function Content() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 套餐状态 Card */}
|
{/* 套餐状态 Card */}
|
||||||
<Card className='rounded-[20px] border border-[#D9D9D9] p-6 text-[#666666] shadow-[0px_0px_52.6px_1px_rgba(15,44,83,0.05)]'>
|
<Card className='2xl:order-0 rounded-[20px] border border-[#D9D9D9] p-6 text-[#666666] shadow-[0px_0px_52.6px_1px_rgba(15,44,83,0.05)] 2xl:col-span-2'>
|
||||||
<div className=''>
|
<div className=''>
|
||||||
<h3 className='flex items-center justify-between text-[#666666]'>
|
<h3 className='flex items-center justify-between text-[#666666]'>
|
||||||
<div className={'flex items-center justify-between'}>
|
<div className={'flex items-center justify-between'}>
|
||||||
<span className={'text-base font-medium sm:text-xl'}>{t('planStatus')}</span>
|
<span className={'text-base font-medium sm:text-xl'}>{t('planStatus')}</span>
|
||||||
{userSubscribe?.length > 0 && userSubscribe?.[0]?.status === 1 ? (
|
{userSubscribe?.status === 1 ? (
|
||||||
<span className={'ml-2.5 rounded-full bg-[#A8D4ED] px-2 text-[8px] text-white'}>
|
<span className={'ml-2.5 rounded-full bg-[#A8D4ED] px-2 text-[8px] text-white'}>
|
||||||
{t('inEffect')}
|
{t('inEffect')}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : (
|
||||||
|
<span className={'ml-2.5 rounded-full bg-[#666666] px-2 text-[8px] text-white'}>
|
||||||
|
{t('notEffect')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ResetTraffic
|
<ResetTraffic
|
||||||
className={
|
className={
|
||||||
'border-0 bg-transparent p-0 text-sm font-normal text-[#225BA9] shadow-none outline-0 hover:bg-transparent'
|
'border-0 bg-transparent p-0 text-sm font-normal text-[#225BA9] shadow-none outline-0 hover:bg-transparent'
|
||||||
}
|
}
|
||||||
id={userSubscribe?.[0]?.id || 0}
|
id={userSubscribe?.id || 0}
|
||||||
replacement={userSubscribe?.[0]?.subscribe.replacement}
|
replacement={userSubscribe?.subscribe?.replacement}
|
||||||
/>
|
/>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{userSubscribe?.length ? (
|
<div>
|
||||||
<>
|
<div className='mt-1 text-xs font-light text-[#666666] sm:text-sm'>
|
||||||
<div className='mt-1 text-xs text-[#666666] sm:text-sm'>
|
{t('planExpirationTime')}
|
||||||
{t('planExpirationTime')}
|
{formatDate(userSubscribe?.expire_time, false) || t('None')}
|
||||||
{formatDate(userSubscribe?.[0]?.expire_time, false)}
|
</div>
|
||||||
</div>
|
<div className='mb-3 mt-1 sm:mb-5'>
|
||||||
<div className='mb-3 sm:mb-5'>
|
<span className='text-2xl font-medium text-[#091B33]'>
|
||||||
<span className='text-2xl font-medium text-[#091B33]'>
|
{userSubscribe?.subscribe?.name ? (
|
||||||
{userSubscribe?.[0]?.subscribe.name}
|
userSubscribe?.subscribe?.name
|
||||||
</span>
|
) : (
|
||||||
</div>
|
<span className={'text-[#848484]'}>{t('noPlanAvailable')}</span>
|
||||||
<div className='mb-4 flex items-center justify-between'>
|
)}
|
||||||
<div className='flex items-center gap-2'>
|
</span>
|
||||||
<span className='text-xs sm:text-sm'>{t('availableDevices')}</span>
|
</div>
|
||||||
<div className='flex gap-2'>
|
<div className='mb-4 flex items-center justify-between'>
|
||||||
{Array.from({ length: userSubscribe?.[0]?.subscribe.device_limit }).map(
|
<div className='flex items-center gap-2'>
|
||||||
(_, index) => {
|
<span className='text-xs sm:text-sm'>{t('availableDevices')}</span>
|
||||||
return (
|
<div className='flex gap-2'>
|
||||||
<div
|
{Array.from({ length: userSubscribe?.subscribe?.device_limit || 6 }).map(
|
||||||
key={index}
|
(_, index) => {
|
||||||
className={`h-4 w-4 rounded-full ${index < (userSubscribe?.[0]?.subscribe.device_limit || 0) > 1 ? 'bg-[#225BA9]' : 'bg-[#D9D9D9]'}`}
|
return (
|
||||||
></div>
|
<div
|
||||||
);
|
key={index}
|
||||||
},
|
className={`h-2.5 w-2.5 rounded-full sm:h-4 sm:w-4 ${index < (userSubscribe?.subscribe?.device_limit || 0) > 1 ? 'bg-[#225BA9]' : 'bg-[#D9D9D9]'}`}
|
||||||
)}
|
></div>
|
||||||
</div>
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className='text-xs font-light sm:text-sm'>
|
||||||
|
{t('online')}
|
||||||
|
{data?.online_device || 0}/{userSubscribe?.subscribe?.device_limit || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='mb-1 flex items-center justify-between font-light'>
|
||||||
<span className='text-xs sm:text-sm'>
|
<span className='text-xs sm:text-sm'>
|
||||||
{t('online')}
|
{t('usedTrafficTotalTraffic')}
|
||||||
{data?.online_device} / {userSubscribe?.[0]?.subscribe.device_limit}
|
{userSubscribe?.subscribe?.device_limit ? (
|
||||||
|
<>
|
||||||
|
<Display
|
||||||
|
type='traffic'
|
||||||
|
value={userSubscribe?.upload + userSubscribe?.download}
|
||||||
|
unlimited={!userSubscribe?.traffic}
|
||||||
|
/>
|
||||||
|
/
|
||||||
|
<Display
|
||||||
|
type='traffic'
|
||||||
|
value={userSubscribe?.traffic || 0}
|
||||||
|
unlimited={!userSubscribe?.traffic}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'0GB/0GB'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className='text-xs sm:text-sm'>
|
||||||
|
{t('remaining')}
|
||||||
|
{userSubscribe?.status === 1 ? (
|
||||||
|
<>
|
||||||
|
{100 -
|
||||||
|
Math.round(
|
||||||
|
(((userSubscribe?.upload || 0) + (userSubscribe?.download || 0)) /
|
||||||
|
(userSubscribe?.traffic || 1)) *
|
||||||
|
100,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className='flex h-5 w-full items-center rounded-[20px] bg-[#EAEAEA] p-0.5'>
|
||||||
<div className='mb-1 flex items-center justify-between'>
|
<div
|
||||||
<span className='text-xs sm:text-sm'>
|
className={'h-full rounded-[20px] bg-[#225BA9]'}
|
||||||
{t('usedTrafficTotalTraffic')}
|
style={{
|
||||||
<Display
|
width: `${Math.round((((userSubscribe?.upload || 0) + (userSubscribe?.download || 0)) / (userSubscribe?.traffic || 1)) * 100)}%`,
|
||||||
type='traffic'
|
}}
|
||||||
value={userSubscribe?.[0]?.upload + userSubscribe?.[0]?.download}
|
></div>
|
||||||
unlimited={!userSubscribe?.[0]?.traffic}
|
|
||||||
/>
|
|
||||||
/{' '}
|
|
||||||
<Display
|
|
||||||
type='traffic'
|
|
||||||
value={userSubscribe?.[0]?.traffic}
|
|
||||||
unlimited={!userSubscribe?.[0]?.traffic}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className='text-xs sm:text-sm'>
|
|
||||||
{t('remaining')}
|
|
||||||
{100 -
|
|
||||||
Math.round(
|
|
||||||
(((userSubscribe?.[0]?.upload || 0) + (userSubscribe?.[0]?.download || 0)) /
|
|
||||||
(userSubscribe?.[0]?.traffic || 1)) *
|
|
||||||
100,
|
|
||||||
)}
|
|
||||||
%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='flex h-5 w-full items-center rounded-[20px] bg-[#EAEAEA] p-0.5'>
|
|
||||||
<div
|
|
||||||
className={'h-full rounded-[20px] bg-[#225BA9]'}
|
|
||||||
style={{
|
|
||||||
width: `${Math.round((((userSubscribe?.[0]?.upload || 0) + (userSubscribe?.[0]?.download || 0)) / (userSubscribe?.[0]?.traffic || 1)) * 100)}%`,
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<Airo_Empty className={'py-0'} description={t('noPlanAvailable')} />
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 网站公告 Card */}
|
{/* 网站公告 Card */}
|
||||||
<Card className='relative order-4 rounded-[20px] border border-[#EAEAEA] bg-gradient-to-b from-white to-[#EAEAEA] p-6 pb-0 sm:order-none'>
|
<Card className='relative rounded-[20px] border border-[#EAEAEA] bg-gradient-to-b from-white to-[#EAEAEA] p-6 pb-0 2xl:order-4 2xl:col-span-2'>
|
||||||
<div className='mb-3 flex items-center justify-between sm:mb-1'>
|
<div className='mb-3 flex items-center justify-between sm:mb-1'>
|
||||||
<h3 className='text-base font-medium text-[#666666] sm:text-xl'>
|
<h3 className='text-base font-medium text-[#666666] sm:text-xl'>
|
||||||
{t('siteAnnouncements')}
|
{t('siteAnnouncements')}
|
||||||
@@ -282,31 +417,6 @@ export default function Content() {
|
|||||||
<Popup ref={popupRef} />
|
<Popup ref={popupRef} />
|
||||||
<AnnouncementDialog ref={dialogRef} />
|
<AnnouncementDialog ref={dialogRef} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 我的订阅 Card */}
|
|
||||||
<Card className='rounded-[20px] border border-[#D9D9D9] p-6 shadow-[0px_0px_52.6px_1px_rgba(15,44,83,0.05)]'>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<h3 className='text-base font-medium text-[#666666] sm:text-xl'>
|
|
||||||
{t('mySubscription')}
|
|
||||||
</h3>
|
|
||||||
<Link
|
|
||||||
href={'/document'}
|
|
||||||
className='border-0 bg-transparent p-0 text-sm font-semibold text-[#225BA9] shadow-none outline-0 hover:bg-transparent sm:font-normal'
|
|
||||||
>
|
|
||||||
{t('beginnerTutorial')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{userSubscribe?.[0] && data?.protocol ? (
|
|
||||||
<SubScribeCard
|
|
||||||
userSubscribeData={userSubscribe?.[0]}
|
|
||||||
protocol={data.protocol}
|
|
||||||
refetch={refetch}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Empty />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export function TutorialButton({ items }: { items: Item[] }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className={'flex'}>
|
||||||
{item.download ? (
|
{item.download ? (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function Page() {
|
|||||||
<Card className='border-none shadow-none sm:border sm:shadow'>
|
<Card className='border-none shadow-none sm:border sm:shadow'>
|
||||||
<CardContent className='grid gap-2 p-6 text-sm'>
|
<CardContent className='grid gap-2 p-6 text-sm'>
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
<div className={'absolute flex gap-3'}>
|
<div className={'flex gap-3 sm:absolute'}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'flex items-center justify-between rounded-md border-2 border-[#225BA9] p-0.5'
|
'flex items-center justify-between rounded-md border-2 border-[#225BA9] p-0.5'
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const WalletDialog: WalletDialogProps = (props) => {
|
|||||||
const { currency } = common;
|
const { currency } = common;
|
||||||
|
|
||||||
// 定义支持的账户类型
|
// 定义支持的账户类型
|
||||||
const ACCOUNT_TYPE = ['USDT', '微信', '支付宝'] as const;
|
const ACCOUNT_TYPE = ['USDT(TRC20)', '微信', '支付宝'] as const;
|
||||||
|
|
||||||
// 根据账户类型定义 Zod 验证模式
|
// 根据账户类型定义 Zod 验证模式
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
@@ -76,11 +76,11 @@ const WalletDialog: WalletDialogProps = (props) => {
|
|||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
// 根据提现方式进行条件验证
|
// 根据提现方式进行条件验证
|
||||||
if (data.type === 'USDT') {
|
if (data.type === 'USDT(TRC20)') {
|
||||||
if (!data.account || data.account.trim().length === 0) {
|
if (!data.account || data.account.trim().length === 0) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'USDT 账号不能为空',
|
message: 'USDT(TRC20) 账号不能为空',
|
||||||
path: ['account'],
|
path: ['account'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ const WalletDialog: WalletDialogProps = (props) => {
|
|||||||
// 构建 title 和 description
|
// 构建 title 和 description
|
||||||
const title = `提现金额-${data.money}`;
|
const title = `提现金额-${data.money}`;
|
||||||
let description = '';
|
let description = '';
|
||||||
if (data.type === 'USDT') {
|
if (data.type === 'USDT(TRC20)') {
|
||||||
description = `${data.type}-${data.account || ''}`;
|
description = `${data.type}-${data.account || ''}`;
|
||||||
} else if (data.type === '微信' || data.type === '支付宝') {
|
} else if (data.type === '微信' || data.type === '支付宝') {
|
||||||
description = `${data.type}-${data.avatar || ''}`;
|
description = `${data.type}-${data.avatar || ''}`;
|
||||||
@@ -151,7 +151,7 @@ const WalletDialog: WalletDialogProps = (props) => {
|
|||||||
size: 1,
|
size: 1,
|
||||||
issue_type: 1,
|
issue_type: 1,
|
||||||
});
|
});
|
||||||
if (ticketData?.list?.length > 1) {
|
if (ticketData?.list?.length > 0) {
|
||||||
toast.info('已经存在待处理提现,请耐心等待');
|
toast.info('已经存在待处理提现,请耐心等待');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ const WalletDialog: WalletDialogProps = (props) => {
|
|||||||
|
|
||||||
// 根据提现方式动态生成弹窗描述
|
// 根据提现方式动态生成弹窗描述
|
||||||
const getModalDescription = () => {
|
const getModalDescription = () => {
|
||||||
if (currentType === 'USDT') {
|
if (currentType === 'USDT(TRC20)') {
|
||||||
const accountInfo = account || '未知地址';
|
const accountInfo = account || '未知地址';
|
||||||
return `请确认您的提现地址及金额无误,您将提现${money}RMB至${accountInfo}地址账号。`;
|
return `请确认您的提现地址及金额无误,您将提现${money}RMB至${accountInfo}地址账号。`;
|
||||||
} else {
|
} else {
|
||||||
@@ -214,7 +214,7 @@ const WalletDialog: WalletDialogProps = (props) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className={'pt-4'}>
|
<div className={'pt-4'}>
|
||||||
<div className={'pb-2 text-sm font-semibold text-[#7A7A7A]'}>
|
<div className={'pb-2 text-sm font-semibold text-[#7A7A7A]'}>
|
||||||
{currentType === 'USDT'
|
{currentType === 'USDT(TRC20)'
|
||||||
? '将佣金提现至您的个人数字钱包,无手续费'
|
? '将佣金提现至您的个人数字钱包,无手续费'
|
||||||
: '该提现方式需10%手续费,该费率由支付平台收取'}
|
: '该提现方式需10%手续费,该费率由支付平台收取'}
|
||||||
</div>
|
</div>
|
||||||
@@ -250,8 +250,8 @@ const WalletDialog: WalletDialogProps = (props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 提现账号输入(仅当选择USDT时显示) */}
|
{/* 提现账号输入(仅当选择USDT(TRC20)时显示) */}
|
||||||
{currentType === 'USDT' && (
|
{currentType === 'USDT(TRC20)' && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='account'
|
name='account'
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
'use client';
|
||||||
|
import EmailAuthForm1 from '@/app/auth/email2/auth-form';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<div className='container w-full py-8 sm:w-[496px]'>
|
||||||
|
<EmailAuthForm1 isRedirect={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/*import { GlobalMap } from '@/components/main/global-map';
|
||||||
|
import { Hero } from '@/components/main/hero';
|
||||||
|
import { ProductShowcase } from '@/components/main/product-showcase/index';
|
||||||
|
import { Stats } from '@/components/main/stats';*/
|
||||||
|
import Header from '@/components/Header/Header';
|
||||||
|
import { queryUserInfo } from '@/services/user/user';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
import EmailAuthForm2 from '@/app/auth/email2/auth-form';
|
||||||
|
import { LoginDialogProvider } from '@/app/auth/LoginDialogContext';
|
||||||
|
import FooterCopyright from '@/components/main/FooterCopyright';
|
||||||
|
import FullScreenVideoBackground from '@/components/main/FullScreenVideoBackground';
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const Authorization = (await cookies()).get('Authorization')?.value;
|
||||||
|
|
||||||
|
if (Authorization) {
|
||||||
|
let user = null;
|
||||||
|
try {
|
||||||
|
user = await queryUserInfo({
|
||||||
|
skipErrorHandler: true,
|
||||||
|
Authorization,
|
||||||
|
}).then((res) => res.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Token validation failed:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// redirect('/dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoginDialogProvider>
|
||||||
|
<FullScreenVideoBackground />
|
||||||
|
<Header />
|
||||||
|
<main className='fixed inset-0 z-40 flex items-center justify-center'>
|
||||||
|
<div className={'w-[80%] pt-8 sm:w-[496px]'}>
|
||||||
|
<EmailAuthForm2 isRedirect={true} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<FooterCopyright />
|
||||||
|
</LoginDialogProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { resetPassword, userLogin, userRegister } from '@/services/common/auth';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ReactNode, useState, useTransition } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NEXT_PUBLIC_DEFAULT_USER_EMAIL,
|
||||||
|
NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
|
||||||
|
} from '@/config/constants';
|
||||||
|
import useGlobalStore from '@/config/use-global';
|
||||||
|
import { getRedirectUrl, setAuthorization } from '@/utils/common';
|
||||||
|
import LoginForm from './login-form';
|
||||||
|
import RegisterForm from './register-form';
|
||||||
|
import ResetForm from './reset-form';
|
||||||
|
|
||||||
|
export default function EmailAuthForm(props: { isRedirect: boolean }) {
|
||||||
|
const t = useTranslations('auth');
|
||||||
|
const router = useRouter();
|
||||||
|
const [type, setType] = useState<'login' | 'register' | 'reset'>('register');
|
||||||
|
const [loading, startTransition] = useTransition();
|
||||||
|
const [initialValues, setInitialValues] = useState<{
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
}>({
|
||||||
|
email: NEXT_PUBLIC_DEFAULT_USER_EMAIL,
|
||||||
|
password: NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
|
||||||
|
});
|
||||||
|
const { getUserInfo } = useGlobalStore();
|
||||||
|
const handleFormSubmit = async (params: any) => {
|
||||||
|
const onLogin = async (token?: string) => {
|
||||||
|
if (!token) return;
|
||||||
|
setAuthorization(token);
|
||||||
|
console.log('props.isRedirect', token);
|
||||||
|
console.log('props.isRedirect', props.isRedirect);
|
||||||
|
console.log('props.isRedirect ', getRedirectUrl());
|
||||||
|
if (props.isRedirect) {
|
||||||
|
router.replace(getRedirectUrl());
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
await getUserInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case 'login': {
|
||||||
|
const login = await userLogin(params);
|
||||||
|
toast.success(t('login.success'));
|
||||||
|
onLogin(login.data.data?.token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'register': {
|
||||||
|
const create = await userRegister(params);
|
||||||
|
toast.success(t('register.success'));
|
||||||
|
onLogin(create.data.data?.token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'reset':
|
||||||
|
await resetPassword(params);
|
||||||
|
toast.success(t('reset.success'));
|
||||||
|
setType('login');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let UserForm: ReactNode = null;
|
||||||
|
switch (type) {
|
||||||
|
case 'login':
|
||||||
|
UserForm = (
|
||||||
|
<LoginForm
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
setInitialValues={setInitialValues}
|
||||||
|
onSwitchForm={setType}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'register':
|
||||||
|
UserForm = (
|
||||||
|
<RegisterForm
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
setInitialValues={setInitialValues}
|
||||||
|
onSwitchForm={setType}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
UserForm = (
|
||||||
|
<ResetForm
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
setInitialValues={setInitialValues}
|
||||||
|
onSwitchForm={setType}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserForm;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import useGlobalStore from '@/config/use-global';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
||||||
|
import { Button } from '@workspace/airo-ui/components/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@workspace/airo-ui/components/form';
|
||||||
|
import { Input } from '@workspace/airo-ui/components/input';
|
||||||
|
import { Icon } from '@workspace/airo-ui/custom-components/icon';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Dispatch, SetStateAction, useRef } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import CloudFlareTurnstile, { TurnstileRef } from '../turnstile';
|
||||||
|
|
||||||
|
export default function LoginForm({
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
setInitialValues,
|
||||||
|
onSwitchForm,
|
||||||
|
}: {
|
||||||
|
loading?: boolean;
|
||||||
|
onSubmit: (data: any) => void;
|
||||||
|
initialValues: any;
|
||||||
|
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||||
|
onSwitchForm: Dispatch<SetStateAction<'register' | 'reset' | 'login'>>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('auth.login');
|
||||||
|
const { common } = useGlobalStore();
|
||||||
|
const { verify } = common;
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
email: z.string().email(t('email')),
|
||||||
|
password: z.string(),
|
||||||
|
cf_token:
|
||||||
|
verify.enable_login_verify && verify.turnstile_site_key ? z.string() : z.string().optional(),
|
||||||
|
});
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnstile = useRef<TurnstileRef>(null);
|
||||||
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
|
try {
|
||||||
|
onSubmit(data);
|
||||||
|
} catch (error) {
|
||||||
|
turnstile.current?.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'h-[84px] text-2xl font-bold leading-[84px]'}>账户验证</div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit} className=''>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={'mb-5'}>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='Email'
|
||||||
|
type='email'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={'mb-2'}>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='Password'
|
||||||
|
type='password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{verify.enable_login_verify && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='cf_token'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={'last:mb-0'}>
|
||||||
|
<FormControl>
|
||||||
|
<CloudFlareTurnstile id='login' {...field} ref={turnstile} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className='flex w-full justify-between text-sm'>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
type='button'
|
||||||
|
className='p-0 text-[#225BA9]'
|
||||||
|
onClick={() => onSwitchForm('reset')}
|
||||||
|
>
|
||||||
|
{t('forgotPassword')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
type='button'
|
||||||
|
className='p-0 text-[#225BA9]'
|
||||||
|
onClick={() => {
|
||||||
|
setInitialValues(undefined);
|
||||||
|
onSwitchForm('register');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('registerAccount')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6 flex justify-center'>
|
||||||
|
<AiroButton
|
||||||
|
type='submit'
|
||||||
|
variant='default'
|
||||||
|
disabled={loading}
|
||||||
|
className='h-auto min-w-[157px] py-2 text-lg font-medium'
|
||||||
|
>
|
||||||
|
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||||
|
{t('title')}
|
||||||
|
</AiroButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
import useGlobalStore from '@/config/use-global';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
||||||
|
import { Button } from '@workspace/airo-ui/components/button';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@workspace/airo-ui/components/form';
|
||||||
|
import { Input } from '@workspace/airo-ui/components/input';
|
||||||
|
import { Icon } from '@workspace/airo-ui/custom-components/icon';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import SendCode from '../send-code';
|
||||||
|
import { TurnstileRef } from '../turnstile';
|
||||||
|
|
||||||
|
export default function RegisterForm({
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
setInitialValues,
|
||||||
|
onSwitchForm,
|
||||||
|
}: {
|
||||||
|
loading?: boolean;
|
||||||
|
onSubmit: (data: any) => void;
|
||||||
|
initialValues: any;
|
||||||
|
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||||
|
onSwitchForm: Dispatch<SetStateAction<'register' | 'reset' | 'login'>>;
|
||||||
|
}) {
|
||||||
|
const { common } = useGlobalStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email('请输入有效的电子邮件地址。'),
|
||||||
|
password: z.string().min(1, '请输入密码'), // 必填提示
|
||||||
|
repeat_password: z.string().min(1, '请重复输入密码'), // 必填
|
||||||
|
code: z.string().min(1, '请输入验证码'), // 必填,
|
||||||
|
invite: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.superRefine(({ password, repeat_password }, ctx) => {
|
||||||
|
if (password !== repeat_password) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: '两次密码输入不一致',
|
||||||
|
path: ['repeat_password'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [inviteDefault, setInviteDefault] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const invite = localStorage.getItem('invite') || '';
|
||||||
|
setInviteDefault(invite);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
...initialValues,
|
||||||
|
invite: inviteDefault,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnstile = useRef<TurnstileRef>(null);
|
||||||
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
|
try {
|
||||||
|
onSubmit(data);
|
||||||
|
} catch (error) {
|
||||||
|
turnstile.current?.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'h-[84px] text-2xl font-bold leading-[84px]'}>线路优化</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className='grid gap-5'>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='电子邮箱'
|
||||||
|
type='email'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='设置密码'
|
||||||
|
type='password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='repeat_password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder='再次输入密码'
|
||||||
|
type='password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='code'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className='flex items-center gap-8'>
|
||||||
|
<Input
|
||||||
|
disabled={loading}
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='邮箱验证码'
|
||||||
|
type='text'
|
||||||
|
{...field}
|
||||||
|
value={field.value as string}
|
||||||
|
/>
|
||||||
|
<SendCode
|
||||||
|
type='email'
|
||||||
|
form={form}
|
||||||
|
params={{
|
||||||
|
...form.getValues(),
|
||||||
|
type: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='invite'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
disabled={loading || !!inviteDefault}
|
||||||
|
placeholder={'邀请码(非必填)'}
|
||||||
|
{...field}
|
||||||
|
value={field.value || ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='text-right text-sm'>
|
||||||
|
已有账户?
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
type='button'
|
||||||
|
className='p-0 text-[#225BA9]'
|
||||||
|
onClick={() => {
|
||||||
|
router.replace('/');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className='mt-6 flex justify-center'>
|
||||||
|
<AiroButton
|
||||||
|
type='submit'
|
||||||
|
disabled={loading}
|
||||||
|
className='h-auto min-w-[157px] py-2 text-lg font-medium'
|
||||||
|
>
|
||||||
|
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||||
|
注册
|
||||||
|
</AiroButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import useGlobalStore from '@/config/use-global';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
||||||
|
import { Button } from '@workspace/airo-ui/components/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@workspace/airo-ui/components/form';
|
||||||
|
import { Input } from '@workspace/airo-ui/components/input';
|
||||||
|
import { Icon } from '@workspace/airo-ui/custom-components/icon';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Dispatch, SetStateAction, useRef } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import SendCode from '../send-code';
|
||||||
|
import CloudFlareTurnstile, { TurnstileRef } from '../turnstile';
|
||||||
|
|
||||||
|
export default function ResetForm({
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
setInitialValues,
|
||||||
|
onSwitchForm,
|
||||||
|
}: {
|
||||||
|
loading?: boolean;
|
||||||
|
onSubmit: (data: any) => void;
|
||||||
|
initialValues: any;
|
||||||
|
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||||
|
onSwitchForm: Dispatch<SetStateAction<'register' | 'reset' | 'login'>>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('auth.reset');
|
||||||
|
|
||||||
|
const { common } = useGlobalStore();
|
||||||
|
const { verify, auth } = common;
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
email: z.string().email(t('email')),
|
||||||
|
password: z.string(),
|
||||||
|
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
|
||||||
|
cf_token:
|
||||||
|
verify.enable_register_verify && verify.turnstile_site_key
|
||||||
|
? z.string()
|
||||||
|
: z.string().nullish(),
|
||||||
|
});
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnstile = useRef<TurnstileRef>(null);
|
||||||
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
|
try {
|
||||||
|
onSubmit(data);
|
||||||
|
} catch (error) {
|
||||||
|
turnstile.current?.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'h-[84px] text-2xl font-bold leading-[84px]'}>找回账户</div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className='grid gap-5'>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='Enter your email...'
|
||||||
|
type='email'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='code'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className='flex items-center gap-8'>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder='Enter code...'
|
||||||
|
type='text'
|
||||||
|
{...field}
|
||||||
|
value={field.value as string}
|
||||||
|
/>
|
||||||
|
<SendCode
|
||||||
|
type='email'
|
||||||
|
form={form}
|
||||||
|
params={{
|
||||||
|
...form.getValues(),
|
||||||
|
type: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='Enter your new password...'
|
||||||
|
type='password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{verify.enable_reset_password_verify && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='cf_token'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<CloudFlareTurnstile id='reset' {...field} ref={turnstile} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='text-right text-sm'>
|
||||||
|
{t('existingAccount')}
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
type='button'
|
||||||
|
className='p-0 text-[#225BA9]'
|
||||||
|
onClick={() => {
|
||||||
|
setInitialValues(undefined);
|
||||||
|
onSwitchForm('login');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('switchToLogin')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className='mt-6 flex justify-center'>
|
||||||
|
<AiroButton
|
||||||
|
type='submit'
|
||||||
|
disabled={loading}
|
||||||
|
className='h-auto min-w-[157px] py-2 text-lg font-medium'
|
||||||
|
>
|
||||||
|
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||||
|
{t('title')}
|
||||||
|
</AiroButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { resetPassword, userLogin, userRegister } from '@/services/common/auth';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ReactNode, useState, useTransition } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NEXT_PUBLIC_DEFAULT_USER_EMAIL,
|
||||||
|
NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
|
||||||
|
} from '@/config/constants';
|
||||||
|
import useGlobalStore from '@/config/use-global';
|
||||||
|
import { getRedirectUrl, setAuthorization } from '@/utils/common';
|
||||||
|
import LoginForm from './login-form';
|
||||||
|
import RegisterForm from './register-form';
|
||||||
|
import ResetForm from './reset-form';
|
||||||
|
|
||||||
|
export default function EmailAuthForm(props: { isRedirect: boolean }) {
|
||||||
|
const t = useTranslations('auth');
|
||||||
|
const router = useRouter();
|
||||||
|
const [type, setType] = useState<'login' | 'register' | 'reset'>('register');
|
||||||
|
const [loading, startTransition] = useTransition();
|
||||||
|
const [initialValues, setInitialValues] = useState<{
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
}>({
|
||||||
|
email: NEXT_PUBLIC_DEFAULT_USER_EMAIL,
|
||||||
|
password: NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
|
||||||
|
});
|
||||||
|
const { getUserInfo } = useGlobalStore();
|
||||||
|
const handleFormSubmit = async (params: any) => {
|
||||||
|
const onLogin = async (token?: string) => {
|
||||||
|
if (!token) return;
|
||||||
|
setAuthorization(token);
|
||||||
|
console.log('props.isRedirect', token);
|
||||||
|
console.log('props.isRedirect', props.isRedirect);
|
||||||
|
console.log('props.isRedirect ', getRedirectUrl());
|
||||||
|
if (props.isRedirect) {
|
||||||
|
router.replace(getRedirectUrl());
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
await getUserInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case 'login': {
|
||||||
|
const login = await userLogin(params);
|
||||||
|
toast.success(t('login.success'));
|
||||||
|
onLogin(login.data.data?.token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'register': {
|
||||||
|
const create = await userRegister(params);
|
||||||
|
toast.success(t('register.success'));
|
||||||
|
onLogin(create.data.data?.token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'reset':
|
||||||
|
await resetPassword(params);
|
||||||
|
toast.success(t('reset.success'));
|
||||||
|
setType('login');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let UserForm: ReactNode = null;
|
||||||
|
switch (type) {
|
||||||
|
case 'login':
|
||||||
|
UserForm = (
|
||||||
|
<LoginForm
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
setInitialValues={setInitialValues}
|
||||||
|
onSwitchForm={setType}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'register':
|
||||||
|
UserForm = (
|
||||||
|
<RegisterForm
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
setInitialValues={setInitialValues}
|
||||||
|
onSwitchForm={setType}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
UserForm = (
|
||||||
|
<ResetForm
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
setInitialValues={setInitialValues}
|
||||||
|
onSwitchForm={setType}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserForm;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import useGlobalStore from '@/config/use-global';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
||||||
|
import { Button } from '@workspace/airo-ui/components/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@workspace/airo-ui/components/form';
|
||||||
|
import { Input } from '@workspace/airo-ui/components/input';
|
||||||
|
import { Icon } from '@workspace/airo-ui/custom-components/icon';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Dispatch, SetStateAction, useRef } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import CloudFlareTurnstile, { TurnstileRef } from '../turnstile';
|
||||||
|
|
||||||
|
export default function LoginForm({
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
setInitialValues,
|
||||||
|
onSwitchForm,
|
||||||
|
}: {
|
||||||
|
loading?: boolean;
|
||||||
|
onSubmit: (data: any) => void;
|
||||||
|
initialValues: any;
|
||||||
|
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||||
|
onSwitchForm: Dispatch<SetStateAction<'register' | 'reset' | 'login'>>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('auth.login');
|
||||||
|
const { common } = useGlobalStore();
|
||||||
|
const { verify } = common;
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
email: z.string().email(t('email')),
|
||||||
|
password: z.string(),
|
||||||
|
cf_token:
|
||||||
|
verify.enable_login_verify && verify.turnstile_site_key ? z.string() : z.string().optional(),
|
||||||
|
});
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnstile = useRef<TurnstileRef>(null);
|
||||||
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
|
try {
|
||||||
|
onSubmit(data);
|
||||||
|
} catch (error) {
|
||||||
|
turnstile.current?.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'h-[84px] text-2xl font-bold leading-[84px]'}>账户验证</div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit} className=''>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={'mb-5'}>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='Email'
|
||||||
|
type='email'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={'mb-2'}>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='Password'
|
||||||
|
type='password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{verify.enable_login_verify && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='cf_token'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={'last:mb-0'}>
|
||||||
|
<FormControl>
|
||||||
|
<CloudFlareTurnstile id='login' {...field} ref={turnstile} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className='flex w-full justify-between text-sm'>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
type='button'
|
||||||
|
className='p-0 text-[#225BA9]'
|
||||||
|
onClick={() => onSwitchForm('reset')}
|
||||||
|
>
|
||||||
|
{t('forgotPassword')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
type='button'
|
||||||
|
className='p-0 text-[#225BA9]'
|
||||||
|
onClick={() => {
|
||||||
|
setInitialValues(undefined);
|
||||||
|
onSwitchForm('register');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('registerAccount')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6 flex justify-center'>
|
||||||
|
<AiroButton
|
||||||
|
type='submit'
|
||||||
|
variant='default'
|
||||||
|
disabled={loading}
|
||||||
|
className='h-auto min-w-[157px] py-2 text-lg font-medium'
|
||||||
|
>
|
||||||
|
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||||
|
{t('title')}
|
||||||
|
</AiroButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
'use client';
|
||||||
|
import useGlobalStore from '@/config/use-global';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
||||||
|
import { Button } from '@workspace/airo-ui/components/button';
|
||||||
|
|
||||||
|
import { useLoginDialog } from '@/app/auth/LoginDialogContext';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@workspace/airo-ui/components/form';
|
||||||
|
import { Input } from '@workspace/airo-ui/components/input';
|
||||||
|
import { Icon } from '@workspace/airo-ui/custom-components/icon';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import SendCode from '../send-code';
|
||||||
|
import { TurnstileRef } from '../turnstile';
|
||||||
|
|
||||||
|
export default function RegisterForm({
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
setInitialValues,
|
||||||
|
onSwitchForm,
|
||||||
|
}: {
|
||||||
|
loading?: boolean;
|
||||||
|
onSubmit: (data: any) => void;
|
||||||
|
initialValues: any;
|
||||||
|
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||||
|
onSwitchForm: Dispatch<SetStateAction<'register' | 'reset' | 'login'>>;
|
||||||
|
}) {
|
||||||
|
const { common } = useGlobalStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email('请输入有效的电子邮件地址。'),
|
||||||
|
password: z.string().min(1, '请输入密码'), // 必填提示
|
||||||
|
repeat_password: z.string().min(1, '请重复输入密码'), // 必填
|
||||||
|
code: z.string().min(1, '请输入验证码'), // 必填,
|
||||||
|
invite: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.superRefine(({ password, repeat_password }, ctx) => {
|
||||||
|
if (password !== repeat_password) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: '两次密码输入不一致',
|
||||||
|
path: ['repeat_password'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [inviteDefault, setInviteDefault] = useState('');
|
||||||
|
const { openLoginDialog } = useLoginDialog();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const invite = localStorage.getItem('invite') || '';
|
||||||
|
setInviteDefault(invite);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
...initialValues,
|
||||||
|
invite: inviteDefault,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnstile = useRef<TurnstileRef>(null);
|
||||||
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
|
try {
|
||||||
|
onSubmit(data);
|
||||||
|
} catch (error) {
|
||||||
|
turnstile.current?.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'h-[84px] text-2xl font-bold leading-[84px] text-white'}>线路优化</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className='grid gap-5'>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={
|
||||||
|
'h-[46px] rounded-full bg-white shadow-[inset_0_0_7.6px_0_#00000040]'
|
||||||
|
}
|
||||||
|
placeholder='电子邮箱'
|
||||||
|
type='email'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={
|
||||||
|
'h-[46px] rounded-full bg-white shadow-[inset_0_0_7.6px_0_#00000040]'
|
||||||
|
}
|
||||||
|
placeholder='设置密码'
|
||||||
|
type='password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='repeat_password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={
|
||||||
|
'h-[46px] rounded-full bg-white shadow-[inset_0_0_7.6px_0_#00000040]'
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder='再次输入密码'
|
||||||
|
type='password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='code'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className='flex items-center gap-8'>
|
||||||
|
<Input
|
||||||
|
disabled={loading}
|
||||||
|
className={
|
||||||
|
'h-[46px] rounded-full bg-white shadow-[inset_0_0_7.6px_0_#00000040]'
|
||||||
|
}
|
||||||
|
placeholder='邮箱验证码'
|
||||||
|
type='text'
|
||||||
|
{...field}
|
||||||
|
value={field.value as string}
|
||||||
|
/>
|
||||||
|
<SendCode
|
||||||
|
type='email'
|
||||||
|
form={form}
|
||||||
|
params={{
|
||||||
|
...form.getValues(),
|
||||||
|
type: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='invite'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={
|
||||||
|
'h-[46px] rounded-full bg-white shadow-[inset_0_0_7.6px_0_#00000040]'
|
||||||
|
}
|
||||||
|
disabled={loading || !!inviteDefault}
|
||||||
|
placeholder={'邀请码(非必填)'}
|
||||||
|
{...field}
|
||||||
|
value={field.value || ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='text-right text-sm'>
|
||||||
|
已有账户?
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
type='button'
|
||||||
|
className='p-0 text-[#225BA9]'
|
||||||
|
onClick={() => {
|
||||||
|
openLoginDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className='mt-6 flex justify-center'>
|
||||||
|
<AiroButton
|
||||||
|
type='submit'
|
||||||
|
variant={'default'}
|
||||||
|
disabled={loading}
|
||||||
|
className='h-auto min-w-[157px] border-[3px] border-white bg-white/10 py-2 text-lg font-medium text-white transition hover:bg-white/25'
|
||||||
|
>
|
||||||
|
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||||
|
注册
|
||||||
|
</AiroButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import useGlobalStore from '@/config/use-global';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
||||||
|
import { Button } from '@workspace/airo-ui/components/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@workspace/airo-ui/components/form';
|
||||||
|
import { Input } from '@workspace/airo-ui/components/input';
|
||||||
|
import { Icon } from '@workspace/airo-ui/custom-components/icon';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Dispatch, SetStateAction, useRef } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import SendCode from '../send-code';
|
||||||
|
import CloudFlareTurnstile, { TurnstileRef } from '../turnstile';
|
||||||
|
|
||||||
|
export default function ResetForm({
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
setInitialValues,
|
||||||
|
onSwitchForm,
|
||||||
|
}: {
|
||||||
|
loading?: boolean;
|
||||||
|
onSubmit: (data: any) => void;
|
||||||
|
initialValues: any;
|
||||||
|
setInitialValues: Dispatch<SetStateAction<any>>;
|
||||||
|
onSwitchForm: Dispatch<SetStateAction<'register' | 'reset' | 'login'>>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('auth.reset');
|
||||||
|
|
||||||
|
const { common } = useGlobalStore();
|
||||||
|
const { verify, auth } = common;
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
email: z.string().email(t('email')),
|
||||||
|
password: z.string(),
|
||||||
|
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
|
||||||
|
cf_token:
|
||||||
|
verify.enable_register_verify && verify.turnstile_site_key
|
||||||
|
? z.string()
|
||||||
|
: z.string().nullish(),
|
||||||
|
});
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnstile = useRef<TurnstileRef>(null);
|
||||||
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
|
try {
|
||||||
|
onSubmit(data);
|
||||||
|
} catch (error) {
|
||||||
|
turnstile.current?.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'h-[84px] text-2xl font-bold leading-[84px]'}>找回账户</div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className='grid gap-5'>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='Enter your email...'
|
||||||
|
type='email'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='code'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className='flex items-center gap-8'>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder='Enter code...'
|
||||||
|
type='text'
|
||||||
|
{...field}
|
||||||
|
value={field.value as string}
|
||||||
|
/>
|
||||||
|
<SendCode
|
||||||
|
type='email'
|
||||||
|
form={form}
|
||||||
|
params={{
|
||||||
|
...form.getValues(),
|
||||||
|
type: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className={'h-[46px] rounded-full shadow-[inset_0_0_7.6px_0_#00000040]'}
|
||||||
|
placeholder='Enter your new password...'
|
||||||
|
type='password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{verify.enable_reset_password_verify && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='cf_token'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<CloudFlareTurnstile id='reset' {...field} ref={turnstile} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='text-right text-sm'>
|
||||||
|
{t('existingAccount')}
|
||||||
|
<Button
|
||||||
|
variant='link'
|
||||||
|
type='button'
|
||||||
|
className='p-0 text-[#225BA9]'
|
||||||
|
onClick={() => {
|
||||||
|
setInitialValues(undefined);
|
||||||
|
onSwitchForm('login');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('switchToLogin')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className='mt-6 flex justify-center'>
|
||||||
|
<AiroButton
|
||||||
|
type='submit'
|
||||||
|
disabled={loading}
|
||||||
|
className='h-auto min-w-[157px] py-2 text-lg font-medium'
|
||||||
|
>
|
||||||
|
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
|
||||||
|
{t('title')}
|
||||||
|
</AiroButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -84,16 +84,14 @@ export default function SendCode({ type, params, form }: SendCodeProps) {
|
|||||||
await getPhoneCode();
|
await getPhoneCode();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const disabled =
|
const disabled = seconds > 0;
|
||||||
seconds > 0 ||
|
|
||||||
(type === 'email' ? !params.email : !params.telephone || !params.telephone_area_code);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AiroButton
|
<AiroButton
|
||||||
type='button'
|
type='button'
|
||||||
variant={'primary'}
|
variant={'primary'}
|
||||||
className={'h-[30px] w-[109px]'}
|
className={'h-[46px] w-[109px]'}
|
||||||
onClick={handleSendCode}
|
onClick={handleSendCode}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<svg width="58" height="58" viewBox="0 0 58 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_d_2526_1784)">
|
||||||
|
<rect x="2.11401" y="2.06323" width="53.6962" height="53.6962" rx="10" fill="white"/>
|
||||||
|
<g clip-path="url(#clip0_2526_1784)">
|
||||||
|
<path d="M35.2874 19.2087C35.2874 18.9251 35.4299 18.6623 35.663 18.5164L41.8535 14.64C42.3693 14.3171 43.0255 14.7047 43.0255 15.3324V21.9808C43.0255 22.4267 42.679 22.7882 42.2517 22.7882H36.0612C35.6339 22.7882 35.2874 22.4267 35.2874 21.9808V19.2087Z" fill="#455FE9"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.8295 24.9768C24.5964 25.1227 24.4538 25.3855 24.4538 25.6691V33.2867V34.0936V34.0941V35.3041C24.4538 35.75 24.1074 36.0712 23.68 36.0712H22.1323C21.705 36.0712 21.3585 35.75 21.3585 35.3041V34.0936V29.06C21.3585 28.4323 20.7023 28.0447 20.1866 28.3676L13.996 32.2439C13.763 32.3899 13.6204 32.6526 13.6204 32.9364V41.3613C13.6204 41.8072 13.9668 42.1687 14.3942 42.1687H20.5847C21.0121 42.1687 21.3585 41.8072 21.3585 41.3613V40.5533V39.5381C21.3585 39.0921 21.705 38.8219 22.1323 38.8219H23.68C24.1074 38.8219 24.4538 39.0921 24.4538 39.5381V40.5533V41.3613C24.4538 41.8072 24.8003 42.1687 25.2276 42.1687H31.4181C31.8455 42.1687 32.192 41.8072 32.192 41.3613V34.0941V33.2867V21.7929C32.192 21.1652 31.5358 20.7775 31.0199 21.1005L24.8295 24.9768Z" fill="#455FE9"/>
|
||||||
|
<path d="M35.2878 26.0989V41.4406C35.2878 41.8643 35.6343 42.2077 36.0617 42.2077H42.2522C42.6795 42.2077 43.026 41.8643 43.026 41.4406V26.0989C43.026 25.6752 42.6795 25.3318 42.2522 25.3318H36.0617C35.6343 25.3318 35.2878 25.6752 35.2878 26.0989Z" fill="#455FE9"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_d_2526_1784" x="0.114014" y="0.0632324" width="57.6963" height="57.6963" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="1"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="out"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2526_1784"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2526_1784" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="clip0_2526_1784">
|
||||||
|
<rect width="29.4051" height="30.6835" fill="white" transform="translate(13.6204 12.2913)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.0 KiB |
@@ -1,6 +1,6 @@
|
|||||||
// 加载状态组件
|
// 加载状态组件
|
||||||
import { unitConversion } from '@workspace/airo-ui/utils';
|
import { unitConversion } from '@workspace/airo-ui/utils';
|
||||||
import { useMemo, useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
|
||||||
const LoadingState = () => {
|
const LoadingState = () => {
|
||||||
const t = useTranslations('components.offerDialog');
|
const t = useTranslations('components.offerDialog');
|
||||||
@@ -338,16 +338,17 @@ const TabContent: React.FC<TabContentProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用 useMemo 优化数据处理性能
|
console.log('subscribeData:', subscribeData);
|
||||||
const yearlyPlans: PlanProps[] = useMemo(
|
|
||||||
() => subscribeData?.map((item) => processPlanData(item, true)),
|
|
||||||
[subscribeData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const monthlyPlans: PlanProps[] = useMemo(
|
let yearlyPlans: PlanProps[] = [];
|
||||||
() => subscribeData?.map((item) => processPlanData(item, false)),
|
if (Array.isArray(subscribeData)) {
|
||||||
[subscribeData],
|
yearlyPlans = subscribeData.map((item) => processPlanData(item, true));
|
||||||
);
|
}
|
||||||
|
|
||||||
|
let monthlyPlans: PlanProps[] = [];
|
||||||
|
if (Array.isArray(subscribeData)) {
|
||||||
|
monthlyPlans = subscribeData.map((item) => processPlanData(item, false));
|
||||||
|
}
|
||||||
|
|
||||||
const PurchaseRef = useRef<{
|
const PurchaseRef = useRef<{
|
||||||
show: (subscribe: API.Subscribe, tabValue: string) => void;
|
show: (subscribe: API.Subscribe, tabValue: string) => void;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function Providers({
|
|||||||
|
|
||||||
const { setCommon, setUser } = useGlobalStore();
|
const { setCommon, setUser } = useGlobalStore();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const whiteList = ['/'];
|
const whiteList = ['/', '/register', '/register2'];
|
||||||
const isWhite = whiteList.includes(pathname);
|
const isWhite = whiteList.includes(pathname);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import PaymentMethods from '@/components/subscribe/payment-methods';
|
import PaymentMethods from '@/components/subscribe/payment-methods';
|
||||||
import PlanTabs, { TabValueType } from '@/components/SubscribePlan/PlanTabs/PlanTabs';
|
import PlanTabs, { TabValueType } from '@/components/SubscribePlan/PlanTabs/PlanTabs';
|
||||||
import useGlobalStore from '@/config/use-global';
|
import useGlobalStore from '@/config/use-global';
|
||||||
import { preCreateOrder, purchase } from '@/services/user/order';
|
import { preCreateOrder, purchase, renewal } from '@/services/user/order';
|
||||||
|
import { queryUserSubscribe } from '@/services/user/user';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
||||||
import {
|
import {
|
||||||
@@ -104,7 +105,23 @@ const Purchase = forwardRef<PurchaseDialogRef, PurchaseProps>((props, ref) => {
|
|||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await purchase(params as API.PurchaseOrderRequest);
|
const { data } = await queryUserSubscribe({ params: { includeExpired: 'all' } });
|
||||||
|
const activeList = data.data?.list || [];
|
||||||
|
const existingUser = activeList.find(
|
||||||
|
(item: any) => item.subscribe_id === params.subscribe_id,
|
||||||
|
);
|
||||||
|
let response;
|
||||||
|
// 3. 根据判断结果调用不同接口
|
||||||
|
if (existingUser) {
|
||||||
|
// 已有记录,走续费逻辑
|
||||||
|
response = await renewal({
|
||||||
|
user_subscribe_id: existingUser.id,
|
||||||
|
...params,
|
||||||
|
} as API.RenewalOrderRequest);
|
||||||
|
} else {
|
||||||
|
// 无记录,走新购逻辑
|
||||||
|
response = await purchase(params as API.PurchaseOrderRequest);
|
||||||
|
}
|
||||||
const orderNo = response.data.data?.order_no;
|
const orderNo = response.data.data?.order_no;
|
||||||
if (orderNo) {
|
if (orderNo) {
|
||||||
await getUserInfo();
|
await getUserInfo();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"None": "None",
|
||||||
"accountBalance": "Account Balance",
|
"accountBalance": "Account Balance",
|
||||||
"accountOverview": "Account Overview",
|
"accountOverview": "Account Overview",
|
||||||
"address1": "Address 1",
|
"address1": "Address 1",
|
||||||
@@ -7,11 +8,13 @@
|
|||||||
"annualYearPlanUser": "Annual Plan User",
|
"annualYearPlanUser": "Annual Plan User",
|
||||||
"availableDevices": "Available Devices",
|
"availableDevices": "Available Devices",
|
||||||
"beginnerTutorial": "Beginner Tutorial",
|
"beginnerTutorial": "Beginner Tutorial",
|
||||||
|
"buySubscriptionNow": "Buy Subscription Now",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"confirmResetSubscription": "Are you sure you want to reset the subscription address?",
|
"confirmResetSubscription": "Are you sure you want to reset the subscription address?",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copyFailure": "Copy failed, please copy manually",
|
"copyFailure": "Copy failed, please copy manually",
|
||||||
|
"copySubscribeLink": "Copy the subscription link or click the QR code button to scan",
|
||||||
"copySubscriptionLinkOrScanQrCode": "Copy subscription link or click the QR code button to scan",
|
"copySubscriptionLinkOrScanQrCode": "Copy subscription link or click the QR code button to scan",
|
||||||
"copySuccess": "Copy successful",
|
"copySuccess": "Copy successful",
|
||||||
"deducted": "Canceled",
|
"deducted": "Canceled",
|
||||||
@@ -19,6 +22,8 @@
|
|||||||
"expirationDays": "Expiration Days",
|
"expirationDays": "Expiration Days",
|
||||||
"expired": "Expired",
|
"expired": "Expired",
|
||||||
"finished": "Traffic exhausted",
|
"finished": "Traffic exhausted",
|
||||||
|
"freeAppleID": "Free US Apple ID",
|
||||||
|
"get": "Get",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"inEffect": "In Effect",
|
"inEffect": "In Effect",
|
||||||
"latestAnnouncement": "Latest Announcement",
|
"latestAnnouncement": "Latest Announcement",
|
||||||
@@ -32,6 +37,7 @@
|
|||||||
"noPlanAvailable": "No Plan Available",
|
"noPlanAvailable": "No Plan Available",
|
||||||
"noReset": "No Reset",
|
"noReset": "No Reset",
|
||||||
"noYPlan": "No Plan Active",
|
"noYPlan": "No Plan Active",
|
||||||
|
"notEffect": "Not in effect",
|
||||||
"online": "Online: ",
|
"online": "Online: ",
|
||||||
"pageOf": "Page {pageIndex} of {pageCount}",
|
"pageOf": "Page {pageIndex} of {pageCount}",
|
||||||
"pinnedAnnouncement": "[Pinned]",
|
"pinnedAnnouncement": "[Pinned]",
|
||||||
@@ -40,12 +46,14 @@
|
|||||||
"prompt": "Prompt",
|
"prompt": "Prompt",
|
||||||
"purchaseSubscription": "Purchase Subscription",
|
"purchaseSubscription": "Purchase Subscription",
|
||||||
"qrCode": "QR Code",
|
"qrCode": "QR Code",
|
||||||
|
"quickDownloads": "Quick Downloads",
|
||||||
"remaining": "Remaining: ",
|
"remaining": "Remaining: ",
|
||||||
"resetSubscription": "Reset Subscription Address",
|
"resetSubscription": "Reset Subscription Address",
|
||||||
"resetSuccess": "Reset successful",
|
"resetSuccess": "Reset successful",
|
||||||
"rowsPerPage": "Rows per page",
|
"rowsPerPage": "Rows per page",
|
||||||
"scanCodeToSubscribe": "Scan code to subscribe",
|
"scanCodeToSubscribe": "Scan code to subscribe",
|
||||||
"scanToSubscribe": "Scan to Subscribe",
|
"scanToSubscribe": "Scan to Subscribe",
|
||||||
|
"selectOS": "Select the corresponding operating system to download the client",
|
||||||
"siteAnnouncements": "Site Announcements",
|
"siteAnnouncements": "Site Announcements",
|
||||||
"subscriptionUrl": "Subscription URL",
|
"subscriptionUrl": "Subscription URL",
|
||||||
"totalTraffic": "Total Traffic",
|
"totalTraffic": "Total Traffic",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"None": "暂无",
|
||||||
"accountBalance": "账户余额",
|
"accountBalance": "账户余额",
|
||||||
"accountOverview": "账户概况",
|
"accountOverview": "账户概况",
|
||||||
"address1": "地址1",
|
"address1": "地址1",
|
||||||
@@ -7,11 +8,13 @@
|
|||||||
"annualYearPlanUser": "年度套餐用户",
|
"annualYearPlanUser": "年度套餐用户",
|
||||||
"availableDevices": "可用设备",
|
"availableDevices": "可用设备",
|
||||||
"beginnerTutorial": "新手教程",
|
"beginnerTutorial": "新手教程",
|
||||||
|
"buySubscriptionNow": "立即购买订阅",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"confirmResetSubscription": "是否确认重置订阅地址?",
|
"confirmResetSubscription": "是否确认重置订阅地址?",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"copyFailure": "复制失败,请手动复制",
|
"copyFailure": "复制失败,请手动复制",
|
||||||
|
"copySubscribeLink": "复制订阅链接或点击二维码按钮扫码",
|
||||||
"copySubscriptionLinkOrScanQrCode": "复制订阅链接或点击二维码按钮扫码",
|
"copySubscriptionLinkOrScanQrCode": "复制订阅链接或点击二维码按钮扫码",
|
||||||
"copySuccess": "复制成功",
|
"copySuccess": "复制成功",
|
||||||
"deducted": "已取消",
|
"deducted": "已取消",
|
||||||
@@ -19,6 +22,8 @@
|
|||||||
"expirationDays": "到期时间/天",
|
"expirationDays": "到期时间/天",
|
||||||
"expired": "已过期",
|
"expired": "已过期",
|
||||||
"finished": "流量已用尽",
|
"finished": "流量已用尽",
|
||||||
|
"freeAppleID": "免费美区Apple ID",
|
||||||
|
"get": "获取",
|
||||||
"import": "导入",
|
"import": "导入",
|
||||||
"inEffect": "生效中",
|
"inEffect": "生效中",
|
||||||
"latestAnnouncement": "最新公告",
|
"latestAnnouncement": "最新公告",
|
||||||
@@ -32,6 +37,7 @@
|
|||||||
"noPlanAvailable": "暂无套餐",
|
"noPlanAvailable": "暂无套餐",
|
||||||
"noReset": "不重置",
|
"noReset": "不重置",
|
||||||
"noYPlan": "尚未有套餐生效",
|
"noYPlan": "尚未有套餐生效",
|
||||||
|
"notEffect": "未生效",
|
||||||
"online": "在线:",
|
"online": "在线:",
|
||||||
"pageOf": "第 {pageIndex} 页,共 {pageCount} 页",
|
"pageOf": "第 {pageIndex} 页,共 {pageCount} 页",
|
||||||
"pinnedAnnouncement": "【置顶公告】",
|
"pinnedAnnouncement": "【置顶公告】",
|
||||||
@@ -40,12 +46,14 @@
|
|||||||
"prompt": "提示",
|
"prompt": "提示",
|
||||||
"purchaseSubscription": "购买订阅",
|
"purchaseSubscription": "购买订阅",
|
||||||
"qrCode": "二维码",
|
"qrCode": "二维码",
|
||||||
|
"quickDownloads": "快捷下载",
|
||||||
"remaining": "剩余:",
|
"remaining": "剩余:",
|
||||||
"resetSubscription": "重置订阅地址",
|
"resetSubscription": "重置订阅地址",
|
||||||
"resetSuccess": "重置成功",
|
"resetSuccess": "重置成功",
|
||||||
"rowsPerPage": "每页显示",
|
"rowsPerPage": "每页显示",
|
||||||
"scanCodeToSubscribe": "扫描码订阅",
|
"scanCodeToSubscribe": "扫描码订阅",
|
||||||
"scanToSubscribe": "扫描订阅",
|
"scanToSubscribe": "扫描订阅",
|
||||||
|
"selectOS": "选择对应操作系统下载客户端",
|
||||||
"siteAnnouncements": "网站公告",
|
"siteAnnouncements": "网站公告",
|
||||||
"subscriptionUrl": "订阅地址",
|
"subscriptionUrl": "订阅地址",
|
||||||
"totalTraffic": "总流量",
|
"totalTraffic": "总流量",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"framer-motion": "^11.16.1",
|
"framer-motion": "^11.16.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "^15.1.4",
|
"next": "15.3.6",
|
||||||
"next-intl": "^3.26.3",
|
"next-intl": "^3.26.3",
|
||||||
"next-runtime-env": "^3.2.2",
|
"next-runtime-env": "^3.2.2",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function Logout() {
|
|||||||
Crisp.session.reset(); // 2. Unbind the current session
|
Crisp.session.reset(); // 2. Unbind the current session
|
||||||
const pathname = location.pathname;
|
const pathname = location.pathname;
|
||||||
if (
|
if (
|
||||||
!['', '/', '/auth', '/tos', '/privacy-policy'].includes(pathname) &&
|
!['', '/', '/auth', '/tos', '/privacy-policy', '/register', '/register2'].includes(pathname) &&
|
||||||
!pathname.startsWith('/purchasing') &&
|
!pathname.startsWith('/purchasing') &&
|
||||||
!pathname.startsWith('/oauth/')
|
!pathname.startsWith('/oauth/')
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"next": "^15.1.4",
|
"next": "15.3.6",
|
||||||
"next-intl": "^3.26.3",
|
"next-intl": "^3.26.3",
|
||||||
"next-runtime-env": "^3.2.2",
|
"next-runtime-env": "^3.2.2",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"framer-motion": "^11.16.1",
|
"framer-motion": "^11.16.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "^15.1.4",
|
"next": "15.3.6",
|
||||||
"next-intl": "^3.26.3",
|
"next-intl": "^3.26.3",
|
||||||
"next-runtime-env": "^3.2.2",
|
"next-runtime-env": "^3.2.2",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
@@ -716,27 +716,27 @@
|
|||||||
|
|
||||||
"@netlify/plugin-nextjs": ["@netlify/plugin-nextjs@5.12.1", "", {}, "sha512-b2Ic9NkNnnh0lKC/YWDZ2+HdLd/uYdBzLvLKYOkPyFt8KEszoC+Je3GRcwBeOLxaNtK8lji7YPIjtGz8K2sLVQ=="],
|
"@netlify/plugin-nextjs": ["@netlify/plugin-nextjs@5.12.1", "", {}, "sha512-b2Ic9NkNnnh0lKC/YWDZ2+HdLd/uYdBzLvLKYOkPyFt8KEszoC+Je3GRcwBeOLxaNtK8lji7YPIjtGz8K2sLVQ=="],
|
||||||
|
|
||||||
"@next/env": ["@next/env@15.5.2", "", {}, "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg=="],
|
"@next/env": ["@next/env@15.3.6", "", {}, "sha512-/cK+QPcfRbDZxmI/uckT4lu9pHCfRIPBLqy88MhE+7Vg5hKrEYc333Ae76dn/cw2FBP2bR/GoK/4DU+U7by/Nw=="],
|
||||||
|
|
||||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.2", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-lkLrRVxcftuOsJNhWatf1P2hNVfh98k/omQHrCEPPriUypR6RcS13IvLdIrEvkm9AH2Nu2YpR5vLqBuy6twH3Q=="],
|
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.2", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-lkLrRVxcftuOsJNhWatf1P2hNVfh98k/omQHrCEPPriUypR6RcS13IvLdIrEvkm9AH2Nu2YpR5vLqBuy6twH3Q=="],
|
||||||
|
|
||||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ=="],
|
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w=="],
|
||||||
|
|
||||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ=="],
|
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA=="],
|
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g=="],
|
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q=="],
|
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g=="],
|
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg=="],
|
||||||
|
|
||||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg=="],
|
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ=="],
|
||||||
|
|
||||||
"@next/swc-win32-ia32-msvc": ["@next/swc-win32-ia32-msvc@14.2.32", "", { "os": "win32", "cpu": "ia32" }, "sha512-jHUeDPVHrgFltqoAqDB6g6OStNnFxnc7Aks3p0KE0FbwAvRg6qWKYF5mSTdCTxA3axoSAUwxYdILzXJfUwlHhA=="],
|
"@next/swc-win32-ia32-msvc": ["@next/swc-win32-ia32-msvc@14.2.32", "", { "os": "win32", "cpu": "ia32" }, "sha512-jHUeDPVHrgFltqoAqDB6g6OStNnFxnc7Aks3p0KE0FbwAvRg6qWKYF5mSTdCTxA3axoSAUwxYdILzXJfUwlHhA=="],
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q=="],
|
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
@@ -2440,7 +2440,7 @@
|
|||||||
|
|
||||||
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
|
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
|
||||||
|
|
||||||
"next": ["next@15.5.2", "", { "dependencies": { "@next/env": "15.5.2", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.2", "@next/swc-darwin-x64": "15.5.2", "@next/swc-linux-arm64-gnu": "15.5.2", "@next/swc-linux-arm64-musl": "15.5.2", "@next/swc-linux-x64-gnu": "15.5.2", "@next/swc-linux-x64-musl": "15.5.2", "@next/swc-win32-arm64-msvc": "15.5.2", "@next/swc-win32-x64-msvc": "15.5.2", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q=="],
|
"next": ["next@15.3.6", "", { "dependencies": { "@next/env": "15.3.6", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.5", "@next/swc-darwin-x64": "15.3.5", "@next/swc-linux-arm64-gnu": "15.3.5", "@next/swc-linux-arm64-musl": "15.3.5", "@next/swc-linux-x64-gnu": "15.3.5", "@next/swc-linux-x64-musl": "15.3.5", "@next/swc-win32-arm64-msvc": "15.3.5", "@next/swc-win32-x64-msvc": "15.3.5", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w=="],
|
||||||
|
|
||||||
"next-intl": ["next-intl@3.26.5", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "negotiator": "^1.0.0", "use-intl": "^3.26.5" }, "peerDependencies": { "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg=="],
|
"next-intl": ["next-intl@3.26.5", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "negotiator": "^1.0.0", "use-intl": "^3.26.5" }, "peerDependencies": { "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg=="],
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ FROM oven/bun:latest AS base
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create a non-root user for running the production application
|
# Create a non-root user for running the production application
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
RUN groupadd -r -g 1001 nodejs && \
|
||||||
&& adduser --system --uid 1001 nextjs
|
useradd -r -u 1001 -g nodejs -d /home/nextjs -m nextjs
|
||||||
|
|
||||||
# Change to non-root user
|
# Change to non-root user
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ FROM oven/bun:latest AS base
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create non-root user and set permissions
|
# Create non-root user and set permissions
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
RUN groupadd -r -g 1001 nodejs && \
|
||||||
&& adduser --system --uid 1001 nextjs
|
useradd -r -u 1001 -g nodejs -d /home/nextjs -m nextjs
|
||||||
|
|
||||||
# Copy build output and static files
|
# Copy build output and static files
|
||||||
COPY ./apps/user/.next/standalone ./
|
COPY ./apps/user/.next/standalone ./
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ppanel-web",
|
"name": "ppanel-web",
|
||||||
"version": "1.3.0",
|
"version": "1.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://github.com/perfect-panel/ppanel-web",
|
"homepage": "https://github.com/perfect-panel/ppanel-web",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ const buttonVariants = cva(
|
|||||||
primary: 'bg-[#225BA9] text-primary-foreground shadow hover:bg-[#0F2C53] ',
|
primary: 'bg-[#225BA9] text-primary-foreground shadow hover:bg-[#0F2C53] ',
|
||||||
danger: 'bg-[#FF4248] text-primary-foreground shadow hover:bg-[#E22C2E]',
|
danger: 'bg-[#FF4248] text-primary-foreground shadow hover:bg-[#E22C2E]',
|
||||||
dangerLink: 'text-[#E22C2E] ',
|
dangerLink: 'text-[#E22C2E] ',
|
||||||
primaryBlue: 'bg-[#A8D4ED] text-primary-foreground hover:bg-[#225BA9]',
|
primaryBlue:
|
||||||
|
'bg-[#B5C9E2] text-[#225BA9] hover:bg-[#225BA9] hover:text-primary-foreground ',
|
||||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
outline:
|
outline:
|
||||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function DatePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover modal>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
|
|||||||
Reference in New Issue
Block a user