From 297d2d1c5649e91a690248a9b6e5dce2e12c7924 Mon Sep 17 00:00:00 2001
From: ivan
Date: Wed, 11 Feb 2026 19:24:54 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E6=8B=86=E5=88=86?=
=?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=92=8C=E5=90=88=E5=B9=B6=E8=AE=A2=E5=8D=95?=
=?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=AF=B9=E6=8E=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.cursor/skills/deploy/SKILL.md | 47 ++++++++++++++++++
.env | 5 ++
.env.example | 12 ++++-
.env.production | 2 +
AGENTS.md | 6 +++
package.json | 1 +
scripts/deploy.mjs | 75 ++++++++++++++++++++++++++++
src/App.vue | 8 ++-
src/api/event.ts | 49 ++++++++++++++++---
src/api/market.ts | 32 ++++++++++--
src/api/request.ts | 2 +-
src/api/user.ts | 34 +++++++++++++
src/components/MarketCard.vue | 14 +++++-
src/components/TradeComponent.vue | 81 ++++++++++++++++++++++++++-----
src/stores/user.ts | 29 ++++++++++-
src/views/EventMarkets.vue | 27 +++++++----
src/views/Home.vue | 20 +++++++-
src/views/Login.vue | 29 +++++------
src/views/TradeDetail.vue | 76 ++++++++++++++++++++++++++---
src/views/Wallet.vue | 2 +-
vite.config.ts | 26 ++++------
21 files changed, 492 insertions(+), 85 deletions(-)
create mode 100644 .cursor/skills/deploy/SKILL.md
create mode 100644 .env.production
create mode 100644 scripts/deploy.mjs
create mode 100644 src/api/user.ts
diff --git a/.cursor/skills/deploy/SKILL.md b/.cursor/skills/deploy/SKILL.md
new file mode 100644
index 0000000..834e450
--- /dev/null
+++ b/.cursor/skills/deploy/SKILL.md
@@ -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`
diff --git a/.env b/.env
index 4e9b7fb..b224459 100644
--- a/.env
+++ b/.env
@@ -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
diff --git a/.env.example b/.env.example
index 2141460..cf34f1e 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..cb04907
--- /dev/null
+++ b/.env.production
@@ -0,0 +1,2 @@
+# 生产环境 API 地址(npm run build / npm run deploy 时自动使用)
+VITE_API_BASE_URL=https://api.xtrader.vip
diff --git a/AGENTS.md b/AGENTS.md
index 1d27d9f..79b8672 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -136,6 +136,12 @@ Usage notes:
project
+
+deploy
+将 PolyClientVuetify 项目打包并通过 SSH 部署到远程服务器。用户说「部署」「发布」时使用此 skill。
+project
+
+
diff --git a/package.json b/package.json
index d9affc0..4722a00 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs
new file mode 100644
index 0000000..19ad06d
--- /dev/null
+++ b/scripts/deploy.mjs
@@ -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_HOST、DEPLOY_USER、DEPLOY_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()
diff --git a/src/App.vue b/src/App.vue
index 204e94c..e096ddd 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,5 +1,5 @@
diff --git a/src/api/event.ts b/src/api/event.ts
index 0fb4804..3c39863 100644
--- a/src/api/event.ts
+++ b/src/api/event.ts
@@ -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: number,必填,Event 主键
- * 鉴权:需在 headers 中传 x-token
+ * - ID: number,可选
+ * - slug: string,可选
+ * - ID 与 slug 至少传一个,可同时传
+ * 鉴权:需在 headers 中传 x-token、x-user-id
*
* 响应(200):PmEventDetailResponse { code, data: PmEventListItem, msg }
*/
export async function findPmEvent(
- id: number,
+ params: FindPmEventParams,
config?: { headers?: Record }
): Promise {
- return get('/PmEvent/findPmEvent', { ID: id }, config)
+ const query: Record = {}
+ 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('/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),
}
}
diff --git a/src/api/market.ts b/src/api/market.ts
index 7c27ab2..87550a2 100644
--- a/src/api/market.ts
+++ b/src/api/market.ts
@@ -8,19 +8,41 @@ export interface ApiResponse {
}
/**
- * 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 份额得到 USDC(1 Yes + 1 No ≈ 1 USDC)
+ */
+export interface PmMarketMergeRequest {
+ /** 市场 ID */
+ marketID: string
+ /** 合并份额数量(字符串) */
+ amount: string
+}
+
+/**
+ * POST /PmMarket/merge
+ * 合并 Yes、No 份额为 USDC,需鉴权
+ */
+export async function pmMarketMerge(
+ data: PmMarketMergeRequest,
+ config?: { headers?: Record }
+): Promise {
+ return post('/PmMarket/merge', data, config)
}
/**
* 调用测试服务器 /PmMarket/split 接口
- * - 需鉴权:请求头 x-token
+ * - 需鉴权:请求头 x-token、x-user-id
* - 使用 VITE_API_BASE_URL 时可设为 http://192.168.3.21:8888 连接测试服
*/
export async function pmMarketSplit(
diff --git a/src/api/request.ts b/src/api/request.ts
index fa6116e..48be2d8 100644
--- a/src/api/request.ts
+++ b/src/api/request.ts
@@ -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
}
diff --git a/src/api/user.ts b/src/api/user.ts
new file mode 100644
index 0000000..8c3c95b
--- /dev/null
+++ b/src/api/user.ts
@@ -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)
+ * amount、available 需除以 1000000 得到实际 USDC
+ */
+export async function getUsdcBalance(authHeaders: Record): Promise {
+ const res = await get('/user/getUsdcBalance', undefined, {
+ headers: authHeaders,
+ })
+ return res
+}
diff --git a/src/components/MarketCard.vue b/src/components/MarketCard.vue
index bfe36ea..97785e0 100644
--- a/src/components/MarketCard.vue
+++ b/src/components/MarketCard.vue
@@ -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) {
diff --git a/src/components/TradeComponent.vue b/src/components/TradeComponent.vue
index 32e3a69..651b479 100644
--- a/src/components/TradeComponent.vue
+++ b/src/components/TradeComponent.vue
@@ -579,9 +579,19 @@
Available shares: {{ availableMergeShares }}
+ Please select a market first (e.g. click Buy Yes/No on a market).
+ {{ mergeError }}
-
+
Merge Shares
@@ -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;
diff --git a/src/stores/user.ts b/src/stores/user.ts
index 5dc6154..6ee6e67 100644
--- a/src/stores/user.ts
+++ b/src/stores/user.ts
@@ -1,8 +1,11 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
+import { getUsdcBalance, formatUsdcBalance } from '@/api/user'
export interface UserInfo {
+ /** 用户 ID(API 可能返回 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 | 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 }
})
diff --git a/src/views/EventMarkets.vue b/src/views/EventMarkets.vue
index 05d3d6c..1e86fe1 100644
--- a/src/views/EventMarkets.vue
+++ b/src/views/EventMarkets.vue
@@ -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
diff --git a/src/views/Home.vue b/src/views/Home.vue
index 099d0b4..c0c6215 100644
--- a/src/views/Home.vue
+++ b/src/views/Home.vue
@@ -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"
/>
@@ -68,6 +70,7 @@
>
@@ -78,6 +81,7 @@
>
@@ -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(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 的 market(Home 弹窗/底部栏),供 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(null)
let observer: IntersectionObserver | null = null
let resizeObserver: ResizeObserver | null = null
diff --git a/src/views/Login.vue b/src/views/Login.vue
index 169e7aa..3c6ddbc 100644
--- a/src/views/Login.vue
+++ b/src/views/Login.vue
@@ -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('/')
diff --git a/src/views/TradeDetail.vue b/src/views/TradeDetail.vue
index 4b3c82a..d5081dd 100644
--- a/src/views/TradeDetail.vue
+++ b/src/views/TradeDetail.vue
@@ -110,10 +110,13 @@
-
+
@@ -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('0')
diff --git a/src/views/Wallet.vue b/src/views/Wallet.vue
index 8b07cac..016d69d 100644
--- a/src/views/Wallet.vue
+++ b/src/views/Wallet.vue
@@ -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
diff --git a/vite.config.ts b/vite.config.ts
index 5454e93..0273622 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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,允许局域网内其他设备访问
+ },
})