第一次上传

This commit is contained in:
xxk
2026-06-11 10:31:24 +08:00
commit cfef094568
1523 changed files with 210650 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
VITE_APP_PORT=5174
VITE_API_TARGET=http://localhost:8000
+60
View File
@@ -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 安装依赖。
+14
View File
@@ -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>
+2934
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+4
View File
@@ -0,0 +1,4 @@
<template>
<RouterView />
</template>
+60
View File
@@ -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;
+231
View File
@@ -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>
+19
View File
@@ -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>
+80
View File
@@ -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>
+8
View File
@@ -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");
+63
View File
@@ -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;
+64
View File
@@ -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();
}
},
},
});
+58
View File
@@ -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;
}
+364
View File
@@ -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>
+292
View File
@@ -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&amp;qiyunPid=项目ID&amp;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>
+146
View File
@@ -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>
+8
View File
@@ -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;
}
+27
View File
@@ -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: []
};
+23
View File
@@ -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": []
}
+26
View File
@@ -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,
},
},
},
};
});