diff --git a/docs/api/request.md b/docs/api/request.md index 9b0c7f3..feb647f 100644 --- a/docs/api/request.md +++ b/docs/api/request.md @@ -9,7 +9,8 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求 ## 核心能力 - 统一 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 参数,自动序列化 - POST 请求:支持 JSON body - 自定义 headers:通过 `RequestConfig.headers` 传入 @@ -17,10 +18,12 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求 ## 使用方式 ```typescript -import { get, post, getClobWsUrl } from '@/api/request' +import { get, post, getClobWsUrl, getUserWsUrl } from '@/api/request' // 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 请求 const data = await get('/path', { page: 1, keyword: 'x' }) diff --git a/docs/stores/user.md b/docs/stores/user.md index b05683c..7c71eb8 100644 --- a/docs/stores/user.md +++ b/docs/stores/user.md @@ -10,12 +10,12 @@ - `token`、`user`:登录凭证与用户信息 - `isLoggedIn`、`avatarUrl`:派生状态 -- `balance`:USDC 余额显示(如 "0.00") -- `setUser`:设置登录数据并持久化 -- `logout`:清空并清除持久化 +- `balance`:USDC 余额显示(如 "0.00"),支持 **UserSocket** 实时推送更新 +- `setUser`:设置登录数据并持久化,登录成功后自动连接 UserSocket +- `logout`:清空并断开 UserSocket - `getAuthHeaders`:返回 `{ 'x-token', 'x-user-id' }`,未登录时返回 `undefined` -- `fetchUserInfo`、`fetchUsdcBalance`:拉取并更新用户信息与余额 -- `fetchUserInfo` 兼容多种 API 字段名:`userName`/`username`、`nickName`/`nickname`、`headerImg`/`avatar`/`avatarUrl`;支持 `data` 直接为用户对象或 `data.user` 嵌套结构 +- `fetchUserInfo`、`fetchUsdcBalance`:拉取并更新用户信息与余额;`fetchUserInfo` 兼容多种 API 字段名 +- `connectUserSocket`、`disconnectUserSocket`:连接/断开 `sdk/userSocket` 的 UserSdk,用于订单/持仓/余额实时推送 ## 使用方式 @@ -39,9 +39,9 @@ await userStore.fetchUsdcBalance() ## 扩展方式 -1. **多端登录**:扩展 `setUser` 支持多设备 token 管理 -2. **Token 刷新**:在 `getAuthHeaders` 或请求拦截器中加入 refresh 逻辑 -3. **登出回调**:在 `logout` 中增加清理逻辑(如取消订阅、重置其他 store) +1. **订单/持仓推送**:在 `connectUserSocket` 中注册 `onOrderUpdate`、`onPositionUpdate`,触发 Wallet 刷新 +2. **多端登录**:扩展 `setUser` 支持多设备 token 管理 +3. **Token 刷新**:在 `getAuthHeaders` 或请求拦截器中加入 refresh 逻辑 ## 登录后刷新用户信息 diff --git a/sdk/testUser.ts b/sdk/testUser.ts new file mode 100644 index 0000000..0765e50 --- /dev/null +++ b/sdk/testUser.ts @@ -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 { + 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); diff --git a/sdk/userSocket.ts b/sdk/userSocket.ts new file mode 100644 index 0000000..5e5f3db --- /dev/null +++ b/sdk/userSocket.ts @@ -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 | 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); } +} diff --git a/src/api/category.ts b/src/api/category.ts index 15de132..d2cac4b 100644 --- a/src/api/category.ts +++ b/src/api/category.ts @@ -168,33 +168,83 @@ function mapPmTagToTreeNode(item: PmTagMainItem): CategoryTreeNode { } } -/** getPmTagList 响应:data 可能为 { All: PmTag[] } 或 PmTag[] */ -type GetPmTagListData = PmTagMainItem[] | { All?: PmTagMainItem[] } +/** PmTagCatalog 结构(definitions polymarket.PmTagCatalog) */ +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 数据) - * GET /PmTag/getPmTagList(原 getPmTagMain) + * GET /pmTagCatalog/getPmTagCatalogPublic * - * 数据结构:三层结构均在 data.All 下,data.All 为根节点数组 + * 数据结构:可能为树形数组,或 { All: [] } / { list: [] };取 All 下三层展示 */ export async function getPmTagMain(): Promise { - const res = await get<{ code: number; data: GetPmTagListData; msg: string }>( - '/PmTag/getPmTagList' + const res = await get<{ code: number; data: GetPmTagCatalogData; msg: string }>( + '/pmTagCatalog/getPmTagCatalogPublic' ) - // 调试:打印接口原始数据 - console.log('[getPmTagList] 原始响应:', JSON.stringify(res, null, 2)) - let raw: PmTagMainItem[] = [] + console.log('[getPmTagCatalogPublic] 原始响应:', JSON.stringify(res, null, 2)) + let raw: PmTagCatalogItem[] = [] const d = res.data - if (d && typeof d === 'object' && !Array.isArray(d) && 'All' in d) { - raw = Array.isArray((d as { All?: PmTagMainItem[] }).All) - ? (d as { All: PmTagMainItem[] }).All - : [] + if (d && typeof d === 'object' && !Array.isArray(d)) { + const obj = d as { All?: PmTagCatalogItem[]; list?: PmTagCatalogItem[] } + raw = Array.isArray(obj.All) + ? obj.All + : Array.isArray(obj.list) + ? obj.list + : [] } else if (Array.isArray(d)) { raw = d } - let mapped = raw.map(mapPmTagToTreeNode) + let mapped = raw.map(mapCatalogToTreeNode) mapped = sortBySortField(mapped) - // 第一层不显示,取 All 菜单下的三层作为展示数据(政治、体育、加密等) const allNode = mapped.find( (n) => n.slug?.toLowerCase() === 'all' || diff --git a/src/api/request.ts b/src/api/request.ts index ad5b776..1d1f39d 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -15,6 +15,14 @@ export function getClobWsUrl(): string { 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 { /** 请求头,如 { 'x-token': token, 'x-user-id': userId } */ headers?: Record diff --git a/src/stores/user.ts b/src/stores/user.ts index cd83b1f..25d9f6b 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -1,6 +1,8 @@ import { ref, computed } from 'vue' import { defineStore } from 'pinia' import { getUsdcBalance, formatUsdcBalance, getUserInfo } from '@/api/user' +import { getUserWsUrl } from '@/api/request' +import { UserSdk, type BalanceData } from '../../sdk/userSocket' export interface UserInfo { /** 用户 ID(API 可能返回 id 或 ID) */ @@ -47,9 +49,52 @@ export const useUserStore = defineStore('user', () => { const isLoggedIn = computed(() => !!token.value && !!user.value) const avatarUrl = computed(() => user.value?.headerImg ?? '') - /** 钱包余额显示,如 "0.00",可从接口更新 */ + /** 钱包余额显示,如 "0.00",可从接口或 UserSocket 推送更新 */ const balance = ref('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 }) { const t = loginData.token ?? '' const raw = loginData.user ?? null @@ -70,11 +115,17 @@ export const useUserStore = defineStore('user', () => { } else { user.value = null } - if (t && user.value) saveToStorage(t, user.value) - else clearStorage() + if (t && user.value) { + saveToStorage(t, user.value) + connectUserSocket() + } else { + clearStorage() + disconnectUserSocket() + } } function logout() { + disconnectUserSocket() token.value = '' user.value = null clearStorage() @@ -151,5 +202,7 @@ export const useUserStore = defineStore('user', () => { getAuthHeaders, fetchUsdcBalance, fetchUserInfo, + connectUserSocket, + disconnectUserSocket, } }) diff --git a/src/views/Home.vue b/src/views/Home.vue index 773df98..17c70f8 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -302,7 +302,7 @@