第一次上传
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
VITE_APP_PORT=5174
|
||||
VITE_API_TARGET=http://localhost:8000
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# member-web
|
||||
|
||||
会员前台项目,包含官网营销页、登录注册和登录后的会员控制台。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3
|
||||
- Vite
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Pinia
|
||||
- Vue Router
|
||||
- Axios
|
||||
|
||||
## 页面结构
|
||||
|
||||
- `/` 官网首页
|
||||
- `/pricing` 产品套餐
|
||||
- `/help` 帮助中心
|
||||
- `/login` 登录
|
||||
- `/register` 注册
|
||||
- `/console/dashboard` 控制台
|
||||
- `/console/buy` 套餐购买
|
||||
- `/console/orders` 我的订单
|
||||
- `/console/wallet` 我的钱包
|
||||
- `/console/static-assets` 静态代理
|
||||
- `/console/dynamic-channels` 动态通道
|
||||
- `/console/open-api` 开放 API
|
||||
- `/console/verify` 实名认证
|
||||
- `/console/profile` 账户资料
|
||||
|
||||
## 后端接口
|
||||
|
||||
默认代理到本地 Java 服务:
|
||||
|
||||
```bash
|
||||
VITE_API_TARGET=http://localhost:8000
|
||||
```
|
||||
|
||||
当前已封装接口:
|
||||
|
||||
- `/api/v1/member/auth/**`
|
||||
- `/api/v1/member/package-center/**`
|
||||
- `/api/v1/member/orders`
|
||||
- `/api/v1/member/wallet`
|
||||
- `/api/v1/member/static-assets`
|
||||
- `/api/v1/member/dynamic-channels`
|
||||
- `/api/v1/member/verify/**`
|
||||
- `/api/v1/member/open-api/**`
|
||||
- `/api/v1/public/package-center/**`
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
如果本机使用 pnpm,也可以改用 pnpm 安装依赖。
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="专业代理 IP 服务平台,提供静态代理、动态代理、API 接入和会员控制台。" />
|
||||
<title>云启代理 - 专业代理 IP 服务平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+2934
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "member-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"axios": "^1.7.9",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig } from "axios";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: string;
|
||||
data: T;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: "",
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"Content-Type": "application/json;charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const auth = useAuthStore();
|
||||
if (auth.accessToken) {
|
||||
config.headers.Authorization = `Bearer ${auth.accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
const body = response.data as ApiResponse;
|
||||
if (body && Object.prototype.hasOwnProperty.call(body, "code")) {
|
||||
if (body.code === "00000" || body.code === "0" || body.code === "200") {
|
||||
return body.data;
|
||||
}
|
||||
return Promise.reject(new Error(body.msg || "请求失败"));
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
async (error: AxiosError<ApiResponse>) => {
|
||||
const message = error.response?.data?.msg || error.message || "网络连接失败";
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
);
|
||||
|
||||
export function get<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return http.get<unknown, T>(url, config);
|
||||
}
|
||||
|
||||
export function post<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return http.post<unknown, T>(url, data, config);
|
||||
}
|
||||
|
||||
export function put<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return http.put<unknown, T>(url, data, config);
|
||||
}
|
||||
|
||||
export function del<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return http.delete<unknown, T>(url, config);
|
||||
}
|
||||
|
||||
export default http;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { del, get, post, put } from "@/api/http";
|
||||
|
||||
export interface LoginPayload {
|
||||
loginType?: string;
|
||||
account?: string;
|
||||
username?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
registerType?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
code?: string;
|
||||
nickname?: string;
|
||||
inviteCode?: string;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
export interface CurrentMember {
|
||||
id: number;
|
||||
username?: string;
|
||||
nickname?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
verifyStatus?: number;
|
||||
}
|
||||
|
||||
export interface MemberAuthPublicConfig {
|
||||
registerMethods?: string[];
|
||||
loginMethods?: string[];
|
||||
mobileVerificationEnabled?: boolean;
|
||||
emailVerificationEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CaptchaInfo {
|
||||
captchaId?: string;
|
||||
captchaBase64?: string;
|
||||
}
|
||||
|
||||
export interface PackageCatalog {
|
||||
staticPackages?: StaticPackage[];
|
||||
dynamicPackages?: unknown[];
|
||||
durations?: unknown[];
|
||||
}
|
||||
|
||||
export interface DurationOption {
|
||||
durationDays?: number;
|
||||
multiplier?: number;
|
||||
}
|
||||
|
||||
export interface StaticRegion {
|
||||
regionId?: number;
|
||||
regionCode?: string;
|
||||
regionName?: string;
|
||||
regionNameZh?: string;
|
||||
countryCode?: string;
|
||||
countryName?: string;
|
||||
basePrice?: number;
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
export interface QiYunNodeOption {
|
||||
qiyunAreaId?: string;
|
||||
qiyunAreaName?: string;
|
||||
qiyunNodeId?: string;
|
||||
qiyunNodeName?: string;
|
||||
basePrice?: number;
|
||||
priceType?: string;
|
||||
availableQuantity?: number;
|
||||
}
|
||||
|
||||
export interface StaticPackage {
|
||||
productId?: number;
|
||||
productCode?: string;
|
||||
productName?: string;
|
||||
currency?: string;
|
||||
qiyunProductType?: string;
|
||||
upstreamProviderId?: number;
|
||||
qiyunProjectRequired?: boolean;
|
||||
defaultNodePrice?: number;
|
||||
durations?: DurationOption[];
|
||||
regions?: StaticRegion[];
|
||||
}
|
||||
|
||||
export interface StaticPurchasePayload {
|
||||
productId?: number;
|
||||
durationDays?: number;
|
||||
purposeWeb?: string;
|
||||
qiyunPid?: string;
|
||||
qiyunProjectName?: string;
|
||||
qiyunAreaId?: string;
|
||||
qiyunAreaName?: string;
|
||||
qiyunNodeId?: string;
|
||||
qiyunNodeName?: string;
|
||||
quantity?: number;
|
||||
items: Array<{
|
||||
regionId?: number;
|
||||
quantity?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OrderSubmit {
|
||||
orderNo?: string;
|
||||
orderStatus?: string;
|
||||
payStatus?: string;
|
||||
openStatus?: string;
|
||||
saleAmount?: number;
|
||||
payAmount?: number;
|
||||
currency?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface BatchOrderSubmit {
|
||||
orderCount?: number;
|
||||
totalSaleAmount?: number;
|
||||
orders?: OrderSubmit[];
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface WalletOverview {
|
||||
balance?: number;
|
||||
frozenBalance?: number;
|
||||
totalRechargeAmount?: number;
|
||||
totalConsumeAmount?: number;
|
||||
}
|
||||
|
||||
export const MemberAPI = {
|
||||
authConfig() {
|
||||
return get<MemberAuthPublicConfig>("/api/v1/member/auth/config");
|
||||
},
|
||||
captcha(scene: "LOGIN" | "REGISTER") {
|
||||
return get<CaptchaInfo>("/api/v1/member/auth/captcha", { params: { scene } });
|
||||
},
|
||||
sendMobileCode(params: { mobile: string; scene: "LOGIN" | "REGISTER"; captchaId: string; captchaCode: string }) {
|
||||
return post<void>("/api/v1/member/auth/mobile/code", undefined, { params });
|
||||
},
|
||||
sendEmailCode(params: { email: string; scene: "LOGIN" | "REGISTER"; captchaId: string; captchaCode: string }) {
|
||||
return post<void>("/api/v1/member/auth/email/code", undefined, { params });
|
||||
},
|
||||
login(data: LoginPayload) {
|
||||
return post<TokenPair>("/api/v1/member/auth/login", data);
|
||||
},
|
||||
register(data: RegisterPayload) {
|
||||
return post<TokenPair>("/api/v1/member/auth/register", data);
|
||||
},
|
||||
me() {
|
||||
return get<CurrentMember>("/api/v1/member/auth/me");
|
||||
},
|
||||
logout() {
|
||||
return del<void>("/api/v1/member/auth/logout");
|
||||
},
|
||||
publicCatalog() {
|
||||
return get<PackageCatalog>("/api/v1/public/package-center/catalog");
|
||||
},
|
||||
memberCatalog() {
|
||||
return get<PackageCatalog>("/api/v1/member/package-center/catalog");
|
||||
},
|
||||
staticInventories(productId: number, params?: { purposeWeb?: string; qiyunPid?: string; qiyunAreaId?: string }) {
|
||||
return get<QiYunNodeOption[]>("/api/v1/member/package-center/static-inventory", {
|
||||
params: { productId, ...params },
|
||||
});
|
||||
},
|
||||
qiyunProjects(productId: number) {
|
||||
return get<Array<{ id?: string; value?: string; extra?: Record<string, unknown> }>>("/api/v1/member/package-center/qiyun/projects", {
|
||||
params: { productId },
|
||||
});
|
||||
},
|
||||
qiyunAreas(productId: number, pid?: string) {
|
||||
return get<Array<{ id?: string; value?: string; extra?: Record<string, unknown> }>>("/api/v1/member/package-center/qiyun/areas", {
|
||||
params: { productId, pid },
|
||||
});
|
||||
},
|
||||
purchaseStaticPackage(data: StaticPurchasePayload) {
|
||||
return post<BatchOrderSubmit>("/api/v1/member/package-center/static-orders", data);
|
||||
},
|
||||
payOrder(orderNo: string, data?: { paymentType?: string }) {
|
||||
return post<OrderSubmit>(`/api/v1/member/orders/${orderNo}/pay`, data);
|
||||
},
|
||||
wallet() {
|
||||
return get<WalletOverview>("/api/v1/member/wallet");
|
||||
},
|
||||
walletFlows(params?: Record<string, unknown>) {
|
||||
return get<PageResult<unknown>>("/api/v1/member/wallet/flows", { params });
|
||||
},
|
||||
orders(params?: Record<string, unknown>) {
|
||||
return get<PageResult<unknown>>("/api/v1/member/orders", { params });
|
||||
},
|
||||
staticAssets(params?: Record<string, unknown>) {
|
||||
return get<PageResult<unknown>>("/api/v1/member/static-assets", { params });
|
||||
},
|
||||
dynamicChannels(params?: Record<string, unknown>) {
|
||||
return get<PageResult<unknown>>("/api/v1/member/dynamic-channels", { params });
|
||||
},
|
||||
verifyCurrent() {
|
||||
return get<unknown>("/api/v1/member/verify/current");
|
||||
},
|
||||
verifyPolicy() {
|
||||
return get<unknown>("/api/v1/member/verify/policy");
|
||||
},
|
||||
submitVerify(data: unknown) {
|
||||
return post<unknown>("/api/v1/member/verify/submit", data);
|
||||
},
|
||||
openApiCurrent() {
|
||||
return get<unknown>("/api/v1/member/open-api/current");
|
||||
},
|
||||
openApiCredential() {
|
||||
return get<unknown>("/api/v1/member/open-api/credential");
|
||||
},
|
||||
submitOpenApiApply(data: unknown) {
|
||||
return post<unknown>("/api/v1/member/open-api/submit", data);
|
||||
},
|
||||
updateProfile(data: unknown) {
|
||||
return put<CurrentMember>("/api/v1/member/profile", data);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<RouterLink to="/" class="flex h-16 items-center gap-3 border-b border-slate-200 px-6">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-md bg-brand-600 text-base font-bold text-white">云</span>
|
||||
<div>
|
||||
<p class="font-bold text-slate-900">云启代理</p>
|
||||
<p class="text-xs text-slate-500">Member Console</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="flex-1 space-y-1 overflow-y-auto px-4 py-5">
|
||||
<RouterLink
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-slate-600 transition hover:bg-brand-50 hover:text-brand-700"
|
||||
:class="{ 'bg-brand-50 text-brand-700': isActive(item.path) }"
|
||||
@click="$emit('navigate')"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5" />
|
||||
<span>{{ item.title }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="m-4 rounded-lg border border-brand-100 bg-brand-50 p-4">
|
||||
<p class="text-sm font-semibold text-brand-900">需要更多并发?</p>
|
||||
<p class="mt-1 text-xs leading-5 text-brand-700">提交企业需求,支持定制上游、城市覆盖和账期方案。</p>
|
||||
<RouterLink to="/help" class="mt-3 inline-flex text-xs font-semibold text-brand-700">联系顾问</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from "vue-router";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Boxes,
|
||||
Gauge,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
ReceiptText,
|
||||
ShoppingCart,
|
||||
UserRound,
|
||||
Wallet,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
defineEmits<{ navigate: [] }>();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const menuItems = [
|
||||
{ title: "控制台", path: "/console/dashboard", icon: LayoutDashboard },
|
||||
{ title: "购买套餐", path: "/console/buy", icon: ShoppingCart },
|
||||
{ title: "我的订单", path: "/console/orders", icon: ReceiptText },
|
||||
{ title: "我的钱包", path: "/console/wallet", icon: Wallet },
|
||||
{ title: "静态代理", path: "/console/static-assets", icon: Boxes },
|
||||
{ title: "动态通道", path: "/console/dynamic-channels", icon: Gauge },
|
||||
{ title: "开放 API", path: "/console/open-api", icon: KeyRound },
|
||||
{ title: "实名认证", path: "/console/verify", icon: BadgeCheck },
|
||||
{ title: "账户资料", path: "/console/profile", icon: UserRound },
|
||||
];
|
||||
|
||||
function isActive(path: string) {
|
||||
return route.path === path;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-dashed border-slate-300 bg-white p-8 text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-md bg-slate-100 text-slate-500">
|
||||
<Inbox class="h-6 w-6" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-base font-semibold text-slate-900">{{ title }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Inbox } from "lucide-vue-next";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="panel overflow-hidden">
|
||||
<div class="border-b border-slate-200 p-5">
|
||||
<h2 class="text-xl font-bold text-slate-950">{{ title }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="loading" class="p-6 text-sm text-slate-500">正在加载...</div>
|
||||
<div v-else-if="rows.length" class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column.key" class="px-5 py-3">{{ column.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tr v-for="(row, index) in rows" :key="index" class="bg-white">
|
||||
<td v-for="column in columns" :key="column.key" class="px-5 py-4 text-slate-700">
|
||||
{{ text(row, column.key) || "-" }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="p-5">
|
||||
<EmptyState :title="emptyTitle" :description="emptyDescription" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
rows: unknown[];
|
||||
loading: boolean;
|
||||
columns: Array<{ key: string; label: string }>;
|
||||
}>();
|
||||
|
||||
function text(row: unknown, key: string) {
|
||||
if (!row || typeof row !== "object") return "";
|
||||
const value = (row as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-50">
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 hidden w-72 border-r border-slate-200 bg-white lg:block"
|
||||
>
|
||||
<ConsoleSidebar />
|
||||
</aside>
|
||||
|
||||
<div v-if="sidebarOpen" class="fixed inset-0 z-50 bg-slate-950/40 lg:hidden" @click="sidebarOpen = false">
|
||||
<aside class="h-full w-72 bg-white" @click.stop>
|
||||
<ConsoleSidebar @navigate="sidebarOpen = false" />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="lg:pl-72">
|
||||
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/95 backdrop-blur">
|
||||
<div class="flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<button class="rounded-md border border-slate-200 p-2 lg:hidden" @click="sidebarOpen = true">
|
||||
<Menu class="h-5 w-5" />
|
||||
</button>
|
||||
<div class="hidden lg:block">
|
||||
<p class="text-sm text-slate-500">会员控制台</p>
|
||||
<h1 class="text-lg font-semibold text-slate-900">{{ pageTitle }}</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<RouterLink to="/" class="btn-secondary h-9 px-3">返回官网</RouterLink>
|
||||
<div class="flex items-center gap-3 rounded-md border border-slate-200 bg-white px-3 py-2">
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-brand-100 text-sm font-bold text-brand-700">
|
||||
{{ auth.displayName.slice(0, 1) }}
|
||||
</span>
|
||||
<div class="hidden text-sm sm:block">
|
||||
<p class="font-semibold text-slate-800">{{ auth.displayName }}</p>
|
||||
<p class="text-xs text-slate-500">ID {{ auth.user?.id || "-" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="rounded-md border border-slate-200 bg-white p-2 text-slate-600 hover:text-brand-700" @click="handleLogout">
|
||||
<LogOut class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-6 sm:px-6 lg:px-8">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { LogOut, Menu } from "lucide-vue-next";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import ConsoleSidebar from "@/components/ConsoleSidebar.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const sidebarOpen = ref(false);
|
||||
|
||||
const titleMap: Record<string, string> = {
|
||||
"console-dashboard": "控制台首页",
|
||||
"console-buy": "套餐购买",
|
||||
"console-orders": "我的订单",
|
||||
"console-wallet": "我的钱包",
|
||||
"console-static-assets": "静态代理",
|
||||
"console-dynamic-channels": "动态通道",
|
||||
"console-open-api": "开放 API",
|
||||
"console-verify": "实名认证",
|
||||
"console-profile": "账户资料",
|
||||
};
|
||||
|
||||
const pageTitle = computed(() => titleMap[String(route.name)] || "会员控制台");
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout();
|
||||
router.push("/login");
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-white">
|
||||
<header class="sticky top-0 z-40 border-b border-slate-100 bg-white/90 backdrop-blur">
|
||||
<div class="container-page flex h-16 items-center justify-between">
|
||||
<RouterLink to="/" class="flex items-center gap-3">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-md bg-brand-600 text-base font-bold text-white">云</span>
|
||||
<span class="text-lg font-bold text-ink">云启代理</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden items-center gap-8 text-sm font-medium text-slate-600 md:flex">
|
||||
<RouterLink class="hover:text-brand-700" to="/">首页</RouterLink>
|
||||
<RouterLink class="hover:text-brand-700" to="/pricing">产品套餐</RouterLink>
|
||||
<RouterLink class="hover:text-brand-700" to="/help">帮助中心</RouterLink>
|
||||
<a class="hover:text-brand-700" href="/console/dashboard">控制台</a>
|
||||
</nav>
|
||||
|
||||
<div class="hidden items-center gap-3 md:flex">
|
||||
<RouterLink to="/login" class="btn-secondary">登录</RouterLink>
|
||||
<RouterLink to="/register" class="btn-primary">免费测试</RouterLink>
|
||||
</div>
|
||||
|
||||
<button class="rounded-md border border-slate-200 p-2 md:hidden" @click="mobileOpen = !mobileOpen">
|
||||
<Menu class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileOpen" class="border-t border-slate-100 bg-white md:hidden">
|
||||
<div class="container-page grid gap-2 py-4 text-sm font-medium text-slate-700">
|
||||
<RouterLink to="/" @click="mobileOpen = false">首页</RouterLink>
|
||||
<RouterLink to="/pricing" @click="mobileOpen = false">产品套餐</RouterLink>
|
||||
<RouterLink to="/help" @click="mobileOpen = false">帮助中心</RouterLink>
|
||||
<RouterLink to="/login" @click="mobileOpen = false">登录</RouterLink>
|
||||
<RouterLink to="/register" class="text-brand-700" @click="mobileOpen = false">免费测试</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-slate-200 bg-slate-950 text-slate-300">
|
||||
<div class="container-page grid gap-8 py-10 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<div>
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-md bg-brand-500 text-base font-bold text-white">云</span>
|
||||
<span class="text-lg font-bold text-white">云启代理</span>
|
||||
</div>
|
||||
<p class="max-w-xl text-sm leading-7 text-slate-400">
|
||||
面向数据采集、业务监测、账号风控和企业自动化场景,提供稳定、可控、可审计的代理 IP 服务。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-3 text-sm font-semibold text-white">产品</p>
|
||||
<div class="grid gap-2 text-sm">
|
||||
<RouterLink to="/pricing">静态长效代理</RouterLink>
|
||||
<RouterLink to="/pricing">动态短效代理</RouterLink>
|
||||
<RouterLink to="/help">API 接入</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-3 text-sm font-semibold text-white">联系</p>
|
||||
<div class="grid gap-2 text-sm text-slate-400">
|
||||
<span>工作时间:7 x 24 小时</span>
|
||||
<span>客服邮箱:support@example.com</span>
|
||||
<span>服务热线:400-000-0000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-slate-800 py-4 text-center text-xs text-slate-500">
|
||||
Copyright 2026 云启代理. 仅提供合法合规的数据传输服务。
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Menu } from "lucide-vue-next";
|
||||
|
||||
const mobileOpen = ref(false);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./styles/index.css";
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount("#app");
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import MarketingLayout from "@/layouts/MarketingLayout.vue";
|
||||
import ConsoleLayout from "@/layouts/ConsoleLayout.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
component: MarketingLayout,
|
||||
children: [
|
||||
{ path: "", name: "home", component: () => import("@/views/marketing/HomeView.vue") },
|
||||
{ path: "pricing", name: "pricing", component: () => import("@/views/marketing/PricingView.vue") },
|
||||
{ path: "help", name: "help", component: () => import("@/views/marketing/HelpView.vue") },
|
||||
{ path: "login", name: "login", component: () => import("@/views/account/LoginView.vue") },
|
||||
{ path: "register", name: "register", component: () => import("@/views/account/RegisterView.vue") },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/console",
|
||||
component: ConsoleLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: "", redirect: "/console/dashboard" },
|
||||
{ path: "dashboard", name: "console-dashboard", component: () => import("@/views/console/DashboardView.vue") },
|
||||
{ path: "buy", name: "console-buy", component: () => import("@/views/console/BuyView.vue") },
|
||||
{ path: "orders", name: "console-orders", component: () => import("@/views/console/OrdersView.vue") },
|
||||
{ path: "wallet", name: "console-wallet", component: () => import("@/views/console/WalletView.vue") },
|
||||
{ path: "static-assets", name: "console-static-assets", component: () => import("@/views/console/StaticAssetsView.vue") },
|
||||
{ path: "dynamic-channels", name: "console-dynamic-channels", component: () => import("@/views/console/DynamicChannelsView.vue") },
|
||||
{ path: "open-api", name: "console-open-api", component: () => import("@/views/console/OpenApiView.vue") },
|
||||
{ path: "verify", name: "console-verify", component: () => import("@/views/console/VerifyView.vue") },
|
||||
{ path: "profile", name: "console-profile", component: () => import("@/views/console/ProfileView.vue") },
|
||||
],
|
||||
},
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
|
||||
if (auth.accessToken && !auth.user) {
|
||||
try {
|
||||
await auth.loadCurrentUser();
|
||||
} catch {
|
||||
auth.clearSession();
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isLoggedIn) {
|
||||
return { path: "/login", query: { redirect: to.fullPath } };
|
||||
}
|
||||
|
||||
if ((to.name === "login" || to.name === "register") && auth.isLoggedIn) {
|
||||
return { path: "/console/dashboard" };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { MemberAPI, type CurrentMember } from "@/api/member";
|
||||
|
||||
const ACCESS_TOKEN_KEY = "member_access_token";
|
||||
const REFRESH_TOKEN_KEY = "member_refresh_token";
|
||||
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
state: () => ({
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN_KEY) || "",
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN_KEY) || "",
|
||||
user: null as CurrentMember | null,
|
||||
loading: false,
|
||||
}),
|
||||
getters: {
|
||||
isLoggedIn: (state) => Boolean(state.accessToken),
|
||||
displayName: (state) =>
|
||||
state.user?.nickname || state.user?.username || state.user?.mobile || "会员用户",
|
||||
},
|
||||
actions: {
|
||||
setTokens(accessToken: string, refreshToken?: string) {
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken || "";
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
},
|
||||
clearSession() {
|
||||
this.accessToken = "";
|
||||
this.refreshToken = "";
|
||||
this.user = null;
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
},
|
||||
async login(payload: { loginType: string; account?: string; mobile?: string; email?: string; password?: string; code?: string }) {
|
||||
const token = await MemberAPI.login(payload);
|
||||
this.setTokens(token.accessToken, token.refreshToken);
|
||||
await this.loadCurrentUser();
|
||||
},
|
||||
async register(payload: { registerType?: string; username?: string; mobile?: string; email?: string; code?: string; password?: string; inviteCode?: string }) {
|
||||
const token = await MemberAPI.register(payload);
|
||||
this.setTokens(token.accessToken, token.refreshToken);
|
||||
await this.loadCurrentUser();
|
||||
},
|
||||
async loadCurrentUser() {
|
||||
if (!this.accessToken) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
this.user = await MemberAPI.me();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
if (this.accessToken) {
|
||||
await MemberAPI.logout();
|
||||
}
|
||||
} finally {
|
||||
this.clearSession();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color: #172033;
|
||||
background: #f7faff;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.container-page {
|
||||
width: min(1200px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex h-11 items-center justify-center gap-2 rounded-md bg-brand-600 px-5 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 shadow-sm transition hover:border-brand-200 hover:text-brand-700;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply h-11 w-full rounded-md border border-slate-200 bg-white px-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply rounded-lg border border-slate-200 bg-white shadow-sm;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@apply rounded-lg border border-slate-200 bg-white p-5 shadow-sm;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<section class="container-page grid min-h-[calc(100vh-320px)] items-center gap-10 py-14 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<div class="hidden rounded-lg bg-brand-600 p-10 text-white lg:block">
|
||||
<p class="text-sm font-semibold text-brand-100">会员登录</p>
|
||||
<h1 class="mt-3 text-3xl font-bold">进入控制台管理代理资源</h1>
|
||||
<p class="mt-4 text-sm leading-7 text-brand-50">查看余额、订单、静态代理、实名认证和开放 API 状态。</p>
|
||||
</div>
|
||||
<div class="panel mx-auto w-full max-w-md p-6">
|
||||
<h2 class="text-2xl font-bold text-slate-950">登录账户</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ loadingConfig ? "正在读取登录配置..." : activeMethodHint }}</p>
|
||||
|
||||
<div
|
||||
v-if="enabledMethods.length > 1"
|
||||
class="auth-tabs mt-6"
|
||||
:style="{ gridTemplateColumns: `repeat(${enabledMethods.length}, minmax(0, 1fr))` }"
|
||||
>
|
||||
<button
|
||||
v-for="method in enabledMethods"
|
||||
:key="method"
|
||||
type="button"
|
||||
class="auth-tab"
|
||||
:class="{ 'auth-tab--active': activeMethod === method }"
|
||||
@click="setActiveMethod(method)"
|
||||
>
|
||||
{{ methodLabel(method) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="mt-6 grid gap-4" @submit.prevent="submit">
|
||||
<label v-if="activeMethod === 'ACCOUNT'" class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
账号
|
||||
<input v-model="form.account" class="input-field" placeholder="请输入用户名/手机号/邮箱" />
|
||||
</label>
|
||||
<label v-if="activeMethod === 'MOBILE'" class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
手机号
|
||||
<input v-model="form.mobile" class="input-field" placeholder="请输入手机号" />
|
||||
</label>
|
||||
<label v-if="activeMethod === 'EMAIL'" class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
邮箱
|
||||
<input v-model="form.email" class="input-field" placeholder="请输入邮箱" />
|
||||
</label>
|
||||
|
||||
<label v-if="needsPassword" class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
密码
|
||||
<input v-model="form.password" class="input-field" placeholder="请输入密码" type="password" />
|
||||
</label>
|
||||
|
||||
<div v-if="needsCode" class="grid gap-3">
|
||||
<label class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
验证码
|
||||
<div class="grid grid-cols-[1fr_112px] gap-2">
|
||||
<input v-model="form.code" class="input-field" placeholder="请输入验证码" />
|
||||
<button type="button" class="btn-secondary h-11 px-3" :disabled="codeSending" @click="openCaptchaDialog">
|
||||
{{ codeSending ? "发送中" : "发送验证码" }}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ errorMessage }}</p>
|
||||
<button class="btn-primary w-full" :disabled="submitting || loadingConfig">
|
||||
{{ submitting ? "登录中..." : "登录" }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-5 text-center text-sm text-slate-500">
|
||||
还没有账户?
|
||||
<RouterLink to="/register" class="font-semibold text-brand-700">立即注册</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="captchaDialogVisible" class="captcha-modal" @click.self="closeCaptchaDialog">
|
||||
<div class="captcha-dialog">
|
||||
<h3 class="text-lg font-bold text-slate-950">图形验证码</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">输入图片中的验证码后发送{{ methodLabel(activeMethod) }}验证码。</p>
|
||||
<div class="mt-5 grid gap-3">
|
||||
<div class="captcha-row">
|
||||
<input v-model="form.captchaCode" class="input-field" placeholder="请输入图形验证码" />
|
||||
<button type="button" class="captcha-image" @click="loadCaptcha">
|
||||
<img v-if="captcha.captchaBase64" :src="captcha.captchaBase64" alt="验证码" />
|
||||
<span v-else>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="captchaErrorMessage" class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ captchaErrorMessage }}</p>
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-2 gap-3">
|
||||
<button type="button" class="btn-secondary" @click="closeCaptchaDialog">取消</button>
|
||||
<button type="button" class="btn-primary" :disabled="codeSending" @click="sendCode">
|
||||
{{ codeSending ? "发送中..." : "确定发送" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { MemberAPI, type CaptchaInfo, type MemberAuthPublicConfig } from "@/api/member";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
type AuthMethod = "ACCOUNT" | "MOBILE" | "EMAIL";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const submitting = ref(false);
|
||||
const loadingConfig = ref(false);
|
||||
const codeSending = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const captchaErrorMessage = ref("");
|
||||
const captchaDialogVisible = ref(false);
|
||||
const config = ref<MemberAuthPublicConfig>({});
|
||||
const activeMethod = ref<AuthMethod>("ACCOUNT");
|
||||
const captcha = reactive<CaptchaInfo>({});
|
||||
const form = reactive({
|
||||
account: "",
|
||||
mobile: "",
|
||||
email: "",
|
||||
password: "",
|
||||
code: "",
|
||||
captchaCode: "",
|
||||
});
|
||||
|
||||
const enabledMethods = computed<AuthMethod[]>(() => normalizeMethods(config.value.loginMethods, ["ACCOUNT", "MOBILE"]));
|
||||
const needsCode = computed(() =>
|
||||
(activeMethod.value === "MOBILE" && config.value.mobileVerificationEnabled) ||
|
||||
(activeMethod.value === "EMAIL" && config.value.emailVerificationEnabled)
|
||||
);
|
||||
const needsPassword = computed(() => !needsCode.value);
|
||||
const activeMethodHint = computed(() => {
|
||||
if (activeMethod.value === "ACCOUNT") return "使用账号和密码登录。";
|
||||
if (needsCode.value) return `使用${methodLabel(activeMethod.value)}验证码登录。`;
|
||||
return `使用${methodLabel(activeMethod.value)}和密码登录。`;
|
||||
});
|
||||
|
||||
watch(activeMethod, () => {
|
||||
errorMessage.value = "";
|
||||
captchaErrorMessage.value = "";
|
||||
form.code = "";
|
||||
form.captchaCode = "";
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loadingConfig.value = true;
|
||||
try {
|
||||
config.value = await MemberAPI.authConfig();
|
||||
activeMethod.value = enabledMethods.value[0] || "ACCOUNT";
|
||||
} catch {
|
||||
config.value = { loginMethods: ["ACCOUNT", "MOBILE"], registerMethods: ["MOBILE"] };
|
||||
} finally {
|
||||
loadingConfig.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveMethod(method: AuthMethod): void {
|
||||
activeMethod.value = method;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const payload = buildLoginPayload();
|
||||
if (!payload) return;
|
||||
submitting.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await auth.login(payload);
|
||||
router.push(String(route.query.redirect || "/console/dashboard"));
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "登录失败";
|
||||
if (needsCode.value) {
|
||||
loadCaptcha();
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildLoginPayload() {
|
||||
if (activeMethod.value === "ACCOUNT") {
|
||||
if (!form.account || !form.password) {
|
||||
errorMessage.value = "请输入账号和密码";
|
||||
return null;
|
||||
}
|
||||
return { loginType: "ACCOUNT", account: form.account, password: form.password };
|
||||
}
|
||||
if (activeMethod.value === "MOBILE") {
|
||||
if (!form.mobile) {
|
||||
errorMessage.value = "请输入手机号";
|
||||
return null;
|
||||
}
|
||||
if (needsCode.value) {
|
||||
if (!form.code) {
|
||||
errorMessage.value = "请输入验证码";
|
||||
return null;
|
||||
}
|
||||
return { loginType: "MOBILE", mobile: form.mobile, code: form.code };
|
||||
}
|
||||
if (!form.password) {
|
||||
errorMessage.value = "请输入密码";
|
||||
return null;
|
||||
}
|
||||
return { loginType: "MOBILE", mobile: form.mobile, password: form.password };
|
||||
}
|
||||
if (!form.email) {
|
||||
errorMessage.value = "请输入邮箱";
|
||||
return null;
|
||||
}
|
||||
if (needsCode.value) {
|
||||
if (!form.code) {
|
||||
errorMessage.value = "请输入验证码";
|
||||
return null;
|
||||
}
|
||||
return { loginType: "EMAIL", email: form.email, code: form.code };
|
||||
}
|
||||
if (!form.password) {
|
||||
errorMessage.value = "请输入密码";
|
||||
return null;
|
||||
}
|
||||
return { loginType: "EMAIL", email: form.email, password: form.password };
|
||||
}
|
||||
|
||||
async function loadCaptcha(): Promise<void> {
|
||||
Object.assign(captcha, await MemberAPI.captcha("LOGIN"));
|
||||
}
|
||||
|
||||
async function openCaptchaDialog(): Promise<void> {
|
||||
const target = activeMethod.value === "MOBILE" ? form.mobile : form.email;
|
||||
if (!target) {
|
||||
errorMessage.value = activeMethod.value === "MOBILE" ? "请先填写手机号" : "请先填写邮箱";
|
||||
return;
|
||||
}
|
||||
errorMessage.value = "";
|
||||
captchaErrorMessage.value = "";
|
||||
form.captchaCode = "";
|
||||
captchaDialogVisible.value = true;
|
||||
await loadCaptcha();
|
||||
}
|
||||
|
||||
function closeCaptchaDialog(): void {
|
||||
captchaDialogVisible.value = false;
|
||||
captchaErrorMessage.value = "";
|
||||
form.captchaCode = "";
|
||||
}
|
||||
|
||||
async function sendCode(): Promise<void> {
|
||||
const target = activeMethod.value === "MOBILE" ? form.mobile : form.email;
|
||||
if (!target || !captcha.captchaId || !form.captchaCode) {
|
||||
captchaErrorMessage.value = "请输入图形验证码";
|
||||
return;
|
||||
}
|
||||
codeSending.value = true;
|
||||
captchaErrorMessage.value = "";
|
||||
try {
|
||||
if (activeMethod.value === "MOBILE") {
|
||||
await MemberAPI.sendMobileCode({ mobile: form.mobile, scene: "LOGIN", captchaId: captcha.captchaId, captchaCode: form.captchaCode });
|
||||
} else {
|
||||
await MemberAPI.sendEmailCode({ email: form.email, scene: "LOGIN", captchaId: captcha.captchaId, captchaCode: form.captchaCode });
|
||||
}
|
||||
captchaDialogVisible.value = false;
|
||||
form.captchaCode = "";
|
||||
errorMessage.value = "验证码已发送,请注意查收";
|
||||
} catch (error) {
|
||||
captchaErrorMessage.value = error instanceof Error ? error.message : "发送验证码失败";
|
||||
await loadCaptcha();
|
||||
} finally {
|
||||
codeSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMethods(methods: string[] | undefined, fallback: AuthMethod[]): AuthMethod[] {
|
||||
const allowed = new Set<AuthMethod>(["ACCOUNT", "MOBILE", "EMAIL"]);
|
||||
const normalized = (methods || []).map((item) => String(item).toUpperCase()).filter((item): item is AuthMethod => allowed.has(item as AuthMethod));
|
||||
return normalized.length ? normalized : fallback;
|
||||
}
|
||||
|
||||
function methodLabel(method: AuthMethod): string {
|
||||
return method === "ACCOUNT" ? "账号" : method === "MOBILE" ? "手机号" : "邮箱";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-tabs {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
height: 42px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.auth-tab:hover {
|
||||
color: #1456b8;
|
||||
}
|
||||
|
||||
.auth-tab--active {
|
||||
background: #fff;
|
||||
color: #1456b8;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.captcha-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
}
|
||||
|
||||
.captcha-dialog {
|
||||
width: min(100%, 480px);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 160px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.captcha-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.captcha-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<section class="container-page py-14">
|
||||
<div class="panel mx-auto max-w-lg p-6">
|
||||
<h1 class="text-2xl font-bold text-slate-950">注册会员账户</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ loadingConfig ? "正在读取注册配置..." : activeMethodHint }}</p>
|
||||
|
||||
<div
|
||||
v-if="enabledMethods.length > 1"
|
||||
class="auth-tabs mt-6"
|
||||
:style="{ gridTemplateColumns: `repeat(${enabledMethods.length}, minmax(0, 1fr))` }"
|
||||
>
|
||||
<button
|
||||
v-for="method in enabledMethods"
|
||||
:key="method"
|
||||
type="button"
|
||||
class="auth-tab"
|
||||
:class="{ 'auth-tab--active': activeMethod === method }"
|
||||
@click="setActiveMethod(method)"
|
||||
>
|
||||
{{ methodLabel(method) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="mt-6 grid gap-4" @submit.prevent="submit">
|
||||
<label v-if="activeMethod === 'ACCOUNT'" class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
用户名
|
||||
<input v-model="form.username" class="input-field" placeholder="请输入用户名" />
|
||||
</label>
|
||||
<label v-if="activeMethod === 'MOBILE'" class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
手机号
|
||||
<input v-model="form.mobile" class="input-field" placeholder="请输入手机号" />
|
||||
</label>
|
||||
<label v-if="activeMethod === 'EMAIL'" class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
邮箱
|
||||
<input v-model="form.email" class="input-field" placeholder="请输入邮箱" />
|
||||
</label>
|
||||
|
||||
<div v-if="needsCode" class="grid gap-3">
|
||||
<label class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
验证码
|
||||
<div class="grid grid-cols-[1fr_112px] gap-2">
|
||||
<input v-model="form.code" class="input-field" placeholder="请输入验证码" />
|
||||
<button type="button" class="btn-secondary h-11 px-3" :disabled="codeSending" @click="openCaptchaDialog">
|
||||
{{ codeSending ? "发送中" : "发送验证码" }}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
密码
|
||||
<input v-model="form.password" class="input-field" placeholder="请设置登录密码" type="password" />
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
邀请码
|
||||
<input v-model="form.inviteCode" class="input-field" placeholder="可选" />
|
||||
</label>
|
||||
<p v-if="errorMessage" class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ errorMessage }}</p>
|
||||
<button class="btn-primary w-full" :disabled="submitting || loadingConfig">
|
||||
{{ submitting ? "注册中..." : "创建账户" }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-5 text-center text-sm text-slate-500">
|
||||
已有账户?
|
||||
<RouterLink to="/login" class="font-semibold text-brand-700">去登录</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="captchaDialogVisible" class="captcha-modal" @click.self="closeCaptchaDialog">
|
||||
<div class="captcha-dialog">
|
||||
<h3 class="text-lg font-bold text-slate-950">图形验证码</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">输入图片中的验证码后发送{{ methodLabel(activeMethod) }}验证码。</p>
|
||||
<div class="mt-5 grid gap-3">
|
||||
<div class="captcha-row">
|
||||
<input v-model="form.captchaCode" class="input-field" placeholder="请输入图形验证码" />
|
||||
<button type="button" class="captcha-image" @click="loadCaptcha">
|
||||
<img v-if="captcha.captchaBase64" :src="captcha.captchaBase64" alt="验证码" />
|
||||
<span v-else>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="captchaErrorMessage" class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ captchaErrorMessage }}</p>
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-2 gap-3">
|
||||
<button type="button" class="btn-secondary" @click="closeCaptchaDialog">取消</button>
|
||||
<button type="button" class="btn-primary" :disabled="codeSending" @click="sendCode">
|
||||
{{ codeSending ? "发送中..." : "确定发送" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { MemberAPI, type CaptchaInfo, type MemberAuthPublicConfig } from "@/api/member";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
type AuthMethod = "ACCOUNT" | "MOBILE" | "EMAIL";
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const submitting = ref(false);
|
||||
const loadingConfig = ref(false);
|
||||
const codeSending = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const captchaErrorMessage = ref("");
|
||||
const captchaDialogVisible = ref(false);
|
||||
const config = ref<MemberAuthPublicConfig>({});
|
||||
const activeMethod = ref<AuthMethod>("MOBILE");
|
||||
const captcha = reactive<CaptchaInfo>({});
|
||||
const form = reactive({
|
||||
mobile: "",
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
code: "",
|
||||
captchaCode: "",
|
||||
inviteCode: "",
|
||||
});
|
||||
|
||||
const enabledMethods = computed<AuthMethod[]>(() => normalizeMethods(config.value.registerMethods, ["MOBILE"]));
|
||||
const needsCode = computed(() =>
|
||||
(activeMethod.value === "MOBILE" && config.value.mobileVerificationEnabled) ||
|
||||
(activeMethod.value === "EMAIL" && config.value.emailVerificationEnabled)
|
||||
);
|
||||
const activeMethodHint = computed(() => {
|
||||
if (activeMethod.value === "ACCOUNT") return "使用用户名和密码注册。";
|
||||
if (needsCode.value) return `使用${methodLabel(activeMethod.value)}验证码注册。`;
|
||||
return `使用${methodLabel(activeMethod.value)}和密码注册。`;
|
||||
});
|
||||
|
||||
watch(activeMethod, () => {
|
||||
errorMessage.value = "";
|
||||
captchaErrorMessage.value = "";
|
||||
form.code = "";
|
||||
form.captchaCode = "";
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loadingConfig.value = true;
|
||||
try {
|
||||
config.value = await MemberAPI.authConfig();
|
||||
activeMethod.value = enabledMethods.value[0] || "MOBILE";
|
||||
} catch {
|
||||
config.value = { registerMethods: ["MOBILE"], loginMethods: ["ACCOUNT", "MOBILE"] };
|
||||
} finally {
|
||||
loadingConfig.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveMethod(method: AuthMethod): void {
|
||||
activeMethod.value = method;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const payload = buildRegisterPayload();
|
||||
if (!payload) return;
|
||||
submitting.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await auth.register(payload);
|
||||
router.push("/console/dashboard");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "注册失败";
|
||||
if (needsCode.value) {
|
||||
loadCaptcha();
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRegisterPayload() {
|
||||
if (!form.password) {
|
||||
errorMessage.value = "请输入密码";
|
||||
return null;
|
||||
}
|
||||
if (activeMethod.value === "ACCOUNT") {
|
||||
if (!form.username) {
|
||||
errorMessage.value = "请输入用户名";
|
||||
return null;
|
||||
}
|
||||
return { registerType: "ACCOUNT", username: form.username, password: form.password, inviteCode: form.inviteCode };
|
||||
}
|
||||
if (activeMethod.value === "MOBILE") {
|
||||
if (!form.mobile) {
|
||||
errorMessage.value = "请输入手机号";
|
||||
return null;
|
||||
}
|
||||
if (needsCode.value && !form.code) {
|
||||
errorMessage.value = "请输入验证码";
|
||||
return null;
|
||||
}
|
||||
return { registerType: "MOBILE", mobile: form.mobile, password: form.password, code: form.code, inviteCode: form.inviteCode };
|
||||
}
|
||||
if (!form.email) {
|
||||
errorMessage.value = "请输入邮箱";
|
||||
return null;
|
||||
}
|
||||
if (needsCode.value && !form.code) {
|
||||
errorMessage.value = "请输入验证码";
|
||||
return null;
|
||||
}
|
||||
return { registerType: "EMAIL", email: form.email, password: form.password, code: form.code, inviteCode: form.inviteCode };
|
||||
}
|
||||
|
||||
async function loadCaptcha(): Promise<void> {
|
||||
Object.assign(captcha, await MemberAPI.captcha("REGISTER"));
|
||||
}
|
||||
|
||||
async function openCaptchaDialog(): Promise<void> {
|
||||
const target = activeMethod.value === "MOBILE" ? form.mobile : form.email;
|
||||
if (!target) {
|
||||
errorMessage.value = activeMethod.value === "MOBILE" ? "请先填写手机号" : "请先填写邮箱";
|
||||
return;
|
||||
}
|
||||
errorMessage.value = "";
|
||||
captchaErrorMessage.value = "";
|
||||
form.captchaCode = "";
|
||||
captchaDialogVisible.value = true;
|
||||
await loadCaptcha();
|
||||
}
|
||||
|
||||
function closeCaptchaDialog(): void {
|
||||
captchaDialogVisible.value = false;
|
||||
captchaErrorMessage.value = "";
|
||||
form.captchaCode = "";
|
||||
}
|
||||
|
||||
async function sendCode(): Promise<void> {
|
||||
const target = activeMethod.value === "MOBILE" ? form.mobile : form.email;
|
||||
if (!target || !captcha.captchaId || !form.captchaCode) {
|
||||
captchaErrorMessage.value = "请输入图形验证码";
|
||||
return;
|
||||
}
|
||||
codeSending.value = true;
|
||||
captchaErrorMessage.value = "";
|
||||
try {
|
||||
if (activeMethod.value === "MOBILE") {
|
||||
await MemberAPI.sendMobileCode({ mobile: form.mobile, scene: "REGISTER", captchaId: captcha.captchaId, captchaCode: form.captchaCode });
|
||||
} else {
|
||||
await MemberAPI.sendEmailCode({ email: form.email, scene: "REGISTER", captchaId: captcha.captchaId, captchaCode: form.captchaCode });
|
||||
}
|
||||
captchaDialogVisible.value = false;
|
||||
form.captchaCode = "";
|
||||
errorMessage.value = "验证码已发送,请注意查收";
|
||||
} catch (error) {
|
||||
captchaErrorMessage.value = error instanceof Error ? error.message : "发送验证码失败";
|
||||
await loadCaptcha();
|
||||
} finally {
|
||||
codeSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMethods(methods: string[] | undefined, fallback: AuthMethod[]): AuthMethod[] {
|
||||
const allowed = new Set<AuthMethod>(["ACCOUNT", "MOBILE", "EMAIL"]);
|
||||
const normalized = (methods || []).map((item) => String(item).toUpperCase()).filter((item): item is AuthMethod => allowed.has(item as AuthMethod));
|
||||
return normalized.length ? normalized : fallback;
|
||||
}
|
||||
|
||||
function methodLabel(method: AuthMethod): string {
|
||||
return method === "ACCOUNT" ? "账号" : method === "MOBILE" ? "手机号" : "邮箱";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-tabs {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
height: 42px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.auth-tab:hover {
|
||||
color: #1456b8;
|
||||
}
|
||||
|
||||
.auth-tab--active {
|
||||
background: #fff;
|
||||
color: #1456b8;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.captcha-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
}
|
||||
|
||||
.captcha-dialog {
|
||||
width: min(100%, 480px);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 160px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.captcha-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.captcha-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<section>
|
||||
<h1 class="text-2xl font-bold text-slate-950">购买静态代理</h1>
|
||||
<p class="mt-3 text-sm leading-7 text-slate-600">选择商品、项目、省份、节点、数量和时长后,系统会创建订单并使用钱包余额支付开通。</p>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="panel p-6 text-sm text-slate-500">正在加载套餐...</div>
|
||||
<div v-else-if="staticPackages.length" class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="grid gap-5">
|
||||
<div class="panel p-5">
|
||||
<label class="text-sm font-semibold text-slate-700">商品</label>
|
||||
<select v-model="form.productId" class="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm" @change="handleProductChange">
|
||||
<option v-for="item in staticPackages" :key="item.productId" :value="item.productId">
|
||||
{{ item.productName || item.productCode || item.productId }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="panel p-5">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label v-if="currentPackage?.qiyunProjectRequired" class="block">
|
||||
<span class="text-sm font-semibold text-slate-700">项目</span>
|
||||
<select v-model="form.qiyunPid" class="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm" @change="handleProjectChange">
|
||||
<option value="">请选择项目</option>
|
||||
<option v-for="item in projectOptions" :key="item.id" :value="item.id">{{ item.value }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-slate-700">省份</span>
|
||||
<select v-model="form.qiyunAreaId" class="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm" @change="handleAreaChange">
|
||||
<option value="">全部省份</option>
|
||||
<option v-for="item in areaOptions" :key="item.id" :value="item.id">{{ item.value }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-base font-bold text-slate-950">节点</h3>
|
||||
<button class="btn-secondary" :disabled="nodeLoading || !form.productId" @click="loadNodes">
|
||||
{{ nodeLoading ? "刷新中..." : "刷新节点" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="currentPackage?.qiyunProjectRequired && !form.qiyunPid" class="mt-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-700">
|
||||
请先选择项目后再选择省份和节点。
|
||||
</p>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<button
|
||||
v-for="node in nodeOptions"
|
||||
:key="node.qiyunNodeId"
|
||||
class="rounded-lg border p-4 text-left transition"
|
||||
:class="node.qiyunNodeId === form.qiyunNodeId ? 'border-brand-500 bg-brand-50' : 'border-slate-200 bg-white hover:border-brand-300'"
|
||||
@click="selectNode(node)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-950">{{ node.qiyunNodeName || "-" }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ node.qiyunAreaName || "全部省份" }} / {{ node.priceType === "NODE" ? "特殊价" : "默认价" }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-bold text-slate-950">{{ money(node.basePrice) }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">库存 {{ node.availableQuantity ?? "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel h-fit p-5">
|
||||
<h3 class="text-base font-bold text-slate-950">订单配置</h3>
|
||||
<div class="mt-4 grid gap-4">
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-slate-700">购买时长</span>
|
||||
<select v-model="form.durationDays" class="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||
<option v-for="duration in currentDurations" :key="duration.durationDays" :value="duration.durationDays">
|
||||
{{ duration.durationDays }} 天
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-slate-700">购买数量</span>
|
||||
<input v-model.number="form.quantity" min="1" type="number" class="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<div class="rounded-lg bg-slate-50 p-4">
|
||||
<div class="flex justify-between text-sm text-slate-500">
|
||||
<span>预估金额</span>
|
||||
<strong class="text-slate-950">{{ estimateAmount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-primary" :disabled="submitting" @click="submitOrder">
|
||||
{{ submitting ? "提交中..." : "提交并支付" }}
|
||||
</button>
|
||||
<p v-if="errorMessage" class="rounded-lg bg-red-50 p-3 text-sm text-red-600">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="rounded-lg bg-emerald-50 p-3 text-sm text-emerald-700">{{ successMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else title="暂无可购买套餐" description="请在管理后台配置齐云供应商、静态商品、默认节点价格和时长后再购买。" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import { MemberAPI, type QiYunNodeOption, type StaticPackage } from "@/api/member";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const nodeLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const successMessage = ref("");
|
||||
const staticPackages = ref<StaticPackage[]>([]);
|
||||
const projectOptions = ref<Array<{ id?: string; value?: string }>>([]);
|
||||
const areaOptions = ref<Array<{ id?: string; value?: string }>>([]);
|
||||
const nodeOptions = ref<QiYunNodeOption[]>([]);
|
||||
|
||||
const form = reactive({
|
||||
productId: 0,
|
||||
qiyunPid: "",
|
||||
qiyunProjectName: "",
|
||||
qiyunAreaId: "",
|
||||
qiyunAreaName: "",
|
||||
qiyunNodeId: "",
|
||||
qiyunNodeName: "",
|
||||
durationDays: 0,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
const currentPackage = computed(() => staticPackages.value.find((item) => Number(item.productId) === Number(form.productId)));
|
||||
const currentDurations = computed(() => currentPackage.value?.durations || []);
|
||||
const selectedDuration = computed(() => currentDurations.value.find((item) => Number(item.durationDays) === Number(form.durationDays)));
|
||||
const selectedNode = computed(() => nodeOptions.value.find((item) => item.qiyunNodeId === form.qiyunNodeId));
|
||||
const estimateAmount = computed(() => {
|
||||
const basePrice = Number(selectedNode.value?.basePrice || currentPackage.value?.defaultNodePrice || 0);
|
||||
const multiplier = Number(selectedDuration.value?.multiplier || 1);
|
||||
const quantity = Math.max(Number(form.quantity || 1), 1);
|
||||
return money(basePrice * multiplier * quantity);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const catalog = await MemberAPI.memberCatalog();
|
||||
staticPackages.value = catalog.staticPackages || [];
|
||||
initSelection();
|
||||
await refreshSelectors();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "套餐加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function initSelection(): void {
|
||||
const firstPackage = staticPackages.value[0];
|
||||
form.productId = Number(firstPackage?.productId || 0);
|
||||
form.durationDays = Number(firstPackage?.durations?.[0]?.durationDays || 0);
|
||||
form.quantity = 1;
|
||||
}
|
||||
|
||||
async function handleProductChange(): Promise<void> {
|
||||
const pkg = currentPackage.value;
|
||||
form.durationDays = Number(pkg?.durations?.[0]?.durationDays || 0);
|
||||
form.qiyunPid = "";
|
||||
form.qiyunProjectName = "";
|
||||
form.qiyunAreaId = "";
|
||||
form.qiyunAreaName = "";
|
||||
form.qiyunNodeId = "";
|
||||
form.qiyunNodeName = "";
|
||||
await refreshSelectors();
|
||||
}
|
||||
|
||||
async function refreshSelectors(): Promise<void> {
|
||||
projectOptions.value = [];
|
||||
areaOptions.value = [];
|
||||
nodeOptions.value = [];
|
||||
const pkg = currentPackage.value;
|
||||
if (!pkg?.productId || !pkg.qiyunProductType) return;
|
||||
if (pkg.qiyunProjectRequired) {
|
||||
projectOptions.value = await MemberAPI.qiyunProjects(pkg.productId);
|
||||
if (!form.qiyunPid) return;
|
||||
}
|
||||
areaOptions.value = await MemberAPI.qiyunAreas(pkg.productId, form.qiyunPid || undefined);
|
||||
await loadNodes();
|
||||
}
|
||||
|
||||
async function handleProjectChange(): Promise<void> {
|
||||
const selected = projectOptions.value.find((item) => item.id === form.qiyunPid);
|
||||
form.qiyunProjectName = selected?.value || "";
|
||||
form.qiyunAreaId = "";
|
||||
form.qiyunAreaName = "";
|
||||
await refreshSelectors();
|
||||
}
|
||||
|
||||
async function handleAreaChange(): Promise<void> {
|
||||
const selected = areaOptions.value.find((item) => item.id === form.qiyunAreaId);
|
||||
form.qiyunAreaName = selected?.value || "";
|
||||
form.qiyunNodeId = "";
|
||||
form.qiyunNodeName = "";
|
||||
await loadNodes();
|
||||
}
|
||||
|
||||
async function loadNodes(): Promise<void> {
|
||||
if (!form.productId) return;
|
||||
if (currentPackage.value?.qiyunProjectRequired && !form.qiyunPid) {
|
||||
nodeOptions.value = [];
|
||||
selectNode(undefined);
|
||||
return;
|
||||
}
|
||||
nodeLoading.value = true;
|
||||
try {
|
||||
const rows = await MemberAPI.staticInventories(form.productId, {
|
||||
qiyunPid: form.qiyunPid || undefined,
|
||||
qiyunAreaId: form.qiyunAreaId || undefined,
|
||||
});
|
||||
nodeOptions.value = rows.filter((item) => {
|
||||
if (!form.qiyunAreaId) return true;
|
||||
return String(item.qiyunAreaId || "") === String(form.qiyunAreaId);
|
||||
});
|
||||
if (!nodeOptions.value.some((item) => item.qiyunNodeId === form.qiyunNodeId)) {
|
||||
const first = nodeOptions.value[0];
|
||||
selectNode(first);
|
||||
}
|
||||
} finally {
|
||||
nodeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectNode(node?: QiYunNodeOption): void {
|
||||
form.qiyunNodeId = node?.qiyunNodeId || "";
|
||||
form.qiyunNodeName = node?.qiyunNodeName || "";
|
||||
if (node?.qiyunAreaId) {
|
||||
form.qiyunAreaId = node.qiyunAreaId;
|
||||
form.qiyunAreaName = node.qiyunAreaName || "";
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOrder(): Promise<void> {
|
||||
errorMessage.value = "";
|
||||
successMessage.value = "";
|
||||
if (!form.productId || !form.durationDays || !form.quantity || !form.qiyunNodeId) {
|
||||
errorMessage.value = "请选择完整的商品、节点、时长和数量";
|
||||
return;
|
||||
}
|
||||
if (currentPackage.value?.qiyunProjectRequired && !form.qiyunPid) {
|
||||
errorMessage.value = "请选择项目";
|
||||
return;
|
||||
}
|
||||
if (selectedNode.value?.availableQuantity !== undefined && Number(selectedNode.value.availableQuantity) < Number(form.quantity)) {
|
||||
errorMessage.value = `当前节点库存不足,可用 ${selectedNode.value.availableQuantity}`;
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const submitResult = await MemberAPI.purchaseStaticPackage({
|
||||
productId: form.productId,
|
||||
durationDays: form.durationDays,
|
||||
qiyunPid: form.qiyunPid,
|
||||
qiyunProjectName: form.qiyunProjectName,
|
||||
qiyunAreaId: form.qiyunAreaId,
|
||||
qiyunAreaName: form.qiyunAreaName,
|
||||
qiyunNodeId: form.qiyunNodeId,
|
||||
qiyunNodeName: form.qiyunNodeName,
|
||||
quantity: form.quantity,
|
||||
items: [],
|
||||
});
|
||||
const orders = submitResult.orders || [];
|
||||
if (!orders.length || !orders[0].orderNo) throw new Error("订单创建成功但未返回订单号");
|
||||
const paidResults = [];
|
||||
for (const order of orders) {
|
||||
if (order.orderNo) paidResults.push(await MemberAPI.payOrder(order.orderNo, { paymentType: "BALANCE" }));
|
||||
}
|
||||
const firstResult = paidResults[0];
|
||||
successMessage.value = `订单已支付,当前开通状态:${firstResult?.openStatus || firstResult?.orderStatus || "处理中"}`;
|
||||
setTimeout(() => router.push("/console/static-assets"), 800);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "下单失败";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function money(value?: number): string {
|
||||
const amount = Number(value || 0);
|
||||
return `¥${amount.toFixed(2)}`;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="grid gap-6">
|
||||
<section class="rounded-lg bg-[linear-gradient(135deg,#1667d9,#0b2455)] p-6 text-white">
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr_auto]">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-brand-100">欢迎回来,{{ auth.displayName }}</p>
|
||||
<h2 class="mt-2 text-2xl font-bold">这里是你的代理资源控制台</h2>
|
||||
<p class="mt-3 max-w-2xl text-sm leading-7 text-brand-50">
|
||||
你可以在这里购买套餐、查看订单、管理静态代理白名单、生成动态代理出口,并配置开放 API。
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink to="/console/buy" class="inline-flex h-11 items-center justify-center rounded-md bg-white px-5 text-sm font-bold text-brand-700">
|
||||
购买套餐
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div v-for="item in stats" :key="item.label" class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-slate-500">{{ item.label }}</p>
|
||||
<component :is="item.icon" class="h-5 w-5 text-brand-600" />
|
||||
</div>
|
||||
<strong class="mt-3 block text-2xl font-bold text-slate-950">{{ item.value }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-2">
|
||||
<div class="panel p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="font-bold text-slate-950">最近订单</h3>
|
||||
<RouterLink to="/console/orders" class="text-sm font-semibold text-brand-700">查看全部</RouterLink>
|
||||
</div>
|
||||
<div v-if="recentOrders.length" class="grid gap-3">
|
||||
<div v-for="order in recentOrders" :key="getText(order, 'orderNo')" class="rounded-md border border-slate-200 p-3">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ getText(order, "orderNo") || "订单" }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ getText(order, "orderStatus") || getText(order, "status") || "等待处理" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else title="暂无订单" description="购买套餐后,订单会展示在这里。" />
|
||||
</div>
|
||||
|
||||
<div class="panel p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="font-bold text-slate-950">资源快捷入口</h3>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<RouterLink v-for="entry in shortcuts" :key="entry.path" :to="entry.path" class="rounded-md border border-slate-200 p-4 transition hover:border-brand-200 hover:bg-brand-50">
|
||||
<component :is="entry.icon" class="h-6 w-6 text-brand-600" />
|
||||
<p class="mt-3 text-sm font-semibold text-slate-900">{{ entry.title }}</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">{{ entry.desc }}</p>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { Boxes, Gauge, ReceiptText, ShieldCheck, Wallet } from "lucide-vue-next";
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import { MemberAPI, type WalletOverview } from "@/api/member";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const wallet = ref<WalletOverview>({});
|
||||
const recentOrders = ref<unknown[]>([]);
|
||||
const staticTotal = ref(0);
|
||||
const dynamicTotal = ref(0);
|
||||
|
||||
const stats = computed(() => [
|
||||
{ label: "账户余额", value: formatAmount(wallet.value.balance), icon: Wallet },
|
||||
{ label: "累计消费", value: formatAmount(wallet.value.totalConsumeAmount), icon: ReceiptText },
|
||||
{ label: "静态代理", value: `${staticTotal.value}`, icon: Boxes },
|
||||
{ label: "动态通道", value: `${dynamicTotal.value}`, icon: Gauge },
|
||||
]);
|
||||
|
||||
const shortcuts = [
|
||||
{ title: "静态代理", desc: "查看节点、续费、白名单和改密。", path: "/console/static-assets", icon: Boxes },
|
||||
{ title: "动态通道", desc: "查看流量、更新通道和生成出口。", path: "/console/dynamic-channels", icon: Gauge },
|
||||
{ title: "实名认证", desc: "提交或查看认证审核状态。", path: "/console/verify", icon: ShieldCheck },
|
||||
{ title: "我的钱包", desc: "查看余额、充值和资金流水。", path: "/console/wallet", icon: Wallet },
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
const [walletData, orderPage, staticPage, dynamicPage] = await Promise.allSettled([
|
||||
MemberAPI.wallet(),
|
||||
MemberAPI.orders({ pageNum: 1, pageSize: 5 }),
|
||||
MemberAPI.staticAssets({ pageNum: 1, pageSize: 1 }),
|
||||
MemberAPI.dynamicChannels({ pageNum: 1, pageSize: 1 }),
|
||||
]);
|
||||
|
||||
if (walletData.status === "fulfilled") wallet.value = walletData.value || {};
|
||||
if (orderPage.status === "fulfilled") recentOrders.value = orderPage.value.list || [];
|
||||
if (staticPage.status === "fulfilled") staticTotal.value = staticPage.value.total || 0;
|
||||
if (dynamicPage.status === "fulfilled") dynamicTotal.value = dynamicPage.value.total || 0;
|
||||
});
|
||||
|
||||
function formatAmount(value?: number) {
|
||||
return `¥${Number(value || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function getText(row: unknown, key: string) {
|
||||
if (!row || typeof row !== "object") return "";
|
||||
const value = (row as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<ResourceTable
|
||||
title="动态通道"
|
||||
description="查看动态通道、流量使用情况和出口生成状态。"
|
||||
empty-title="暂无动态通道"
|
||||
empty-description="购买动态流量套餐后,通道会展示在这里。"
|
||||
:rows="rows"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import ResourceTable from "@/components/ResourceTable.vue";
|
||||
import { MemberAPI } from "@/api/member";
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<unknown[]>([]);
|
||||
const columns = [
|
||||
{ key: "channelName", label: "通道名称" },
|
||||
{ key: "channelNo", label: "通道编号" },
|
||||
{ key: "status", label: "状态" },
|
||||
{ key: "expireTime", label: "到期时间" },
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await MemberAPI.dynamicChannels({ pageNum: 1, pageSize: 20 });
|
||||
rows.value = page.list || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="grid gap-6">
|
||||
<section class="panel p-5">
|
||||
<div class="grid gap-5 lg:grid-cols-[1fr_auto]">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-brand-700">开放 API</p>
|
||||
<h2 class="mt-2 text-xl font-bold text-slate-950">将代理能力接入你的系统</h2>
|
||||
<p class="mt-2 text-sm leading-7 text-slate-500">
|
||||
通过开放接口查询套餐、预览订单、创建订单、查询余额和接收回调。需要先提交申请,后台审核通过后发放凭证。
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="submitApply">提交申请</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="panel p-5">
|
||||
<h3 class="font-bold text-slate-950">申请状态</h3>
|
||||
<pre class="mt-4 whitespace-pre-wrap break-words rounded-md bg-slate-50 p-4 text-xs leading-6 text-slate-600">{{ currentText }}</pre>
|
||||
</div>
|
||||
<div class="panel p-5">
|
||||
<h3 class="font-bold text-slate-950">应用凭证</h3>
|
||||
<pre class="mt-4 whitespace-pre-wrap break-words rounded-md bg-slate-950 p-4 text-xs leading-6 text-brand-50">{{ credentialText }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel p-5">
|
||||
<h3 class="font-bold text-slate-950">接口入口</h3>
|
||||
<div class="mt-4 grid gap-3 text-sm">
|
||||
<code class="rounded-md bg-slate-50 p-3 text-slate-700">POST /api/v1/open/auth/token</code>
|
||||
<code class="rounded-md bg-slate-50 p-3 text-slate-700">GET /api/v1/open/package-center/catalog</code>
|
||||
<code class="rounded-md bg-slate-50 p-3 text-slate-700">GET /api/v1/open/package-center/static-inventory?productId=1&qiyunPid=项目ID&qiyunAreaId=省份ID</code>
|
||||
<code class="rounded-md bg-slate-50 p-3 text-slate-700">POST /api/v1/open/package-center/static-orders/preview</code>
|
||||
<code class="rounded-md bg-slate-50 p-3 text-slate-700">POST /api/v1/open/package-center/dynamic-orders</code>
|
||||
</div>
|
||||
<pre class="mt-4 overflow-x-auto rounded-md bg-slate-950 p-4 text-xs leading-6 text-brand-50">{{ staticOrderExample }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { MemberAPI } from "@/api/member";
|
||||
|
||||
const current = ref<unknown>(null);
|
||||
const credential = ref<unknown>(null);
|
||||
|
||||
const currentText = computed(() => current.value ? JSON.stringify(current.value, null, 2) : "暂无申请记录");
|
||||
const credentialText = computed(() => credential.value ? JSON.stringify(credential.value, null, 2) : "审核通过后显示应用凭证");
|
||||
const staticOrderExample = `{
|
||||
"productId": 1,
|
||||
"durationDays": 30,
|
||||
"qiyunPid": "项目ID,独享custom可不传",
|
||||
"qiyunProjectName": "项目名称",
|
||||
"qiyunAreaId": "省份ID,可选",
|
||||
"qiyunAreaName": "省份名称,可选",
|
||||
"qiyunNodeId": "节点ID",
|
||||
"qiyunNodeName": "节点名称",
|
||||
"quantity": 1
|
||||
}`;
|
||||
|
||||
onMounted(load);
|
||||
|
||||
async function load() {
|
||||
const [currentRes, credentialRes] = await Promise.allSettled([
|
||||
MemberAPI.openApiCurrent(),
|
||||
MemberAPI.openApiCredential(),
|
||||
]);
|
||||
if (currentRes.status === "fulfilled") current.value = currentRes.value;
|
||||
if (credentialRes.status === "fulfilled") credential.value = credentialRes.value;
|
||||
}
|
||||
|
||||
async function submitApply() {
|
||||
current.value = await MemberAPI.submitOpenApiApply({
|
||||
applyReason: "希望通过开放 API 接入代理套餐、订单和回调能力。",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="panel overflow-hidden">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 p-5">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-950">我的订单</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">查看套餐购买、支付、开通和补偿状态。</p>
|
||||
</div>
|
||||
<RouterLink to="/console/buy" class="btn-primary">购买套餐</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-6 text-sm text-slate-500">正在加载订单...</div>
|
||||
<div v-else-if="rows.length" class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-5 py-3">订单号</th>
|
||||
<th class="px-5 py-3">类型</th>
|
||||
<th class="px-5 py-3">金额</th>
|
||||
<th class="px-5 py-3">状态</th>
|
||||
<th class="px-5 py-3">创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tr v-for="row in rows" :key="text(row, 'orderNo')" class="bg-white">
|
||||
<td class="px-5 py-4 font-semibold text-slate-900">{{ text(row, "orderNo") || "-" }}</td>
|
||||
<td class="px-5 py-4 text-slate-600">{{ text(row, "productType") || text(row, "orderType") || "-" }}</td>
|
||||
<td class="px-5 py-4 text-slate-600">{{ amount(row, "payAmount") }}</td>
|
||||
<td class="px-5 py-4"><span class="rounded-full bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-700">{{ text(row, "orderStatus") || text(row, "status") || "-" }}</span></td>
|
||||
<td class="px-5 py-4 text-slate-500">{{ text(row, "createTime") || "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="p-5">
|
||||
<EmptyState title="暂无订单" description="购买套餐后,订单会展示在这里。" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import { MemberAPI } from "@/api/member";
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<unknown[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await MemberAPI.orders({ pageNum: 1, pageSize: 20 });
|
||||
rows.value = page.list || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function text(row: unknown, key: string) {
|
||||
if (!row || typeof row !== "object") return "";
|
||||
const value = (row as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function amount(row: unknown, key: string) {
|
||||
const value = Number(text(row, key));
|
||||
return Number.isFinite(value) ? `¥${value.toFixed(2)}` : "-";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<section class="panel p-5">
|
||||
<h2 class="text-xl font-bold text-slate-950">账户资料</h2>
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-lg bg-brand-100 text-2xl font-bold text-brand-700">
|
||||
{{ auth.displayName.slice(0, 1) }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-slate-950">{{ auth.displayName }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">会员 ID:{{ auth.user?.id || "-" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="mt-6 grid gap-3 text-sm">
|
||||
<div class="flex justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||
<dt class="text-slate-500">手机号</dt>
|
||||
<dd class="font-medium text-slate-800">{{ auth.user?.mobile || "未绑定" }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||
<dt class="text-slate-500">邮箱</dt>
|
||||
<dd class="font-medium text-slate-800">{{ auth.user?.email || "未绑定" }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between rounded-md bg-slate-50 px-3 py-2">
|
||||
<dt class="text-slate-500">认证状态</dt>
|
||||
<dd class="font-medium text-slate-800">{{ auth.user?.verifyStatus ?? "未提交" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel p-5">
|
||||
<h2 class="text-xl font-bold text-slate-950">编辑资料</h2>
|
||||
<form class="mt-6 grid gap-4" @submit.prevent="submit">
|
||||
<label class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
昵称
|
||||
<input v-model="form.nickname" class="input-field" placeholder="请输入昵称" />
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
邮箱
|
||||
<input v-model="form.email" class="input-field" placeholder="请输入邮箱" />
|
||||
</label>
|
||||
<p v-if="message" class="rounded-md bg-brand-50 px-3 py-2 text-sm text-brand-700">{{ message }}</p>
|
||||
<button class="btn-primary w-full" :disabled="submitting">{{ submitting ? "保存中..." : "保存资料" }}</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watchEffect } from "vue";
|
||||
import { MemberAPI } from "@/api/member";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const submitting = ref(false);
|
||||
const message = ref("");
|
||||
const form = reactive({ nickname: "", email: "" });
|
||||
|
||||
watchEffect(() => {
|
||||
form.nickname = auth.user?.nickname || "";
|
||||
form.email = auth.user?.email || "";
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
auth.user = await MemberAPI.updateProfile({ ...form });
|
||||
message.value = "资料已保存";
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : "保存失败";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="panel overflow-hidden">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 p-5">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-950">静态代理</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">查看已开通节点,复制代理地址、账号和密码。</p>
|
||||
</div>
|
||||
<button class="btn-secondary" :disabled="loading" @click="loadData">{{ loading ? "刷新中..." : "刷新" }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-6 text-sm text-slate-500">正在加载静态代理...</div>
|
||||
<div v-else-if="rows.length" class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-5 py-3">代理地址</th>
|
||||
<th class="px-5 py-3">账号</th>
|
||||
<th class="px-5 py-3">密码</th>
|
||||
<th class="px-5 py-3">协议</th>
|
||||
<th class="px-5 py-3">节点/地区</th>
|
||||
<th class="px-5 py-3">状态</th>
|
||||
<th class="px-5 py-3">到期时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tr v-for="row in rows" :key="text(row, 'id') || text(row, 'proxyAddress')" class="bg-white">
|
||||
<td class="px-5 py-4 font-semibold text-slate-900">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ proxyEndpoint(row) }}</span>
|
||||
<button class="text-xs font-semibold text-brand-700" @click="copy(proxyEndpoint(row))">复制</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-slate-700">
|
||||
<button class="font-mono text-sm text-slate-800" @click="copy(text(row, 'username'))">{{ text(row, "username") || "-" }}</button>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-slate-700">
|
||||
<button class="font-mono text-sm text-slate-800" @click="copy(text(row, 'password'))">{{ text(row, "password") || "-" }}</button>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-slate-600">{{ text(row, "protocols") || "-" }}</td>
|
||||
<td class="px-5 py-4 text-slate-600">{{ text(row, "cityName") || text(row, "countryName") || text(row, "countryCode") || "-" }}</td>
|
||||
<td class="px-5 py-4">
|
||||
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="Number(text(row, 'proxyStatus')) === 1 ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600'">
|
||||
{{ Number(text(row, "proxyStatus")) === 1 ? "可用" : "未知" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-slate-500">{{ text(row, "expiredAt") || text(row, "expireTime") || "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="p-5">
|
||||
<EmptyState title="暂无静态代理" description="购买静态长效套餐后,节点会展示在这里。" />
|
||||
</div>
|
||||
<div v-if="copyMessage" class="border-t border-slate-100 p-4 text-sm text-emerald-700">{{ copyMessage }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import { MemberAPI } from "@/api/member";
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<unknown[]>([]);
|
||||
const copyMessage = ref("");
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await MemberAPI.staticAssets({ pageNum: 1, pageSize: 50 });
|
||||
rows.value = page.list || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function proxyEndpoint(row: unknown): string {
|
||||
const address = text(row, "proxyAddress");
|
||||
const port = text(row, "port");
|
||||
return address && port ? `${address}:${port}` : address || "-";
|
||||
}
|
||||
|
||||
async function copy(value: string): Promise<void> {
|
||||
if (!value || value === "-") return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
copyMessage.value = "已复制";
|
||||
window.setTimeout(() => {
|
||||
copyMessage.value = "";
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function text(row: unknown, key: string): string {
|
||||
if (!row || typeof row !== "object") return "";
|
||||
const value = (row as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr_0.9fr]">
|
||||
<section class="panel p-5">
|
||||
<p class="text-sm font-semibold text-brand-700">实名认证</p>
|
||||
<h2 class="mt-2 text-xl font-bold text-slate-950">提交认证资料</h2>
|
||||
<p class="mt-2 text-sm leading-7 text-slate-500">
|
||||
部分操作可能要求先完成认证,例如下单、白名单新增或动态出口生成。
|
||||
</p>
|
||||
|
||||
<form class="mt-6 grid gap-4" @submit.prevent="submit">
|
||||
<label class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
真实姓名
|
||||
<input v-model="form.realName" class="input-field" placeholder="请输入真实姓名" />
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
证件号码
|
||||
<input v-model="form.idCardNo" class="input-field" placeholder="请输入证件号码" />
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm font-medium text-slate-700">
|
||||
用途说明
|
||||
<textarea v-model="form.purpose" class="min-h-28 rounded-md border border-slate-200 p-3 text-sm outline-none focus:border-brand-500 focus:ring-4 focus:ring-brand-100" placeholder="请说明业务用途"></textarea>
|
||||
</label>
|
||||
<p v-if="message" class="rounded-md bg-brand-50 px-3 py-2 text-sm text-brand-700">{{ message }}</p>
|
||||
<button class="btn-primary w-full" :disabled="submitting">{{ submitting ? "提交中..." : "提交认证" }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel p-5">
|
||||
<h3 class="text-lg font-bold text-slate-950">当前认证状态</h3>
|
||||
<div class="mt-5 rounded-lg bg-slate-50 p-4">
|
||||
<pre class="whitespace-pre-wrap break-words text-xs leading-6 text-slate-600">{{ currentText }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { MemberAPI } from "@/api/member";
|
||||
|
||||
const current = ref<unknown>(null);
|
||||
const message = ref("");
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
realName: "",
|
||||
idCardNo: "",
|
||||
purpose: "",
|
||||
});
|
||||
|
||||
const currentText = computed(() => current.value ? JSON.stringify(current.value, null, 2) : "暂未提交认证资料");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
current.value = await MemberAPI.verifyCurrent();
|
||||
} catch {
|
||||
current.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!form.realName || !form.idCardNo) {
|
||||
message.value = "请填写姓名和证件号码";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
current.value = await MemberAPI.submitVerify({ ...form });
|
||||
message.value = "认证资料已提交,请等待审核";
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : "提交失败";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="grid gap-6">
|
||||
<section class="grid gap-4 md:grid-cols-4">
|
||||
<div v-for="item in metrics" :key="item.label" class="stat-card">
|
||||
<p class="text-sm text-slate-500">{{ item.label }}</p>
|
||||
<strong class="mt-3 block text-2xl font-bold text-slate-950">{{ item.value }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-950">资金流水</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">展示充值、消费和退款记录。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="flows.length" class="grid gap-3">
|
||||
<div v-for="flow in flows" :key="text(flow, 'id')" class="rounded-md border border-slate-200 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ text(flow, "flowType") || "流水" }}</p>
|
||||
<p class="text-sm font-bold text-slate-950">{{ amount(flow, "amount") }}</p>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ text(flow, "remark") || text(flow, "createTime") || "-" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else title="暂无流水" description="充值、购买或退款后会产生资金流水。" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import { MemberAPI, type WalletOverview } from "@/api/member";
|
||||
|
||||
const wallet = ref<WalletOverview>({});
|
||||
const flows = ref<unknown[]>([]);
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: "可用余额", value: format(wallet.value.balance) },
|
||||
{ label: "冻结余额", value: format(wallet.value.frozenBalance) },
|
||||
{ label: "累计充值", value: format(wallet.value.totalRechargeAmount) },
|
||||
{ label: "累计消费", value: format(wallet.value.totalConsumeAmount) },
|
||||
]);
|
||||
|
||||
onMounted(async () => {
|
||||
const [walletRes, flowRes] = await Promise.allSettled([
|
||||
MemberAPI.wallet(),
|
||||
MemberAPI.walletFlows({ pageNum: 1, pageSize: 10 }),
|
||||
]);
|
||||
if (walletRes.status === "fulfilled") wallet.value = walletRes.value || {};
|
||||
if (flowRes.status === "fulfilled") flows.value = flowRes.value.list || [];
|
||||
});
|
||||
|
||||
function format(value?: number) {
|
||||
return `¥${Number(value || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function text(row: unknown, key: string) {
|
||||
if (!row || typeof row !== "object") return "";
|
||||
const value = (row as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function amount(row: unknown, key: string) {
|
||||
const value = Number(text(row, key));
|
||||
return Number.isFinite(value) ? `¥${value.toFixed(2)}` : "-";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<section class="container-page py-14">
|
||||
<div class="mb-10 max-w-3xl">
|
||||
<p class="text-sm font-semibold text-brand-700">帮助中心</p>
|
||||
<h1 class="mt-2 text-4xl font-bold text-slate-950">常见问题与接入说明</h1>
|
||||
<p class="mt-4 text-base leading-8 text-slate-600">这里先放首版帮助内容,后续可以接后台公告或帮助文档管理。</p>
|
||||
</div>
|
||||
<div class="grid gap-4">
|
||||
<details v-for="item in faqs" :key="item.q" class="panel p-5">
|
||||
<summary class="cursor-pointer text-base font-semibold text-slate-900">{{ item.q }}</summary>
|
||||
<p class="mt-3 text-sm leading-7 text-slate-600">{{ item.a }}</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const faqs = [
|
||||
{ q: "如何开始测试?", a: "注册会员账户后进入控制台,在套餐购买页选择静态或动态产品,完成支付后即可在对应资源页查看。" },
|
||||
{ q: "静态代理和动态代理有什么区别?", a: "静态代理更适合固定节点和长期会话;动态代理更适合批量调度和短时任务。" },
|
||||
{ q: "是否支持 API 接入?", a: "支持。会员可提交开放 API 申请,审核通过后获得应用凭证,并通过开放接口完成余额、套餐和订单操作。" },
|
||||
{ q: "为什么要设置白名单?", a: "白名单用于限制代理资源的调用来源,避免账号泄露后被非授权 IP 使用。" },
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="relative overflow-hidden bg-[linear-gradient(180deg,#f3f8ff_0%,#ffffff_78%)]">
|
||||
<div class="container-page grid min-h-[620px] items-center gap-10 py-14 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div>
|
||||
<div class="mb-5 inline-flex items-center gap-2 rounded-full border border-brand-100 bg-white px-4 py-2 text-sm font-semibold text-brand-700 shadow-sm">
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
企业级代理 IP 云服务
|
||||
</div>
|
||||
<h1 class="max-w-3xl text-4xl font-bold leading-tight text-slate-950 sm:text-5xl">
|
||||
稳定、海量、可控的代理 IP 服务平台
|
||||
</h1>
|
||||
<p class="mt-5 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
覆盖静态长效、动态短效、开放 API 和企业定制场景,帮助数据采集、业务监测和自动化任务稳定运行。
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<RouterLink to="/register" class="btn-primary">
|
||||
立即免费测试
|
||||
<ArrowRight class="h-4 w-4" />
|
||||
</RouterLink>
|
||||
<RouterLink to="/pricing" class="btn-secondary">查看产品套餐</RouterLink>
|
||||
</div>
|
||||
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div v-for="item in stats" :key="item.label" class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<strong class="text-2xl font-bold text-slate-950">{{ item.value }}</strong>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ item.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute -left-6 top-8 hidden rounded-lg border border-slate-200 bg-white p-4 shadow-soft lg:block">
|
||||
<p class="text-xs font-semibold text-slate-500">实时可用率</p>
|
||||
<p class="mt-1 text-2xl font-bold text-brand-700">99.2%</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">资源分布</p>
|
||||
<h2 class="text-xl font-bold text-slate-950">全国节点池</h2>
|
||||
</div>
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">运行中</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div v-for="city in cityCards" :key="city.name" class="rounded-md bg-slate-50 p-4">
|
||||
<p class="text-sm font-semibold text-slate-800">{{ city.name }}</p>
|
||||
<p class="mt-2 text-2xl font-bold text-brand-700">{{ city.count }}</p>
|
||||
<p class="text-xs text-slate-500">可用线路</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 rounded-md bg-slate-950 p-4 text-sm text-slate-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>API 提取示例</span>
|
||||
<span class="text-emerald-300">200 OK</span>
|
||||
</div>
|
||||
<code class="mt-3 block overflow-x-auto text-xs text-brand-100">
|
||||
GET /api/v1/open/package-center/static-inventory
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container-page py-16">
|
||||
<div class="mb-10 text-center">
|
||||
<p class="text-sm font-semibold text-brand-700">产品能力</p>
|
||||
<h2 class="mt-2 text-3xl font-bold text-slate-950">覆盖主流代理业务场景</h2>
|
||||
</div>
|
||||
<div class="grid gap-5 md:grid-cols-3">
|
||||
<div v-for="product in products" :key="product.title" class="panel p-6">
|
||||
<component :is="product.icon" class="h-9 w-9 text-brand-600" />
|
||||
<h3 class="mt-5 text-lg font-bold text-slate-950">{{ product.title }}</h3>
|
||||
<p class="mt-3 text-sm leading-7 text-slate-600">{{ product.desc }}</p>
|
||||
<RouterLink to="/pricing" class="mt-5 inline-flex text-sm font-semibold text-brand-700">了解更多</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-slate-50 py-16">
|
||||
<div class="container-page grid gap-8 lg:grid-cols-[0.85fr_1.15fr]">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-brand-700">应用场景</p>
|
||||
<h2 class="mt-2 text-3xl font-bold text-slate-950">让代理资源接入业务流程</h2>
|
||||
<p class="mt-4 text-base leading-8 text-slate-600">
|
||||
从小规模测试到企业级调用,平台提供套餐购买、余额支付、API 接入、白名单、订单和日志能力。
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div v-for="scene in scenes" :key="scene.title" class="rounded-lg bg-white p-5 shadow-sm">
|
||||
<h3 class="font-bold text-slate-900">{{ scene.title }}</h3>
|
||||
<p class="mt-2 text-sm leading-7 text-slate-600">{{ scene.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container-page py-16">
|
||||
<div class="rounded-lg bg-brand-600 p-8 text-white md:p-10">
|
||||
<div class="grid items-center gap-6 md:grid-cols-[1fr_auto]">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">注册账户,开始测试代理 IP 服务</h2>
|
||||
<p class="mt-3 text-sm leading-7 text-brand-50">支持静态代理、动态通道、余额钱包和开放 API,适合先测试再批量开通。</p>
|
||||
</div>
|
||||
<RouterLink to="/register" class="inline-flex h-11 items-center justify-center rounded-md bg-white px-5 text-sm font-bold text-brand-700">
|
||||
创建免费账户
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, Boxes, Globe2, Network, ShieldCheck } from "lucide-vue-next";
|
||||
|
||||
const stats = [
|
||||
{ value: "300+", label: "地区覆盖" },
|
||||
{ value: "500万+", label: "日均资源" },
|
||||
{ value: "7x24", label: "服务支持" },
|
||||
{ value: "API", label: "自动接入" },
|
||||
];
|
||||
|
||||
const cityCards = [
|
||||
{ name: "华东", count: "96" },
|
||||
{ name: "华南", count: "78" },
|
||||
{ name: "华北", count: "64" },
|
||||
{ name: "西南", count: "42" },
|
||||
{ name: "华中", count: "51" },
|
||||
{ name: "东北", count: "33" },
|
||||
];
|
||||
|
||||
const products = [
|
||||
{ title: "静态长效代理", desc: "适合稳定会话、账号运营、固定地区业务和长期任务,支持续费、改密和白名单。", icon: Globe2 },
|
||||
{ title: "动态短效代理", desc: "适合高频任务和批量调度,按流量套餐开通通道,支持生成代理出口。", icon: Network },
|
||||
{ title: "开放 API 接入", desc: "面向系统集成和分销场景,支持令牌、账户余额、订单预览、回调和沙盒调试。", icon: Boxes },
|
||||
];
|
||||
|
||||
const scenes = [
|
||||
{ title: "数据采集", desc: "分散请求来源,降低单点访问压力,适合公开数据采集和监测任务。" },
|
||||
{ title: "电商监测", desc: "按地区调度代理资源,支持价格、库存和竞品信息的自动化监测。" },
|
||||
{ title: "业务自动化", desc: "通过 API 下单、查询、回调,把代理资源接入已有系统流程。" },
|
||||
{ title: "隐私与风控", desc: "隐藏真实出口地址,结合白名单和操作日志提升访问控制能力。" },
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<section class="container-page py-14">
|
||||
<div class="mb-10 max-w-3xl">
|
||||
<p class="text-sm font-semibold text-brand-700">产品套餐</p>
|
||||
<h1 class="mt-2 text-4xl font-bold text-slate-950">按业务场景选择代理资源</h1>
|
||||
<p class="mt-4 text-base leading-8 text-slate-600">当前页面先展示推荐套餐,登录后可在控制台查看实时价格、库存和下单结果。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 lg:grid-cols-3">
|
||||
<div v-for="plan in plans" :key="plan.title" class="panel p-6" :class="{ 'border-brand-200 ring-4 ring-brand-50': plan.hot }">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-slate-950">{{ plan.title }}</h2>
|
||||
<span v-if="plan.hot" class="rounded-full bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-700">推荐</span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-7 text-slate-600">{{ plan.desc }}</p>
|
||||
<div class="mt-6">
|
||||
<span class="text-3xl font-bold text-slate-950">{{ plan.price }}</span>
|
||||
<span class="text-sm text-slate-500">{{ plan.unit }}</span>
|
||||
</div>
|
||||
<ul class="mt-6 space-y-3 text-sm text-slate-600">
|
||||
<li v-for="feature in plan.features" :key="feature" class="flex gap-2">
|
||||
<CheckCircle2 class="mt-0.5 h-4 w-4 flex-none text-emerald-600" />
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<RouterLink to="/register" class="btn-primary mt-7 w-full">立即开通测试</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2 } from "lucide-vue-next";
|
||||
|
||||
const plans = [
|
||||
{
|
||||
title: "开放代理",
|
||||
desc: "适合轻量任务和基础测试,成本低,上手快。",
|
||||
price: "按量",
|
||||
unit: " / 灵活配置",
|
||||
features: ["HTTP 协议支持", "基础地区筛选", "API 获取", "适合测试业务"],
|
||||
},
|
||||
{
|
||||
title: "动态代理",
|
||||
desc: "适合批量采集、短时任务和高并发场景。",
|
||||
price: "流量",
|
||||
unit: " / 套餐售卖",
|
||||
hot: true,
|
||||
features: ["动态通道开通", "流量用量查询", "出口生成", "适合自动化任务"],
|
||||
},
|
||||
{
|
||||
title: "静态长效",
|
||||
desc: "适合稳定会话和长期运行任务。",
|
||||
price: "周期",
|
||||
unit: " / 按地区定价",
|
||||
features: ["固定节点资源", "白名单管理", "续费和改密", "适合账号类业务"],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{vue,ts}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#1d7afc",
|
||||
600: "#1667d9",
|
||||
700: "#1456b8",
|
||||
800: "#1e40af",
|
||||
900: "#0b2455"
|
||||
},
|
||||
ink: "#172033"
|
||||
},
|
||||
boxShadow: {
|
||||
soft: "0 18px 50px rgba(15, 35, 80, 0.10)"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: Number(env.VITE_APP_PORT || 5174),
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: env.VITE_API_TARGET || "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user