新增:用户信息SDK接入
This commit is contained in:
parent
2306b34e00
commit
d1351345b0
@ -9,7 +9,8 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求
|
|||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- 统一 BASE_URL:默认 `https://api.xtrader.vip`,可通过环境变量 `VITE_API_BASE_URL` 覆盖
|
- 统一 BASE_URL:默认 `https://api.xtrader.vip`,可通过环境变量 `VITE_API_BASE_URL` 覆盖
|
||||||
- CLOB WebSocket URL:`getClobWsUrl()` 返回与 REST API 同源的 `ws(s)://host/api/clob/ws`
|
- CLOB WebSocket URL:`getClobWsUrl()` 返回与 REST API 同源的 `ws(s)://host/clob/ws`
|
||||||
|
- User WebSocket URL:`getUserWsUrl()` 返回 `ws(s)://host/clob/ws/user`(订单/持仓/余额推送)
|
||||||
- GET 请求:支持 query 参数,自动序列化
|
- GET 请求:支持 query 参数,自动序列化
|
||||||
- POST 请求:支持 JSON body
|
- POST 请求:支持 JSON body
|
||||||
- 自定义 headers:通过 `RequestConfig.headers` 传入
|
- 自定义 headers:通过 `RequestConfig.headers` 传入
|
||||||
@ -17,10 +18,12 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求
|
|||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { get, post, getClobWsUrl } from '@/api/request'
|
import { get, post, getClobWsUrl, getUserWsUrl } from '@/api/request'
|
||||||
|
|
||||||
// CLOB WebSocket URL(与 REST 同源)
|
// CLOB WebSocket URL(与 REST 同源)
|
||||||
const wsUrl = getClobWsUrl() // e.g. wss://api.xtrader.vip/api/clob/ws
|
const clobWsUrl = getClobWsUrl()
|
||||||
|
// User WebSocket URL(订单/持仓/余额推送,需带 token 参数)
|
||||||
|
const userWsUrl = getUserWsUrl()
|
||||||
|
|
||||||
// GET 请求
|
// GET 请求
|
||||||
const data = await get<MyResponse>('/path', { page: 1, keyword: 'x' })
|
const data = await get<MyResponse>('/path', { page: 1, keyword: 'x' })
|
||||||
|
|||||||
@ -10,12 +10,12 @@
|
|||||||
|
|
||||||
- `token`、`user`:登录凭证与用户信息
|
- `token`、`user`:登录凭证与用户信息
|
||||||
- `isLoggedIn`、`avatarUrl`:派生状态
|
- `isLoggedIn`、`avatarUrl`:派生状态
|
||||||
- `balance`:USDC 余额显示(如 "0.00")
|
- `balance`:USDC 余额显示(如 "0.00"),支持 **UserSocket** 实时推送更新
|
||||||
- `setUser`:设置登录数据并持久化
|
- `setUser`:设置登录数据并持久化,登录成功后自动连接 UserSocket
|
||||||
- `logout`:清空并清除持久化
|
- `logout`:清空并断开 UserSocket
|
||||||
- `getAuthHeaders`:返回 `{ 'x-token', 'x-user-id' }`,未登录时返回 `undefined`
|
- `getAuthHeaders`:返回 `{ 'x-token', 'x-user-id' }`,未登录时返回 `undefined`
|
||||||
- `fetchUserInfo`、`fetchUsdcBalance`:拉取并更新用户信息与余额
|
- `fetchUserInfo`、`fetchUsdcBalance`:拉取并更新用户信息与余额;`fetchUserInfo` 兼容多种 API 字段名
|
||||||
- `fetchUserInfo` 兼容多种 API 字段名:`userName`/`username`、`nickName`/`nickname`、`headerImg`/`avatar`/`avatarUrl`;支持 `data` 直接为用户对象或 `data.user` 嵌套结构
|
- `connectUserSocket`、`disconnectUserSocket`:连接/断开 `sdk/userSocket` 的 UserSdk,用于订单/持仓/余额实时推送
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
@ -39,9 +39,9 @@ await userStore.fetchUsdcBalance()
|
|||||||
|
|
||||||
## 扩展方式
|
## 扩展方式
|
||||||
|
|
||||||
1. **多端登录**:扩展 `setUser` 支持多设备 token 管理
|
1. **订单/持仓推送**:在 `connectUserSocket` 中注册 `onOrderUpdate`、`onPositionUpdate`,触发 Wallet 刷新
|
||||||
2. **Token 刷新**:在 `getAuthHeaders` 或请求拦截器中加入 refresh 逻辑
|
2. **多端登录**:扩展 `setUser` 支持多设备 token 管理
|
||||||
3. **登出回调**:在 `logout` 中增加清理逻辑(如取消订阅、重置其他 store)
|
3. **Token 刷新**:在 `getAuthHeaders` 或请求拦截器中加入 refresh 逻辑
|
||||||
|
|
||||||
## 登录后刷新用户信息
|
## 登录后刷新用户信息
|
||||||
|
|
||||||
|
|||||||
101
sdk/testUser.ts
Normal file
101
sdk/testUser.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { UserSdk, UserMsg, OrderData, PositionData, BalanceData } from './userSocket';
|
||||||
|
|
||||||
|
// Node 环境:注入 WebSocket(userSocket 使用全局 WebSocket)
|
||||||
|
(global as any).WebSocket = WebSocket;
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
const CONFIG = {
|
||||||
|
baseURL: 'http://localhost:8888', // API 基础地址
|
||||||
|
wsURL: 'ws://localhost:8888/clob/ws/user', // WebSocket 地址
|
||||||
|
};
|
||||||
|
|
||||||
|
// 模拟登录获取 Token
|
||||||
|
async function loginUser(index: number = 1): Promise<string | null> {
|
||||||
|
const hexIndex = index.toString(16).padStart(36, '0');
|
||||||
|
const walletAddress = `0x0000${hexIndex}`; // 测试用钱包地址
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
walletAddress: walletAddress,
|
||||||
|
nonce: "test_nonce",
|
||||||
|
signature: "test_signature",
|
||||||
|
message: "test_message"
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`正在登录用户 (地址: ${walletAddress})...`);
|
||||||
|
const response = await axios.post(`${CONFIG.baseURL}/base/walletLogin`, payload);
|
||||||
|
|
||||||
|
if (response.data.code === 0 && response.data.data) {
|
||||||
|
console.log(`✅ 登录成功! 用户ID: ${response.data.data.user.ID}`);
|
||||||
|
return response.data.data.token;
|
||||||
|
} else {
|
||||||
|
console.error(`❌ 登录失败:`, response.data.msg);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ 登录请求错误:`, error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 1. 获取 Token
|
||||||
|
const token = await loginUser(1);
|
||||||
|
if (!token) {
|
||||||
|
console.error("无法获取 Token,退出测试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 初始化 SDK
|
||||||
|
console.log("初始化 User SDK...");
|
||||||
|
const sdk = new UserSdk({
|
||||||
|
url: CONFIG.wsURL,
|
||||||
|
token: token,
|
||||||
|
autoReconnect: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 注册事件监听
|
||||||
|
sdk.onConnect(() => {
|
||||||
|
console.log("✅ WebSocket 连接已建立");
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.onDisconnect((event) => {
|
||||||
|
console.log(`⚠️ WebSocket 连接断开 (Code: ${event.code})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.onError((error) => {
|
||||||
|
console.error("❌ WebSocket 错误:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.onWelcome((msg) => {
|
||||||
|
console.log("👋 收到 Welcome 消息:", msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.onOrderUpdate((data: OrderData) => {
|
||||||
|
console.log("\n📦 [订单更新] Received Order Update:");
|
||||||
|
console.log(` ID: ${data.ID}, Status: ${data.status}, Side: ${data.side}, Price: ${data.price}, Matched: ${data.sizeMatched}/${data.originalSize}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.onPositionUpdate((data: PositionData) => {
|
||||||
|
console.log("\n📊 [持仓更新] Received Position Update:");
|
||||||
|
console.log(` Market: ${data.marketID}, Token: ${data.tokenId}, Side: ${data.side}, Size: ${data.size}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.onBalanceUpdate((data: BalanceData) => {
|
||||||
|
console.log("\n💰 [余额更新] Received Balance Update:");
|
||||||
|
console.log(` Token: ${data.token_id || data.tokenType}, Amount: ${data.amount}, Available: ${data.available}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 连接
|
||||||
|
sdk.connect();
|
||||||
|
|
||||||
|
// 保持运行
|
||||||
|
console.log("正在监听消息... (按 Ctrl+C 退出)");
|
||||||
|
|
||||||
|
// 保持进程活跃
|
||||||
|
setInterval(() => {}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
216
sdk/userSocket.ts
Normal file
216
sdk/userSocket.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
// 前端 User WebSocket SDK(浏览器使用原生 WebSocket,Node 测试需注入 global.WebSocket)
|
||||||
|
// 消息类型定义
|
||||||
|
export type UserUpdateType = 'order_update' | 'position_update' | 'balance_update' | 'welcome';
|
||||||
|
|
||||||
|
export interface UserMsg {
|
||||||
|
type: UserUpdateType;
|
||||||
|
data?: any;
|
||||||
|
msg?: string; // mainly for welcome
|
||||||
|
timestamp?: number;
|
||||||
|
t?: number; // for welcome timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单数据结构 (对应后端 ClobOrder)
|
||||||
|
export interface OrderData {
|
||||||
|
ID: number;
|
||||||
|
userID: number;
|
||||||
|
market: string;
|
||||||
|
status: number;
|
||||||
|
assetID: string;
|
||||||
|
side: number;
|
||||||
|
price: number;
|
||||||
|
originalSize: number;
|
||||||
|
sizeMatched: number;
|
||||||
|
outcome: string;
|
||||||
|
expiration: number;
|
||||||
|
orderType: number;
|
||||||
|
feeRateBps: number;
|
||||||
|
CreatedAt?: string;
|
||||||
|
UpdatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持仓数据结构 (对应后端 ClobPosition)
|
||||||
|
export interface PositionData {
|
||||||
|
ID: number;
|
||||||
|
userID: number;
|
||||||
|
marketID: string;
|
||||||
|
tokenId: string;
|
||||||
|
side: number;
|
||||||
|
size: number;
|
||||||
|
available: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 余额数据结构 (对应后端 PmTokenBalance)
|
||||||
|
export interface BalanceData {
|
||||||
|
ID: string;
|
||||||
|
userID: number;
|
||||||
|
amount: string;
|
||||||
|
available: string;
|
||||||
|
frozen: string;
|
||||||
|
tokenType: string;
|
||||||
|
token_id: string; // 注意后端字段可能是不一致的,这里根据 Go 结构体推测,或者是 json tag
|
||||||
|
market_id: string;
|
||||||
|
wallet_address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDK 配置选项
|
||||||
|
export interface UserSocketOptions {
|
||||||
|
url?: string;
|
||||||
|
token: string; // 必须提供 Token
|
||||||
|
autoReconnect?: boolean;
|
||||||
|
reconnectInterval?: number;
|
||||||
|
maxReconnectAttempts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回调函数类型(兼容浏览器 Event/CloseEvent)
|
||||||
|
export type ConnectCallback = (event: Event) => void;
|
||||||
|
export type DisconnectCallback = (event: CloseEvent) => void;
|
||||||
|
export type ErrorCallback = (event: Event) => void;
|
||||||
|
export type WelcomeCallback = (msg: UserMsg) => void;
|
||||||
|
export type OrderCallback = (data: OrderData) => void;
|
||||||
|
export type PositionCallback = (data: PositionData) => void;
|
||||||
|
export type BalanceCallback = (data: BalanceData) => void;
|
||||||
|
|
||||||
|
export class UserSdk {
|
||||||
|
private url: string;
|
||||||
|
private token: string;
|
||||||
|
private ws: InstanceType<typeof WebSocket> | null = null;
|
||||||
|
private autoReconnect: boolean;
|
||||||
|
private reconnectInterval: number;
|
||||||
|
private maxReconnectAttempts: number;
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private isExplicitClose = false;
|
||||||
|
|
||||||
|
private listeners = {
|
||||||
|
connect: [] as ConnectCallback[],
|
||||||
|
disconnect: [] as DisconnectCallback[],
|
||||||
|
error: [] as ErrorCallback[],
|
||||||
|
welcome: [] as WelcomeCallback[],
|
||||||
|
order: [] as OrderCallback[],
|
||||||
|
position: [] as PositionCallback[],
|
||||||
|
balance: [] as BalanceCallback[]
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(options: UserSocketOptions) {
|
||||||
|
this.token = options.token;
|
||||||
|
this.autoReconnect = options.autoReconnect ?? true;
|
||||||
|
this.reconnectInterval = options.reconnectInterval ?? 3000;
|
||||||
|
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
||||||
|
|
||||||
|
// 构造 URL
|
||||||
|
let baseUrl = options.url || "ws://localhost:8888/clob/ws/user";
|
||||||
|
if (baseUrl.startsWith("/")) {
|
||||||
|
// 如果是相对路径 (浏览器环境),自动补充 host
|
||||||
|
if (typeof window !== 'undefined' && window.location) {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
baseUrl = `${protocol}//${window.location.host}${baseUrl}`;
|
||||||
|
} else {
|
||||||
|
baseUrl = "ws://localhost:8888" + baseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼接 Token
|
||||||
|
const separator = baseUrl.includes('?') ? '&' : '?';
|
||||||
|
this.url = `${baseUrl}${separator}token=${this.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接
|
||||||
|
public connect() {
|
||||||
|
if (this.ws) {
|
||||||
|
console.warn("UserSdk: WebSocket already connected or connecting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`UserSdk: Connecting to ${this.url}...`);
|
||||||
|
this.ws = new WebSocket(this.url);
|
||||||
|
|
||||||
|
this.ws.onopen = (event: Event) => {
|
||||||
|
console.log("UserSdk: Connected");
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.listeners.connect.forEach(cb => cb(event));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = (event: CloseEvent) => {
|
||||||
|
console.log(`UserSdk: Disconnected (Code: ${event.code})`);
|
||||||
|
this.ws = null;
|
||||||
|
this.listeners.disconnect.forEach(cb => cb(event));
|
||||||
|
|
||||||
|
if (this.autoReconnect && !this.isExplicitClose) {
|
||||||
|
this.handleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (event: Event) => {
|
||||||
|
console.error("UserSdk: Error", event);
|
||||||
|
this.listeners.error.forEach(cb => cb(event));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const raw = typeof event.data === 'string' ? event.data : String(event.data);
|
||||||
|
const msg = JSON.parse(raw) as UserMsg;
|
||||||
|
this.handleMessage(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("UserSdk: Failed to parse message", event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("UserSdk: Connection failed", e);
|
||||||
|
if (this.autoReconnect) this.handleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断开连接
|
||||||
|
public disconnect() {
|
||||||
|
this.isExplicitClose = true;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息分发
|
||||||
|
private handleMessage(msg: UserMsg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'welcome':
|
||||||
|
this.listeners.welcome.forEach(cb => cb(msg));
|
||||||
|
break;
|
||||||
|
case 'order_update':
|
||||||
|
this.listeners.order.forEach(cb => cb(msg.data));
|
||||||
|
break;
|
||||||
|
case 'position_update':
|
||||||
|
this.listeners.position.forEach(cb => cb(msg.data));
|
||||||
|
break;
|
||||||
|
case 'balance_update':
|
||||||
|
this.listeners.balance.forEach(cb => cb(msg.data));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn("UserSdk: Unknown message type", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重连逻辑
|
||||||
|
private handleReconnect() {
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.error("UserSdk: Max reconnect attempts reached");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
console.log(`UserSdk: Reconnecting in ${this.reconnectInterval}ms (Attempt ${this.reconnectAttempts})...`);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connect();
|
||||||
|
}, this.reconnectInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件注册方法
|
||||||
|
public onConnect(cb: ConnectCallback) { this.listeners.connect.push(cb); }
|
||||||
|
public onDisconnect(cb: DisconnectCallback) { this.listeners.disconnect.push(cb); }
|
||||||
|
public onError(cb: ErrorCallback) { this.listeners.error.push(cb); }
|
||||||
|
public onWelcome(cb: WelcomeCallback) { this.listeners.welcome.push(cb); }
|
||||||
|
public onOrderUpdate(cb: OrderCallback) { this.listeners.order.push(cb); }
|
||||||
|
public onPositionUpdate(cb: PositionCallback) { this.listeners.position.push(cb); }
|
||||||
|
public onBalanceUpdate(cb: BalanceCallback) { this.listeners.balance.push(cb); }
|
||||||
|
}
|
||||||
@ -168,33 +168,83 @@ function mapPmTagToTreeNode(item: PmTagMainItem): CategoryTreeNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** getPmTagList 响应:data 可能为 { All: PmTag[] } 或 PmTag[] */
|
/** PmTagCatalog 结构(definitions polymarket.PmTagCatalog) */
|
||||||
type GetPmTagListData = PmTagMainItem[] | { All?: PmTagMainItem[] }
|
export interface PmTagCatalogItem {
|
||||||
|
ID?: number
|
||||||
|
name?: string
|
||||||
|
tagId?: { slug?: string; label?: string; ID?: number } | number
|
||||||
|
children?: PmTagCatalogItem[]
|
||||||
|
level?: number
|
||||||
|
parentId?: number
|
||||||
|
sort?: number
|
||||||
|
isShow?: boolean
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
/** 扩展字段 */
|
||||||
|
slug?: string
|
||||||
|
label?: string
|
||||||
|
icon?: string
|
||||||
|
sectionTitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 PmTagCatalogItem 转为 CategoryTreeNode */
|
||||||
|
function mapCatalogToTreeNode(item: PmTagCatalogItem): CategoryTreeNode {
|
||||||
|
const rawId = item.ID
|
||||||
|
const id = rawId != null ? String(rawId) : ''
|
||||||
|
const tagIdObj =
|
||||||
|
typeof item.tagId === 'object' && item.tagId != null ? item.tagId : {}
|
||||||
|
const slug =
|
||||||
|
item.slug ??
|
||||||
|
(typeof tagIdObj === 'object' && 'slug' in tagIdObj
|
||||||
|
? (tagIdObj as { slug?: string }).slug
|
||||||
|
: undefined) ??
|
||||||
|
''
|
||||||
|
const label = item.label ?? item.name ?? ''
|
||||||
|
const children = Array.isArray(item.children)
|
||||||
|
? item.children.map(mapCatalogToTreeNode)
|
||||||
|
: undefined
|
||||||
|
const icon = item.icon ?? resolveCategoryIcon({ label, slug })
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
slug,
|
||||||
|
icon,
|
||||||
|
sectionTitle: item.sectionTitle,
|
||||||
|
sort: item.sort,
|
||||||
|
children: children?.length ? children : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** getPmTagCatalogPublic 响应:data 可能为数组、{ All: [] }、{ list: [] } 等 */
|
||||||
|
type GetPmTagCatalogData =
|
||||||
|
| PmTagCatalogItem[]
|
||||||
|
| { All?: PmTagCatalogItem[]; list?: PmTagCatalogItem[] }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取主分类(首页顶部分类 Tab 数据)
|
* 获取主分类(首页顶部分类 Tab 数据)
|
||||||
* GET /PmTag/getPmTagList(原 getPmTagMain)
|
* GET /pmTagCatalog/getPmTagCatalogPublic
|
||||||
*
|
*
|
||||||
* 数据结构:三层结构均在 data.All 下,data.All 为根节点数组
|
* 数据结构:可能为树形数组,或 { All: [] } / { list: [] };取 All 下三层展示
|
||||||
*/
|
*/
|
||||||
export async function getPmTagMain(): Promise<CategoryTreeResponse> {
|
export async function getPmTagMain(): Promise<CategoryTreeResponse> {
|
||||||
const res = await get<{ code: number; data: GetPmTagListData; msg: string }>(
|
const res = await get<{ code: number; data: GetPmTagCatalogData; msg: string }>(
|
||||||
'/PmTag/getPmTagList'
|
'/pmTagCatalog/getPmTagCatalogPublic'
|
||||||
)
|
)
|
||||||
// 调试:打印接口原始数据
|
console.log('[getPmTagCatalogPublic] 原始响应:', JSON.stringify(res, null, 2))
|
||||||
console.log('[getPmTagList] 原始响应:', JSON.stringify(res, null, 2))
|
let raw: PmTagCatalogItem[] = []
|
||||||
let raw: PmTagMainItem[] = []
|
|
||||||
const d = res.data
|
const d = res.data
|
||||||
if (d && typeof d === 'object' && !Array.isArray(d) && 'All' in d) {
|
if (d && typeof d === 'object' && !Array.isArray(d)) {
|
||||||
raw = Array.isArray((d as { All?: PmTagMainItem[] }).All)
|
const obj = d as { All?: PmTagCatalogItem[]; list?: PmTagCatalogItem[] }
|
||||||
? (d as { All: PmTagMainItem[] }).All
|
raw = Array.isArray(obj.All)
|
||||||
: []
|
? obj.All
|
||||||
|
: Array.isArray(obj.list)
|
||||||
|
? obj.list
|
||||||
|
: []
|
||||||
} else if (Array.isArray(d)) {
|
} else if (Array.isArray(d)) {
|
||||||
raw = d
|
raw = d
|
||||||
}
|
}
|
||||||
let mapped = raw.map(mapPmTagToTreeNode)
|
let mapped = raw.map(mapCatalogToTreeNode)
|
||||||
mapped = sortBySortField(mapped)
|
mapped = sortBySortField(mapped)
|
||||||
// 第一层不显示,取 All 菜单下的三层作为展示数据(政治、体育、加密等)
|
|
||||||
const allNode = mapped.find(
|
const allNode = mapped.find(
|
||||||
(n) =>
|
(n) =>
|
||||||
n.slug?.toLowerCase() === 'all' ||
|
n.slug?.toLowerCase() === 'all' ||
|
||||||
|
|||||||
@ -15,6 +15,14 @@ export function getClobWsUrl(): string {
|
|||||||
return `${protocol}//${url.host}/clob/ws`
|
return `${protocol}//${url.host}/clob/ws`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User WebSocket URL(订单/持仓/余额推送),与 REST API 同源 */
|
||||||
|
export function getUserWsUrl(): string {
|
||||||
|
const base = BASE_URL || (typeof window !== 'undefined' ? window.location.origin : 'https://api.xtrader.vip')
|
||||||
|
const url = new URL(base)
|
||||||
|
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
return `${protocol}//${url.host}/clob/ws/user`
|
||||||
|
}
|
||||||
|
|
||||||
export interface RequestConfig {
|
export interface RequestConfig {
|
||||||
/** 请求头,如 { 'x-token': token, 'x-user-id': userId } */
|
/** 请求头,如 { 'x-token': token, 'x-user-id': userId } */
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { getUsdcBalance, formatUsdcBalance, getUserInfo } from '@/api/user'
|
import { getUsdcBalance, formatUsdcBalance, getUserInfo } from '@/api/user'
|
||||||
|
import { getUserWsUrl } from '@/api/request'
|
||||||
|
import { UserSdk, type BalanceData } from '../../sdk/userSocket'
|
||||||
|
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
/** 用户 ID(API 可能返回 id 或 ID) */
|
/** 用户 ID(API 可能返回 id 或 ID) */
|
||||||
@ -47,9 +49,52 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value && !!user.value)
|
const isLoggedIn = computed(() => !!token.value && !!user.value)
|
||||||
const avatarUrl = computed(() => user.value?.headerImg ?? '')
|
const avatarUrl = computed(() => user.value?.headerImg ?? '')
|
||||||
/** 钱包余额显示,如 "0.00",可从接口更新 */
|
/** 钱包余额显示,如 "0.00",可从接口或 UserSocket 推送更新 */
|
||||||
const balance = ref<string>('0.00')
|
const balance = ref<string>('0.00')
|
||||||
|
|
||||||
|
let userSdkRef: UserSdk | null = null
|
||||||
|
|
||||||
|
// 若从 storage 恢复登录态,自动连接 UserSocket
|
||||||
|
if (stored?.token && stored?.user) {
|
||||||
|
// 延迟到 nextTick 后连接,避免 store 未完全初始化
|
||||||
|
Promise.resolve().then(() => connectUserSocket())
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectUserSocket() {
|
||||||
|
if (!token.value) return
|
||||||
|
disconnectUserSocket()
|
||||||
|
const sdk = new UserSdk({
|
||||||
|
url: getUserWsUrl(),
|
||||||
|
token: token.value,
|
||||||
|
autoReconnect: true,
|
||||||
|
reconnectInterval: 2000,
|
||||||
|
})
|
||||||
|
sdk.onBalanceUpdate((data: BalanceData) => {
|
||||||
|
const avail = data.available ?? data.amount
|
||||||
|
if (avail != null) {
|
||||||
|
balance.value = formatUsdcBalance(String(avail))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
sdk.onConnect(() => {
|
||||||
|
console.log('[UserStore] UserSocket 已连接')
|
||||||
|
})
|
||||||
|
sdk.onDisconnect(() => {
|
||||||
|
console.log('[UserStore] UserSocket 已断开')
|
||||||
|
})
|
||||||
|
sdk.onError((e) => {
|
||||||
|
console.error('[UserStore] UserSocket 错误:', e)
|
||||||
|
})
|
||||||
|
userSdkRef = sdk
|
||||||
|
sdk.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectUserSocket() {
|
||||||
|
if (userSdkRef) {
|
||||||
|
userSdkRef.disconnect()
|
||||||
|
userSdkRef = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setUser(loginData: { token?: string; user?: UserInfo }) {
|
function setUser(loginData: { token?: string; user?: UserInfo }) {
|
||||||
const t = loginData.token ?? ''
|
const t = loginData.token ?? ''
|
||||||
const raw = loginData.user ?? null
|
const raw = loginData.user ?? null
|
||||||
@ -70,11 +115,17 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
} else {
|
} else {
|
||||||
user.value = null
|
user.value = null
|
||||||
}
|
}
|
||||||
if (t && user.value) saveToStorage(t, user.value)
|
if (t && user.value) {
|
||||||
else clearStorage()
|
saveToStorage(t, user.value)
|
||||||
|
connectUserSocket()
|
||||||
|
} else {
|
||||||
|
clearStorage()
|
||||||
|
disconnectUserSocket()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
|
disconnectUserSocket()
|
||||||
token.value = ''
|
token.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
clearStorage()
|
clearStorage()
|
||||||
@ -151,5 +202,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
getAuthHeaders,
|
getAuthHeaders,
|
||||||
fetchUsdcBalance,
|
fetchUsdcBalance,
|
||||||
fetchUserInfo,
|
fetchUserInfo,
|
||||||
|
connectUserSocket,
|
||||||
|
disconnectUserSocket,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -302,7 +302,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ name: 'Home' })
|
defineOptions({ name: 'Home' })
|
||||||
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, computed } from 'vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import MarketCard from '../components/MarketCard.vue'
|
import MarketCard from '../components/MarketCard.vue'
|
||||||
import TradeComponent from '../components/TradeComponent.vue'
|
import TradeComponent from '../components/TradeComponent.vue'
|
||||||
@ -697,7 +697,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
function removeScrollListeners() {
|
||||||
const sentinel = sentinelRef.value
|
const sentinel = sentinelRef.value
|
||||||
if (observer && sentinel) observer.unobserve(sentinel)
|
if (observer && sentinel) observer.unobserve(sentinel)
|
||||||
observer = null
|
observer = null
|
||||||
@ -706,6 +706,34 @@ onUnmounted(() => {
|
|||||||
resizeObserver = null
|
resizeObserver = null
|
||||||
}
|
}
|
||||||
window.removeEventListener('scroll', checkScrollLoad)
|
window.removeEventListener('scroll', checkScrollLoad)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep-alive 时离开页面不会触发 onUnmounted,需在 onDeactivated 移除监听,否则详情页滚动到底会误触发 loadMore
|
||||||
|
onDeactivated(removeScrollListeners)
|
||||||
|
onUnmounted(removeScrollListeners)
|
||||||
|
|
||||||
|
// 从详情页返回时重新注册监听(仅当 observer 已被 removeScrollListeners 清空时)
|
||||||
|
onActivated(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
const sentinel = sentinelRef.value
|
||||||
|
if (sentinel && !observer) {
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries[0]?.isIntersecting) return
|
||||||
|
loadMore()
|
||||||
|
},
|
||||||
|
{ root: null, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 },
|
||||||
|
)
|
||||||
|
observer.observe(sentinel)
|
||||||
|
window.addEventListener('scroll', checkScrollLoad, { passive: true })
|
||||||
|
}
|
||||||
|
const listEl = listRef.value
|
||||||
|
if (listEl && !resizeObserver) {
|
||||||
|
updateGridColumns()
|
||||||
|
resizeObserver = new ResizeObserver(updateGridColumns)
|
||||||
|
resizeObserver.observe(listEl)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user