first commit
Some checks failed
site-dist-deploy / build-and-deploy (push) Has been cancelled

This commit is contained in:
speakeloudest 2026-01-29 04:16:00 -08:00
commit e233c87fe6
81 changed files with 11399 additions and 0 deletions

8
.editorconfig Normal file
View File

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
.env.dev Normal file
View File

@ -0,0 +1 @@
VITE_APP_BASE_URL=/

1
.env.pord Normal file
View File

@ -0,0 +1 @@
VITE_APP_BASE_URL=/

1
.env.test Normal file
View File

@ -0,0 +1 @@
VITE_APP_BASE_URL=/

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

195
.gitea/workflows/docker.yml Normal file
View File

@ -0,0 +1,195 @@
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-partner
# 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

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/

7
.prettierrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}

8
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"prettier.prettier-vscode"
]
}

29
README.md Normal file
View File

@ -0,0 +1,29 @@
# hi-partner
代理合伙人页面
### 启动环境
nvm use 24
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```
### Lint with [ESLint](https://eslint.org/)
```sh
pnpm lint
```

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/styles/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}

7
env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

38
eslint.config.ts Normal file
View File

@ -0,0 +1,38 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
rules: {
// 1. 强制 Vue 文件内标签块的顺序
'vue/block-order': [
'error',
{
order: ['template', 'script', 'style'],
},
],
// 2. 自动关闭单单词组件名检查(根据需要可选)
'vue/multi-word-component-names': 'off',
// 3. 可以在这里添加更多 TypeScript 或 Vue 的自定义校验
'@typescript-eslint/no-explicit-any': 'warn',
},
},
skipFormatting,
)

26
index.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<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>
<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" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4457
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "hi-landing-hreo",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite --mode dev",
"build:test": "vite build --mode test",
"build:prod": "vite build --mode pord",
"preview": "vite preview",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.1.0",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"lucide-vue-next": "^0.562.0",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue": "^3.5.26",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.3",
"@types/node": "^24.10.4",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.2",
"eslint-plugin-vue": "~10.6.2",
"jiti": "^2.6.1",
"npm-run-all2": "^8.0.4",
"prettier": "3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.3.0",
"vite-plugin-vue-devtools": "^8.0.5",
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^3.2.1"
}
}

3968
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

133
scripts/ci-build.sh Normal file
View File

@ -0,0 +1,133 @@
#!/bin/bash
set -e
# ==========================================
# 统一构建脚本 (CI Build Script)
# 功能:自动检测环境、安装 Node.js (不依赖系统预装)、构建项目、打包产物
# 解决Runner 环境缺失 Node/Docker/Python 等工具导致的问题
# ==========================================
# 配置节点版本
NODE_VERSION="20.10.0"
DIST_FILE="site_dist.tgz"
echo ">>> [Init] Starting CI Build Script..."
# ----------------------------------------------------------------
# 1. 基础工具检测与安装 (curl, tar, xz)
# ----------------------------------------------------------------
ensure_tools() {
echo ">>> [Tools] Checking basic tools..."
local missing_tools=()
for tool in curl tar xz; do
if ! command -v "$tool" &> /dev/null; then
missing_tools+=("$tool")
fi
done
if [ ${#missing_tools[@]} -eq 0 ]; then
echo " All tools present."
return 0
fi
echo " Missing tools: ${missing_tools[*]}. Attempting installation..."
if command -v apk &> /dev/null; then
apk add --no-cache curl tar xz
elif command -v apt-get &> /dev/null; then
apt-get update && apt-get install -y curl tar xz-utils
elif command -v yum &> /dev/null; then
yum install -y curl tar xz
elif command -v dnf &> /dev/null; then
dnf install -y curl tar xz
elif command -v zypper &> /dev/null; then
zypper install -y curl tar xz
else
echo "!!! Error: Cannot install missing tools. No known package manager found."
exit 1
fi
}
# ----------------------------------------------------------------
# 2. 环境安装 (System Node.js - Most Reliable on Alpine)
# ----------------------------------------------------------------
install_env() {
echo ">>> [Env] Setting up System Node.js environment..."
# 尝试使用系统包管理器安装 Node.js 和 npm
if command -v apk &> /dev/null; then
echo " Detected Alpine Linux. Installing nodejs and npm via apk..."
apk add --no-cache nodejs npm
elif command -v apt-get &> /dev/null; then
echo " Detected Debian/Ubuntu. Installing nodejs and npm via apt..."
apt-get update && apt-get install -y nodejs npm
elif command -v yum &> /dev/null; then
yum install -y nodejs npm
elif command -v dnf &> /dev/null; then
dnf install -y nodejs npm
elif command -v zypper &> /dev/null; then
zypper install -y nodejs npm
else
echo "!!! Warning: No package manager found. Checking if Node is pre-installed..."
fi
# 验证安装
if ! command -v node &> /dev/null; then
echo "!!! Error: Node.js not found and could not be installed."
exit 1
fi
if ! command -v npm &> /dev/null; then
echo "!!! Error: npm not found and could not be installed."
exit 1
fi
echo " Node version: $(node -v)"
echo " npm version: $(npm -v)"
}
# ----------------------------------------------------------------
# 3. 项目构建与打包
# ----------------------------------------------------------------
build_project() {
echo ">>> [Build] Starting project build..."
# 注入环境变量
if [ -n "$VITE_APP_BASE_URL" ]; then
echo " Setting VITE_APP_BASE_URL=${VITE_APP_BASE_URL}"
# 兼容 package.json 中的 mode: pord (Typo in original project)
echo "VITE_APP_BASE_URL=${VITE_APP_BASE_URL}" > .env.pord
# 同时写入 .env 以防万一
echo "VITE_APP_BASE_URL=${VITE_APP_BASE_URL}" >> .env
fi
echo " Installing dependencies..."
# 使用 npm install 而不是 npm ci以避免因 lockfile 版本不匹配或 engines 检查导致的失败
npm install --no-audit --progress=false
echo " Building..."
# 使用 package.json 中定义的 build:prod 命令
npm run build:prod
echo ">>> [Package] Compressing artifacts..."
if [ ! -d "dist" ]; then
echo "!!! Error: 'dist' directory not found after build."
# 列出当前目录以便调试
ls -la
exit 1
fi
# 直接打包 dist 目录下的内容
tar -C dist -czf "$DIST_FILE" .
echo " Success! Artifact created: $DIST_FILE"
ls -lh "$DIST_FILE"
}
# ==========================================
# Main Execution Flow
# ==========================================
ensure_tools
install_env
build_project

30
src/App.vue Normal file
View File

@ -0,0 +1,30 @@
<template>
<div>
<main>
<RouterView />
</main>
<Toaster
position="top-center"
:toast-options="{
style: {
background: '#ddd',
color: '#000',
border: '1px solid rgba(255, 255, 255)',
},
classes: {
title: 'text-[20px] font-bold',
toast: 'rounded-[20px]', // 便 Dialog
},
}"
/>
</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>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
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',
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',
},
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',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
>
<slot />
</DialogOverlay>
</template>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View File

@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'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',
props.class,
)"
>
</template>

View File

@ -0,0 +1 @@
export { default as Input } from "./Input.vue"

View File

@ -0,0 +1,42 @@
<script lang="ts" setup>
import type { ToasterProps } from "vue-sonner"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-vue-next"
import { Toaster as Sonner } from "vue-sonner"
import { cn } from "@/lib/utils"
const props = defineProps<ToasterProps>()
</script>
<template>
<Sonner
:class="cn('toaster group', props.class)"
:style="{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
}"
v-bind="props"
>
<template #success-icon>
<CircleCheckIcon class="size-4" />
</template>
<template #info-icon>
<InfoIcon class="size-4" />
</template>
<template #warning-icon>
<TriangleAlertIcon class="size-4" />
</template>
<template #error-icon>
<OctagonXIcon class="size-4" />
</template>
<template #loading-icon>
<div>
<Loader2Icon class="size-4 animate-spin" />
</div>
</template>
<template #close-icon>
<XIcon class="size-4" />
</template>
</Sonner>
</template>

View File

@ -0,0 +1 @@
export { default as Toaster } from "./Sonner.vue"

View File

@ -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>

7
src/lib/utils.ts Normal file
View File

@ -0,0 +1,7 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/main.ts Normal file
View File

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles/index.css'
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,72 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="visible"
class="!pointer-events-auto fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div
class="backdrop-blur-[2px z-[110]] absolute inset-0 bg-black/40"
@click.stop="hide"
></div>
<div
class="relative z-10 w-full max-w-[280px] rounded-[40px] border border-white/0 bg-[#DDDDDD] px-8 pt-8 pb-4 shadow-2xl"
@click.stop
>
<div class="space-y-2 text-black">
<h3 class="flex items-start text-lg font-black"><span class="mr-1">*</span>重要提示</h3>
<p class="text-[15px] leading-relaxed font-bold tracking-tight">
验证邮件已发送至邮箱如无法找到请检查垃圾邮件箱或营销邮件箱
</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>
</Teleport>
</template>
<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
const show = () => {
visible.value = true
}
const hide = () => {
console.log('hide')
visible.value = false
}
//
onBeforeUnmount(() => {
if (timer) clearTimeout(timer)
})
defineExpose({ show, hide })
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
</style>

View File

@ -0,0 +1,259 @@
<template>
<form @submit.prevent="handleLogin" class="flex flex-col gap-6 text-base text-black md:text-2xl">
<div class="rounded-[20px] bg-[#78788029] px-4">
<div class="relative">
<Input
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.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
@click="handleGetCode"
:disabled="countdown > 0 || isSendingCode"
class="relative h-10 min-w-[100px] cursor-pointer overflow-hidden rounded-full bg-[#4B94E6] px-[32px] text-base font-bold text-white hover:bg-[#4B94E6]/90 md:min-w-[130px] md:px-[45px] md:text-[18px]"
>
<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]"
>
取消
</Button>
<Button
type="submit"
:disabled="isLoggingIn"
class="relative h-[48px] flex-1 cursor-pointer overflow-hidden rounded-[25px] bg-[#A8FF53] text-lg font-medium text-black hover:bg-[#96E64A]"
>
<div class="flex items-center justify-center">
<Loader2 v-if="isLoggingIn" class="absolute left-4 h-5 w-5 animate-spin md:left-8" />
<span :class="{ 'ml-2': isLoggingIn }"> 登录/注册 </span>
</div>
</Button>
</div>
<CodeSentTip ref="CodeSentTipRef" />
</form>
</template>
<script setup lang="ts">
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<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 = async () => {
if (!email.value) {
toast('请输入邮箱')
return
}
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,
})
// 2.
await request.post('/api/v1/common/send_code', {
email: email.value,
type: exist ? 2 : 1, // 1=, 2=
})
// 3.
CodeSentTipRef.value?.show()
countdown.value = 60
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) clearInterval(timer)
}, 1000)
} catch (error: any) {
console.error('发送验证码失败:', error)
// request toast
} finally {
isSendingCode.value = false // []
}
}
const router = useRouter()
const isLoggingIn = ref(false)
const handleLogin = () => {
if (!code.value) {
toast('请输入验证码')
return
}
if (!validateEmail(email.value)) {
return toast.error('邮箱格式无效,请检查')
}
isLoggingIn.value = true
request
.post<any, { token: string }>('/api/v1/auth/login/email', {
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>
<style scoped>
/* 移除 Shadcn Input 的默认边框阴影,保持纯净感 */
input:focus {
outline: none;
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<Dialog :open="isOpen" @update:open="setOpen">
<DialogContent
@pointer-down-outside="(event) => event.preventDefault()"
@focus-outside="(event) => event.preventDefault()"
class="max-w-[300px] rounded-4xl bg-[#DDDDDD] p-[14px] md:max-w-[390px]"
:showCloseButton="false"
>
<DialogHeader class="py-2 pl-2">
<DialogTitle class="text-left text-base md:text-2xl">代理登录/注册</DialogTitle>
<DialogDescription class="text-left text-base text-[#999999]">
请输入您的邮箱并通过验证码登录如您第一次使用将自动为您创建Hi快高能合伙人帐号
</DialogDescription>
</DialogHeader>
<LoginForm @close="isOpen = false" />
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import LoginForm from './LoginForm.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>

BIN
src/pages/Home/image-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

185
src/pages/Home/index.vue Normal file
View File

@ -0,0 +1,185 @@
<template>
<div
class="relative min-h-screen overflow-hidden bg-black bg-[url('@/pages/Home/bg-mobile.webp')] bg-cover bg-center bg-no-repeat pb-[calc(1rem+env(safe-area-inset-bottom))] font-sans text-white md:flex md:flex-col md:bg-[url('@/pages/Home/bg-desktop.webp')] md:pb-0"
>
<!-- Full Width Header -->
<div class="h-[60px] md:h-[125px]">
<div class="fixed top-[20px] z-50 w-full md:top-[45px]">
<div class="container">
<header
class="lucid-glass-bar flex h-[40px] items-center justify-between rounded-[90px] pr-[5px] pl-5 transition-all duration-300 md:h-[60px] md:pr-[10px]"
>
<router-link to="/" class="flex items-center gap-2">
<!-- Desktop Logo -->
<Logo alt="Hi快VPN" class="h-[18px] w-auto md:ml-8 md:h-[29px]" />
<span class="font-black text-2xl ml-3">高能合伙人</span>
</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-[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>
</header>
</div>
</div>
</div>
<!-- Main Content Container -->
<div class="container mx-auto flex flex-col">
<main class="pt-10">
<!-- Left Column: Text & Downloads -->
<div class="md:flex md:w-[432px] md:flex-col md:justify-center">
<div class="mb-[20px] ml-[42px] md:ml-[17px]">
<h2 class="mb-2 text-2xl font-black md:text-8xl">
<Logo class="h-[34px] md:h-[66px]" />
</h2>
<p class="font-600 text-3xl md:text-[48px]">网在我在, 网快我快</p>
</div>
</div>
</main>
</div>
<div class="container justify-center flex flex-col items-center">
<div class="mb-1 h-[20px] w-[352px] md:ml-[17px]">
<img src="./image-1.png" alt="image" />
</div>
<div class="text-center text-[10px] leading-[14px] font-[300] md:ml-[17px] md:text-left">
<span class="font-[600]">Hi快VPN</span> &copy; All rights reserved.<br />
<router-link to="/terms" class="underline">Terms of Service</router-link>
<router-link to="/privacy" class="ml-2 underline">Privacy Policy</router-link>
</div>
</div>
<LoginFormModal ref="loginModalRef" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useLocalStorage } from '@vueuse/core'
import LoginFormModal from './components/LoginFormModal.vue'
import Logo from './logo.svg?component'
import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
const token = useLocalStorage('Authorization', '')
const userEmail = useLocalStorage('UserEmail', '')
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(() => {
fetchUserInfo()
if (route.query.login === 'true') {
openLoginModal()
router.replace({ query: { ...route.query, login: undefined } })
}
})
watch(
() => route.query.login,
(newVal) => {
if (newVal === 'true') {
openLoginModal()
router.replace({ query: { ...route.query, login: undefined } })
}
},
)
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>

9
src/pages/Home/logo.svg Normal file
View File

@ -0,0 +1,9 @@
<svg viewBox="0 0 103 29" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.4016 11.4828H17.0339L19.3193 0H26.26L20.5763 28.7559H13.6144L15.8796 17.2933H9.24829L6.96193 28.7559H0L4.88123 4.20849L2.71884 0H12.6668L10.4016 11.4828Z" fill="currentColor"/>
<path d="M29.5968 28.7559H22.6561L26.7156 8.2363H33.6708L29.5968 28.7559Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.8974 5.31659L47.9128 6.28534L48.4606 3.90191H52.8411L53.7012 0H60.0375L59.3081 3.90191H67.3656L65.775 13.4087H67.7568L66.9216 18.8301H58.1346L65.775 24.4081L61.4983 28.7559L53.3629 22.3658L44.1319 28.7559L41.9109 26.556L41.4977 28.7559H35.4228L37.8303 16.29L33.6237 15.7134L36.7327 5.46556H39.9206L40.9768 0H46.896L45.8974 5.31659ZM42.6336 22.706L48.9036 18.8301H43.3612L42.6336 22.706ZM46.896 12.7283L44.7086 11.6538L44.3789 13.4087H51.042L52.0069 9.11375H49.4774L46.896 12.7283ZM57.4561 13.4087H59.5945L60.3768 9.11375H58.3172L57.4561 13.4087Z" fill="currentColor"/>
<path d="M75.7576 22.3514L78.5379 15.6634H81.5884L76.1459 28.7559H73.0955L72.3103 15.6634H75.3607L75.7576 22.3514Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M85.5412 15.6634C86.4527 15.6725 87.5357 15.7459 88.5464 16.0651C89.0518 16.2202 89.5124 16.4575 89.9275 16.7676C90.7578 17.3971 91.2998 18.3916 91.2999 19.9334C91.2999 21.1832 90.8303 22.4242 89.7564 23.3913C88.6734 24.3584 86.9764 24.9789 84.5215 24.9789H83.7632L83.0501 28.7559H79.9997L82.4994 15.6634H85.5412ZM84.2688 22.3331H85.027C86.7057 22.3331 88.4474 21.6942 88.4474 20.0247C88.4473 18.3279 86.6334 18.3092 85.3067 18.3092H85.0357L84.2688 22.3331Z" fill="currentColor"/>
<path d="M98.0504 22.3149L99.3228 15.6634H102.373L99.8735 28.7559H96.8231L94.9462 22.114L93.6824 28.7559H90.6319L93.1317 15.6634H96.1821L98.0504 22.3149Z" fill="currentColor"/>
<path d="M34.011 6.52561H27.0539L28.3407 0.0201823H35.3027L34.011 6.52561Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,18 @@
<svg viewBox="0 0 67 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.64761 6.43747H10.3658L11.647 0H15.5382L12.3518 16.1211H8.44879L9.71872 9.69499H6.00106L4.71928 16.1211H0.816284L3.5528 2.35936L2.34052 0H7.91754L6.64761 6.43747Z" fill="currentColor"/>
<path d="M17.4089 16.1211H13.5177L15.7936 4.61743H19.6928L17.4089 16.1211Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.5473 2.98059L27.6771 3.52369L27.9842 2.18749H30.4401L30.9223 0H34.4745L34.0656 2.18749H38.5828L37.6911 7.5172H38.8021L38.3339 10.5565H33.4077L37.6911 13.6837L35.2935 16.1211L30.7326 12.5387L25.5575 16.1211L24.3124 14.8879L24.0807 16.1211H20.675L22.0247 9.13249L19.6664 8.80922L21.4094 3.0641H23.1966L23.7887 0H27.1071L26.5473 2.98059ZM24.7176 12.7295L28.2326 10.5565H25.1254L24.7176 12.7295ZM27.1071 7.13574L25.8808 6.53337L25.696 7.5172H29.4314L29.9724 5.10935H28.5543L27.1071 7.13574ZM33.0273 7.5172H34.2261L34.6647 5.10935H33.5101L33.0273 7.5172Z" fill="currentColor"/>
<path d="M43.2875 12.5306L44.8462 8.7812H46.5563L43.5052 16.1211H41.7951L41.3549 8.7812H43.065L43.2875 12.5306Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.7724 8.7812C49.2834 8.78632 49.8905 8.82745 50.4572 9.00642C50.7405 9.09336 50.9987 9.2264 51.2314 9.40027C51.6969 9.75318 52.0008 10.3107 52.0008 11.175C52.0008 11.8757 51.7376 12.5715 51.1355 13.1136C50.5284 13.6558 49.577 14.0037 48.2007 14.0037H47.7756L47.3758 16.1211H45.6657L47.0671 8.7812H48.7724ZM48.059 12.5204H48.4841C49.4252 12.5204 50.4017 12.1622 50.4017 11.2262C50.4016 10.275 49.3847 10.2645 48.6409 10.2645H48.489L48.059 12.5204Z" fill="currentColor"/>
<path d="M55.7853 12.5102L56.4986 8.7812H58.2088L56.8074 16.1211H55.0972L54.045 12.3976L53.3365 16.1211H51.6264L53.0278 8.7812H54.7379L55.7853 12.5102Z" fill="currentColor"/>
<path d="M19.8835 3.65839H15.9832L16.7047 0.0113146H20.6077L19.8835 3.65839Z" fill="currentColor"/>
<path d="M2.77626 25.5216C2.5952 25.2235 2.41414 24.9333 2.23308 24.651C1.98412 25.3412 1.6899 25.9373 1.35041 26.4392L0.852494 25.7961C1.19953 25.2471 1.48621 24.6039 1.71253 23.8824C1.41076 23.4588 1.09391 23.0431 0.777052 22.6431V27.349H0V20.3373H6.20133V26.4706C6.20133 27.0196 5.93728 27.2941 5.41673 27.2941H4.66231L4.46616 26.502L5.16023 26.5412C5.33375 26.5412 5.42428 26.4549 5.42428 26.2824V25.3961L4.9339 25.9059C4.73775 25.5451 4.54161 25.1922 4.33037 24.8471C4.05878 25.5373 3.74192 26.1255 3.3798 26.6275L2.88188 25.9843C3.25909 25.4196 3.57595 24.7765 3.83245 24.0392C3.53068 23.5765 3.21383 23.1294 2.88943 22.6902L3.37226 22.2039C3.59104 22.4706 3.83245 22.7843 4.08895 23.1608C4.21721 22.651 4.32282 22.1098 4.40581 21.5373L5.14514 21.6706C5.01689 22.5176 4.84337 23.2941 4.63214 23.9843C4.88864 24.3843 5.15269 24.8235 5.42428 25.2941V21.1216H0.777052V22.5569L1.22216 22.1098C1.44849 22.3608 1.6899 22.6588 1.9464 23.0039C2.05202 22.5333 2.14255 22.0314 2.21045 21.5137L2.93469 21.6471C2.82153 22.4314 2.67819 23.1373 2.50467 23.7804C2.76118 24.1412 3.02522 24.5412 3.29681 24.9804L2.77626 25.5216Z" fill="currentColor"/>
<path d="M9.55095 23.6471H11.173V22.298H11.9726V23.6471H13.7003V24.4471H11.9726V26.2824H14.0247V27.0824H9.27936V26.2824H11.173V24.4471H9.55095V23.6471ZM8.23072 24.4314C8.02702 24.6353 7.81579 24.8314 7.597 25.0275L7.09154 24.3451C7.93649 23.6157 8.61547 22.7843 9.12093 21.8588H7.36313V21.0588H9.49814C9.64148 20.7137 9.76219 20.3608 9.86781 20L10.6826 20.102C10.5921 20.4314 10.4864 20.7451 10.3733 21.0588H14.0247V21.8588H10.0262C9.73956 22.4549 9.40007 23.0118 9.00777 23.5294V27.3647H8.23072V24.4314Z" fill="currentColor"/>
<path d="M18.272 23.6471V24.4784C17.8722 24.6196 17.4573 24.7451 17.0197 24.8627V26.4549C17.0197 26.9725 16.7557 27.2392 16.2276 27.2392H15.3751L15.2016 26.4627C15.4656 26.4941 15.7146 26.5098 15.956 26.5098C16.1295 26.5098 16.22 26.4157 16.22 26.2431V25.0588C15.7825 25.1529 15.3373 25.2392 14.8696 25.3176L14.7564 24.4941C15.2695 24.4157 15.7598 24.3216 16.22 24.2196V22.8706H14.7489V22.0863H16.22V21.3569C15.8579 21.4353 15.4882 21.498 15.0959 21.5529L14.93 20.7765C15.9711 20.6588 16.9065 20.4314 17.7213 20.0863L17.9929 20.8549C17.6836 20.9725 17.3592 21.0745 17.0197 21.1686V22.0863H18.5663C18.521 21.4824 18.5059 20.8157 18.5059 20.0941H19.2981C19.3056 20.8471 19.3282 21.5059 19.3735 22.0863H21.6292V22.8706H19.4338C19.4791 23.3804 19.547 23.7961 19.63 24.1255C19.6602 24.251 19.6904 24.3608 19.7205 24.4706C20.0298 24.0706 20.3014 23.6235 20.5278 23.1373L21.2067 23.5137C20.8748 24.2196 20.4749 24.8392 20.0148 25.3569C20.0902 25.5451 20.1732 25.7098 20.2562 25.8353C20.4674 26.1804 20.6334 26.3529 20.739 26.3529C20.822 26.3529 20.905 25.9529 20.9955 25.1686L21.6971 25.5529C21.501 26.6745 21.2218 27.2392 20.8522 27.2392C20.5202 27.2392 20.1732 27.0039 19.8186 26.5412C19.6828 26.3608 19.5546 26.1569 19.4414 25.9216C18.9284 26.3608 18.355 26.7059 17.7289 26.9569L17.2913 26.2588C17.9854 25.9922 18.5964 25.6157 19.117 25.1294C19.034 24.8784 18.9586 24.6196 18.8907 24.3529C18.7775 23.9294 18.6945 23.4353 18.6342 22.8706H17.0197V24.0314C17.4573 23.9137 17.8797 23.7882 18.272 23.6471ZM20.1279 20.1647C20.656 20.5098 21.1087 20.8549 21.4859 21.2L20.9201 21.7882C20.6107 21.4431 20.1732 21.0824 19.5998 20.6902L20.1279 20.1647Z" fill="currentColor"/>
<path d="M24.6393 23.6471H26.2613V22.298H27.061V23.6471H28.7887V24.4471H27.061V26.2824H29.1131V27.0824H24.3678V26.2824H26.2613V24.4471H24.6393V23.6471ZM23.3191 24.4314C23.1154 24.6353 22.9042 24.8314 22.6854 25.0275L22.1799 24.3451C23.0249 23.6157 23.7039 22.7843 24.2093 21.8588H22.4515V21.0588H24.5865C24.7299 20.7137 24.8506 20.3608 24.9562 20L25.771 20.102C25.6804 20.4314 25.5748 20.7451 25.4617 21.0588H29.1131V21.8588H25.1146C24.8279 22.4549 24.4885 23.0118 24.0962 23.5294V27.3647H23.3191V24.4314Z" fill="currentColor"/>
<path d="M30.9689 25.3412C31.1726 25.3412 31.3461 25.4275 31.4819 25.6C31.6102 25.7725 31.6781 26 31.6781 26.2745C31.6781 26.6902 31.5574 27.051 31.331 27.3647C31.0972 27.6706 30.7803 27.8824 30.388 28V27.4824C30.5917 27.4039 30.7652 27.2784 30.9086 27.1059C31.0368 26.9176 31.1047 26.7373 31.1047 26.549C31.0595 26.5647 30.9991 26.5804 30.9161 26.5804C30.7501 26.5804 30.6143 26.5176 30.5012 26.4C30.388 26.2824 30.3352 26.1333 30.3352 25.9608C30.3352 25.7725 30.3956 25.6235 30.5163 25.5137C30.6294 25.3961 30.7803 25.3412 30.9689 25.3412Z" fill="currentColor"/>
<path d="M40.4972 25.5216C40.3162 25.2235 40.1351 24.9333 39.9541 24.651C39.7051 25.3412 39.4109 25.9373 39.0714 26.4392L38.5735 25.7961C38.9205 25.2471 39.2072 24.6039 39.4335 23.8824C39.1317 23.4588 38.8149 23.0431 38.498 22.6431V27.349H37.721V20.3373H43.9223V26.4706C43.9223 27.0196 43.6583 27.2941 43.1377 27.2941H42.3833L42.1871 26.502L42.8812 26.5412C43.0547 26.5412 43.1453 26.4549 43.1453 26.2824V25.3961L42.6549 25.9059C42.4587 25.5451 42.2626 25.1922 42.0513 24.8471C41.7798 25.5373 41.4629 26.1255 41.1008 26.6275L40.6029 25.9843C40.9801 25.4196 41.2969 24.7765 41.5534 24.0392C41.2517 23.5765 40.9348 23.1294 40.6104 22.6902L41.0932 22.2039C41.312 22.4706 41.5534 22.7843 41.8099 23.1608C41.9382 22.651 42.0438 22.1098 42.1268 21.5373L42.8661 21.6706C42.7379 22.5176 42.5644 23.2941 42.3531 23.9843C42.6096 24.3843 42.8737 24.8235 43.1453 25.2941V21.1216H38.498V22.5569L38.9431 22.1098C39.1695 22.3608 39.4109 22.6588 39.6674 23.0039C39.773 22.5333 39.8635 22.0314 39.9314 21.5137L40.6557 21.6471C40.5425 22.4314 40.3992 23.1373 40.2257 23.7804C40.4822 24.1412 40.7462 24.5412 41.0178 24.9804L40.4972 25.5216Z" fill="currentColor"/>
<path d="M47.5963 21.2H48.8487V20.0941H49.6182V21.2H51.2175V23.5451H51.7079V24.3451H49.8596C50.3047 25.2863 50.9912 26.0627 51.9116 26.6588L51.376 27.3098C50.4556 26.6118 49.7615 25.7569 49.2938 24.7373C48.9317 25.8824 48.2829 26.7373 47.3323 27.302L46.8495 26.6275C47.7397 26.149 48.3281 25.3882 48.6223 24.3451H47.2946V23.5451H48.7808C48.826 23.2314 48.8487 22.902 48.8487 22.5569V21.9608H47.5963V21.2ZM49.6182 21.9608V22.3765C49.6182 22.7843 49.588 23.1765 49.5427 23.5451H50.4631V21.9608H49.6182ZM45.069 21.6314L45.6726 21.6784C45.6575 22.4627 45.5745 23.2078 45.4387 23.9137L44.805 23.7255C44.9559 23.0667 45.0464 22.3686 45.069 21.6314ZM47.1663 21.4275C47.3625 21.9294 47.5435 22.5098 47.6944 23.1608L47.0682 23.3255C46.9551 22.7765 46.8117 22.2431 46.6307 21.7333V27.3176H45.8536V20.102H46.6307V21.6078L47.1663 21.4275Z" fill="currentColor"/>
<path d="M55.993 23.6471V24.4784C55.5932 24.6196 55.1782 24.7451 54.7407 24.8627V26.4549C54.7407 26.9725 54.4766 27.2392 53.9485 27.2392H53.0961L52.9225 26.4627C53.1866 26.4941 53.4355 26.5098 53.6769 26.5098C53.8505 26.5098 53.941 26.4157 53.941 26.2431V25.0588C53.5034 25.1529 53.0583 25.2392 52.5906 25.3176L52.4774 24.4941C52.9904 24.4157 53.4808 24.3216 53.941 24.2196V22.8706H52.4699V22.0863H53.941V21.3569C53.5789 21.4353 53.2092 21.498 52.8169 21.5529L52.6509 20.7765C53.692 20.6588 54.6275 20.4314 55.4423 20.0863L55.7139 20.8549C55.4046 20.9725 55.0802 21.0745 54.7407 21.1686V22.0863H56.2872C56.242 21.4824 56.2269 20.8157 56.2269 20.0941H57.019C57.0266 20.8471 57.0492 21.5059 57.0945 22.0863H59.3502V22.8706H57.1548C57.2001 23.3804 57.268 23.7961 57.351 24.1255C57.3812 24.251 57.4113 24.3608 57.4415 24.4706C57.7508 24.0706 58.0224 23.6235 58.2487 23.1373L58.9277 23.5137C58.5958 24.2196 58.1959 24.8392 57.7357 25.3569C57.8112 25.5451 57.8942 25.7098 57.9771 25.8353C58.1884 26.1804 58.3544 26.3529 58.46 26.3529C58.543 26.3529 58.6259 25.9529 58.7165 25.1686L59.4181 25.5529C59.2219 26.6745 58.9428 27.2392 58.5731 27.2392C58.2412 27.2392 57.8942 27.0039 57.5396 26.5412C57.4038 26.3608 57.2755 26.1569 57.1624 25.9216C56.6494 26.3608 56.076 26.7059 55.4498 26.9569L55.0123 26.2588C55.7063 25.9922 56.3174 25.6157 56.838 25.1294C56.755 24.8784 56.6795 24.6196 56.6116 24.3529C56.4985 23.9294 56.4155 23.4353 56.3551 22.8706H54.7407V24.0314C55.1782 23.9137 55.6007 23.7882 55.993 23.6471ZM57.8489 20.1647C58.377 20.5098 58.8296 20.8549 59.2068 21.2L58.641 21.7882C58.3317 21.4431 57.8942 21.0824 57.3208 20.6902L57.8489 20.1647Z" fill="currentColor"/>
<path d="M62.6847 21.2H63.9371V20.0941H64.7066V21.2H66.3059V23.5451H66.7963V24.3451H64.948C65.3931 25.2863 66.0796 26.0627 67 26.6588L66.4644 27.3098C65.544 26.6118 64.8499 25.7569 64.3822 24.7373C64.02 25.8824 63.3712 26.7373 62.4207 27.302L61.9378 26.6275C62.8281 26.149 63.4165 25.3882 63.7107 24.3451H62.383V23.5451H63.8692C63.9144 23.2314 63.9371 22.902 63.9371 22.5569V21.9608H62.6847V21.2ZM64.7066 21.9608V22.3765C64.7066 22.7843 64.6764 23.1765 64.6311 23.5451H65.5515V21.9608H64.7066ZM60.1574 21.6314L60.761 21.6784C60.7459 22.4627 60.6629 23.2078 60.5271 23.9137L59.8934 23.7255C60.0443 23.0667 60.1348 22.3686 60.1574 21.6314ZM62.2547 21.4275C62.4509 21.9294 62.6319 22.5098 62.7828 23.1608L62.1566 23.3255C62.0435 22.7765 61.9001 22.2431 61.7191 21.7333V27.3176H60.942V20.102H61.7191V21.6078L62.2547 21.4275Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

BIN
src/pages/Home/x-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,33 @@
<template>
<!-- Main Neon Green Card -->
<div class="flex h-[678px] justify-center">
<div class="h-full w-[345px]">
<Transition name="fade" mode="out-in">
<UserInfo />
</Transition>
</div>
<div
class="ml-2.5 flex items-center overflow-hidden rounded-4xl pt-[32px] pb-[22px]"
>
<div class="h-full w-[345px]">
</div>
<div
class="mx-[5px] h-[624px]"
></div>
<div class="h-full w-[345px]">
</div>
</div>
</div>
</template>
<script setup lang="ts">
import UserInfo from '@/pages/UserCenter/components/UserInfo/index.vue'
</script>
<style scoped>
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,4 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.14678 6.25189H11.9369C12.5063 6.25189 13.0523 6.47268 13.4549 6.86569C13.8575 7.2587 14.0837 7.79174 14.0837 8.34754V17.9046C14.0837 18.4604 13.8575 18.9934 13.4549 19.3864C13.0523 19.7794 12.5063 20.0002 11.9369 20.0003H2.14682C1.57745 20.0003 1.03139 19.7795 0.628788 19.3864C0.226182 18.9934 4.67869e-10 18.4604 4.67869e-10 17.9046V8.34758C-5.88518e-06 8.07237 0.055518 7.79986 0.163401 7.5456C0.271285 7.29133 0.429415 7.06031 0.628762 6.8657C0.82811 6.6711 1.06477 6.51673 1.32523 6.41141C1.58569 6.30609 1.86486 6.25189 2.14678 6.25189Z" fill="black"/>
<path d="M18.3412 0H8.55111C7.98218 0.00138285 7.43695 0.222623 7.03465 0.615344C6.63235 1.00807 6.40571 1.54031 6.4043 2.0957V5.00011H11.9369C12.8457 5.00221 13.7166 5.35556 14.3592 5.98286C15.0018 6.61016 15.3638 7.46036 15.366 8.3475V13.7484H18.3412C18.9101 13.747 19.4553 13.5257 19.8576 13.133C20.2599 12.7403 20.4866 12.2081 20.488 11.6527V2.09572C20.4866 1.54033 20.2599 1.00808 19.8576 0.615355C19.4553 0.222629 18.9101 0.00138455 18.3412 0Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,117 @@
<template>
<div
class="flex h-full w-full flex-col justify-between rounded-4xl border-1 border-white py-[32px] pb-[22px]"
>
<div>
<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>
<div class="flex items-center">
<img src="./avatar.png" class="size-[60px]" alt="" />
<div class="ml-2 flex flex-col justify-center text-white">
<div class="text-base font-semibold">{{ userInfo.email }}</div>
<div class="flex items-center text-base font-semibold">
<span class="mr-0.5 text-3xl">🌞</span> 恒星合伙人
</div>
</div>
</div>
<div class="mt-7 mb-1 font-semibold text-[#ADFF5B]">专属代理链接</div>
<div
class="mb-[10px] flex h-[50px] w-full items-center justify-between rounded-[32px] bg-[#ADFF5B] px-4 font-medium text-black"
>
{{ userInfo.share_link }}
<CopyIcon />
</div>
<div
class="flex min-h-[90px] w-full items-center justify-between rounded-[25px] bg-[#ADFF5B] px-4 font-medium text-black"
>
<div>
<div class="text-xl font-semibold">佣金账户余额</div>
<div class="text-3xl font-black">$12344</div>
</div>
<Button variant="ghost" class="hover:bg-transparent">点击提现</Button>
</div>
</div>
</Transition>
</div>
</div>
<div>
<div class="flex flex-col gap-[10px] px-6 pt-8 pb-9">
<div class="h-[50px] w-full rounded-[32px] bg-[#222222] px-4 leading-[50px] font-medium">
历史佣金总计$ 1,326.99
</div>
<div class="h-[50px] w-full rounded-[32px] bg-[#222222] px-4 leading-[50px] font-medium">
历史推荐人数123
</div>
<Button
variant="outline"
@click="logout"
class="h-[50px] w-full rounded-[32px] border-2 border-white bg-transparent text-xl font-bold text-white transition-all hover:bg-white/90 active:scale-[0.98]"
>
退出登录
</Button>
</div>
<div class="text-center text-xs text-white tabular-nums">注册时间{{ formattedDate }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
import { Button } from '@/components/ui/button'
import CopyIcon from './copy.svg?component'
import { computed, onMounted, ref } from 'vue'
import request from '@/utils/request'
import { useRouter } from 'vue-router'
const router = useRouter()
const userInfo = ref({
email: '',
created_at: '',
})
const isUserLoading = ref(true)
async function init() {
// 1. &
isUserLoading.value = true
request
.get('/api/v1/public/user/info')
.then((res: any) => {
userInfo.value = res
const emailInfo = res.auth_methods?.find((item: any) => item.auth_type === 'email')
if (emailInfo) {
userInfo.value.email = emailInfo.auth_identifier
localStorage.setItem('UserEmail', emailInfo.auth_identifier)
}
})
.finally(() => {
isUserLoading.value = false
})
}
onMounted(() => {
init()
})
const formattedDate = computed(() => {
const dateStr = userInfo?.value.created_at
if (!dateStr) return '加载中...'
const date = new Date(dateStr)
// 使 Intl 2026/01/29 17:00
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
})
function logout() {
router.push('/')
localStorage.removeItem('Authorization')
}
</script>

View File

@ -0,0 +1,96 @@
<template>
<div class="flex min-h-screen flex-col bg-black text-white">
<!-- Full Width Header -->
<div class="h-[60px] md:h-[125px]">
<div class="fixed top-[20px] z-50 w-full md:top-[45px]">
<div class="container">
<header
class="lucid-glass-bar flex h-[40px] items-center justify-between rounded-[90px] pr-[5px] pl-5 transition-all duration-300 md:h-[60px] md:pr-[10px]"
>
<router-link to="/" class="flex items-center gap-2">
<!-- Desktop Logo -->
<Logo alt="Hi快VPN" class="h-[18px] w-auto md:ml-8 md:h-[29px]" />
<span class="font-black text-2xl ml-3">高能合伙人</span>
</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
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>
</header>
</div>
</div>
</div>
<div class="flex flex-1 flex-col">
<!-- Main Neon Green Card -->
<!-- <div class="container md:hidden">
</div>-->
<div class="hidden flex-1 items-center justify-center md:flex md:pb-[50px]">
<div class="container mx-auto">
<DesktopLayout
: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"
@pay="handlePay"
@show-order-details="orderDetailsModalRef?.show()"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DesktopLayout from './DesktopLayout/index.vue'
import OrderStatusDialog from '@/components/user-center/OrderStatusDialog.vue'
import OrderDetailsModal from './components/OrderDetails/index.vue'
import Logo from '@/pages/Home/logo.svg?component'
import request from '@/utils/request'
import { toast } from 'vue-sonner'
import { useLocalStorage } from '@vueuse/core'
const route = useRoute()
const router = useRouter()
const token = useLocalStorage('Authorization', '')
const userEmail = useLocalStorage('UserEmail', '')
const isLoggedIn = computed(() => !!token.value)
const userLetter = computed(() => {
if (!userEmail.value) return '?'
return userEmail.value.charAt(0).toUpperCase()
})
// --- 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)
</script>
<style scoped>
</style>
<style scoped>
</style>

47
src/router/index.ts Normal file
View File

@ -0,0 +1,47 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: () => import('../pages/Home/index.vue'),
},
{
path: '/user-center',
name: 'user-center',
component: () => import('../pages/UserCenter/index.vue'),
},
{
path: '/terms',
name: 'terms-of-service',
component: () => import('../pages/TermsOfService/index.vue'),
},
{
path: '/privacy',
name: 'privacy-policy',
component: () => import('../pages/PrivacyPolicy/index.vue'),
},
{
path: '/login/:token',
name: 'login-redirect',
component: () => import('../pages/LoginRedirect/index.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
redirect: '/',
},
],
})
router.beforeEach((to, _from, next) => {
if (to.path === '/user-center' && !localStorage.getItem('Authorization')) {
next({ path: '/', query: { login: 'true' } })
} else {
next()
}
})
export default router

186
src/styles/index.css Normal file
View File

@ -0,0 +1,186 @@
@font-face {
font-family: 'Roboto';
src: url('/src/styles/roboto.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
html, body {
background: #000;
}
.lucid-glass-bar {
/* 基础背景Figma 中通常是叠加的,这里取中值确保通透度 */
background: rgb(255 255 255 / 6%);
/* 核心模糊 */
@apply backdrop-blur-[36px];
/* 圆角:由于你有 border-image建议使用这种方式保留圆角 */
border-radius: 999px;
/* 边框Figma border-image CSS 圆角容器上会有兼容问题
建议改用单纯的 border 配合 rgba 以达到线性渐变的效果 */
border: 1px solid rgba(255, 255, 255, 0.2);
/* 阴影叠加:按照 Figma 从上到下的顺序转换 */
box-shadow:
/* 1. 这里的 #F2F2F280 33px 扩散是导致发白的主因,建议将其放在最底层并调低不透明度 */
inset 0px 0px 33px 0px rgba(242, 242, 242, 0.15),
/* 2. 右下角的反向高光 (原本的 -3px -4.5px) */
inset 0px -2px 1.5px -3px rgba(179, 179, 179, 0.5),
/* 3. 左上角的微弱亮边 */
inset 3px 4.5px 1.5px -3px rgba(179, 179, 179, 0.2),
/* 4. 核心受光面 (Figma: 4.5px 4.5px #FFFFFF80) */
inset 4.5px 4.5px 1.5px -5.25px rgba(255, 255, 255, 0.4),
/* 5. 核心背光面 (Figma: -4.5px -4.5px #FFFFFF80) */
inset -4.5px -4.5px 1.5px -5.25px rgba(255, 255, 255, 0.3);
}
@import "tailwindcss";
@import "tw-animate-css";
@layer components {
.container {
width: 100%;
margin-left: auto;
margin-right: auto;
/* 1. 移动端默认:左右 18px 边距 */
padding-left: 18px;
padding-right: 18px;
/* 2. 桌面端逻辑:当屏幕达到 1440px 及以上 */
@media (width >= 1440px) {
/* 计算公式
内容区固定为 1268px
侧边留白 = (1440 - 1268) / 2 = 86px
*/
max-width: 1268px;
/* 此时 padding 可以设为 0因为 max-width 配合 margin: auto 已经产生了 86px 的留白 */
padding-left: 0;
padding-right: 0;
}
}
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

BIN
src/styles/roboto.woff2 Normal file

Binary file not shown.

3
src/utils/constant.ts Normal file
View File

@ -0,0 +1,3 @@
// utils/constant.ts
export const downLoadIos = 'https://apps.apple.com/us/app/hi%E5%BF%ABvpn/id6755683167'

View File

@ -0,0 +1,77 @@
import CryptoJS from 'crypto-js'
/**
* HiAesUtil - AES-256-CBC
* Dart 100%
*/
export class HiAesUtil {
/**
* 32 (SHA-256)
*/
static _generateKey(keyStr) {
return CryptoJS.SHA256(keyStr)
}
/**
* 16 IV
* 逻辑: SHA-256(MD5(nonce) + keyStr) -> 16
*/
static _generateIv(nonce, keyStr) {
// 1. MD5 处理 nonce
const md5Hash = CryptoJS.MD5(nonce)
// 2. 转为 16 进制字符串
const md5Hex = md5Hash.toString(CryptoJS.enc.Hex)
// 3. 拼接 md5Hex + keyStr 并做 SHA-256
const finalHash = CryptoJS.SHA256(md5Hex + keyStr)
// 4. 重要:截取前 16 字节 (128位)
// CryptoJS 的 WordArray 由 32 位整数组成16 字节即前 4 个 words
const iv = CryptoJS.lib.WordArray.create(finalHash.words.slice(0, 4), 16)
return iv
}
/**
*
* @param {string} plainText -
* @param {string} keyStr -
*/
static encryptData(plainText, keyStr) {
// 生成 ISO8601 时间戳 (与 Dart DateTime.now().toIso8601String() 对应)
// Dart: 2025-12-30T21:05:06.075850 (no Z, 6 decimal places)
const now = new Date()
const nonce = now.toISOString().replace('Z', '') + '000'
const key = this._generateKey(keyStr)
const iv = this._generateIv(nonce, keyStr)
const encrypted = CryptoJS.AES.encrypt(plainText, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
})
return {
data: encrypted.toString(), // Base64 字符串
time: nonce,
}
}
/**
*
* @param {string} encryptedData - Base64
* @param {string} nonce -
* @param {string} keyStr -
*/
static decryptData(encryptedData, nonce, keyStr) {
const key = this._generateKey(keyStr)
const iv = this._generateIv(nonce, keyStr)
const decrypted = CryptoJS.AES.decrypt(encryptedData, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
})
return decrypted.toString(CryptoJS.enc.Utf8)
}
}

View File

@ -0,0 +1,58 @@
import type { AxiosRequestConfig, Canceler } from 'axios'
import axios from 'axios'
// Used to store the identification and cancellation function of each request
let pendingMap = new Map<string, Canceler>()
const getPendingUrl = (config: AxiosRequestConfig) => [config.method, config.url].join('&')
export class AxiosCanceler {
/**
* Add request
* @param {Object} config
*/
addPending(config: AxiosRequestConfig): void {
this.removePending(config)
const url = getPendingUrl(config)
config.cancelToken = config.cancelToken
|| new axios.CancelToken((cancel) => {
if (!pendingMap.has(url)) {
// If there is no current request in pending, add it
pendingMap.set(url, cancel)
}
})
}
/**
* @description: Clear all pending
*/
removeAllPending(): void {
pendingMap.forEach((cancel) => {
cancel?.()
})
pendingMap.clear()
}
/**
* Removal request
* @param {Object} config
*/
removePending(config: AxiosRequestConfig): void {
const url = getPendingUrl(config)
if (pendingMap.has(url)) {
// If there is a current request identifier in pending,
// the current request needs to be cancelled and removed
const cancel = pendingMap.get(url)
cancel && cancel(url)
pendingMap.delete(url)
}
}
/**
* @description: reset
*/
reset(): void {
pendingMap = new Map<string, Canceler>()
}
}

281
src/utils/request/core.ts Normal file
View File

@ -0,0 +1,281 @@
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import axios from 'axios'
import { AxiosCanceler } from './cancel'
import router from '@/router'
import { toast } from 'vue-sonner'
// import { HiAesUtil } from './HiAesUtil.ts'
// const encryptionKey = 'c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx'
function redirectLogin() {
localStorage.removeItem('Authorization')
router.push({ path: '/', query: { login: 'true' } })
}
export interface ExtraConfig {
/**
* 1 0-1-warning2-error
*/
errorLevel?: 0 | 1 | 2
/**
* true token
*/
withToken?: boolean
/**
* true
*/
handleResponse?: boolean
/**
* false
*/
cancelRepetition?: boolean
/**
* false axios完整响应
*/
originResponseData?: boolean
/**
* token key
*/
tokenKey?: string
/**
* token的方法
*/
getToken?: () => string
/**
* header方法header会覆盖默认的header
*/
formatHeader?: (header: Record<string, string>) => Record<string, string>
}
export interface RequestConfig extends AxiosRequestConfig {
extraConfig?: ExtraConfig
}
interface ResponseType extends AxiosResponse {
config: RequestConfig
}
const ERROR_MESSAGES: Record<number | string, string> = {
'200': '成功',
'500': '内部服务器错误',
'10001': '数据库查询错误',
'10002': '数据库更新错误',
'10003': '数据库插入错误',
'10004': '数据库删除错误',
'20001': '用户已存在',
'20002': '用户不存在',
'20003': '用户密码错误',
'20004': '用户已禁用',
'20005': '余额不足',
'20006': '停止注册',
'20007': '未绑定Telegram',
'20008': '用户未绑定OAuth方式',
'20009': '邀请码错误',
'30001': '节点已存在',
'30002': '节点不存在',
'30003': '节点组已存在',
'30004': '节点组不存在',
'30005': '节点组不为空',
'400': '参数错误',
'40002': '用户令牌为空',
'40003': '用户令牌无效',
'40004': '用户令牌已过期',
'40005': '您还没有登录',
'401': '请求过多',
'50001': '优惠券不存在',
'50002': '优惠券已被使用',
'50003': '优惠券不匹配',
'60001': '订阅已过期',
'60002': '订阅不可用',
'60003': '用户已有订阅',
'60004': '订阅已被使用',
'60005': '单一订阅模式超出限制',
'60006': '订阅配额限制',
'70001': '验证码错误',
'80001': '队列入队错误',
'90001': '调试模式已启用',
'90002': '发送短信错误',
'90003': '短信功能未启用',
'90004': '电子邮件功能未启用',
'90005': '不支持的登录方式',
'90006': '身份验证器不支持此方式',
'90007': '电话区号为空',
'90008': '密码为空',
'90009': '区号为空',
'90010': '需要密码或验证码',
'90011': '电子邮件已存在',
'90012': '电话号码已存在',
'90013': '设备已存在',
'90014': '电话号码错误',
'90015': '此账户今日已达到发送次数限制',
'90017': '设备不存在',
'90018': '用户 ID 不匹配',
'61001': '订单不存在',
'61002': '支付方式未找到',
'61003': '订单状态错误',
'61004': '重置周期不足',
'61005': '存在没用完的流量',
}
export default class Request {
public axiosInstance: AxiosInstance
private config: RequestConfig
constructor(config: RequestConfig) {
this.config = config
this.axiosInstance = axios.create(config)
this.init()
}
static defaultConfig: Required<ExtraConfig> = {
errorLevel: 2,
withToken: true,
handleResponse: true,
cancelRepetition: false,
originResponseData: false,
tokenKey: 'token',
formatHeader: (headers) => {
return headers
},
getToken: () => '',
}
private errorReport(lv: number, message: string) {
toast(message)
}
private init() {
const axiosCanceler = new AxiosCanceler()
this.axiosInstance.interceptors.request.use(
(config: RequestConfig) => {
const mergeExtraConfig = {
...Request.defaultConfig,
...this.config.extraConfig,
...config.extraConfig,
}
// if (config.data && !(config.data instanceof FormData)) {
// const plainText = JSON.stringify(config.data)
// config.data = HiAesUtil.encryptData(plainText, encryptionKey)
// }
//
// if (config.method?.toLowerCase() === 'get' || config.params) {
// const paramsToEncrypt = config.params || {}
// // console.log('paramsToEncrypt', paramsToEncrypt)
// const plainParamsText = JSON.stringify(paramsToEncrypt)
// const encryptedParams = HiAesUtil.encryptData(plainParamsText, encryptionKey)
//
// config.params = {
// data: encryptedParams.data,
// time: encryptedParams.time,
// }
// }
config.headers = mergeExtraConfig.formatHeader({
...this.config.headers,
...config.headers,
lang: 'zh_CN',
// 'login-type': 'device',
...(mergeExtraConfig.withToken && {
[mergeExtraConfig.tokenKey]: mergeExtraConfig.getToken(),
}),
} as any)
config.extraConfig = mergeExtraConfig
if (mergeExtraConfig.cancelRepetition) {
axiosCanceler.addPending(config)
}
return config
},
(error) => {
this.errorReport(error?.config.extraConfig.errorLevel, error)
return Promise.reject(error)
},
)
this.axiosInstance.interceptors.response.use(
(response: ResponseType) => {
const { data, config } = response
const responseData = response.data.data
// if (responseData && responseData.data && responseData.time) {
// try {
// const decryptedStr = HiAesUtil.decryptData(
// responseData.data,
// responseData.time,
// encryptionKey,
// )
// // console.log('decryptedStr', JSON.parse(decryptedStr))
// responseData = JSON.parse(decryptedStr)
// } catch (e) {
// console.error('解密失败:', e)
// return Promise.reject({ message: '数据解密异常' })
// }
// }
axiosCanceler.removePending(config)
if (data.code !== 200) {
const msg = ERROR_MESSAGES[data.code] || response.data?.msg || data?.error || '未知错误'
if (data.code == 40004 || data.code == 40003 || data.code == 40005) {
toast.error(msg)
redirectLogin()
return
}
if (config.extraConfig?.handleResponse) {
this.errorReport(config.extraConfig.errorLevel ?? 2, msg)
return Promise.reject({
...data,
message: msg,
})
}
}
return config.extraConfig?.originResponseData ? response : responseData
},
(error) => {
const status = error?.response?.status
const code = error?.code
let message = error?.message
if (status === 401) {
redirectLogin()
return
}
if (code === 'ECONNABORTED') {
message = '网络环境太差,请求超时'
} else if (code === 'Network Error' || message === 'Network Error') {
if (error.response) {
message = `${error.response.status}:network连接失败请求中断`
} else {
message = '网络好像出现问题了'
}
}
if (error.__CANCEL__) {
console.warn('request canceled', error?.message)
} else {
this.errorReport(error?.config?.extraConfig?.errorLevel, message)
}
return Promise.reject(error)
},
)
}
public get<T>(url: string, params?: Record<string, unknown>, config?: RequestConfig): Promise<T> {
return this.axiosInstance.get(url, { ...config, params })
}
public post<D, T>(url: string, data?: D, config?: RequestConfig): Promise<T> {
return this.axiosInstance.post(url, data, { ...config })
}
public put<D, T>(url: string, data?: D, config?: RequestConfig): Promise<T> {
return this.axiosInstance.put(url, data, { ...config })
}
public patch<D, T>(url: string, data?: D, config?: RequestConfig): Promise<T> {
return this.axiosInstance.patch(url, data, { ...config })
}
public delete<D, T>(url: string, params?: D, config?: RequestConfig): Promise<T> {
return this.axiosInstance.delete(url, { ...config, params })
}
}

View File

@ -0,0 +1,16 @@
import Request from './core'
export * from './core'
const baseUrl = import.meta.env.VITE_APP_BASE_URL
const request = new Request({
baseURL: baseUrl,
timeout: 6000,
headers: {},
extraConfig: {
/** 这里是核心配置一般不需要再去修改request/core.ts */
tokenKey: 'Authorization',
getToken: () => localStorage.getItem('Authorization') || '',
},
})
export default request

157
src/utils/url-utils.ts Normal file
View File

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

12
tsconfig.app.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
],
/* shadcn */
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

19
tsconfig.node.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

36
vite.config.ts Normal file
View File

@ -0,0 +1,36 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
import svgLoader from 'vite-svg-loader'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss(), vueDevTools(), svgLoader()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
// 将所有以 /api 开头的请求转发到目标服务器
// 1. 匹配所有以 /public 开头的请求
'/api/v1': {
target: 'https://hifastvpn.com',
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, ''),
autoRewrite: true,
// 3. 关键:将路径重写,在前面补上 /api/v1
// 验证请求是否进入代理,可以在终端看到打印
configure: (proxy, _options) => {
proxy.on('proxyReq', (proxyReq, req, _res) => {
console.log('代理请求:', req.method, req.url, ' -> ', proxyReq.path)
})
},
},
},
},
})