Compare commits
No commits in common. "main" and "develop" have entirely different histories.
@ -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
@ -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
@ -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
|
||||
}
|
||||
|
||||
46
index.html
@ -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
@ -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,
|
||||
|
||||
12
package.json
@ -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
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB |
32
src/App.vue
@ -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>
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 |
@ -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)
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 192 KiB |
@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 242 KiB |
BIN
src/pages/Home/bg-mobile.jpg
Normal file
|
After Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
@ -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 |
@ -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 |
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
@ -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>
|
||||
|
||||
|
||||
@ -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 |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 33 KiB |
@ -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>
|
||||
@ -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 |
@ -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> © 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;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@ -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>
|
||||
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@ -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>
|
||||
|
Before Width: | Height: | Size: 6.4 KiB |
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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]"><<</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]"><</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]">></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]">>></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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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: '/',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@ -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%;
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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初始化失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||