Compare commits

137 Commits

Author SHA1 Message Date
sepakeloudest e6526b77aa fix: 更新苹果账号
CI / build (20.15.1) (push) Successful in 25m29s
2026-01-28 20:59:05 -08:00
sepakeloudest 780dbe075b fix: 获取用户已有订阅,如果有就续费,否则使用新购
CI / build (20.15.1) (push) Successful in 17m33s
2025-12-30 22:25:44 -08:00
sepakeloudest edf961eb7b feat: 更新免费账号
CI / build (20.15.1) (push) Successful in 18m29s
2025-12-29 08:45:58 -08:00
sepakeloudest 285f4d07bc feat: 增加register2页面
CI / build (20.15.1) (push) Successful in 21m30s
2025-12-11 06:34:10 -08:00
sepakeloudest f7fa3a46d2 feat: 增加register2页面
CI / build (20.15.1) (push) Has been cancelled
2025-12-11 06:31:19 -08:00
sepakeloudest b0c8cfb024 feat: 去除配置项等待
CI / build (20.15.1) (push) Failing after 55s
2025-12-10 04:41:15 -08:00
sepakeloudest f86e3b4725 feat: 增加线路优化注册功能
CI / build (20.15.1) (push) Successful in 21m1s
2025-12-09 21:53:52 -08:00
shanshanzhong147 c6a825c284 chore: 更新版本号至1.3.2
CI / build (20.15.1) (push) Has been cancelled
2025-12-09 21:45:12 -08:00
sepakeloudest c79de38d54 feat: 增加线路优化注册功能
CI / build (20.15.1) (push) Successful in 21m47s
2025-12-09 21:10:05 -08:00
shanshanzhong147 a5b4302ab7 fix(docker): 移除Dockerfile中的hosts配置并添加到docker run命令
CI / build (20.15.1) (push) Failing after 23m22s
将hosts配置从Dockerfile中移除,改为在docker run命令中使用--add-host参数添加
2025-12-07 09:11:56 -08:00
shanshanzhong147 162f8fe040 fix(docker): 添加api.airoport.co的主机映射到容器hosts文件
CI / build (20.15.1) (push) Failing after 22m40s
2025-12-07 08:28:37 -08:00
shanshanzhong147 2e4540095b fix(ci): 修复版本检测为空时未回退到根目录版本的问题
CI / build (20.15.1) (push) Failing after 24m4s
当检测到版本为空时,回退到根目录的package.json中获取版本号
2025-12-07 06:18:56 -08:00
shanshanzhong147 42cf06c373 fix(docker): 修正Dockerfile中groupadd命令的语法错误
CI / build (20.15.1) (push) Failing after 15m38s
修复addgroup命令错误使用的问题,改用正确的groupadd命令
2025-12-07 05:41:36 -08:00
shanshanzhong147 97187806e1 refactor(docker): 优化Dockerfile中的用户和组创建命令
CI / build (20.15.1) (push) Failing after 23m15s
使用更简洁的useradd和addgroup命令替代原有写法,保持功能不变但提高可读性
2025-12-07 04:34:21 -08:00
sepakeloudest d92202b6ab fix: 升级安全版本并锁定15.3.6,修复指南https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components
CI / build (20.15.1) (push) Failing after 21m0s
2025-12-06 19:37:14 -08:00
shanshanzhong147 4ae7ed783f ci(docker): 更新Docker配置和安装流程
CI / build (20.15.1) (push) Failing after 26m3s
- 移除apps中package.json的version字段
- 添加DOCKER_API_VERSION环境变量
- 改进Docker安装流程,使用官方源安装新版CLI和Buildx插件
2025-11-13 19:14:39 -08:00
sepakeloudest 72bf4fee7c fix: admin提交样式
CI / build (20.15.1) (push) Failing after 21m15s
2025-10-07 00:51:16 -07:00
shanshanzhong147 daacab613a ci(docker): 更新版本号并改进版本读取逻辑
CI / build (20.15.1) (push) Failing after 24m23s
更新admin和user应用的package.json版本号为1.3.1
改进docker.yml工作流,根据BUILD_TARGET动态读取对应应用的版本号
2025-10-06 00:21:58 -07:00
shanshanzhong147 52b62694c3 merge: 合并 main 分支到 dev,保留动态 SSH 配置
CI / build (20.15.1) (push) Has been cancelled
2025-10-06 00:12:56 -07:00
shanshanzhong147 36419f9bf2 feat: 根据分支动态选择SSH参数 2025-10-06 00:10:54 -07:00
sepakeloudest 5576135726 fix: admin提交样式
CI / build (20.15.1) (push) Failing after 3s
2025-10-05 08:36:26 -07:00
sepakeloudest 02da429358 fix: 提交样式
CI / build (20.15.1) (push) Failing after 4s
2025-10-05 08:23:07 -07:00
shanshanzhong147 71d768bd9e 更新用户面板界面 [user-only]
CI / build (20.15.1) (push) Successful in 21m27s
2025-09-28 20:31:17 -07:00
shanshanzhong147 3b7c8bbcb2 更新管理面板界面 [admin-only]
CI / build (20.15.1) (push) Has been cancelled
2025-09-28 20:15:38 -07:00
shanshanzhong147 ccfd5be59f tg
CI / build (20.15.1) (push) Has been cancelled
2025-09-28 19:48:57 -07:00
shanshanzhong147 96aa507f77 cicd
CI / build (20.15.1) (push) Failing after 25m32s
2025-09-28 18:06:33 -07:00
shanshanzhong147 95f614a51e x
CI / build (20.15.1) (push) Failing after 28m55s
2025-09-27 10:52:48 -07:00
shanshanzhong147 bd4091524e in1
CI / build (20.15.1) (push) Failing after 24m12s
2025-09-26 18:50:38 -07:00
shanshanzhong147 5b7f778551 in1
CI / build (20.15.1) (push) Successful in 30m33s
2025-09-26 17:33:59 -07:00
shanshanzhong147 38f64f770d in1
CI / build (20.15.1) (push) Failing after 23m2s
2025-09-26 09:48:00 -07:00
shanshanzhong147 2ee249f6f7 in1
CI / build (20.15.1) (push) Failing after 24m30s
2025-09-26 07:22:05 -07:00
shanshanzhong147 e3698a8a60 in1 2025-09-26 07:21:51 -07:00
shanshanzhong147 618ac0bf8c in1
CI / build (20.15.1) (push) Failing after 1m6s
2025-09-26 07:14:26 -07:00
shanshanzhong147 0f58c4ef7c in1
CI / build (20.15.1) (push) Failing after 23m22s
2025-09-26 06:47:11 -07:00
shanshanzhong147 92e298ee4e in1
CI / build (20.15.1) (push) Failing after 23m4s
2025-09-26 05:45:09 -07:00
shanshanzhong147 3df33ae4ce inini
CI / build (20.15.1) (push) Failing after 4m31s
2025-09-26 05:03:18 -07:00
shanshanzhong147 fd711a22c8 tg
CI / build (20.15.1) (push) Has been cancelled
2025-09-26 04:58:24 -07:00
shanshanzhong147 d81cdfe789 tg
CI / build (20.15.1) (push) Failing after 23m29s
2025-09-26 03:33:53 -07:00
shanshanzhong147 fd8b07b0d8 tg
CI / build (20.15.1) (push) Failing after 1m42s
2025-09-26 02:37:06 -07:00
shanshanzhong147 394727ccdb tg
CI / build (20.15.1) (push) Failing after 21m21s
2025-09-26 02:14:40 -07:00
shanshanzhong147 993c6d8870 tg 2025-09-26 02:14:33 -07:00
shanshanzhong147 f35eb48480 ci11
CI / build (20.15.1) (push) Failing after 21m52s
2025-09-26 01:49:44 -07:00
shanshanzhong147 8b25de05dc ci11
CI / build (20.15.1) (push) Failing after 33s
2025-09-26 01:38:57 -07:00
shanshanzhong147 afba56c177 i9
CI / build (20.15.1) (push) Failing after 1m56s
2025-09-25 11:44:51 -07:00
shanshanzhong147 d37e2abf0d i9 2025-09-25 11:43:59 -07:00
shanshanzhong147 71dbb921ff i9 2025-09-25 11:28:51 -07:00
shanshanzhong147 a38bb5d38a i8
CI / build (20.15.1) (push) Failing after 18m20s
2025-09-25 11:24:18 -07:00
shanshanzhong147 94c515b2de i7
CI / build (20.15.1) (push) Has been cancelled
2025-09-25 11:17:05 -07:00
shanshanzhong147 61b299ed15 i7
CI / build (20.15.1) (push) Has been cancelled
2025-09-25 11:15:12 -07:00
shanshanzhong147 94004069cb i5
CI / build (20.15.1) (push) Has been cancelled
2025-09-25 11:01:23 -07:00
shanshanzhong147 1c85a4abef i5
CI / build (20.15.1) (push) Failing after 18m47s
2025-09-25 10:19:47 -07:00
shanshanzhong147 7d39166ed5 i4
CI / build (20.15.1) (push) Has been cancelled
2025-09-25 10:18:59 -07:00
shanshanzhong147 ff9401c24e i3
CI / build (20.15.1) (push) Failing after 17m29s
2025-09-25 10:01:17 -07:00
shanshanzhong147 4181e4bd71 i2
CI / build (20.15.1) (push) Failing after 2m17s
2025-09-25 09:54:43 -07:00
shanshanzhong147 5be2e91d0a i1
CI / build (20.15.1) (push) Has been cancelled
2025-09-25 09:53:01 -07:00
shanshanzhong147 4745f230b2 init
CI / build (20.15.1) (push) Failing after 17m48s
2025-09-25 09:35:56 -07:00
shanshanzhong147 bad2843bfb init 2025-09-25 09:34:46 -07:00
shanshanzhong147 cc2f715e77 init 2025-09-25 09:32:47 -07:00
shanshanzhong147 6df5f6a8db init 2025-09-25 09:30:17 -07:00
shanshanzhong147 6524911c31 init 2025-09-25 09:29:54 -07:00
shanshanzhong147 fcac29b7b0 oo2 2025-09-25 09:28:58 -07:00
shanshanzhong147 2b24a66dbc oo1 2025-09-25 09:27:18 -07:00
shanshanzhong147 92487eac92 11 2025-09-25 09:16:23 -07:00
shanshanzhong147 c1ab737894 c 2025-09-25 09:13:54 -07:00
shanshanzhong147 3fdd504d01 i1 2025-09-25 09:11:44 -07:00
shanshanzhong147 98a9fc041f init02 2025-09-25 09:10:04 -07:00
shanshanzhong147 006886751f ci(build:admin) 2025-09-25 09:06:10 -07:00
shanshanzhong147 832e55363b ci(build:admin) 2025-09-25 09:02:08 -07:00
shanshanzhong147 ea16dbb91d tg 2025-09-25 08:58:54 -07:00
shanshanzhong147 fcfd2a9a9f init01 2025-09-25 08:55:12 -07:00
shanshanzhong147 02fc54df21 init01 2025-09-25 08:36:23 -07:00
shanshanzhong147 f3113d787d init01 2025-09-25 08:31:59 -07:00
shanshanzhong147 a607748df0 init01 2025-09-25 08:21:38 -07:00
shanshanzhong147 731d64b26d init01 2025-09-25 08:16:56 -07:00
shanshanzhong147 b6a2c13f72 init01 2025-09-25 08:14:59 -07:00
shanshanzhong147 3dcd4b0581 init01 2025-09-25 08:07:36 -07:00
sepakeloudest 330a8c128b fix: 样式修改
Publish Release Assets / Publish Release Assets (push) Has been cancelled
2025-09-25 06:16:07 -07:00
shanshanzhong147 8ec2f9f097 init 2025-09-25 06:04:01 -07:00
shanshanzhong147 bbf27538b2 init 2025-09-25 05:53:33 -07:00
shanshanzhong147 56cf0e1de0 ini 2025-09-25 05:13:29 -07:00
shanshanzhong147 83476efe63 ini 2025-09-25 05:11:03 -07:00
shanshanzhong147 49359845c0 main 2025-09-25 04:57:54 -07:00
shanshanzhong147 170c42cb52 main 2025-09-25 04:47:45 -07:00
shanshanzhong147 67a427af49 main 2025-09-25 04:43:02 -07:00
shanshanzhong147 e65a2ded46 main 2025-09-25 04:36:19 -07:00
shanshanzhong147 de8761846a main 2025-09-25 04:24:47 -07:00
shanshanzhong147 b0ecc0a514 main 2025-09-25 04:20:43 -07:00
shanshanzhong147 3d404dc6ab main 2025-09-25 04:17:57 -07:00
shanshanzhong147 f22be971a5 init 2025-09-25 04:12:01 -07:00
shanshanzhong147 e5da524504 init 2025-09-25 04:05:52 -07:00
shanshanzhong147 2bc3fd541a init 2025-09-25 04:05:09 -07:00
shanshanzhong147 82aec50e7a init 2025-09-25 04:03:29 -07:00
shanshanzhong147 314406cd41 ci 2025-09-25 03:47:46 -07:00
shanshanzhong147 13635b2d9d ci: 移除pnpm action setup的注释链接 2025-09-25 03:35:44 -07:00
shanshanzhong147 3eb4d5ff80 ci 2025-09-25 03:34:55 -07:00
shanshanzhong147 601425c783 ci 2025-09-25 03:34:09 -07:00
shanshanzhong147 cb705c0150 ci 2025-09-25 03:24:17 -07:00
shanshanzhong147 419b3c8625 ci 2025-09-25 03:18:40 -07:00
shanshanzhong147 b5a460e440 ci 2025-09-25 03:16:43 -07:00
shanshanzhong147 109e5ec119 ci 2025-09-25 03:12:23 -07:00
shanshanzhong147 22f5197417 ci 2025-09-25 03:03:46 -07:00
shanshanzhong147 854273f6a4 ci 2025-09-25 02:59:51 -07:00
shanshanzhong147 fad0450fd1 ci 2025-09-25 02:58:43 -07:00
shanshanzhong147 3ce7e7cee0 ci 2025-09-25 02:54:28 -07:00
shanshanzhong147 5637b002a5 ci 2025-09-25 02:50:29 -07:00
shanshanzhong147 eed624e491 ci 2025-09-25 02:49:29 -07:00
shanshanzhong147 ed9cbde7be ci 2025-09-25 02:43:52 -07:00
sepakeloudest e020a53020 fix: 国际化 2025-09-25 02:41:08 -07:00
shanshanzhong147 0db0f40ce3 ci 2025-09-25 02:37:59 -07:00
sepakeloudest 8aa9b5694a fix: 增加默认状态 2025-09-25 02:32:17 -07:00
shanshanzhong147 dabe5fbb2c ci 2025-09-25 02:29:14 -07:00
shanshanzhong147 61fa7be47d ci 2025-09-25 02:19:18 -07:00
shanshanzhong147 849a7219df ci 2025-09-25 02:17:57 -07:00
shanshanzhong147 c6f47e0e10 ci 2025-09-25 02:12:56 -07:00
shanshanzhong147 dbaee3d858 ci 2025-09-25 01:59:37 -07:00
shanshanzhong147 8ed24ed44b ci 2025-09-25 01:52:12 -07:00
shanshanzhong147 3d7e13cbc2 ci 2025-09-25 01:44:24 -07:00
shanshanzhong147 db0cd8167d ci 2025-09-25 01:42:01 -07:00
shanshanzhong147 611602cfef ci 2025-09-24 23:42:11 -07:00
sepakeloudest 804af6b1fc fix: 修改下载路径 2025-09-24 22:45:23 -07:00
shanshanzhong147 67eec9ebb4 ci 2025-09-24 21:32:19 -07:00
shanshanzhong147 6c1953a831 ci 2025-09-24 21:28:07 -07:00
shanshanzhong147 df01c08734 ci 2025-09-24 21:18:09 -07:00
sepakeloudest f3d2590a44 fix: 修改下载路径 2025-09-24 20:29:28 -07:00
shanshanzhong147 23385ce3ac ci 2025-09-24 19:44:46 -07:00
shanshanzhong147 a65c46cb46 ci 2025-09-24 19:32:34 -07:00
shanshanzhong147 9f716728fa ci 2025-09-24 19:30:52 -07:00
shanshanzhong147 0fba9eac06 ci 2025-09-24 19:20:14 -07:00
shanshanzhong147 b0b58333a4 ci 2025-09-24 19:16:26 -07:00
shanshanzhong147 df79d6f74e ci 2025-09-24 19:13:51 -07:00
shanshanzhong147 cf66053337 ci 2025-09-24 19:09:07 -07:00
shanshanzhong147 7cedf4fba3 ci 2025-09-24 19:08:19 -07:00
sepakeloudest 09084eef21 feat: 新版样式 2025-09-24 07:26:08 -07:00
sepakeloudest 8c7d415ae7 feat: 弹窗兼容修改 2025-09-23 06:19:12 -07:00
sepakeloudest b6c82e9bc9 feat: 修改图标 2025-09-19 03:28:59 -07:00
sepakeloudest 6c40fde269 feat: 佣金提现和备注功能 2025-09-16 04:51:27 -07:00
sepakeloudest 5db1e8b15a feat: 佣金提现和备注功能 2025-09-15 20:31:10 -07:00
45 changed files with 2649 additions and 318 deletions
+764 -120
View File
@@ -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
# 设置缓存可用性标志
CACHE_AVAILABLE=true
# 测试GitHub Actions缓存API
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: Upload tar.gz file to release - name: 缓存降级提示
if: ${{ gitea.event_name == 'release' }} if: env.CACHE_AVAILABLE == 'false'
uses: softprops/action-gh-release@v2 run: |
with: echo "🔄 缓存服务不可用,构建将在无缓存模式下进行"
files: | echo "⏱️ 这可能会增加构建时间,但不会影响构建结果"
out/ppanel-admin-web.tar.gz echo "📦 所有依赖将重新下载和安装"
out/ppanel-user-web.tar.gz
token: ${{ secrets.GITEA_TOKEN }}
- name: Set up QEMU - name: Install system tools (jq, docker, curl)
uses: docker/setup-qemu-action@v3 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"
# 设置Bun安装路径
export BUN_INSTALL="$HOME/.bun"
echo "BUN_INSTALL=$BUN_INSTALL" >> $GITHUB_ENV
echo "PATH=$BUN_INSTALL/bin:${PATH}" >> $GITHUB_ENV
# 检查缓存是否存在
if [ -d "$BUN_INSTALL" ]; then
echo "✅ Bun cache found at $BUN_INSTALL"
ls -la "$BUN_INSTALL" || true
else
echo "❌ No Bun cache found, will install fresh"
fi
# 安装Bun
curl -fsSL https://bun.sh/install | bash
# 验证安装
"$BUN_INSTALL/bin/bun" --version
echo "✅ Bun installed successfully"
- name: Install jq - name: Configure npm registry (npmmirror) and canvas mirror
run: sudo apt-get install -y jq run: |
echo "registry=https://registry.npmmirror.com" >> .npmrc
echo "canvas_binary_host_mirror=https://registry.npmmirror.com/-/binary/canvas" >> .npmrc
- name: Extract version from package.json - name: Install dependencies cache (Bun)
id: version if: env.CACHE_AVAILABLE == 'true'
run: echo "PPANEL_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_ENV uses: actions/cache@v4
continue-on-error: true
- name: Build and push Docker image for ppanel-admin-web
uses: docker/build-push-action@v6
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 }}
⚠️ 请检查构建日志获取详细信息
+91
View File
@@ -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}"
+1 -1
View File
@@ -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
View File
@@ -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=
+1 -1
View File
@@ -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)
· ·
+12 -5
View File
@@ -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'
+3 -1
View File
@@ -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} />
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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>
);
}
+111
View File
@@ -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;
}
+147
View File
@@ -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>
</>
);
}
+218
View File
@@ -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'>
&nbsp;
<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>
</>
);
}
+174
View File
@@ -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')}&nbsp;
<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>
</>
);
}
+111
View File
@@ -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;
}
+147
View File
@@ -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>
</>
);
}
+231
View File
@@ -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'>
&nbsp;
<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>
</>
);
}
+174
View File
@@ -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')}&nbsp;
<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>
</>
);
}
+2 -4
View File
@@ -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}
> >
+25
View File
@@ -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;
+1 -1
View File
@@ -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(() => {
+19 -2
View File
@@ -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();
+8
View File
@@ -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",
+8
View File
@@ -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": "总流量",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/')
) { ) {
+12 -12
View File
@@ -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=="],
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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'