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

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 # API 基础地址,不设置时默认 https://api.xtrader.vip
# 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释: # 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释:
VITE_API_BASE_URL=http://192.168.3.21:8888 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 # API 基础地址(开发环境 npm run dev
# 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释: # 不设置时默认 https://api.xtrader.vip
# 连接测试服务器时复制本文件为 .env 并取消下一行注释:
# VITE_API_BASE_URL=http://192.168.3.21:8888 # 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> <location>project</location>
</skill> </skill>
<skill>
<name>deploy</name>
<description>将 PolyClientVuetify 项目打包并通过 SSH 部署到远程服务器。用户说「部署」「发布」时使用此 skill。</description>
<location>project</location>
</skill>
</available_skills> </available_skills>
<!-- SKILLS_TABLE_END --> <!-- SKILLS_TABLE_END -->

View File

@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p type-check \"build-only {@}\" --",
"deploy": "node --env-file=.env --env-file=.env.production scripts/deploy.mjs",
"preview": "vite preview", "preview": "vite preview",
"test:unit": "vitest", "test:unit": "vitest",
"test:e2e": "playwright test", "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"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useUserStore } from './stores/user' import { useUserStore } from './stores/user'
@ -9,6 +9,12 @@ const userStore = useUserStore()
const currentRoute = computed(() => { const currentRoute = computed(() => {
return route.path return route.path
}) })
onMounted(() => {
if (userStore.isLoggedIn) {
userStore.fetchUsdcBalance()
}
})
</script> </script>
<template> <template>

View File

@ -55,18 +55,30 @@ export interface PmEventListItem {
* - outcomePrices: 各选项价格 Yes/Up * - outcomePrices: 各选项价格 Yes/Up
*/ */
export interface PmEventMarketItem { export interface PmEventMarketItem {
/** 市场 ID部分接口返回大写 ID */
ID?: number ID?: number
/** 市场 ID部分接口或 JSON 序列化为小写 id */
id?: number
question?: string question?: string
slug?: string slug?: string
/** 选项展示文案,与 outcomePrices 顺序一致 */ /** 选项展示文案,与 outcomePrices 顺序一致 */
outcomes?: string[] outcomes?: string[]
/** 各选项价格outcomes[0] 对应 outcomePrices[0] */ /** 各选项价格outcomes[0] 对应 outcomePrices[0] */
outcomePrices?: string[] | number[] outcomePrices?: string[] | number[]
/** 市场对应的 clob token id 与 outcomePrices 和outcomes顺序一致 */
clobTokenIds:string[]
endDate?: string endDate?: string
volume?: number volume?: number
[key: string]: unknown [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 常用字段 */ /** 对应 definitions polymarket.PmSeries 常用字段 */
export interface PmEventSeriesItem { export interface PmEventSeriesItem {
ID?: number 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 * GET /PmEvent/findPmEvent
* *
* Query * Query
* - ID: numberEvent * - ID: number
* headers x-token * - slug: string
* - ID slug
* headers x-tokenx-user-id
* *
* 200PmEventDetailResponse { code, data: PmEventListItem, msg } * 200PmEventDetailResponse { code, data: PmEventListItem, msg }
*/ */
export async function findPmEvent( export async function findPmEvent(
id: number, params: FindPmEventParams,
config?: { headers?: Record<string, string> } config?: { headers?: Record<string, string> }
): Promise<PmEventDetailResponse> { ): 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 { export interface EventCardItem {
id: string id: string
/** Event slug用于 findPmEvent 传参 */
slug?: string
marketTitle: string marketTitle: string
chanceValue: number chanceValue: number
marketInfo: string marketInfo: string
@ -178,6 +211,8 @@ export interface EventCardItem {
noLabel?: string noLabel?: string
/** 是否显示 NEW 角标 */ /** 是否显示 NEW 角标 */
isNew?: boolean isNew?: boolean
/** 当前市场 ID单 market 时为第一个 market 的 ID供交易/Split 使用) */
marketId?: string
} }
/** 内存缓存:列表数据,切换页面时复用,下拉刷新时清空 */ /** 内存缓存:列表数据,切换页面时复用,下拉刷新时清空 */
@ -262,12 +297,13 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
chanceValue: marketChance(m), chanceValue: marketChance(m),
yesLabel: m.outcomes?.[0] ?? 'Yes', yesLabel: m.outcomes?.[0] ?? 'Yes',
noLabel: m.outcomes?.[1] ?? 'No', noLabel: m.outcomes?.[1] ?? 'No',
marketId: m.ID != null ? String(m.ID) : undefined, marketId: getMarketId(m),
})) }))
: undefined : undefined
return { return {
id, id,
slug: item.slug ?? undefined,
marketTitle, marketTitle,
chanceValue, chanceValue,
marketInfo, marketInfo,
@ -279,5 +315,6 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
yesLabel: firstMarket?.outcomes?.[0] ?? 'Yes', yesLabel: firstMarket?.outcomes?.[0] ?? 'Yes',
noLabel: firstMarket?.outcomes?.[1] ?? 'No', noLabel: firstMarket?.outcomes?.[1] ?? 'No',
isNew: item.new === true, 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 * USDC Yes+No 1 USDC 1 Yes + 1 No
*/ */
export interface PmMarketSplitRequest { export interface PmMarketSplitRequest {
/** 市场 ID */ /** 市场 ID */
marketId: string marketID: string
/** 要 split 的 USDC 金额 */ /** 要 split 的 USDC 金额(字符串) */
amount: number 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 * /PmMarket/split
* - x-token * - x-tokenx-user-id
* - 使 VITE_API_BASE_URL http://192.168.3.21:8888 连接测试服 * - 使 VITE_API_BASE_URL http://192.168.3.21:8888 连接测试服
*/ */
export async function pmMarketSplit( 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' : 'https://api.xtrader.vip'
export interface RequestConfig { export interface RequestConfig {
/** 请求头,如 { 'x-token': token } */ /** 请求头,如 { 'x-token': token, 'x-user-id': userId } */
headers?: Record<string, string> 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 chanceValue: number
marketInfo: string marketInfo: string
id: string id: string
/** Event slug用于 findPmEvent 传参 */
slug?: string
imageUrl?: string imageUrl?: string
category?: string category?: string
expiresAt?: string expiresAt?: string
@ -194,6 +196,8 @@ const props = withDefaults(
noLabel?: string noLabel?: string
/** 是否显示 + NEW */ /** 是否显示 + NEW */
isNew?: boolean isNew?: boolean
/** 当前市场 ID单 market 时供交易/Split 使用) */
marketId?: string
}>(), }>(),
{ {
marketTitle: 'Mamdan opens city-owned grocery store b...', marketTitle: 'Mamdan opens city-owned grocery store b...',
@ -208,6 +212,7 @@ const props = withDefaults(
yesLabel: 'Yes', yesLabel: 'Yes',
noLabel: 'No', noLabel: 'No',
isNew: false, isNew: false,
marketId: undefined,
} }
) )
@ -262,7 +267,10 @@ const semiProgressColor = computed(() => {
const navigateToDetail = () => { const navigateToDetail = () => {
if (props.displayType === 'multi' && (props.outcomes?.length ?? 0) > 1) { 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 return
} }
router.push({ router.push({
@ -274,12 +282,14 @@ const navigateToDetail = () => {
marketInfo: props.marketInfo || undefined, marketInfo: props.marketInfo || undefined,
expiresAt: props.expiresAt || undefined, expiresAt: props.expiresAt || undefined,
chance: String(props.chanceValue), chance: String(props.chanceValue),
...(props.marketId && { marketId: props.marketId }),
...(props.slug && { slug: props.slug }),
}, },
}) })
} }
function openTradeSingle(side: 'yes' | 'no') { 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) { function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) {

View File

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

View File

@ -1,8 +1,11 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { getUsdcBalance, formatUsdcBalance } from '@/api/user'
export interface UserInfo { export interface UserInfo {
/** 用户 IDAPI 可能返回 id 或 ID */
id?: number | string id?: number | string
ID?: number
headerImg?: string headerImg?: string
nickName?: string nickName?: string
userName?: string userName?: string
@ -62,5 +65,29 @@ export const useUserStore = defineStore('user', () => {
clearStorage() 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 * as echarts from 'echarts'
import type { ECharts } from 'echarts' import type { ECharts } from 'echarts'
import TradeComponent from '../components/TradeComponent.vue' 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 { MOCK_EVENT_LIST } from '../api/mockEventList'
import { useUserStore } from '../stores/user' 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 yesPrice = yesRaw != null && Number.isFinite(Number(yesRaw)) ? Number(yesRaw) : 0.5
const noPrice = noRaw != null && Number.isFinite(Number(noRaw)) ? Number(noRaw) : 0.5 const noPrice = noRaw != null && Number.isFinite(Number(noRaw)) ? Number(noRaw) : 0.5
return { return {
marketId: m.ID != null ? String(m.ID) : undefined, marketId: getMarketId(m),
yesPrice, yesPrice,
noPrice, noPrice,
title: m.question, title: m.question,
@ -472,29 +472,38 @@ function goToTradeDetail(market: PmEventMarketItem, side?: 'yes' | 'no') {
marketInfo: formatVolume(market.volume), marketInfo: formatVolume(market.volume),
chance: String(marketChance(market)), chance: String(marketChance(market)),
...(side && { side }), ...(side && { side }),
...(eventDetail.value?.slug && { slug: eventDetail.value.slug }),
}, },
}) })
} }
async function loadEventDetail() { async function loadEventDetail() {
const idRaw = route.params.id const idRaw = route.params.id
const id = typeof idRaw === 'string' ? parseInt(idRaw, 10) : Number(idRaw) const idStr = String(idRaw ?? '').trim()
if (!Number.isFinite(id) || id < 1) { if (!idStr) {
detailError.value = '无效的 ID' detailError.value = '无效的 ID 或 slug'
eventDetail.value = null eventDetail.value = null
return 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 detailError.value = null
detailLoading.value = true detailLoading.value = true
try { try {
const res = await findPmEvent(id, { const res = await findPmEvent(params, {
headers: userStore.token ? { 'x-token': userStore.token } : undefined, headers: userStore.getAuthHeaders(),
}) })
if (res.code === 0 || res.code === 200) { if (res.code === 0 || res.code === 200) {
eventDetail.value = res.data ?? null eventDetail.value = res.data ?? null
detailError.value = null detailError.value = null
} else { } else {
const fallback = getMockEventById(id) const fallback = isNumericId ? getMockEventById(numId) : null
if (fallback) { if (fallback) {
eventDetail.value = fallback eventDetail.value = fallback
detailError.value = null detailError.value = null
@ -504,7 +513,7 @@ async function loadEventDetail() {
} }
} }
} catch (e) { } catch (e) {
const fallback = getMockEventById(id) const fallback = isNumericId ? getMockEventById(numId) : null
if (fallback) { if (fallback) {
eventDetail.value = fallback eventDetail.value = fallback
detailError.value = null detailError.value = null

View File

@ -17,6 +17,7 @@
v-for="card in eventList" v-for="card in eventList"
:key="card.id" :key="card.id"
:id="card.id" :id="card.id"
:slug="card.slug"
:market-title="card.marketTitle" :market-title="card.marketTitle"
:chance-value="card.chanceValue" :chance-value="card.chanceValue"
:market-info="card.marketInfo" :market-info="card.marketInfo"
@ -28,6 +29,7 @@
:yes-label="card.yesLabel" :yes-label="card.yesLabel"
:no-label="card.noLabel" :no-label="card.noLabel"
:is-new="card.isNew" :is-new="card.isNew"
:market-id="card.marketId"
@open-trade="onCardOpenTrade" @open-trade="onCardOpenTrade"
/> />
<div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty"> <div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty">
@ -68,6 +70,7 @@
> >
<TradeComponent <TradeComponent
:key="`trade-${tradeDialogMarket?.id}-${tradeDialogSide}`" :key="`trade-${tradeDialogMarket?.id}-${tradeDialogSide}`"
:market="homeTradeMarketPayload"
:initial-option="tradeDialogSide" :initial-option="tradeDialogSide"
/> />
</v-dialog> </v-dialog>
@ -78,6 +81,7 @@
> >
<TradeComponent <TradeComponent
:key="`trade-${tradeDialogMarket?.id}-${tradeDialogSide}`" :key="`trade-${tradeDialogMarket?.id}-${tradeDialogSide}`"
:market="homeTradeMarketPayload"
:initial-option="tradeDialogSide" :initial-option="tradeDialogSide"
embedded-in-sheet embedded-in-sheet
/> />
@ -198,14 +202,26 @@ const noMoreEvents = computed(() => {
const footerLang = ref('English') const footerLang = ref('English')
const tradeDialogOpen = ref(false) const tradeDialogOpen = ref(false)
const tradeDialogSide = ref<'yes' | 'no'>('yes') 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) 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 tradeDialogSide.value = side
tradeDialogMarket.value = market ?? null tradeDialogMarket.value = market ?? null
tradeDialogOpen.value = true 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) const sentinelRef = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null let observer: IntersectionObserver | null = null
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null

View File

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

View File

@ -110,10 +110,13 @@
</v-card> </v-card>
</v-col> </v-col>
<!-- 右侧交易组件固定宽度 --> <!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口 -->
<v-col cols="12" class="trade-col"> <v-col cols="12" class="trade-col">
<div class="trade-sidebar"> <div class="trade-sidebar">
<TradeComponent /> <TradeComponent
:market="tradeMarketPayload"
:initial-option="tradeInitialOption"
/>
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
@ -127,7 +130,7 @@ import * as echarts from 'echarts'
import type { ECharts } from 'echarts' import type { ECharts } from 'echarts'
import OrderBook from '../components/OrderBook.vue' import OrderBook from '../components/OrderBook.vue'
import TradeComponent from '../components/TradeComponent.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' import { useUserStore } from '../stores/user'
/** /**
@ -184,17 +187,25 @@ function formatExpiresAt(endDate: string | undefined): string {
async function loadEventDetail() { async function loadEventDetail() {
const idRaw = route.params.id const idRaw = route.params.id
const id = typeof idRaw === 'string' ? parseInt(idRaw, 10) : Number(idRaw) const idStr = String(idRaw ?? '').trim()
if (!Number.isFinite(id) || id < 1) { if (!idStr) {
detailError.value = '无效的 ID' detailError.value = '无效的 ID 或 slug'
eventDetail.value = null eventDetail.value = null
return 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 detailError.value = null
detailLoading.value = true detailLoading.value = true
try { try {
const res = await findPmEvent(id, { const res = await findPmEvent(params, {
headers: userStore.token ? { 'x-token': userStore.token } : undefined headers: userStore.getAuthHeaders()
}) })
if (res.code === 0 || res.code === 200) { if (res.code === 0 || res.code === 200) {
eventDetail.value = res.data ?? null eventDetail.value = res.data ?? null
@ -228,6 +239,55 @@ const resolutionDate = computed(() => {
return s ? s.replace(/,?\s*\d{4}$/, '').trim() || 'Mar 31' : 'Mar 31' 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 // Comments / Top Holders / Activity
const detailTab = ref('activity') const detailTab = ref('activity')
const activityMinAmount = ref<string>('0') const activityMinAmount = ref<string>('0')

View File

@ -504,7 +504,7 @@ interface Position {
/** 从 avgNow "72¢ → 0.5¢" 解析出 [avg, now] */ /** 从 avgNow "72¢ → 0.5¢" 解析出 [avg, now] */
function parseAvgNow(avgNow: string): [string, string] { function parseAvgNow(avgNow: string): [string, string] {
const parts = avgNow.split(' → ') 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 { interface OpenOrder {
id: string id: string

View File

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