Compare commits

..

42 Commits

Author SHA1 Message Date
shanshanzhong147 e696e2f551 ci(workflow): 添加Telegram通知功能用于部署状态通知
site-dist-deploy / build-and-deploy (push) Successful in 55s
2026-01-04 21:57:47 -08:00
shanshanzhong147 bbe25b027f fix(ci): use move-delete-restore strategy to safely preserve download folders 2026-01-04 21:54:38 -08:00
shanshanzhong147 3b22c583b7 fix(ci): preserve download folder and remove docker dependency
site-dist-deploy / build-and-deploy (push) Successful in 2m10s
2026-01-04 21:49:11 -08:00
shanshanzhong147 f2d0bbf36b Merge remote-tracking branch 'origin/main' into develop
site-dist-deploy / build-and-deploy (push) Has been cancelled
2026-01-04 21:42:51 -08:00
sepakeloudest 97c2832e89 下载 2026-01-04 21:18:14 -08:00
sepakeloudest 9d43162830 下载 2026-01-04 21:04:03 -08:00
sepakeloudest 530df9ce09 增加客服 2026-01-04 05:07:26 -08:00
sepakeloudest 9f2264514d 增加隐私协议和政策 2026-01-04 04:41:07 -08:00
sepakeloudest 57d7b49aa9 增加隐私协议和政策 2026-01-04 04:37:45 -08:00
sepakeloudest 8e7434f67c 增加隐私协议和政策 2026-01-04 04:28:58 -08:00
shanshanzhong147 e15366d5f7 fix: support multiple package managers for sshpass installation
site-dist-deploy / build-and-deploy (push) Successful in 1m33s
2026-01-04 04:12:38 -08:00
shanshanzhong147 517d07caaf fix: replace scp-action with native sshpass/scp to resolve file access issues
site-dist-deploy / build-and-deploy (push) Failing after 1m25s
2026-01-04 04:06:28 -08:00
shanshanzhong147 3a8f171f19 chore: enable scp debug and enhanced file listing
site-dist-deploy / build-and-deploy (push) Failing after 2m13s
2026-01-04 03:52:31 -08:00
shanshanzhong147 1c53fc4fe4 chore: add debug steps for scp failure
site-dist-deploy / build-and-deploy (push) Failing after 2m10s
2026-01-04 03:34:54 -08:00
shanshanzhong147 1c0511d44e fix: use explicit relative path for scp source
site-dist-deploy / build-and-deploy (push) Failing after 2m9s
2026-01-04 03:23:58 -08:00
shanshanzhong147 df759063f2 fix: resolve case sensitivity issue in image path
site-dist-deploy / build-and-deploy (push) Failing after 1m2s
2026-01-04 03:21:32 -08:00
shanshanzhong147 8a0359fd7e fix(ci): use unified build script with bun
site-dist-deploy / build-and-deploy (push) Failing after 1m59s
2026-01-04 03:18:13 -08:00
shanshanzhong147 ae9f9063a9 refactor(ci): 将复杂构建逻辑提取到独立脚本中
site-dist-deploy / build-and-deploy (push) Failing after 3s
重构 CI 构建流程,将原有的复杂构建逻辑提取到独立的 ci-build.sh 脚本中
简化 docker.yml 工作流文件,提高可维护性和复用性
2026-01-04 03:01:23 -08:00
sepakeloudest d8e29af1dc 增加教程样式 2026-01-04 01:38:25 -08:00
shanshanzhong147 69c037bf51 ci(docker): 优化节点安装流程并添加 musl 支持
site-dist-deploy / build-and-deploy (push) Failing after 30m37s
添加 DEBIAN_FRONTEND=noninteractive 以避免 apt 安装交互问题
预装编译依赖确保 nvm 安装成功
添加 musl libc 检测以支持不同环境的节点安装
优化节点目录查找逻辑
2026-01-04 01:15:43 -08:00
shanshanzhong147 be99c7acfa ci(docker): 优化Node安装流程并添加nvm作为备选方案
site-dist-deploy / build-and-deploy (push) Failing after 30s
2026-01-04 01:13:36 -08:00
shanshanzhong147 c5062278a3 ci(docker): 优化构建流程,使用官方Node二进制替代nvm安装
site-dist-deploy / build-and-deploy (push) Failing after 9s
重构Docker工作流中的Node安装步骤,将nvm安装方式替换为直接下载官方二进制包。
新增通用包安装函数支持多种包管理器,并确保构建环境具备必要工具。
2026-01-04 01:08:10 -08:00
shanshanzhong147 8888382067 ci(docker): 确保构建环境中curl可用
site-dist-deploy / build-and-deploy (push) Failing after 26s
添加对curl的检查与安装逻辑,以支持后续nvm安装脚本的执行
2026-01-04 01:00:16 -08:00
shanshanzhong147 d687e47fdc ci(docker): 使用nvm替代直接运行node容器构建
site-dist-deploy / build-and-deploy (push) Failing after 2s
修改docker构建流程,通过nvm安装并使用node 20,替代直接运行node容器的方式
2026-01-04 00:56:14 -08:00
shanshanzhong147 5082885ace ci(workflow): 替换默认的检出操作为手动实现
site-dist-deploy / build-and-deploy (push) Failing after 1s
使用手动实现的git命令替换actions/checkout,以避免Node.js依赖
2026-01-04 00:54:05 -08:00
shanshanzhong147 929e405aab ci: 添加部署站点的Gitea工作流配置
site-dist-deploy / build-and-deploy (push) Failing after 14s
2026-01-04 00:52:28 -08:00
shanshanzhong147 31c1ecb612 Merge remote-tracking branch 'origin/main' into develop 2026-01-04 00:44:37 -08:00
sepakeloudest e38904422d 修改样式 2026-01-04 00:14:09 -08:00
sepakeloudest acd0c94e60 桌面端样式 2026-01-03 23:56:40 -08:00
sepakeloudest f9262b3754 提交交互修改 2026-01-03 20:42:47 -08:00
sepakeloudest 4fc0b21c9c 删除解密 2026-01-03 01:21:25 -08:00
sepakeloudest ffbed8c72d 下载链接 2026-01-02 21:47:56 -08:00
sepakeloudest f6b306c823 下载链接 2026-01-02 21:12:09 -08:00
sepakeloudest ed3b96158f 细节优化 2026-01-02 19:34:12 -08:00
sepakeloudest f9d55161e6 细节优化 2025-12-31 07:37:03 -08:00
sepakeloudest ecd6cf1c94 细节优化 2025-12-31 07:26:24 -08:00
sepakeloudest 107d950771 骨架屏细节 2025-12-31 07:12:53 -08:00
sepakeloudest 4d153b4149 优化样式 2025-12-31 06:47:39 -08:00
sepakeloudest 2b0741fdb9 登录对接 2025-12-31 06:39:11 -08:00
sepakeloudest 033dda7744 登录对接 2025-12-31 02:36:33 -08:00
sepakeloudest 259d5a416c 域名 2025-12-30 22:59:29 -08:00
sepakeloudest f753721492 域名 2025-12-30 22:57:51 -08:00
71 changed files with 6911 additions and 324 deletions
+2
View File
@@ -0,0 +1,2 @@
# https://h.hifastapp.com
VITE_APP_BASE_URL=
-1
View File
@@ -1 +0,0 @@
VITE_APP_BASE_URL=
+1
View File
@@ -1 +1,2 @@
https://h.hifastapp.com
VITE_APP_BASE_URL= VITE_APP_BASE_URL=
+196
View File
@@ -0,0 +1,196 @@
name: site-dist-deploy
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
- develop
env:
VITE_APP_BASE_URL: https://h.hifast.biz
SSH_HOST: ${{ vars.PRO_SSH_HOST }}
SSH_PORT: ${{ vars.PRO_SSH_PORT }}
SSH_USER: ${{ vars.PRO_SSH_USER }}
SSH_PASSWORD: ${{ vars.PRO_SSH_PASSWORD }}
DEPLOY_PATH: /var/www/down
# TG通知
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
TG_CHAT_ID: "-4940243803"
jobs:
build-and-deploy:
runs-on: landing-hero-web01
steps:
- name: Manual checkout (no Node required)
run: |
set -e
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git fetch --all --tags
git checkout "${{ github.ref_name }}"
git reset --hard "origin/${{ github.ref_name }}"
else
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
echo "Cloning $REPO_URL"
git clone --depth=1 --branch "${{ github.ref_name }}" "$REPO_URL" .
git fetch --tags
fi
- name: Build dist with Unified Script
env:
VITE_APP_BASE_URL: "https://h.hifast.biz"
run: |
chmod +x scripts/ci-build.sh
./scripts/ci-build.sh
- name: Check Artifacts
run: |
echo "Current directory: $(pwd)"
echo "Listing all files in workspace:"
find . -maxdepth 2 -not -path '*/.*'
if [ -f "site_dist.tgz" ]; then
echo "✅ File exists: site_dist.tgz"
ls -lh site_dist.tgz
echo "File path: $(readlink -f site_dist.tgz)"
else
echo "❌ File NOT found: site_dist.tgz"
exit 1
fi
- name: Deploy to Host (Native SSH/SCP)
run: |
echo "Installing SSH tools..."
if command -v apk &> /dev/null; then
echo "Detected Alpine Linux. Installing sshpass openssh-client via apk..."
apk add --no-cache sshpass openssh-client
elif command -v apt-get &> /dev/null; then
echo "Detected Debian/Ubuntu. Installing sshpass openssh-client via apt..."
apt-get update -y && apt-get install -y sshpass openssh-client
elif command -v yum &> /dev/null; then
echo "Detected RHEL/CentOS. Installing sshpass openssh-clients via yum..."
yum install -y sshpass openssh-clients
elif command -v dnf &> /dev/null; then
echo "Detected Fedora/RHEL8+. Installing sshpass openssh-clients via dnf..."
dnf install -y sshpass openssh-clients
elif command -v zypper &> /dev/null; then
echo "Detected OpenSUSE. Installing sshpass openssh via zypper..."
zypper install -y sshpass openssh
else
echo "Error: No known package manager found. Cannot install sshpass."
exit 1
fi
echo "Uploading artifact..."
# 使用 sshpass 传递密码 (更安全的方式是使用 key,但此处沿用 password)
export SSHPASS="${{ env.SSH_PASSWORD }}"
# 1. 检查连接并创建目录 (包含 /tmp/ci-upload 和 目标目录的准备)
sshpass -e ssh -o StrictHostKeyChecking=no -p ${{ env.SSH_PORT }} ${{ env.SSH_USER }}@${{ env.SSH_HOST }} "mkdir -p /tmp/ci-upload"
# 2. SCP 上传 (直接使用当前目录下的 site_dist.tgz,规避跨容器挂载问题)
if [ ! -f "site_dist.tgz" ]; then
echo "❌ Error: site_dist.tgz not found in current directory!"
exit 1
fi
sshpass -e scp -o StrictHostKeyChecking=no -P ${{ env.SSH_PORT }} site_dist.tgz ${{ env.SSH_USER }}@${{ env.SSH_HOST }}:/tmp/ci-upload/site_dist.tgz
# 3. 解压并重启 Nginx
echo "Deploying on remote host..."
sshpass -e ssh -o StrictHostKeyChecking=no -p ${{ env.SSH_PORT }} ${{ env.SSH_USER }}@${{ env.SSH_HOST }} "
echo 'Preparing target directory ${{ env.DEPLOY_PATH }}...'
mkdir -p ${{ env.DEPLOY_PATH }}
# 切换到目录,确保操作安全
cd ${{ env.DEPLOY_PATH }} || exit 1
echo 'Cleaning up old files (preserving download/downsload)...'
# 使用更安全的策略:
# 1. 创建临时备份目录
mkdir -p /tmp/site_backup_safe
rm -rf /tmp/site_backup_safe/*
# 2. 将需要保留的文件夹移到备份目录 (如果存在)
if [ -d 'download' ]; then
echo 'Backing up download folder...'
mv download /tmp/site_backup_safe/
fi
if [ -d 'downsload' ]; then
echo 'Backing up downsload folder...'
mv downsload /tmp/site_backup_safe/
fi
# 3. 清空当前目录 (此时 download/downsload 已经移走,安全删除所有)
# 注意:不删除当前目录本身,只删除内容
find . -mindepth 1 -delete
# 4. 移回备份的文件夹
if [ -d '/tmp/site_backup_safe/download' ]; then
echo 'Restoring download folder...'
mv /tmp/site_backup_safe/download .
fi
if [ -d '/tmp/site_backup_safe/downsload' ]; then
echo 'Restoring downsload folder...'
mv /tmp/site_backup_safe/downsload .
fi
# 5. 清理备份目录
rm -rf /tmp/site_backup_safe
echo 'Extracting to ${{ env.DEPLOY_PATH }}...'
# 解压覆盖
tar -xzf /tmp/ci-upload/site_dist.tgz -C ${{ env.DEPLOY_PATH }}
echo 'Reloading Nginx...'
# 尝试多种 reload 方式
nginx -s reload || systemctl reload nginx || echo 'Warning: Nginx reload returned non-zero'
echo 'Cleanup...'
rm -f /tmp/ci-upload/site_dist.tgz
"
echo "✅ Deployment complete!"
# 步骤6: TG通知 (成功)
- name: 📱 发送成功通知到Telegram
if: success()
uses: appleboy/telegram-action@master
with:
token: ${{ env.TG_BOT_TOKEN }}
to: ${{ env.TG_CHAT_ID }}
message: |
✅ 部署成功!
📦 项目: ${{ github.repository }}
🌿 分支: ${{ github.ref_name }}
📝 提交: ${{ github.sha }}
👤 提交者: ${{ github.actor }}
🕐 时间: ${{ github.event.head_commit.timestamp }}
🚀 服务已成功部署到生产环境
parse_mode: Markdown
# 步骤5: TG通知 (失败)
- name: 📱 发送失败通知到Telegram
if: failure()
uses: appleboy/telegram-action@master
with:
token: ${{ env.TG_BOT_TOKEN }}
to: ${{ env.TG_CHAT_ID }}
message: |
❌ 部署失败!
📦 项目: ${{ github.repository }}
🌿 分支: ${{ github.ref_name }}
📝 提交: ${{ github.sha }}
👤 提交者: ${{ github.actor }}
🕐 时间: ${{ github.event.head_commit.timestamp }}
⚠️ 请检查构建日志获取详细信息
parse_mode: Markdown
+790
View File
@@ -0,0 +1,790 @@
name: CI
on:
push:
branches:
- main
- develop
- cicd
pull_request:
branches:
- main
- develop
- cicd
env:
DOMAIN_URL: git.kxsw.us
REPO: ${{ vars.REPO }}
TELEGRAM_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
TELEGRAM_CHAT_ID: "-4940243803"
DOCKER_REGISTRY: registry.kxsw.us
DOCKER_BUILDKIT: 1
DOCKER_API_VERSION: "1.44"
# Host SSH - 根据分支动态选择
SSH_HOST: ${{ github.ref_name == 'main' && vars.PRO_SSH_HOST || (github.ref_name == 'develop' && vars.DEV_SSH_HOST || vars.PRO_SSH_HOST) }}
SSH_PORT: ${{ github.ref_name == 'main' && vars.PRO_SSH_PORT || (github.ref_name == 'develop' && vars.DEV_SSH_PORT || vars.PRO_SSH_PORT) }}
SSH_USER: ${{ github.ref_name == 'main' && vars.PRO_SSH_USER || (github.ref_name == 'dedevelopv' && vars.DEV_SSH_USER || vars.PRO_SSH_USER) }}
SSH_PASSWORD: ${{ github.ref_name == 'main' && vars.PRO_SSH_PASSWORD || (github.ref_name == 'dedevelopv' && vars.DEV_SSH_PASSWORD || vars.PRO_SSH_PASSWORD) }}
jobs:
build:
runs-on: fastvpn-admin01
container:
image: node:20
strategy:
matrix:
# 只有node支持版本号别名
node: ['20.15.1']
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 缓存服务健康检查
id: cache-health
continue-on-error: true
run: |
echo "检查缓存服务可用性..."
# 设置缓存可用性标志
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: 缓存降级提示
if: env.CACHE_AVAILABLE == 'false'
run: |
echo "🔄 缓存服务不可用,构建将在无缓存模式下进行"
echo "⏱️ 这可能会增加构建时间,但不会影响构建结果"
echo "📦 所有依赖将重新下载和安装"
- name: Install system tools (jq, docker, curl)
run: |
set -e
export DEBIAN_FRONTEND=noninteractive
echo "Waiting for apt/dpkg locks (unattended-upgrades) to release..."
# Wait up to 300s for unattended-upgrades/apt/dpkg locks
end=$((SECONDS+300))
while true; do
LOCKS_BUSY=0
# If unattended-upgrades is running, mark busy
if pgrep -x unattended-upgrades >/dev/null 2>&1; then LOCKS_BUSY=1; fi
# If fuser exists, check common lock files
if command -v fuser >/dev/null 2>&1; then
if fuser /var/lib/dpkg/lock >/dev/null 2>&1 \
|| fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
|| fuser /var/lib/apt/lists/lock >/dev/null 2>&1; then
LOCKS_BUSY=1
fi
fi
# Break if not busy
if [ "$LOCKS_BUSY" -eq 0 ]; then break; fi
# Timeout after ~5 minutes
if [ $SECONDS -ge $end ]; then
echo "Timeout waiting for apt/dpkg locks, proceeding with Dpkg::Lock::Timeout..."
break
fi
echo "Still waiting for locks..."; sleep 5
done
apt-get update -y -o Dpkg::Lock::Timeout=600
# 基础工具和GPG
apt-get install -y -o Dpkg::Lock::Timeout=600 jq curl ca-certificates gnupg
# 配置Docker官方源,安装新版CLI与Buildx插件(支持 API 1.44+
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
apt-get update -y -o Dpkg::Lock::Timeout=600
apt-get install -y -o Dpkg::Lock::Timeout=600 docker-ce-cli docker-buildx-plugin
docker --version
jq --version
curl --version
- name: Set up Docker Buildx
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: Configure npm registry (npmmirror) and canvas mirror
run: |
echo "registry=https://registry.npmmirror.com" >> .npmrc
echo "canvas_binary_host_mirror=https://registry.npmmirror.com/-/binary/canvas" >> .npmrc
- name: Install dependencies cache (Bun)
if: env.CACHE_AVAILABLE == 'true'
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.bun
key: bun-${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('bun.lock') }}
restore-keys: |
bun-${{ runner.os }}-${{ matrix.node }}-
bun-${{ runner.os }}-
- name: Install dependencies cache (node_modules)
if: env.CACHE_AVAILABLE == 'true'
uses: actions/cache@v4
continue-on-error: true
with:
path: |
node_modules
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.hifast.biz" >> $GITHUB_ENV
echo "为main分支设置生产环境API地址"
elif [ "${{ github.ref_name }}" = "develop" ]; then
echo "NEXT_PUBLIC_API_URL=https://api.hifast.biz" >> $GITHUB_ENV
echo "为 develop 分支设置开发环境API地址"
else
echo "NEXT_PUBLIC_API_URL=https://api.hifast.biz" >> $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/fastvpn-admin-web:cache \
--cache-to type=registry,ref=${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-admin-web:cache,mode=max \
-f ./docker/ppanel-admin-web/Dockerfile \
-t ${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-admin-web:${{ env.VERSION }} \
--push .
else
echo "使用常规docker构建"
docker build -f ./docker/ppanel-admin-web/Dockerfile -t ${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-admin-web:${{ env.VERSION }} .
docker push ${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-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/fastvpn-user-web:cache \
--cache-to type=registry,ref=${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-user-web:cache,mode=max \
-f ./docker/ppanel-user-web/Dockerfile \
-t ${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-user-web:${{ env.VERSION }} \
--push .
else
echo "使用常规docker构建"
docker build -f ./docker/ppanel-user-web/Dockerfile -t ${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-user-web:${{ env.VERSION }} .
docker push ${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-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/fastvpn-admin-web:${{ env.VERSION }}"
if docker pull ${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-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 fastvpn-admin-web \
--restart unless-stopped \
-p 3001:3000 \
-e NEXT_PUBLIC_API_URL=${{ env.NEXT_PUBLIC_API_URL }} \
${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-admin-web:${{ env.VERSION }}
# 验证容器启动
echo "验证容器启动状态..."
for i in {1..10}; do
if docker ps -q -f name=fastvpn-admin-web | grep -q .; then
echo "✅ 管理面板部署成功"
docker ps -f name=fastvpn-admin-web --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
exit 0
else
echo "等待容器启动... $i/10"
sleep 3
fi
done
echo "❌ 管理面板部署失败 - 容器未能正常启动"
docker logs fastvpn-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 fastvpn-user-web \
--restart unless-stopped \
-p 3002:3000 \
-e NEXT_PUBLIC_API_URL=${{ env.NEXT_PUBLIC_API_URL }} \
${{ env.DOCKER_REGISTRY }}/ppanel/fastvpn-user-web:${{ env.VERSION }}
# 验证容器启动
echo "验证容器启动状态..."
for i in {1..10}; do
if docker ps -q -f name=fastvpn-user-web | grep -q .; then
echo "✅ 用户面板部署成功"
docker ps -f name=fastvpn-user-web --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
exit 0
else
echo "等待容器启动... $i/10"
sleep 3
fi
done
echo "❌ 用户面板部署失败 - 容器未能正常启动"
docker logs fastvpn-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 }}
⚠️ 请检查构建日志获取详细信息
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>HiFast VPN</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+4457
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -21,6 +21,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crisp-sdk-web": "^1.0.27",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"lucide-vue-next": "^0.562.0", "lucide-vue-next": "^0.562.0",
"reka-ui": "^2.7.0", "reka-ui": "^2.7.0",
+8
View File
@@ -23,6 +23,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
crisp-sdk-web:
specifier: ^1.0.27
version: 1.0.27
crypto-js: crypto-js:
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.2.0 version: 4.2.0
@@ -1048,6 +1051,9 @@ packages:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'} engines: {node: '>=18'}
crisp-sdk-web@1.0.27:
resolution: {integrity: sha512-aNWR3te65YiaVFu/iwdqOo3cyUBZHUheE4d6EtgQu/T18jh/9SpoYXjXF/OzUD3Cqy0pGryoqtuy5gxD8tqX9Q==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3047,6 +3053,8 @@ snapshots:
dependencies: dependencies:
is-what: 5.5.0 is-what: 5.5.0
crisp-sdk-web@1.0.27: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
set -e
# ==========================================
# 统一构建脚本 (CI Build Script)
# 功能:自动检测环境、安装 Node.js (不依赖系统预装)、构建项目、打包产物
# 解决:Runner 环境缺失 Node/Docker/Python 等工具导致的问题
# ==========================================
# 配置节点版本
NODE_VERSION="20.10.0"
DIST_FILE="site_dist.tgz"
echo ">>> [Init] Starting CI Build Script..."
# ----------------------------------------------------------------
# 1. 基础工具检测与安装 (curl, tar, xz)
# ----------------------------------------------------------------
ensure_tools() {
echo ">>> [Tools] Checking basic tools..."
local missing_tools=()
for tool in curl tar xz; do
if ! command -v "$tool" &> /dev/null; then
missing_tools+=("$tool")
fi
done
if [ ${#missing_tools[@]} -eq 0 ]; then
echo " All tools present."
return 0
fi
echo " Missing tools: ${missing_tools[*]}. Attempting installation..."
if command -v apk &> /dev/null; then
apk add --no-cache curl tar xz
elif command -v apt-get &> /dev/null; then
apt-get update && apt-get install -y curl tar xz-utils
elif command -v yum &> /dev/null; then
yum install -y curl tar xz
elif command -v dnf &> /dev/null; then
dnf install -y curl tar xz
elif command -v zypper &> /dev/null; then
zypper install -y curl tar xz
else
echo "!!! Error: Cannot install missing tools. No known package manager found."
exit 1
fi
}
# ----------------------------------------------------------------
# 2. 环境安装 (System Node.js - Most Reliable on Alpine)
# ----------------------------------------------------------------
install_env() {
echo ">>> [Env] Setting up System Node.js environment..."
# 尝试使用系统包管理器安装 Node.js 和 npm
if command -v apk &> /dev/null; then
echo " Detected Alpine Linux. Installing nodejs and npm via apk..."
apk add --no-cache nodejs npm
elif command -v apt-get &> /dev/null; then
echo " Detected Debian/Ubuntu. Installing nodejs and npm via apt..."
apt-get update && apt-get install -y nodejs npm
elif command -v yum &> /dev/null; then
yum install -y nodejs npm
elif command -v dnf &> /dev/null; then
dnf install -y nodejs npm
elif command -v zypper &> /dev/null; then
zypper install -y nodejs npm
else
echo "!!! Warning: No package manager found. Checking if Node is pre-installed..."
fi
# 验证安装
if ! command -v node &> /dev/null; then
echo "!!! Error: Node.js not found and could not be installed."
exit 1
fi
if ! command -v npm &> /dev/null; then
echo "!!! Error: npm not found and could not be installed."
exit 1
fi
echo " Node version: $(node -v)"
echo " npm version: $(npm -v)"
}
# ----------------------------------------------------------------
# 3. 项目构建与打包
# ----------------------------------------------------------------
build_project() {
echo ">>> [Build] Starting project build..."
# 注入环境变量
if [ -n "$VITE_APP_BASE_URL" ]; then
echo " Setting VITE_APP_BASE_URL=${VITE_APP_BASE_URL}"
# 兼容 package.json 中的 mode: pord (Typo in original project)
echo "VITE_APP_BASE_URL=${VITE_APP_BASE_URL}" > .env.pord
# 同时写入 .env 以防万一
echo "VITE_APP_BASE_URL=${VITE_APP_BASE_URL}" >> .env
fi
echo " Installing dependencies..."
# 使用 npm install 而不是 npm ci,以避免因 lockfile 版本不匹配或 engines 检查导致的失败
npm install --no-audit --progress=false
echo " Building..."
# 使用 package.json 中定义的 build:prod 命令
npm run build:prod
echo ">>> [Package] Compressing artifacts..."
if [ ! -d "dist" ]; then
echo "!!! Error: 'dist' directory not found after build."
# 列出当前目录以便调试
ls -la
exit 1
fi
# 直接打包 dist 目录下的内容
tar -C dist -czf "$DIST_FILE" .
echo " Success! Artifact created: $DIST_FILE"
ls -lh "$DIST_FILE"
}
# ==========================================
# Main Execution Flow
# ==========================================
ensure_tools
install_env
build_project
+29 -6
View File
@@ -1,9 +1,3 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import 'vue-sonner/style.css'
import { Toaster } from '@/components/ui/sonner'
</script>
<template> <template>
<div> <div>
<main> <main>
@@ -22,4 +16,33 @@ import { Toaster } from '@/components/ui/sonner'
</div> </div>
</template> </template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { onMounted } from 'vue'
import 'vue-sonner/style.css'
import { Toaster } from '@/components/ui/sonner'
import { Crisp } from 'crisp-sdk-web'
const WEBSITE_ID = '47fcc1ac-9674-4ab1-9e3c-6b5666f59a38'
onMounted(() => {
// 定义加载逻辑
const loadCrisp = () => {
console.log('页面资源已就绪,开始初始化 Crisp...')
Crisp.configure(WEBSITE_ID)
// 可选:初始化后自动隐藏或执行其他逻辑
// Crisp.chat.hide();
}
// 如果页面已经加载完成(或者是从其他路由跳转过来的)
if (document.readyState === 'complete') {
loadCrisp()
} else {
// 否则等待 window load 事件,确保图片、CSS等资源全部加载完毕
window.addEventListener('load', loadCrisp, { once: true })
}
})
</script>
<style scoped></style> <style scoped></style>
+4 -1
View File
@@ -1,5 +1,7 @@
<template> <template>
<div class="grid min-h-[76px] grid-cols-2 gap-3"> <div class="device-list-container">
<div v-if="!devices?.length" class="w-full text-center">暂无绑定设备</div>
<div v-else class="grid min-h-[76px] grid-cols-2 gap-3">
<div <div
v-for="device in devices" v-for="device in devices"
:key="device.id" :key="device.id"
@@ -21,6 +23,7 @@
<X :size="16" class="absolute top-[5px] right-[10px] text-[12px]" /> <X :size="16" class="absolute top-[5px] right-[10px] text-[12px]" />
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -0,0 +1,191 @@
<template>
<Dialog :open="open" @update:open="handleClose">
<DialogContent
:show-close-button="false"
@pointer-down-outside="(event) => event.preventDefault()"
@focus-outside="(event) => event.preventDefault()"
class="max-w-[345px] rounded-[32px] border-none bg-[#E5E5E5] p-6"
>
<div class="flex flex-col items-center py-6">
<h2 class="mb-1 text-xl font-semibold text-black">订单状态</h2>
<div class="mb-10 text-[24px] font-bold text-black">
{{ statusInfo.title }}
</div>
<div
v-if="statusInfo.description"
class="mb-8 text-center text-sm whitespace-pre-line text-gray-600"
>
{{ statusInfo.description }}
</div>
<div class="mt-4 flex w-full gap-3">
<Button
variant="secondary"
class="h-[50px] flex-1 rounded-[25px] bg-[#D1D1D1] text-lg font-bold text-gray-600 hover:bg-gray-300"
@click="handleClose"
>
关闭
</Button>
<Button
class="h-[50px] flex-1 rounded-[25px] bg-[#ADFF5B] text-lg font-bold text-black hover:bg-[#9EE64F]"
@click="handleClose"
>
返回首页
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import request from '@/utils/request'
const emit = defineEmits(['close', 'refresh'])
const open = ref(false)
const orderNo = ref<string | null>(null)
const status = ref<number>(1) // 1: Pending, 2: Paid, 3: Finished, 4: Close, 5: Failed, -1: Error
const countdown = ref('')
let timer: any = null
let countdownTimer: any = null
let createdAt: Date | null = null
const statusInfo = computed(() => {
switch (status.value) {
case -1:
return {
title: '检查失败',
description: '检查支付状态失败,请稍后刷新页面或联系客服',
}
case 1:
return {
title: '待支付',
description: `订单正在处理中\n剩余时间: ${countdown.value}`,
}
case 2:
return {
title: '支付确认中',
description: '订单已支付,系统正在确认,请稍候...',
}
case 3:
return {
title: '支付成功',
description: '您的订单已支付完成',
}
case 4:
return {
title: '订单已关闭',
description: '该订单已超时或被手动关闭',
}
case 5:
return {
title: '支付失败',
description: '订单支付失败,请检查支付信息并重试',
}
default:
return {
title: '待支付',
description: `订单正在处理中\n剩余时间: ${countdown.value}`,
}
}
})
function handleClose() {
stopPolling()
open.value = false
emit('close')
}
async function checkStatus() {
if (!orderNo.value) return
try {
const res: any = await request.get('/api/v1/public/order/detail', { order_no: orderNo.value })
// 1. 保存订单创建时间(用于倒计时)
if (!createdAt && res.created_at) {
const timestamp = res.created_at
const isMilliseconds = timestamp > 10000000000
createdAt = new Date(isMilliseconds ? timestamp : timestamp * 1000)
startCountdown()
}
status.value = res.status
// 2. 根据状态处理业务逻辑
if (status.value === 3) {
// Finished
stopPolling()
emit('refresh')
} else if (status.value === 4 || status.value === 5) {
// Closed or Failed
stopPolling()
}
} catch (error) {
console.error('❌ 查询失败:', error)
status.value = -1
stopPolling()
}
}
function startPolling() {
stopPolling()
checkStatus()
timer = setInterval(checkStatus, 3000)
}
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
function startCountdown() {
if (!createdAt) return
const updateCountdown = () => {
const now = new Date()
// 假设订单有效期为 15 分钟 (900 秒)
const expiryTime = new Date(createdAt!.getTime() + 900 * 1000)
const diff = expiryTime.getTime() - now.getTime()
console.log('diff', diff)
if (diff <= 0) {
countdown.value = '00:00'
clearInterval(countdownTimer)
return
}
const minutes = Math.floor(diff / 1000 / 60)
const seconds = Math.floor((diff / 1000) % 60)
countdown.value = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
updateCountdown()
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(updateCountdown, 1000)
}
function show(no: string) {
orderNo.value = no
status.value = 1
createdAt = null
countdown.value = '15:00'
open.value = true
startPolling()
}
defineExpose({ show })
onUnmounted(() => {
stopPolling()
})
</script>
+7 -3
View File
@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="md:flex md:h-full md:flex-col md:justify-between">
<div class="bg-[#A8FF53] px-6 font-sans text-black"> <div class="bg-[#A8FF53] px-6 font-sans text-black">
<h2 class="mb-1 text-center text-2xl font-bold">选择付款方式</h2> <h2 class="mb-1 text-center text-2xl font-bold">选择付款方式</h2>
@@ -39,10 +39,12 @@
</div> </div>
<div class="px-6 pt-[85px] pb-[23px]"> <div class="px-6 pt-[85px] pb-[23px]">
<Button <Button
:disabled="isPaying"
@click="$emit('pay', selectedValue)" @click="$emit('pay', selectedValue)"
class="h-[50px] w-full rounded-[32px] border-none bg-black text-[16px] font-black text-white transition-all hover:bg-black/90 active:scale-[0.98]" class="h-[50px] w-full rounded-[32px] border-none bg-black text-[16px] font-black text-white transition-all hover:bg-black/90 active:scale-[0.98] disabled:opacity-50"
> >
立即支付 <Loader2 v-if="isPaying" class="mr-2 h-4 w-4 animate-spin" />
{{ isPaying ? '正在支付...' : '立即支付' }}
</Button> </Button>
</div> </div>
</div> </div>
@@ -51,6 +53,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-vue-next'
interface PaymentOption { interface PaymentOption {
label: string label: string
@@ -60,6 +63,7 @@ interface PaymentOption {
const props = defineProps<{ const props = defineProps<{
methods: any[] methods: any[]
selectedPlan: any selectedPlan: any
isPaying?: boolean
}>() }>()
const list = computed(() => const list = computed(() =>
@@ -0,0 +1,64 @@
<template>
<div class="user-center-skeleton custom-pulse" :class="[layout === 'desktop' ? 'w-full' : '']">
<!-- --- Header Part --- -->
<template v-if="type === 'header'">
<div v-if="layout === 'mobile'" class="mb-3 ml-[31px] flex h-[60px] items-center gap-3">
<div class="size-[60px] rounded-full bg-[#A8FF53]/60"></div>
<div class="flex flex-col gap-2">
<div class="h-5 w-40 rounded bg-[#A8FF53]/60"></div>
<div class="h-3 w-24 rounded bg-[#A8FF53]/40"></div>
</div>
</div>
<div v-else class="mb-3 flex flex-col items-center">
<div class="size-[60px] rounded-full bg-[#A8FF53]/60"></div>
<div class="mt-2 h-6 w-32 rounded bg-[#A8FF53]/60"></div>
</div>
</template>
<!-- --- Devices Part --- -->
<template v-if="type === 'devices'">
<div v-if="layout === 'mobile'" class="h-[76px] w-full rounded-2xl bg-[#A8FF53]/15"></div>
<div v-else class="h-[160px] w-full rounded-2xl bg-[#A8FF53]/15"></div>
</template>
<!-- --- Plans Part --- -->
<template v-if="type === 'plans'">
<div class="space-y-4">
<div class="h-[120px] w-full rounded-2xl bg-black/15"></div>
<div class="h-[120px] w-full rounded-2xl bg-black/15"></div>
</div>
</template>
<!-- --- Payments Part --- -->
<template v-if="type === 'payments'">
<div class="h-[300px] w-full rounded-2xl bg-black/15"></div>
</template>
<!-- --- Full Layouts (Backward Compatibility) --- -->
<template v-if="!type">
<!-- Old Full Layout implementation if needed, but we'll use type-based now -->
</template>
</div>
</template>
<script setup lang="ts">
defineProps<{
type?: 'header' | 'devices' | 'plans' | 'payments'
layout?: 'mobile' | 'desktop'
}>()
</script>
<style scoped>
.custom-pulse {
animation: fast-pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes fast-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
</style>
@@ -1,15 +1,15 @@
<template> <template>
<div class="font-sans text-white"> <div class="font-sans text-white">
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-[10px]">
<div <div
v-for="item in downloadMethods" v-for="item in downloadMethods"
:key="item.id" :key="item.id"
@click="toggle(item.id)" @click="toggle(item.id)"
class="group relative flex cursor-pointer flex-col rounded-[30px] border-[4px] border-white bg-black pt-4 pb-2 transition-all active:scale-[0.97] md:pb-6" class="group relative flex cursor-pointer flex-col rounded-[30px] border-[4px] border-white bg-black pt-4 pb-2 transition-all md:pb-6"
> >
<div class="px-4 md:px-[42px]"> <div class="px-4 md:pl-[42px]">
<div <div
class="flex flex-wrap items-center gap-x-2 text-sm leading-tight font-bold md:text-4xl" class="flex flex-wrap items-center gap-x-2 text-sm leading-tight font-bold md:text-2xl"
> >
<StartIcon v-if="item.isHot" class="size-[20px] md:size-[30px]" /> <StartIcon v-if="item.isHot" class="size-[20px] md:size-[30px]" />
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
@@ -23,30 +23,85 @@
<!-- 打开按钮 --> <!-- 打开按钮 -->
<div <div
class="mt-2 flex items-center justify-end gap-1.5 self-end text-sm font-bold group-hover:opacity-100" class="flex items-center justify-end gap-1.5 self-end text-sm font-bold group-hover:opacity-100"
>
<div
v-show="activeId !== item.id"
class="mt-[16px] flex items-center gap-[10px] md:mt-[60px] md:text-xl"
> >
<div v-show="activeId !== item.id" class="flex items-center gap-[10px] md:text-2xl">
<span class="tracking-tight">点击展开详情</span> <span class="tracking-tight">点击展开详情</span>
<ArrowIcon class="size-[12px] md:size-[22px]" /> <ArrowIcon class="size-[12px] md:size-[18px]" />
</div> </div>
</div> </div>
</div> </div>
<div v-if="activeId === item.id" class="px-2 md:pt-8"> <div v-if="activeId === item.id" class="px-2 md:pt-8">
<div <div
class="animate-in fade-in zoom-in-95 border-t-2 border-white pt-[10px] text-[15px] leading-relaxed text-gray-300 duration-200 md:pt-[20px]" class="animate-in fade-in zoom-in-95 border-t-2 border-white pt-[10px] text-[15px] leading-relaxed duration-200 md:pt-[20px]"
> >
<!-- 收起按钮 --> <!-- 内容区域 (点击图片不收起只有特定的关闭按钮收起) -->
<div class="px-2"> <div class="relative px-2" @click.stop>
<div <div
class="flex items-center justify-end gap-1.5 self-end text-sm font-bold group-hover:opacity-100 md:text-2xl" class="flex items-center justify-end gap-1.5 self-end text-sm font-bold group-hover:opacity-100 md:text-xl"
> >
<div class="flex items-center gap-[10px]"> <div class="relative z-10 flex items-center gap-[10px]" @click="toggle(item.id)">
<span class="tracking-tight">点击收起</span> <span class="tracking-tight">点击收起</span>
<ArrowIcon class="size-[12px] rotate-180 md:size-[22px]" /> <ArrowIcon class="size-[12px] rotate-180 md:size-[22px]" />
</div> </div>
</div> </div>
<p class="whitespace-pre-line">{{ item.details }}</p>
<!-- Mobile Image Stack -->
<div class="relative -mt-10 flex flex-col md:hidden">
<img
v-for="(img, idx) in item.mobileImages"
:key="idx"
:src="img"
class="w-full"
alt="mobile guide"
/>
<!-- Mobile Hot Zones -->
<div
v-for="(hz, hzIdx) in item.mobileHotzones"
:key="hzIdx"
class="absolute z-20 cursor-pointer"
:style="{
left: hz.x + '%',
top: hz.y + '%',
width: hz.w + '%',
height: hz.h + '%',
}"
@click="handleHotzone(hz)"
></div>
</div>
<!-- PC Image Stack -->
<div class="relative mt-4 hidden flex-col md:flex">
<img
v-for="(img, idx) in item.pcImages"
:key="idx"
:src="img"
class="w-full"
alt="pc guide"
/>
<!-- PC Hot Zones -->
<div
v-for="(hz, hzIdx) in item.pcHotzones"
:key="hzIdx"
class="absolute z-20 cursor-pointer"
:style="{
left: hz.x + '%',
top: hz.y + '%',
width: hz.w + '%',
height: hz.h + '%',
}"
@click="handleHotzone(hz)"
></div>
</div>
<!-- 底部收起按钮 -->
<div class="absolute right-4 bottom-4 z-10">
<div @click="toggle(item.id)" class="h-[30px] w-[200px]"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -59,6 +114,42 @@
import { ref } from 'vue' import { ref } from 'vue'
import ArrowIcon from './arrow-icon.svg?component' import ArrowIcon from './arrow-icon.svg?component'
import StartIcon from './Star-1.svg?component' import StartIcon from './Star-1.svg?component'
import { toast } from 'vue-sonner'
// Sliced WebP Images
// Method 1
import m1_1 from './mobile1/row-1-column-1.webp'
import m1_2 from './mobile1/row-2-column-1.webp'
import m1_3 from './mobile1/row-3-column-1.webp'
import pc1_1 from './pc1/row-1-column-1.webp'
import pc1_2 from './pc1/row-2-column-1.webp'
import pc1_3 from './pc1/row-3-column-1.webp'
// Method 2
import m2_1 from './mobile2/row-1-column-1.webp'
import m2_2 from './mobile2/row-2-column-1.webp'
import m2_3 from './mobile2/row-3-column-1.webp'
import pc2_1 from './pc2/row-1-column-1.webp'
import pc2_2 from './pc2/row-2-column-1.webp'
import pc2_3 from './pc2/row-3-column-1.webp'
// Method 3
import m3_1 from './mobile3/row-1-column-1.webp'
import m3_2 from './mobile3/row-2-column-1.webp'
import m3_3 from './mobile3/row-3-column-1.webp'
import pc3_1 from './pc3/row-1-column-1.webp'
import pc3_2 from './pc3/row-2-column-1.webp'
import pc3_3 from './pc3/row-3-column-1.webp'
interface Hotzone {
x: number // 百分比
y: number // 百分比
w: number // 百分比
h: number // 百分比
type: 'copy' | 'link'
payload: string
label?: string
}
// 直接在内部定义数据 // 直接在内部定义数据
const downloadMethods = ref([ const downloadMethods = ref([
@@ -68,8 +159,10 @@ const downloadMethods = ref([
subtitle: '首推', subtitle: '首推',
isHot: true, isHot: true,
highlight: '', highlight: '',
details: mobileImages: [m1_1, m1_2, m1_3],
'这是最稳定的方式。步骤:1. 访问 Apple 官网... 2. 注册地区选择香港... 3. 无需绑定卡片即可下载。', pcImages: [pc1_1, pc1_2, pc1_3],
mobileHotzones: [] as Hotzone[],
pcHotzones: [] as Hotzone[],
}, },
{ {
id: 2, id: 2,
@@ -77,7 +170,10 @@ const downloadMethods = ref([
subtitle: '', subtitle: '',
isHot: false, isHot: false,
highlight: '', highlight: '',
details: '1. 打开 App Store 个人中心... 2. 点击国家/地区... 3. 更改为香港并填写虚拟地址。', mobileImages: [m2_1, m2_2, m2_3],
pcImages: [pc2_1, pc2_2, pc2_3],
mobileHotzones: [] as Hotzone[],
pcHotzones: [] as Hotzone[],
}, },
{ {
id: 3, id: 3,
@@ -85,7 +181,10 @@ const downloadMethods = ref([
subtitle: '', subtitle: '',
isHot: false, isHot: false,
highlight: '谨慎选择', highlight: '谨慎选择',
details: '注意:千万不要在设置(Settings)中登录公用账号!仅在 App Store 中登录,否则可能锁机。', mobileImages: [m3_1, m3_2, m3_3],
pcImages: [pc3_1, pc3_2, pc3_3],
mobileHotzones: [] as Hotzone[],
pcHotzones: [] as Hotzone[],
}, },
]) ])
@@ -94,6 +193,15 @@ const activeId = ref<number | null>(null)
const toggle = (id: number) => { const toggle = (id: number) => {
activeId.value = activeId.value === id ? null : id activeId.value = activeId.value === id ? null : id
} }
const handleHotzone = (hz: Hotzone) => {
if (hz.type === 'copy') {
navigator.clipboard.writeText(hz.payload)
toast.success(`${hz.label || '内容'}已复制`)
} else if (hz.type === 'link') {
window.open(hz.payload, '_blank')
}
}
</script> </script>
<style scoped> <style scoped>
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg width="49" height="73" viewBox="0 0 49 73" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.175484 64L2.94748 55.06H5.51548L8.28748 64H6.03148L4.91548 59.536C4.79548 59.088 4.67548 58.612 4.55548 58.108C4.44348 57.604 4.32748 57.124 4.20748 56.668H4.15948C4.05548 57.132 3.94348 57.616 3.82348 58.12C3.70348 58.616 3.58748 59.088 3.47548 59.536L2.35948 64H0.175484ZM2.05948 61.948V60.292H6.37948V61.948H2.05948ZM9.05736 64V57.172H10.7974L10.9534 58.024H10.9894C11.2694 57.744 11.5854 57.504 11.9374 57.304C12.2974 57.104 12.7094 57.004 13.1734 57.004C13.9174 57.004 14.4574 57.252 14.7934 57.748C15.1294 58.244 15.2974 58.92 15.2974 59.776V64H13.1614V60.04C13.1614 59.568 13.0974 59.248 12.9694 59.08C12.8414 58.912 12.6414 58.828 12.3694 58.828C12.1294 58.828 11.9254 58.88 11.7574 58.984C11.5894 59.088 11.4014 59.24 11.1934 59.44V64H9.05736ZM19.4038 64.168C18.8358 64.168 18.3398 64.024 17.9158 63.736C17.4998 63.448 17.1758 63.036 16.9438 62.5C16.7198 61.964 16.6078 61.324 16.6078 60.58C16.6078 59.836 16.7438 59.2 17.0158 58.672C17.2878 58.136 17.6398 57.724 18.0718 57.436C18.5038 57.148 18.9558 57.004 19.4278 57.004C19.8038 57.004 20.1158 57.068 20.3638 57.196C20.6118 57.324 20.8478 57.496 21.0718 57.712L20.9998 56.692V54.412H23.1358V64H21.3958L21.2398 63.34H21.1918C20.9518 63.58 20.6758 63.78 20.3638 63.94C20.0518 64.092 19.7318 64.168 19.4038 64.168ZM19.9678 62.416C20.1758 62.416 20.3598 62.376 20.5198 62.296C20.6878 62.216 20.8478 62.064 20.9998 61.84V59.152C20.8398 59 20.6678 58.896 20.4838 58.84C20.2998 58.776 20.1158 58.744 19.9318 58.744C19.7398 58.744 19.5558 58.808 19.3798 58.936C19.2118 59.064 19.0718 59.26 18.9598 59.524C18.8558 59.788 18.8038 60.132 18.8038 60.556C18.8038 60.988 18.8478 61.344 18.9358 61.624C19.0318 61.896 19.1678 62.096 19.3438 62.224C19.5198 62.352 19.7278 62.416 19.9678 62.416ZM24.8542 64V57.172H26.5942L26.7502 58.36H26.7862C27.0422 57.904 27.3422 57.564 27.6862 57.34C28.0382 57.116 28.3862 57.004 28.7302 57.004C28.9382 57.004 29.1022 57.02 29.2222 57.052C29.3502 57.076 29.4622 57.108 29.5582 57.148L29.2102 58.984C29.0822 58.952 28.9622 58.928 28.8502 58.912C28.7382 58.888 28.6062 58.876 28.4542 58.876C28.2062 58.876 27.9422 58.968 27.6622 59.152C27.3902 59.336 27.1662 59.652 26.9902 60.1V64H24.8542ZM33.162 64.168C32.586 64.168 32.042 64.028 31.53 63.748C31.018 63.46 30.602 63.048 30.282 62.512C29.97 61.976 29.814 61.332 29.814 60.58C29.814 59.828 29.97 59.188 30.282 58.66C30.602 58.124 31.018 57.716 31.53 57.436C32.042 57.148 32.586 57.004 33.162 57.004C33.594 57.004 34.01 57.084 34.41 57.244C34.81 57.404 35.166 57.64 35.478 57.952C35.79 58.256 36.038 58.628 36.222 59.068C36.406 59.508 36.498 60.012 36.498 60.58C36.498 61.332 36.338 61.976 36.018 62.512C35.706 63.048 35.294 63.46 34.782 63.748C34.278 64.028 33.738 64.168 33.162 64.168ZM33.162 62.44C33.426 62.44 33.642 62.364 33.81 62.212C33.986 62.06 34.114 61.844 34.194 61.564C34.274 61.284 34.314 60.956 34.314 60.58C34.314 60.204 34.274 59.88 34.194 59.608C34.114 59.328 33.986 59.112 33.81 58.96C33.642 58.808 33.426 58.732 33.162 58.732C32.898 58.732 32.678 58.808 32.502 58.96C32.334 59.112 32.206 59.328 32.118 59.608C32.038 59.88 31.998 60.204 31.998 60.58C31.998 60.956 32.038 61.284 32.118 61.564C32.206 61.844 32.334 62.06 32.502 62.212C32.678 62.364 32.898 62.44 33.162 62.44ZM37.8386 64V57.172H39.9746V64H37.8386ZM38.9066 56.224C38.5466 56.224 38.2586 56.124 38.0426 55.924C37.8266 55.724 37.7186 55.456 37.7186 55.12C37.7186 54.792 37.8266 54.528 38.0426 54.328C38.2586 54.128 38.5466 54.028 38.9066 54.028C39.2586 54.028 39.5426 54.128 39.7586 54.328C39.9826 54.528 40.0946 54.792 40.0946 55.12C40.0946 55.456 39.9826 55.724 39.7586 55.924C39.5426 56.124 39.2586 56.224 38.9066 56.224ZM44.1421 64.168C43.5741 64.168 43.0781 64.024 42.6541 63.736C42.2381 63.448 41.9141 63.036 41.6821 62.5C41.4581 61.964 41.3461 61.324 41.3461 60.58C41.3461 59.836 41.4821 59.2 41.7541 58.672C42.0261 58.136 42.3781 57.724 42.8101 57.436C43.2421 57.148 43.6941 57.004 44.1661 57.004C44.5421 57.004 44.8541 57.068 45.1021 57.196C45.3501 57.324 45.5861 57.496 45.8101 57.712L45.7381 56.692V54.412H47.8741V64H46.1341L45.9781 63.34H45.9301C45.6901 63.58 45.4141 63.78 45.1021 63.94C44.7901 64.092 44.4701 64.168 44.1421 64.168ZM44.7061 62.416C44.9141 62.416 45.0981 62.376 45.2581 62.296C45.4261 62.216 45.5861 62.064 45.7381 61.84V59.152C45.5781 59 45.4061 58.896 45.2221 58.84C45.0381 58.776 44.8541 58.744 44.6701 58.744C44.4781 58.744 44.2941 58.808 44.1181 58.936C43.9501 59.064 43.8101 59.26 43.6981 59.524C43.5941 59.788 43.5421 60.132 43.5421 60.556C43.5421 60.988 43.5861 61.344 43.6741 61.624C43.7701 61.896 43.9061 62.096 44.0821 62.224C44.2581 62.352 44.4661 62.416 44.7061 62.416Z" fill="white"/>
<g clip-path="url(#clip0_9522_1052)">
<path d="M28.3624 37.0001C29.5995 37.0001 30.6784 35.9213 30.6784 34.6842V29.2754H32.2248C33.1527 29.2754 33.7712 28.6567 33.7712 27.729V12.2808H15.229V27.7291C15.229 28.657 15.8476 29.2755 16.7754 29.2755H18.3219V34.6843C18.3219 35.9214 19.4005 37.0003 20.6379 37.0003C21.8749 37.0003 22.9536 35.9214 22.9536 34.6843V29.2755H26.0466V34.6843C26.0464 35.9213 27.1253 37.0001 28.3624 37.0001Z" fill="white"/>
<path d="M37.6333 27.7291C38.8705 27.7291 39.9491 26.6504 39.9491 25.4132V14.5957C39.9491 13.3652 38.8705 12.2808 37.6333 12.2808C36.3961 12.2808 35.3174 13.3652 35.3174 14.5957V25.4132C35.3174 26.6503 36.396 27.7291 37.6333 27.7291Z" fill="white"/>
<path d="M11.3665 27.7291C12.6037 27.7291 13.6825 26.6504 13.6825 25.4132V14.5957C13.6825 13.3652 12.6039 12.2808 11.3665 12.2808C10.1294 12.2808 9.05078 13.3652 9.05078 14.5957V25.4132C9.05078 26.6503 10.1294 27.7291 11.3665 27.7291Z" fill="white"/>
<path d="M31.9154 0.23335C31.6062 -0.0777832 31.1459 -0.0777832 30.8368 0.23335L28.7627 2.30053L28.667 2.39609C27.4371 1.78025 26.0583 1.4727 24.5227 1.46973C24.5152 1.46973 24.5078 1.46949 24.5003 1.46949H24.5C24.4922 1.46949 24.4851 1.46973 24.4773 1.46973C22.9419 1.4727 21.563 1.78025 20.3333 2.39609L20.2374 2.30053L18.1634 0.23335C17.854 -0.0777832 17.394 -0.0777832 17.0848 0.23335C16.7754 0.542754 16.7754 1.00192 17.0848 1.31108L19.0911 3.31776C18.4447 3.7494 17.8584 4.27758 17.3487 4.8817C16.1282 6.32835 15.3498 8.21107 15.2427 10.251C15.2417 10.2722 15.2396 10.2932 15.2386 10.3144C15.2321 10.4533 15.229 10.593 15.229 10.7331H33.7712C33.7712 10.593 33.7678 10.4533 33.7615 10.3144C33.7605 10.2932 33.7584 10.2722 33.7572 10.251C33.6504 8.21107 32.8717 6.32822 31.6513 4.88182C31.1417 4.27771 30.5553 3.74952 29.9088 3.31789L31.9154 1.3112C32.2248 1.00192 32.2248 0.542754 31.9154 0.23335ZM20.6349 8.03303C19.9951 8.03303 19.4765 7.51436 19.4765 6.87456C19.4765 6.23476 19.9951 5.71608 20.6349 5.71608C21.2747 5.71608 21.7934 6.23476 21.7934 6.87456C21.7934 7.51436 21.2747 8.03303 20.6349 8.03303ZM28.3652 8.03303C27.7254 8.03303 27.2068 7.51436 27.2068 6.87456C27.2068 6.23476 27.7254 5.71608 28.3652 5.71608C29.005 5.71608 29.5237 6.23476 29.5237 6.87456C29.5237 7.51436 29.005 8.03303 28.3652 8.03303Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_9522_1052">
<rect width="37" height="37" fill="white" transform="translate(6)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

+70 -27
View File
@@ -1,80 +1,88 @@
<template> <template>
<div class="min-h-screen bg-black text-white"> <div class="min-h-screen bg-black text-white">
<!-- Full Width Header --> <!-- Full Width Header -->
<div class="h-[88px] md:h-[125px]"> <div class="h-[88px] md:h-[94px]">
<div class="fixed top-[20px] z-50 w-full md:top-[45px]"> <div class="fixed z-50 w-full bg-black pt-[20px] md:pt-[34px]">
<div class="container"> <div class="container">
<header <header
class="flex h-[68px] items-center justify-between rounded-full bg-[#ADFF5B] pr-[30px] pl-6 md:h-[80px]" class="flex h-[68px] items-center justify-between rounded-full bg-[#ADFF5B] pr-[30px] pl-6 md:h-[60px] md:pr-[58px] md:pl-[41px]"
> >
<div class="flex items-center gap-2"> <router-link to="/" class="flex items-center gap-2">
<!-- Desktop Logo --> <!-- Desktop Logo -->
<!-- <Logo :src="Logo" alt="Hi快VPN" class="hidden h-10 w-auto text-black md:block" />--> <!-- <Logo :src="Logo" alt="Hi快VPN" class="hidden h-10 w-auto text-black md:block" />-->
<!-- Mobile Logo --> <!-- Mobile Logo -->
<MobileLogo <MobileLogo alt="Hi快VPN" class="block h-[28px] text-black md:h-[36px]" />
:src="MobileLogo" </router-link>
alt="Hi快VPN" <RightText class="block h-[15px] text-black md:h-[24px]" />
class="block h-[28px] text-black md:h-[50px]"
/>
</div>
<div class="text-xl font-[600] text-black md:text-3xl">Hi快iOS版本下载教程</div>
</header> </header>
</div> </div>
</div> </div>
</div> </div>
<div class="container md:px-[252px]"> <div class="h-[125px] md:h-[180px]">
<div <div class="fixed z-50 w-full bg-black">
class="pt-[34px] pb-[15px] text-center text-xl font-[900] md:pt-[110px] md:pb-[30px] md:text-3xl" <div class="m-auto w-[340px] pb-[10px] md:w-[760px]">
> <div class="pt-[30px] pb-[15px] text-center text-xl font-[900] md:pb-[30px] md:text-2xl">
请选择适用您情形的选项 请选择适用您情形的选项
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div <div
class="w-1/2 rounded-full border-5 p-[6px]" class="w-1/2 rounded-full border-5 p-[6px] md:border-[10px] md:p-[10px]"
:class="[activeIndex === 0 ? 'border-white' : 'border-black']" :class="[activeIndex === 0 ? 'border-white' : 'border-black']"
@click="activeIndex = 0" @click="activeIndex = 0"
> >
<Button <Button
class="w-full cursor-pointer rounded-full bg-[#ADFF5B] text-black hover:bg-[#ADFF5B]/90 md:h-[60px] md:text-2xl" class="h-[30px] w-full cursor-pointer rounded-full bg-[#ADFF5B] text-xs text-black hover:bg-[#ADFF5B]/90 md:h-[40px] md:text-xl"
> >
我只有中国大陆Apple ID 我只有中国大陆Apple ID
</Button> </Button>
</div> </div>
<div <div
class="w-1/2 rounded-full border-5 p-[6px]" class="w-1/2 rounded-full border-5 p-[6px] md:border-[10px] md:p-[10px]"
:class="[activeIndex === 1 ? 'border-white' : 'border-black']" :class="[activeIndex === 1 ? 'border-white' : 'border-black']"
@click="activeIndex = 1" @click="activeIndex = 1"
> >
<Button <Button
class="w-full cursor-pointer rounded-full bg-[#ADFF5B] text-black hover:bg-[#ADFF5B]/90 md:h-[60px] md:text-2xl" class="h-[30px] w-full cursor-pointer rounded-full bg-[#ADFF5B] text-xs text-black hover:bg-[#ADFF5B]/90 md:h-[40px] md:text-xl"
> >
我有海外Apple ID 我有海外Apple ID
</Button> </Button>
</div> </div>
</div> </div>
</div>
</div>
</div>
<div class="m-auto w-[340px] md:w-[760px]">
<Transition name="fade" mode="out-in">
<!-- tab1 --> <!-- tab1 -->
<div v-show="activeIndex === 0"> <div v-if="activeIndex === 0" key="tab1">
<div class="pt-[34px] pb-[15px] text-center text-xl font-[900] md:pt-[100px] md:text-3xl"> <div class="pt-[15px] pb-[15px] text-center text-xl font-[900] md:pb-[39px] md:text-2xl">
Hi快提供三种下载方式 Hi快提供三种下载方式
</div> </div>
<DownloadMethodList /> <DownloadMethodList />
</div> </div>
<div v-show="activeIndex === 1"> <!-- tab2 -->
<div v-else-if="activeIndex === 1" key="tab2">
<div <div
class="mx-auto flex w-[237px] items-center justify-center pt-[34px] pb-[15px] text-center text-xl font-[900] md:w-full md:gap-[18px] md:pt-[100px] md:text-3xl" class="mx-auto flex w-[237px] items-center justify-center pt-[34px] text-center text-xl font-[900] md:w-full md:gap-[18px] md:text-2xl"
> >
<ChatIcon class="h-[60px] w-[50px]" />登录海外 Apple ID 后再点击下方下载按钮 <ChatIcon class="h-[60px] w-[50px]" />登录海外 Apple ID 后再点击下方下载按钮
</div> </div>
<div class="mx-auto h-[101px] w-[247px] md:h-[202px] md:w-[594px]"> <div class="mx-auto h-[101px] w-[247px] md:h-[202px] md:w-[494px]">
<a
href="https://apps.apple.com/us/app/hi%E5%BF%ABvpn/id6755683167"
target="_blank"
class="block h-full w-full"
>
<img src="./Group%20100.png" class="h-full w-full" alt="下载" /> <img src="./Group%20100.png" class="h-full w-full" alt="下载" />
</a>
</div> </div>
</div> </div>
<!-- tab2 --> </Transition>
</div> </div>
<div class="container md:px-[92px]"> <div class="container md:px-[92px]">
<div class="pt-[56px] pb-[15px] text-center text-xl font-[900] md:pb-[69px] md:text-3xl"> <div class="pt-[56px] pb-[15px] text-center text-xl font-[900] md:text-2xl">
常见问题与解答 常见问题与解答
</div> </div>
<div class="px-[24px]"> <div class="px-[24px]">
@@ -93,7 +101,18 @@
</div> </div>
<div class="px-[18px] pt-[34px] pb-[calc(50px+env(safe-area-inset-bottom))]"> <div class="px-[18px] pt-[34px] pb-[calc(50px+env(safe-area-inset-bottom))]">
<div class="pb-[15px] text-center text-xl font-[900] md:text-xl">其他客户端下载</div> <div class="pb-[15px] text-center text-xl font-[900] md:pb-[30px] md:text-3xl">
其他客户端下载
</div>
<div class="flex justify-center gap-4">
<template v-for="client in otherClients" :key="client.link">
<a :href="client.link" target="_blank">
<MacosIcon v-if="client.type === 'mac'" />
<WindowsIcon v-if="client.type === 'window'" />
<AndroidIcon v-if="client.type === 'android'" />
</a>
</template>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -103,10 +122,34 @@ import { ref } from 'vue'
// Help page logic // Help page logic
import Logo from '@/pages/Home/logo.svg?component' import Logo from '@/pages/Home/logo.svg?component'
import MobileLogo from '@/pages/Home/mobile-logo.svg?component' import MobileLogo from '@/pages/Home/mobile-logo.svg?component'
import RightText from './right-text.svg?component'
import ChatIcon from './Vector.svg?component' import ChatIcon from './Vector.svg?component'
import MacosIcon from './macos.svg?component'
import WindowsIcon from './windows.svg?component'
import AndroidIcon from './android.svg?component'
import DownloadMethodList from './DownloadMethodList/DownloadMethodList.vue' import DownloadMethodList from './DownloadMethodList/DownloadMethodList.vue'
import FAQAccordion from './FAQAccordion/index.vue' import FAQAccordion from './FAQAccordion/index.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { downLoadAndroid, downLoadWin, downLoadMac } from '@/utils/constant.ts'
const activeIndex = ref(0) const activeIndex = ref(0)
const otherClients = [
{ icon: WindowsIcon, link: downLoadWin, type: 'window' },
{ icon: MacosIcon, link: downLoadMac, type: 'mac' },
{ icon: AndroidIcon, link: downLoadAndroid, type: 'android' },
]
</script> </script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

+38 -13
View File
@@ -1,16 +1,40 @@
<template> <template>
<div class="grid grid-cols-2 gap-4 md:flex md:flex-wrap"> <div class="mx-auto grid grid-cols-2 gap-4 md:flex md:flex-wrap">
<a <template v-for="(item, index) in downloadLinks" :key="index">
v-for="(item, index) in downloadLinks" <router-link
:key="index" v-if="item.isInternal"
:href="item.link" :to="item.link"
target="_blank" class="flex h-[40px] w-[140px] shrink-0 items-center space-x-2 rounded-full transition-transform hover:brightness-110 active:scale-95 md:h-[50px] md:w-[180px]"
class="flex h-[40px] w-[140px] shrink-0 items-center space-x-2 rounded-full transition-transform active:scale-95 md:h-[50px] md:w-[180px]"
style=" style="
backdrop-filter: blur(36px); backdrop-filter: blur(36px);
box-shadow: box-shadow:
0px 0px 33px 0px #f2f2f280 inset, 0px 0px 33px 0px #f2f2f280 inset,
-3px -4.5px 1.5px -3px #b3b3b3 inset, -1px -1.5px 1.5px -3px #b3b3b3 inset,
3px 4.5px 1.5px -3px #b3b3b333 inset,
4.5px 4.5px 1.5px -5.25px #ffffff80 inset,
-4.5px -4.5px 1.5px -5.25px #ffffff80 inset;
border-image-source: linear-gradient(
135deg,
rgba(255, 255, 255, 0.4) 0%,
rgba(255, 255, 255, 0) 30%
);
border-image-slice: 1;
"
>
<div class="flex items-center justify-center">
<component :is="item.icon" class="h-[40px] w-[140px] md:h-[50px] md:w-[180px]" />
</div>
</router-link>
<a
v-else
:href="item.link"
target="_blank"
class="flex h-[40px] w-[140px] shrink-0 items-center space-x-2 rounded-full transition-transform hover:brightness-110 active:scale-95 md:h-[50px] md:w-[180px]"
style="
backdrop-filter: blur(36px);
box-shadow:
0px 0px 33px 0px #f2f2f280 inset,
-1px -1.5px 1.5px -3px #b3b3b3 inset,
3px 4.5px 1.5px -3px #b3b3b333 inset, 3px 4.5px 1.5px -3px #b3b3b333 inset,
4.5px 4.5px 1.5px -5.25px #ffffff80 inset, 4.5px 4.5px 1.5px -5.25px #ffffff80 inset,
-4.5px -4.5px 1.5px -5.25px #ffffff80 inset; -4.5px -4.5px 1.5px -5.25px #ffffff80 inset;
@@ -26,6 +50,7 @@
<component :is="item.icon" class="h-[40px] w-[140px] md:h-[50px] md:w-[180px]" /> <component :is="item.icon" class="h-[40px] w-[140px] md:h-[50px] md:w-[180px]" />
</div> </div>
</a> </a>
</template>
</div> </div>
</template> </template>
@@ -34,12 +59,12 @@ import Icon1 from './Group 105.svg?component'
import Icon2 from './Group 106.svg?component' import Icon2 from './Group 106.svg?component'
import Icon3 from './Group 107.svg?component' import Icon3 from './Group 107.svg?component'
import Icon4 from './Group 108.svg?component' import Icon4 from './Group 108.svg?component'
import { downLoadAndroid, downLoadWin, downLoadMac } from '@/utils/constant.ts'
// 定义下载链接数据 // 定义下载链接数据
const downloadLinks = [ const downloadLinks = [
{ icon: Icon1, link: 'https://apple.com/...' }, { icon: Icon2, link: '/help', isInternal: true },
{ icon: Icon2, link: 'https://down.hi.com/win' }, { icon: Icon1, link: downLoadWin },
{ icon: Icon3, link: 'https://down.hi.com/apk' }, { icon: Icon3, link: downLoadMac },
{ icon: Icon4, link: 'https://down.hi.com/mac' }, { icon: Icon4, link: downLoadAndroid },
] ]
</script> </script>
+30 -3
View File
@@ -56,6 +56,9 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import CodeSentTip from '@/pages/Home/components/CodeSentTip.vue' import CodeSentTip from '@/pages/Home/components/CodeSentTip.vue'
import request from '@/utils/request'
import { useRouter } from 'vue-router'
const CodeSentTipRef = ref(null) const CodeSentTipRef = ref(null)
const email = ref('') const email = ref('')
const code = ref('') const code = ref('')
@@ -68,16 +71,40 @@ const handleGetCode = () => {
return return
} }
CodeSentTipRef.value.show() CodeSentTipRef.value.show()
// 模拟倒计时逻辑
request
.get('/api/v1/auth/check', {
email: email.value,
})
.then(({ exist }) => {
console.log(exist)
request
.post('/api/v1/common/send_code', {
// 1=登录, 2=注册,
email: email.value,
type: exist ? 2 : 1,
})
.then(() => {
countdown.value = 60 countdown.value = 60
const timer = setInterval(() => { const timer = setInterval(() => {
countdown.value-- countdown.value--
if (countdown.value <= 0) clearInterval(timer) if (countdown.value <= 0) clearInterval(timer)
}, 1000) }, 1000)
})
})
} }
const router = useRouter()
const handleLogin = () => { const handleLogin = () => {
console.log('登录提交', { email: email.value, code: code.value }) request
.post('/api/v1/auth/login/email', {
email: email.value,
code: code.value,
})
.then((res) => {
localStorage.setItem('Authorization', res.token)
localStorage.setItem('UserEmail', email.value)
router.push({ path: '/user-center' })
})
} }
</script> </script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+171 -22
View File
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="relative min-h-screen overflow-hidden bg-[#24963e] pb-[calc(1rem+env(safe-area-inset-bottom))] font-sans text-white md:flex md:flex-col md:pb-6" class="relative min-h-screen overflow-hidden bg-[#24963e] bg-[url('@/pages/Home/bg-mobile.jpg')] bg-cover bg-center bg-no-repeat pb-[calc(1rem+env(safe-area-inset-bottom))] font-sans text-white md:flex md:flex-col md:bg-[url('@/pages/Home/bg-desktop.webp')] md:pb-0"
> >
<!-- Full Width Header --> <!-- Full Width Header -->
<div class="h-[88px] md:h-[125px]"> <div class="h-[88px] md:h-[125px]">
@@ -12,7 +12,7 @@
backdrop-filter: blur(36px); backdrop-filter: blur(36px);
box-shadow: box-shadow:
0px 0px 33px 0px #f2f2f280 inset, 0px 0px 33px 0px #f2f2f280 inset,
-3px -4.5px 1.5px -3px #b3b3b3 inset, -1px -1.5px 1.5px -3px #b3b3b3 inset,
3px 4.5px 1.5px -3px #b3b3b333 inset, 3px 4.5px 1.5px -3px #b3b3b333 inset,
4.5px 4.5px 1.5px -5.25px #ffffff80 inset, 4.5px 4.5px 1.5px -5.25px #ffffff80 inset,
-4.5px -4.5px 1.5px -5.25px #ffffff80 inset; -4.5px -4.5px 1.5px -5.25px #ffffff80 inset;
@@ -24,15 +24,24 @@
border-image-slice: 1; border-image-slice: 1;
" "
> >
<div class="flex items-center gap-2"> <router-link to="/" class="flex items-center gap-2">
<!-- Desktop Logo --> <!-- Desktop Logo -->
<Logo alt="Hi快VPN" class="hidden h-[29px] w-auto md:ml-[62px] md:block" /> <Logo alt="Hi快VPN" class="hidden h-[29px] w-auto md:ml-[62px] md:block" />
<!-- Mobile Logo --> <!-- Mobile Logo -->
<MobileLogo alt="Hi快VPN" class="ml-6 block h-[28px] w-[67px] md:hidden" /> <MobileLogo alt="Hi快VPN" class="ml-6 block h-[28px] w-[67px] md:hidden" />
</router-link>
<div v-if="isLoggedIn" class="flex items-center">
<router-link
to="/user-center"
class="mr-2 flex size-[48px] items-center justify-center rounded-full bg-[#A8FF53] text-xl font-bold text-black shadow-lg transition hover:scale-105 md:size-[60px] md:text-3xl"
>
{{ userLetter }}
</router-link>
</div> </div>
<button <button
v-else
@click="openLoginModal" @click="openLoginModal"
class="flex h-[48px] items-center rounded-full bg-[#70C877] px-6 text-sm font-bold backdrop-blur-md transition hover:bg-white/30 md:text-2xl" class="flex h-[48px] cursor-pointer items-center justify-center rounded-full bg-[#78788029] px-6 text-sm font-bold backdrop-blur-md transition hover:brightness-110 md:h-[60px] md:w-[220px] md:text-2xl"
> >
登录 / 注册 登录 / 注册
</button> </button>
@@ -60,6 +69,19 @@
alt="App Screenshot" alt="App Screenshot"
class="h-auto w-full drop-shadow-2xl" class="h-auto w-full drop-shadow-2xl"
/> />
<div class="absolute right-[8%] bottom-[97px] z-50 aspect-square w-[58%]">
<!-- Ripple Animation (Background) -->
<div class="ripple-container absolute inset-0 top-[7px]">
<div class="ripple ripple-outer-3"></div>
<div class="ripple ripple-outer-2"></div>
<div class="ripple ripple-outer-1"></div>
<div class="ripple ripple-core"></div>
</div>
<!-- Center Image (Foreground/Top Layer) -->
<div
class="absolute inset-0 bottom-0 z-100 bg-[url('@/pages/Home/connected-mobile-bg.png')] bg-cover bg-center bg-no-repeat"
></div>
</div>
</div> </div>
<!-- Download Buttons Grid --> <!-- Download Buttons Grid -->
@@ -73,10 +95,36 @@
<div <div
class="mb-5 w-full text-center text-xs leading-5 font-[300] md:ml-[17px] md:text-left md:text-sm" class="mb-5 w-full text-center text-xs leading-5 font-[300] md:ml-[17px] md:text-left md:text-sm"
> >
<p>先进加密协议 - 安全有保障</p> <p>加密协议-安全有保障</p>
<p>全真实IEPL专线 - IP纯净且稳定</p> <p>IEPL专线-纯净稳定</p>
<p>4K高清 / 不限流 - 网速多快, Hi快多快</p> <p>不限速/不限流-网速多快Hi快多快</p>
<p>极速闪连功能 - 连接永远快人一步</p> <p>极速闪连-永远快人一步</p>
<div class="flex justify-center md:justify-start">
<a
href="https://x.com/hifasttech"
target="_blank"
class="mt-[12px] flex h-[30px] w-[100px] shrink-0 items-center justify-center space-x-2 rounded-full transition-transform hover:brightness-110 md:mt-[16px]"
style="
backdrop-filter: blur(36px);
box-shadow:
0px 0px 33px 0px #f2f2f280 inset,
-1px -1.5px 1.5px -3px #b3b3b3 inset,
3px 4.5px 1.5px -3px #b3b3b333 inset,
4.5px 4.5px 1.5px -5.25px #ffffff80 inset,
-4.5px -4.5px 1.5px -5.25px #ffffff80 inset;
border-image-source: linear-gradient(
135deg,
rgba(255, 255, 255, 0.4) 0%,
rgba(255, 255, 255, 0) 30%
);
border-image-slice: 1;
"
>
<div class="flex items-center justify-center">
<img src="./x-logo.png" class="h-[16px] w-[77px]" />
</div>
</a>
</div>
</div> </div>
<div class="mx-auto mb-1 h-[20px] w-[352px] md:ml-[17px]"> <div class="mx-auto mb-1 h-[20px] w-[352px] md:ml-[17px]">
@@ -84,19 +132,30 @@
</div> </div>
<div class="text-center text-[10px] leading-[14px] font-[300] md:ml-[17px] md:text-left"> <div class="text-center text-[10px] leading-[14px] font-[300] md:ml-[17px] md:text-left">
<span class="font-[600]">Hi快VPN</span> &copy; All rights reserved.<br /> <span class="font-[600]">Hi快VPN</span> &copy; All rights reserved.<br />
<a href="#" class="underline">Terms of Service</a> <router-link to="/terms-of-service" class="underline">Terms of Service</router-link>
<a href="#" class="ml-2 underline">Privacy Policy</a> <router-link to="/privacy-policy" class="ml-2 underline">Privacy Policy</router-link>
</div> </div>
</div> </div>
<!-- Right Column: Phone Screenshot --> <!-- Right Column: Phone Screenshot -->
<div class="relative hidden w-full md:mt-0 md:block"> <div class="relative hidden w-full max-w-[632px] md:mt-0 md:block">
<!-- Decorative elements behind phone --> <!-- Screenshot with Ripple Effect -->
<img <div class="absolute right-[9%] bottom-0 z-50 aspect-square w-[58%] overflow-hidden">
:src="ScreenshotDesktop" <!-- Ripple Animation (Background) -->
alt="App Screenshot" <div class="ripple-container absolute inset-0">
class="absolute right-[52px] -bottom-[62px] w-[632px]" <div class="ripple ripple-outer-3"></div>
/> <div class="ripple ripple-outer-2"></div>
<div class="ripple ripple-outer-1"></div>
<div class="ripple ripple-core"></div>
</div>
<!-- Center Image (Foreground/Top Layer) -->
<div
class="absolute inset-0 bottom-[36px] z-100 bg-[url('@/pages/Home/connected-bg.png')] bg-cover bg-center bg-no-repeat"
></div>
</div>
<div class="absolute right-[2vw] bottom-0 max-w-[632px]">
<img :src="ScreenshotDesktop" alt="App Screenshot" class="relative z-10 w-full" />
</div>
</div> </div>
</main> </main>
</div> </div>
@@ -106,25 +165,49 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useLocalStorage } from '@vueuse/core'
import type { LocationQueryValue } from 'vue-router' import type { LocationQueryValue } from 'vue-router'
import LoginFormModal from './components/LoginFormModal.vue' import LoginFormModal from './components/LoginFormModal.vue'
import DownloadButton from './components/DownloadButton.vue' import DownloadButton from './components/DownloadButton.vue'
import Logo from './logo.svg?component' import Logo from './logo.svg?component'
import MobileLogo from './mobile-logo.svg?component' import MobileLogo from './mobile-logo.svg?component'
import ScreenshotMobile from './screenshot-mobile.png' import ScreenshotMobile from './screenshot-mobile.png'
import ScreenshotDesktop from './screenshot-desktop.png' import ScreenshotDesktop from './screenshot-desktop.webp'
import request from '@/utils/request'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const loginModalRef = ref<InstanceType<typeof LoginFormModal> | null>(null) const token = useLocalStorage('Authorization', '')
const userEmail = useLocalStorage('UserEmail', '')
const openLoginModal = () => { const isLoggedIn = computed(() => !!token.value)
loginModalRef.value?.show() const userLetter = computed(() => {
if (!userEmail.value) return '?'
return userEmail.value.charAt(0).toUpperCase()
})
const fetchUserInfo = async () => {
if (!token.value) return
try {
const res = (await request.get('/api/v1/public/user/info')) as any
const emailInfo = res.auth_methods?.find((item: any) => item.auth_type === 'email')
if (emailInfo) {
userEmail.value = emailInfo.auth_identifier
}
} catch (error: any) {
console.error('Failed to fetch user info:', error)
if (error?.code === 401 || error?.status === 401) {
token.value = ''
userEmail.value = ''
}
}
} }
onMounted(() => { onMounted(() => {
fetchUserInfo()
if (route.query.login === 'true') { if (route.query.login === 'true') {
openLoginModal() openLoginModal()
router.replace({ query: { ...route.query, login: undefined } }) router.replace({ query: { ...route.query, login: undefined } })
@@ -140,4 +223,70 @@ watch(
} }
}, },
) )
const loginModalRef = ref<InstanceType<typeof LoginFormModal> | null>(null)
const openLoginModal = () => {
loginModalRef.value?.show()
}
</script> </script>
<style scoped>
.ripple-container {
position: relative;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 50;
}
.ripple {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: #a8ff53; /* Brand neon green */
will-change: width, height, opacity;
}
/* Core: Fixed minimum diameter (~57% of max) */
.ripple-core {
width: 57%;
height: 57%;
opacity: 1;
z-index: 4;
}
/* Outer Layer 1: Opacity 0.8, 5.4s */
.ripple-outer-1 {
animation: breathe 5.4s ease-in-out infinite alternate;
opacity: 0.8;
z-index: 3;
}
/* Outer Layer 2: Opacity 0.6, 4.2s */
.ripple-outer-2 {
animation: breathe 4.2s ease-in-out infinite alternate;
opacity: 0.6;
z-index: 2;
}
/* Outer Layer 3 (Outer-most): Opacity 0.4, 3s */
.ripple-outer-3 {
animation: breathe 3s ease-in-out infinite alternate;
opacity: 0.4;
z-index: 1;
}
@keyframes breathe {
0% {
width: 57%;
height: 57%;
}
100% {
width: 100%;
height: 100%;
}
}
</style>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+53
View File
@@ -0,0 +1,53 @@
<template>
<div class="min-h-screen bg-black text-white" style="font-family: 'Roboto', sans-serif">
<!-- Main Content -->
<main class="mx-auto max-w-[1000px] px-6 md:px-2 md:pt-16 md:pb-[50px]">
<!-- Mobile Images -->
<div class="md:hidden">
<img
src="./mobile/row-1-column-1.webp"
alt="Terms of Service - Part 1"
class="mb-0 w-full"
/>
<img
src="./mobile/row-2-column-1.webp"
alt="Terms of Service - Part 2"
class="mb-0 w-full"
/>
<img
src="./mobile/row-3-column-1.webp"
alt="Terms of Service - Part 3"
class="mb-0 w-full"
/>
</div>
<!-- Desktop Images (2x) -->
<div class="hidden md:block">
<img
src="./pc/row1.png"
alt="Terms of Service - Part 1"
class="mb-10 origin-left scale-50"
/>
<img
src="./pc/row2.png"
alt="Terms of Service - Part 2"
class="mb-0 w-full"
style="width: 100%; height: auto"
/>
<img
src="./pc/row3.png"
alt="Terms of Service - Part 3"
class="m-auto mt-[60px] h-[28px] w-[594px]"
/>
</div>
</main>
</div>
</template>
<script setup lang="ts">
// No additional logic needed for this static page
</script>
<style scoped>
/* No additional styles needed */
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+49
View File
@@ -0,0 +1,49 @@
<template>
<div class="min-h-screen bg-black text-white" style="font-family: 'Roboto', sans-serif">
<!-- Main Content -->
<main class="mx-auto max-w-[1000px] px-6 md:px-2 md:pt-16 md:pb-[50px]">
<!-- Mobile Images -->
<div class="md:hidden">
<img
src="./mobile/row-1-column-1.webp"
alt="Terms of Service - Part 1"
class="mb-0 w-full"
/>
<img
src="./mobile/row-2-column-1.webp"
alt="Terms of Service - Part 2"
class="mb-0 w-full"
/>
<img
src="./mobile/row-3-column-1.webp"
alt="Terms of Service - Part 3"
class="mb-0 w-full"
/>
</div>
<!-- Desktop Images (2x) -->
<div class="hidden md:block">
<img src="./pc/row1.png" alt="Terms of Service - Part 1" class="mb-10 h-[18px] w-[206px]" />
<img
src="./pc/row2.png"
alt="Terms of Service - Part 2"
class="mb-0 w-full"
style="width: 100%; height: auto"
/>
<img
src="./pc/row3.png"
alt="Terms of Service - Part 3"
class="m-auto mt-[60px] h-[70px] w-[693px]"
/>
</div>
</main>
</div>
</template>
<script setup lang="ts">
// No additional logic needed for this static page
</script>
<style scoped>
/* No additional styles needed */
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+45 -5
View File
@@ -1,18 +1,26 @@
<template> <template>
<!-- Main Neon Green Card --> <!-- Main Neon Green Card -->
<div class="flex h-[678px]"> <div class="flex h-[678px] justify-center">
<div <div
class="flex w-[345px] flex-col justify-between rounded-4xl border-5 border-white py-[32px] pb-[22px]" class="flex w-[345px] flex-col justify-between rounded-4xl border-5 border-white py-[32px] pb-[22px]"
> >
<div> <div>
<div class="mb-3 flex flex-col items-center"> <div class="mb-3 flex min-h-[88px] flex-col items-center">
<Transition name="fade" mode="out-in">
<UserCenterSkeleton v-if="isUserLoading" type="header" layout="desktop" />
<div v-else class="flex flex-col items-center">
<img src="../avatar.png" class="size-[60px]" alt="" /> <img src="../avatar.png" class="size-[60px]" alt="" />
<div class="flex flex-col justify-center text-white"> <div class="flex flex-col justify-center text-white">
<div class="text-xl font-semibold">{{ userInfo.email }}</div> <div class="text-xl font-semibold">{{ userInfo.email }}</div>
</div> </div>
</div> </div>
</Transition>
</div>
<div class="mb-5 px-[20px] text-white"> <div class="mb-5 px-[20px] text-white">
<DeviceList :devices="devices" @refresh="emit('refresh')" /> <Transition name="fade" mode="out-in">
<UserCenterSkeleton v-if="isUserLoading" type="devices" layout="desktop" />
<DeviceList v-else :devices="devices" @refresh="emit('refresh')" />
</Transition>
</div> </div>
</div> </div>
<div> <div>
@@ -43,17 +51,34 @@
class="ml-2.5 flex items-center overflow-hidden rounded-4xl bg-[#A8FF53] pt-[32px] pb-[22px]" class="ml-2.5 flex items-center overflow-hidden rounded-4xl bg-[#A8FF53] pt-[32px] pb-[22px]"
> >
<div class="h-full w-[345px]"> <div class="h-full w-[345px]">
<PlanCard :plans="plans" :currentPlanIndex="currentPlanIndex" @select="handlePlanSelect" /> <Transition name="fade" mode="out-in">
<div v-if="isPlansLoading" class="p-8">
<UserCenterSkeleton type="plans" layout="desktop" />
</div>
<PlanCard
v-else
:plans="plans"
:currentPlanIndex="currentPlanIndex"
@select="handlePlanSelect"
/>
</Transition>
</div> </div>
<div <div
class="mx-[5px] h-[624px] w-[1px] bg-[url(@/pages/userCenter/DesktopLayout/Line-8.png)]" class="mx-[5px] h-[624px] w-[1px] bg-[url(@/pages/UserCenter/DesktopLayout/Line-8.png)]"
></div> ></div>
<div class="h-full w-[345px]"> <div class="h-full w-[345px]">
<Transition name="fade" mode="out-in">
<div v-if="isPaymentsLoading" class="p-8">
<UserCenterSkeleton type="payments" layout="desktop" />
</div>
<PaymentMethod <PaymentMethod
v-else
:methods="payments" :methods="payments"
:selectedPlan="selectedPlan" :selectedPlan="selectedPlan"
:is-paying="isPaying"
@pay="(id: number | string) => $emit('pay', id)" @pay="(id: number | string) => $emit('pay', id)"
/> />
</Transition>
</div> </div>
</div> </div>
</div> </div>
@@ -64,6 +89,7 @@ import { computed } from 'vue'
import PlanCard from '@/components/user-center/PlanCard.vue' import PlanCard from '@/components/user-center/PlanCard.vue'
import DeviceList from '@/components/user-center/DeviceList.vue' import DeviceList from '@/components/user-center/DeviceList.vue'
import PaymentMethod from '@/components/user-center/PaymentMethod.vue' import PaymentMethod from '@/components/user-center/PaymentMethod.vue'
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -76,6 +102,10 @@ const props = defineProps<{
userInfo: { email: string } userInfo: { email: string }
selectedPlanId: string selectedPlanId: string
selectedPlan: any selectedPlan: any
isPaying: boolean
isUserLoading: boolean
isPlansLoading: boolean
isPaymentsLoading: boolean
}>() }>()
const emit = defineEmits(['select-plan', 'pay', 'refresh']) const emit = defineEmits(['select-plan', 'pay', 'refresh'])
@@ -137,4 +167,14 @@ function logout() {
.tracking-tight { .tracking-tight {
font-family: 'Inter', 'PingFang SC', sans-serif; font-family: 'Inter', 'PingFang SC', sans-serif;
} }
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style> </style>
+42 -2
View File
@@ -2,6 +2,9 @@
<!-- Main Neon Green Card --> <!-- Main Neon Green Card -->
<div class="pt-[35px]"> <div class="pt-[35px]">
<div class="mb-3 ml-[31px] flex h-[60px] items-center gap-3"> <div class="mb-3 ml-[31px] flex h-[60px] items-center gap-3">
<Transition name="fade" mode="out-in">
<UserCenterSkeleton v-if="isUserLoading" type="header" layout="mobile" />
<div v-else class="flex items-center gap-3 text-white">
<img src="../avatar.png" class="size-[60px]" alt="" /> <img src="../avatar.png" class="size-[60px]" alt="" />
<div class="flex h-full flex-col justify-center text-white"> <div class="flex h-full flex-col justify-center text-white">
<div class="text-xl font-semibold">{{ userInfo.email }}</div> <div class="text-xl font-semibold">{{ userInfo.email }}</div>
@@ -10,20 +13,42 @@
</div> </div>
</div> </div>
</div> </div>
</Transition>
</div>
<div class="mb-5 pr-[25px] pl-[21px] text-white"> <div class="mb-5 pr-[25px] pl-[21px] text-white">
<DeviceList :devices="devices" @refresh="emit('refresh')" /> <Transition name="fade" mode="out-in">
<UserCenterSkeleton v-if="isUserLoading" type="devices" layout="mobile" />
<DeviceList v-else :devices="devices" @refresh="emit('refresh')" />
</Transition>
</div> </div>
<div class="overflow-hidden rounded-4xl bg-[#A8FF53]"> <div class="overflow-hidden rounded-4xl bg-[#A8FF53]">
<div class="pt-7"> <div class="pt-7">
<PlanCard :plans="plans" :currentPlanIndex="currentPlanIndex" @select="handlePlanSelect" /> <Transition name="fade" mode="out-in">
<div v-if="isPlansLoading" class="px-6">
<UserCenterSkeleton type="plans" layout="mobile" />
</div>
<PlanCard
v-else
:plans="plans"
:currentPlanIndex="currentPlanIndex"
@select="handlePlanSelect"
/>
</Transition>
</div> </div>
<div class="pt-7"> <div class="pt-7">
<Transition name="fade" mode="out-in">
<div v-if="isPaymentsLoading" class="px-6">
<UserCenterSkeleton type="payments" layout="mobile" />
</div>
<PaymentMethod <PaymentMethod
v-else
:methods="payments" :methods="payments"
:selectedPlan="selectedPlan" :selectedPlan="selectedPlan"
:is-paying="isPaying"
@pay="(id: number | string) => $emit('pay', id)" @pay="(id: number | string) => $emit('pay', id)"
/> />
</Transition>
</div> </div>
</div> </div>
@@ -51,6 +76,7 @@ import { computed } from 'vue'
import PlanCard from '@/components/user-center/PlanCard.vue' import PlanCard from '@/components/user-center/PlanCard.vue'
import DeviceList from '@/components/user-center/DeviceList.vue' import DeviceList from '@/components/user-center/DeviceList.vue'
import PaymentMethod from '@/components/user-center/PaymentMethod.vue' import PaymentMethod from '@/components/user-center/PaymentMethod.vue'
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -63,6 +89,10 @@ const props = defineProps<{
userInfo: { email: string } userInfo: { email: string }
selectedPlanId: string selectedPlanId: string
selectedPlan: any selectedPlan: any
isPaying: boolean
isUserLoading: boolean
isPlansLoading: boolean
isPaymentsLoading: boolean
}>() }>()
const emit = defineEmits(['select-plan', 'pay', 'refresh']) const emit = defineEmits(['select-plan', 'pay', 'refresh'])
@@ -124,4 +154,14 @@ function logout() {
.tracking-tight { .tracking-tight {
font-family: 'Inter', 'PingFang SC', sans-serif; font-family: 'Inter', 'PingFang SC', sans-serif;
} }
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style> </style>
+103 -22
View File
@@ -2,12 +2,12 @@
<div class="flex min-h-screen flex-col bg-black text-black"> <div class="flex min-h-screen flex-col bg-black text-black">
<!-- Full Width Header --> <!-- Full Width Header -->
<div class="h-[88px] md:h-[125px]"> <div class="h-[88px] md:h-[125px]">
<div class="fixed top-[20px] z-50 w-full md:top-[45px]"> <div class="fixed z-50 w-full bg-black pt-[20px] md:pt-[45px]">
<div class="container"> <div class="container">
<header <header
class="flex h-[68px] items-center justify-between rounded-full bg-[#ADFF5B] pr-[30px] pl-6 md:h-[80px]" class="flex min-h-[66px] items-center justify-between rounded-full bg-[#ADFF5B] pr-[30px] pl-6 md:h-[80px] md:pr-[58px] md:pl-[41px]"
> >
<div class="flex items-center gap-2"> <router-link to="/" class="flex items-center gap-2">
<!-- Desktop Logo --> <!-- Desktop Logo -->
<!-- <Logo :src="Logo" alt="Hi快VPN" class="hidden h-10 w-auto text-black md:block" />--> <!-- <Logo :src="Logo" alt="Hi快VPN" class="hidden h-10 w-auto text-black md:block" />-->
<!-- Mobile Logo --> <!-- Mobile Logo -->
@@ -16,12 +16,13 @@
alt="Hi快VPN" alt="Hi快VPN"
class="block h-[28px] text-black md:h-[50px]" class="block h-[28px] text-black md:h-[50px]"
/> />
</div> </router-link>
<div class="text-xl font-[600] text-black md:text-3xl">个人账户</div> <div class="text-xl font-[600] text-black md:text-3xl">个人账户</div>
</header> </header>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-1 flex-col">
<!-- Main Neon Green Card --> <!-- Main Neon Green Card -->
<div class="container md:hidden"> <div class="container md:hidden">
<MobileLayout <MobileLayout
@@ -32,12 +33,17 @@
:user-info="userSubInfo" :user-info="userSubInfo"
:selected-plan-id="selectedPlanId" :selected-plan-id="selectedPlanId"
:selected-plan="activePlan" :selected-plan="activePlan"
:is-paying="isPaying"
:is-user-loading="isUserLoading"
:is-plans-loading="isPlansLoading"
:is-payments-loading="isPaymentsLoading"
@select-plan="handlePlanSelect" @select-plan="handlePlanSelect"
@pay="handlePay" @pay="handlePay"
@refresh="init" @refresh="init"
/> />
</div> </div>
<div class="container mx-auto hidden flex-1 items-center justify-center md:flex"> <div class="hidden flex-1 items-center justify-center md:flex md:pb-[50px]">
<div class="container mx-auto">
<DesktopLayout <DesktopLayout
:already-subscribed="alreadySubscribed" :already-subscribed="alreadySubscribed"
:devices="devices" :devices="devices"
@@ -46,25 +52,43 @@
:user-info="userSubInfo" :user-info="userSubInfo"
:selected-plan-id="selectedPlanId" :selected-plan-id="selectedPlanId"
:selected-plan="activePlan" :selected-plan="activePlan"
:is-paying="isPaying"
:is-user-loading="isUserLoading"
:is-plans-loading="isPlansLoading"
:is-payments-loading="isPaymentsLoading"
@select-plan="handlePlanSelect" @select-plan="handlePlanSelect"
@pay="handlePay" @pay="handlePay"
@refresh="init" @refresh="init"
/> />
</div> </div>
</div> </div>
</div>
<OrderStatusDialog ref="orderStatusDialogRef" @close="handleStatusClose" @refresh="init" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MobileLayout from './MobileLayout/index.vue' import MobileLayout from './MobileLayout/index.vue'
import DesktopLayout from './DesktopLayout/index.vue' import DesktopLayout from './DesktopLayout/index.vue'
import OrderStatusDialog from '@/components/user-center/OrderStatusDialog.vue'
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
import Logo from '@/pages/Home/logo.svg?component' import Logo from '@/pages/Home/logo.svg?component'
import MobileLogo from '@/pages/Home/mobile-logo.svg?component' import MobileLogo from '@/pages/Home/mobile-logo.svg?component'
import request from '@/utils/request' import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
// --- State --- // --- State ---
const selectedPlanId = ref('p2') const selectedPlanId = ref('p2')
const selectedPayment = ref('alipay') // This variable is no longer directly used for payment selection in the UI, but kept for potential future use or if other parts of the app rely on it. const isPaying = ref(false)
const isUserLoading = ref(true)
const isPlansLoading = ref(true)
const isPaymentsLoading = ref(true)
const orderStatusDialogRef = ref<InstanceType<typeof OrderStatusDialog> | null>(null)
const selectedPayment = ref('alipay')
const devices = ref<any[]>([]) const devices = ref<any[]>([])
const plans = ref<any[]>([]) const plans = ref<any[]>([])
@@ -125,12 +149,13 @@ const handlePlanSelect = (id: string) => {
} }
const handlePay = (methodId: number | string) => { const handlePay = (methodId: number | string) => {
if (isPaying.value) return
const plan = plans.value.find((p) => p.id === selectedPlanId.value) const plan = plans.value.find((p) => p.id === selectedPlanId.value)
if (!plan) return if (!plan) return
const already = alreadySubscribed.value.find((s: any) => s.subscribe_id === plan.planId) const already = alreadySubscribed.value.find((s: any) => s.subscribe_id === plan.planId)
const isRenewal = !!already const isRenewal = !!already
const api = isRenewal ? '/api/v1//public/order/renewal' : '/api/v1/public/order/purchase' const api = isRenewal ? '/api/v1/public/order/renewal' : '/api/v1/public/order/purchase'
const params: any = { const params: any = {
subscribe_id: plan.planId, subscribe_id: plan.planId,
quantity: plan.quantity, quantity: plan.quantity,
@@ -140,53 +165,109 @@ const handlePay = (methodId: number | string) => {
if (isRenewal) { if (isRenewal) {
params.user_subscribe_id = already.id params.user_subscribe_id = already.id
} }
console.log(params) isPaying.value = true
request.post(api, params).then((res: any) => { request
.post(api, params)
.then((res: any) => {
request request
.post('/api/v1/public/portal/order/checkout', { .post('/api/v1/public/portal/order/checkout', {
orderNo: res.order_no, orderNo: res.order_no,
returnUrl: window.location.origin, returnUrl: `${window.location.origin}/user-center?order_no=${res.order_no}`,
}) })
.then((checkoutRes: any) => { .then((checkoutRes: any) => {
if (checkoutRes.type === 'url' && checkoutRes.checkout_url) { if (checkoutRes.type === 'url' && checkoutRes.checkout_url) {
localStorage.setItem('pending_order_no', res.order_no)
console.log('pending_order_no', res.order_no)
setTimeout(() => {
window.location.href = checkoutRes.checkout_url window.location.href = checkoutRes.checkout_url
})
} }
}) })
.finally(() => {
isPaying.value = false
})
})
.catch(() => {
isPaying.value = false
}) })
} }
function init() { async function init() {
// 用户信息 & 设备列表 // 1. 用户信息 & 设备列表
request.get('/api/v1/public/user/info').then((res: any) => { isUserLoading.value = true
request
.get('/api/v1/public/user/info')
.then((res: any) => {
devices.value = res.user_devices devices.value = res.user_devices
const emailInfo = res.auth_methods?.find((item: any) => item.auth_type === 'email') const emailInfo = res.auth_methods?.find((item: any) => item.auth_type === 'email')
if (emailInfo) { if (emailInfo) {
userSubInfo.value.email = emailInfo.auth_identifier userSubInfo.value.email = emailInfo.auth_identifier
localStorage.setItem('UserEmail', emailInfo.auth_identifier)
} }
}) })
.finally(() => {
isUserLoading.value = false
})
// 已订阅列表 // 2. 已订阅列表 (非阻塞主要展示)
request.get('/api/v1/public/user/subscribe').then((res: any) => { request.get('/api/v1/public/user/subscribe').then((res: any) => {
alreadySubscribed.value = res.list || [] alreadySubscribed.value = res.list || []
}) })
// 订阅套餐列表 // 3. 订阅套餐列表
request.get('/api/v1/public/subscribe/list').then((res: any) => { isPlansLoading.value = true
request
.get('/api/v1/public/subscribe/list')
.then((res: any) => {
plans.value = mapPlans(res.list || []) plans.value = mapPlans(res.list || [])
if (plans.value.length > 0) { if (plans.value.length > 0) {
selectedPlanId.value = plans.value[0].id selectedPlanId.value = plans.value[0].id
} }
}) })
.finally(() => {
isPlansLoading.value = false
})
// 获取支付方式 // 4. 获取支付方式
request.get('/api/v1/public/payment/methods').then((res: any) => { isPaymentsLoading.value = true
console.log(res) request
payments.value = res.list?.filter((p: any) => p.platform !== 'apple_iap') || [] .get('/api/v1/public/payment/methods')
.then((res: any) => {
payments.value =
res.list?.filter((p: any) => p.platform !== 'apple_iap' && p.platform !== 'Stripe') || []
})
.finally(() => {
isPaymentsLoading.value = false
}) })
} }
init()
onMounted(() => {
init()
const orderNo = (route.query.order_no as string) || localStorage.getItem('pending_order_no')
if (orderNo) {
orderStatusDialogRef.value?.show(orderNo)
}
})
function handleStatusClose() {
localStorage.removeItem('pending_order_no')
// 清除 URL 中的 order_no,防止刷新再次弹出
router.replace({ query: { ...route.query, order_no: undefined } })
}
</script> </script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
<style scoped> <style scoped>
/* Simplified layout font */ /* Simplified layout font */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
+10
View File
@@ -18,6 +18,16 @@ const router = createRouter({
name: 'user-center', name: 'user-center',
component: () => import('../pages/UserCenter/index.vue'), component: () => import('../pages/UserCenter/index.vue'),
}, },
{
path: '/terms-of-service',
name: 'terms-of-service',
component: () => import('../pages/TermsOfService/index.vue'),
},
{
path: '/privacy-policy',
name: 'privacy-policy',
component: () => import('../pages/PrivacyPolicy/index.vue'),
},
], ],
}) })
+8
View File
@@ -1,3 +1,11 @@
@font-face {
font-family: 'Roboto';
src: url('/src/styles/roboto.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@layer utilities { @layer utilities {
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
export const downLoadAndroid = 'https://h.hifastapp.com/download/app-arm64-v8a-release.apk'
export const downLoadMac = 'https://h.hifastapp.com/download/HiFastVPN-1.0.0+100-macos.dmg'
export const downLoadWin = 'https://h.hifastapp.com/download/HiFastVPN-0.0.2-windows-setup.exe'
+104 -55
View File
@@ -52,6 +52,68 @@ interface ResponseType extends AxiosResponse {
config: RequestConfig config: RequestConfig
} }
const ERROR_MESSAGES: Record<number | string, string> = {
'200': '成功',
'500': '内部服务器错误',
'10001': '数据库查询错误',
'10002': '数据库更新错误',
'10003': '数据库插入错误',
'10004': '数据库删除错误',
'20001': '用户已存在',
'20002': '用户不存在',
'20003': '用户密码错误',
'20004': '用户已禁用',
'20005': '余额不足',
'20006': '停止注册',
'20007': '未绑定Telegram',
'20008': '用户未绑定OAuth方式',
'20009': '邀请码错误',
'30001': '节点已存在',
'30002': '节点不存在',
'30003': '节点组已存在',
'30004': '节点组不存在',
'30005': '节点组不为空',
'400': '参数错误',
'40002': '用户令牌为空',
'40003': '用户令牌无效',
'40004': '用户令牌已过期',
'40005': '您还没有登录',
'401': '请求过多',
'50001': '优惠券不存在',
'50002': '优惠券已被使用',
'50003': '优惠券不匹配',
'60001': '订阅已过期',
'60002': '订阅不可用',
'60003': '用户已有订阅',
'60004': '订阅已被使用',
'60005': '单一订阅模式超出限制',
'60006': '订阅配额限制',
'70001': '验证码错误',
'80001': '队列入队错误',
'90001': '调试模式已启用',
'90002': '发送短信错误',
'90003': '短信功能未启用',
'90004': '电子邮件功能未启用',
'90005': '不支持的登录方式',
'90006': '身份验证器不支持此方式',
'90007': '电话区号为空',
'90008': '密码为空',
'90009': '区号为空',
'90010': '需要密码或验证码',
'90011': '电子邮件已存在',
'90012': '电话号码已存在',
'90013': '设备已存在',
'90014': '电话号码错误',
'90015': '此账户今日已达到发送次数限制',
'90017': '设备不存在',
'90018': '用户 ID 不匹配',
'61001': '订单不存在',
'61002': '支付方式未找到',
'61003': '订单状态错误',
'61004': '重置周期不足',
'61005': '存在没用完的流量',
}
export default class Request { export default class Request {
public axiosInstance: AxiosInstance public axiosInstance: AxiosInstance
@@ -91,36 +153,27 @@ export default class Request {
...config.extraConfig, ...config.extraConfig,
} }
if (config.data && !(config.data instanceof FormData)) { // if (config.data && !(config.data instanceof FormData)) {
const plainText = JSON.stringify(config.data) // const plainText = JSON.stringify(config.data)
// 加密后,config.data 会变成 { data: '...', time: '...' } // config.data = HiAesUtil.encryptData(plainText, encryptionKey)
config.data = HiAesUtil.encryptData(plainText, encryptionKey) // }
} //
// if (config.method?.toLowerCase() === 'get' || config.params) {
if (config.method?.toLowerCase() === 'get' || config.params) { // const paramsToEncrypt = config.params || {}
const paramsToEncrypt = config.params || {} // 为空则加密 "{}" // const plainParamsText = JSON.stringify(paramsToEncrypt)
const plainParamsText = JSON.stringify(paramsToEncrypt) // const encryptedParams = HiAesUtil.encryptData(plainParamsText, encryptionKey)
const encryptedParams = HiAesUtil.encryptData(plainParamsText, encryptionKey) //
// config.params = {
// 将原参数替换为加密后的 data 和 time 字段 // data: encryptedParams.data,
config.params = { // time: encryptedParams.time,
data: encryptedParams.data, // }
time: encryptedParams.time, // }
}
}
if (config.data?.time) {
console.log(
'解密',
HiAesUtil.decryptData(config.data.data, config.data.time, encryptionKey),
)
}
config.headers = mergeExtraConfig.formatHeader({ config.headers = mergeExtraConfig.formatHeader({
...this.config.headers, ...this.config.headers,
...config.headers, ...config.headers,
lang: 'zh_CN', lang: 'zh_CN',
'login-type': 'device', // 'login-type': 'device',
...(mergeExtraConfig.withToken && { ...(mergeExtraConfig.withToken && {
[mergeExtraConfig.tokenKey]: mergeExtraConfig.getToken(), [mergeExtraConfig.tokenKey]: mergeExtraConfig.getToken(),
}), }),
@@ -140,52 +193,48 @@ export default class Request {
this.axiosInstance.interceptors.response.use( this.axiosInstance.interceptors.response.use(
(response: ResponseType) => { (response: ResponseType) => {
const { data, config } = response const { data, config } = response
let responseData = response.data.data const responseData = response.data.data
// 假设后端返回格式为 { data: "base64...", time: "..." } // if (responseData && responseData.data && responseData.time) {
if (responseData && responseData.data && responseData.time) { // try {
try { // const decryptedStr = HiAesUtil.decryptData(
const decryptedStr = HiAesUtil.decryptData( // responseData.data,
responseData.data, // responseData.time,
responseData.time, // encryptionKey,
encryptionKey, // )
) // responseData = JSON.parse(decryptedStr)
// 解密后转化为 JSON 对象供后续业务使用 // } catch (e) {
responseData = JSON.parse(decryptedStr) // console.error('解密失败:', e)
} catch (e) { // return Promise.reject({ message: '数据解密异常' })
console.error('解密失败:', e) // }
return Promise.reject({ message: '数据解密异常' }) // }
}
}
axiosCanceler.removePending(config) axiosCanceler.removePending(config)
if (data.code == 40004) {
toast.error('登录状态已失效, 请重新登录') if (data.code !== 200) {
const msg = ERROR_MESSAGES[data.code] || response.data?.msg || data?.error || '未知错误'
if (data.code == 40004 || data.code == 40003 || data.code == 40005) {
toast.error(msg)
redirectLogin() redirectLogin()
return return
} }
const resData = config.extraConfig?.originResponseData ? response : responseData
if (config.extraConfig?.handleResponse) { if (config.extraConfig?.handleResponse) {
if (data.code === 200) { this.errorReport(config.extraConfig.errorLevel ?? 2, msg)
return resData
}
this.errorReport(
config.extraConfig.errorLevel ?? 2,
response.data?.msg ?? data?.error ?? '未知错误',
)
return Promise.reject({ return Promise.reject({
...data, ...data,
message: response.data?.msg ?? data?.error ?? '未知错误', message: msg,
}) })
} else {
return resData
} }
}
return config.extraConfig?.originResponseData ? response : responseData
}, },
(error) => { (error) => {
const status = error?.response?.status const status = error?.response?.status
const code = error?.code const code = error?.code
let message = error?.message let message = error?.message
if (status === 401) { if (status === 401) {
// message = '未登录或登录状态失效'
redirectLogin() redirectLogin()
return return
} }