新增:持仓数据接口对接

This commit is contained in:
ivan 2026-02-27 15:15:17 +08:00
parent 38d70e3e56
commit 3e329c307e
6 changed files with 442 additions and 52 deletions

View File

@ -16,19 +16,38 @@ description: Interprets the XTrader API from the Swagger 2.0 spec at https://api
### 强制动作序列(必须依次执行)
1. **收到对接请求** → 立即用 `mcp_web_fetch``curl` 获取 `https://api.xtrader.vip/swagger/doc.json`
2. **第一步** → 解析 `paths["<path>"]["<method>"]``definitions`**在对话中输出**
2. **检查接口是否存在** → 若 `paths["<path>"]``paths["<path>"]["<method>"]` **不存在**,则:
- ❌ **禁止**自行猜测或虚构数据结构并自动对接
- ✅ **必须**在对话中明确告知用户「该接口在 doc.json 中不存在」
- ✅ **必须**向用户询问:
- 是否仍要对接?若对接,请提供**请求参数**与**响应数据结构**(或示例 JSON
- 用户可选择**不对接**,则流程终止
- 仅在用户明确提供数据结构或确认对接后,才可进入第二步
3. **第一步**(接口存在时)→ 解析 `paths["<path>"]["<method>"]``definitions`**在对话中输出**
- 请求参数表Query/Body/鉴权)
- 响应参数表200 schema
- data 的 `$ref` 对应 definitions 的**完整字段表**
- 输出后**明确标注「第一步完成」**
3. **第二步** → 在 `src/api/` 中根据第一步表格定义 TypeScript 类型,**不得在第一步完成前执行**
4. **第三步** → 实现请求函数并集成到页面,**不得在第二步完成前执行**
4. **第二步** → 在 `src/api/` 中根据第一步表格(或用户提供的数据结构)定义 TypeScript 类型,**不得在第一步完成前执行**
5. **第三步** → 实现请求函数并集成到页面,**不得在第二步完成前执行**
### 禁止行为
- ❌ 在对话中输出第一步结果**之前**写任何 `src/api/``src/views/` 业务代码
- ❌ 跳过第一步直接定义类型或实现请求
- ❌ 合并步骤(如边输出边写代码)
- ❌ **接口文档不存在时**:自行猜测数据结构并自动对接;必须向用户询问后再决定是否对接
### 接口文档不存在时的处理流程
`paths["<path>"]` 在 doc.json 中**不存在**时:
1. **立即停止**:不写任何对接代码,不猜测数据结构
2. **明确告知**:在对话中说明「该接口在 Swagger doc.json 中不存在」
3. **向用户询问**
- 是否仍要对接?若对接,请提供请求参数与响应数据结构(或示例 JSON
- 用户可选择**不对接**,则流程终止
4. **仅在用户明确提供**后,才继续执行第二步、第三步
### 第一步输出模板(必须包含)
@ -163,6 +182,7 @@ Swagger UI 页面(如 [PmEvent findPmEvent](https://api.xtrader.vip/swagger/in
## 简要检查清单
- [ ] **接口存在性**:若 doc.json 中无该 path已向用户询问数据结构未擅自猜测对接
- [ ] **按规范顺序**:已先列出请求参数与响应参数,再建 Model最后集成到页面
- [ ] 规范 URL 使用 `https://api.xtrader.vip/swagger/doc.json`,或本地缓存与之一致
- [ ] 请求 path、method、query/body 与 `paths` 一致

View File

@ -38,12 +38,21 @@ export interface OrderListResponse {
msg: string
}
/** 订单状态1=未成交Live2=已成交0=已取消等 */
export const OrderStatus = {
Live: 1,
Matched: 2,
Cancelled: 0,
} as const
/**
* GET /clob/order/getOrderList
*/
export interface GetOrderListParams {
page?: number
pageSize?: number
/** 订单状态筛选1=未成交 */
status?: number
startCreatedAt?: string
endCreatedAt?: string
marketID?: string
@ -60,8 +69,10 @@ export async function getOrderList(
params: GetOrderListParams = {},
config?: { headers?: Record<string, string> },
): Promise<OrderListResponse> {
const { page = 1, pageSize = 10, startCreatedAt, endCreatedAt, marketID, tokenID, userID } = params
const { page = 1, pageSize = 10, status, startCreatedAt, endCreatedAt, marketID, tokenID, userID } =
params
const query: Record<string, string | number | undefined> = { page, pageSize }
if (status != null && Number.isFinite(status)) query.status = status
if (startCreatedAt != null && startCreatedAt !== '') query.startCreatedAt = startCreatedAt
if (endCreatedAt != null && endCreatedAt !== '') query.endCreatedAt = endCreatedAt
if (marketID != null && marketID !== '') query.marketID = marketID
@ -136,3 +147,59 @@ export function mapOrderToHistoryItem(order: ClobOrderItem): HistoryDisplayItem
shares: String(size),
}
}
/** 钱包 Open Orders 展示项(与 Wallet.vue OpenOrder 一致) */
export interface OpenOrderDisplayItem {
id: string
market: string
side: 'Yes' | 'No'
outcome: string
price: string
filled: string
total: string
expiration: string
actionLabel?: string
filledDisplay?: string
orderID?: number
tokenID?: string
}
/** OrderType GTC=0 表示 Until Cancelled */
const OrderType = { GTC: 0, GTD: 1 } as const
/**
* ClobOrderItem Open Orders
* price 10000
*/
export function mapOrderToOpenOrderItem(order: ClobOrderItem): OpenOrderDisplayItem {
const id = String(order.ID ?? '')
const market = order.market ?? ''
const sideNum = order.side ?? Side.Buy
const side = sideNum === Side.Sell ? 'No' : 'Yes'
const outcome = order.outcome || (side === 'Yes' ? 'Yes' : 'No')
const priceBps = order.price ?? 0
const priceCents = Math.round(priceBps / 100)
const price = `${priceCents}¢`
const originalSize = order.originalSize ?? 0
const sizeMatched = order.sizeMatched ?? 0
const filled = `${sizeMatched}/${originalSize}`
const totalUsd = (priceBps / 10000) * originalSize
const total = `$${totalUsd.toFixed(2)}`
const expiration =
order.orderType === OrderType.GTC ? 'Until Cancelled' : order.expiration?.toString() ?? ''
const actionLabel = sideNum === Side.Buy ? `Buy ${outcome}` : `Sell ${outcome}`
return {
id,
market,
side,
outcome,
price,
filled,
total,
expiration,
actionLabel,
filledDisplay: filled,
orderID: order.ID,
tokenID: order.assetID,
}
}

122
src/api/position.ts Normal file
View File

@ -0,0 +1,122 @@
import { get } from './request'
/** 分页结果 */
export interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
/**
* doc.json definitions["model.ClobPosition"]
* GET /clob/position/getPositionList
*/
export interface ClobPositionItem {
ID?: number
available?: number
createdAt?: string
updatedAt?: string
marketID?: string
side?: number
size?: number
tokenId?: string
userID?: number
[key: string]: unknown
}
/** 持仓列表响应 */
export interface PositionListResponse {
code: number
data: PageResult<ClobPositionItem>
msg: string
}
/**
* GET /clob/position/getPositionList
*/
export interface GetPositionListParams {
page?: number
pageSize?: number
startCreatedAt?: string
endCreatedAt?: string
marketID?: string
tokenID?: string
userID?: number
}
/**
*
* GET /clob/position/getPositionList
* x-tokenx-user-id
*/
export async function getPositionList(
params: GetPositionListParams = {},
config?: { headers?: Record<string, string> },
): Promise<PositionListResponse> {
const { page = 1, pageSize = 10, startCreatedAt, endCreatedAt, marketID, tokenID, userID } = params
const query: Record<string, string | number | undefined> = { page, pageSize }
if (startCreatedAt != null && startCreatedAt !== '') query.startCreatedAt = startCreatedAt
if (endCreatedAt != null && endCreatedAt !== '') query.endCreatedAt = endCreatedAt
if (marketID != null && marketID !== '') query.marketID = marketID
if (tokenID != null && tokenID !== '') query.tokenID = tokenID
if (userID != null && Number.isFinite(userID)) query.userID = userID
return get<PositionListResponse>('/clob/position/getPositionList', query, config)
}
/** 钱包 Positions 展示项(与 Wallet.vue Position 一致) */
export interface PositionDisplayItem {
id: string
market: string
shares: string
avgNow: string
bet: string
toWin: string
value: string
valueChange?: string
valueChangePct?: string
valueChangeLoss?: boolean
sellOutcome?: string
outcomeWord?: string
iconChar?: string
iconClass?: string
outcomeTag?: string
outcomePillClass?: string
}
/** Side: Buy=1, Sell=2 */
const Side = { Buy: 1, Sell: 2 } as const
/**
* ClobPositionItem Position
* model.ClobPosition sizeavailablemarketIDside
*/
export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplayItem {
const id = String(pos.ID ?? '')
const market = pos.marketID ?? ''
const size = pos.size ?? pos.available ?? 0
const shares = `${size} shares`
const sideNum = pos.side ?? Side.Buy
const outcomeWord = sideNum === Side.Sell ? 'No' : 'Yes'
const pillClass = sideNum === Side.Sell ? 'pill-down' : 'pill-yes'
const sellOutcome = outcomeWord
const valueUsd = size
const value = `$${valueUsd.toFixed(2)}`
const bet = value
const toWin = `$${valueUsd.toFixed(2)}`
const avgNow = '—'
const outcomeTag = `${outcomeWord}`
return {
id,
market,
shares,
avgNow,
bet,
toWin,
value,
sellOutcome,
outcomeWord,
outcomeTag,
outcomePillClass: pillClass,
}
}

View File

@ -3,11 +3,49 @@ import { get } from './request'
const USDC_DECIMALS = 1_000_000
/**
* getUserInfo datadefinitions system.SysUser
* doc.json definitions["system.SysUser"]
* getUserInfo data 2026
* data balanceorderspositionsuserInfo
*/
/** 余额项data.balance */
export interface UserInfoBalance {
ID?: number
CreatedAt?: string
UpdatedAt?: string
userID?: number
marketID?: string
tokenType?: string
tokenID?: string
amount?: string
available?: string
locked?: string
version?: number
}
/** 订单项data.orders[] */
export interface UserInfoOrder {
ID?: number
CreatedAt?: string
UpdatedAt?: string
userID?: number
market?: string
status?: number
assetID?: string
side?: number
price?: number
originalSize?: number
sizeMatched?: number
outcome?: string
expiration?: number
orderType?: number
feeRateBps?: number
}
/** 用户信息data.userInfo */
export interface UserInfoData {
ID?: number
CreatedAt?: string
UpdatedAt?: string
userName?: string
nickName?: string
headerImg?: string
@ -15,8 +53,6 @@ export interface UserInfoData {
authorityId?: number
authority?: unknown
authorities?: unknown[]
createdAt?: string
updatedAt?: string
email?: string
phone?: string
enable?: number
@ -26,12 +62,20 @@ export interface UserInfoData {
promotionCode?: string
puserId?: number
remark?: string
originSetting?: Record<string, unknown>
originSetting?: Record<string, unknown> | null
}
/** getUserInfo 完整 data */
export interface GetUserInfoData {
balance?: UserInfoBalance
orders?: UserInfoOrder[]
positions?: unknown[]
userInfo?: UserInfoData
}
export interface GetUserInfoResponse {
code: number
data?: UserInfoData
data?: GetUserInfoData
msg?: string
}

View File

@ -70,6 +70,7 @@ export const useUserStore = defineStore('user', () => {
reconnectInterval: 2000,
})
sdk.onBalanceUpdate((data: BalanceData) => {
if ((data.tokenType ?? '').toUpperCase() !== 'USDC') return
const avail = data.available ?? data.amount
if (avail != null) {
balance.value = formatUsdcBalance(String(avail))
@ -151,19 +152,22 @@ export const useUserStore = defineStore('user', () => {
}
}
/** 请求用户信息(需已登录),更新 store 中的 user */
/** 请求用户信息(需已登录),更新 store 中的 user 与 balance */
async function fetchUserInfo() {
const headers = getAuthHeaders()
if (!headers) return
try {
const res = await getUserInfo(headers)
const data = res.data as Record<string, unknown> | undefined
// 接口返回 data.userInfo 或 data.user取实际用户对象若仍含 userInfo 则再取一层
let u = (data?.userInfo ?? data?.user ?? data) as Record<string, unknown>
if (u?.userInfo && (u.ID == null && u.id == null)) {
u = u.userInfo as Record<string, unknown>
if (res.code !== 0 && res.code !== 200) return
// 更新余额data.balance.available
const bal = data?.balance as { available?: string } | undefined
if (bal?.available != null) {
balance.value = formatUsdcBalance(String(bal.available))
}
if ((res.code === 0 || res.code === 200) && u) {
// 更新用户信息data.userInfo
const u = (data?.userInfo ?? data?.user ?? data) as Record<string, unknown> | undefined
if (!u) return
const rawId = u.ID ?? u.id
const numId =
typeof rawId === 'number'
@ -180,7 +184,6 @@ export const useUserStore = defineStore('user', () => {
ID: Number.isFinite(numId) ? numId : undefined,
} as UserInfo
if (token.value && user.value) saveToStorage(token.value, user.value)
}
} catch (e) {
console.error('[fetchUserInfo] 请求失败:', e)
}

View File

@ -154,7 +154,10 @@
<template v-if="activeTab === 'positions'">
<!-- 移动端可折叠列表 -->
<div v-if="mobile" class="positions-mobile-list">
<template v-if="filteredPositions.length === 0">
<template v-if="positionLoading">
<div class="empty-cell">{{ t('common.loading') }}</div>
</template>
<template v-else-if="filteredPositions.length === 0">
<div class="empty-cell">{{ t('wallet.noPositionsFound') }}</div>
</template>
<div
@ -256,7 +259,10 @@
</tr>
</thead>
<tbody>
<tr v-if="filteredPositions.length === 0">
<tr v-if="positionLoading">
<td colspan="6" class="empty-cell">{{ t('common.loading') }}</td>
</tr>
<tr v-else-if="filteredPositions.length === 0">
<td colspan="6" class="empty-cell">{{ t('wallet.noPositionsFound') }}</td>
</tr>
<tr v-for="pos in paginatedPositions" :key="pos.id" class="position-row">
@ -325,7 +331,10 @@
<template v-else-if="activeTab === 'orders'">
<!-- 移动端挂单卡片列表 -->
<div v-if="mobile" class="orders-mobile-list">
<template v-if="filteredOpenOrders.length === 0">
<template v-if="openOrderLoading">
<div class="empty-cell">{{ t('common.loading') }}</div>
</template>
<template v-else-if="filteredOpenOrders.length === 0">
<div class="empty-cell">{{ t('wallet.noOpenOrdersFound') }}</div>
</template>
<div v-for="ord in paginatedOpenOrders" :key="ord.id" class="order-mobile-card">
@ -374,7 +383,10 @@
</tr>
</thead>
<tbody>
<tr v-if="filteredOpenOrders.length === 0">
<tr v-if="openOrderLoading">
<td colspan="8" class="empty-cell">{{ t('common.loading') }}</td>
</tr>
<tr v-else-if="filteredOpenOrders.length === 0">
<td colspan="8" class="empty-cell">{{ t('wallet.noOpenOrdersFound') }}</td>
</tr>
<tr v-for="ord in paginatedOpenOrders" :key="ord.id">
@ -615,7 +627,8 @@ import DepositDialog from '../components/DepositDialog.vue'
import WithdrawDialog from '../components/WithdrawDialog.vue'
import { useUserStore } from '../stores/user'
import { pmCancelOrder } from '../api/market'
import { getOrderList, mapOrderToHistoryItem } from '../api/order'
import { getOrderList, mapOrderToHistoryItem, mapOrderToOpenOrderItem, OrderStatus } from '../api/order'
import { getPositionList, mapPositionToDisplayItem } from '../api/position'
import {
MOCK_TOKEN_ID,
MOCK_WALLET_POSITIONS,
@ -724,9 +737,99 @@ interface HistoryItem {
const positions = ref<Position[]>(
USE_MOCK_WALLET ? [...MOCK_WALLET_POSITIONS] : [],
)
/** 持仓列表API 数据,非 mock 时使用) */
const positionList = ref<Position[]>([])
const positionTotal = ref(0)
const positionLoading = ref(false)
async function loadPositionList() {
if (USE_MOCK_WALLET) return
const headers = userStore.getAuthHeaders()
if (!headers) {
positionList.value = []
positionTotal.value = 0
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
positionList.value = []
positionTotal.value = 0
return
}
positionLoading.value = true
try {
const res = await getPositionList(
{ page: page.value, pageSize: itemsPerPage.value, userID },
{ headers },
)
if (res.code === 0 || res.code === 200) {
const list = res.data?.list ?? []
positionList.value = list.map(mapPositionToDisplayItem)
positionTotal.value = res.data?.total ?? 0
} else {
positionList.value = []
positionTotal.value = 0
}
} catch {
positionList.value = []
positionTotal.value = 0
} finally {
positionLoading.value = false
}
}
const openOrders = ref<OpenOrder[]>(
USE_MOCK_WALLET ? [...MOCK_WALLET_ORDERS] : [],
)
/** 未成交订单API 数据,非 mock 时使用) */
const openOrderList = ref<OpenOrder[]>([])
const openOrderTotal = ref(0)
const openOrderLoading = ref(false)
async function loadOpenOrders() {
if (USE_MOCK_WALLET) return
const headers = userStore.getAuthHeaders()
if (!headers) {
openOrderList.value = []
openOrderTotal.value = 0
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
openOrderList.value = []
openOrderTotal.value = 0
return
}
openOrderLoading.value = true
try {
const res = await getOrderList(
{
page: page.value,
pageSize: itemsPerPage.value,
userID,
status: OrderStatus.Live,
},
{ headers },
)
if (res.code === 0 || res.code === 200) {
const list = res.data?.list ?? []
const openOnly = list.filter((o) => (o.status ?? 1) === OrderStatus.Live)
openOrderList.value = openOnly.map(mapOrderToOpenOrderItem)
openOrderTotal.value = openOnly.length
} else {
openOrderList.value = []
openOrderTotal.value = 0
}
} catch {
openOrderList.value = []
openOrderTotal.value = 0
} finally {
openOrderLoading.value = false
}
}
const history = ref<HistoryItem[]>(
USE_MOCK_WALLET ? [...MOCK_WALLET_HISTORY] : [],
)
@ -780,8 +883,14 @@ function matchSearch(text: string): boolean {
const q = search.value.trim().toLowerCase()
return !q || text.toLowerCase().includes(q)
}
const filteredPositions = computed(() => positions.value.filter((p) => matchSearch(p.market)))
const filteredOpenOrders = computed(() => openOrders.value.filter((o) => matchSearch(o.market)))
const filteredPositions = computed(() => {
const list = USE_MOCK_WALLET ? positions.value : positionList.value
return list.filter((p) => matchSearch(p.market))
})
const filteredOpenOrders = computed(() => {
const list = USE_MOCK_WALLET ? openOrders.value : openOrderList.value
return list.filter((o) => matchSearch(o.market))
})
const filteredHistory = computed(() => {
const list = USE_MOCK_WALLET ? history.value : historyList.value
return list.filter((h) => matchSearch(h.market))
@ -795,27 +904,37 @@ function paginate<T>(list: T[]) {
const start = (page.value - 1) * itemsPerPage.value
return list.slice(start, start + itemsPerPage.value)
}
const paginatedPositions = computed(() => paginate(filteredPositions.value))
const paginatedOpenOrders = computed(() => paginate(filteredOpenOrders.value))
const paginatedPositions = computed(() => {
if (USE_MOCK_WALLET) return paginate(filteredPositions.value)
return filteredPositions.value
})
const paginatedOpenOrders = computed(() => {
if (USE_MOCK_WALLET) return paginate(filteredOpenOrders.value)
return filteredOpenOrders.value
})
const paginatedHistory = computed(() => {
if (USE_MOCK_WALLET) return paginate(filteredHistory.value)
return filteredHistory.value
})
const totalPagesPositions = computed(() =>
Math.max(1, Math.ceil(filteredPositions.value.length / itemsPerPage.value)),
)
const totalPagesOrders = computed(() =>
Math.max(1, Math.ceil(filteredOpenOrders.value.length / itemsPerPage.value)),
)
const totalPagesPositions = computed(() => {
const total = USE_MOCK_WALLET ? filteredPositions.value.length : positionTotal.value
return Math.max(1, Math.ceil(total / itemsPerPage.value))
})
const totalPagesOrders = computed(() => {
const total = USE_MOCK_WALLET ? filteredOpenOrders.value.length : openOrderTotal.value
return Math.max(1, Math.ceil(total / itemsPerPage.value))
})
const totalPagesHistory = computed(() => {
const total = USE_MOCK_WALLET ? filteredHistory.value.length : historyTotal.value
return Math.max(1, Math.ceil(total / itemsPerPage.value))
})
const currentListTotal = computed(() => {
if (activeTab.value === 'positions') return filteredPositions.value.length
if (activeTab.value === 'orders') return filteredOpenOrders.value.length
if (activeTab.value === 'positions')
return USE_MOCK_WALLET ? filteredPositions.value.length : positionTotal.value
if (activeTab.value === 'orders')
return USE_MOCK_WALLET ? filteredOpenOrders.value.length : openOrderTotal.value
return USE_MOCK_WALLET ? filteredHistory.value.length : historyTotal.value
})
const currentTotalPages = computed(() => {
@ -832,9 +951,13 @@ const currentPageEnd = computed(() =>
watch(activeTab, (tab) => {
page.value = 1
if (tab === 'positions' && !USE_MOCK_WALLET) loadPositionList()
if (tab === 'orders' && !USE_MOCK_WALLET) loadOpenOrders()
if (tab === 'history' && !USE_MOCK_WALLET) loadHistoryOrders()
})
watch([page, itemsPerPage], () => {
if (activeTab.value === 'positions' && !USE_MOCK_WALLET) loadPositionList()
if (activeTab.value === 'orders' && !USE_MOCK_WALLET) loadOpenOrders()
if (activeTab.value === 'history' && !USE_MOCK_WALLET) loadHistoryOrders()
})
watch([currentListTotal, itemsPerPage], () => {
@ -870,7 +993,12 @@ async function cancelOrder(ord: OpenOrder) {
try {
const res = await pmCancelOrder({ orderID, tokenID, userID }, { headers })
if (res.code === 0 || res.code === 200) {
if (USE_MOCK_WALLET) {
openOrders.value = openOrders.value.filter((o) => o.id !== ord.id)
} else {
openOrderList.value = openOrderList.value.filter((o) => o.id !== ord.id)
openOrderTotal.value = openOrderList.value.length
}
userStore.fetchUsdcBalance()
} else {
cancelOrderError.value = res.msg || '取消失败'
@ -885,7 +1013,12 @@ async function cancelOrder(ord: OpenOrder) {
}
function cancelAllOrders() {
if (USE_MOCK_WALLET) {
openOrders.value = []
} else {
openOrderList.value = []
openOrderTotal.value = 0
}
}
const sellReceiveAmount = computed(() => {
@ -1052,6 +1185,7 @@ const handleResize = () => plChartInstance?.resize()
watch(plRange, () => updatePlChart())
onMounted(() => {
if (!USE_MOCK_WALLET && activeTab.value === 'positions') loadPositionList()
nextTick(() => {
initPlChart()
})