新增:拆分订单和合并订单接口对接

This commit is contained in:
ivan 2026-02-11 19:24:54 +08:00
parent e14bd3bc23
commit 297d2d1c56
21 changed files with 492 additions and 85 deletions

View File

@ -0,0 +1,47 @@
---
name: deploy
description: 将 PolyClientVuetify 项目打包并通过 SSH 部署到远程服务器。用户说「部署」「发布」时使用此 skill。
---
# 项目打包并部署
将 PolyClientVuetify 项目构建后通过 SSH rsync 部署到 `root@38.246.250.238:/opt/1panel/www/sites/pm.xtrader.vip/index`
## 使用方式
在项目根目录执行:
```bash
npm run deploy
```
## 执行流程
1. **打包**:执行 `npm run build`,生成 `dist/` 目录
- 生产构建自动使用 `.env.production`API 地址为 `https://api.xtrader.vip`
2. **SSH 部署**:使用 rsync 将 `dist/` 同步到远程目录
## 前置条件
- 本机已配置 SSH 免密登录 `root@38.246.250.238`,或可交互输入密码
- 远程目录 `/opt/1panel/www/sites/pm.xtrader.vip/index` 需存在且有写权限
## 环境变量(可选)
`.env``.env.local` 中配置,不配置时使用默认值:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `DEPLOY_HOST` | 38.246.250.238 | 部署目标主机 |
| `DEPLOY_USER` | root | SSH 用户 |
| `DEPLOY_PATH` | /opt/1panel/www/sites/pm.xtrader.vip/index | 远程目录 |
## 涉及文件
- **部署脚本**`scripts/deploy.mjs`
- **构建输出**`dist/`(由 Vite 生成)
## 故障排查
- **Permission denied**:确认 SSH 密钥已添加到远程服务器(`ssh-copy-id root@38.246.250.238`
- **目录不存在**:在远程服务器执行 `mkdir -p /opt/1panel/www/sites/pm.xtrader.vip/index`

5
.env
View File

@ -1,3 +1,8 @@
# API 基础地址,不设置时默认 https://api.xtrader.vip
# 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释:
VITE_API_BASE_URL=http://192.168.3.21:8888
# SSH 部署npm run deploy可选覆盖
# DEPLOY_HOST=38.246.250.238
# DEPLOY_USER=root
# DEPLOY_PATH=/opt/1panel/www/sites/pm.xtrader.vip/index

View File

@ -1,3 +1,11 @@
# API 基础地址,不设置时默认 https://api.xtrader.vip
# 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释:
# API 基础地址(开发环境 npm run dev
# 不设置时默认 https://api.xtrader.vip
# 连接测试服务器时复制本文件为 .env 并取消下一行注释:
# VITE_API_BASE_URL=http://192.168.3.21:8888
#
# 生产打包/部署时自动使用 .env.production 中的 https://api.xtrader.vip
# SSH 部署npm run deploy不配置时使用默认值
# DEPLOY_HOST=38.246.250.238
# DEPLOY_USER=root
# DEPLOY_PATH=/opt/1panel/www/sites/pm.xtrader.vip/index

2
.env.production Normal file
View File

@ -0,0 +1,2 @@
# 生产环境 API 地址npm run build / npm run deploy 时自动使用)
VITE_API_BASE_URL=https://api.xtrader.vip

View File

@ -136,6 +136,12 @@ Usage notes:
<location>project</location>
</skill>
<skill>
<name>deploy</name>
<description>将 PolyClientVuetify 项目打包并通过 SSH 部署到远程服务器。用户说「部署」「发布」时使用此 skill。</description>
<location>project</location>
</skill>
</available_skills>
<!-- SKILLS_TABLE_END -->

View File

@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"deploy": "node --env-file=.env --env-file=.env.production scripts/deploy.mjs",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",

75
scripts/deploy.mjs Normal file
View File

@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* 将项目打包并部署到远程服务器
* 使用方式npm run deploy
* 通过 SSH rsync 部署到 root@38.246.250.238:/opt/1panel/www/sites/pm.xtrader.vip/index
*
* 环境变量可选DEPLOY_HOSTDEPLOY_USERDEPLOY_PATH
* 依赖rsync系统自带ssh 免密或密钥
*/
import { execSync, spawnSync } from 'child_process'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = join(__dirname, '..')
const distDir = join(projectRoot, 'dist')
const config = {
host: process.env.DEPLOY_HOST || '38.246.250.238',
user: process.env.DEPLOY_USER || 'root',
path: process.env.DEPLOY_PATH || '/opt/1panel/www/sites/pm.xtrader.vip/index',
}
const target = `${config.user}@${config.host}:${config.path}`
function build() {
const apiUrl = process.env.VITE_API_BASE_URL || 'https://api.xtrader.vip'
console.log(`📦 正在打包项目(.env.production API: ${apiUrl}...`)
execSync('npm run build', {
cwd: projectRoot,
stdio: 'inherit',
// 继承 process.env已由 --env-file=.env.production 加载生产配置)
env: process.env,
})
console.log('✅ 打包完成\n')
}
function deploy() {
const rsyncCheck = spawnSync('which', ['rsync'], { encoding: 'utf8' })
if (rsyncCheck.status !== 0) {
console.error('❌ 未找到 rsync')
process.exit(1)
}
console.log(`🔌 正在通过 SSH 部署到 ${target} ...`)
const result = spawnSync(
'rsync',
[
'-avz',
'--delete',
'--exclude=.DS_Store',
`${distDir}/`,
`${target}/`,
],
{
cwd: projectRoot,
stdio: 'inherit',
encoding: 'utf8',
}
)
if (result.status !== 0) {
console.error('❌ 部署失败')
process.exit(1)
}
console.log('\n🎉 部署成功!')
}
function main() {
build()
deploy()
}
main()

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useUserStore } from './stores/user'
@ -9,6 +9,12 @@ const userStore = useUserStore()
const currentRoute = computed(() => {
return route.path
})
onMounted(() => {
if (userStore.isLoggedIn) {
userStore.fetchUsdcBalance()
}
})
</script>
<template>

View File

@ -55,18 +55,30 @@ export interface PmEventListItem {
* - outcomePrices: 各选项价格 Yes/Up
*/
export interface PmEventMarketItem {
/** 市场 ID部分接口返回大写 ID */
ID?: number
/** 市场 ID部分接口或 JSON 序列化为小写 id */
id?: number
question?: string
slug?: string
/** 选项展示文案,与 outcomePrices 顺序一致 */
outcomes?: string[]
/** 各选项价格outcomes[0] 对应 outcomePrices[0] */
outcomePrices?: string[] | number[]
/** 市场对应的 clob token id 与 outcomePrices 和outcomes顺序一致 */
clobTokenIds:string[]
endDate?: string
volume?: number
[key: string]: unknown
}
/** 从市场项取 marketId兼容 ID / id */
export function getMarketId(m: PmEventMarketItem | null | undefined): string | undefined {
if (!m) return undefined
const raw = m.ID ?? m.id
return raw != null ? String(raw) : undefined
}
/** 对应 definitions polymarket.PmSeries 常用字段 */
export interface PmEventSeriesItem {
ID?: number
@ -128,20 +140,39 @@ export interface PmEventDetailResponse {
}
/**
* id Event
* findPmEvent Query
* doc.json: ID slug
*/
export interface FindPmEventParams {
/** Event 主键(数字 ID */
id?: number
/** Event 的 slug 标识 */
slug?: string
}
/**
* id / slug Event
* GET /PmEvent/findPmEvent
*
* Query
* - ID: numberEvent
* headers x-token
* - ID: number
* - slug: string
* - ID slug
* headers x-tokenx-user-id
*
* 200PmEventDetailResponse { code, data: PmEventListItem, msg }
*/
export async function findPmEvent(
id: number,
params: FindPmEventParams,
config?: { headers?: Record<string, string> }
): Promise<PmEventDetailResponse> {
return get<PmEventDetailResponse>('/PmEvent/findPmEvent', { ID: id }, config)
const query: Record<string, string | number> = {}
if (params.id != null) query.ID = params.id
if (params.slug != null && params.slug !== '') query.slug = params.slug
if (Object.keys(query).length === 0) {
throw new Error('findPmEvent: 至少需要传 id 或 slug')
}
return get<PmEventDetailResponse>('/PmEvent/findPmEvent', query, config)
}
/** 多选项卡片中单个选项(用于左右滑动切换) */
@ -163,6 +194,8 @@ export interface EventCardOutcome {
*/
export interface EventCardItem {
id: string
/** Event slug用于 findPmEvent 传参 */
slug?: string
marketTitle: string
chanceValue: number
marketInfo: string
@ -178,6 +211,8 @@ export interface EventCardItem {
noLabel?: string
/** 是否显示 NEW 角标 */
isNew?: boolean
/** 当前市场 ID单 market 时为第一个 market 的 ID供交易/Split 使用) */
marketId?: string
}
/** 内存缓存:列表数据,切换页面时复用,下拉刷新时清空 */
@ -262,12 +297,13 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
chanceValue: marketChance(m),
yesLabel: m.outcomes?.[0] ?? 'Yes',
noLabel: m.outcomes?.[1] ?? 'No',
marketId: m.ID != null ? String(m.ID) : undefined,
marketId: getMarketId(m),
}))
: undefined
return {
id,
slug: item.slug ?? undefined,
marketTitle,
chanceValue,
marketInfo,
@ -279,5 +315,6 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
yesLabel: firstMarket?.outcomes?.[0] ?? 'Yes',
noLabel: firstMarket?.outcomes?.[1] ?? 'No',
isNew: item.new === true,
marketId: getMarketId(firstMarket),
}
}

View File

@ -8,19 +8,41 @@ export interface ApiResponse<T = unknown> {
}
/**
* Split 192.168.3.21:8888 /PmMarket/split
* Split /PmMarket/split
* USDC Yes+No 1 USDC 1 Yes + 1 No
*/
export interface PmMarketSplitRequest {
/** 市场 ID */
marketId: string
/** 要 split 的 USDC 金额 */
amount: number
marketID: string
/** 要 split 的 USDC 金额(字符串) */
usdcAmount: string
}
/**
* Merge /PmMarket/merge
* Yes + No USDC1 Yes + 1 No 1 USDC
*/
export interface PmMarketMergeRequest {
/** 市场 ID */
marketID: string
/** 合并份额数量(字符串) */
amount: string
}
/**
* POST /PmMarket/merge
* YesNo USDC
*/
export async function pmMarketMerge(
data: PmMarketMergeRequest,
config?: { headers?: Record<string, string> }
): Promise<ApiResponse> {
return post<ApiResponse>('/PmMarket/merge', data, config)
}
/**
* /PmMarket/split
* - x-token
* - x-tokenx-user-id
* - 使 VITE_API_BASE_URL http://192.168.3.21:8888 连接测试服
*/
export async function pmMarketSplit(

View File

@ -6,7 +6,7 @@ const BASE_URL = typeof import.meta !== 'undefined' && (import.meta as unknown a
: 'https://api.xtrader.vip'
export interface RequestConfig {
/** 请求头,如 { 'x-token': token } */
/** 请求头,如 { 'x-token': token, 'x-user-id': userId } */
headers?: Record<string, string>
}

34
src/api/user.ts Normal file
View File

@ -0,0 +1,34 @@
import { get } from './request'
const USDC_DECIMALS = 1_000_000
/** getUsdcBalance 返回的 data 结构 */
export interface UsdcBalanceData {
amount: string
available: string
locked: string
}
export interface GetUsdcBalanceResponse {
code: number
data?: UsdcBalanceData
msg?: string
}
/** 将接口返回的原始数值(需除以 1000000转为显示用字符串 */
export function formatUsdcBalance(raw: string): string {
const n = Number(raw) / USDC_DECIMALS
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
/**
* GET /user/getUsdcBalance
* USDC x-token
* amountavailable 1000000 USDC
*/
export async function getUsdcBalance(authHeaders: Record<string, string>): Promise<GetUsdcBalanceResponse> {
const res = await get<GetUsdcBalanceResponse>('/user/getUsdcBalance', undefined, {
headers: authHeaders,
})
return res
}

View File

@ -181,6 +181,8 @@ const props = withDefaults(
chanceValue: number
marketInfo: string
id: string
/** Event slug用于 findPmEvent 传参 */
slug?: string
imageUrl?: string
category?: string
expiresAt?: string
@ -194,6 +196,8 @@ const props = withDefaults(
noLabel?: string
/** 是否显示 + NEW */
isNew?: boolean
/** 当前市场 ID单 market 时供交易/Split 使用) */
marketId?: string
}>(),
{
marketTitle: 'Mamdan opens city-owned grocery store b...',
@ -208,6 +212,7 @@ const props = withDefaults(
yesLabel: 'Yes',
noLabel: 'No',
isNew: false,
marketId: undefined,
}
)
@ -262,7 +267,10 @@ const semiProgressColor = computed(() => {
const navigateToDetail = () => {
if (props.displayType === 'multi' && (props.outcomes?.length ?? 0) > 1) {
router.push({ path: `/event/${props.id}/markets` })
router.push({
path: `/event/${props.id}/markets`,
query: { ...(props.slug && { slug: props.slug }) },
})
return
}
router.push({
@ -274,12 +282,14 @@ const navigateToDetail = () => {
marketInfo: props.marketInfo || undefined,
expiresAt: props.expiresAt || undefined,
chance: String(props.chanceValue),
...(props.marketId && { marketId: props.marketId }),
...(props.slug && { slug: props.slug }),
},
})
}
function openTradeSingle(side: 'yes' | 'no') {
emit('openTrade', side, { id: props.id, title: props.marketTitle })
emit('openTrade', side, { id: props.id, title: props.marketTitle, marketId: props.marketId })
}
function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) {

View File

@ -579,9 +579,19 @@
Available shares: {{ availableMergeShares }}
<button type="button" class="merge-max-link" @click="setMergeMax">Max</button>
</p>
<p v-if="!props.market?.marketId" class="merge-no-market">Please select a market first (e.g. click Buy Yes/No on a market).</p>
<p v-if="mergeError" class="merge-error">{{ mergeError }}</p>
</v-card-text>
<v-card-actions class="merge-dialog-actions">
<v-btn color="primary" variant="flat" block class="merge-submit-btn" @click="submitMerge">
<v-btn
color="primary"
variant="flat"
block
class="merge-submit-btn"
:loading="mergeLoading"
:disabled="mergeLoading || mergeAmount <= 0"
@click="submitMerge"
>
Merge Shares
</v-btn>
</v-card-actions>
@ -624,7 +634,7 @@
block
class="split-submit-btn"
:loading="splitLoading"
:disabled="splitLoading || !props.market?.marketId || splitAmount <= 0"
:disabled="splitLoading || splitAmount <= 0"
@click="submitSplit"
>
Split
@ -638,7 +648,7 @@
import { ref, computed, watch, onMounted } from 'vue'
import { useDisplay } from 'vuetify'
import { useUserStore } from '../stores/user'
import { pmMarketSplit } from '../api/market'
import { pmMarketMerge, pmMarketSplit } from '../api/market'
const { mobile } = useDisplay()
const userStore = useUserStore()
@ -663,19 +673,42 @@ const props = withDefaults(
//
const sheetOpen = ref(false)
// Merge shares dialog
// Merge shares dialog /PmMarket/merge
const mergeDialogOpen = ref(false)
const mergeAmount = ref(0)
const availableMergeShares = ref(0)
const mergeLoading = ref(false)
const mergeError = ref('')
function openMergeDialog() {
mergeAmount.value = 0
mergeError.value = ''
mergeDialogOpen.value = true
}
function setMergeMax() {
mergeAmount.value = availableMergeShares.value
}
function submitMerge() {
mergeDialogOpen.value = false
async function submitMerge() {
const marketId = props.market?.marketId
if (mergeAmount.value <= 0) return
if (!marketId) return
mergeLoading.value = true
mergeError.value = ''
try {
const res = await pmMarketMerge(
{ marketID: marketId, amount: String(mergeAmount.value) },
{ headers: userStore.getAuthHeaders() }
)
if (res.code === 0 || res.code === 200) {
mergeDialogOpen.value = false
userStore.fetchUsdcBalance()
} else {
mergeError.value = res.msg || 'Merge failed'
}
} catch (e) {
mergeError.value = e instanceof Error ? e.message : 'Request failed'
} finally {
mergeLoading.value = false
}
}
// Split dialog /PmMarket/split
@ -684,19 +717,20 @@ const splitAmount = ref(0)
const splitLoading = ref(false)
const splitError = ref('')
function openSplitDialog() {
splitAmount.value = 0
splitAmount.value = splitAmount.value > 0 ? splitAmount.value : 1
splitError.value = ''
splitDialogOpen.value = true
}
async function submitSplit() {
const marketId = props.market?.marketId
if (!marketId || splitAmount.value <= 0) return
if (splitAmount.value <= 0) return
if (!marketId) return // split-no-market splitError
splitLoading.value = true
splitError.value = ''
try {
const res = await pmMarketSplit(
{ marketId, amount: splitAmount.value },
{ headers: userStore.token ? { 'x-token': userStore.token } : undefined }
{ marketID: marketId, usdcAmount: String(splitAmount.value) },
{ headers: userStore.getAuthHeaders() }
)
if (res.code === 0 || res.code === 200) {
splitDialogOpen.value = false
@ -766,20 +800,29 @@ const actionButtonText = computed(() => {
function applyInitialOption(option: 'yes' | 'no') {
selectedOption.value = option
syncLimitPriceFromMarket()
}
/** 根据当前 props.market 与 selectedOption 同步 limitPrice组件显示或 market 更新时调用) */
function syncLimitPriceFromMarket() {
const yesP = props.market?.yesPrice ?? 0.19
const noP = props.market?.noPrice ?? 0.82
limitPrice.value = option === 'yes' ? yesP : noP
limitPrice.value = selectedOption.value === 'yes' ? yesP : noP
}
onMounted(() => {
if (props.initialOption) applyInitialOption(props.initialOption)
else if (props.market) syncLimitPriceFromMarket()
})
watch(() => props.initialOption, (option) => {
if (option) applyInitialOption(option)
}, { immediate: true })
watch(() => props.market, (m) => {
if (m && props.initialOption) applyInitialOption(props.initialOption)
if (m) {
if (props.initialOption) applyInitialOption(props.initialOption)
else syncLimitPriceFromMarket()
}
}, { deep: true })
// Methods
@ -1366,6 +1409,20 @@ function submitOrder() {
text-decoration: underline;
}
.merge-no-market,
.merge-error {
font-size: 13px;
margin: 8px 0 0;
}
.merge-no-market {
color: #6b7280;
}
.merge-error {
color: #dc2626;
}
.merge-dialog-actions {
padding: 16px 20px 20px;
padding-top: 8px;

View File

@ -1,8 +1,11 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { getUsdcBalance, formatUsdcBalance } from '@/api/user'
export interface UserInfo {
/** 用户 IDAPI 可能返回 id 或 ID */
id?: number | string
ID?: number
headerImg?: string
nickName?: string
userName?: string
@ -62,5 +65,29 @@ export const useUserStore = defineStore('user', () => {
clearStorage()
}
return { token, user, isLoggedIn, avatarUrl, balance, setUser, logout }
/** 鉴权请求头x-token 与 x-user-id未登录时返回 undefined */
function getAuthHeaders(): Record<string, string> | undefined {
if (!token.value || !user.value) return undefined
const uid = user.value.id ?? user.value.ID
return {
'x-token': token.value,
...(uid != null && uid !== '' && { 'x-user-id': String(uid) }),
}
}
/** 请求 USDC 余额需已登录amount/available 除以 1000000 后更新余额显示 */
async function fetchUsdcBalance() {
const headers = getAuthHeaders()
if (!headers) return
try {
const res = await getUsdcBalance(headers)
if (res.code === 0 && res.data) {
balance.value = formatUsdcBalance(res.data.available)
}
} catch (e) {
console.error('[fetchUsdcBalance] 请求失败:', e)
}
}
return { token, user, isLoggedIn, avatarUrl, balance, setUser, logout, getAuthHeaders, fetchUsdcBalance }
})

View File

@ -123,7 +123,7 @@ import { useRoute, useRouter } from 'vue-router'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import TradeComponent from '../components/TradeComponent.vue'
import { findPmEvent, type PmEventListItem, type PmEventMarketItem } from '../api/event'
import { findPmEvent, getMarketId, type FindPmEventParams, type PmEventListItem, type PmEventMarketItem } from '../api/event'
import { MOCK_EVENT_LIST } from '../api/mockEventList'
import { useUserStore } from '../stores/user'
@ -152,7 +152,7 @@ const tradeMarketPayload = computed(() => {
const yesPrice = yesRaw != null && Number.isFinite(Number(yesRaw)) ? Number(yesRaw) : 0.5
const noPrice = noRaw != null && Number.isFinite(Number(noRaw)) ? Number(noRaw) : 0.5
return {
marketId: m.ID != null ? String(m.ID) : undefined,
marketId: getMarketId(m),
yesPrice,
noPrice,
title: m.question,
@ -472,29 +472,38 @@ function goToTradeDetail(market: PmEventMarketItem, side?: 'yes' | 'no') {
marketInfo: formatVolume(market.volume),
chance: String(marketChance(market)),
...(side && { side }),
...(eventDetail.value?.slug && { slug: eventDetail.value.slug }),
},
})
}
async function loadEventDetail() {
const idRaw = route.params.id
const id = typeof idRaw === 'string' ? parseInt(idRaw, 10) : Number(idRaw)
if (!Number.isFinite(id) || id < 1) {
detailError.value = '无效的 ID'
const idStr = String(idRaw ?? '').trim()
if (!idStr) {
detailError.value = '无效的 ID 或 slug'
eventDetail.value = null
return
}
const numId = parseInt(idStr, 10)
const isNumericId = Number.isFinite(numId) && String(numId) === idStr && numId >= 1
const slugFromQuery = (route.query.slug as string)?.trim()
const params: FindPmEventParams = {
id: isNumericId ? numId : undefined,
slug: isNumericId ? (slugFromQuery || undefined) : idStr,
}
detailError.value = null
detailLoading.value = true
try {
const res = await findPmEvent(id, {
headers: userStore.token ? { 'x-token': userStore.token } : undefined,
const res = await findPmEvent(params, {
headers: userStore.getAuthHeaders(),
})
if (res.code === 0 || res.code === 200) {
eventDetail.value = res.data ?? null
detailError.value = null
} else {
const fallback = getMockEventById(id)
const fallback = isNumericId ? getMockEventById(numId) : null
if (fallback) {
eventDetail.value = fallback
detailError.value = null
@ -504,7 +513,7 @@ async function loadEventDetail() {
}
}
} catch (e) {
const fallback = getMockEventById(id)
const fallback = isNumericId ? getMockEventById(numId) : null
if (fallback) {
eventDetail.value = fallback
detailError.value = null

View File

@ -17,6 +17,7 @@
v-for="card in eventList"
:key="card.id"
:id="card.id"
:slug="card.slug"
:market-title="card.marketTitle"
:chance-value="card.chanceValue"
:market-info="card.marketInfo"
@ -28,6 +29,7 @@
:yes-label="card.yesLabel"
:no-label="card.noLabel"
:is-new="card.isNew"
:market-id="card.marketId"
@open-trade="onCardOpenTrade"
/>
<div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty">
@ -68,6 +70,7 @@
>
<TradeComponent
:key="`trade-${tradeDialogMarket?.id}-${tradeDialogSide}`"
:market="homeTradeMarketPayload"
:initial-option="tradeDialogSide"
/>
</v-dialog>
@ -78,6 +81,7 @@
>
<TradeComponent
:key="`trade-${tradeDialogMarket?.id}-${tradeDialogSide}`"
:market="homeTradeMarketPayload"
:initial-option="tradeDialogSide"
embedded-in-sheet
/>
@ -198,14 +202,26 @@ const noMoreEvents = computed(() => {
const footerLang = ref('English')
const tradeDialogOpen = ref(false)
const tradeDialogSide = ref<'yes' | 'no'>('yes')
const tradeDialogMarket = ref<{ id: string; title: string } | null>(null)
const tradeDialogMarket = ref<{ id: string; title: string; marketId?: string } | null>(null)
const scrollRef = ref<HTMLElement | null>(null)
function onCardOpenTrade(side: 'yes' | 'no', market?: { id: string; title: string }) {
function onCardOpenTrade(side: 'yes' | 'no', market?: { id: string; title: string; marketId?: string }) {
tradeDialogSide.value = side
tradeDialogMarket.value = market ?? null
tradeDialogOpen.value = true
}
/** 传给 TradeComponent 的 marketHome 弹窗/底部栏),供 Split 等使用;优先用 marketId无则用事件 id */
const homeTradeMarketPayload = computed(() => {
const m = tradeDialogMarket.value
if (!m) return undefined
const marketId = m.marketId ?? m.id
const chance = 50
const yesPrice = Math.min(1, Math.max(0, chance / 100))
const noPrice = 1 - yesPrice
return { marketId, yesPrice, noPrice, title: m.title }
})
const sentinelRef = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
let resizeObserver: ResizeObserver | null = null

View File

@ -66,7 +66,9 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { BrowserProvider } from 'ethers'
import { SiweMessage } from 'siwe'
import { useUserStore } from '../stores/user'
import { useUserStore } from '@/stores/user'
import type { UserInfo } from '@/stores/user'
import { post } from '@/api/request'
const router = useRouter()
const userStore = useUserStore()
@ -165,33 +167,24 @@ const connectWithWallet = async () => {
console.log('Signature:', signature)
// Call login API
const loginResponse = await fetch('https://api.xtrader.vip/base/walletLogin', {
//http://localhost:8080 //https://api.xtrader.vip
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
// Call login API使 BASE_URL VITE_API_BASE_URL
const loginData = await post<{ code: number; data?: { token: string; user?: UserInfo } }>(
'/base/walletLogin',
{
Message: message1,
nonce,
signature,
walletAddress,
}),
})
if (!loginResponse.ok) {
throw new Error('Login failed. Please try again.')
}
const loginData = await loginResponse.json()
}
)
console.log('Login API response:', loginData)
if (loginData.code === 0 && loginData.data) {
userStore.setUser({
token: loginData.data.token,
user: loginData.data.user ?? null,
user: loginData.data.user,
})
userStore.fetchUsdcBalance()
}
router.push('/')

View File

@ -110,10 +110,13 @@
</v-card>
</v-col>
<!-- 右侧交易组件固定宽度 -->
<!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口 -->
<v-col cols="12" class="trade-col">
<div class="trade-sidebar">
<TradeComponent />
<TradeComponent
:market="tradeMarketPayload"
:initial-option="tradeInitialOption"
/>
</div>
</v-col>
</v-row>
@ -127,7 +130,7 @@ import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import OrderBook from '../components/OrderBook.vue'
import TradeComponent from '../components/TradeComponent.vue'
import { findPmEvent, type PmEventListItem } from '../api/event'
import { findPmEvent, getMarketId, type FindPmEventParams, type PmEventListItem } from '../api/event'
import { useUserStore } from '../stores/user'
/**
@ -184,17 +187,25 @@ function formatExpiresAt(endDate: string | undefined): string {
async function loadEventDetail() {
const idRaw = route.params.id
const id = typeof idRaw === 'string' ? parseInt(idRaw, 10) : Number(idRaw)
if (!Number.isFinite(id) || id < 1) {
detailError.value = '无效的 ID'
const idStr = String(idRaw ?? '').trim()
if (!idStr) {
detailError.value = '无效的 ID 或 slug'
eventDetail.value = null
return
}
const numId = parseInt(idStr, 10)
const isNumericId = Number.isFinite(numId) && String(numId) === idStr && numId >= 1
const slugFromQuery = (route.query.slug as string)?.trim()
const params: FindPmEventParams = {
id: isNumericId ? numId : undefined,
slug: isNumericId ? (slugFromQuery || undefined) : idStr,
}
detailError.value = null
detailLoading.value = true
try {
const res = await findPmEvent(id, {
headers: userStore.token ? { 'x-token': userStore.token } : undefined
const res = await findPmEvent(params, {
headers: userStore.getAuthHeaders()
})
if (res.code === 0 || res.code === 200) {
eventDetail.value = res.data ?? null
@ -228,6 +239,55 @@ const resolutionDate = computed(() => {
return s ? s.replace(/,?\s*\d{4}$/, '').trim() || 'Mar 31' : 'Mar 31'
})
/** 当前市场(用于交易组件与 Split 拆单query.marketId 匹配或取第一个 */
const currentMarket = computed(() => {
const list = eventDetail.value?.markets ?? []
if (list.length === 0) return null
const qId = route.query.marketId
if (qId != null && String(qId).trim() !== '') {
const qStr = String(qId).trim()
const found = list.find((m) => getMarketId(m) === qStr)
if (found) return found
}
return list[0]
})
/** 传给 TradeComponent 的 market供 Split 调用 /PmMarket/split接口未返回时用 query 兜底 */
const tradeMarketPayload = computed(() => {
const m = currentMarket.value
if (m) {
const yesRaw = m.outcomePrices?.[0]
const noRaw = m.outcomePrices?.[1]
const yesPrice = yesRaw != null && Number.isFinite(Number(yesRaw)) ? Number(yesRaw) : 0.5
const noPrice = noRaw != null && Number.isFinite(Number(noRaw)) ? Number(noRaw) : 0.5
return {
marketId: getMarketId(m),
yesPrice,
noPrice,
title: m.question,
}
}
const qId = route.query.marketId
if (qId != null && String(qId).trim() !== '') {
const chance = route.query.chance != null ? Number(route.query.chance) : NaN
const yesPrice = Number.isFinite(chance) ? Math.min(1, Math.max(0, chance / 100)) : 0.5
const noPrice = Number.isFinite(chance) ? 1 - yesPrice : 0.5
return {
marketId: String(qId).trim(),
yesPrice,
noPrice,
title: (route.query.title as string) || undefined,
}
}
return undefined
})
const tradeInitialOption = computed(() => {
const side = route.query.side
if (side === 'yes' || side === 'no') return side
return undefined
})
// Comments / Top Holders / Activity
const detailTab = ref('activity')
const activityMinAmount = ref<string>('0')

View File

@ -504,7 +504,7 @@ interface Position {
/** 从 avgNow "72¢ → 0.5¢" 解析出 [avg, now] */
function parseAvgNow(avgNow: string): [string, string] {
const parts = avgNow.split(' → ')
return parts.length >= 2 ? [parts[0].trim(), parts[1].trim()] : [avgNow, '']
return parts.length >= 2 ? [parts[0]!.trim(), parts[1]!.trim()] : [avgNow, '']
}
interface OpenOrder {
id: string

View File

@ -1,25 +1,14 @@
// import { fileURLToPath, URL } from 'node:url'
// import { defineConfig } from 'vite'
// import vue from '@vitejs/plugin-vue'
// import vueJsx from '@vitejs/plugin-vue-jsx'
// import vueDevTools from 'vite-plugin-vue-devtools'
// // https://vite.dev/config/
// export default defineConfig({
// plugins: [vue(), vueJsx(), vueDevTools()],
// resolve: {
// alias: {
// '@': fileURLToPath(new URL('./src', import.meta.url)),
// },
// },
// })
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 1. 导入插件
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
plugins: [
vue(),
// 2. 将此插件添加到插件数组中
@ -32,4 +21,7 @@ export default defineConfig({
define: {
'process.env': {},
},
server: {
host: true, // 监听 0.0.0.0,允许局域网内其他设备访问
},
})