Compare commits
113 Commits
2b0741fdb9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c2cc33620 | |||
| 82f41b1731 | |||
| 2e4c6c2329 | |||
| 06587fb47b | |||
| cd307c3297 | |||
| d025ab2d33 | |||
| 90699d7fe1 | |||
| 8d0d609494 | |||
| 8175f19da0 | |||
| 6c296dbaaf | |||
| 3313b95ab1 | |||
| 4dc5cb4dd9 | |||
| aeeea4c5d2 | |||
| 1414444175 | |||
| 5f67cf76bd | |||
| b6caf1c38b | |||
| 784cabaddc | |||
| 73afc3c435 | |||
| fb7e5031c8 | |||
| a350863a3e | |||
| f33e4a727a | |||
| 26bfcaa5f3 | |||
| 0ed394ea67 | |||
| 1e017b2453 | |||
| 3bed820d1d | |||
| 169338cfad | |||
| 1fa5d49572 | |||
| 9e00434eb6 | |||
| dd58e3ce55 | |||
| 466a7c8592 | |||
| 719f3bc35b | |||
| 08b08574b9 | |||
| 50ad1852ab | |||
| 4578e27d38 | |||
| 08a16a68c6 | |||
| 7a2cf6b8f8 | |||
| e768e0f9f0 | |||
| 0af2e0f896 | |||
| 77e7d81c4b | |||
| c4f967affa | |||
| 0c8afb05e3 | |||
| 3f61439a96 | |||
| 7f12066ec4 | |||
| 833cd9e305 | |||
| 686be3a855 | |||
| c0e22f5a6e | |||
| 32bda7da80 | |||
| 5f9b23c112 | |||
| dfd236a70b | |||
| 8153fdab79 | |||
| efafec56e0 | |||
| 0aee63988c | |||
| 7e12363976 | |||
| a3e58a01a1 | |||
| e3cca4c51c | |||
| fa29905c7c | |||
| 8b5444919a | |||
| 595d1d0932 | |||
| be35ecb62a | |||
| 69d10f88a0 | |||
| 09bc1df55c | |||
| 4e73b7a864 | |||
| 7a03955aac | |||
| e86cfd0881 | |||
| 27ebf5adc5 | |||
| 3578d3fb4a | |||
| e6c3e563a8 | |||
| dc40c24b73 | |||
| 96dbe0c108 | |||
| 02da9d975f | |||
| 8e3076e113 | |||
| 1cae216052 | |||
| 3fddc11b17 | |||
| ea20a1016e | |||
| 77e7ec603b | |||
| e696e2f551 | |||
| bbe25b027f | |||
| 3b22c583b7 | |||
| f2d0bbf36b | |||
| 97c2832e89 | |||
| 9d43162830 | |||
| 530df9ce09 | |||
| 9f2264514d | |||
| 57d7b49aa9 | |||
| 8e7434f67c | |||
| e15366d5f7 | |||
| 517d07caaf | |||
| 3a8f171f19 | |||
| 1c53fc4fe4 | |||
| 1c0511d44e | |||
| df759063f2 | |||
| 8a0359fd7e | |||
| ae9f9063a9 | |||
| d8e29af1dc | |||
| 69c037bf51 | |||
| be99c7acfa | |||
| c5062278a3 | |||
| 8888382067 | |||
| d687e47fdc | |||
| 5082885ace | |||
| 929e405aab | |||
| 31c1ecb612 | |||
| e38904422d | |||
| acd0c94e60 | |||
| f9262b3754 | |||
| 4fc0b21c9c | |||
| ffbed8c72d | |||
| f6b306c823 | |||
| ed3b96158f | |||
| f9d55161e6 | |||
| ecd6cf1c94 | |||
| 107d950771 | |||
| 4d153b4149 |
@@ -1,2 +1 @@
|
||||
# https://api.hifast.biz
|
||||
VITE_APP_BASE_URL=
|
||||
VITE_APP_BASE_URL=/
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
name: site-dist-deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
|
||||
env:
|
||||
VITE_APP_BASE_URL: /
|
||||
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
|
||||
# TG通知
|
||||
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
||||
TG_CHAT_ID: "-4940243803"
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: landing-hero-web01
|
||||
steps:
|
||||
- name: Manual checkout (no Node required)
|
||||
run: |
|
||||
set -e
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git fetch --all --tags
|
||||
git checkout "${{ github.ref_name }}"
|
||||
git reset --hard "origin/${{ github.ref_name }}"
|
||||
else
|
||||
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
|
||||
echo "Cloning $REPO_URL"
|
||||
git clone --depth=1 --branch "${{ github.ref_name }}" "$REPO_URL" .
|
||||
git fetch --tags
|
||||
fi
|
||||
|
||||
- name: Build dist with Unified Script
|
||||
env:
|
||||
VITE_APP_BASE_URL: "/"
|
||||
run: |
|
||||
chmod +x scripts/ci-build.sh
|
||||
./scripts/ci-build.sh
|
||||
|
||||
- name: Check Artifacts
|
||||
run: |
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Listing all files in workspace:"
|
||||
find . -maxdepth 2 -not -path '*/.*'
|
||||
if [ -f "site_dist.tgz" ]; then
|
||||
echo "✅ File exists: site_dist.tgz"
|
||||
ls -lh site_dist.tgz
|
||||
echo "File path: $(readlink -f site_dist.tgz)"
|
||||
else
|
||||
echo "❌ File NOT found: site_dist.tgz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy to Host (Native SSH/SCP)
|
||||
run: |
|
||||
echo "Installing SSH tools..."
|
||||
if command -v apk &> /dev/null; then
|
||||
echo "Detected Alpine Linux. Installing sshpass openssh-client via apk..."
|
||||
apk add --no-cache sshpass openssh-client
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
echo "Detected Debian/Ubuntu. Installing sshpass openssh-client via apt..."
|
||||
apt-get update -y && apt-get install -y sshpass openssh-client
|
||||
elif command -v yum &> /dev/null; then
|
||||
echo "Detected RHEL/CentOS. Installing sshpass openssh-clients via yum..."
|
||||
yum install -y sshpass openssh-clients
|
||||
elif command -v dnf &> /dev/null; then
|
||||
echo "Detected Fedora/RHEL8+. Installing sshpass openssh-clients via dnf..."
|
||||
dnf install -y sshpass openssh-clients
|
||||
elif command -v zypper &> /dev/null; then
|
||||
echo "Detected OpenSUSE. Installing sshpass openssh via zypper..."
|
||||
zypper install -y sshpass openssh
|
||||
else
|
||||
echo "Error: No known package manager found. Cannot install sshpass."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Uploading artifact..."
|
||||
# 使用 sshpass 传递密码 (更安全的方式是使用 key,但此处沿用 password)
|
||||
export SSHPASS="${{ env.SSH_PASSWORD }}"
|
||||
|
||||
# 1. 检查连接并创建目录 (包含 /tmp/ci-upload 和 目标目录的准备)
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no -p ${{ env.SSH_PORT }} ${{ env.SSH_USER }}@${{ env.SSH_HOST }} "mkdir -p /tmp/ci-upload"
|
||||
|
||||
# 2. SCP 上传 (直接使用当前目录下的 site_dist.tgz,规避跨容器挂载问题)
|
||||
if [ ! -f "site_dist.tgz" ]; then
|
||||
echo "❌ Error: site_dist.tgz not found in current directory!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sshpass -e scp -o StrictHostKeyChecking=no -P ${{ env.SSH_PORT }} site_dist.tgz ${{ env.SSH_USER }}@${{ env.SSH_HOST }}:/tmp/ci-upload/site_dist.tgz
|
||||
|
||||
# 3. 解压并重启 Nginx
|
||||
echo "Deploying on remote host..."
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no -p ${{ env.SSH_PORT }} ${{ env.SSH_USER }}@${{ env.SSH_HOST }} "
|
||||
echo 'Preparing target directory ${{ env.DEPLOY_PATH }}...'
|
||||
mkdir -p ${{ env.DEPLOY_PATH }}
|
||||
|
||||
# 切换到目录,确保操作安全
|
||||
cd ${{ env.DEPLOY_PATH }} || exit 1
|
||||
|
||||
echo 'Cleaning up old files (preserving download/downsload)...'
|
||||
|
||||
# 使用更安全的策略:
|
||||
# 1. 创建临时备份目录
|
||||
mkdir -p /tmp/site_backup_safe
|
||||
rm -rf /tmp/site_backup_safe/*
|
||||
|
||||
# 2. 将需要保留的文件夹移到备份目录 (如果存在)
|
||||
if [ -d 'download' ]; then
|
||||
echo 'Backing up download folder...'
|
||||
mv download /tmp/site_backup_safe/
|
||||
fi
|
||||
if [ -d 'downsload' ]; then
|
||||
echo 'Backing up downsload folder...'
|
||||
mv downsload /tmp/site_backup_safe/
|
||||
fi
|
||||
|
||||
# 3. 清空当前目录 (此时 download/downsload 已经移走,安全删除所有)
|
||||
# 注意:不删除当前目录本身,只删除内容
|
||||
find . -mindepth 1 -delete
|
||||
|
||||
# 4. 移回备份的文件夹
|
||||
if [ -d '/tmp/site_backup_safe/download' ]; then
|
||||
echo 'Restoring download folder...'
|
||||
mv /tmp/site_backup_safe/download .
|
||||
fi
|
||||
if [ -d '/tmp/site_backup_safe/downsload' ]; then
|
||||
echo 'Restoring downsload folder...'
|
||||
mv /tmp/site_backup_safe/downsload .
|
||||
fi
|
||||
|
||||
# 5. 清理备份目录
|
||||
rm -rf /tmp/site_backup_safe
|
||||
|
||||
echo 'Extracting to ${{ env.DEPLOY_PATH }}...'
|
||||
# 解压覆盖
|
||||
tar -xzf /tmp/ci-upload/site_dist.tgz -C ${{ env.DEPLOY_PATH }}
|
||||
|
||||
echo 'Reloading Nginx...'
|
||||
# 尝试多种 reload 方式
|
||||
nginx -s reload || systemctl reload nginx || echo 'Warning: Nginx reload returned non-zero'
|
||||
|
||||
echo 'Cleanup...'
|
||||
rm -f /tmp/ci-upload/site_dist.tgz
|
||||
"
|
||||
echo "✅ Deployment complete!"
|
||||
|
||||
|
||||
# 步骤6: TG通知 (成功)
|
||||
- name: 📱 发送成功通知到Telegram
|
||||
if: success()
|
||||
uses: appleboy/telegram-action@master
|
||||
with:
|
||||
token: ${{ env.TG_BOT_TOKEN }}
|
||||
to: ${{ env.TG_CHAT_ID }}
|
||||
message: |
|
||||
✅ 部署成功!
|
||||
|
||||
📦 项目: ${{ github.repository }}
|
||||
🌿 分支: ${{ github.ref_name }}
|
||||
📝 提交: ${{ github.sha }}
|
||||
👤 提交者: ${{ github.actor }}
|
||||
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
||||
|
||||
🚀 服务已成功部署到生产环境
|
||||
parse_mode: Markdown
|
||||
|
||||
# 步骤5: TG通知 (失败)
|
||||
- name: 📱 发送失败通知到Telegram
|
||||
if: failure()
|
||||
uses: appleboy/telegram-action@master
|
||||
with:
|
||||
token: ${{ env.TG_BOT_TOKEN }}
|
||||
to: ${{ env.TG_CHAT_ID }}
|
||||
message: |
|
||||
❌ 部署失败!
|
||||
|
||||
📦 项目: ${{ github.repository }}
|
||||
🌿 分支: ${{ github.ref_name }}
|
||||
📝 提交: ${{ github.sha }}
|
||||
👤 提交者: ${{ github.actor }}
|
||||
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
||||
|
||||
⚠️ 请检查构建日志获取详细信息
|
||||
parse_mode: Markdown
|
||||
|
||||
@@ -5,3 +5,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<html lang="zh-CN">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"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",
|
||||
@@ -40,6 +41,7 @@
|
||||
"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",
|
||||
@@ -49,5 +51,12 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,133 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ==========================================
|
||||
# 统一构建脚本 (CI Build Script)
|
||||
# 功能:自动检测环境、安装 Node.js (不依赖系统预装)、构建项目、打包产物
|
||||
# 解决:Runner 环境缺失 Node/Docker/Python 等工具导致的问题
|
||||
# ==========================================
|
||||
|
||||
# 配置节点版本
|
||||
NODE_VERSION="20.10.0"
|
||||
DIST_FILE="site_dist.tgz"
|
||||
|
||||
echo ">>> [Init] Starting CI Build Script..."
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 1. 基础工具检测与安装 (curl, tar, xz)
|
||||
# ----------------------------------------------------------------
|
||||
ensure_tools() {
|
||||
echo ">>> [Tools] Checking basic tools..."
|
||||
local missing_tools=()
|
||||
|
||||
for tool in curl tar xz; do
|
||||
if ! command -v "$tool" &> /dev/null; then
|
||||
missing_tools+=("$tool")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_tools[@]} -eq 0 ]; then
|
||||
echo " All tools present."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " Missing tools: ${missing_tools[*]}. Attempting installation..."
|
||||
|
||||
if command -v apk &> /dev/null; then
|
||||
apk add --no-cache curl tar xz
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
apt-get update && apt-get install -y curl tar xz-utils
|
||||
elif command -v yum &> /dev/null; then
|
||||
yum install -y curl tar xz
|
||||
elif command -v dnf &> /dev/null; then
|
||||
dnf install -y curl tar xz
|
||||
elif command -v zypper &> /dev/null; then
|
||||
zypper install -y curl tar xz
|
||||
else
|
||||
echo "!!! Error: Cannot install missing tools. No known package manager found."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 2. 环境安装 (System Node.js - Most Reliable on Alpine)
|
||||
# ----------------------------------------------------------------
|
||||
install_env() {
|
||||
echo ">>> [Env] Setting up System Node.js environment..."
|
||||
|
||||
# 尝试使用系统包管理器安装 Node.js 和 npm
|
||||
if command -v apk &> /dev/null; then
|
||||
echo " Detected Alpine Linux. Installing nodejs and npm via apk..."
|
||||
apk add --no-cache nodejs npm
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
echo " Detected Debian/Ubuntu. Installing nodejs and npm via apt..."
|
||||
apt-get update && apt-get install -y nodejs npm
|
||||
elif command -v yum &> /dev/null; then
|
||||
yum install -y nodejs npm
|
||||
elif command -v dnf &> /dev/null; then
|
||||
dnf install -y nodejs npm
|
||||
elif command -v zypper &> /dev/null; then
|
||||
zypper install -y nodejs npm
|
||||
else
|
||||
echo "!!! Warning: No package manager found. Checking if Node is pre-installed..."
|
||||
fi
|
||||
|
||||
# 验证安装
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "!!! Error: Node.js not found and could not be installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v npm &> /dev/null; then
|
||||
echo "!!! Error: npm not found and could not be installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Node version: $(node -v)"
|
||||
echo " npm version: $(npm -v)"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 3. 项目构建与打包
|
||||
# ----------------------------------------------------------------
|
||||
build_project() {
|
||||
echo ">>> [Build] Starting project build..."
|
||||
|
||||
# 注入环境变量
|
||||
if [ -n "$VITE_APP_BASE_URL" ]; then
|
||||
echo " Setting VITE_APP_BASE_URL=${VITE_APP_BASE_URL}"
|
||||
# 兼容 package.json 中的 mode: pord (Typo in original project)
|
||||
echo "VITE_APP_BASE_URL=${VITE_APP_BASE_URL}" > .env.pord
|
||||
# 同时写入 .env 以防万一
|
||||
echo "VITE_APP_BASE_URL=${VITE_APP_BASE_URL}" >> .env
|
||||
fi
|
||||
|
||||
echo " Installing dependencies..."
|
||||
# 使用 npm install 而不是 npm ci,以避免因 lockfile 版本不匹配或 engines 检查导致的失败
|
||||
npm install --no-audit --progress=false
|
||||
|
||||
echo " Building..."
|
||||
# 使用 package.json 中定义的 build:prod 命令
|
||||
npm run build:prod
|
||||
|
||||
echo ">>> [Package] Compressing artifacts..."
|
||||
if [ ! -d "dist" ]; then
|
||||
echo "!!! Error: 'dist' directory not found after build."
|
||||
# 列出当前目录以便调试
|
||||
ls -la
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 直接打包 dist 目录下的内容
|
||||
tar -C dist -czf "$DIST_FILE" .
|
||||
|
||||
echo " Success! Artifact created: $DIST_FILE"
|
||||
ls -lh "$DIST_FILE"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Main Execution Flow
|
||||
# ==========================================
|
||||
ensure_tools
|
||||
install_env
|
||||
build_project
|
||||
@@ -1,9 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import 'vue-sonner/style.css'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<main>
|
||||
@@ -13,13 +7,24 @@ import { Toaster } from '@/components/ui/sonner'
|
||||
position="top-center"
|
||||
:toast-options="{
|
||||
style: {
|
||||
background: '#000000',
|
||||
color: '#ffffff',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
background: '#ddd',
|
||||
color: '#000',
|
||||
border: '1px solid rgba(255, 255, 255)',
|
||||
},
|
||||
classes: {
|
||||
title: 'text-[20px] font-bold',
|
||||
toast: 'rounded-[20px]', // 顺便统一一下你 Dialog 的圆角风格
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import { onMounted } from 'vue'
|
||||
import 'vue-sonner/style.css'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
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 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 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",
|
||||
{
|
||||
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',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="device-list-container">
|
||||
<div v-if="!devices?.length" class="w-full text-center">暂无绑定设备</div>
|
||||
<div class="grid min-h-[76px] grid-cols-2 gap-3">
|
||||
<div v-else class="grid min-h-[76px] grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
@@ -22,6 +23,7 @@
|
||||
<X :size="16" class="absolute top-[5px] right-[10px] text-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -54,43 +54,50 @@ 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:
|
||||
case 1: // kr_statusPending
|
||||
return {
|
||||
title: '待支付',
|
||||
description: `订单正在处理中\n剩余时间: ${countdown.value}`,
|
||||
}
|
||||
case 2:
|
||||
case 2: // kr_statusPaid
|
||||
return {
|
||||
title: '支付确认中',
|
||||
description: '订单已支付,系统正在确认,请稍候...',
|
||||
}
|
||||
case 3:
|
||||
return {
|
||||
title: '支付成功',
|
||||
description: '您的订单已支付完成',
|
||||
}
|
||||
case 4:
|
||||
case 3: // kr_statusClose
|
||||
return {
|
||||
title: '订单已关闭',
|
||||
description: '该订单已超时或被手动关闭',
|
||||
}
|
||||
case 5:
|
||||
case 4: // kr_statusFailed
|
||||
return {
|
||||
title: '支付失败',
|
||||
description: '订单支付失败,请检查支付信息并重试',
|
||||
}
|
||||
case 5: // kr_statusFinished
|
||||
return {
|
||||
title: '支付成功',
|
||||
description: '您的订单已支付完成,感谢您的支持',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: '待支付',
|
||||
description: `订单正在处理中\n剩余时间: ${countdown.value}`,
|
||||
title: '订单处理中',
|
||||
description: '请稍候...',
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -116,15 +123,16 @@ async function checkStatus() {
|
||||
|
||||
status.value = res.status
|
||||
|
||||
// 2. 根据状态处理业务逻辑
|
||||
if (status.value === 3) {
|
||||
// Finished
|
||||
stopPolling()
|
||||
emit('refresh')
|
||||
} else if (status.value === 4 || status.value === 5) {
|
||||
// Closed or Failed
|
||||
// --- 核心逻辑修改点 ---
|
||||
if (status.value === 5) {
|
||||
// 5: 已完成 (kr_statusFinished)
|
||||
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>
|
||||
<div class="bg-[#A8FF53] px-6 font-sans text-black">
|
||||
<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">
|
||||
<h2 class="mb-1 text-center text-2xl font-bold">选择付款方式</h2>
|
||||
|
||||
<div class="flex flex-col gap-4 pt-[43px]">
|
||||
@@ -39,10 +39,12 @@
|
||||
</div>
|
||||
<div class="px-6 pt-[85px] pb-[23px]">
|
||||
<Button
|
||||
:disabled="isPaying"
|
||||
@click="$emit('pay', selectedValue)"
|
||||
class="h-[50px] w-full rounded-[32px] border-none bg-black text-[16px] font-black text-white transition-all hover:bg-black/90 active:scale-[0.98]"
|
||||
class="h-[50px] w-full rounded-[32px] border-none bg-black text-[16px] font-black text-white transition-all hover:bg-black/90 active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
立即支付
|
||||
<Loader2 v-if="isPaying" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ isPaying ? '正在支付...' : '立即支付' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,6 +53,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
|
||||
interface PaymentOption {
|
||||
label: string
|
||||
@@ -60,6 +63,7 @@ interface PaymentOption {
|
||||
const props = defineProps<{
|
||||
methods: any[]
|
||||
selectedPlan: any
|
||||
isPaying?: boolean
|
||||
}>()
|
||||
|
||||
const list = computed(() =>
|
||||
|
||||
@@ -1,40 +1,165 @@
|
||||
<template>
|
||||
<div class="px-6 font-sans">
|
||||
<h2 class="mb-1 text-center text-2xl font-bold">套餐选择</h2>
|
||||
<p class="mb-4 text-center text-sm font-[100] text-gray-600">*所有套餐均不限流量不限速度</p>
|
||||
<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">
|
||||
<!-- Activity Price Card -->
|
||||
<div
|
||||
v-for="(plan, index) in plans"
|
||||
:key="plan.id"
|
||||
v-if="hasActivityPrice(plan, index)"
|
||||
@click="$emit('select', activityPlan(plan, index).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-[25px] 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.5 w-full -translate-y-1/2 bg-black/60"></div>
|
||||
</div>
|
||||
<div class="text-sm text-black">约${{ plan.daily }}/天</div>
|
||||
</div>
|
||||
|
||||
<!-- Right overlay (Activity 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-1.5 pl-1 leading-[1.05]">
|
||||
<div class="text-[14px] font-bold">{{ activityTitle(plan, index) }}</div>
|
||||
<div class="mb-0.5 text-[10px]" v-if="activityCountdown(plan, index)">
|
||||
{{ activityCountdown(plan, index) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-full flex-1 items-center justify-center rounded-bl-[40px] border-4 border-black bg-[#ADFF5B] text-[32px] leading-none font-semibold"
|
||||
:class="
|
||||
isActivitySelected(plan, index)
|
||||
? 'bg-black text-white'
|
||||
: 'bg-[#ADFF5B] text-black'
|
||||
"
|
||||
>
|
||||
${{ activityPrice(plan, index) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Normal Card -->
|
||||
<div
|
||||
v-else
|
||||
@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="flex items-center gap-2 text-2xl font-bold">
|
||||
<Goods90Icon
|
||||
v-if="plan.days === 90 && !plan.promoPrice"
|
||||
: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="[
|
||||
index > 1 ? 'font-semibold' : '',
|
||||
currentPlanIndex === index ? 'bg-[#A8FF53]! text-black' : ' ',
|
||||
index > 1 ? 'top-[15px] -right-[30px] h-[35px]' : 'top-[20px] -right-[40px] h-[16px]',
|
||||
]"
|
||||
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]"
|
||||
class="absolute h-[16px] w-[126px] origin-center rotate-45 bg-black text-center text-[14px] leading-[16px] font-[200] font-semibold text-[#ADFF5B]"
|
||||
>
|
||||
{{ plan.discount }}
|
||||
<span
|
||||
v-html="
|
||||
plan.discount + (index === 2 ? '<br>年销百万' : index === 3 ? '<br>最划算' : '')
|
||||
"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import Goods90Icon from './goods-90-icon.svg'
|
||||
const props = defineProps({
|
||||
plans: Array,
|
||||
currentPlanIndex: Number,
|
||||
trialPlan: Object,
|
||||
trialCountdown: String,
|
||||
selectedPlanId: [String, Number],
|
||||
})
|
||||
defineEmits(['select'])
|
||||
|
||||
const now = ref(Date.now())
|
||||
const countdownStart = Date.now()
|
||||
let timer = null
|
||||
|
||||
const isTrialSelected = computed(() => {
|
||||
return props.trialPlan && props.selectedPlanId === props.trialPlan.id
|
||||
})
|
||||
|
||||
const hasActivityPrice = (plan, index) => {
|
||||
return Boolean(plan?.promoPrice || (index === 0 && props.trialPlan))
|
||||
}
|
||||
|
||||
const activityPlan = (plan, index) => {
|
||||
if (plan?.promoPrice) return plan
|
||||
if (index === 0 && props.trialPlan) return props.trialPlan
|
||||
return plan
|
||||
}
|
||||
|
||||
const activityTitle = (plan, index) => {
|
||||
const promoPlan = activityPlan(plan, index)
|
||||
return promoPlan?.promoTitle || promoPlan?.discount || '新客尝鲜价'
|
||||
}
|
||||
|
||||
const activityPrice = (plan, index) => {
|
||||
const promoPlan = activityPlan(plan, index)
|
||||
return promoPlan?.promoPrice || promoPlan?.price
|
||||
}
|
||||
|
||||
const activityCountdown = (plan, index) => {
|
||||
const promoPlan = activityPlan(plan, index)
|
||||
if (promoPlan?.promoPrice) {
|
||||
return formatPromoCountdown(promoPlan.promoRemainingSeconds)
|
||||
}
|
||||
return props.trialCountdown
|
||||
}
|
||||
|
||||
const isActivitySelected = (plan, index) => {
|
||||
const promoPlan = activityPlan(plan, index)
|
||||
return props.selectedPlanId === promoPlan?.id || (index === 0 && isTrialSelected.value)
|
||||
}
|
||||
|
||||
const formatPromoCountdown = (remainingSeconds) => {
|
||||
if (remainingSeconds === null || remainingSeconds === undefined) return ''
|
||||
|
||||
const elapsedSeconds = Math.floor((now.value - countdownStart) / 1000)
|
||||
const totalSeconds = Math.max(0, Math.floor(remainingSeconds) - elapsedSeconds)
|
||||
if (totalSeconds <= 0) return ''
|
||||
|
||||
const days = Math.floor(totalSeconds / 86400)
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
if (days > 0) return `${days}d:${hours}h:${minutes}m后失效`
|
||||
return `${hours}h:${minutes}m:${seconds}s后失效`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="user-center-skeleton custom-pulse" :class="[layout === 'desktop' ? 'w-full' : '']">
|
||||
<!-- --- Header Part --- -->
|
||||
<template v-if="type === 'header'">
|
||||
<div v-if="layout === 'mobile'" class="mb-3 ml-[31px] flex h-[60px] items-center gap-3">
|
||||
<div class="size-[60px] rounded-full bg-[#A8FF53]/60"></div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="h-5 w-40 rounded bg-[#A8FF53]/60"></div>
|
||||
<div class="h-3 w-24 rounded bg-[#A8FF53]/40"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mb-3 flex flex-col items-center">
|
||||
<div class="size-[60px] rounded-full bg-[#A8FF53]/60"></div>
|
||||
<div class="mt-2 h-6 w-32 rounded bg-[#A8FF53]/60"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- --- Devices Part --- -->
|
||||
<template v-if="type === 'devices'">
|
||||
<div v-if="layout === 'mobile'" class="h-[76px] w-full rounded-2xl bg-[#A8FF53]/15"></div>
|
||||
<div v-else class="h-[160px] w-full rounded-2xl bg-[#A8FF53]/15"></div>
|
||||
</template>
|
||||
|
||||
<!-- --- Plans Part --- -->
|
||||
<template v-if="type === 'plans'">
|
||||
<div class="space-y-4">
|
||||
<div class="h-[120px] w-full rounded-2xl bg-black/15"></div>
|
||||
<div class="h-[120px] w-full rounded-2xl bg-black/15"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- --- Payments Part --- -->
|
||||
<template v-if="type === 'payments'">
|
||||
<div class="h-[300px] w-full rounded-2xl bg-black/15"></div>
|
||||
</template>
|
||||
|
||||
<!-- --- Full Layouts (Backward Compatibility) --- -->
|
||||
<template v-if="!type">
|
||||
<!-- Old Full Layout implementation if needed, but we'll use type-based now -->
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
type?: 'header' | 'devices' | 'plans' | 'payments'
|
||||
layout?: 'mobile' | 'desktop'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-pulse {
|
||||
animation: fast-pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes fast-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/index.css'
|
||||
import '@/utils/openinstall'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="font-sans text-white">
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-[10px]">
|
||||
<div
|
||||
v-for="item in downloadMethods"
|
||||
:key="item.id"
|
||||
@click="toggle(item.id)"
|
||||
class="group relative flex cursor-pointer flex-col rounded-[30px] border-[4px] border-white bg-black pt-4 pb-2 transition-all active:scale-[0.97] md:pb-6"
|
||||
class="group relative flex flex-col rounded-[30px] border-[4px] border-white bg-black pt-4 pb-2 transition-all md:pb-6"
|
||||
>
|
||||
<div class="px-4 md:px-[42px]">
|
||||
<div class="px-4 md:pl-[42px]">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-2 text-sm leading-tight font-bold md:text-4xl"
|
||||
class="flex flex-wrap items-center gap-x-2 text-sm leading-tight font-bold md:text-2xl"
|
||||
>
|
||||
<StartIcon v-if="item.isHot" class="size-[20px] md:size-[30px]" />
|
||||
<span>{{ item.title }}</span>
|
||||
@@ -23,53 +23,235 @@
|
||||
|
||||
<!-- 打开按钮 -->
|
||||
<div
|
||||
class="mt-2 flex items-center justify-end gap-1.5 self-end text-sm font-bold group-hover:opacity-100"
|
||||
class="flex items-center justify-end gap-1.5 self-end text-sm font-bold group-hover:opacity-100"
|
||||
>
|
||||
<div
|
||||
v-show="activeId !== item.id"
|
||||
class="mt-[16px] flex items-center gap-[10px] md:mt-[60px] md:text-xl"
|
||||
>
|
||||
<div v-show="activeId !== item.id" class="flex items-center gap-[10px] md:text-2xl">
|
||||
<span class="tracking-tight">点击展开详情</span>
|
||||
<ArrowIcon class="size-[12px] md:size-[22px]" />
|
||||
<ArrowIcon class="size-[12px] md:size-[18px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeId === item.id" class="px-2 md:pt-8">
|
||||
<div v-if="activeId === item.id" class="px-2 pt-4 md:pt-8">
|
||||
<div
|
||||
class="animate-in fade-in zoom-in-95 border-t-2 border-white pt-[10px] text-[15px] leading-relaxed text-gray-300 duration-200 md:pt-[20px]"
|
||||
class="animate-in fade-in zoom-in-95 border-t-2 border-white pt-[10px] text-[15px] leading-relaxed duration-200 md:pt-[20px]"
|
||||
>
|
||||
<!-- 收起按钮 -->
|
||||
<div class="px-2">
|
||||
<!-- 内容区域 (点击图片不收起,只有特定的关闭按钮收起) -->
|
||||
<div class="relative px-2" @click.stop>
|
||||
<div
|
||||
class="flex items-center justify-end gap-1.5 self-end text-sm font-bold group-hover:opacity-100 md:text-2xl"
|
||||
class="flex items-center justify-end gap-1.5 self-end text-sm font-bold group-hover:opacity-100 md:text-xl"
|
||||
>
|
||||
<div class="flex items-center gap-[10px]">
|
||||
<div class="relative z-10 flex items-center gap-[10px]" @click="toggle(item.id)">
|
||||
<span class="tracking-tight">点击收起</span>
|
||||
<ArrowIcon class="size-[12px] rotate-180 md:size-[22px]" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="whitespace-pre-line">{{ item.details }}</p>
|
||||
|
||||
<!-- Mobile Image Stack -->
|
||||
<div class="relative -mt-10 flex min-h-[800px] flex-col md:hidden">
|
||||
<img
|
||||
v-for="(img, idx) in item.mobileImages"
|
||||
:key="idx"
|
||||
:src="img"
|
||||
class="w-full"
|
||||
alt="mobile guide"
|
||||
/>
|
||||
<!-- Mobile Hot Zones -->
|
||||
<div
|
||||
v-for="(hz, hzIdx) in item.mobileHotzones"
|
||||
:key="hzIdx"
|
||||
class="absolute z-20 cursor-pointer"
|
||||
:style="{
|
||||
left: hz.x + 'px',
|
||||
top: hz.y + 'px',
|
||||
width: hz.w + 'px',
|
||||
height: hz.h + 'px',
|
||||
}"
|
||||
@click="handleHotzone(hz)"
|
||||
>
|
||||
<span class="text-xs font-bold text-black" v-if="hz.type === 'text'">{{
|
||||
hz.payload
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PC Image Stack -->
|
||||
<div class="relative mt-4 hidden min-h-[800px] flex-col md:flex">
|
||||
<img
|
||||
v-for="(img, idx) in item.pcImages"
|
||||
:key="idx"
|
||||
:src="img"
|
||||
class="w-full"
|
||||
alt="pc guide"
|
||||
/>
|
||||
<!-- PC Hot Zones -->
|
||||
<div
|
||||
v-for="(hz, hzIdx) in item.pcHotzones"
|
||||
:key="hzIdx"
|
||||
class="absolute z-20 cursor-pointer"
|
||||
:style="{
|
||||
left: hz.x + 'px',
|
||||
top: hz.y + 'px',
|
||||
width: hz.w + 'px',
|
||||
height: hz.h + 'px',
|
||||
}"
|
||||
@click="handleHotzone(hz)"
|
||||
>
|
||||
<span class="text-xs font-bold text-black" v-if="hz.type === 'text'">{{
|
||||
hz.payload
|
||||
}}</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- iframe 弹窗 -->
|
||||
<div
|
||||
v-if="iframeVisible"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 px-4"
|
||||
>
|
||||
<div class="relative w-full max-w-[400px] rounded-2xl bg-white p-4">
|
||||
<div class="mb-2 flex justify-end">
|
||||
<button
|
||||
class="rounded-lg bg-black px-4 py-1 text-sm font-bold text-white"
|
||||
@click="iframeVisible = false"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="400"
|
||||
:src="accountShareUrl"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
class="rounded-lg"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import ArrowIcon from './arrow-icon.svg?component'
|
||||
import StartIcon from './Star-1.svg?component'
|
||||
|
||||
// 直接在内部定义数据
|
||||
const downloadMethods = ref([
|
||||
// Sliced WebP Images
|
||||
// Method 1
|
||||
import m1_1 from './mobile1/row-1-column-1.webp'
|
||||
import m1_2 from './mobile1/row-2-column-1.webp'
|
||||
import m1_3 from './mobile1/row-3-column-1.webp'
|
||||
import pc1_1 from './pc1/row-1-column-1.webp'
|
||||
import pc1_2 from './pc1/row-2-column-1.webp'
|
||||
import pc1_3 from './pc1/row-3-column-1.webp'
|
||||
|
||||
// Method 2
|
||||
import m2_1 from './mobile2/row-1-column-1.webp'
|
||||
import m2_2 from './mobile2/row-2-column-1.webp'
|
||||
import m2_3 from './mobile2/row-3-column-1.webp'
|
||||
import pc2_1 from './pc2/row-1-column-1.webp'
|
||||
import pc2_2 from './pc2/row-2-column-1.webp'
|
||||
import pc2_3 from './pc2/row-3-column-1.webp'
|
||||
|
||||
// Method 3
|
||||
import m3_1 from './mobile3/row-1-column-1.webp'
|
||||
import m3_2 from './mobile3/row-2-column-1.webp'
|
||||
import m3_3 from './mobile3/row-3-column-1.webp'
|
||||
import pc3_1 from './pc3/row-1-column-1.webp'
|
||||
import pc3_2 from './pc3/row-2-column-1.webp'
|
||||
import pc3_3 from './pc3/row-3-column-1.webp'
|
||||
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
|
||||
const accountShareUrl = 'https://aunlock.laomaos.com/share/jibsPdEN'
|
||||
|
||||
interface Hotzone {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
type: 'copy' | 'link' | 'text'
|
||||
payload: string
|
||||
label?: string
|
||||
}
|
||||
const accounts = [
|
||||
{ account: '点击获取邮箱', password: '****' },
|
||||
// { 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: '',
|
||||
details:
|
||||
'这是最稳定的方式。步骤:1. 访问 Apple 官网... 2. 注册地区选择香港... 3. 无需绑定卡片即可下载。',
|
||||
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,
|
||||
@@ -77,7 +259,44 @@ const downloadMethods = ref([
|
||||
subtitle: '',
|
||||
isHot: false,
|
||||
highlight: '',
|
||||
details: '1. 打开 App Store 个人中心... 2. 点击国家/地区... 3. 更改为香港并填写虚拟地址。',
|
||||
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,
|
||||
@@ -85,14 +304,134 @@ const downloadMethods = ref([
|
||||
subtitle: '',
|
||||
isHot: false,
|
||||
highlight: '谨慎选择',
|
||||
details: '注意:千万不要在设置(Settings)中登录公用账号!仅在 App Store 中登录,否则可能锁机。',
|
||||
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: '账号',
|
||||
},
|
||||
{
|
||||
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: '账号',
|
||||
},
|
||||
{
|
||||
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 activeId = ref<number | null>(null)
|
||||
const iframeVisible = ref(false)
|
||||
|
||||
/**
|
||||
* 切换下载方式详情面板显示状态。
|
||||
* @param {number} id 当前点击的下载方式 ID。
|
||||
* @returns {void} 无返回值。
|
||||
*/
|
||||
const toggle = (id: number) => {
|
||||
activeId.value = activeId.value === id ? null : id
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理热区点击行为。
|
||||
* @param {Hotzone} hz 当前点击的热区配置。
|
||||
* @returns {void} 无返回值。
|
||||
*/
|
||||
const handleHotzone = (hz: Hotzone) => {
|
||||
if (hz.type === 'copy') {
|
||||
iframeVisible.value = true
|
||||
} else if (hz.type === 'link') {
|
||||
window.open(hz.payload, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 204 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg width="49" height="73" viewBox="0 0 49 73" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.175484 64L2.94748 55.06H5.51548L8.28748 64H6.03148L4.91548 59.536C4.79548 59.088 4.67548 58.612 4.55548 58.108C4.44348 57.604 4.32748 57.124 4.20748 56.668H4.15948C4.05548 57.132 3.94348 57.616 3.82348 58.12C3.70348 58.616 3.58748 59.088 3.47548 59.536L2.35948 64H0.175484ZM2.05948 61.948V60.292H6.37948V61.948H2.05948ZM9.05736 64V57.172H10.7974L10.9534 58.024H10.9894C11.2694 57.744 11.5854 57.504 11.9374 57.304C12.2974 57.104 12.7094 57.004 13.1734 57.004C13.9174 57.004 14.4574 57.252 14.7934 57.748C15.1294 58.244 15.2974 58.92 15.2974 59.776V64H13.1614V60.04C13.1614 59.568 13.0974 59.248 12.9694 59.08C12.8414 58.912 12.6414 58.828 12.3694 58.828C12.1294 58.828 11.9254 58.88 11.7574 58.984C11.5894 59.088 11.4014 59.24 11.1934 59.44V64H9.05736ZM19.4038 64.168C18.8358 64.168 18.3398 64.024 17.9158 63.736C17.4998 63.448 17.1758 63.036 16.9438 62.5C16.7198 61.964 16.6078 61.324 16.6078 60.58C16.6078 59.836 16.7438 59.2 17.0158 58.672C17.2878 58.136 17.6398 57.724 18.0718 57.436C18.5038 57.148 18.9558 57.004 19.4278 57.004C19.8038 57.004 20.1158 57.068 20.3638 57.196C20.6118 57.324 20.8478 57.496 21.0718 57.712L20.9998 56.692V54.412H23.1358V64H21.3958L21.2398 63.34H21.1918C20.9518 63.58 20.6758 63.78 20.3638 63.94C20.0518 64.092 19.7318 64.168 19.4038 64.168ZM19.9678 62.416C20.1758 62.416 20.3598 62.376 20.5198 62.296C20.6878 62.216 20.8478 62.064 20.9998 61.84V59.152C20.8398 59 20.6678 58.896 20.4838 58.84C20.2998 58.776 20.1158 58.744 19.9318 58.744C19.7398 58.744 19.5558 58.808 19.3798 58.936C19.2118 59.064 19.0718 59.26 18.9598 59.524C18.8558 59.788 18.8038 60.132 18.8038 60.556C18.8038 60.988 18.8478 61.344 18.9358 61.624C19.0318 61.896 19.1678 62.096 19.3438 62.224C19.5198 62.352 19.7278 62.416 19.9678 62.416ZM24.8542 64V57.172H26.5942L26.7502 58.36H26.7862C27.0422 57.904 27.3422 57.564 27.6862 57.34C28.0382 57.116 28.3862 57.004 28.7302 57.004C28.9382 57.004 29.1022 57.02 29.2222 57.052C29.3502 57.076 29.4622 57.108 29.5582 57.148L29.2102 58.984C29.0822 58.952 28.9622 58.928 28.8502 58.912C28.7382 58.888 28.6062 58.876 28.4542 58.876C28.2062 58.876 27.9422 58.968 27.6622 59.152C27.3902 59.336 27.1662 59.652 26.9902 60.1V64H24.8542ZM33.162 64.168C32.586 64.168 32.042 64.028 31.53 63.748C31.018 63.46 30.602 63.048 30.282 62.512C29.97 61.976 29.814 61.332 29.814 60.58C29.814 59.828 29.97 59.188 30.282 58.66C30.602 58.124 31.018 57.716 31.53 57.436C32.042 57.148 32.586 57.004 33.162 57.004C33.594 57.004 34.01 57.084 34.41 57.244C34.81 57.404 35.166 57.64 35.478 57.952C35.79 58.256 36.038 58.628 36.222 59.068C36.406 59.508 36.498 60.012 36.498 60.58C36.498 61.332 36.338 61.976 36.018 62.512C35.706 63.048 35.294 63.46 34.782 63.748C34.278 64.028 33.738 64.168 33.162 64.168ZM33.162 62.44C33.426 62.44 33.642 62.364 33.81 62.212C33.986 62.06 34.114 61.844 34.194 61.564C34.274 61.284 34.314 60.956 34.314 60.58C34.314 60.204 34.274 59.88 34.194 59.608C34.114 59.328 33.986 59.112 33.81 58.96C33.642 58.808 33.426 58.732 33.162 58.732C32.898 58.732 32.678 58.808 32.502 58.96C32.334 59.112 32.206 59.328 32.118 59.608C32.038 59.88 31.998 60.204 31.998 60.58C31.998 60.956 32.038 61.284 32.118 61.564C32.206 61.844 32.334 62.06 32.502 62.212C32.678 62.364 32.898 62.44 33.162 62.44ZM37.8386 64V57.172H39.9746V64H37.8386ZM38.9066 56.224C38.5466 56.224 38.2586 56.124 38.0426 55.924C37.8266 55.724 37.7186 55.456 37.7186 55.12C37.7186 54.792 37.8266 54.528 38.0426 54.328C38.2586 54.128 38.5466 54.028 38.9066 54.028C39.2586 54.028 39.5426 54.128 39.7586 54.328C39.9826 54.528 40.0946 54.792 40.0946 55.12C40.0946 55.456 39.9826 55.724 39.7586 55.924C39.5426 56.124 39.2586 56.224 38.9066 56.224ZM44.1421 64.168C43.5741 64.168 43.0781 64.024 42.6541 63.736C42.2381 63.448 41.9141 63.036 41.6821 62.5C41.4581 61.964 41.3461 61.324 41.3461 60.58C41.3461 59.836 41.4821 59.2 41.7541 58.672C42.0261 58.136 42.3781 57.724 42.8101 57.436C43.2421 57.148 43.6941 57.004 44.1661 57.004C44.5421 57.004 44.8541 57.068 45.1021 57.196C45.3501 57.324 45.5861 57.496 45.8101 57.712L45.7381 56.692V54.412H47.8741V64H46.1341L45.9781 63.34H45.9301C45.6901 63.58 45.4141 63.78 45.1021 63.94C44.7901 64.092 44.4701 64.168 44.1421 64.168ZM44.7061 62.416C44.9141 62.416 45.0981 62.376 45.2581 62.296C45.4261 62.216 45.5861 62.064 45.7381 61.84V59.152C45.5781 59 45.4061 58.896 45.2221 58.84C45.0381 58.776 44.8541 58.744 44.6701 58.744C44.4781 58.744 44.2941 58.808 44.1181 58.936C43.9501 59.064 43.8101 59.26 43.6981 59.524C43.5941 59.788 43.5421 60.132 43.5421 60.556C43.5421 60.988 43.5861 61.344 43.6741 61.624C43.7701 61.896 43.9061 62.096 44.0821 62.224C44.2581 62.352 44.4661 62.416 44.7061 62.416Z" fill="white"/>
|
||||
<g clip-path="url(#clip0_9522_1052)">
|
||||
<path d="M28.3624 37.0001C29.5995 37.0001 30.6784 35.9213 30.6784 34.6842V29.2754H32.2248C33.1527 29.2754 33.7712 28.6567 33.7712 27.729V12.2808H15.229V27.7291C15.229 28.657 15.8476 29.2755 16.7754 29.2755H18.3219V34.6843C18.3219 35.9214 19.4005 37.0003 20.6379 37.0003C21.8749 37.0003 22.9536 35.9214 22.9536 34.6843V29.2755H26.0466V34.6843C26.0464 35.9213 27.1253 37.0001 28.3624 37.0001Z" fill="white"/>
|
||||
<path d="M37.6333 27.7291C38.8705 27.7291 39.9491 26.6504 39.9491 25.4132V14.5957C39.9491 13.3652 38.8705 12.2808 37.6333 12.2808C36.3961 12.2808 35.3174 13.3652 35.3174 14.5957V25.4132C35.3174 26.6503 36.396 27.7291 37.6333 27.7291Z" fill="white"/>
|
||||
<path d="M11.3665 27.7291C12.6037 27.7291 13.6825 26.6504 13.6825 25.4132V14.5957C13.6825 13.3652 12.6039 12.2808 11.3665 12.2808C10.1294 12.2808 9.05078 13.3652 9.05078 14.5957V25.4132C9.05078 26.6503 10.1294 27.7291 11.3665 27.7291Z" fill="white"/>
|
||||
<path d="M31.9154 0.23335C31.6062 -0.0777832 31.1459 -0.0777832 30.8368 0.23335L28.7627 2.30053L28.667 2.39609C27.4371 1.78025 26.0583 1.4727 24.5227 1.46973C24.5152 1.46973 24.5078 1.46949 24.5003 1.46949H24.5C24.4922 1.46949 24.4851 1.46973 24.4773 1.46973C22.9419 1.4727 21.563 1.78025 20.3333 2.39609L20.2374 2.30053L18.1634 0.23335C17.854 -0.0777832 17.394 -0.0777832 17.0848 0.23335C16.7754 0.542754 16.7754 1.00192 17.0848 1.31108L19.0911 3.31776C18.4447 3.7494 17.8584 4.27758 17.3487 4.8817C16.1282 6.32835 15.3498 8.21107 15.2427 10.251C15.2417 10.2722 15.2396 10.2932 15.2386 10.3144C15.2321 10.4533 15.229 10.593 15.229 10.7331H33.7712C33.7712 10.593 33.7678 10.4533 33.7615 10.3144C33.7605 10.2932 33.7584 10.2722 33.7572 10.251C33.6504 8.21107 32.8717 6.32822 31.6513 4.88182C31.1417 4.27771 30.5553 3.74952 29.9088 3.31789L31.9154 1.3112C32.2248 1.00192 32.2248 0.542754 31.9154 0.23335ZM20.6349 8.03303C19.9951 8.03303 19.4765 7.51436 19.4765 6.87456C19.4765 6.23476 19.9951 5.71608 20.6349 5.71608C21.2747 5.71608 21.7934 6.23476 21.7934 6.87456C21.7934 7.51436 21.2747 8.03303 20.6349 8.03303ZM28.3652 8.03303C27.7254 8.03303 27.2068 7.51436 27.2068 6.87456C27.2068 6.23476 27.7254 5.71608 28.3652 5.71608C29.005 5.71608 29.5237 6.23476 29.5237 6.87456C29.5237 7.51436 29.005 8.03303 28.3652 8.03303Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9522_1052">
|
||||
<rect width="37" height="37" fill="white" transform="translate(6)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -1,80 +1,98 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-black text-white">
|
||||
<!-- Full Width Header -->
|
||||
<div class="h-[88px] md:h-[125px]">
|
||||
<div class="fixed top-[20px] z-50 w-full md:top-[45px]">
|
||||
<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-[68px] items-center justify-between rounded-full bg-[#ADFF5B] pr-[30px] pl-6 md:h-[80px]"
|
||||
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]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<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
|
||||
:src="MobileLogo"
|
||||
alt="Hi快VPN"
|
||||
class="block h-[28px] text-black md:h-[50px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xl font-[600] text-black md:text-3xl">Hi快iOS版本下载教程</div>
|
||||
<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>
|
||||
<div class="container md:px-[252px]">
|
||||
<div class="">
|
||||
<div class="w-full bg-black">
|
||||
<div class="m-auto w-[340px] pb-[10px] md:w-[760px]">
|
||||
<div
|
||||
class="pt-[34px] pb-[15px] text-center text-xl font-[900] md:pt-[110px] md:pb-[30px] md:text-3xl"
|
||||
class="pb-[15px] text-center text-xl font-[900] md:pt-[20px] 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]"
|
||||
class="w-1/2 rounded-full border-5 p-[6px] md:border-[10px] md:p-[10px]"
|
||||
:class="[activeIndex === 0 ? 'border-white' : 'border-black']"
|
||||
@click="activeIndex = 0"
|
||||
@click="changeActiveIndex(0)"
|
||||
>
|
||||
<Button
|
||||
class="w-full cursor-pointer rounded-full bg-[#ADFF5B] text-black hover:bg-[#ADFF5B]/90 md:h-[60px] md:text-2xl"
|
||||
class="h-[30px] w-full cursor-pointer rounded-full bg-[#ADFF5B] text-xs text-black hover:bg-[#ADFF5B]/90 md:h-[40px] md:text-xl"
|
||||
>
|
||||
我只有中国大陆Apple ID
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="w-1/2 rounded-full border-5 p-[6px]"
|
||||
class="w-1/2 rounded-full border-5 p-[6px] md:border-[10px] md:p-[10px]"
|
||||
:class="[activeIndex === 1 ? 'border-white' : 'border-black']"
|
||||
@click="activeIndex = 1"
|
||||
@click="changeActiveIndex(1)"
|
||||
>
|
||||
<Button
|
||||
class="w-full cursor-pointer rounded-full bg-[#ADFF5B] text-black hover:bg-[#ADFF5B]/90 md:h-[60px] md:text-2xl"
|
||||
class="h-[30px] w-full cursor-pointer rounded-full bg-[#ADFF5B] text-xs text-black hover:bg-[#ADFF5B]/90 md:h-[40px] md:text-xl"
|
||||
>
|
||||
我有海外Apple ID
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-auto w-[340px] md:w-[760px]">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<!-- tab1 -->
|
||||
<div v-show="activeIndex === 0">
|
||||
<div class="pt-[34px] pb-[15px] text-center text-xl font-[900] md:pt-[100px] md:text-3xl">
|
||||
<div v-if="activeIndex === 0" key="tab1">
|
||||
<div class="pt-[15px] pb-[15px] text-center text-xl font-[900] md:pb-[39px] md:text-2xl">
|
||||
Hi快提供三种下载方式
|
||||
</div>
|
||||
<DownloadMethodList />
|
||||
</div>
|
||||
<div v-show="activeIndex === 1">
|
||||
<!-- tab2 -->
|
||||
<div v-else-if="activeIndex === 1" key="tab2">
|
||||
<div
|
||||
class="mx-auto flex w-[237px] items-center justify-center pt-[34px] pb-[15px] text-center text-xl font-[900] md:w-full md:gap-[18px] md:pt-[100px] md:text-3xl"
|
||||
class="mx-auto flex w-[237px] items-center justify-center pt-[34px] text-center text-xl font-[900] md:w-full md:gap-[18px] md:text-2xl"
|
||||
>
|
||||
<ChatIcon class="h-[60px] w-[50px]" />登录海外 Apple ID 后再点击下方下载按钮
|
||||
</div>
|
||||
<div class="mx-auto h-[101px] w-[247px] md:h-[202px] md:w-[594px]">
|
||||
<div class="mx-auto h-[101px] w-[247px] md:h-[202px] md:w-[494px]">
|
||||
<div
|
||||
@click="handleDownload('ios')"
|
||||
class="relative block h-full w-full cursor-pointer transition-transform active:scale-95"
|
||||
>
|
||||
<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>
|
||||
<!-- tab2 -->
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="container md:px-[92px]">
|
||||
<div class="pt-[56px] pb-[15px] text-center text-xl font-[900] md:pb-[69px] md:text-3xl">
|
||||
<div class="pt-[56px] pb-[15px] text-center text-xl font-[900] md:text-2xl">
|
||||
常见问题与解答
|
||||
</div>
|
||||
<div class="px-[24px]">
|
||||
@@ -93,20 +111,149 @@
|
||||
</div>
|
||||
|
||||
<div class="px-[18px] pt-[34px] pb-[calc(50px+env(safe-area-inset-bottom))]">
|
||||
<div class="pb-[15px] text-center text-xl font-[900] md:text-xl">其他客户端下载</div>
|
||||
<div class="pb-[15px] text-center text-xl font-[900] md:pb-[30px] md:text-3xl">
|
||||
其他客户端下载
|
||||
</div>
|
||||
<div class="flex justify-center gap-4">
|
||||
<template v-for="client in otherClients" :key="client.type">
|
||||
<div
|
||||
@click="handleDownload(client.type)"
|
||||
class="relative cursor-pointer transition-transform hover:brightness-110 active:scale-95"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, 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 } })
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After 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/10 bg-[#999999] p-8 shadow-2xl"
|
||||
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"
|
||||
@click.stop
|
||||
>
|
||||
<div class="space-y-2 text-black">
|
||||
@@ -20,6 +20,15 @@
|
||||
验证邮件已发送至邮箱,如无法找到,请检查垃圾邮件箱或营销邮件箱。
|
||||
</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>
|
||||
@@ -28,6 +37,7 @@
|
||||
|
||||
<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,31 +1,81 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-4 md:flex md:flex-wrap">
|
||||
<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]"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<component
|
||||
:is="mainButton.mainIcon"
|
||||
class="h-auto w-full text-black transition-transform"
|
||||
/>
|
||||
</router-link>
|
||||
<a
|
||||
v-for="(item, index) in downloadLinks"
|
||||
:key="index"
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
class="flex h-[40px] w-[140px] shrink-0 items-center space-x-2 rounded-full transition-transform active:scale-95 md:h-[50px] md:w-[180px]"
|
||||
style="
|
||||
backdrop-filter: blur(36px);
|
||||
box-shadow:
|
||||
0px 0px 33px 0px #f2f2f280 inset,
|
||||
-3px -4.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;
|
||||
"
|
||||
:aria-label="item.label"
|
||||
class="transition-transform hover:brightness-110 active:scale-95"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<component :is="item.icon" class="h-[40px] w-[140px] md:h-[50px] md:w-[180px]" />
|
||||
</div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -34,12 +84,174 @@ 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'
|
||||
|
||||
// 定义下载链接数据
|
||||
const downloadLinks = [
|
||||
{ icon: Icon1, link: 'https://apple.com/...' },
|
||||
{ icon: Icon2, link: 'https://down.hi.com/win' },
|
||||
{ icon: Icon3, link: 'https://down.hi.com/apk' },
|
||||
{ icon: Icon4, link: 'https://down.hi.com/mac' },
|
||||
]
|
||||
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)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -1,38 +1,74 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 text-base text-black md:text-2xl">
|
||||
<div class="overflow-hidden rounded-[20px] bg-[#78788029] px-4">
|
||||
<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="relative">
|
||||
<Input
|
||||
v-model="email"
|
||||
type="email"
|
||||
v-model.trim="email"
|
||||
type="text"
|
||||
name="user_email_identity"
|
||||
autocomplete="new-password"
|
||||
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="code"
|
||||
v-model.trim="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"
|
||||
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]"
|
||||
: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]"
|
||||
>
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取' }}
|
||||
<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>
|
||||
</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]"
|
||||
@@ -40,70 +76,178 @@
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
@click="handleLogin"
|
||||
class="h-[48px] flex-1 cursor-pointer rounded-[25px] bg-[#A8FF53] text-lg font-medium text-black hover:bg-[#96E64A]"
|
||||
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]"
|
||||
>
|
||||
登录/注册
|
||||
<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" />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, watch } 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(null)
|
||||
const CodeSentTipRef = ref<InstanceType<typeof CodeSentTip> | null>(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 = () => {
|
||||
const handleGetCode = async () => {
|
||||
if (!email.value) {
|
||||
toast('请输入邮箱')
|
||||
return
|
||||
}
|
||||
CodeSentTipRef.value.show()
|
||||
|
||||
request
|
||||
.get('/api/v1/auth/check', {
|
||||
if (!validateEmail(email.value)) {
|
||||
return toast.error('邮箱格式无效,请检查')
|
||||
}
|
||||
|
||||
isSendingCode.value = true // [开始加载]
|
||||
|
||||
try {
|
||||
// 1. 检查用户是否存在
|
||||
const { exist } = await request.get<{ exist: boolean }>('/api/v1/auth/check', {
|
||||
email: email.value,
|
||||
})
|
||||
.then(({ exist }) => {
|
||||
console.log(exist)
|
||||
request
|
||||
.post('/api/v1/common/send_code', {
|
||||
// 1=登录, 2=注册,
|
||||
|
||||
// 2. 发送验证码
|
||||
await request.post('/api/v1/common/send_code', {
|
||||
email: email.value,
|
||||
type: exist ? 2 : 1,
|
||||
type: exist ? 2 : 1, // 1=登录, 2=注册
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
// 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('/api/v1/auth/login/email', {
|
||||
.post<any, { token: string }>('/api/v1/auth/login/email', {
|
||||
email: email.value,
|
||||
code: code.value,
|
||||
})
|
||||
.then((res) => {
|
||||
localStorage.setItem('Authorization', res.token)
|
||||
localStorage.setItem('UserEmail', email.value)
|
||||
router.push({ path: '/user-center' })
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('登录失败', error)
|
||||
})
|
||||
.finally(() => {
|
||||
isLoggingIn.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,193 @@
|
||||
<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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 369 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -1,38 +1,30 @@
|
||||
<template>
|
||||
<div
|
||||
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.jpg')] md:pb-6"
|
||||
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"
|
||||
>
|
||||
<!-- Full Width Header -->
|
||||
<div class="h-[88px] md:h-[125px]">
|
||||
<div class="h-[60px] md:h-[125px]">
|
||||
<div class="fixed top-[20px] z-50 w-full md:top-[45px]">
|
||||
<div class="container">
|
||||
<header
|
||||
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,
|
||||
-3px -4.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;
|
||||
"
|
||||
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]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<router-link to="/" class="flex items-center gap-2">
|
||||
<!-- Desktop Logo -->
|
||||
<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" />
|
||||
<Logo alt="Hi快VPN" class="h-[18px] w-auto md:ml-8 md:h-[29px]" />
|
||||
</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"
|
||||
>
|
||||
{{ userLetter }}
|
||||
</router-link>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
@click="openLoginModal"
|
||||
class="flex h-[48px] items-center rounded-full bg-[#70C877] px-6 text-sm font-bold backdrop-blur-md transition hover:scale-[0.97] hover:bg-white/30 md:text-2xl"
|
||||
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"
|
||||
>
|
||||
登录 / 注册
|
||||
</button>
|
||||
@@ -43,14 +35,19 @@
|
||||
|
||||
<!-- Main Content Container -->
|
||||
<div class="container mx-auto flex flex-1 flex-col">
|
||||
<main class="pt-10 md:grid md:flex-1 md:grid-cols-2 md:pt-0">
|
||||
<main class="pt-10 md:flex md:flex-1 md:justify-between md:pt-0">
|
||||
<!-- Left Column: Text & Downloads -->
|
||||
<div class="md:flex md:w-[432px] md:flex-col md:justify-center">
|
||||
<div class="pb-4 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-[66px]" />
|
||||
<Logo class="h-[34px] md: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 -->
|
||||
@@ -58,45 +55,93 @@
|
||||
<img
|
||||
:src="ScreenshotMobile"
|
||||
alt="App Screenshot"
|
||||
class="h-auto w-full drop-shadow-2xl"
|
||||
class="h-auto min-h-[284px] w-full drop-shadow-2xl"
|
||||
/>
|
||||
<div class="absolute right-[8%] bottom-[97px] z-50 aspect-square w-[58%]">
|
||||
<!-- Ripple Animation (Background) -->
|
||||
<div class="ripple-container absolute inset-0 top-[7px]">
|
||||
<div class="ripple ripple-outer-3"></div>
|
||||
<div class="ripple ripple-outer-2"></div>
|
||||
<div class="ripple ripple-outer-1"></div>
|
||||
<div class="ripple ripple-core size-[80%]!"></div>
|
||||
</div>
|
||||
<!-- Center Image (Foreground/Top Layer) -->
|
||||
<div
|
||||
class="absolute inset-0 bottom-0 z-100 bg-[url('@/pages/Home/connected-mobile-bg.png')] bg-cover bg-center bg-no-repeat"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Buttons Grid -->
|
||||
<div
|
||||
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"
|
||||
class="mb-4 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-xs leading-5 font-[300] md:ml-[17px] md:text-left md:text-sm"
|
||||
class="mb-5 w-full text-center text-[10px] leading-5 font-[300] md:ml-[17px] md:text-left md:text-sm"
|
||||
>
|
||||
<p>最先进加密协议 - 安全有保障</p>
|
||||
<p>全真实IEPL专线 - IP纯净且稳定</p>
|
||||
<p>4K高清 / 不限流量 - 网速有多快, Hi快有多快</p>
|
||||
<p>极速闪连功能 - 连接永远快人一步</p>
|
||||
<p>最新加密协议-安全有保障</p>
|
||||
<p>IEPL专线-纯净、稳定</p>
|
||||
<p>不限流-网速多快,Hi快多快</p>
|
||||
<p>极速闪连-永远快人一步</p>
|
||||
<div class="flex justify-center md:justify-start">
|
||||
<a
|
||||
href="https://getsapp.net/xcom"
|
||||
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]"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<img src="./x-logo.png" alt="X.com" 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 />
|
||||
<a href="#" class="underline">Terms of Service</a>
|
||||
<a href="#" class="ml-2 underline">Privacy Policy</a>
|
||||
<router-link to="/terms" class="underline">Terms of Service</router-link>
|
||||
<router-link to="/privacy" class="ml-2 underline">Privacy Policy</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Phone Screenshot -->
|
||||
<div class="relative hidden w-full md:mt-0 md:block">
|
||||
<!-- Decorative elements behind phone -->
|
||||
<img
|
||||
:src="ScreenshotDesktop"
|
||||
alt="App Screenshot"
|
||||
class="absolute right-[52px] -bottom-[62px] w-[632px]"
|
||||
/>
|
||||
<div class="relative hidden w-full max-w-[582px] md:mt-0 md:block">
|
||||
<!-- Review Carousel -->
|
||||
<div class="absolute right-[2vw]">
|
||||
<ReviewCarousel />
|
||||
</div>
|
||||
<!-- Screenshot with Ripple Effect -->
|
||||
<div class="absolute right-[10%] 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>
|
||||
<div class="ripple ripple-outer-2"></div>
|
||||
<div class="ripple ripple-outer-1"></div>
|
||||
<div class="ripple ripple-core"></div>
|
||||
</div>
|
||||
<!-- Center Image (Foreground/Top Layer) -->
|
||||
<div
|
||||
class="absolute inset-0 bottom-[36px] z-100 bg-[url('@/pages/Home/connected-bg.png')] bg-cover bg-center bg-no-repeat"
|
||||
></div>
|
||||
</div>
|
||||
<div class="absolute right-[2vw] bottom-0 max-w-[582px]">
|
||||
<img :src="ScreenshotDesktop" alt="App Screenshot" class="relative z-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -106,25 +151,56 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import CompanyNameIcon from './company-name.svg?component'
|
||||
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.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()
|
||||
const loginModalRef = ref<InstanceType<typeof LoginFormModal> | null>(null)
|
||||
const token = useLocalStorage('Authorization', '')
|
||||
const userEmail = useLocalStorage('UserEmail', '')
|
||||
|
||||
const openLoginModal = () => {
|
||||
loginModalRef.value?.show()
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const userLetter = computed(() => {
|
||||
if (!userEmail.value) return '?'
|
||||
return userEmail.value.charAt(0).toUpperCase()
|
||||
})
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
if (!token.value) return
|
||||
|
||||
try {
|
||||
const res = (await request.get('/api/v1/public/user/info')) as any
|
||||
const emailInfo = res.auth_methods?.find((item: any) => item.auth_type === 'email')
|
||||
if (emailInfo) {
|
||||
userEmail.value = emailInfo.auth_identifier
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch user info:', error)
|
||||
if (error?.code === 401 || error?.status === 401) {
|
||||
token.value = ''
|
||||
userEmail.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const ic = getAllQueryString('ic')
|
||||
if (ic && typeof ic === 'string') {
|
||||
sessionStorage.setItem('ic', ic)
|
||||
}
|
||||
fetchUserInfo()
|
||||
if (route.query.login === 'true') {
|
||||
openLoginModal()
|
||||
router.replace({ query: { ...route.query, login: undefined } })
|
||||
@@ -140,4 +216,70 @@ watch(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const loginModalRef = ref<InstanceType<typeof LoginFormModal> | null>(null)
|
||||
|
||||
const openLoginModal = () => {
|
||||
loginModalRef.value?.show()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ripple-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.ripple {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: #a8ff53; /* Brand neon green */
|
||||
will-change: width, height, opacity;
|
||||
}
|
||||
|
||||
/* Core: Fixed minimum diameter (~57% of max) */
|
||||
.ripple-core {
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
opacity: 1;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* Outer Layer 1: Opacity 0.8, 5.4s */
|
||||
.ripple-outer-1 {
|
||||
animation: breathe 5.4s ease-in-out infinite alternate;
|
||||
opacity: 0.8;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Outer Layer 2: Opacity 0.6, 4.2s */
|
||||
.ripple-outer-2 {
|
||||
animation: breathe 4.2s ease-in-out infinite alternate;
|
||||
opacity: 0.6;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Outer Layer 3 (Outer-most): Opacity 0.4, 3s */
|
||||
.ripple-outer-3 {
|
||||
animation: breathe 3s ease-in-out infinite alternate;
|
||||
opacity: 0.4;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0% {
|
||||
width: 57%;
|
||||
height: 57%;
|
||||
}
|
||||
100% {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,32 @@
|
||||
<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>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-black text-white" style="font-family: 'Roboto', sans-serif">
|
||||
<!-- Main Content -->
|
||||
<main class="mx-auto max-w-[1000px] px-6 md:px-2 md:pt-16 md:pb-[50px]">
|
||||
<!-- Mobile Images -->
|
||||
<div class="md:hidden">
|
||||
<img
|
||||
src="./mobile/row-1-column-1.webp"
|
||||
alt="Terms of Service - Part 1"
|
||||
class="mb-0 w-full"
|
||||
/>
|
||||
<img
|
||||
src="./mobile/row-2-column-1.webp"
|
||||
alt="Terms of Service - Part 2"
|
||||
class="mb-0 w-full"
|
||||
/>
|
||||
<img
|
||||
src="./mobile/row-3-column-1.webp"
|
||||
alt="Terms of Service - Part 3"
|
||||
class="mb-0 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Images (2x) -->
|
||||
<div class="hidden md:block">
|
||||
<img
|
||||
src="./pc/row1.png"
|
||||
alt="Terms of Service - Part 1"
|
||||
class="mb-10 origin-left scale-50"
|
||||
/>
|
||||
<img
|
||||
src="./pc/row2.png"
|
||||
alt="Terms of Service - Part 2"
|
||||
class="mb-0 w-full"
|
||||
style="width: 100%; height: auto"
|
||||
/>
|
||||
<img
|
||||
src="./pc/row3.png"
|
||||
alt="Terms of Service - Part 3"
|
||||
class="m-auto mt-[60px] h-[28px] w-[594px]"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No additional logic needed for this static page
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* No additional styles needed */
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,288 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-black text-white" style="font-family: 'Roboto', sans-serif">
|
||||
<!-- Main Content -->
|
||||
<main class="mx-auto max-w-[1000px] px-6 md:px-2 md:pt-16 md:pb-[50px]">
|
||||
<!-- Mobile Images -->
|
||||
<div class="md:hidden">
|
||||
<img
|
||||
src="./mobile/row-1-column-1.webp"
|
||||
alt="Terms of Service - Part 1"
|
||||
class="mb-0 w-full"
|
||||
/>
|
||||
<img
|
||||
src="./mobile/row-2-column-1.webp"
|
||||
alt="Terms of Service - Part 2"
|
||||
class="mb-0 w-full"
|
||||
/>
|
||||
<img
|
||||
src="./mobile/row-3-column-1.webp"
|
||||
alt="Terms of Service - Part 3"
|
||||
class="mb-0 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Images (2x) -->
|
||||
<div class="hidden md:block">
|
||||
<img src="./pc/row1.png" alt="Terms of Service - Part 1" class="mb-10 h-[18px] w-[206px]" />
|
||||
<img
|
||||
src="./pc/row2.png"
|
||||
alt="Terms of Service - Part 2"
|
||||
class="mb-0 w-full"
|
||||
style="width: 100%; height: auto"
|
||||
/>
|
||||
<img
|
||||
src="./pc/row3.png"
|
||||
alt="Terms of Service - Part 3"
|
||||
class="m-auto mt-[60px] h-[70px] w-[693px]"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No additional logic needed for this static page
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* No additional styles needed */
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -1,28 +1,37 @@
|
||||
<template>
|
||||
<!-- Main Neon Green Card -->
|
||||
<div class="flex h-[678px]">
|
||||
<div class="flex h-[678px] justify-center">
|
||||
<div
|
||||
class="flex w-[345px] flex-col justify-between rounded-4xl border-5 border-white py-[32px] pb-[22px]"
|
||||
>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-col items-center">
|
||||
<div class="mb-3 flex min-h-[88px] flex-col items-center">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<UserCenterSkeleton v-if="isUserLoading" type="header" layout="desktop" />
|
||||
<div v-else class="flex flex-col items-center">
|
||||
<img src="../avatar.png" class="size-[60px]" alt="" />
|
||||
<div class="flex flex-col justify-center text-white">
|
||||
<div class="text-xl font-semibold">{{ userInfo.email }}</div>
|
||||
<div class="pt-3 text-xl font-semibold">{{ userInfo.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="mb-5 px-[20px] text-white">
|
||||
<DeviceList :devices="devices" @refresh="emit('refresh')" />
|
||||
<Transition name="fade" mode="out-in">
|
||||
<UserCenterSkeleton v-if="isUserLoading" type="devices" layout="desktop" />
|
||||
<DeviceList v-else :devices="devices" @refresh="emit('refresh')" />
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="px-6 pt-8 pb-9">
|
||||
<!-- <Button
|
||||
<Button
|
||||
variant="outline"
|
||||
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]"
|
||||
@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]"
|
||||
>
|
||||
注销账户
|
||||
</Button>-->
|
||||
订单详情
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -33,7 +42,10 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-xs" :class="{ 'text-[#FF00B7]': expireDateInfo.highlight }">
|
||||
<div
|
||||
class="text-center text-xs text-white tabular-nums"
|
||||
:class="{ 'text-[#FF00B7]': expireDateInfo.highlight }"
|
||||
>
|
||||
{{ expireDateInfo.text }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,17 +55,37 @@
|
||||
class="ml-2.5 flex items-center overflow-hidden rounded-4xl bg-[#A8FF53] pt-[32px] pb-[22px]"
|
||||
>
|
||||
<div class="h-full w-[345px]">
|
||||
<PlanCard :plans="plans" :currentPlanIndex="currentPlanIndex" @select="handlePlanSelect" />
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="isPlansLoading" class="p-8">
|
||||
<UserCenterSkeleton type="plans" layout="desktop" />
|
||||
</div>
|
||||
<PlanCard
|
||||
v-else
|
||||
:plans="plans"
|
||||
:trial-plan="trialPlan"
|
||||
:trial-countdown="trialCountdown"
|
||||
:currentPlanIndex="currentPlanIndex"
|
||||
:selectedPlanId="selectedPlanId"
|
||||
@select="handlePlanSelect"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
<div
|
||||
class="mx-[5px] h-[624px] w-[1px] bg-[url(@/pages/userCenter/DesktopLayout/Line-8.png)]"
|
||||
class="mx-[5px] h-[624px] w-[1px] bg-[url(@/pages/UserCenter/DesktopLayout/Line-8.png)]"
|
||||
></div>
|
||||
<div class="h-full w-[345px]">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="isPaymentsLoading" class="p-8">
|
||||
<UserCenterSkeleton type="payments" layout="desktop" />
|
||||
</div>
|
||||
<PaymentMethod
|
||||
v-else
|
||||
:methods="payments"
|
||||
:selectedPlan="selectedPlan"
|
||||
:is-paying="isPaying"
|
||||
@pay="(id: number | string) => $emit('pay', id)"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,7 +96,9 @@ import { computed } from 'vue'
|
||||
import PlanCard from '@/components/user-center/PlanCard.vue'
|
||||
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()
|
||||
@@ -76,9 +110,15 @@ const props = defineProps<{
|
||||
userInfo: { email: string }
|
||||
selectedPlanId: string
|
||||
selectedPlan: any
|
||||
isPaying: boolean
|
||||
isUserLoading: boolean
|
||||
isPlansLoading: boolean
|
||||
isPaymentsLoading: boolean
|
||||
trialPlan: any
|
||||
trialCountdown: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['select-plan', 'pay', 'refresh'])
|
||||
const emit = defineEmits(['select-plan', 'pay', 'refresh', 'show-order-details'])
|
||||
|
||||
// --- Handlers ---
|
||||
const handlePlanSelect = (id: string) => {
|
||||
@@ -90,38 +130,7 @@ const currentPlanIndex = computed(() => {
|
||||
|
||||
const expireDateInfo = computed(() => {
|
||||
const first = props.alreadySubscribed[0]
|
||||
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 }
|
||||
return formatExpireDate(first)
|
||||
})
|
||||
|
||||
function logout() {
|
||||
@@ -131,10 +140,17 @@ 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;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,39 +1,71 @@
|
||||
<template>
|
||||
<!-- Main Neon Green Card -->
|
||||
<div class="pt-[35px]">
|
||||
<div class="pt-[15px]">
|
||||
<div class="mb-3 ml-[31px] flex h-[60px] items-center gap-3">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<UserCenterSkeleton v-if="isUserLoading" type="header" layout="mobile" />
|
||||
<div v-else class="flex items-center gap-3 text-white">
|
||||
<img src="../avatar.png" class="size-[60px]" alt="" />
|
||||
<div class="flex h-full flex-col justify-center text-white">
|
||||
<div class="text-xl font-semibold">{{ userInfo.email }}</div>
|
||||
<div class="text-xs" :class="{ 'text-[#FF00B7]': expireDateInfo.highlight }">
|
||||
<div
|
||||
class="text-xs tabular-nums"
|
||||
:class="{ 'text-[#FF00B7]': expireDateInfo.highlight }"
|
||||
>
|
||||
{{ expireDateInfo.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 pr-[25px] pl-[21px] text-white">
|
||||
<DeviceList :devices="devices" @refresh="emit('refresh')" />
|
||||
<Transition name="fade" mode="out-in">
|
||||
<UserCenterSkeleton v-if="isUserLoading" type="devices" layout="mobile" />
|
||||
<DeviceList v-else :devices="devices" @refresh="emit('refresh')" />
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-4xl bg-[#A8FF53]">
|
||||
<div class="pt-7">
|
||||
<PlanCard :plans="plans" :currentPlanIndex="currentPlanIndex" @select="handlePlanSelect" />
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="isPlansLoading" class="px-6">
|
||||
<UserCenterSkeleton type="plans" layout="mobile" />
|
||||
</div>
|
||||
<PlanCard
|
||||
v-else
|
||||
:plans="plans"
|
||||
:trial-plan="trialPlan"
|
||||
:trial-countdown="trialCountdown"
|
||||
:currentPlanIndex="currentPlanIndex"
|
||||
:selectedPlanId="selectedPlanId"
|
||||
@select="handlePlanSelect"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="pt-7">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="isPaymentsLoading" class="px-6">
|
||||
<UserCenterSkeleton type="payments" layout="mobile" />
|
||||
</div>
|
||||
<PaymentMethod
|
||||
v-else
|
||||
:methods="payments"
|
||||
:selectedPlan="selectedPlan"
|
||||
:is-paying="isPaying"
|
||||
@pay="(id: number | string) => $emit('pay', id)"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pt-8 pb-[calc(50px+env(safe-area-inset-bottom))]">
|
||||
<!-- <Button
|
||||
<Button
|
||||
variant="outline"
|
||||
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]"
|
||||
@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]"
|
||||
>
|
||||
注销账户
|
||||
</Button>-->
|
||||
订单详情
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -51,7 +83,9 @@ import { computed } from 'vue'
|
||||
import PlanCard from '@/components/user-center/PlanCard.vue'
|
||||
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()
|
||||
@@ -63,9 +97,15 @@ const props = defineProps<{
|
||||
userInfo: { email: string }
|
||||
selectedPlanId: string
|
||||
selectedPlan: any
|
||||
isPaying: boolean
|
||||
isUserLoading: boolean
|
||||
isPlansLoading: boolean
|
||||
isPaymentsLoading: boolean
|
||||
trialPlan: any
|
||||
trialCountdown: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['select-plan', 'pay', 'refresh'])
|
||||
const emit = defineEmits(['select-plan', 'pay', 'refresh', 'show-order-details'])
|
||||
|
||||
// --- Handlers ---
|
||||
const handlePlanSelect = (id: string) => {
|
||||
@@ -77,38 +117,7 @@ const currentPlanIndex = computed(() => {
|
||||
|
||||
const expireDateInfo = computed(() => {
|
||||
const first = props.alreadySubscribed[0]
|
||||
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 }
|
||||
return formatExpireDate(first)
|
||||
})
|
||||
|
||||
function logout() {
|
||||
@@ -118,10 +127,17 @@ 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;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<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>
|
||||
@@ -0,0 +1,60 @@
|
||||
<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,27 +1,24 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col bg-black text-black">
|
||||
<!-- Full Width Header -->
|
||||
<div class="h-[88px] md:h-[125px]">
|
||||
<div class="fixed top-[20px] z-50 w-full md:top-[45px]">
|
||||
<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="container">
|
||||
<header
|
||||
class="flex h-[68px] items-center justify-between rounded-full bg-[#ADFF5B] pr-[30px] pl-6 md:h-[80px]"
|
||||
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]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<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
|
||||
:src="MobileLogo"
|
||||
alt="Hi快VPN"
|
||||
class="block h-[28px] text-black md:h-[50px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xl font-[600] text-black md:text-3xl">个人账户</div>
|
||||
<MobileLogo alt="Hi快VPN" class="block h-[22px] text-black md:h-[36px]" />
|
||||
</router-link>
|
||||
<div class="text-base font-[600] text-black md:text-2xl">个人账户</div>
|
||||
</header>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col">
|
||||
<!-- Main Neon Green Card -->
|
||||
<div class="container md:hidden">
|
||||
<MobileLayout
|
||||
@@ -32,12 +29,20 @@
|
||||
:user-info="userSubInfo"
|
||||
:selected-plan-id="selectedPlanId"
|
||||
:selected-plan="activePlan"
|
||||
:is-paying="isPaying"
|
||||
: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="container mx-auto hidden flex-1 items-center justify-center md:flex">
|
||||
<div class="hidden flex-1 items-center justify-center md:flex md:pb-[50px]">
|
||||
<div class="container mx-auto">
|
||||
<DesktopLayout
|
||||
:already-subscribed="alreadySubscribed"
|
||||
:devices="devices"
|
||||
@@ -46,31 +51,49 @@
|
||||
:user-info="userSubInfo"
|
||||
:selected-plan-id="selectedPlanId"
|
||||
:selected-plan="activePlan"
|
||||
:is-paying="isPaying"
|
||||
: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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
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()
|
||||
// --- State ---
|
||||
const selectedPlanId = ref('p2')
|
||||
const isPaying = ref(false)
|
||||
const isUserLoading = ref(true)
|
||||
const isPlansLoading = ref(true)
|
||||
const isPaymentsLoading = ref(true)
|
||||
const orderStatusDialogRef = ref<InstanceType<typeof OrderStatusDialog> | null>(null)
|
||||
const orderDetailsModalRef = ref<InstanceType<typeof OrderDetailsModal> | null>(null)
|
||||
const selectedPayment = ref('alipay')
|
||||
|
||||
const devices = ref<any[]>([])
|
||||
@@ -82,7 +105,80 @@ 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 > 4 &&
|
||||
!allMappedPlans.some((plan) => plan.promoPrice)
|
||||
) {
|
||||
// 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 no valid plan is selected
|
||||
const hasSelectedPlan =
|
||||
plans.value.some((p) => p.id === selectedPlanId.value) ||
|
||||
(trialPlan.value && trialPlan.value.id === selectedPlanId.value)
|
||||
if (plans.value.length > 0 && !hasSelectedPlan) {
|
||||
selectedPlanId.value = trialPlan.value?.id || 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
|
||||
@@ -104,6 +200,10 @@ const mapPlans = (apiList: any[]) => {
|
||||
const totalDays = opt.quantity * unitDays
|
||||
const ratioToPay = opt.discount === 0 || opt.discount === undefined ? 100 : opt.discount
|
||||
const totalPrice = (item.unit_price / 100) * opt.quantity * (ratioToPay / 100)
|
||||
const promo = opt.promo || {}
|
||||
const promoPriceCents = getNumber(promo.promo_price ?? opt.promo_price)
|
||||
const promoRemainingSeconds = getPromoRemainingSeconds(promo, opt)
|
||||
const hasPromoPrice = typeof promoPriceCents === 'number' && promoPriceCents > 0
|
||||
|
||||
flattened.push({
|
||||
id: `${item.id}-${opt.quantity}`,
|
||||
@@ -111,6 +211,10 @@ const mapPlans = (apiList: any[]) => {
|
||||
quantity: opt.quantity,
|
||||
days: totalDays,
|
||||
price: totalPrice.toFixed(2),
|
||||
promoPrice: hasPromoPrice ? (promoPriceCents / 100).toFixed(2) : '',
|
||||
promoPriceCents: hasPromoPrice ? promoPriceCents : null,
|
||||
promoTitle: hasPromoPrice ? promo.rule_name || opt.promo_title || '新客尝鲜价' : '',
|
||||
promoRemainingSeconds,
|
||||
daily: (totalPrice / totalDays).toFixed(2),
|
||||
discount:
|
||||
globalIndex > 0 && ratioToPay < 100 ? `${Math.ceil(100 - ratioToPay)}% off` : '',
|
||||
@@ -122,77 +226,184 @@ const mapPlans = (apiList: any[]) => {
|
||||
return flattened
|
||||
}
|
||||
|
||||
const getNumber = (value: any) => {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? numberValue : null
|
||||
}
|
||||
|
||||
const getPromoRemainingSeconds = (promo: any, opt: any) => {
|
||||
const directSeconds = getNumber(
|
||||
promo.remaining_seconds ??
|
||||
promo.remaining_time ??
|
||||
promo.remain_seconds ??
|
||||
promo.remain_time ??
|
||||
opt.remaining_seconds ??
|
||||
opt.remaining_time,
|
||||
)
|
||||
if (typeof directSeconds === 'number') return Math.max(0, Math.floor(directSeconds))
|
||||
|
||||
const expiresAt = getNumber(promo.expires_at ?? opt.expires_at)
|
||||
if (!expiresAt) return null
|
||||
|
||||
const expiresAtMs = expiresAt > 10000000000 ? expiresAt : expiresAt * 1000
|
||||
return Math.max(0, Math.floor((expiresAtMs - Date.now()) / 1000))
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
const activePlan = computed(() => {
|
||||
return plans.value.find((p) => p.id === selectedPlanId.value)
|
||||
if (trialPlan.value && selectedPlanId.value === trialPlan.value.id) {
|
||||
return getPayablePlan(trialPlan.value)
|
||||
}
|
||||
return getPayablePlan(plans.value.find((p) => p.id === selectedPlanId.value))
|
||||
})
|
||||
|
||||
const getPayablePlan = (plan: any) => {
|
||||
if (!plan) return plan
|
||||
if (!plan.promoPrice) return plan
|
||||
return {
|
||||
...plan,
|
||||
price: plan.promoPrice,
|
||||
}
|
||||
}
|
||||
|
||||
const handlePlanSelect = (id: string) => {
|
||||
selectedPlanId.value = id
|
||||
}
|
||||
|
||||
const handlePay = (methodId: number | string) => {
|
||||
const plan = plans.value.find((p) => p.id === selectedPlanId.value)
|
||||
if (!plan) return
|
||||
const handlePay = async (methodId: number | string) => {
|
||||
if (isPaying.value) return
|
||||
|
||||
const already = alreadySubscribed.value.find((s: any) => s.subscribe_id === plan.planId)
|
||||
const isRenewal = !!already
|
||||
// 1. 查找套餐并校验
|
||||
const plan = activePlan.value
|
||||
if (!plan) {
|
||||
toast.error('请选择有效的订阅套餐')
|
||||
return
|
||||
}
|
||||
|
||||
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 params: any = {
|
||||
|
||||
const orderParams = {
|
||||
subscribe_id: plan.planId,
|
||||
quantity: plan.quantity,
|
||||
payment: methodId,
|
||||
...(plan.promoPriceCents ? { promo_price: plan.promoPriceCents } : {}),
|
||||
...(isRenewal && { user_subscribe_id: existingSub.id }), // 仅续费时添加此字段
|
||||
}
|
||||
|
||||
if (isRenewal) {
|
||||
params.user_subscribe_id = already.id
|
||||
}
|
||||
console.log(params)
|
||||
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}`,
|
||||
// 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}`,
|
||||
})
|
||||
.then((checkoutRes: any) => {
|
||||
|
||||
// 5. 执行跳转
|
||||
if (checkoutRes.type === 'url' && checkoutRes.checkout_url) {
|
||||
localStorage.setItem('pending_order_no', res.order_no)
|
||||
console.log('pending_order_no', res.order_no)
|
||||
setTimeout(() => {
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function init() {
|
||||
// 用户信息 & 设备列表
|
||||
request.get('/api/v1/public/user/info').then((res: any) => {
|
||||
async function init() {
|
||||
// 1. 用户信息 & 设备列表
|
||||
isUserLoading.value = true
|
||||
request
|
||||
.get('/api/v1/public/user/info')
|
||||
.then((res: any) => {
|
||||
devices.value = res.user_devices
|
||||
const emailInfo = res.auth_methods?.find((item: any) => item.auth_type === 'email')
|
||||
if (emailInfo) {
|
||||
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
|
||||
})
|
||||
|
||||
// 已订阅列表
|
||||
// 2. 已订阅列表 (非阻塞主要展示)
|
||||
request.get('/api/v1/public/user/subscribe').then((res: any) => {
|
||||
alreadySubscribed.value = res.list || []
|
||||
})
|
||||
|
||||
// 订阅套餐列表
|
||||
request.get('/api/v1/public/subscribe/list').then((res: any) => {
|
||||
plans.value = mapPlans(res.list || [])
|
||||
if (plans.value.length > 0) {
|
||||
selectedPlanId.value = plans.value[0].id
|
||||
}
|
||||
// 3. 订阅套餐列表
|
||||
isPlansLoading.value = true
|
||||
request
|
||||
.get('/api/v1/public/subscribe/list')
|
||||
.then((res: any) => {
|
||||
rawPlansList.value = res.list || []
|
||||
updatePlans()
|
||||
})
|
||||
.finally(() => {
|
||||
isPlansLoading.value = false
|
||||
})
|
||||
|
||||
// 获取支付方式
|
||||
request.get('/api/v1/public/payment/methods').then((res: any) => {
|
||||
console.log(res)
|
||||
payments.value = res.list?.filter((p: any) => p.platform !== 'apple_iap') || []
|
||||
// 4. 获取支付方式
|
||||
isPaymentsLoading.value = true
|
||||
request
|
||||
.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',
|
||||
) || []
|
||||
})
|
||||
.finally(() => {
|
||||
isPaymentsLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,8 +411,16 @@ 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: any) => {
|
||||
if (data.status === 1 || data.status === 5 || data.status === 2) {
|
||||
orderStatusDialogRef.value?.show(orderNo)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
})
|
||||
|
||||
function handleStatusClose() {
|
||||
@@ -212,9 +431,18 @@ function handleStatusClose() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Simplified layout font */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.tracking-tight {
|
||||
font-family: 'Inter', 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 订阅对象的接口定义(根据你的后端数据结构调整)
|
||||
*/
|
||||
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,
|
||||
}
|
||||
}
|
||||