Compare commits

..

No commits in common. "main" and "develop" have entirely different histories.

70 changed files with 1387 additions and 3007 deletions

View File

@ -1 +1,2 @@
VITE_APP_BASE_URL=/
# https://api.hifast.biz
VITE_APP_BASE_URL=

View File

@ -1 +1,2 @@
VITE_APP_BASE_URL=/
# https://h.hifastapp.com
VITE_APP_BASE_URL=

View File

@ -1 +1,2 @@
VITE_APP_BASE_URL=/
https://h.hifastapp.com
VITE_APP_BASE_URL=

View File

@ -12,12 +12,12 @@ on:
env:
VITE_APP_BASE_URL: /
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/hi-landing-hero
DEPLOY_PATH: /var/www/down
# TG通知
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
TG_CHAT_ID: "-4940243803"
@ -42,7 +42,7 @@ jobs:
- name: Build dist with Unified Script
env:
VITE_APP_BASE_URL: "/"
VITE_APP_BASE_URL: "https://h.hifast.biz"
run: |
chmod +x scripts/ci-build.sh
./scripts/ci-build.sh

790
aaa.txt Normal file
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 }}
⚠️ 请检查构建日志获取详细信息

6
env.d.ts vendored
View File

@ -5,9 +5,3 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '*.svg?component' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -1,55 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HiFast VPN - 高速稳定的全球网络加速服务</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
<meta name="description" content="HiFast VPN 提供安全、匿名且高速的全球网络连接。支持 Windows, Mac, Android 等多平台下载,一键开启极速体验。" />
<meta name="keywords" content="VPN, 加速器, HiFast, 网络安全, 翻墙, 科学上网" />
<meta property="og:type" content="website" />
<meta property="og:title" content="HiFast VPN - 极速连接全球" />
<meta property="og:description" content="专业加密协议,保护隐私,解除地理限制。立即下载各平台客户端。" />
<!-- <meta property="og:image" content="https://h.hifastapp.com/og-image.png" />-->
<meta property="og:url" content="https://hifastvpn.com/" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="HiFast VPN" />
<!-- <link rel="apple-touch-icon" href="/icon-192x192.png" />-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=AW-17916853811">
</script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'AW-17916853811');
</script>
<title>HiFast VPN</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
(function(d,t) {
var BASE_URL="https://app.chatwoot.com";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js";
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken: 'YXQmh16ymNYW1SVybhnoQQ9w',
baseUrl: BASE_URL
})
}
})(document,"script");
</script>
</body>
</html>

550
package-lock.json generated
View File

@ -13,6 +13,7 @@
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"crisp-sdk-web": "^1.0.27",
"crypto-js": "^4.2.0",
"lucide-vue-next": "^0.562.0",
"reka-ui": "^2.7.0",
@ -23,7 +24,6 @@
"vue-sonner": "^2.0.9"
},
"devDependencies": {
"@csstools/postcss-cascade-layers": "^5.0.2",
"@tsconfig/node24": "^24.0.3",
"@types/node": "^24.10.4",
"@vitejs/plugin-vue": "^6.0.3",
@ -33,7 +33,6 @@
"eslint": "^9.39.2",
"eslint-plugin-vue": "~10.6.2",
"jiti": "^2.6.1",
"lightningcss": "^1.32.0",
"npm-run-all2": "^8.0.4",
"prettier": "3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
@ -467,56 +466,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@csstools/postcss-cascade-layers": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.2.tgz",
"integrity": "sha512-nWBE08nhO8uWl6kSAeCx4im7QfVko3zLrtgWZY4/bP87zrSPpSyN/3W3TDqz1jJuH+kbKOHXg5rJnK+ZVYcFFg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"dependencies": {
"@csstools/selector-specificity": "^5.0.0",
"postcss-selector-parser": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"postcss": "^8.4"
}
},
"node_modules/@csstools/selector-specificity": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz",
"integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"postcss-selector-parser": "^7.0.0"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.2",
"cpu": [
@ -913,255 +862,6 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.18",
"license": "MIT",
@ -2129,6 +1829,12 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/crisp-sdk-web": {
"version": "1.0.27",
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.0.27.tgz",
"integrity": "sha512-aNWR3te65YiaVFu/iwdqOo3cyUBZHUheE4d6EtgQu/T18jh/9SpoYXjXF/OzUD3Cqy0pGryoqtuy5gxD8tqX9Q==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"dev": true,
@ -3259,10 +2965,7 @@
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"devOptional": true,
"version": "1.30.2",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@ -3275,48 +2978,24 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"version": "1.30.2",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3330,195 +3009,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"dev": true,

View File

@ -21,6 +21,7 @@
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"crisp-sdk-web": "^1.0.27",
"crypto-js": "^4.2.0",
"lucide-vue-next": "^0.562.0",
"reka-ui": "^2.7.0",
@ -31,7 +32,6 @@
"vue-sonner": "^2.0.9"
},
"devDependencies": {
"@csstools/postcss-cascade-layers": "^5.0.2",
"@tsconfig/node24": "^24.0.3",
"@types/node": "^24.10.4",
"@vitejs/plugin-vue": "^6.0.3",
@ -41,7 +41,6 @@
"eslint": "^9.39.2",
"eslint-plugin-vue": "~10.6.2",
"jiti": "^2.6.1",
"lightningcss": "^1.32.0",
"npm-run-all2": "^8.0.4",
"prettier": "3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
@ -51,12 +50,5 @@
"vite-plugin-vue-devtools": "^8.0.5",
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^3.2.1"
},
"browserslist": [
"chrome >= 90",
"edge >= 90",
"safari >= 15",
"firefox >= 90"
]
}
}

8
pnpm-lock.yaml generated
View File

@ -23,6 +23,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
crisp-sdk-web:
specifier: ^1.0.27
version: 1.0.27
crypto-js:
specifier: ^4.2.0
version: 4.2.0
@ -1048,6 +1051,9 @@ packages:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
crisp-sdk-web@1.0.27:
resolution: {integrity: sha512-aNWR3te65YiaVFu/iwdqOo3cyUBZHUheE4d6EtgQu/T18jh/9SpoYXjXF/OzUD3Cqy0pGryoqtuy5gxD8tqX9Q==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -3047,6 +3053,8 @@ snapshots:
dependencies:
is-what: 5.5.0
crisp-sdk-web@1.0.27: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@ -7,13 +7,9 @@
position="top-center"
:toast-options="{
style: {
background: '#ddd',
color: '#000',
border: '1px solid rgba(255, 255, 255)',
},
classes: {
title: 'text-[20px] font-bold',
toast: 'rounded-[20px]', // 便 Dialog
background: '#000000',
color: '#ffffff',
border: '1px solid rgba(255, 255, 255, 0.1)',
},
}"
/>
@ -25,6 +21,28 @@ 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>

View File

@ -1,34 +1,37 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from './Button.vue'
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)

View File

@ -54,50 +54,43 @@ const countdown = ref('')
let timer: any = null
let countdownTimer: any = null
let createdAt: Date | null = null
/*
* static const int kr_statusPending = 1; //
static const int kr_statusPaid = 2; //
static const int kr_statusClose = 3; //
static const int kr_statusFailed = 4; //
static const int kr_statusFinished = 5; //
*
* */
const statusInfo = computed(() => {
switch (status.value) {
case -1:
return {
title: '检查失败',
description: '获取支付状态失败,请稍后刷新页面',
description: '检查支付状态失败,请稍后刷新页面或联系客服',
}
case 1: // kr_statusPending
case 1:
return {
title: '待支付',
description: `订单正在处理中\n剩余时间: ${countdown.value}`,
}
case 2: // kr_statusPaid
case 2:
return {
title: '支付确认中',
description: '订单已支付,系统正在确认,请稍候...',
}
case 3: // kr_statusClose
case 3:
return {
title: '支付成功',
description: '您的订单已支付完成',
}
case 4:
return {
title: '订单已关闭',
description: '该订单已超时或被手动关闭',
}
case 4: // kr_statusFailed
case 5:
return {
title: '支付失败',
description: '订单支付失败,请检查支付信息并重试',
}
case 5: // kr_statusFinished
return {
title: '支付成功',
description: '您的订单已支付完成,感谢您的支持',
}
default:
return {
title: '订单处理中',
description: '请稍候...',
title: '待支付',
description: `订单正在处理中\n剩余时间: ${countdown.value}`,
}
}
})
@ -123,16 +116,15 @@ async function checkStatus() {
status.value = res.status
// --- ---
if (status.value === 5) {
// 5: (kr_statusFinished)
// 2.
if (status.value === 3) {
// Finished
stopPolling()
emit('refresh')
} else if (status.value === 4 || status.value === 5) {
// Closed or Failed
stopPolling()
emit('refresh') //
} else if (status.value === 3 || status.value === 4) {
// 3: (kr_statusClose), 4: (kr_statusFailed)
stopPolling() //
}
// 1 2
} catch (error) {
console.error('❌ 查询失败:', error)
status.value = -1

View File

@ -1,6 +1,6 @@
<template>
<div class="md:flex md:h-full md:flex-col md:justify-between">
<div class="bg-[#A8FF53] px-6 font-sans text-black md:flex-1">
<div class="bg-[#A8FF53] px-6 font-sans text-black">
<h2 class="mb-1 text-center text-2xl font-bold">选择付款方式</h2>
<div class="flex flex-col gap-4 pt-[43px]">

View File

@ -4,93 +4,37 @@
<p class="mb-4 text-center text-sm font-[100] text-gray-600">*所有套餐均不限流量不限速度</p>
<div class="space-y-4">
<template v-for="(plan, index) in plans" :key="plan.id">
<!-- Special Trial Card -->
<div
v-for="(plan, index) in plans"
:key="plan.id"
@click="$emit('select', plan.id)"
:class="[
'relative h-[126px] cursor-pointer overflow-hidden rounded-[40px] border-4 border-black py-4 pl-[45px] transition-all',
currentPlanIndex === index ? 'bg-black text-white' : 'bg-[#A8FF53] text-black',
]"
>
<div class="text-2xl font-bold">{{ plan.days }}</div>
<div class="text-[40px] leading-none font-semibold">${{ plan.price }}</div>
<div :class="'text-sm'">约${{ plan.daily }}/</div>
<div
v-if="index === 0 && trialPlan"
@click="$emit('select', trialPlan.id)"
class="relative h-[126px] cursor-pointer overflow-hidden rounded-[40px] border-4 border-black bg-[#A8FF53] transition-all"
>
<!-- Left content (Original Plan but dimmed/struck) -->
<div class="py-4 pl-[45px] opacity-60">
<div class="text-2xl font-bold text-black">{{ plan.days }}</div>
<div class="relative w-fit text-[40px] leading-none font-semibold text-black">
${{ plan.price }}
<div class="absolute top-1/2 left-0 h-1 w-full -translate-y-1/2 bg-black/60"></div>
</div>
<div class="text-sm text-black">约${{ plan.daily }}/</div>
</div>
<!-- Right overlay (Trial Info) -->
<div
class="absolute top-0 right-0 bottom-0 flex h-[120px] w-[126px] flex-col justify-center rounded-bl-[40px] bg-black/60 text-[#ADFF5B]"
>
<div class="ml-[4px] flex h-full w-full flex-col rounded-bl-[40px] bg-black">
<div class="pt-2 pl-1">
<div class="text-lg font-bold">新客尝鲜价</div>
<div class="mb-1 text-xs">{{ trialCountdown }}</div>
</div>
<div
class="flex h-full flex-1 items-center justify-center rounded-bl-[40px] border-4 border-black bg-[#ADFF5B] text-4xl font-semibold"
:class="isTrialSelected ? 'bg-black text-white' : 'bg-[#ADFF5B] text-black'"
>
${{ trialPlan.price }}
</div>
</div>
</div>
</div>
<!-- Normal Card -->
<div
v-else
@click="$emit('select', plan.id)"
v-if="plan.discount"
:class="[
'relative h-[126px] cursor-pointer overflow-hidden rounded-[40px] border-4 border-black py-4 pl-[45px] transition-all',
currentPlanIndex === index ? 'bg-black text-white' : 'bg-[#A8FF53] text-black',
index > 1 ? 'font-semibold' : '',
currentPlanIndex === index ? 'bg-[#A8FF53]! text-black' : ' ',
]"
class="absolute top-[20px] -right-[40px] h-[16px] w-[126px] origin-center rotate-45 bg-black text-center text-[14px] leading-[16px] font-[200] text-[#ADFF5B]"
>
<div class="flex items-center gap-2 text-2xl font-bold">
<Goods90Icon
v-if="plan.days === 90"
:class="[currentPlanIndex === index ? 'text-[#ADFF5B]' : 'text-black']"
/>
{{ plan.days }}
</div>
<div class="text-[40px] leading-none font-semibold">${{ plan.price }}</div>
<div :class="'text-sm'">约${{ plan.daily }}/</div>
<div
v-if="plan.discount"
:class="[
currentPlanIndex === index ? 'bg-[#A8FF53]! text-black' : ' ',
index > 1 ? 'top-[15px] -right-[30px] h-[35px]' : 'top-[20px] -right-[40px] h-[16px]',
]"
class="absolute h-[16px] w-[126px] origin-center rotate-45 bg-black text-center text-[14px] leading-[16px] font-[200] font-semibold text-[#ADFF5B]"
>
<span
v-html="
plan.discount + (index === 2 ? '<br>年销百万' : index === 3 ? '<br>最划算' : '')
"
></span>
</div>
{{ plan.discount }}
</div>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Goods90Icon from './goods-90-icon.svg'
const props = defineProps({
defineProps({
plans: Array,
currentPlanIndex: Number,
trialPlan: Object,
trialCountdown: String,
selectedPlanId: [String, Number],
})
defineEmits(['select'])
const isTrialSelected = computed(() => {
return props.trialPlan && props.selectedPlanId === props.trialPlan.id
})
</script>

View File

@ -1,3 +0,0 @@
<svg width="22" height="21" viewBox="0 0 22 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9698 0.620247L14.559 6.21814C14.5851 6.27797 14.6271 6.32974 14.6804 6.36799C14.7338 6.40624 14.7967 6.42956 14.8623 6.43549L21.0502 7.139C21.254 7.16112 21.4469 7.24088 21.6059 7.3687C21.7649 7.49652 21.8832 7.66698 21.9466 7.85961C22.01 8.05223 22.0158 8.25884 21.9632 8.45462C21.9107 8.65041 21.8021 8.82705 21.6505 8.96334L17.063 13.126C17.0131 13.1689 16.9759 13.2244 16.9554 13.2866C16.9349 13.3488 16.9317 13.4153 16.9464 13.479L18.1814 19.5116C18.2232 19.7098 18.2063 19.9157 18.1325 20.1046C18.0587 20.2935 17.9312 20.4572 17.7654 20.5762C17.5996 20.6952 17.4026 20.7642 17.198 20.7751C16.9934 20.786 16.79 20.7382 16.6123 20.6375L11.1877 17.6127C11.1306 17.5799 11.0657 17.5627 10.9997 17.5627C10.9337 17.5627 10.8689 17.5799 10.8118 17.6127L5.38595 20.6375C5.20818 20.7379 5.00485 20.7855 4.80041 20.7745C4.59596 20.7634 4.39908 20.6943 4.23341 20.5754C4.06774 20.4565 3.94033 20.2929 3.86648 20.1042C3.79263 19.9155 3.77549 19.7097 3.8171 19.5116L5.05209 13.479C5.06671 13.4153 5.0636 13.3488 5.04307 13.2866C5.02255 13.2244 4.98538 13.1689 4.93551 13.126L0.348975 8.96286C0.197134 8.82666 0.0883434 8.65001 0.0356868 8.45417C-0.0169699 8.25832 -0.0112508 8.05162 0.0521563 7.85891C0.115563 7.6662 0.233959 7.49569 0.393104 7.36789C0.552249 7.24009 0.745368 7.16044 0.949237 7.13852L7.1381 6.43501C7.20376 6.42908 7.26659 6.40576 7.31997 6.36751C7.37335 6.32926 7.4153 6.27749 7.4414 6.21765L10.0297 0.620247C10.1137 0.435474 10.2499 0.278626 10.422 0.168585C10.5941 0.0585442 10.7947 0 10.9997 0C11.2047 0 11.4053 0.0585442 11.5774 0.168585C11.7495 0.278626 11.8858 0.435474 11.9698 0.620247Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -2,7 +2,6 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles/index.css'
import '@/utils/openinstall'
const app = createApp(App)

View File

@ -5,7 +5,7 @@
v-for="item in downloadMethods"
:key="item.id"
@click="toggle(item.id)"
class="group relative flex flex-col rounded-[30px] border-[4px] border-white bg-black pt-4 pb-2 transition-all 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:pl-[42px]">
<div
@ -35,7 +35,7 @@
</div>
</div>
<div v-if="activeId === item.id" class="px-2 pt-4 md:pt-8">
<div v-if="activeId === item.id" class="px-2 md:pt-8">
<div
class="animate-in fade-in zoom-in-95 border-t-2 border-white pt-[10px] text-[15px] leading-relaxed duration-200 md:pt-[20px]"
>
@ -51,7 +51,7 @@
</div>
<!-- Mobile Image Stack -->
<div class="relative -mt-10 flex min-h-[800px] flex-col md:hidden">
<div class="relative -mt-10 flex flex-col md:hidden">
<img
v-for="(img, idx) in item.mobileImages"
:key="idx"
@ -65,21 +65,17 @@
:key="hzIdx"
class="absolute z-20 cursor-pointer"
:style="{
left: hz.x + 'px',
top: hz.y + 'px',
width: hz.w + 'px',
height: hz.h + 'px',
left: hz.x + '%',
top: hz.y + '%',
width: hz.w + '%',
height: hz.h + '%',
}"
@click="handleHotzone(hz)"
>
<span class="text-xs font-bold text-black" v-if="hz.type === 'text'">{{
hz.payload
}}</span>
</div>
></div>
</div>
<!-- PC Image Stack -->
<div class="relative mt-4 hidden min-h-[800px] flex-col md:flex">
<div class="relative mt-4 hidden flex-col md:flex">
<img
v-for="(img, idx) in item.pcImages"
:key="idx"
@ -93,29 +89,18 @@
:key="hzIdx"
class="absolute z-20 cursor-pointer"
:style="{
left: hz.x + 'px',
top: hz.y + 'px',
width: hz.w + 'px',
height: hz.h + 'px',
left: hz.x + '%',
top: hz.y + '%',
width: hz.w + '%',
height: hz.h + '%',
}"
@click="handleHotzone(hz)"
>
<span class="text-xs font-bold text-black" v-if="hz.type === 'text'">{{
hz.payload
}}</span>
</div>
></div>
</div>
<!-- 底部收起按钮 -->
<div class="absolute right-4 bottom-4 z-10">
<div @click="toggle(item.id)" class="h-[30px] w-[200px]">
<div
class="hidden cursor-pointer items-center justify-end gap-[10px] rounded-full bg-black/40 px-3 py-1 text-xl backdrop-blur-sm transition-all hover:bg-black/60 md:flex"
>
<span class="tracking-tight">点击收起</span>
<ArrowIcon class="size-[12px] rotate-180 md:size-[22px]" />
</div>
</div>
<div @click="toggle(item.id)" class="h-[30px] w-[200px]"></div>
</div>
</div>
</div>
@ -126,7 +111,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref } from 'vue'
import ArrowIcon from './arrow-icon.svg?component'
import StartIcon from './Star-1.svg?component'
import { toast } from 'vue-sonner'
@ -155,238 +140,58 @@ 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'
import { getAllQueryString } from '@/utils/url-utils.ts'
const ic = getAllQueryString('ic') || sessionStorage.getItem('ic')
const ADJ_GO_LINK =
ic && typeof ic === 'string'
? `https://hifastvpn.go.link?adj_t=1xf6e7ru&inviteCode=${ic}`
: 'https://hifastvpn.go.link/?adj_t=1xf6e7ru'
const downLoadIos = ADJ_GO_LINK
interface Hotzone {
x: number
y: number
w: number
h: number
type: 'copy' | 'link' | 'text'
x: number //
y: number //
w: number //
h: number //
type: 'copy' | 'link'
payload: string
label?: string
}
const accounts = [
{ account: 'prla08741@gmx.com', password: 'Qw990088' },
{ account: 'zhongcujiu8051@hotmail.com', password: 'Dx114461' },
]
const selectedIndex = ref(0) //
const downloadMethods = computed(() => {
const account = accounts[selectedIndex.value]
return [
{
id: 1,
title: '创建新“香港Apple ID”',
subtitle: '首推',
isHot: true,
highlight: '',
mobileImages: [m1_1, m1_2, m1_3],
pcImages: [pc1_1, pc1_2, pc1_3],
mobileHotzones: [
{
x: 62,
y: 130,
w: 88,
h: 20,
type: 'link',
payload: 'https://account.apple.com/account',
},
{
x: 142,
y: 1952,
w: 88,
h: 20,
type: 'link',
payload: downLoadIos,
},
] as Hotzone[],
pcHotzones: [
{
x: 410,
y: 224,
w: 140,
h: 20,
type: 'link',
payload: 'https://account.apple.com/account',
},
{
x: 580,
y: 1689,
w: 88,
h: 20,
type: 'link',
payload: downLoadIos,
},
] as Hotzone[],
},
{
id: 2,
title: '修改已有 Apple ID 至 <香港>',
subtitle: '',
isHot: false,
highlight: '',
mobileImages: [m2_1, m2_2, m2_3],
pcImages: [pc2_1, pc2_2, pc2_3],
mobileHotzones: [
{
x: 62,
y: 130,
w: 88,
h: 20,
type: 'link',
payload: 'https://account.apple.com',
},
{
x: 142,
y: 2510,
w: 88,
h: 20,
type: 'link',
payload: downLoadIos,
},
] as Hotzone[],
pcHotzones: [
{
x: 410,
y: 224,
w: 140,
h: 20,
type: 'link',
payload: 'https://account.apple.com',
},
{
x: 580,
y: 2106,
w: 88,
h: 20,
type: 'link',
payload: downLoadIos,
},
] as Hotzone[],
},
{
id: 3,
title: '使用Hi快提供的公用账号',
subtitle: '',
isHot: false,
highlight: '谨慎选择',
mobileImages: [m3_1, m3_2, m3_3],
pcImages: [pc3_1, pc3_2, pc3_3],
mobileHotzones: [
{
x: 62,
y: 1950,
w: 170,
h: 20,
type: 'text', //
payload: account.account,
},
{
x: 62,
y: 1985,
w: 170,
h: 20,
type: 'text', //
payload: account.password,
},
{
x: 53,
y: 1941,
w: 200,
h: 32,
type: 'copy', //
payload: account.account,
label: '账号', // account
},
{
x: 53,
y: 1976,
w: 200,
h: 32,
type: 'copy', //
payload: account.password,
label: '密码', //
},
{
x: 142,
y: 3006,
w: 88,
h: 20,
type: 'link',
payload: downLoadIos,
},
] as Hotzone[],
pcHotzones: [
{
x: 103,
y: 1426,
w: 140,
h: 20,
type: 'text', //
payload: account.account,
},
{
x: 103,
y: 1463,
w: 140,
h: 20,
type: 'text', //
payload: account.password,
},
{
x: 103,
y: 1414,
w: 194,
h: 34,
type: 'copy',
payload: account.account,
label: '账号', // account
},
{
x: 103,
y: 1451,
w: 194,
h: 34,
type: 'copy',
payload: account.password,
label: '密码', //
},
{
x: 580,
y: 2386,
w: 88,
h: 20,
type: 'link',
payload: downLoadIos,
},
] as Hotzone[],
},
]
})
//
const downloadMethods = ref([
{
id: 1,
title: '创建新“香港Apple ID”',
subtitle: '首推',
isHot: true,
highlight: '',
mobileImages: [m1_1, m1_2, m1_3],
pcImages: [pc1_1, pc1_2, pc1_3],
mobileHotzones: [] as Hotzone[],
pcHotzones: [] as Hotzone[],
},
{
id: 2,
title: '修改已有 Apple ID 至 <香港>',
subtitle: '',
isHot: false,
highlight: '',
mobileImages: [m2_1, m2_2, m2_3],
pcImages: [pc2_1, pc2_2, pc2_3],
mobileHotzones: [] as Hotzone[],
pcHotzones: [] as Hotzone[],
},
{
id: 3,
title: '使用Hi快提供的公用账号',
subtitle: '',
isHot: false,
highlight: '谨慎选择',
mobileImages: [m3_1, m3_2, m3_3],
pcImages: [pc3_1, pc3_2, pc3_3],
mobileHotzones: [] as Hotzone[],
pcHotzones: [] as Hotzone[],
},
])
const activeId = ref<number | null>(null)
const toggle = (id: number) => {
if (activeId.value === id) {
activeId.value = null
window.scrollTo(0, 0)
} else {
activeId.value = id
// ID 3
if (id === 3) {
//
selectedIndex.value = selectedIndex.value === 0 ? 1 : 0
}
}
activeId.value = activeId.value === id ? null : id
}
const handleHotzone = (hz: Hotzone) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@ -1,36 +1,34 @@
<template>
<div class="min-h-screen bg-black text-white">
<!-- Full Width Header -->
<div class="h-[80px] md:h-[114px]">
<div class="fixed z-50 w-full bg-black pt-[20px] pb-[20px] md:pt-[34px]">
<div class="h-[88px] md:h-[94px]">
<div class="fixed z-50 w-full bg-black pt-[20px] md:pt-[34px]">
<div class="container">
<header
class="flex h-[40px] items-center justify-between rounded-full bg-[#ADFF5B] pr-[30px] pl-5 md:h-[60px] md:pr-[58px] md:pl-[41px]"
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]"
>
<router-link to="/" class="flex items-center gap-2">
<!-- Desktop Logo -->
<!-- <Logo :src="Logo" alt="Hi快VPN" class="hidden h-10 w-auto text-black md:block" />-->
<!-- Mobile Logo -->
<MobileLogo alt="Hi快VPN" class="block h-[22px] text-black md:h-[36px]" />
<MobileLogo alt="Hi快VPN" class="block h-[28px] text-black md:h-[36px]" />
</router-link>
<RightText class="block h-[12px] text-black md:h-[24px]" />
<RightText class="block h-[15px] text-black md:h-[24px]" />
</header>
</div>
</div>
</div>
<div class="">
<div class="w-full bg-black">
<div class="h-[125px] md:h-[180px]">
<div class="fixed z-50 w-full bg-black">
<div class="m-auto w-[340px] pb-[10px] md:w-[760px]">
<div
class="pb-[15px] text-center text-xl font-[900] md:pt-[20px] md:pb-[30px] md:text-2xl"
>
<div class="pt-[30px] pb-[15px] text-center text-xl font-[900] md:pb-[30px] md:text-2xl">
请选择适用您情形的选项
</div>
<div class="flex items-center justify-between">
<div
class="w-1/2 rounded-full border-5 p-[6px] md:border-[10px] md:p-[10px]"
:class="[activeIndex === 0 ? 'border-white' : 'border-black']"
@click="changeActiveIndex(0)"
@click="activeIndex = 0"
>
<Button
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"
@ -41,7 +39,7 @@
<div
class="w-1/2 rounded-full border-5 p-[6px] md:border-[10px] md:p-[10px]"
:class="[activeIndex === 1 ? 'border-white' : 'border-black']"
@click="changeActiveIndex(1)"
@click="activeIndex = 1"
>
<Button
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"
@ -71,21 +69,13 @@
<ChatIcon class="h-[60px] w-[50px]" />登录海外 Apple ID 后再点击下方下载按钮
</div>
<div class="mx-auto h-[101px] w-[247px] md:h-[202px] md:w-[494px]">
<div
@click="handleDownload('ios')"
class="relative block h-full w-full cursor-pointer transition-transform active:scale-95"
<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="下载" />
<!-- Loading Overlay -->
<div
v-if="isLoadingIos"
class="absolute inset-0 flex items-center justify-center rounded-[20px] bg-black/40"
>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-[#ADFF5B] border-t-transparent md:h-12 md:w-12"
></div>
</div>
</div>
</a>
</div>
</div>
</Transition>
@ -115,25 +105,12 @@
其他客户端下载
</div>
<div class="flex justify-center gap-4">
<template v-for="client in otherClients" :key="client.type">
<div
@click="handleDownload(client.type)"
class="relative cursor-pointer transition-transform hover:brightness-110 active:scale-95"
>
<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'" />
<!-- Loading Overlay for Android -->
<div
v-if="client.type === 'android' && isLoadingAndroid"
class="absolute inset-0 flex items-center justify-center rounded-lg bg-black/40"
>
<div
class="h-6 w-6 animate-spin rounded-full border-2 border-[#ADFF5B] border-t-transparent"
></div>
</div>
</div>
</a>
</template>
</div>
</div>
@ -141,119 +118,38 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
// Help page logic
import Logo from '@/pages/Home/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 MacosIcon from './macos.svg?component'
import WindowsIcon from './windows.svg?component'
import AndroidIcon from './android.svg?component'
import request from '@/utils/request'
import { getAllQueryString } from '@/utils/url-utils.ts'
import { toast } from 'vue-sonner'
import DownloadMethodList from './DownloadMethodList/DownloadMethodList.vue'
import FAQAccordion from './FAQAccordion/index.vue'
import { Button } from '@/components/ui/button'
const handleDownload = async (key: string) => {
const platformMap: Record<string, string> = {
window: 'windows',
mac: 'mac',
android: 'android',
ios: 'ios',
}
const platform = platformMap[key] || 'windows'
const ic = getAllQueryString('ic') || sessionStorage.getItem('ic') || 'uSSfg1Y1vt'
//
const ua = navigator.userAgent
const isAndroidDevice = /Android/i.test(ua)
const isIosDevice =
/iPhone|iPod|iPad/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
const isMobileDevice = isAndroidDevice || isIosDevice
// 1.
if (key === 'android') {
if (isAndroidDevice) {
isLoadingAndroid.value = true
await triggerOpenInstall(ic)
isLoadingAndroid.value = false
return
} else {
window.open(
'https://api.hifast.biz/v1/common/client/download/file/Hi%E5%BF%ABVPN-android-1.0.0.apk',
'_blank',
)
return
}
}
// 2. iOS/Mac
if (key === 'ios' || key === 'mac') {
if (isMobileDevice) {
if (key === 'ios') isLoadingIos.value = true
await triggerOpenInstall(ic)
isLoadingIos.value = false
return
} else {
window.open(
'https://apps.apple.com/us/app/hi%E5%BF%ABvpn/id6755683167?l=zh-Hans-CN',
'_blank',
)
return
}
}
// 3. ( Windows)
try {
const res: any = await request.get('/api/v1/common/client/download', {
invite_code: ic,
platform: platform,
})
if (res.url) {
window.open(res.url, '_blank')
}
} catch (error) {
console.error('Download fetch failed', error)
}
}
// OpenInstall
const triggerOpenInstall = async (ic: string) => {
if (!(window as any).OI_SDK?.isReady && (window as any).OI_SDK_PROMISE) {
try {
await Promise.race([
(window as any).OI_SDK_PROMISE,
new Promise((_, reject) => setTimeout(() => reject(new Error('OpenInstall timeout')), 3000)),
])
} catch (e) {
console.warn('OpenInstall readiness wait failed or timeout:', e)
}
}
if ((window as any).OI_SDK && (window as any).OI_SDK.OI) {
;(window as any).OI_SDK.OI.wakeupOrInstall({ data: { platform: 'download', inviteCode: ic } })
}
}
import { downLoadAndroid, downLoadWin, downLoadMac } from '@/utils/constant.ts'
const activeIndex = ref(0)
const isLoadingIos = ref(false)
const isLoadingAndroid = ref(false)
const otherClients = computed(() => {
return [
{ icon: WindowsIcon, type: 'window' },
{ icon: MacosIcon, type: 'mac' },
{ icon: AndroidIcon, type: 'android' },
]
})
function changeActiveIndex(index: number) {
activeIndex.value = index
window.scrollTo(0, 0)
}
const otherClients = [
{ icon: WindowsIcon, link: downLoadWin, type: 'window' },
{ icon: MacosIcon, link: downLoadMac, type: 'mac' },
{ icon: AndroidIcon, link: downLoadAndroid, type: 'android' },
]
</script>
<style scoped></style>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.8 KiB

View File

@ -1,13 +0,0 @@
<svg viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_13954_5340)">
<path d="M13.7436 22.7399C14.5039 22.7399 15.167 22.0768 15.167 21.3165V17.9923H16.1174C16.6877 17.9923 17.0678 17.6121 17.0678 17.0419V7.54761H5.672V17.042C5.672 17.6122 6.05216 17.9924 6.6224 17.9924H7.57288V21.3166C7.57288 22.0769 8.23577 22.7399 8.99624 22.7399C9.75649 22.7399 10.4195 22.0769 10.4195 21.3166V17.9924H12.3203V21.3166C12.3203 22.0768 12.9833 22.7399 13.7436 22.7399Z" fill="currentColor"/>
<path d="M19.4417 17.042C20.202 17.042 20.8649 16.379 20.8649 15.6186V8.97037C20.8649 8.21407 20.202 7.54761 19.4417 7.54761C18.6813 7.54761 18.0183 8.21407 18.0183 8.97037V15.6186C18.0183 16.379 18.6812 17.042 19.4417 17.042Z" fill="currentColor"/>
<path d="M3.29821 17.042C4.05861 17.042 4.72158 16.379 4.72158 15.6186V8.97037C4.72158 8.21407 4.05869 7.54761 3.29821 7.54761C2.53789 7.54761 1.875 8.21407 1.875 8.97037V15.6186C1.875 16.379 2.53789 17.042 3.29821 17.042Z" fill="currentColor"/>
<path d="M15.9272 0.143414C15.7372 -0.0478047 15.4544 -0.0478047 15.2643 0.143414L13.9896 1.41388L13.9308 1.47261C13.175 1.09412 12.3275 0.905102 11.3838 0.903281C11.3792 0.903281 11.3746 0.903129 11.37 0.903129H11.3699C11.3651 0.903129 11.3607 0.903281 11.3559 0.903281C10.4122 0.905102 9.56482 1.09412 8.80905 1.47261L8.75009 1.41388L7.47545 0.143414C7.28529 -0.0478047 7.00256 -0.0478047 6.81256 0.143414C6.6224 0.333571 6.6224 0.61577 6.81256 0.805775L8.04561 2.03906C7.6483 2.30434 7.28802 2.62895 6.97471 3.00024C6.22463 3.88933 5.74621 5.04643 5.68042 6.30013C5.67981 6.31318 5.67852 6.32608 5.67792 6.33913C5.67389 6.4245 5.672 6.51032 5.672 6.59644H17.0678C17.0678 6.51032 17.0657 6.4245 17.0619 6.33913C17.0613 6.32608 17.06 6.31318 17.0592 6.30013C16.9936 5.04643 16.515 3.88925 15.7649 3.00031C15.4518 2.62903 15.0913 2.30441 14.694 2.03914L15.9272 0.805851C16.1174 0.61577 16.1174 0.333571 15.9272 0.143414ZM8.99442 4.93701C8.60121 4.93701 8.28244 4.61824 8.28244 4.22502C8.28244 3.83181 8.60121 3.51304 8.99442 3.51304C9.38763 3.51304 9.70641 3.83181 9.70641 4.22502C9.70641 4.61824 9.38763 4.93701 8.99442 4.93701ZM13.7454 4.93701C13.3522 4.93701 13.0334 4.61824 13.0334 4.22502C13.0334 3.83181 13.3522 3.51304 13.7454 3.51304C14.1386 3.51304 14.4574 3.83181 14.4574 4.22502C14.4574 4.61824 14.1386 4.93701 13.7454 4.93701Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_13954_5340">
<rect width="22.7398" height="22.7398" fill="currentColor"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,4 +0,0 @@
<svg viewBox="0 0 20 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.7045 12.7631C16.729 10.9104 17.7481 9.15738 19.3648 8.18699C18.3449 6.76488 16.6366 5.86322 14.8592 5.80893C12.9636 5.61467 11.1258 6.91639 10.1598 6.91639C9.17508 6.91639 7.68777 5.82822 6.08619 5.86039C3.99859 5.92624 2.05242 7.08501 1.03676 8.86687C-1.14651 12.5573 0.482015 17.9809 2.5734 20.964C3.61977 22.4247 4.84267 24.0564 6.44281 23.9985C8.00865 23.9351 8.59346 23.0237 10.4836 23.0237C12.3561 23.0237 12.9048 23.9985 14.5374 23.9617C16.2176 23.9351 17.2762 22.4945 18.2859 21.02C19.0377 19.9791 19.6162 18.8288 20 17.6116C18.0254 16.7962 16.7068 14.8562 16.7045 12.7631Z" fill="currentColor"/>
<path d="M13.6208 3.84713C14.5369 2.77343 14.9882 1.39335 14.8789 0C13.4793 0.143521 12.1864 0.796601 11.2579 1.82911C10.35 2.83793 9.87748 4.19372 9.96681 5.53382C11.3669 5.54789 12.7434 4.91252 13.6208 3.84713Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

View File

@ -11,7 +11,7 @@
></div>
<div
class="relative z-10 w-full max-w-[280px] rounded-[40px] border border-white/0 bg-[#DDDDDD] px-8 pt-8 pb-4 shadow-2xl"
class="relative z-10 w-full max-w-[280px] rounded-[40px] border border-white/10 bg-[#999999] p-8 shadow-2xl"
@click.stop
>
<div class="space-y-2 text-black">
@ -20,15 +20,6 @@
验证邮件已发送至邮箱如无法找到请检查垃圾邮件箱或营销邮件箱
</p>
</div>
<div class="flex justify-center pt-3">
<Button
class="h-[40px] w-[85px] cursor-pointer rounded-full bg-[#A8FF53] font-medium text-black hover:bg-[#96E64A]"
@click.stop="hide"
>
好的
</Button>
</div>
</div>
</div>
</Transition>
@ -37,7 +28,6 @@
<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue'
import { Button } from '@/components/ui/button'
const visible = ref(false)
const timer: ReturnType<typeof setTimeout> | null = null

View File

@ -1,81 +1,56 @@
<template>
<div class="flex w-full flex-col items-center justify-center md:justify-start">
<!-- 主下载按钮 -->
<div
class="relative mx-auto flex h-[60px] w-[210px] items-center justify-center overflow-hidden rounded-full bg-[#ADFF5B] px-[16px] md:mb-4 md:ml-0 md:h-[83px] md:w-[300px] md:px-[24px]"
>
<div class="mx-auto grid grid-cols-2 gap-4 md:flex md:flex-wrap">
<template v-for="(item, index) in downloadLinks" :key="index">
<router-link
v-if="mainButton?.link && !mainButton.link.startsWith('http')"
:to="mainButton.link"
class="flex h-full w-full items-center justify-center transition-transform hover:brightness-110 active:scale-95"
v-if="item.isInternal"
:to="item.link"
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,
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;
"
>
<component
:is="mainButton.mainIcon"
class="h-auto w-full text-black transition-transform"
/>
<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-if="mainButton?.link"
:href="mainButton.link"
target="_blank"
:aria-label="mainButton.label"
class="flex h-full w-full items-center justify-center transition-transform hover:brightness-110 active:scale-95"
>
<component
:is="mainButton.mainIcon"
class="h-auto w-full text-black transition-transform"
/>
</a>
<div
v-else
:id="mainButton?.id"
:aria-label="mainButton?.label"
@click="handleDownload(mainButton.key)"
class="flex h-full w-full cursor-pointer items-center justify-center transition-transform hover:brightness-110 active:scale-95"
: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,
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;
"
>
<component
:is="mainButton?.mainIcon"
class="h-auto w-full text-black transition-transform"
/>
</div>
</div>
<!-- 其他版本下载 -->
<div class="mt-2 w-full">
<div class="mb-3 text-center md:text-left">
<span class="text-xs whitespace-nowrap md:text-sm">其他版本下载</span>
</div>
<div class="flex items-center justify-center gap-4 md:justify-start">
<template v-for="(item, index) in otherButtons" :key="index">
<router-link
v-if="item.link && !item.link.startsWith('http')"
:to="item.link"
class="transition-transform hover:brightness-110 active:scale-95"
>
<component :is="item.secondaryIcon" class="h-[24px] text-white md:h-[34px]" />
</router-link>
<a
v-else-if="item.link"
:href="item.link"
target="_blank"
:aria-label="item.label"
class="transition-transform hover:brightness-110 active:scale-95"
>
<component :is="item.secondaryIcon" class="h-[24px] text-white md:h-[34px]" />
</a>
<div
v-else
:id="item.id"
:aria-label="item.label"
@click="handleDownload(item.key)"
class="cursor-pointer transition-transform hover:brightness-110 active:scale-95"
>
<component :is="item.secondaryIcon" class="h-[24px] text-white md:h-[34px]" />
</div>
</template>
</div>
</div>
<div class="flex items-center justify-center">
<component :is="item.icon" class="h-[40px] w-[140px] md:h-[50px] md:w-[180px]" />
</div>
</a>
</template>
</div>
</template>
@ -84,174 +59,12 @@ import Icon1 from './Group 105.svg?component'
import Icon2 from './Group 106.svg?component'
import Icon3 from './Group 107.svg?component'
import Icon4 from './Group 108.svg?component'
import WinIcon from './WinIcon.svg?component'
import MacIcon from './MacIcon.svg?component'
import AppleIcon from './AppleIcon.svg?component'
import AndroidIcon from './AndroidIcon.svg?component'
import request from '@/utils/request'
import { computed, ref, onMounted } from 'vue'
import { getAllQueryString } from '@/utils/url-utils.ts'
const currentPlatform = ref('win')
onMounted(() => {
const ua = navigator.userAgent
const platform = navigator.platform || '' //
// 1. Android ()
if (/Android/i.test(ua)) {
currentPlatform.value = 'android'
}
// 2. iOS (iPhone, iPod)
else if (/iPhone|iPod/i.test(ua)) {
currentPlatform.value = 'ios'
}
// 3. Mac iPad
else if (/Macintosh|Mac OS X/i.test(ua)) {
// iPadOS > 1
// Mac maxTouchPoints 0 undefined
if (navigator.maxTouchPoints > 1) {
currentPlatform.value = 'ios' // iOS iPad
} else {
currentPlatform.value = 'mac'
}
}
// 4. Windows
else {
currentPlatform.value = 'win'
}
console.log('Detected Platform:', currentPlatform.value)
})
// request.get('/api/v1/common/client/download', {
// invite_code: getAllQueryString('ic'),
// platform: 'mac',
// }).then((res: any) => {
// downLoadMac.value = res.url
// })
const downLoadWin = ref('')
const handleDownload = async (key: string) => {
const platformMap: Record<string, string> = {
win: 'windows',
mac: 'mac',
android: 'android',
ios: 'ios',
}
const platform = platformMap[key] || 'windows'
const ic = getAllQueryString('ic') || sessionStorage.getItem('ic') || 'uSSfg1Y1vt'
//
const ua = navigator.userAgent
const isAndroidDevice = /Android/i.test(ua)
const isIosDevice =
/iPhone|iPod|iPad/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
const isMobileDevice = isAndroidDevice || isIosDevice
// 1.
if (key === 'android') {
if (isAndroidDevice) {
await triggerOpenInstall(ic)
return
} else {
window.open(
'https://api.hifast.biz/v1/common/client/download/file/Hi%E5%BF%ABVPN-android-1.0.0.apk',
'_blank',
)
return
}
}
// 2. iOS/Mac
if (key === 'ios' || key === 'mac') {
if (isMobileDevice) {
await triggerOpenInstall(ic)
return
} else {
window.open(
'https://apps.apple.com/us/app/hi%E5%BF%ABvpn/id6755683167?l=zh-Hans-CN',
'_blank',
)
return
}
}
// 3. ( Windows)
try {
const res: any = await request.get('/api/v1/common/client/download', {
invite_code: ic,
platform: platform,
})
if (res.url) {
if (key === 'win') {
downLoadWin.value = res.url
}
window.open(res.url, '_blank')
}
} catch (error) {
console.error('Download fetch failed', error)
}
}
// OpenInstall
const triggerOpenInstall = async (ic: string) => {
if (!(window as any).OI_SDK?.isReady && (window as any).OI_SDK_PROMISE) {
try {
await Promise.race([
(window as any).OI_SDK_PROMISE,
new Promise((_, reject) => setTimeout(() => reject(new Error('OpenInstall timeout')), 3000)),
])
} catch (e) {
console.warn('OpenInstall readiness wait failed or timeout:', e)
}
}
if ((window as any).OI_SDK && (window as any).OI_SDK.OI) {
;(window as any).OI_SDK.OI.wakeupOrInstall({ data: { platform: 'download', inviteCode: ic } })
}
}
const allDownloadOptions = computed(() => [
{
key: 'win',
mainIcon: Icon1,
secondaryIcon: WinIcon,
link: downLoadWin.value,
label: 'Windows',
id: 'downloadButton_win',
},
{
key: 'mac',
mainIcon: Icon3,
secondaryIcon: MacIcon,
link: '/help',
label: 'macOS',
},
{
key: 'ios',
mainIcon: Icon2,
secondaryIcon: AppleIcon,
link: '/help',
label: 'iOS',
},
{
key: 'android',
mainIcon: Icon4,
secondaryIcon: AndroidIcon,
label: 'Android',
id: 'downloadButton_android',
},
])
const mainButton = computed(() => {
const platform = currentPlatform.value
return allDownloadOptions.value.find((opt) => opt.key === platform) || allDownloadOptions.value[0]
})
const otherButtons = computed(() => {
return allDownloadOptions.value.filter((opt) => opt.key !== currentPlatform.value)
})
import { downLoadAndroid, downLoadWin, downLoadMac } from '@/utils/constant.ts'
//
const downloadLinks = [
{ icon: Icon2, link: '/help', isInternal: true },
{ icon: Icon1, link: downLoadWin },
{ icon: Icon3, link: downLoadMac },
{ icon: Icon4, link: downLoadAndroid },
]
</script>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -1,74 +1,38 @@
<template>
<form @submit.prevent="handleLogin" class="flex flex-col gap-6 text-base text-black md:text-2xl">
<div class="rounded-[20px] bg-[#78788029] px-4">
<div class="flex flex-col gap-6 text-base text-black md:text-2xl">
<div class="overflow-hidden rounded-[20px] bg-[#78788029] px-4">
<div class="relative">
<Input
v-model.trim="email"
type="text"
name="user_email_identity"
autocomplete="new-password"
v-model="email"
type="email"
placeholder="Email"
class="h-[50px] border-none bg-transparent text-base focus-visible:ring-0 md:text-2xl"
@focus="isFocused = true"
@blur="onBlur"
@keydown="handleKeyDown"
/>
<transition name="fade">
<div
v-if="isFocused && suggestList.length > 0"
class="absolute top-[55px] left-0 z-[100] w-full rounded-xl border border-white/20 bg-white/95 p-1 shadow-2xl backdrop-blur-xl dark:bg-black/90"
>
<div
v-for="(item, index) in suggestList"
:key="item.full"
@mousedown="selectSuggest(item.full)"
:class="[
'flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors md:text-xl',
activeIndex === index ? 'bg-[#A8FF53]/10' : '',
]"
>
<span class="font-medium text-black/80">{{ prefix }}</span>
<span class="font-medium text-black">{{ item.userInputPart }}</span>
<span class="text-black opacity-30">{{ item.suggestPart }}</span>
</div>
</div>
</transition>
<div class="absolute bottom-0 left-0 h-[1px] w-full bg-gray-400/30"></div>
</div>
<div class="relative flex items-center">
<div class="flex-1">
<Input
v-model.trim="code"
v-model="code"
type="text"
inputmode="numeric"
name="code"
autocomplete="one-time-code"
placeholder="验证码"
class="h-[50px] border-none bg-transparent text-base focus-visible:ring-0 md:text-2xl"
/>
</div>
<Button
type="button"
@click="handleGetCode"
:disabled="countdown > 0 || isSendingCode"
class="relative h-10 min-w-[100px] cursor-pointer overflow-hidden rounded-full bg-[#4B94E6] px-[32px] text-base font-bold text-white hover:bg-[#4B94E6]/90 md:min-w-[130px] md:px-[45px] md:text-[18px]"
:disabled="countdown > 0"
class="h-10 rounded-full bg-[#4B94E6] px-[32px] text-base font-bold text-white hover:bg-[#4B94E6]/90 md:px-[45px] md:text-[18px]"
>
<div class="flex items-center justify-center">
<Loader2 v-if="isSendingCode" class="absolute left-4 h-4 w-4 animate-spin md:left-8" />
<span :class="{ 'ml-4': isSendingCode }">
{{ isSendingCode ? '发送中' : countdown > 0 ? `${countdown}s` : '获取' }}
</span>
</div>
{{ countdown > 0 ? `${countdown}s` : '获取' }}
</Button>
</div>
</div>
<div class="flex gap-4 pt-2">
<Button
type="button"
variant="secondary"
@click="emit('close')"
class="h-[48px] flex-1 cursor-pointer rounded-[25px] bg-[#D1D1D1] text-lg font-medium text-[#757575] hover:bg-[#C1C1C1]"
@ -76,164 +40,63 @@
取消
</Button>
<Button
type="submit"
:disabled="isLoggingIn"
class="relative h-[48px] flex-1 cursor-pointer overflow-hidden rounded-[25px] bg-[#A8FF53] text-lg font-medium text-black hover:bg-[#96E64A]"
@click="handleLogin"
class="h-[48px] flex-1 cursor-pointer rounded-[25px] bg-[#A8FF53] text-lg font-medium text-black hover:bg-[#96E64A]"
>
<div class="flex items-center justify-center">
<Loader2 v-if="isLoggingIn" class="absolute left-4 h-5 w-5 animate-spin md:left-8" />
<span :class="{ 'ml-2': isLoggingIn }"> 登录/注册 </span>
</div>
登录/注册
</Button>
</div>
<CodeSentTip ref="CodeSentTipRef" />
</form>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toast } from 'vue-sonner'
import CodeSentTip from '@/pages/Home/components/CodeSentTip.vue'
import request from '@/utils/request'
import { useRouter } from 'vue-router'
import { Loader2 } from 'lucide-vue-next'
const CodeSentTipRef = ref<InstanceType<typeof CodeSentTip> | null>(null)
const CodeSentTipRef = ref(null)
const email = ref('')
const code = ref('')
const countdown = ref(0)
const isFocused = ref(false)
const commonSuffixes = ['@gmail.com', '@outlook.com', '@qq.com', '@163.com']
const activeIndex = ref(-1) //
const isSendingCode = ref(false) // loading
// 1. @
const prefix = computed(() => {
const atIndex = email.value.indexOf('@')
return atIndex > -1 ? email.value.slice(0, atIndex) : email.value
})
//
watch(email, () => {
activeIndex.value = -1
})
//
const handleKeyDown = (e: KeyboardEvent) => {
if (!suggestList.value.length) return
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % suggestList.value.length
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value =
(activeIndex.value - 1 + suggestList.value.length) % suggestList.value.length
} else if (e.key === 'Enter' && activeIndex.value !== -1) {
e.preventDefault()
// [object Object] .full
selectSuggest(suggestList.value[activeIndex.value].full)
} else if (e.key === 'Tab' && activeIndex.value !== -1) {
// 💡 Tab
e.preventDefault()
selectSuggest(suggestList.value[activeIndex.value].full)
}
}
// 2.
const suggestList = computed(() => {
const val = email.value.trim()
if (!val || val.length < 1) return []
const atIndex = val.indexOf('@')
// "abc@gm" domainPart "@gm"
const domainPart = atIndex > -1 ? val.slice(atIndex) : ''
//
const matches = commonSuffixes.filter((s) => s.startsWith(domainPart) && s !== domainPart)
return matches.map((full) => {
return {
full: full, //
userInputPart: domainPart, //
suggestPart: full.replace(domainPart, ''), //
}
})
})
// 4.
const selectSuggest = (fullSuffix: string) => {
email.value = prefix.value + fullSuffix
isFocused.value = false
}
const onBlur = () => {
// blur
setTimeout(() => {
isFocused.value = false
activeIndex.value = -1 //
}, 200)
}
// 3.
const validateEmail = (str: string) => {
return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(str)
}
const emit = defineEmits(['close'])
const handleGetCode = async () => {
const handleGetCode = () => {
if (!email.value) {
toast('请输入邮箱')
return
}
CodeSentTipRef.value.show()
if (!validateEmail(email.value)) {
return toast.error('邮箱格式无效,请检查')
}
isSendingCode.value = true // []
try {
// 1.
const { exist } = await request.get<{ exist: boolean }>('/api/v1/auth/check', {
request
.get('/api/v1/auth/check', {
email: email.value,
})
// 2.
await request.post('/api/v1/common/send_code', {
email: email.value,
type: exist ? 2 : 1, // 1=, 2=
.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
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) clearInterval(timer)
}, 1000)
})
})
// 3.
CodeSentTipRef.value?.show()
countdown.value = 60
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) clearInterval(timer)
}, 1000)
} catch (error: any) {
console.error('发送验证码失败:', error)
// request toast
} finally {
isSendingCode.value = false // []
}
}
const router = useRouter()
const isLoggingIn = ref(false)
const handleLogin = () => {
if (!code.value) {
toast('请输入验证码')
return
}
if (!validateEmail(email.value)) {
return toast.error('邮箱格式无效,请检查')
}
isLoggingIn.value = true
request
.post<any, { token: string }>('/api/v1/auth/login/email', {
.post('/api/v1/auth/login/email', {
email: email.value,
code: code.value,
})
@ -242,12 +105,6 @@ const handleLogin = () => {
localStorage.setItem('UserEmail', email.value)
router.push({ path: '/user-center' })
})
.catch((error) => {
console.error('登录失败', error)
})
.finally(() => {
isLoggingIn.value = false
})
}
</script>

View File

@ -1,5 +0,0 @@
<svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.1116 0.0118162C16.9306 -0.294218 22.1579 5.38658 21.2692 12.1411C20.6604 16.7678 17.0315 20.4574 12.4153 21.1753C5.11499 22.3107 -0.973361 16.2395 0.129739 8.96223C0.880978 4.00611 5.08396 0.237434 10.1116 0.0118162ZM6.49253 5.57971V5.15095L5.98082 5.17139V7.82566H6.51301V6.03912C6.51301 5.98384 6.63856 5.77528 6.68403 5.72991C6.88648 5.5281 7.29764 5.51339 7.4835 5.74493C7.50613 5.77314 7.57751 5.89024 7.57751 5.9166V7.82566H8.1097V5.99835C8.1097 5.94388 8.21835 5.76649 8.26341 5.72286C8.46761 5.52535 8.93703 5.50705 9.10159 5.76445C9.11408 5.78387 9.1742 5.92774 9.1742 5.93704V7.82566H9.70639V5.79408C9.70639 5.7478 9.62795 5.54057 9.6004 5.49142C9.3318 5.01168 8.57411 4.99431 8.20125 5.3545C8.14677 5.40712 8.05789 5.57378 8.01795 5.58022C7.97975 5.58798 7.99153 5.56969 7.9818 5.55365C7.95384 5.50726 7.94073 5.45402 7.905 5.4061C7.60444 5.00309 6.91567 5.00514 6.61542 5.4061C6.57978 5.45372 6.56678 5.5094 6.53851 5.55324C6.52551 5.57347 6.54087 5.58941 6.49263 5.5796L6.49253 5.57971ZM12.5518 7.82566V5.85539C12.5518 5.81707 12.4886 5.63723 12.4674 5.59248C12.1186 4.85861 10.5293 4.96621 10.4024 5.84517H10.9039C11.0519 5.48621 11.7558 5.45167 11.9549 5.75658C11.9666 5.77446 12.0196 5.88727 12.0196 5.89627V6.23316C11.4897 6.3051 10.5307 6.18166 10.3345 6.84942C10.0652 7.76599 11.284 8.18596 11.8843 7.59902C11.9319 7.55243 11.97 7.44902 12.04 7.43778V7.82566H12.5517H12.5518ZM15.5608 6.00846C15.4337 4.96539 13.8804 4.80915 13.3746 5.63478C13.0153 6.22141 13.0868 7.29166 13.7078 7.68322C14.3639 8.09685 15.4815 7.83905 15.561 6.96815H15.0595C14.9687 7.24251 14.7832 7.40059 14.4871 7.41796C13.5044 7.47579 13.4897 5.83066 14.1785 5.59932C14.5531 5.47354 14.9288 5.63356 15.0595 6.00846H15.561H15.5608ZM7.1639 9.17927C5.47873 9.29443 4.41475 10.5135 4.18239 12.1265C3.87671 14.2482 4.74347 16.4983 7.13758 16.6867C9.76558 16.8934 11.0634 14.884 10.8525 12.4504C10.6708 10.3526 9.32217 9.03182 7.1639 9.17916V9.17927ZM16.83 11.2762C16.7202 9.40591 14.6005 8.79854 13.0581 9.3822C11.3439 10.0309 11.1638 12.1881 12.9005 12.96C13.8462 13.3804 16.4376 13.3588 15.9806 14.9514C15.7548 15.7383 14.68 15.9375 13.9772 15.8676C13.2545 15.7957 12.5363 15.3986 12.429 14.6247H11.5284C11.5735 15.7605 12.4946 16.458 13.5512 16.6397C15.195 16.9222 17.1231 16.2394 16.9504 14.2701C16.7967 12.5179 14.6073 12.67 13.4325 12.1845C12.3671 11.7442 12.3516 10.5542 13.4063 10.1377C14.3723 9.75629 15.8132 10.0661 15.909 11.2762H16.8302H16.83Z" fill="currentColor"/>
<path d="M7.26639 10.0163C8.68592 9.90412 9.63357 10.7978 9.86378 12.1503C10.179 14.0017 9.43061 16.0109 7.24008 15.8494C6.04358 15.7612 5.31119 14.8035 5.12758 13.6891C4.86675 12.1062 5.39639 10.1641 7.26639 10.0163Z" fill="currentColor"/>
<path d="M12.0195 6.62085C12.0894 7.12757 11.7454 7.48858 11.2338 7.43514C10.9315 7.40346 10.6886 7.10938 10.8934 6.83523C11.0759 6.5906 11.737 6.64987 12.0195 6.62095V6.62085Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View File

@ -1,193 +0,0 @@
<template>
<div class="review-carousel-container relative">
<div
class="review-card lucid-glass-bar relative flex items-center overflow-hidden rounded-2xl p-4 text-white md:p-5"
:style="{ height: isMobile ? '114px' : '130px' }"
>
<!-- Static More Reviews Button, 不受动画影响随时可点 -->
<router-link
to="/reviews"
class="absolute top-[18px] right-4 z-20 cursor-pointer text-[10px] text-white underline decoration-white underline-offset-4 transition-colors hover:text-[#ADFF5B] md:top-[16px] md:right-5 md:text-sm"
>
More Reviews
</router-link>
<!-- 动画应用在卡片内部的内容上 -->
<transition name="fade" mode="out-in">
<div v-if="currentReview" :key="currentIndex" class="flex h-full w-full items-center">
<!-- Avatar -->
<div class="mr-4 flex-shrink-0 md:mr-6">
<img
:src="currentReview.avatar"
alt="User Avatar"
class="h-[70px] w-[70px] rounded-full border-2 border-pink-300/30 object-cover md:h-[84px] md:w-[84px]"
/>
</div>
<!-- Content -->
<div class="min-w-0 flex-grow">
<div class="mb-1 flex items-start justify-between">
<h3 class="truncate pr-20 text-base font-bold md:pr-28 md:text-xl">
{{ currentReview.username }}
</h3>
</div>
<!-- Stars and Rating -->
<div class="mb-1 flex items-center gap-1 md:mb-2">
<div class="flex gap-0.5">
<svg
v-for="i in 5"
:key="i"
class="h-3 w-3 text-[#ADFF5B] md:h-4 md:w-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
</div>
<span class="ml-1 text-xs font-medium text-white/50 md:text-sm">4.9</span>
<span class="ml-2 text-xs text-white/30 md:text-sm"
>{{ currentReview.reviewCount }}k reviews</span
>
</div>
<!-- Comment Text -->
<p class="line-clamp-2 text-xs leading-[1.4] text-white/90 md:pr-4 md:text-sm">
{{ currentReview.comment }}
</p>
</div>
</div>
</transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
// Import avatars
import avatar1 from './avatars/avatar1.png'
import avatar2 from './avatars/avatar2.png'
import avatar3 from './avatars/avatar3.png'
import avatar4 from './avatars/avatar4.png'
import avatar6 from './avatars/avatar6.png'
const isMobile = ref(false)
const reviews = [
{
username: '庄子不给',
avatar: avatar1,
comment: '真心不错,比我只前买的那个好用多了。网宿很给力。',
reviewCount: '5.7',
},
{
username: 'TechEnthusiast',
avatar: avatar6,
comment: 'Speed is incredible! The best VPN I have used in years for international streaming.',
reviewCount: '12.4',
},
{
username: '阿杰',
avatar: avatar2,
comment: '非常稳定的连接在高峰时段也没掉过线。UI设计很现代用着很舒服。',
reviewCount: '3.2',
},
{
username: 'Lina_Zhang',
avatar: avatar3,
comment: '客服响应速度很快,配置简单。推荐给需要长期稳定翻墙的朋友们。',
reviewCount: '8.9',
},
{
username: 'GlobalNomad',
avatar: avatar4,
comment: 'Perfect for my travels. Low latency and high security. A must-have for privacy.',
reviewCount: '6.1',
},
]
const currentIndex = ref(0)
const currentReview = computed(() => reviews[currentIndex.value])
let timer: any = null
const startCarousel = () => {
timer = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % reviews.length
}, 5000)
}
const handleResize = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
handleResize()
window.addEventListener('resize', handleResize)
startCarousel()
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (timer) clearInterval(timer)
})
</script>
<style scoped>
.review-carousel-container {
width: 100%;
}
@media (min-width: 768px) {
.review-carousel-container {
max-width: 400px;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.review-card {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
transition: all 0.3s ease;
border-radius: 16px !important;
}
.review-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, rgba(173, 255, 91, 0.1), transparent 50%);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
</style>

View File

@ -1,6 +0,0 @@
<svg viewBox="0 0 19 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="9.08603" height="9.1215" fill="currentColor"/>
<rect x="9.91211" width="9.08603" height="9.1215" fill="currentColor"/>
<rect y="9.95068" width="9.08603" height="9.1215" fill="currentColor"/>
<rect x="9.91211" y="9.95068" width="9.08603" height="9.1215" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 369 B

View File

@ -1,22 +1,39 @@
<template>
<div
class="relative min-h-screen overflow-hidden bg-black bg-[url('@/pages/Home/bg-mobile.webp')] 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"
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 -->
<div class="h-[60px] md:h-[125px]">
<div class="h-[88px] md:h-[125px]">
<div class="fixed top-[20px] z-50 w-full md:top-[45px]">
<div class="container">
<header
class="lucid-glass-bar flex h-[40px] items-center justify-between rounded-[90px] pr-[5px] pl-5 transition-all duration-300 md:h-[60px] md:pr-[10px]"
class="flex h-[68px] items-center justify-between rounded-[90px] pr-[10px] transition-all duration-300 md:h-[80px]"
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;
"
>
<router-link to="/" class="flex items-center gap-2">
<!-- Desktop Logo -->
<Logo alt="Hi快VPN" class="h-[18px] w-auto md:ml-8 md:h-[29px]" />
<Logo alt="Hi快VPN" class="hidden h-[29px] w-auto md:ml-[62px] md:block" />
<!-- Mobile Logo -->
<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="flex size-[30px] items-center justify-center rounded-full bg-[#78788029] text-xl font-bold text-white shadow-lg transition hover:scale-105 md:size-[40px] md:text-3xl"
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>
@ -24,7 +41,7 @@
<button
v-else
@click="openLoginModal"
class="flex h-[30px] 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-[40px] md:w-[220px] md:text-xl"
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>
@ -35,19 +52,14 @@
<!-- Main Content Container -->
<div class="container mx-auto flex flex-1 flex-col">
<main class="pt-10 md:flex md:flex-1 md:justify-between md:pt-0">
<main class="pt-10 md:grid md:flex-1 md:grid-cols-2 md:pt-0">
<!-- Left Column: Text & Downloads -->
<div class="pb-4 md:flex md:w-[432px] md:flex-col md:justify-center">
<div class="md:flex md:w-[432px] md:flex-col md:justify-center">
<div class="mb-[20px] ml-[42px] md:ml-[17px]">
<h2 class="mb-2 text-2xl font-black md:text-8xl">
<Logo class="h-[34px] md:h-[66px]" />
<Logo class="h-[66px]" />
</h2>
<p class="font-600 text-3xl md:text-[48px]">网在我在, 网快我快</p>
<div class="overflow-hidden">
<div class="float-left mt-2 rounded-full bg-white px-4 py-2 md:mt-4">
<UserCount class="h-[29px]" />
</div>
</div>
</div>
<!-- Screenshot Image -->
@ -55,7 +67,7 @@
<img
:src="ScreenshotMobile"
alt="App Screenshot"
class="h-auto min-h-[284px] 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) -->
@ -63,7 +75,7 @@
<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 size-[80%]!"></div>
<div class="ripple ripple-core"></div>
</div>
<!-- Center Image (Foreground/Top Layer) -->
<div
@ -74,18 +86,14 @@
<!-- Download Buttons Grid -->
<div
class="mb-4 flex grid-cols-2 flex-wrap gap-[10px] px-[24px] md:mt-[124px] md:ml-[17px] md:px-0"
class="mg:mb-[65px] mb-9 flex grid-cols-2 flex-wrap gap-[10px] px-[24px] md:mt-[124px] md:ml-[17px] md:px-0"
>
<DownloadButton />
</div>
<!-- Review Carousel -->
<div class="mb-2 md:hidden">
<ReviewCarousel />
</div>
<!-- Features / Footer Info -->
<div
class="mb-5 w-full text-center text-[10px] 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>IEPL专线-纯净稳定</p>
@ -93,40 +101,46 @@
<p>极速闪连-永远快人一步</p>
<div class="flex justify-center md:justify-start">
<a
href="https://getsapp.net/xcom"
href="https://x.com/hifasttech"
target="_blank"
class="lucid-glass-bar mt-[8px] flex h-[30px] w-[100px] shrink-0 items-center justify-center space-x-2 rounded-full transition-transform hover:brightness-110 md:mt-[16px]"
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" alt="X.com" class="h-[16px] w-[77px]" />
<img src="./x-logo.png" class="h-[16px] w-[77px]" />
</div>
</a>
</div>
</div>
<div class="mb-[10px] hidden md:ml-[17px] md:block md:text-left">
<CompanyNameIcon class="h-[16px]" />
</div>
<div class="mx-auto mb-1 h-[20px] w-[352px] md:ml-[17px]">
<img src="./image-1.png" alt="image" />
</div>
<div class="mt-2 mb-1 md:hidden">
<CompanyNameIcon class="mx-auto h-[10px]" />
</div>
<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 />
<router-link to="/terms" class="underline">Terms of Service</router-link>
<router-link to="/privacy" class="ml-2 underline">Privacy Policy</router-link>
<router-link to="/terms-of-service" class="underline">Terms of Service</router-link>
<router-link to="/privacy-policy" class="ml-2 underline">Privacy Policy</router-link>
</div>
</div>
<!-- Right Column: Phone Screenshot -->
<div class="relative hidden w-full max-w-[582px] md:mt-0 md:block">
<!-- Review Carousel -->
<div class="absolute right-[2vw]">
<ReviewCarousel />
</div>
<div class="relative hidden w-full max-w-[632px] md:mt-0 md:block">
<!-- Screenshot with Ripple Effect -->
<div class="absolute right-[10%] bottom-0 z-50 aspect-square w-[58%] overflow-hidden">
<div class="absolute right-[9%] bottom-0 z-50 aspect-square w-[58%] overflow-hidden">
<!-- Ripple Animation (Background) -->
<div class="ripple-container absolute inset-0">
<div class="ripple ripple-outer-3"></div>
@ -139,7 +153,7 @@
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-[582px]">
<div class="absolute right-[2vw] bottom-0 max-w-[632px]">
<img :src="ScreenshotDesktop" alt="App Screenshot" class="relative z-10 w-full" />
</div>
</div>
@ -154,17 +168,14 @@
import { ref, onMounted, watch, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useLocalStorage } from '@vueuse/core'
import CompanyNameIcon from './company-name.svg?component'
import type { LocationQueryValue } from 'vue-router'
import LoginFormModal from './components/LoginFormModal.vue'
import DownloadButton from './components/DownloadButton.vue'
import Logo from './logo.svg?component'
import UserCount from './user-count.svg?component'
import ReviewCarousel from './components/ReviewCarousel/index.vue'
import MobileLogo from './mobile-logo.svg?component'
import ScreenshotMobile from './screenshot-mobile.png'
import ScreenshotDesktop from './screenshot-desktop.webp'
import request from '@/utils/request'
import { getAllQueryString } from '@/utils/url-utils.ts'
const route = useRoute()
const router = useRouter()
@ -196,10 +207,6 @@ const fetchUserInfo = async () => {
}
onMounted(() => {
const ic = getAllQueryString('ic')
if (ic && typeof ic === 'string') {
sessionStorage.setItem('ic', ic)
}
fetchUserInfo()
if (route.query.login === 'true') {
openLoginModal()
@ -245,8 +252,8 @@ const openLoginModal = () => {
/* Core: Fixed minimum diameter (~57% of max) */
.ripple-core {
width: 70%;
height: 70%;
width: 57%;
height: 57%;
opacity: 1;
z-index: 4;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 283 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,32 +0,0 @@
<template>
<div class="login-redirect">
<!-- Optional: Adding a loading state for visibility during transition -->
<div class="loading-spinner">登录中...</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getQueryString } from '@/utils/url-utils.ts'
const router = useRouter()
onMounted(() => {
const token = getQueryString('t')
console.log(token)
if (token) {
localStorage.setItem('Authorization', token)
}
router.replace('/user-center')
})
</script>
<style scoped>
.login-redirect {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 1.2rem;
color: #666;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

View File

@ -1,288 +0,0 @@
<template>
<div class="reviews-page min-h-screen overflow-x-hidden bg-black font-sans text-white">
<!-- Header -->
<div class="h-[80px] md:h-[114px]">
<div class="fixed z-50 w-full bg-black pt-[20px] pb-[20px] md:pt-[34px]">
<div class="container">
<header
class="flex h-[40px] items-center justify-between rounded-full bg-[#ADFF5B] pr-[30px] pl-5 md:h-[60px] md:pr-[58px] md:pl-[41px]"
>
<router-link to="/" class="flex items-center gap-2">
<!-- Desktop Logo -->
<!-- <Logo :src="Logo" alt="Hi快VPN" class="hidden h-10 w-auto text-black md:block" />-->
<!-- Mobile Logo -->
<MobileLogo alt="Hi快VPN" class="block h-[22px] text-black md:h-[36px]" />
</router-link>
<RightText class="block h-[12px] text-black md:h-[24px]" />
</header>
</div>
</div>
</div>
<!-- Main Content -->
<main class="container mx-auto max-w-2xl px-4 py-8">
<img src="./image2.png" class="hidden md:block" />
<img src="./image1.png" class="md:hidden" />
<div class="my-[40px] h-[1px] w-full bg-[#757575]" />
<div class="space-y-8">
<div
v-for="(review, index) in displayReviews"
:key="index"
class="review-card"
:style="{ '--index': index % 10 }"
>
<div class="mb-3 flex items-start justify-between">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 class="text-lg leading-tight font-bold">{{ review.username }}</h2>
<span class="text-sm text-white/40">{{ review.date }}</span>
</div>
<!-- Stars -->
<div class="mt-1 flex shrink-0 gap-0.5">
<svg
v-for="i in 5"
:key="i"
class="h-4 w-4"
:class="i <= (review.stars || 5) ? 'text-[#ADFF5B]' : 'text-white/10'"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
</div>
</div>
<p class="mb-8 text-[15px] leading-relaxed text-white/80 md:text-base">
{{ review.comment }}
</p>
<div class="w-full border-b border-[#757575]"></div>
</div>
<!-- Loading state -->
<div v-if="isLoading" class="flex justify-center py-10">
<div
class="h-6 w-6 animate-spin rounded-full border-2 border-[#ADFF5B]/30 border-t-[#ADFF5B]"
></div>
</div>
<!-- No more data -->
<div v-if="!hasMore && !isLoading" class="py-10 text-center text-sm text-white/30">
已经到底啦 ~
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useInfiniteScroll } from '@vueuse/core'
import { getAllQueryString } from '@/utils/url-utils.ts'
import MobileLogo from '@/pages/Home/mobile-logo.svg?component'
import RightText from './right-text.svg?component'
const router = useRouter()
onMounted(() => {
const ic = getAllQueryString('ic')
if (ic && typeof ic === 'string') {
sessionStorage.setItem('ic', ic)
}
})
const isLoading = ref(false)
const hasMore = ref(true)
// Fixed 20 random reviews based on the provided design
const allReviews = [
{
username: 'Alex Chen',
date: 'Oct.12',
stars: 5,
comment:
'真的是极简主义的胜利。没有多余的按钮打开软件的瞬间就已经连接成功了。抛弃了传统VPN繁琐的线路选择这种“无感”的体验让人非常舒服。Netflix 4K 拖拽完全没有缓冲。',
},
{
username: 'Fjafw****@gmail.com',
date: 'Feb. 2',
stars: 5,
comment:
'IEPL专线稳定性确实不是普通机场能比的。晚高峰打竞技游戏延迟依然很低几乎感觉不到身在海外。UI设计长在我的审美上黑绿配色极其克制且高级。',
},
{
username: 'Sarah Lin',
date: 'Sep. 19',
stars: 4,
comment:
'速度和稳定性都没得说,是目前用过最省心的软件。唯一扣掉的一星是希望能在未来加入一些特定地区冷门节点的备选方案,虽然目前的智能分配已经足够好用。总体强烈推荐。',
},
{
username: 'Michael W.',
date: 'Jan. 15',
stars: 5,
comment:
'I have tried many VPNs, but HiFast is by far the fastest. The connection is instantaneous and the bandwidth is impressive.',
},
{
username: '张晓明',
date: 'Dec. 5',
stars: 5,
comment: '非常好用连接速度快看YouTube 4K一点都不卡。',
},
{
username: 'David K.',
date: 'Nov. 22',
stars: 5,
comment:
'The UI is clean and the performance is top-notch. Highly recommended for professionals.',
},
{
username: 'Emily Chen',
date: 'Mar. 10',
stars: 5,
comment: 'Best VPN for gaming! Low ping and no packet loss. Love it.',
},
{
username: '李华',
date: 'Feb. 28',
stars: 5,
comment: '客服态度很好,遇到问题解决得很快。软件本身也非常稳定。',
},
{
username: 'Robert J.',
date: 'May. 14',
stars: 5,
comment: 'Solid performance. I use it for my remote work and it never fails me.',
},
{
username: '王大力',
date: 'Jun. 20',
stars: 4,
comment: '很不错的VPN虽然价格稍微贵一点但物有所值。',
},
{
username: 'Jessica S.',
date: 'Aug. 3',
stars: 5,
comment: 'So easy to use. Just one click and I am protected. Great job!',
},
{
username: '陈伟',
date: 'Jul. 12',
stars: 5,
comment: '在国外用这个看国内视频非常流畅,没有任何限制。',
},
{
username: 'Kevin L.',
date: 'Sep. 30',
stars: 5,
comment: 'The encryption makes me feel safe. High speed is a bonus.',
},
{
username: '周杰',
date: 'Apr. 18',
stars: 5,
comment: '支持多设备同时在线,非常方便。家里几个人共用一个账号。',
},
{
username: 'Anna B.',
date: 'Oct. 5',
stars: 5,
comment: 'Love the dark mode UI. It looks so modern and sleek.',
},
{
username: '赵三',
date: 'Dec. 25',
stars: 4,
comment: '整体感觉不错,连接速度挺快的,偶尔会断线。',
},
{
username: 'Thomas M.',
date: 'Nov. 8',
stars: 5,
comment: 'Great service. I have recommended it to all my friends.',
},
{
username: '刘红',
date: 'Jan. 22',
stars: 5,
comment: '非常感谢Hi快VPN让我能方便地学习国外的课程。',
},
{
username: 'Steven H.',
date: 'Feb. 14',
stars: 5,
comment: 'Excellent bandwidth. Streaming 4K is like watching local videos.',
},
{
username: '吴优',
date: 'Mar. 25',
stars: 5,
comment: '用过最简单的VPN即使是不懂技术的人也能轻松上手。',
},
]
const displayReviews = ref(allReviews.slice(0, 5))
const currentIndex = ref(5)
const loadMore = () => {
if (isLoading.value || !hasMore.value) return
isLoading.value = true
// Simulate network delay
setTimeout(() => {
const nextBatch = allReviews.slice(currentIndex.value, currentIndex.value + 3)
if (nextBatch.length > 0) {
displayReviews.value.push(...nextBatch)
currentIndex.value += nextBatch.length
}
if (currentIndex.value >= allReviews.length) {
hasMore.value = false
}
isLoading.value = false
}, 800)
}
useInfiniteScroll(
window,
() => {
loadMore()
},
{ distance: 200 },
)
const goBack = () => {
router.push('/')
}
</script>
<style scoped>
.reviews-page {
background-color: #000;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.review-card {
animation: slideUp 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Staggered animation for initial items */
.review-card {
animation-delay: calc(var(--index, 0) * 0.05s);
}
</style>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -11,7 +11,7 @@
<div v-else class="flex flex-col items-center">
<img src="../avatar.png" class="size-[60px]" alt="" />
<div class="flex flex-col justify-center text-white">
<div class="pt-3 text-xl font-semibold">{{ userInfo.email }}</div>
<div class="text-xl font-semibold">{{ userInfo.email }}</div>
</div>
</div>
</Transition>
@ -25,13 +25,12 @@
</div>
<div>
<div class="px-6 pt-8 pb-9">
<Button
<!-- <Button
variant="outline"
@click="$emit('show-order-details')"
class="mb-[10px] h-[50px] w-full rounded-[32px] border-2 border-[#ADFF5B] bg-transparent text-xl font-bold text-[#ADFF5B] transition-all hover:bg-[#ADFF5B]/90 active:scale-[0.98]"
class="mb-[10px] h-[50px] w-full rounded-[32px] border-2 border-[#FF00B7] bg-transparent text-xl font-bold text-[#FF00B7] transition-all hover:bg-[#FF00FF]/90 active:scale-[0.98]"
>
订单详情
</Button>
注销账户
</Button>-->
<Button
variant="outline"
@ -42,10 +41,7 @@
</Button>
</div>
<div
class="text-center text-xs text-white tabular-nums"
:class="{ 'text-[#FF00B7]': expireDateInfo.highlight }"
>
<div class="text-center text-xs" :class="{ 'text-[#FF00B7]': expireDateInfo.highlight }">
{{ expireDateInfo.text }}
</div>
</div>
@ -62,10 +58,7 @@
<PlanCard
v-else
:plans="plans"
:trial-plan="trialPlan"
:trial-countdown="trialCountdown"
:currentPlanIndex="currentPlanIndex"
:selectedPlanId="selectedPlanId"
@select="handlePlanSelect"
/>
</Transition>
@ -98,7 +91,6 @@ import DeviceList from '@/components/user-center/DeviceList.vue'
import PaymentMethod from '@/components/user-center/PaymentMethod.vue'
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
import { Button } from '@/components/ui/button'
import { formatExpireDate } from '../subscription'
import { toast } from 'vue-sonner'
import { useRouter } from 'vue-router'
const router = useRouter()
@ -114,11 +106,9 @@ const props = defineProps<{
isUserLoading: boolean
isPlansLoading: boolean
isPaymentsLoading: boolean
trialPlan: any
trialCountdown: string
}>()
const emit = defineEmits(['select-plan', 'pay', 'refresh', 'show-order-details'])
const emit = defineEmits(['select-plan', 'pay', 'refresh'])
// --- Handlers ---
const handlePlanSelect = (id: string) => {
@ -130,7 +120,38 @@ const currentPlanIndex = computed(() => {
const expireDateInfo = computed(() => {
const first = props.alreadySubscribed[0]
return formatExpireDate(first)
let text = ''
let highlight = false
if (!first || !first.expireDate) {
text = '尚未购买套餐'
highlight = true
} else {
//
const dateStr = first.expireDate.replace(/ /g, 'T')
const expireDateTime = new Date(dateStr)
if (isNaN(expireDateTime.getTime())) {
text = '套餐信息无效'
} else if (expireDateTime < new Date()) {
const year = expireDateTime.getFullYear()
const month = String(expireDateTime.getMonth() + 1).padStart(2, '0')
const day = String(expireDateTime.getDate()).padStart(2, '0')
text = `已于 ${year}/${month}/${day} 到期`
highlight = true
} else {
const year = expireDateTime.getFullYear()
const month = String(expireDateTime.getMonth() + 1).padStart(2, '0')
const day = String(expireDateTime.getDate()).padStart(2, '0')
const hour = String(expireDateTime.getHours()).padStart(2, '0')
const minute = String(expireDateTime.getMinutes()).padStart(2, '0')
const second = String(expireDateTime.getSeconds()).padStart(2, '0')
text = `到期时间:${year}/${month}/${day} ${hour}:${minute}:${second}`
highlight = false
}
}
return { text, highlight }
})
function logout() {
@ -140,6 +161,9 @@ function logout() {
</script>
<style scoped>
/* Simplified layout font */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
.tracking-tight {
font-family: 'Inter', 'PingFang SC', sans-serif;
}

View File

@ -1,6 +1,6 @@
<template>
<!-- Main Neon Green Card -->
<div class="pt-[15px]">
<div class="pt-[35px]">
<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" />
@ -8,10 +8,7 @@
<img src="../avatar.png" class="size-[60px]" alt="" />
<div class="flex h-full flex-col justify-center text-white">
<div class="text-xl font-semibold">{{ userInfo.email }}</div>
<div
class="text-xs tabular-nums"
:class="{ 'text-[#FF00B7]': expireDateInfo.highlight }"
>
<div class="text-xs" :class="{ 'text-[#FF00B7]': expireDateInfo.highlight }">
{{ expireDateInfo.text }}
</div>
</div>
@ -34,10 +31,7 @@
<PlanCard
v-else
:plans="plans"
:trial-plan="trialPlan"
:trial-countdown="trialCountdown"
:currentPlanIndex="currentPlanIndex"
:selectedPlanId="selectedPlanId"
@select="handlePlanSelect"
/>
</Transition>
@ -59,13 +53,12 @@
</div>
<div class="px-6 pt-8 pb-[calc(50px+env(safe-area-inset-bottom))]">
<Button
<!-- <Button
variant="outline"
@click="$emit('show-order-details')"
class="mb-[10px] h-[50px] w-full rounded-[32px] border-2 border-[#ADFF5B] bg-transparent text-xl font-bold text-[#ADFF5B] transition-all hover:bg-[#ADFF5B]/90 active:scale-[0.98]"
class="mb-[10px] h-[50px] w-full rounded-[32px] border-2 border-[#FF00B7] bg-transparent text-xl font-bold text-[#FF00B7] transition-all hover:bg-[#FF00FF]/90 active:scale-[0.98]"
>
订单详情
</Button>
注销账户
</Button>-->
<Button
variant="outline"
@ -85,7 +78,6 @@ import DeviceList from '@/components/user-center/DeviceList.vue'
import PaymentMethod from '@/components/user-center/PaymentMethod.vue'
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
import { Button } from '@/components/ui/button'
import { formatExpireDate } from '../subscription'
import { toast } from 'vue-sonner'
import { useRouter } from 'vue-router'
const router = useRouter()
@ -101,11 +93,9 @@ const props = defineProps<{
isUserLoading: boolean
isPlansLoading: boolean
isPaymentsLoading: boolean
trialPlan: any
trialCountdown: string
}>()
const emit = defineEmits(['select-plan', 'pay', 'refresh', 'show-order-details'])
const emit = defineEmits(['select-plan', 'pay', 'refresh'])
// --- Handlers ---
const handlePlanSelect = (id: string) => {
@ -117,7 +107,38 @@ const currentPlanIndex = computed(() => {
const expireDateInfo = computed(() => {
const first = props.alreadySubscribed[0]
return formatExpireDate(first)
let text = ''
let highlight = false
if (!first || !first.expireDate) {
text = '尚未购买套餐'
highlight = true
} else {
//
const dateStr = first.expireDate.replace(/ /g, 'T')
const expireDateTime = new Date(dateStr)
if (isNaN(expireDateTime.getTime())) {
text = '套餐信息无效'
} else if (expireDateTime < new Date()) {
const year = expireDateTime.getFullYear()
const month = String(expireDateTime.getMonth() + 1).padStart(2, '0')
const day = String(expireDateTime.getDate()).padStart(2, '0')
text = `已于 ${year}/${month}/${day} 到期`
highlight = true
} else {
const year = expireDateTime.getFullYear()
const month = String(expireDateTime.getMonth() + 1).padStart(2, '0')
const day = String(expireDateTime.getDate()).padStart(2, '0')
const hour = String(expireDateTime.getHours()).padStart(2, '0')
const minute = String(expireDateTime.getMinutes()).padStart(2, '0')
const second = String(expireDateTime.getSeconds()).padStart(2, '0')
text = `到期时间:${year}/${month}/${day} ${hour}:${minute}:${second}`
highlight = false
}
}
return { text, highlight }
})
function logout() {
@ -127,6 +148,9 @@ function logout() {
</script>
<style scoped>
/* Simplified layout font */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
.tracking-tight {
font-family: 'Inter', 'PingFang SC', sans-serif;
}

View File

@ -1,163 +0,0 @@
<template>
<div class="flex flex-1 flex-col overflow-hidden">
<!-- List Container -->
<div class="h-[470px]">
<div v-if="loading" class="flex h-40 items-center justify-center">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-[#ADFF5B] border-t-transparent"
></div>
</div>
<div
v-else-if="list.length === 0"
class="flex h-40 items-center justify-center text-gray-500"
>
暂无订单记录
</div>
<div v-else class="space-y-[10px]">
<div
v-for="order in list"
:key="order.id"
class="rounded-[20px] bg-[#CECECF] py-2 text-[14px] font-normal text-black"
>
<div class="grid grid-cols-2 gap-y-1">
<!-- Order No -->
<div class="col-span-2 pl-4">
<div class="">订单号</div>
<div class="break-all">{{ order.order_no }}</div>
</div>
<!-- Name and Status -->
<div class="pl-4">
<div class="text-gray-500">名称</div>
<div class="whitespace-nowrap">{{ order.quantity }}天VPN服务</div>
</div>
<div class="">
<div class="text-gray-500">状态</div>
<div>{{ statusText(order.status) }}</div>
</div>
<!-- Amount and Time -->
<div class="pl-4">
<div class="text-gray-500">支付金额</div>
<div>${{ order.amount / 100 }}</div>
</div>
<div class="overflow-hidden">
<div class="text-gray-500">支付时间</div>
<div class="whitespace-nowrap">{{ formatTime(order.created_at) }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination Controls -->
<div v-if="total > 0" class="flex flex-col items-center pt-[18px]">
<div class="mb-2 flex items-center gap-[10px]">
<button
@click="changePage(1)"
:disabled="page === 1"
class="flex h-[30px] min-w-[30px] items-center justify-center rounded-full bg-[#EAEAEA] transition-opacity"
>
<span class="text-lg font-bold text-[#848484]">&lt;&lt;</span>
</button>
<button
@click="changePage(page - 1)"
:disabled="page === 1"
class="flex h-[30px] min-w-[30px] items-center justify-center rounded-full bg-[#EAEAEA] transition-opacity"
>
<span class="text-lg font-bold text-[#848484]">&lt;</span>
</button>
<div
class="flex h-[30px] min-w-[65px] items-center justify-center rounded-full bg-[#EAEAEA] px-2"
>
<span class="text-base text-[#848484]">{{ page }}</span>
<span class="ml-1 text-base text-[#848484]">v</span>
</div>
<button
@click="changePage(page + 1)"
:disabled="page >= totalPages"
class="flex h-[30px] min-w-[30px] items-center justify-center rounded-full bg-[#EAEAEA] transition-opacity"
>
<span class="text-lg font-bold text-[#848484]">&gt;</span>
</button>
<button
@click="changePage(totalPages)"
:disabled="page >= totalPages"
class="flex h-[30px] min-w-[30px] items-center justify-center rounded-full bg-[#EAEAEA] transition-opacity"
>
<span class="text-lg font-bold text-[#848484]">&gt;&gt;</span>
</button>
</div>
<div class="text-xs font-[300] text-[#848484]"> {{ page }} / {{ totalPages }} </div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import request from '@/utils/request'
const page = ref(1)
const size = ref(3)
const total = ref(0)
const list = ref<any[]>([])
const loading = ref(false)
const totalPages = computed(() => Math.ceil(total.value / size.value) || 1)
async function fetchOrders() {
loading.value = true
try {
const res: any = await request.get('/api/v1/public/order/list', {
page: page.value,
size: size.value,
status: 5,
})
list.value = res.list || []
total.value = res.total || 0
} catch (error) {
console.error('Fetch orders error:', error)
} finally {
loading.value = false
}
}
function changePage(p: number) {
if (p < 1 || p > totalPages.value) return
page.value = p
fetchOrders()
}
function statusText(status: number) {
const map: Record<number, string> = {
1: '待支付',
2: '已支付',
3: '已关闭',
4: '支付失败',
5: '已完成',
}
return map[status] || '未知'
}
function formatTime(timestamp: number) {
if (!timestamp) return '-'
const date = new Date(timestamp > 10000000000 ? timestamp : timestamp * 1000)
return date.toLocaleString('en-US', {
month: 'numeric',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
}
onMounted(() => {
fetchOrders()
})
</script>
<style scoped></style>

View File

@ -1,60 +0,0 @@
<template>
<Dialog :open="isOpen" @update:open="setOpen">
<DialogContent
class="top-[94px] flex h-[calc(100vh-94px-50px)] w-[322px] translate-y-0 flex-col items-center justify-center border-none bg-transparent p-0 shadow-none outline-none focus:ring-0"
:showCloseButton="false"
>
<div class="relative flex h-[678px] w-full flex-col rounded-[32px] bg-[#DDDDDD] px-4 py-6">
<!-- Close Button -->
<button
@click="hide"
class="absolute top-6 right-6 z-10 flex size-8 items-center justify-center rounded-lg transition-colors hover:bg-black/5"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="text-black"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<h2 class="mb-2 px-4 pt-8 text-center text-[20px] font-bold text-black">订单详情</h2>
<OrderList />
</div>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import OrderList from './OrderList.vue'
const isOpen = ref(false)
const setOpen = (value: boolean) => {
isOpen.value = value
}
const show = () => {
isOpen.value = true
}
const hide = () => {
isOpen.value = false
}
defineExpose({
show,
hide,
})
</script>

View File

@ -1,19 +1,23 @@
<template>
<div class="flex min-h-screen flex-col bg-black text-black">
<!-- Full Width Header -->
<div class="h-[80px] md:h-[94px]">
<div class="fixed z-50 w-full bg-black pt-[20px] pb-[20px] md:pt-[34px]">
<div class="h-[88px] md:h-[125px]">
<div class="fixed z-50 w-full bg-black pt-[20px] md:pt-[45px]">
<div class="container">
<header
class="flex h-[40px] items-center justify-between rounded-full bg-[#ADFF5B] pr-[30px] pl-5 md:h-[60px] md:pr-[58px] md:pl-[41px]"
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]"
>
<router-link to="/" class="flex items-center gap-2">
<!-- Desktop Logo -->
<!-- <Logo :src="Logo" alt="Hi快VPN" class="hidden h-10 w-auto text-black md:block" />-->
<!-- Mobile Logo -->
<MobileLogo alt="Hi快VPN" class="block h-[22px] text-black md:h-[36px]" />
<MobileLogo
:src="MobileLogo"
alt="Hi快VPN"
class="block h-[28px] text-black md:h-[50px]"
/>
</router-link>
<div class="text-base font-[600] text-black md:text-2xl">个人账户</div>
<div class="text-xl font-[600] text-black md:text-3xl">个人账户</div>
</header>
</div>
</div>
@ -33,12 +37,9 @@
:is-user-loading="isUserLoading"
:is-plans-loading="isPlansLoading"
:is-payments-loading="isPaymentsLoading"
:trial-plan="trialPlan"
:trial-countdown="trialCountdown"
@select-plan="handlePlanSelect"
@pay="handlePay"
@refresh="init"
@show-order-details="orderDetailsModalRef?.show()"
/>
</div>
<div class="hidden flex-1 items-center justify-center md:flex md:pb-[50px]">
@ -55,19 +56,15 @@
:is-user-loading="isUserLoading"
:is-plans-loading="isPlansLoading"
:is-payments-loading="isPaymentsLoading"
:trial-plan="trialPlan"
:trial-countdown="trialCountdown"
@select-plan="handlePlanSelect"
@pay="handlePay"
@refresh="init"
@show-order-details="orderDetailsModalRef?.show()"
/>
</div>
</div>
</div>
<OrderStatusDialog ref="orderStatusDialogRef" @close="handleStatusClose" @refresh="init" />
<OrderDetailsModal ref="orderDetailsModalRef" />
</div>
</template>
@ -77,12 +74,10 @@ import { useRoute, useRouter } from 'vue-router'
import MobileLayout from './MobileLayout/index.vue'
import DesktopLayout from './DesktopLayout/index.vue'
import OrderStatusDialog from '@/components/user-center/OrderStatusDialog.vue'
import OrderDetailsModal from './components/OrderDetails/index.vue'
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
import Logo from '@/pages/Home/logo.svg?component'
import MobileLogo from '@/pages/Home/mobile-logo.svg?component'
import request from '@/utils/request'
import { toast } from 'vue-sonner'
const route = useRoute()
const router = useRouter()
@ -93,7 +88,6 @@ const isUserLoading = ref(true)
const isPlansLoading = ref(true)
const isPaymentsLoading = ref(true)
const orderStatusDialogRef = ref<InstanceType<typeof OrderStatusDialog> | null>(null)
const orderDetailsModalRef = ref<InstanceType<typeof OrderDetailsModal> | null>(null)
const selectedPayment = ref('alipay')
const devices = ref<any[]>([])
@ -105,77 +99,7 @@ const userSubInfo = ref({
expireDate: '',
})
const rawPlansList = ref<any[]>([])
const isTrial = ref(false)
const trialPlan = ref<any>(null)
const trialCountdown = ref('')
let countdownTimer: any = null
// --- Data Mapping ---
const updatePlans = () => {
// First map all plans using the standard logic
const allMappedPlans = mapPlans(rawPlansList.value)
if (isTrial.value && allMappedPlans.length > 0) {
// Extract the last item as the trial plan (based on user requirement)
const trial = allMappedPlans.pop()
if (trial) {
trial.discount = '新客尝鲜价' // Override discount text
trialPlan.value = trial
// If we haven't selected a plan yet, or if the current selection is invalid, select the trial plan
if (
!selectedPlanId.value ||
(allMappedPlans.length > 0 &&
!allMappedPlans.find((p: any) => p.id === selectedPlanId.value))
) {
selectedPlanId.value = trial.id
}
}
} else {
trialPlan.value = null
}
// The remaining plans are displayed in the normal list
plans.value = allMappedPlans
// Fallback selection if trial is not active and no plan selected
if (
!isTrial.value &&
plans.value.length > 0 &&
!plans.value.find((p) => p.id === selectedPlanId.value)
) {
selectedPlanId.value = plans.value[0].id
}
}
const startCountdown = (createdAtStr: string) => {
const createdAt = new Date(createdAtStr).getTime()
const deadline = createdAt + 24 * 60 * 60 * 1000
const update = () => {
const now = new Date().getTime()
const diff = deadline - now
if (diff <= 0) {
isTrial.value = false
trialPlan.value = null
updatePlans()
if (countdownTimer) clearInterval(countdownTimer)
return
}
const h = Math.floor(diff / (1000 * 60 * 60))
const m = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const s = Math.floor((diff % (1000 * 60)) / 1000)
trialCountdown.value = `${h}h:${m}m:${s}s后失效`
}
update()
countdownTimer = setInterval(update, 1000)
}
const mapPlans = (apiList: any[]) => {
const flattened: any[] = []
let globalIndex = 0
@ -217,9 +141,6 @@ const mapPlans = (apiList: any[]) => {
// --- Handlers ---
const activePlan = computed(() => {
if (trialPlan.value && selectedPlanId.value === trialPlan.value.id) {
return trialPlan.value
}
return plans.value.find((p) => p.id === selectedPlanId.value)
})
@ -227,59 +148,48 @@ const handlePlanSelect = (id: string) => {
selectedPlanId.value = id
}
const handlePay = async (methodId: number | string) => {
const handlePay = (methodId: number | string) => {
if (isPaying.value) return
// 1.
const plan = plans.value.find((p) => p.id === selectedPlanId.value)
if (!plan) {
toast.error('请选择有效的订阅套餐')
return
if (!plan) return
const already = alreadySubscribed.value.find((s: any) => s.subscribe_id === plan.planId)
const isRenewal = !!already
const api = isRenewal ? '/api/v1/public/order/renewal' : '/api/v1/public/order/purchase'
const params: any = {
subscribe_id: plan.planId,
quantity: plan.quantity,
payment: methodId,
}
if (isRenewal) {
params.user_subscribe_id = already.id
}
isPaying.value = true
try {
// 2.
const { list } = (await request.get('/api/v1/public/user/subscribe', {
includeExpired: 'all',
})) as any
const existingSub = list?.find((s: any) => s.subscribe_id === plan.planId)
const isRenewal = !!existingSub
const api = isRenewal ? '/api/v1/public/order/renewal' : '/api/v1/public/order/purchase'
const orderParams = {
subscribe_id: plan.planId,
quantity: plan.quantity,
payment: methodId,
...(isRenewal && { user_subscribe_id: existingSub.id }), //
}
// 3.
const orderRes: any = await request.post(api, orderParams)
if (!orderRes?.order_no) throw new Error('订单创建失败')
// 4.
const checkoutRes: any = await request.post('/api/v1/public/portal/order/checkout', {
orderNo: orderRes.order_no,
returnUrl: `${window.location.origin}/user-center?order_no=${orderRes.order_no}`,
request
.post(api, params)
.then((res: any) => {
request
.post('/api/v1/public/portal/order/checkout', {
orderNo: res.order_no,
returnUrl: `${window.location.origin}/user-center?order_no=${res.order_no}`,
})
.then((checkoutRes: any) => {
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
})
}
})
.finally(() => {
isPaying.value = false
})
})
.catch(() => {
isPaying.value = false
})
// 5.
if (checkoutRes.type === 'url' && checkoutRes.checkout_url) {
localStorage.setItem('pending_order_no', orderRes.order_no)
window.location.href = checkoutRes.checkout_url
} else {
throw new Error('支付网关配置异常')
}
} catch (error: any) {
console.error('支付流程异常:', error)
// 使 Toaster
toast.error(error.message || '发起支付失败,请稍后重试')
} finally {
isPaying.value = false
}
}
async function init() {
@ -294,36 +204,6 @@ async function init() {
userSubInfo.value.email = emailInfo.auth_identifier
localStorage.setItem('UserEmail', emailInfo.auth_identifier)
}
/*
*
* */
// mock 12:00
// const todayStart = new Date()
// todayStart.setHours(0, 0, 0, 0)
//
// res.created_at = todayStart.getTime()
// Check trial eligibility
if (res.created_at) {
const createdAt = new Date(res.created_at).getTime()
const now = new Date().getTime()
if (now - createdAt < 24 * 60 * 60 * 1000) {
request
.get('/api/v1/public/order/list', {
page: 1,
size: 1,
status: 5,
})
.then((orderRes: any) => {
if (orderRes.list && orderRes.list.length === 0) {
isTrial.value = true
updatePlans()
startCountdown(res.created_at)
}
})
}
}
})
.finally(() => {
isUserLoading.value = false
@ -339,8 +219,10 @@ async function init() {
request
.get('/api/v1/public/subscribe/list')
.then((res: any) => {
rawPlansList.value = res.list || []
updatePlans()
plans.value = mapPlans(res.list || [])
if (plans.value.length > 0) {
selectedPlanId.value = plans.value[0].id
}
})
.finally(() => {
isPlansLoading.value = false
@ -352,10 +234,7 @@ async function init() {
.get('/api/v1/public/payment/methods')
.then((res: any) => {
payments.value =
res.list?.filter(
(p: any) =>
p.platform !== 'apple_iap' && p.platform !== 'Stripe' && p.platform !== 'balance',
) || []
res.list?.filter((p: any) => p.platform !== 'apple_iap' && p.platform !== 'Stripe') || []
})
.finally(() => {
isPaymentsLoading.value = false
@ -366,11 +245,7 @@ onMounted(() => {
init()
const orderNo = (route.query.order_no as string) || localStorage.getItem('pending_order_no')
if (orderNo) {
request.get('/api/v1/public/order/detail', { order_no: orderNo }).then((data) => {
if (data.status === 1 || data.status === 5 || data.status === 2) {
orderStatusDialogRef.value?.show(orderNo)
}
})
orderStatusDialogRef.value?.show(orderNo)
}
})
@ -394,8 +269,10 @@ function handleStatusClose() {
</style>
<style scoped>
.tracking-tight {
/* Simplified layout font */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
.tracking-tight {
font-family: 'Inter', 'PingFang SC', sans-serif;
}
</style>

View File

@ -1,59 +0,0 @@
/**
*
*/
interface SubscriptionItem {
expire_time?: string | number | null
}
export interface ExpireInfo {
text: string
highlight: boolean
}
/**
*
* @param sub item
*/
export function formatExpireDate(sub?: SubscriptionItem | null): ExpireInfo {
// 1. 处理对象不存在的情况
if (!sub || !sub.expire_time) {
return { text: '尚未购买套餐', highlight: true }
}
// 2. 解析日期(兼容 ISO 字符串和时间戳)
const timestamp = sub.expire_time
const isMs = typeof timestamp === 'number' && timestamp > 10000000000
const expireDate = new Date(
isMs ? timestamp : typeof timestamp === 'number' ? timestamp * 1000 : timestamp,
)
if (isNaN(expireDate.getTime())) {
return { text: '套餐信息无效', highlight: false }
}
// 3. 提取日期组件
const now = new Date()
const isExpired = expireDate < now
const y = expireDate.getFullYear()
const m = String(expireDate.getMonth() + 1).padStart(2, '0')
const d = String(expireDate.getDate()).padStart(2, '0')
const dateStr = `${y}/${m}/${d}`
// 4. 根据是否过期返回不同格式
if (isExpired) {
return {
text: `已于 ${dateStr} 到期`,
highlight: true,
}
}
const hh = String(expireDate.getHours()).padStart(2, '0')
const mm = String(expireDate.getMinutes()).padStart(2, '0')
// const ss = String(expireDate.getSeconds()).padStart(2, '0')
return {
text: `到期时间:${dateStr} ${hh}:${mm}`,
highlight: false,
}
}

View File

@ -8,11 +8,6 @@ const router = createRouter({
name: 'home',
component: () => import('../pages/Home/index.vue'),
},
{
path: '/reviews',
name: 'reviews',
component: () => import('../pages/Reviews/index.vue'),
},
{
path: '/help',
name: 'help',
@ -24,25 +19,15 @@ const router = createRouter({
component: () => import('../pages/UserCenter/index.vue'),
},
{
path: '/terms',
path: '/terms-of-service',
name: 'terms-of-service',
component: () => import('../pages/TermsOfService/index.vue'),
},
{
path: '/privacy',
path: '/privacy-policy',
name: 'privacy-policy',
component: () => import('../pages/PrivacyPolicy/index.vue'),
},
{
path: '/login',
name: 'login-redirect',
component: () => import('../pages/LoginRedirect/index.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
redirect: '/',
},
],
})

View File

@ -5,44 +5,37 @@
font-style: normal;
font-display: swap;
}
html, body {
background: #000;
}
.lucid-glass-bar {
/* 基础背景Figma 中通常是叠加的,这里取中值确保通透度 */
background: rgb(255 255 255 / 6%);
/* 核心模糊 */
@apply backdrop-blur-[36px];
/* 圆角:由于你有 border-image建议使用这种方式保留圆角 */
border-radius: 999px;
/* 边框Figma border-image CSS 圆角容器上会有兼容问题
建议改用单纯的 border 配合 rgba 以达到线性渐变的效果 */
border: 1px solid rgba(255, 255, 255, 0.2);
/* 阴影叠加:按照 Figma 从上到下的顺序转换 */
box-shadow:
/* 1. 这里的 #F2F2F280 33px 扩散是导致发白的主因,建议将其放在最底层并调低不透明度 */
inset 0px 0px 33px 0px rgba(242, 242, 242, 0.15),
/* 2. 右下角的反向高光 (原本的 -3px -4.5px) */
inset 0px -2px 1.5px -3px rgba(179, 179, 179, 0.5),
/* 3. 左上角的微弱亮边 */
inset 3px 4.5px 1.5px -3px rgba(179, 179, 179, 0.2),
/* 4. 核心受光面 (Figma: 4.5px 4.5px #FFFFFF80) */
inset 4.5px 4.5px 1.5px -5.25px rgba(255, 255, 255, 0.4),
/* 5. 核心背光面 (Figma: -4.5px -4.5px #FFFFFF80) */
inset -4.5px -4.5px 1.5px -5.25px rgba(255, 255, 255, 0.3);
}
@import "tailwindcss";
@import "tw-animate-css";
@layer utilities {
.lucid-capsule {
/* 1. 圆角与模糊 */
border-radius: 90px;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
/* 2. 边框:仅顶部和左侧,模拟光线从左上方射入 */
border-top: 1px solid #FFFFFF;
border-left: 1px solid #FFFFFF;
/* 3. 多层背景与混合模式 (核心:注意叠加顺序) */
background:
linear-gradient(0deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.10)), /* 顶层:提亮 */
linear-gradient(0deg, #1D1D1D, #1D1D1D), /* 中层:底色 */
rgba(29, 29, 29, 0.10); /* 底层:深度 */
background-blend-mode: normal, plus-lighter, color-burn;
/* 4. 五层复合内阴影 */
box-shadow:
-4.5px -4.5px 1.5px -5.25px rgba(255, 255, 255, 0.50) inset,
4.5px 4.5px 1.5px -5.25px rgba(255, 255, 255, 0.50) inset,
3px 4.5px 1.5px -3px rgba(179, 179, 179, 0.20) inset,
-3px -4.5px 1.5px -3px #B3B3B3 inset,
0 0 33px 0 rgba(242, 242, 242, 0.50) inset;
}
}
@layer components {
.container {
width: 100%;

View File

@ -1,3 +1,3 @@
// utils/constant.ts
export const downLoadIos = 'https://apps.apple.com/us/app/hi%E5%BF%ABvpn/id6755683167'
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'

View File

@ -1,86 +0,0 @@
import { getAllQueryString } from '@/utils/url-utils.ts'
/**
* OpenInstall sdk h5邀请参数
* openinstall sdk window对象上 window.OI_SDK访问
* sdk初始化时会自动检索url上的参数并作为拉起下载或唤醒app时的参数
*/
const script = document.createElement('script')
script.type = 'text/javascript'
script.charset = 'UTF-8'
script.src = 'https://web.cdn.openinstall.io/openinstall.js'
document.head.appendChild(script)
let resolveReady: (sdk: OpenInstallSdk) => void
window.OI_SDK_PROMISE = new Promise((resolve) => {
resolveReady = resolve
})
script.addEventListener('load', () => {
window.OI_SDK = new OpenInstallSdk(resolveReady)
})
class OpenInstallSdk {
public urlQuery: any // openinstall.js中提供的api解析当前网页url中的查询参数并对data进行赋值
public OI: Record<string, any> // openinstall 实例
public isReady = false
constructor(private onReadyCallback?: (sdk: OpenInstallSdk) => void) {
this.OI = {}
this.urlQuery = window.OpenInstall.parseUrlParams()
const id = getAllQueryString('id')
if (id) {
this.urlQuery = {
platform: 'merchant',
code: id,
}
}
this.init()
}
async init() {
const self = this
try {
this.OI = new window.OpenInstall(
{
appKey: 'alf57p',
onready: function () {
// 初始化成功回调方法。当初始化完成后,会自动进入
// 注意:此时的 this 是 OpenInstall 的原始实例对象 (m)
const m = this
// m.schemeWakeup() // 尝试使用scheme打开App主要用于Android以及iOS的QQ环境中
const button = document.getElementById('downloadButton_apple')
const button_mac = document.getElementById('downloadButton_mac')
const button1 = document.getElementById('downloadButton_android')
const ic = getAllQueryString('ic') || 'uSSfg1Y1vt'
const clickHandler = function () {
if (ic) {
m.wakeupOrInstall({ data: { platform: 'download', inviteCode: ic } })
} else {
m.wakeupOrInstall()
}
return false
}
if (button) button.onclick = clickHandler
if (button_mac) button_mac.onclick = clickHandler
if (button1) button1.onclick = clickHandler
// 标记就绪并触发 Promise
self.isReady = true
if (self.onReadyCallback) {
self.onReadyCallback(self)
}
},
},
this.urlQuery,
) // 初始化时传入data作为一键拉起/App传参安装时候的参数
} catch (e) {
console.log(e, 'OpenInstall——sdk初始化失败')
}
}
}

View File

@ -153,29 +153,27 @@ export default class Request {
...config.extraConfig,
}
if (config.data && !(config.data instanceof FormData)) {
const plainText = JSON.stringify(config.data)
config.data = HiAesUtil.encryptData(plainText, encryptionKey)
}
if (config.method?.toLowerCase() === 'get' || config.params) {
const paramsToEncrypt = config.params || {}
// console.log('paramsToEncrypt', paramsToEncrypt)
const plainParamsText = JSON.stringify(paramsToEncrypt)
const encryptedParams = HiAesUtil.encryptData(plainParamsText, encryptionKey)
config.params = {
data: encryptedParams.data,
time: encryptedParams.time,
}
}
// if (config.data && !(config.data instanceof FormData)) {
// const plainText = JSON.stringify(config.data)
// config.data = HiAesUtil.encryptData(plainText, encryptionKey)
// }
//
// if (config.method?.toLowerCase() === 'get' || config.params) {
// const paramsToEncrypt = config.params || {}
// const plainParamsText = JSON.stringify(paramsToEncrypt)
// const encryptedParams = HiAesUtil.encryptData(plainParamsText, encryptionKey)
//
// config.params = {
// data: encryptedParams.data,
// time: encryptedParams.time,
// }
// }
config.headers = mergeExtraConfig.formatHeader({
...this.config.headers,
...config.headers,
lang: 'zh_CN',
'login-type': 'device',
'X-App-Id': 'web-client',
// 'login-type': 'device',
...(mergeExtraConfig.withToken && {
[mergeExtraConfig.tokenKey]: mergeExtraConfig.getToken(),
}),
@ -195,22 +193,21 @@ export default class Request {
this.axiosInstance.interceptors.response.use(
(response: ResponseType) => {
const { data, config } = response
let responseData = response.data.data
const responseData = response.data.data
if (responseData && responseData.data && responseData.time) {
try {
const decryptedStr = HiAesUtil.decryptData(
responseData.data,
responseData.time,
encryptionKey,
)
// console.log('decryptedStr', JSON.parse(decryptedStr))
responseData = JSON.parse(decryptedStr)
} catch (e) {
console.error('解密失败:', e)
return Promise.reject({ message: '数据解密异常' })
}
}
// if (responseData && responseData.data && responseData.time) {
// try {
// const decryptedStr = HiAesUtil.decryptData(
// responseData.data,
// responseData.time,
// encryptionKey,
// )
// responseData = JSON.parse(decryptedStr)
// } catch (e) {
// console.error('解密失败:', e)
// return Promise.reject({ message: '数据解密异常' })
// }
// }
axiosCanceler.removePending(config)
if (data.code !== 200) {

View File

@ -4,7 +4,7 @@ const baseUrl = import.meta.env.VITE_APP_BASE_URL
const request = new Request({
baseURL: baseUrl,
timeout: 60 * 1000,
timeout: 6000,
headers: {},
extraConfig: {
/** 这里是核心配置一般不需要再去修改request/core.ts */

View File

@ -1,157 +0,0 @@
/**
*
* @param {Object} data
*/
export const param = (data) => {
if (!data) {
return ''
}
let url = ''
for (const k in data) {
const value = data[k] !== undefined ? data[k] : ''
url += `&${k}=${encodeURIComponent(value)}`
}
return url ? url.substr(1) : ''
}
/**
*
*/
export const paramToObj = (data) => {
if (!data) {
return ''
}
let url = ''
for (const k in data) {
const value = data[k] !== undefined ? data[k] : ''
if (typeof value === 'object') {
url += `&${k}=${encodeURIComponent(JSON.stringify(value))}`
} else {
url += `&${k}=${encodeURIComponent(value)}`
}
}
return url ? url.substr(1) : ''
}
/**
* code
* @param {string} name
*/
export const getQueryString = (name) => {
const reg = `(^|&)${name}=([^&]*)(&|$)`
const query = window.location.search.substr(1) || window.location.hash.split('?')[1]
const r = query ? query.match(reg) : null
if (r != null) return decodeURIComponent(r[2])
return null
}
/**
* URL上的值
* @param {*} name key
* @param {*} isMerge hash和search,search优先级高
*/
export const getAllQueryString = (name, isMerge = true) => {
const reg = `(?:^|&)${name}=([^&]*)(?:&|$)`
const search = window.location.search.substr(1)
const hash = window.location.hash.split('?')[1]
const searchR = search ? search.match(reg) && search.match(reg)![1] : null
const hashR = hash ? hash.match(reg) && hash.match(reg)![1] : null
if (isMerge) {
const result = searchR || hashR
return result ? unescape(result) : null
}
return {
search: searchR ? unescape(searchR) : null,
hash: hashR ? unescape(hashR) : null,
}
}
/**
* code情况
* @param {string} url
*/
export function parseURL(url) {
const a = document.createElement('a')
a.href = url
const protocol = a.protocol.replace(':', '')
const host = a.hostname
const path = a.pathname.replace(/^([^\/])/, '/$1')
return {
href: `${protocol}://${host}${path}?`,
source: url,
protocol,
host,
port: a.port,
query: a.search,
params: (function () {
const params = {}
const seg = a.search.replace(/^\?/, '').split('&')
const len = seg.length
let p
for (let i = 0; i < len; i++) {
if (seg[i]) {
p = seg[i].split('=')
params[p[0]] = p[1]
}
}
return params
})(),
hash: a.hash.replace('#', ''),
path,
}
}
/**
* url query对象
*/
export function urlGetParam() {
const t = location.search
.substring(1)
.split('&')
.filter((item) => !!item)
const f = {}
for (let i = 0; i < t.length; i++) {
const x = t[i].split('=')
f[x[0]] = x[1]
}
return f
}
/**
*
* @param {array} removes
* @param {boolean} hash hash值
*/
export function removeUrlQuery(removes, isHash = false) {
const currentParam = urlGetParam()
removes.forEach((removeItem) => {
delete currentParam[removeItem]
})
return `${location.origin}${location.pathname}?${param(currentParam)}${
isHash ? location.hash : ''
}`
}
/**
* keyValue
*/
export function hrefKeyValue(key, url) {
const reg = new RegExp(/(\w+)=(\w+)/, 'gi')
const currentUrl = url || location.href
const results = currentUrl.match(reg)
if (results) {
const resultKeyValues = results.map((o) => ({
[o.split('=')[0]]: o.split('=')[1],
}))
const result = resultKeyValues.find((o) => o.hasOwnProperty(key))
return (result && result[key]) || null
}
return null
}
/**
* url中的参数
*/
export function replaceQueryString(url, name, value) {
const re = new RegExp(name + '=[^&]*', 'gi')
return url.replace(re, name + '=' + value)
}

View File

@ -14,14 +14,7 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
css: {
transformer: 'lightningcss',
},
build: {
cssMinify: 'lightningcss',
},
server: {
proxy: {
// 将所有以 /api 开头的请求转发到目标服务器
// 1. 匹配所有以 /public 开头的请求
@ -31,6 +24,12 @@ export default defineConfig({
rewrite: (path) => path.replace(/^\/api/, ''),
autoRewrite: true,
// 3. 关键:将路径重写,在前面补上 /api/v1
// 验证请求是否进入代理,可以在终端看到打印
configure: (proxy, _options) => {
proxy.on('proxyReq', (proxyReq, req, _res) => {
console.log('代理请求:', req.method, req.url, ' -> ', proxyReq.path)
})
},
},
},
},