Compare commits

...

10 Commits

Author SHA1 Message Date
ivan
8d103e2d98 新增:取消订单 2026-02-11 21:51:12 +08:00
ivan
b73a910b43 新增:限价单接口对接 2026-02-11 21:25:50 +08:00
ivan
297d2d1c56 新增:拆分订单和合并订单接口对接 2026-02-11 19:24:54 +08:00
ivan
e14bd3bc23 新增:多Market详情 2026-02-11 12:19:40 +08:00
ivan
c6e896efc3 优化:颜色样式优化 2026-02-10 18:06:42 +08:00
ivan
a572fdfa99 新增:多Market卡片UI 2026-02-10 17:29:54 +08:00
ivan
f0c1be71cb 新增:对接详情接口 2026-02-10 13:18:44 +08:00
ivan
def6b95b5b 新增:接口skill和项目结构skill 2026-02-10 13:18:03 +08:00
ivan
e603e04d0f 新增:列表数据接口 2026-02-09 22:32:11 +08:00
ivan
08ae68b767 优化:钱包手机端兼容 2026-02-09 16:34:51 +08:00
27 changed files with 4640 additions and 411 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`

View File

@ -0,0 +1,85 @@
---
name: project-framework
description: Defines the PolyClientVuetify project's framework, structure, and coding conventions. Use when adding or modifying any code in this repository so that changes follow the same stack, patterns, and style.
---
# 项目框架规范
在**新增或修改**本仓库内任何代码时,按以下规范执行,保证风格与架构一致。
## 技术栈
- **框架**Vue 3Composition API+ TypeScript
- **构建**Vite 7路径别名 `@/*``./src/*`
- **UI**Vuetify 4`v-app``v-card``v-btn` 等)
- **状态**Pinia
- **路由**Vue Router 5createWebHistory
- **代码质量**ESLint + Prettier + oxlint单测 VitestE2E Playwright
## 目录与文件放置
| 用途 | 目录/文件 | 说明 |
|----------------|------------------------------|------|
| 页面级视图 | `src/views/` | 对应路由,一个路由一个 Vue 文件 |
| 可复用组件 | `src/components/` | 被多个 view 或其它组件引用 |
| 接口与类型 | `src/api/` | 请求封装、接口函数、响应/请求类型 |
| 全局状态 | `src/stores/` | Pinia store按领域拆分 |
| 路由配置 | `src/router/index.ts` | 集中声明 routes |
| 入口与插件 | `src/main.ts``src/plugins/` | 不随意新增顶级文件 |
- 新页面:在 `views/` 新增 `.vue`,并在 `router/index.ts` 增加 `path``name``component`
- 新接口:在 `api/` 下合适模块(如 `event.ts`)或新建 `xxx.ts`,复用 `request.ts``get`/`post`,并导出类型与函数。
## Vue 组件规范
1. **单文件结构顺序**`<template>``<script setup lang="ts">``<style scoped>`
2. **Script**:一律使用 `<script setup lang="ts">`,不用 Options API。
3. **Props**:用 `defineProps({ ... })` 声明,带 `type` 与必要时 `default`;在 script 中通过 `props.xxx` 访问。
4. **Emits**:用 `defineEmits<{ eventName: [arg1Type, arg2?] }>()` 做类型声明,再 `emit('eventName', ...)`
5. **组合式用法**:从 `vue` 按需导入 `ref``computed``watch` 等;从 `vue-router``useRouter`/`useRoute`;从 `@/stores/xxx` 用对应 `useXxxStore()`
6. **组件命名**:文件名 PascalCase`MarketCard.vue`),在模板或路由中可写为 PascalCase 或 kebab-case。
## 模板与样式
- **模板**:优先使用 Vuetify 组件;自定义类名用 **kebab-case**(如 `market-card``semi-progress-wrap`)。
- **样式**:使用 `<style scoped>`,类名与模板中的 class 一致;需要时可加简短注释区分区块(如 `/* Top Section */`)。
- **多语言**:当前为中文 + 英文混用,文案风格与现有页面保持一致即可。
## API 层规范
- **基础请求**:统一通过 `src/api/request.ts``get<T>(path, params?, config?)` 等;鉴权在 `config.headers['x-token']` 传入。
- **接口模块**:每个接口文件导出:
- 请求/响应用到的 **TypeScript 接口**(如 `XxxListItem``XxxResponse``PageResult<T>`
- 对外调用的 **异步函数**(如 `getXxxList(params)`),函数上方用注释标明 `GET /Xxx/Yyy``POST /Xxx/Yyy`
- **命名**:列表项类型 `XxxListItem`,分页结果 `PageResult<T>`,统一响应体 `XxxResponse`;与现有 `event.ts` 保持一致。
## 状态Pinia
- 使用 **setup 写法**`defineStore('storeName', () => { ... return { state, getters, actions } })`
- 状态用 `ref`,派生用 `computed`,方法直接定义为函数并 return。
- 需要持久化的(如登录态)在 store 内用 `localStorage` 读写,键名常量化。
## 路由
- 路由表在 `src/router/index.ts` 中集中配置;每个路由包含 `path``name``component`
- 页面跳转使用 `router.push()``router.replace()`;需要带参时用 `path` + `query``params`(如 `/trade-detail/:id`)。
## 格式与规范
- **Prettier**已配置无分号、单引号、printWidth 100。修改或新增代码后保持格式化项目内可运行 `npm run format`)。
- **TypeScript**:启用严格类型;新接口、函数需有明确类型,避免 `any`
- **命名**
- 文件名:组件/视图 PascalCase其余 camelCase 或 kebab-case 与现有一致;
- 变量/函数camelCase
- 常量:全大写下划线或 camelCase 与现有文件一致;
- 类型/接口PascalCase。
## 修改与新增时的自检
- [ ] 新页面已加入 `router/index.ts`,组件放在 `views/``components/` 正确位置。
- [ ] 新接口在 `api/` 中实现,使用 `request.ts` 并导出类型与函数。
- [ ] Vue 文件使用 `<script setup lang="ts">`Props/Emits 有类型。
- [ ] 样式使用 scoped类名 kebab-case。
- [ ] 符合现有 Prettier/ESLint 配置,无新增 lint 报错。
遵循本规范可保证与本项目现有架构和风格一致;涉及接口文档与接口实现细节时,可结合 `xtrader-api-docs` skill 使用。

View File

@ -0,0 +1,136 @@
---
name: xtrader-api-docs
description: Interprets the XTrader API from the Swagger 2.0 spec at https://api.xtrader.vip/swagger/doc.json and helps implement endpoints, TypeScript types, and request helpers. Use when implementing or understanding XTrader API endpoints, adding new API calls, or when the user refers to the API docs or Swagger.
---
# XTrader API 接口文档解读
规范来源:[OpenAPI 规范](https://api.xtrader.vip/swagger/doc.json)Swagger 2.0。Swagger UI<https://api.xtrader.vip/swagger/index.html>
## 规范地址与格式
- **规范 URL**`https://api.xtrader.vip/swagger/doc.json`
- **格式**Swagger 2.0;接口在 `paths` 下,类型在 `definitions` 下,引用为 `#/definitions/xxx`(如 `response.Response``system.SysUser`)。
- **使用方式**:用 `mcp_web_fetch` 或项目内请求获取 doc.json解析 `paths``definitions` 编写 TS 类型与请求。鉴权:请求头 `x-token`(见 `securityDefinitions.ApiKeyAuth`)。
## Swagger UI 与 doc.json 的对应关系(如何定位 Example Value / Model
Swagger UI 页面(如 [PmEvent findPmEvent](https://api.xtrader.vip/swagger/index.html#/PmEvent/get_PmEvent_findPmEvent))的数据来源就是 **doc.json**。页面上的「Parameters」「Responses」里的 **Example Value****Model** 都对应 doc.json 里的同一套 schema。
### 对应关系一览
| Swagger UI 上看到的 | 在 doc.json 中的位置 |
|---------------------|----------------------|
| 某个接口如「用id查询Event Management」 | `paths["/PmEvent/findPmEvent"].get`(或对应 path + method |
| Parameters → Body / Query | `paths["/PmEvent/findPmEvent"].get.parameters`,或 body 的 `schema`(常为 `$ref` |
| **Responses → 200 → Model** | `paths["/PmEvent/findPmEvent"].get.responses["200"].schema`,且需把其中的 `$ref` 解析到 `definitions` |
| **Responses → 200 → Example Value** | 由同一 `responses["200"].schema` 生成(或 schema 里的 `example`);结构同 Model |
| Model 里展开的 `data``user` 等对象 | `definitions` 里对应的 schema`definitions["polymarket.PmEvent"]``definitions["system.SysUser"]` |
### 如何快速定位「响应」的 Model / 数据结构
1. **确定 path 和 method**
例如 findPmEventpath = `/PmEvent/findPmEvent`method = `get`
2. **取 200 的 schema**
```text
doc.json → paths["/PmEvent/findPmEvent"].get.responses["200"].schema
```
这就是页面上 200 的 **Model** 的完整定义(可能含 `allOf``$ref`)。
3. **解析 allOf + $ref**
- 若 schema 为 `allOf: [ { $ref: "#/definitions/response.Response" }, { type: "object", properties: { data: { $ref: "..." }, msg } } ]`,则合并后得到根结构:`code``data``msg`
- 对 `data``$ref`(如 `#/definitions/polymarket.PmEvent`),到 **definitions** 里找:
`definitions["polymarket.PmEvent"]`(或 `definitions["response.LoginResponse"]` 等)。
- definitions 的 key 在 JSON 里是字符串,如 `"polymarket.PmEvent"`(带引号),对应 Swagger 里 `#/definitions/polymarket.PmEvent`
4. **Example Value**
与 Model 共用同一份 `responses["200"].schema`Example Value 是 Swagger UI 按该 schema 生成的示例,或 schema 内 `example` 字段。所以**看响应结构时以 Model 为准**,在 doc.json 里就是上述 `responses["200"].schema` 及其引用的 `definitions`
## 接口集成规范(必须按顺序执行)
接入任意 XTrader 接口时,**必须**按以下顺序执行,不得跳过或调换。
### 第一步:列出请求参数与响应参数
在写代码前,先从 doc.json 中整理并列出:
1. **请求参数**
- **Query**`paths["<path>"]["<method>"].parameters``in: "query"` 的项name、type、required、description
- **Body**:若存在 `in: "body"`,写出其 `schema` 对应的 `$ref` 或内联结构(来自 `definitions`)。
- **鉴权**:该接口是否带 `security: [ { ApiKeyAuth: [] } ]`,若是则需在请求头加 `x-token`
2. **响应参数**
- 取 `paths["<path>"]["<method>"].responses["200"].schema`
- 若有 `allOf`,合并得到根结构(通常为 `code``data``msg`)。
- 对 `data` 及其他嵌套对象的 `$ref`,到 `definitions` 中查完整结构并列出字段(名称、类型、说明)。
输出形式可为表格或结构化列表,便于第二步写类型。
### 第二步:根据响应数据创建 Model 类
- 在 `src/api/` 下相应模块(如 `event.ts``market.ts`)中,**根据第一步整理出的响应结构**定义 TypeScript 类型/接口:
- 根响应:如 `XxxResponse { code: number; data: XxxData; msg: string }`
- `data` 及嵌套对象:对应 doc.json 的 `definitions`,写出完整 Model`PmEvent``PmMarket`),避免只写 `any` 或过度使用 `[key: string]: unknown`(仅在确有扩展字段时使用)。
- 命名与项目一致:`PageResult<T>``XxxResponse``XxxParams` 等;与 `event.ts` 风格保持一致。
### 第三步:将接口集成到页面
- 在对应 API 模块中实现请求函数:使用 `get`/`post``src/api/request.ts`路径、query/body 与第一步一致,返回类型使用第二步定义的 Response 类型。
- 在页面Vue 组件)中:调用该请求函数,将返回的 `data` 绑定到组件的状态或 UI处理 loading、错误与空数据若为鉴权接口确保调用时传入带 `x-token` 的 config或使用已注入 token 的 request 封装)。
---
## 解读规范并落地的步骤
1. **Base URL**:规范中 `host` + `basePath`(可为空);本项目已用 `src/api/request.ts``BASE_URL`(默认 `https://api.xtrader.vip`),实现时用相对 path 即可。
2. **路径与方法**:遍历 `paths`,每个 path + method 对应一个接口path 以 `/` 开头。需鉴权接口在请求中加 `config.headers['x-token']`
3. **请求参数**Query 对应 `parameters``in: 'query'`Body 对应 `in: 'body'``schema`(常为 `$ref``definitions`。Swagger 2.0 中 body 参数名为 `data` 时,实际请求体即该 schema 的 JSON。
4. **响应类型**:看 `responses['200'].schema`;若为 `allOf`,合并 `response.Response` 与扩展中的 `data``msg` 等。`$ref``#/definitions/XXX` 时,在 `definitions` 中查对应结构并转为 TS 接口。统一响应包装为 `{ code, data, msg }`
5. **与项目风格一致**:新接口放在 `src/api/` 下;导出请求/响应类型及调用 `get`/`post` 的函数;类型命名与 `event.ts` 一致(如 `PageResult<T>``XxxResponse`)。
## 已知接口(来自 doc.json
### 钱包登录 `POST /base/walletLogin`
- **请求体**`definitions.request.WalletLogin``{ message: string, nonce: string, signature: string, walletAddress: string }`
- **响应 200**`{ code, data, msg }``data``response.LoginResponse`
- **response.LoginResponse**`{ expiresAt: number, token: string, user: system.SysUser }`
- **system.SysUser**`data.user`):含 **ID**integer主键、userName、nickName、headerImg、uuid、authorityId、authority、createdAt、updatedAt、email、phone、enable、walletAddress 等。**返回结果中包含用户 id对应字段为 `user.ID`**JSON 中可能为 `ID``id`,与后端序列化一致即可)。
- **项目映射**`src/views/Login.vue` 调该接口;`src/stores/user.ts``UserInfo` 建议包含 `id?: number | string` 并与 `data.user.ID` 对应。
### 公开事件列表 `GET /PmEvent/getPmEventPublic`
- **Query**page、pageSize、keyword、createdAtRangearray、tokenid可选来自 market.clobTokenIds 的值,可传单个或数组)。
- **响应 200**`data``response.PageResult`list、page、pageSize、totallist 项为 `polymarket.PmEvent`,内含 markets、series、tags 等markets[].outcomePrices 为价格数组,首项对应 Yes 概率markets[].clobTokenIds 与 outcomes/outcomePrices 顺序一致。
### 订单类型与交易方向(用于交易接口,`src/api/constants.ts`
- **OrderType**GTC=0一直有效直到取消、GTD=1指定时间内有效、FOK=2全部成交或取消、FAK=3立即成交剩余取消、Market=4市价单
- **Side**Buy=1、Sell=2。
### 通用响应与鉴权
- **response.Response**`{ code: number, data: any, msg: string }`
- **response.PageResult**`{ list, page, pageSize, total }`
- 需鉴权接口在文档中带 `security: [ { ApiKeyAuth: [] } ]`,请求时加 header `x-token`
## 简要检查清单
- [ ] **按规范顺序**:已先列出请求参数与响应参数,再建 Model最后集成到页面
- [ ] 规范 URL 使用 `https://api.xtrader.vip/swagger/doc.json`,或本地缓存与之一致
- [ ] 请求 path、method、query/body 与 `paths` 一致
- [ ] 响应类型Model覆盖 `code`/`data`/`msg``definitions` 中业务字段
- [ ] 鉴权接口使用 `x-token` header
- [ ] 新代码风格与 `src/api/request.ts``src/api/event.ts` 一致
## 参考
- 规范:<https://api.xtrader.vip/swagger/doc.json>
- Swagger UI<https://api.xtrader.vip/swagger/index.html>
- 项目请求封装:`src/api/request.ts`
- 现有接口与类型:`src/api/event.ts`

8
.env Normal file
View File

@ -0,0 +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

11
.env.example Normal file
View File

@ -0,0 +1,11 @@
# 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

148
AGENTS.md Normal file
View File

@ -0,0 +1,148 @@
# AGENTS
<skills_system priority="1">
## Available Skills
<!-- SKILLS_TABLE_START -->
<usage>
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
How to use skills:
- Invoke: `npx openskills read <skill-name>` (run in your shell)
- For multiple: `npx openskills read skill-one,skill-two`
- The skill content will load with detailed instructions on how to complete the task
- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
Usage notes:
- Only use skills listed in <available_skills> below
- Do not invoke a skill that is already loaded in your context
- Each skill invocation is stateless
</usage>
<available_skills>
<skill>
<name>algorithmic-art</name>
<description>Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.</description>
<location>global</location>
</skill>
<skill>
<name>brand-guidelines</name>
<description>Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.</description>
<location>global</location>
</skill>
<skill>
<name>canvas-design</name>
<description>Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.</description>
<location>global</location>
</skill>
<skill>
<name>doc-coauthoring</name>
<description>Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks.</description>
<location>global</location>
</skill>
<skill>
<name>docx</name>
<description>"Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of \"Word doc\", \"word document\", \".docx\", or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a \"report\", \"memo\", \"letter\", \"template\", or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation."</description>
<location>global</location>
</skill>
<skill>
<name>frontend-design</name>
<description>Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.</description>
<location>global</location>
</skill>
<skill>
<name>internal-comms</name>
<description>A set of resources to help me write all kinds of internal communications, using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.).</description>
<location>global</location>
</skill>
<skill>
<name>mcp-builder</name>
<description>Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).</description>
<location>global</location>
</skill>
<skill>
<name>pdf</name>
<description>Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill.</description>
<location>global</location>
</skill>
<skill>
<name>pptx</name>
<description>"Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions \"deck,\" \"slides,\" \"presentation,\" or references a .pptx filename, regardless of what they plan to do with the content afterward. If a .pptx file needs to be opened, created, or touched, use this skill."</description>
<location>global</location>
</skill>
<skill>
<name>skill-creator</name>
<description>Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.</description>
<location>global</location>
</skill>
<skill>
<name>slack-gif-creator</name>
<description>Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."</description>
<location>global</location>
</skill>
<skill>
<name>template</name>
<description>Replace with description of the skill and when Claude should use it.</description>
<location>global</location>
</skill>
<skill>
<name>theme-factory</name>
<description>Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.</description>
<location>global</location>
</skill>
<skill>
<name>web-artifacts-builder</name>
<description>Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.</description>
<location>global</location>
</skill>
<skill>
<name>webapp-testing</name>
<description>Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.</description>
<location>global</location>
</skill>
<skill>
<name>xlsx</name>
<description>"Use this skill any time a spreadsheet file is the primary input or output. This means any task where the user wants to: open, read, edit, or fix an existing .xlsx, .xlsm, .csv, or .tsv file (e.g., adding columns, computing formulas, formatting, charting, cleaning messy data); create a new spreadsheet from scratch or from other data sources; or convert between tabular file formats. Trigger especially when the user references a spreadsheet file by name or path — even casually (like \"the xlsx in my downloads\") — and wants something done to it or produced from it. Also trigger for cleaning or restructuring messy tabular data files (malformed rows, misplaced headers, junk data) into proper spreadsheets. The deliverable must be a spreadsheet file. Do NOT trigger when the primary deliverable is a Word document, HTML report, standalone Python script, database pipeline, or Google Sheets API integration, even if tabular data is involved."</description>
<location>global</location>
</skill>
<skill>
<name>xtrader-api-docs</name>
<description>Interprets the XTrader API documentation from Swagger at https://api.xtrader.vip/swagger/index.html and helps implement endpoints, TypeScript types, and request helpers. Use when implementing or understanding XTrader API endpoints, adding new API calls, or when the user refers to the API docs or Swagger.</description>
<location>project</location>
</skill>
<skill>
<name>project-framework</name>
<description>Defines the PolyClientVuetify project's framework, structure, and coding conventions. Use when adding or modifying any code in this repository so that changes follow the same stack, patterns, and style.</description>
<location>project</location>
</skill>
<skill>
<name>deploy</name>
<description>将 PolyClientVuetify 项目打包并通过 SSH 部署到远程服务器。用户说「部署」「发布」时使用此 skill。</description>
<location>project</location>
</skill>
</available_skills>
<!-- SKILLS_TABLE_END -->
</skills_system>

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>
@ -56,7 +62,11 @@ const currentRoute = computed(() => {
</template> </template>
</v-app-bar> </v-app-bar>
<v-main> <v-main>
<router-view /> <router-view v-slot="{ Component }">
<keep-alive :include="['Home']">
<component :is="Component" />
</keep-alive>
</router-view>
</v-main> </v-main>
</v-app> </v-app>
</template> </template>

30
src/api/constants.ts Normal file
View File

@ -0,0 +1,30 @@
/**
* OrderType
*
*/
export const OrderType = {
/** Good Till Cancelled: 一直有效直到取消 */
GTC: 0,
/** Good Till Date: 指定时间内有效 */
GTD: 1,
/** Fill Or Kill: 全部成交或立即取消(不允许部分成交) */
FOK: 2,
/** Fill And Kill (IOC): 立即成交,剩余部分取消 */
FAK: 3,
/** Market Order: 市价单,立即按最优价成交,剩余取消 (同 FAK但不限价) */
Market: 4,
} as const
export type OrderTypeValue = (typeof OrderType)[keyof typeof OrderType]
/**
* Side
*/
export const Side = {
/** 买入 */
Buy: 1,
/** 卖出 */
Sell: 2,
} as const
export type SideValue = (typeof Side)[keyof typeof Side]

344
src/api/event.ts Normal file
View File

@ -0,0 +1,344 @@
import { get } from './request'
/** 分页结果 */
export interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
/**
* Event doc.json definitions["polymarket.PmEvent"]
* /PmEvent/getPmEventPublic /PmEvent/findPmEvent data
*/
export interface PmEventListItem {
ID: number
createdAt?: string
updatedAt?: string
slug?: string
ticker?: string
title: string
description?: string
resolutionSource?: string
startDate?: string
endDate?: string
creationDate?: string
closedTime?: string
startTime?: string
image?: string
icon?: string
active?: boolean
archived?: boolean
closed?: boolean
featured?: boolean
new?: boolean
restricted?: boolean
enableOrderBook?: boolean
competitive?: number
liquidity?: number
liquidityAmm?: number
liquidityClob?: number
openInterest?: number
volume?: number
commentCount?: number
seriesSlug?: string
markets?: PmEventMarketItem[]
series?: PmEventSeriesItem[]
tags?: PmEventTagItem[]
[key: string]: unknown
}
/**
* definitions polymarket.PmMarket
* - outcomes: 选项展示文案 ["Yes", "No"] ["Up", "Down"] outcomePrices
* - 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 顺序一致outcomes[0] 对应 clobTokenIds[0] */
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
}
/** 从市场项取 clobTokenIdoutcomeIndex 0=Yes/第一选项1=No/第二选项 */
export function getClobTokenId(
m: PmEventMarketItem | null | undefined,
outcomeIndex: 0 | 1 = 0
): string | undefined {
if (!m?.clobTokenIds?.length) return undefined
const id = m.clobTokenIds[outcomeIndex]
return id != null ? String(id) : undefined
}
/** 对应 definitions polymarket.PmSeries 常用字段 */
export interface PmEventSeriesItem {
ID?: number
ticker?: string
title?: string
slug?: string
[key: string]: unknown
}
/** 对应 definitions polymarket.PmTag 常用字段 */
export interface PmEventTagItem {
label?: string
slug?: string
ID?: number
[key: string]: unknown
}
/** 接口统一响应 */
export interface PmEventListResponse {
code: number
data: PageResult<PmEventListItem>
msg: string
}
export interface GetPmEventListParams {
page?: number
pageSize?: number
keyword?: string
/** 创建时间范围,如 ['2025-01-01', '2025-12-31'] */
createdAtRange?: string[]
/** clobTokenIds 对应的值,用于按市场 token 筛选;可从 market.clobTokenIds 获取 */
tokenid?: string | string[]
}
/**
* Event
* GET /PmEvent/getPmEventPublic
*
* Query: page, pageSize, keyword, createdAtRange, tokenid
* tokenid market.clobTokenIds
*/
export async function getPmEventPublic(
params: GetPmEventListParams = {}
): Promise<PmEventListResponse> {
const { page = 1, pageSize = 10, keyword, createdAtRange, tokenid } = params
const query: Record<string, string | number | string[] | undefined> = {
page,
pageSize,
}
if (keyword != null && keyword !== '') query.keyword = keyword
if (createdAtRange != null && createdAtRange.length) query.createdAtRange = createdAtRange
if (tokenid != null) {
query.tokenid = Array.isArray(tokenid) ? tokenid : [tokenid]
}
return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
}
/**
* GET /PmEvent/findPmEvent 200
* doc.json: responses["200"].schema = allOf [ response.Response, { data: polymarket.PmEvent, msg } ]
*/
export interface PmEventDetailResponse {
code: number
data: PmEventListItem
msg: string
}
/**
* 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
* - slug: string
* - ID slug
* headers x-tokenx-user-id
*
* 200PmEventDetailResponse { code, data: PmEventListItem, msg }
*/
export async function findPmEvent(
params: FindPmEventParams,
config?: { headers?: Record<string, string> }
): Promise<PmEventDetailResponse> {
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)
}
/** 多选项卡片中单个选项(用于左右滑动切换) */
export interface EventCardOutcome {
title: string
/** 第一选项概率(来自 outcomePrices[0] */
chanceValue: number
/** 第一选项按钮文案(来自 outcomes[0],如 Yes / Up */
yesLabel?: string
/** 第二选项按钮文案(来自 outcomes[1],如 No / Down */
noLabel?: string
/** 可选,用于交易时区分 market */
marketId?: string
/** 用于下单 tokenId与 outcomes 顺序一致 */
clobTokenIds?: string[]
}
/**
* mapEventItemToCard
* displayTypesingle = Yes/Nomulti =
*/
export interface EventCardItem {
id: string
/** Event slug用于 findPmEvent 传参 */
slug?: string
marketTitle: string
chanceValue: number
marketInfo: string
imageUrl: string
category: string
expiresAt: string
/** 展示类型:单一二元 或 多选项滑动 */
displayType: 'single' | 'multi'
/** 多选项时每个选项的标题与概率,按顺序滑动展示 */
outcomes?: EventCardOutcome[]
/** 单一类型时可选按钮文案,如 "Up"/"Down" */
yesLabel?: string
noLabel?: string
/** 是否显示 NEW 角标 */
isNew?: boolean
/** 当前市场 ID单 market 时为第一个 market 的 ID供交易/Split 使用) */
marketId?: string
/** 用于下单 tokenId单 market 时取自 firstMarket.clobTokenIds */
clobTokenIds?: string[]
}
/** 内存缓存:列表数据,切换页面时复用,下拉刷新时清空 */
let eventListCache: {
list: EventCardItem[]
page: number
total: number
pageSize: number
} | null = null
export function getEventListCache(): typeof eventListCache {
return eventListCache
}
export function setEventListCache(data: {
list: EventCardItem[]
page: number
total: number
pageSize: number
}) {
eventListCache = data
}
export function clearEventListCache(): void {
eventListCache = null
}
function marketChance(market: PmEventMarketItem): number {
const raw = market?.outcomePrices?.[0]
if (raw == null) return 17
const yesPrice = parseFloat(String(raw))
if (!Number.isFinite(yesPrice)) return 17
return Math.min(100, Math.max(0, Math.round(yesPrice * 100)))
}
/**
* list MarketCard
* - market marketdisplayType single
* - marketsdisplayType multioutcomes +
*/
export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
const id = String(item.ID ?? '')
const marketTitle = item.title ?? ''
const imageUrl = item.image ?? item.icon ?? ''
const markets = item.markets ?? []
const multi = markets.length > 1
let chanceValue = 17
const firstMarket = markets[0]
if (firstMarket?.outcomePrices?.[0] != null) {
chanceValue = marketChance(firstMarket)
}
let marketInfo = '$0 Vol.'
if (item.volume != null && Number.isFinite(item.volume)) {
const v = item.volume
if (v >= 1000) {
marketInfo = `$${(v / 1000).toFixed(1)}k Vol.`
} else {
marketInfo = `$${Math.round(v)} Vol.`
}
}
let expiresAt = ''
if (item.endDate) {
try {
const d = new Date(item.endDate)
if (!Number.isNaN(d.getTime())) {
expiresAt = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
} catch {
expiresAt = item.endDate
}
}
const category = item.series?.[0]?.title ?? item.tags?.[0]?.label ?? ''
const outcomes: EventCardOutcome[] | undefined = multi
? markets.map((m) => ({
title: m.question ?? '',
chanceValue: marketChance(m),
yesLabel: m.outcomes?.[0] ?? 'Yes',
noLabel: m.outcomes?.[1] ?? 'No',
marketId: getMarketId(m),
clobTokenIds: m.clobTokenIds,
}))
: undefined
return {
id,
slug: item.slug ?? undefined,
marketTitle,
chanceValue,
marketInfo,
imageUrl,
category,
expiresAt,
displayType: multi ? 'multi' : 'single',
outcomes,
yesLabel: firstMarket?.outcomes?.[0] ?? 'Yes',
noLabel: firstMarket?.outcomes?.[1] ?? 'No',
isNew: item.new === true,
marketId: getMarketId(firstMarket),
clobTokenIds: firstMarket?.clobTokenIds,
}
}

103
src/api/market.ts Normal file
View File

@ -0,0 +1,103 @@
import { post } from './request'
/** 通用响应:与 doc.json response.Response 一致 */
export interface ApiResponse<T = unknown> {
code: number
data?: T
msg: string
}
/**
* /clob/gateway/submitOrder
* tokenID market.clobTokenIdsoutcomeIndex 0=Yes 1=No
*/
export interface ClobSubmitOrderRequest {
expiration: number
feeRateBps: number
nonce: number
orderType: number
/** 价格(整数,已乘 10000 */
price: number
side: number
/** 数量(份额) */
size: number
taker: boolean
tokenID: string
userID: number
}
/**
* POST /clob/gateway/submitOrder
* / Yes No
*/
export async function pmOrderPlace(
data: ClobSubmitOrderRequest,
config?: { headers?: Record<string, string> }
): Promise<ApiResponse> {
return post<ApiResponse>('/clob/gateway/submitOrder', data, config)
}
/**
* /clob/gateway/cancelOrder
*/
export interface ClobCancelOrderRequest {
orderID: number
tokenID: string
userID: number
}
/**
* POST /clob/gateway/cancelOrder
*
*/
export async function pmCancelOrder(
data: ClobCancelOrderRequest,
config?: { headers?: Record<string, string> }
): Promise<ApiResponse> {
return post<ApiResponse>('/clob/gateway/cancelOrder', data, config)
}
/**
* Split /PmMarket/split
* USDC Yes+No 1 USDC 1 Yes + 1 No
*/
export interface PmMarketSplitRequest {
/** 市场 ID */
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-tokenx-user-id
* - 使 VITE_API_BASE_URL http://192.168.3.21:8888 连接测试服
*/
export async function pmMarketSplit(
data: PmMarketSplitRequest,
config?: { headers?: Record<string, string> }
): Promise<ApiResponse> {
return post<ApiResponse>('/PmMarket/split', data, config)
}

175
src/api/mockEventList.ts Normal file
View File

@ -0,0 +1,175 @@
import type { PmEventListItem } from './event'
/**
*
* - Up/DownYes/No
* - markets
*/
export const MOCK_EVENT_LIST: PmEventListItem[] = [
// 1. 单一 market按钮文案为 Up/Down测试动态 outcomes
{
ID: 9001,
title: 'S&P 500 (SPX) Opens Up or Down on February 10?',
slug: 'spx-up-down-feb10',
ticker: 'SPX',
image: '',
icon: '',
volume: 11000,
endDate: '2026-02-10T21:00:00.000Z',
new: true,
markets: [
{
ID: 90011,
question: 'S&P 500 (SPX) Opens Up or Down on February 10?',
outcomes: ['Up', 'Down'],
outcomePrices: [0.48, 0.52],
},
],
series: [{ ID: 1, title: 'Economy', ticker: 'ECON' }],
tags: [{ label: 'Markets', slug: 'markets' }],
},
// 2. 多个 markets测试左右滑动轮播NFL Champion 多支队伍)
{
ID: 9002,
title: 'NFL Champion 2027',
slug: 'nfl-champion-2027',
ticker: 'NFL27',
image: '',
icon: '',
volume: 196000,
endDate: '2027-02-15T00:00:00.000Z',
new: true,
markets: [
{
ID: 90021,
question: 'Seattle Seahawks',
outcomes: ['Yes', 'No'],
outcomePrices: [0.12, 0.88],
},
{
ID: 90022,
question: 'Buffalo Bills',
outcomes: ['Yes', 'No'],
outcomePrices: [0.08, 0.92],
},
{
ID: 90023,
question: 'Kansas City Chiefs',
outcomes: ['Yes', 'No'],
outcomePrices: [0.18, 0.82],
},
],
series: [{ ID: 2, title: 'Sports', ticker: 'SPORT' }],
tags: [{ label: 'NFL', slug: 'nfl' }],
},
// 3. 单一 marketYes/No带 + NEW
{
ID: 9003,
title: 'Will Trump pardon Ghislaine Maxwell by end of 2026?',
slug: 'trump-pardon-maxwell-2026',
ticker: 'POL',
image: '',
icon: '',
volume: 361000,
endDate: '2026-12-31T23:59:59.000Z',
new: false,
markets: [
{
ID: 90031,
question: 'Will Trump pardon Ghislaine Maxwell by end of 2026?',
outcomes: ['Yes', 'No'],
outcomePrices: [0.09, 0.91],
},
],
series: [{ ID: 3, title: 'Politics', ticker: 'POL' }],
tags: [{ label: 'Politics', slug: 'politics' }],
},
// 4. 两个 markets不同 outcomes 文案(混合 Yes/No
{
ID: 9004,
title: 'Which team wins the 2026 World Cup?',
slug: 'world-cup-2026-winner',
ticker: 'FIFA',
image: '',
icon: '',
volume: 52000,
endDate: '2026-07-19T00:00:00.000Z',
new: true,
markets: [
{
ID: 90041,
question: 'Brazil',
outcomes: ['Yes', 'No'],
outcomePrices: [0.22, 0.78],
},
{
ID: 90042,
question: 'Germany',
outcomes: ['Yes', 'No'],
outcomePrices: [0.15, 0.85],
},
],
series: [{ ID: 4, title: 'Sports', ticker: 'SPORT' }],
tags: [{ label: 'World Cup', slug: 'world-cup' }],
},
// 5. 多 marketElon Musk 推文数量区间(类似 Polymarket 多档位)
{
ID: 9005,
title: 'Elon Musk # tweets February 3 - February 10, 2026?',
slug: 'elon-musk-tweets-feb-2026',
ticker: 'TWEET',
image: '',
icon: '',
volume: 22136050,
endDate: '2026-02-10T23:59:59.000Z',
new: true,
markets: [
{ ID: 90051, question: '260-279', outcomes: ['Yes', 'No'], outcomePrices: [0.01, 0.99], volume: 120000 },
{ ID: 90052, question: '280-299', outcomes: ['Yes', 'No'], outcomePrices: [0.42, 0.58], volume: 939379 },
{ ID: 90053, question: '300-319', outcomes: ['Yes', 'No'], outcomePrices: [0.45, 0.55], volume: 850000 },
{ ID: 90054, question: '320-339', outcomes: ['Yes', 'No'], outcomePrices: [0.16, 0.84], volume: 320000 },
{ ID: 90055, question: '340-359', outcomes: ['Yes', 'No'], outcomePrices: [0.08, 0.92], volume: 180000 },
],
series: [{ ID: 5, title: 'Culture', ticker: 'CULTURE' }],
tags: [{ label: 'Tech', slug: 'tech' }, { label: 'Twitter', slug: 'twitter' }],
},
// 6. 多 market总统候选人胜选概率多候选人
{
ID: 9006,
title: 'Who wins the 2028 U.S. Presidential Election?',
slug: 'us-president-2028',
ticker: 'POL28',
image: '',
icon: '',
volume: 12500000,
endDate: '2028-11-07T23:59:59.000Z',
new: true,
markets: [
{ ID: 90061, question: 'Democrat nominee', outcomes: ['Yes', 'No'], outcomePrices: [0.48, 0.52], volume: 3200000 },
{ ID: 90062, question: 'Republican nominee', outcomes: ['Yes', 'No'], outcomePrices: [0.45, 0.55], volume: 3100000 },
{ ID: 90063, question: 'Third party / Independent', outcomes: ['Yes', 'No'], outcomePrices: [0.07, 0.93], volume: 800000 },
],
series: [{ ID: 6, title: 'Politics', ticker: 'POL' }],
tags: [{ label: 'Election', slug: 'election' }],
},
// 7. 多 marketNBA 分区冠军4 个选项)
{
ID: 9007,
title: 'NBA Eastern Conference Champion 2025-26?',
slug: 'nba-east-champion-2026',
ticker: 'NBA26',
image: '',
icon: '',
volume: 890000,
endDate: '2026-05-31T23:59:59.000Z',
new: false,
markets: [
{ ID: 90071, question: 'Boston Celtics', outcomes: ['Yes', 'No'], outcomePrices: [0.35, 0.65], volume: 280000 },
{ ID: 90072, question: 'Milwaukee Bucks', outcomes: ['Yes', 'No'], outcomePrices: [0.28, 0.72], volume: 220000 },
{ ID: 90073, question: 'Philadelphia 76ers', outcomes: ['Yes', 'No'], outcomePrices: [0.18, 0.82], volume: 150000 },
{ ID: 90074, question: 'New York Knicks', outcomes: ['Yes', 'No'], outcomePrices: [0.12, 0.88], volume: 120000 },
],
series: [{ ID: 7, title: 'Sports', ticker: 'SPORT' }],
tags: [{ label: 'NBA', slug: 'nba' }],
},
]

65
src/api/request.ts Normal file
View File

@ -0,0 +1,65 @@
/**
* URL https://api.xtrader.vip可通过环境变量 VITE_API_BASE_URL 覆盖
*/
const BASE_URL = typeof import.meta !== 'undefined' && (import.meta as unknown as { env?: Record<string, string> }).env?.VITE_API_BASE_URL
? (import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_URL
: 'https://api.xtrader.vip'
export interface RequestConfig {
/** 请求头,如 { 'x-token': token, 'x-user-id': userId } */
headers?: Record<string, string>
}
/**
* x-token GET
*/
export async function get<T = unknown>(
path: string,
params?: Record<string, string | number | string[] | undefined>,
config?: RequestConfig
): Promise<T> {
const url = new URL(path, BASE_URL || window.location.origin)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value === undefined) return
if (Array.isArray(value)) {
value.forEach((v) => url.searchParams.append(key, String(v)))
} else {
url.searchParams.set(key, String(value))
}
})
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...config?.headers,
}
const res = await fetch(url.toString(), { method: 'GET', headers })
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json() as Promise<T>
}
/**
* x-token POST
*/
export async function post<T = unknown>(
path: string,
body?: unknown,
config?: RequestConfig
): Promise<T> {
const url = new URL(path, BASE_URL || window.location.origin)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...config?.headers,
}
const res = await fetch(url.toString(), {
method: 'POST',
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json() as Promise<T>
}

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

@ -1,9 +1,8 @@
<template> <template>
<v-card class="market-card" elevation="0" :rounded="'lg'" @click="navigateToDetail"> <v-card class="market-card" elevation="0" :rounded="'lg'" @click="navigateToDetail">
<div class="market-card-content"> <div class="market-card-content">
<!-- Top Section --> <!-- Top Section头像 + 标题 + 半圆概率 / 多选项时仅标题 -->
<div class="top-section"> <div class="top-section">
<!-- Market Image and Title -->
<div class="image-title-container"> <div class="image-title-container">
<v-avatar class="market-image" :size="40" color="#f0f0f0" rounded="sm"> <v-avatar class="market-image" :size="40" color="#f0f0f0" rounded="sm">
<v-img v-if="props.imageUrl" :src="props.imageUrl" cover /> <v-img v-if="props.imageUrl" :src="props.imageUrl" cover />
@ -12,50 +11,148 @@
{{ marketTitle }} {{ marketTitle }}
</v-card-title> </v-card-title>
</div> </div>
<!-- 单一类型右侧半圆进度条 -->
<!-- Chance Container --> <div v-if="!isMulti" class="chance-container">
<div class="chance-container"> <div class="semi-progress-wrap">
<v-progress-circular <svg class="semi-progress-svg" viewBox="0 0 60 36" preserveAspectRatio="xMidYMin meet">
class="progress-bar" <path
:size="60" class="semi-progress-track"
:width="4" d="M 4 30 A 26 26 0 0 1 56 30"
:value="chanceValue" fill="none"
:color="progressColor" stroke="#e8e8e8"
:background="'#e0e0e0'" stroke-width="5"
> stroke-linecap="round"
<template v-slot:default> />
<path
class="semi-progress-fill"
d="M 4 30 A 26 26 0 0 1 56 30"
fill="none"
:stroke="semiProgressColor"
stroke-width="5"
stroke-linecap="round"
stroke-dasharray="81.68"
:stroke-dashoffset="semiProgressOffset"
/>
</svg>
<div class="semi-progress-inner">
<span class="chance-value">{{ chanceValue }}%</span> <span class="chance-value">{{ chanceValue }}%</span>
</template> <span class="chance-label">chance</span>
</v-progress-circular> </div>
<span class="chance-label">chance</span> </div>
</div> </div>
</div> </div>
<!-- Options Section点击 Yes/No 弹出交易框阻止冒泡不触发卡片跳转 --> <!-- 单一类型中间 Yes/No Up/Down -->
<div class="options-section"> <template v-if="!isMulti">
<v-btn <div class="options-section">
class="option-yes" <v-btn
:color="'#e6f9e6'" class="option-yes"
:rounded="'sm'" :color="'#b8e0b8'"
:text="true" :rounded="'sm'"
@click.stop="openTrade('yes')" :text="true"
elevation="0"
@click.stop="openTradeSingle('yes')"
>
<span class="option-text-yes">{{ yesLabel }}</span>
</v-btn>
<v-btn
class="option-no"
:color="'#f0b8b8'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeSingle('no')"
>
<span class="option-text-no">{{ noLabel }}</span>
</v-btn>
</div>
</template>
<!-- 多选项类型左右滑动轮播每页一项 = 左侧 title+% 右侧 Yes/No -->
<div v-else class="multi-section">
<v-carousel
v-model="currentSlide"
class="outcome-carousel"
:continuous="true"
:show-arrows="false"
hide-delimiters
height="35"
> >
<span class="option-text-yes">Yes</span> <v-carousel-item
</v-btn> v-for="(outcome, idx) in props.outcomes"
<v-btn :key="idx"
class="option-no" class="outcome-slide"
:color="'#ffe6e6'" >
:rounded="'sm'" <div class="outcome-slide-inner">
:text="true" <div class="outcome-row">
@click.stop="openTrade('no')" <div class="outcome-item">
> <span class="outcome-title">{{ outcome.title }}</span>
<span class="option-text-no">No</span> <span class="outcome-chance-value">{{ outcome.chanceValue }}%</span>
</v-btn> </div>
<div class="outcome-buttons">
<v-btn
class="option-yes option-yes-no-compact"
:color="'#b8e0b8'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeMulti('yes', outcome)"
>
<span class="option-text-yes">{{ outcome.yesLabel ?? 'Yes' }}</span>
</v-btn>
<v-btn
class="option-no option-yes-no-compact"
:color="'#f0b8b8'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeMulti('no', outcome)"
>
<span class="option-text-no">{{ outcome.noLabel ?? 'No' }}</span>
</v-btn>
</div>
</div>
</div>
</v-carousel-item>
</v-carousel>
<div class="carousel-controls" @mousedown.stop @touchstart.stop>
<button
type="button"
class="carousel-arrow carousel-arrow--left"
:disabled="outcomeCount <= 1"
aria-label="上一项"
@click.stop="prevSlide"
>
<v-icon size="18">mdi-chevron-left</v-icon>
</button>
<div class="carousel-dots">
<button
v-for="(_, idx) in (props.outcomes ?? [])"
:key="idx"
type="button"
:class="['carousel-dot', { active: currentSlide === idx }]"
:aria-label="`选项 ${idx + 1}`"
@click.stop="currentSlide = idx"
/>
</div>
<button
type="button"
class="carousel-arrow carousel-arrow--right"
:disabled="outcomeCount <= 1"
aria-label="下一项"
@click.stop="nextSlide"
>
<v-icon size="18">mdi-chevron-right</v-icon>
</button>
</div>
</div> </div>
<!-- Bottom Section --> <!-- Bottom Section -->
<div class="bottom-section"> <div class="bottom-section">
<span class="market-info">{{ marketInfo }}</span> <span class="market-info">
<span v-if="props.isNew" class="new-badge">+ NEW</span>
{{ marketInfo }}
</span>
<div class="icons-container"> <div class="icons-container">
<v-icon class="gift-icon" size="16">mdi-gift</v-icon> <v-icon class="gift-icon" size="16">mdi-gift</v-icon>
<v-icon class="bookmark-icon" size="16">mdi-bookmark</v-icon> <v-icon class="bookmark-icon" size="16">mdi-bookmark</v-icon>
@ -66,57 +163,118 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { EventCardOutcome } from '../api/event'
const router = useRouter() const router = useRouter()
const emit = defineEmits<{ const emit = defineEmits<{
openTrade: [side: 'yes' | 'no', market?: { id: string; title: string }] openTrade: [
side: 'yes' | 'no',
market?: { id: string; title: string; marketId?: string; outcomeTitle?: string; clobTokenIds?: string[] }
]
}>() }>()
const props = defineProps({ const props = withDefaults(
marketTitle: { defineProps<{
type: String, marketTitle: string
default: 'Mamdan opens city-owned grocery store b...', chanceValue: number
}, marketInfo: string
chanceValue: { id: string
type: Number, /** Event slug用于 findPmEvent 传参 */
default: 17, slug?: string
}, imageUrl?: string
marketInfo: { category?: string
type: String, expiresAt?: string
default: '$155k Vol.', /** 展示类型single 单一 Yes/Nomulti 多选项左右滑动 */
}, displayType?: 'single' | 'multi'
id: { /** 多选项时的选项列表(用于滑动) */
type: String, outcomes?: EventCardOutcome[]
default: '1', /** 单一类型时左按钮文案,如 Up */
}, yesLabel?: string
/** 市场图片 URL由卡片传入供详情页展示 */ /** 单一类型时右按钮文案,如 Down */
imageUrl: { noLabel?: string
type: String, /** 是否显示 + NEW */
default: '', isNew?: boolean
}, /** 当前市场 ID单 market 时供交易/Split 使用) */
/** 分类标签,如 "Economy · World" */ marketId?: string
category: { /** 用于下单 tokenId单 market 时 */
type: String, clobTokenIds?: string[]
default: '', }>(),
}, {
/** 结算/到期日期,如 "Mar 31, 2026" */ marketTitle: 'Mamdan opens city-owned grocery store b...',
expiresAt: { chanceValue: 17,
type: String, marketInfo: '$155k Vol.',
default: '', id: '1',
}, imageUrl: '',
category: '',
expiresAt: '',
displayType: 'single',
outcomes: () => [],
yesLabel: 'Yes',
noLabel: 'No',
isNew: false,
marketId: undefined,
}
)
const isMulti = computed(
() => props.displayType === 'multi' && (props.outcomes?.length ?? 0) > 1
)
const currentSlide = ref(0)
const outcomeCount = computed(() => (props.outcomes ?? []).length)
function prevSlide() {
if (outcomeCount.value <= 1) return
currentSlide.value = (currentSlide.value - 1 + outcomeCount.value) % outcomeCount.value
}
function nextSlide() {
if (outcomeCount.value <= 1) return
currentSlide.value = (currentSlide.value + 1) % outcomeCount.value
}
// stroke-dashoffset π*26 0° 180°
const SEMI_CIRCLE_LENGTH = Math.PI * 26
const semiProgressOffset = computed(() => {
const p = Math.min(100, Math.max(0, props.chanceValue)) / 100
return SEMI_CIRCLE_LENGTH * (1 - p)
}) })
// (0%)绿(100%) // 0% 50% #FFBB5C 100% 绿 RGB
const progressColor = computed(() => { const COLOR_RED = { r: 239, g: 68, b: 68 }
// HSL0绿120 const COLOR_ORANGE_YELLOW = { r: 255, g: 187, b: 92 }
const hue = (props.chanceValue / 100) * 120 const COLOR_GREEN = { r: 22, g: 163, b: 74 }
return `hsl(${hue}, 100%, 50%)` function rgbToHex(r: number, g: number, b: number): string {
const toByte = (v: number) => Math.min(255, Math.max(0, Math.round(v)))
return `#${toByte(r).toString(16).padStart(2, '0')}${toByte(g).toString(16).padStart(2, '0')}${toByte(b).toString(16).padStart(2, '0')}`
}
const semiProgressColor = computed(() => {
const t = Math.min(1, Math.max(0, props.chanceValue / 100))
if (t <= 0.5) {
const u = t * 2
return rgbToHex(
COLOR_RED.r + (COLOR_ORANGE_YELLOW.r - COLOR_RED.r) * u,
COLOR_RED.g + (COLOR_ORANGE_YELLOW.g - COLOR_RED.g) * u,
COLOR_RED.b + (COLOR_ORANGE_YELLOW.b - COLOR_RED.b) * u
)
}
const u = (t - 0.5) * 2
return rgbToHex(
COLOR_ORANGE_YELLOW.r + (COLOR_GREEN.r - COLOR_ORANGE_YELLOW.r) * u,
COLOR_ORANGE_YELLOW.g + (COLOR_GREEN.g - COLOR_ORANGE_YELLOW.g) * u,
COLOR_ORANGE_YELLOW.b + (COLOR_GREEN.b - COLOR_ORANGE_YELLOW.b) * u
)
}) })
// query
const navigateToDetail = () => { const navigateToDetail = () => {
if (props.displayType === 'multi' && (props.outcomes?.length ?? 0) > 1) {
router.push({
path: `/event/${props.id}/markets`,
query: { ...(props.slug && { slug: props.slug }) },
})
return
}
router.push({ router.push({
path: `/trade-detail/${props.id}`, path: `/trade-detail/${props.id}`,
query: { query: {
@ -126,21 +284,38 @@ 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 }),
}, },
}) })
} }
// Yes/No openTrade function openTradeSingle(side: 'yes' | 'no') {
function openTrade(side: 'yes' | 'no') { emit('openTrade', side, {
emit('openTrade', side, { id: props.id, title: props.marketTitle }) id: props.id,
title: props.marketTitle,
marketId: props.marketId,
clobTokenIds: props.clobTokenIds,
})
}
function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) {
emit('openTrade', side, {
id: props.id,
title: outcome.title,
marketId: outcome.marketId,
outcomeTitle: outcome.title,
clobTokenIds: outcome.clobTokenIds,
})
} }
</script> </script>
<style scoped> <style scoped>
/* 单 market 与多 market 统一高度 */
.market-card { .market-card {
position: relative; position: relative;
width: 310px; width: 310px;
height: 160px; height: 176px;
background-color: #ffffff; background-color: #ffffff;
border-radius: 8px; border-radius: 8px;
border: 1px solid #e7e7e7; border: 1px solid #e7e7e7;
@ -150,8 +325,6 @@ function openTrade(side: 'yes' | 'no') {
} }
.market-card:hover { .market-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
border-color: #d0d0d0; border-color: #d0d0d0;
} }
@ -159,7 +332,16 @@ function openTrade(side: 'yes' | 'no') {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
gap: 12px; gap: 10px;
}
/* 扁平化:卡片内所有按钮无阴影 */
.market-card :deep(.v-btn),
.market-card .carousel-arrow {
box-shadow: none !important;
}
.market-card :deep(.v-btn::before) {
box-shadow: none;
} }
/* Top Section */ /* Top Section */
@ -181,57 +363,90 @@ function openTrade(side: 'yes' | 'no') {
flex-shrink: 0; flex-shrink: 0;
} }
/* 覆盖 Vuetify v-card-title 的 padding、white-space保证两行完整显示且不露第三行 */
.market-title { .market-title {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding: 0;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
line-height: 1.4;
color: #000000; color: #000000;
white-space: normal;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
/* 仅文字区域两行高,无 padding 占用 */
max-height: 2.8em;
} }
.chance-container { .chance-container {
flex-shrink: 0;
width: 60px;
}
.semi-progress-wrap {
position: relative;
width: 60px;
height: 44px;
}
.semi-progress-svg {
display: block;
width: 60px;
height: 36px;
overflow: visible;
}
.semi-progress-track,
.semi-progress-fill {
transition: stroke-dashoffset 0.25s ease;
}
/* 半圆弧 viewBox 0 0 60 36弧的视觉中心约 y=17百分比文字的 top 对齐该中心 */
.semi-progress-inner {
position: absolute;
left: 50%;
top: 17px;
transform: translateX(-50%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
flex-shrink: 0; justify-content: flex-start;
width: 60px; pointer-events: none;
height: 60px;
}
.progress-bar {
margin-bottom: 2px;
} }
.chance-value { .chance-value {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 12px; font-size: 14px;
font-weight: 600; font-weight: 700;
color: #000000; color: #000000;
line-height: 1.2;
} }
.chance-label { .chance-label {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 10px; font-size: 10px;
font-weight: normal; font-weight: normal;
color: #808080; color: #9ca3af;
margin-top: 4px; margin-top: 2px;
} }
/* Options Section */ /* Options Section:单 market 时靠底对齐 */
.options-section { .options-section {
display: flex; display: flex;
gap: 12px; gap: 12px;
margin-top: auto;
} }
.option-yes { .option-yes {
flex: 1; flex: 1;
background-color: #e6f9e6; background-color: #b8e0b8;
border-radius: 6px;
height: 40px; height: 40px;
} }
@ -239,12 +454,13 @@ function openTrade(side: 'yes' | 'no') {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: #008000; color: #006600;
} }
.option-no { .option-no {
flex: 1; flex: 1;
background-color: #ffe6e6; background-color: #f0b8b8;
border-radius: 6px;
height: 40px; height: 40px;
} }
@ -252,15 +468,16 @@ function openTrade(side: 'yes' | 'no') {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: #ff0000; color: #cc0000;
} }
/* Bottom Section */ /* Bottom Section:禁止被挤压,避免与 multi-section 重叠 */
.bottom-section { .bottom-section {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: auto; margin-top: auto;
flex-shrink: 0;
} }
.market-info { .market-info {
@ -282,4 +499,170 @@ function openTrade(side: 'yes' | 'no') {
.bookmark-icon { .bookmark-icon {
color: #808080; color: #808080;
} }
/* 多选项:左右滑动区域,整体靠底对齐;预留底部 padding 避免与 bottom-section 重叠 */
.multi-section {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 auto;
min-height: 0;
margin-top: auto;
padding-bottom: 14px;
box-sizing: border-box;
overflow: hidden;
}
.outcome-carousel {
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.outcome-carousel :deep(.v-carousel__container) {
touch-action: pan-y pinch-zoom;
}
.outcome-slide {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
box-sizing: border-box;
}
.outcome-slide-inner {
width: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
padding: 6px 12px 6px 10px;
}
.outcome-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.outcome-item {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.outcome-title {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 500;
color: #000000;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outcome-chance-value {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 700;
color: #000000;
flex-shrink: 0;
}
.outcome-buttons {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.outcome-buttons .option-yes-no-compact {
min-width: 44px;
height: 28px;
padding: 0 10px;
}
.outcome-buttons .option-text-yes,
.outcome-buttons .option-text-no {
font-size: 13px;
}
.carousel-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0;
margin-bottom: 10px;
flex-shrink: 0;
}
.carousel-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background-color: #f0f0f0;
color: #333;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
box-shadow: none;
}
.carousel-arrow:hover:not(:disabled) {
background-color: #e0e0e0;
color: #000;
}
.carousel-arrow:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.carousel-dots {
display: flex;
justify-content: center;
gap: 6px;
padding: 0;
flex-shrink: 0;
}
.carousel-dot {
width: 6px;
height: 6px;
border-radius: 50%;
border: none;
background-color: #e0e0e0;
cursor: pointer;
transition: background-color 0.2s ease;
padding: 0;
}
.carousel-dot:hover {
background-color: #bdbdbd;
}
.carousel-dot.active {
background-color: rgb(var(--v-theme-primary));
width: 8px;
height: 6px;
border-radius: 3px;
}
.new-badge {
display: inline-block;
margin-right: 6px;
padding: 1px 6px;
font-size: 11px;
font-weight: 600;
color: #000;
background-color: #fef08a;
border-radius: 4px;
}
</style> </style>

View File

@ -28,13 +28,13 @@
<div class="order-list-header-total">TOTAL</div> <div class="order-list-header-total">TOTAL</div>
</div> </div>
<!-- Asks Orders --> <!-- Asks Orders -->
<div class="asks-label">Asks</div>
<div v-for="(ask, index) in asksWithCumulativeTotal" :key="index" class="order-item"> <div v-for="(ask, index) in asksWithCumulativeTotal" :key="index" class="order-item">
<div class="order-progress"> <div class="order-progress">
<HorizontalProgressBar <HorizontalProgressBar
:max="maxAsksTotal" :max="maxAsksTotal"
:value="ask.total" :value="ask.total"
:fillStyle="{ backgroundColor: '#ffb3ba' }" :fillStyle="{ backgroundColor: '#e89595' }"
:trackStyle="{ backgroundColor: 'transparent' }" :trackStyle="{ backgroundColor: 'transparent' }"
/> />
</div> </div>
@ -43,6 +43,7 @@
<div class="order-total">{{ ask.cumulativeTotal.toFixed(2) }}</div> <div class="order-total">{{ ask.cumulativeTotal.toFixed(2) }}</div>
</div> </div>
<!-- Bids Orders --> <!-- Bids Orders -->
<div class="asks-label">Asks</div>
<div class="bids-label">Bids</div> <div class="bids-label">Bids</div>
<div <div
v-for="(bid, index) in bidsWithCumulativeTotal" v-for="(bid, index) in bidsWithCumulativeTotal"
@ -53,7 +54,7 @@
<HorizontalProgressBar <HorizontalProgressBar
:max="maxBidsTotal" :max="maxBidsTotal"
:value="bid.total" :value="bid.total"
:fillStyle="{ backgroundColor: '#b3ffb3' }" :fillStyle="{ backgroundColor: '#7acc7a' }"
:trackStyle="{ backgroundColor: 'transparent' }" :trackStyle="{ backgroundColor: 'transparent' }"
/> />
</div> </div>
@ -311,11 +312,11 @@ const maxBidsTotal = computed(() => {
} }
.asks-bar { .asks-bar {
background-color: #ffb3ba; background-color: #e89595;
} }
.bids-bar { .bids-bar {
background-color: #b3ffb3; background-color: #7acc7a;
} }
.order-list { .order-list {
@ -379,12 +380,12 @@ const maxBidsTotal = computed(() => {
.asks-label { .asks-label {
color: #ffffff; color: #ffffff;
background-color: #ff0000; background-color: #cc0000;
} }
.bids-label { .bids-label {
color: #ffffff; color: #ffffff;
background-color: #008000; background-color: #006600;
} }
.order-item { .order-item {
@ -402,11 +403,11 @@ const maxBidsTotal = computed(() => {
} }
.asks-price { .asks-price {
color: #ff0000; color: #cc0000;
} }
.bids-price { .bids-price {
color: #008000; color: #006600;
} }
.order-price { .order-price {

View File

@ -25,7 +25,7 @@
<v-list-item @click="openMergeDialog"> <v-list-item @click="openMergeDialog">
<v-list-item-title>Merge</v-list-item-title> <v-list-item-title>Merge</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item> <v-list-item @click="openSplitDialog">
<v-list-item-title>Split</v-list-item-title> <v-list-item-title>Split</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
@ -44,7 +44,7 @@
text text
@click="handleOptionChange('yes')" @click="handleOptionChange('yes')"
> >
Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }} Yes {{ yesPriceCents }}¢
</v-btn> </v-btn>
<v-btn <v-btn
class="no-btn" class="no-btn"
@ -52,7 +52,7 @@
text text
@click="handleOptionChange('no')" @click="handleOptionChange('no')"
> >
No {{ selectedOption === 'no' ? '82¢' : '81¢' }} No {{ noPriceCents }}¢
</v-btn> </v-btn>
</div> </div>
@ -84,8 +84,9 @@
</template> </template>
</div> </div>
<p v-if="orderError" class="order-error">{{ orderError }}</p>
<!-- Action Button --> <!-- Action Button -->
<v-btn class="action-btn" @click="submitOrder"> <v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">
{{ actionButtonText }} {{ actionButtonText }}
</v-btn> </v-btn>
</template> </template>
@ -100,7 +101,7 @@
text text
@click="handleOptionChange('yes')" @click="handleOptionChange('yes')"
> >
Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }} Yes {{ yesPriceCents }}¢
</v-btn> </v-btn>
<v-btn <v-btn
class="no-btn" class="no-btn"
@ -108,7 +109,7 @@
text text
@click="handleOptionChange('no')" @click="handleOptionChange('no')"
> >
No {{ selectedOption === 'no' ? '82¢' : '81¢' }} No {{ noPriceCents }}¢
</v-btn> </v-btn>
</div> </div>
@ -151,7 +152,7 @@
text text
@click="handleOptionChange('yes')" @click="handleOptionChange('yes')"
> >
Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }} Yes {{ yesPriceCents }}¢
</v-btn> </v-btn>
<v-btn <v-btn
class="no-btn" class="no-btn"
@ -159,7 +160,7 @@
text text
@click="handleOptionChange('no')" @click="handleOptionChange('no')"
> >
No {{ selectedOption === 'no' ? '82¢' : '81¢' }} No {{ noPriceCents }}¢
</v-btn> </v-btn>
</div> </div>
@ -172,13 +173,17 @@
<v-icon>mdi-minus</v-icon> <v-icon>mdi-minus</v-icon>
</v-btn> </v-btn>
<v-text-field <v-text-field
v-model.number="limitPrice" :model-value="limitPrice"
type="number" type="number"
min="0.01" min="0"
max="1"
step="0.01" step="0.01"
class="price-input-field" class="price-input-field"
hide-details hide-details
density="compact" density="compact"
@update:model-value="onLimitPriceInput"
@keydown="onLimitPriceKeydown"
@paste="onLimitPricePaste"
></v-text-field> ></v-text-field>
<v-btn class="adjust-btn" icon @click="increasePrice"> <v-btn class="adjust-btn" icon @click="increasePrice">
<v-icon>mdi-plus</v-icon> <v-icon>mdi-plus</v-icon>
@ -193,12 +198,15 @@
<span class="label">Shares</span> <span class="label">Shares</span>
<div class="shares-input"> <div class="shares-input">
<v-text-field <v-text-field
v-model.number="shares" :model-value="shares"
type="number" type="number"
min="0" min="1"
class="shares-input-field" class="shares-input-field"
hide-details hide-details
density="compact" density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field> ></v-text-field>
</div> </div>
</div> </div>
@ -267,8 +275,8 @@
</template> </template>
</div> </div>
<!-- Action Button --> <p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn class="action-btn" @click="submitOrder"> <v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">
{{ actionButtonText }} {{ actionButtonText }}
</v-btn> </v-btn>
</template> </template>
@ -295,15 +303,15 @@
<v-list-item @click="limitType = 'Limit'"><v-list-item-title>Limit</v-list-item-title></v-list-item> <v-list-item @click="limitType = 'Limit'"><v-list-item-title>Limit</v-list-item-title></v-list-item>
<v-divider></v-divider> <v-divider></v-divider>
<v-list-item @click="openMergeDialog"><v-list-item-title>Merge</v-list-item-title></v-list-item> <v-list-item @click="openMergeDialog"><v-list-item-title>Merge</v-list-item-title></v-list-item>
<v-list-item><v-list-item-title>Split</v-list-item-title></v-list-item> <v-list-item @click="openSplitDialog"><v-list-item-title>Split</v-list-item-title></v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</div> </div>
<template v-if="isMarketMode"> <template v-if="isMarketMode">
<template v-if="balance > 0"> <template v-if="balance > 0">
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="total-section"> <div class="total-section">
<template v-if="activeTab === 'buy'"> <template v-if="activeTab === 'buy'">
@ -314,12 +322,13 @@
<div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div> <div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div>
</template> </template>
</div> </div>
<v-btn class="action-btn" @click="submitOrder">{{ actionButtonText }}</v-btn> <p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">{{ actionButtonText }}</v-btn>
</template> </template>
<template v-else> <template v-else>
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="input-group"> <div class="input-group">
<div class="amount-header"> <div class="amount-header">
@ -338,15 +347,15 @@
</template> </template>
<template v-else> <template v-else>
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="input-group limit-price-group"> <div class="input-group limit-price-group">
<div class="limit-price-header"> <div class="limit-price-header">
<span class="label">Limit Price</span> <span class="label">Limit Price</span>
<div class="price-input"> <div class="price-input">
<v-btn class="adjust-btn" icon @click="decreasePrice"><v-icon>mdi-minus</v-icon></v-btn> <v-btn class="adjust-btn" icon @click="decreasePrice"><v-icon>mdi-minus</v-icon></v-btn>
<v-text-field v-model.number="limitPrice" type="number" min="0.01" step="0.01" class="price-input-field" hide-details density="compact"></v-text-field> <v-text-field :model-value="limitPrice" type="number" min="0" max="1" step="0.01" class="price-input-field" hide-details density="compact" @update:model-value="onLimitPriceInput" @keydown="onLimitPriceKeydown" @paste="onLimitPricePaste"></v-text-field>
<v-btn class="adjust-btn" icon @click="increasePrice"><v-icon>mdi-plus</v-icon></v-btn> <v-btn class="adjust-btn" icon @click="increasePrice"><v-icon>mdi-plus</v-icon></v-btn>
</div> </div>
</div> </div>
@ -355,7 +364,7 @@
<div class="shares-header"> <div class="shares-header">
<span class="label">Shares</span> <span class="label">Shares</span>
<div class="shares-input"> <div class="shares-input">
<v-text-field v-model.number="shares" type="number" min="0" class="shares-input-field" hide-details density="compact"></v-text-field> <v-text-field :model-value="shares" type="number" min="1" class="shares-input-field" hide-details density="compact" @update:model-value="onSharesInput" @keydown="onSharesKeydown" @paste="onSharesPaste"></v-text-field>
</div> </div>
</div> </div>
<div v-if="activeTab === 'buy'" class="shares-buttons"> <div v-if="activeTab === 'buy'" class="shares-buttons">
@ -393,7 +402,8 @@
<div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div> <div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div>
</template> </template>
</div> </div>
<v-btn class="action-btn" @click="submitOrder">{{ actionButtonText }}</v-btn> <p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">{{ actionButtonText }}</v-btn>
</template> </template>
</div> </div>
</v-sheet> </v-sheet>
@ -445,15 +455,15 @@
<v-list-item @click="limitType = 'Limit'"><v-list-item-title>Limit</v-list-item-title></v-list-item> <v-list-item @click="limitType = 'Limit'"><v-list-item-title>Limit</v-list-item-title></v-list-item>
<v-divider></v-divider> <v-divider></v-divider>
<v-list-item @click="openMergeDialog"><v-list-item-title>Merge</v-list-item-title></v-list-item> <v-list-item @click="openMergeDialog"><v-list-item-title>Merge</v-list-item-title></v-list-item>
<v-list-item><v-list-item-title>Split</v-list-item-title></v-list-item> <v-list-item @click="openSplitDialog"><v-list-item-title>Split</v-list-item-title></v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</div> </div>
<template v-if="isMarketMode"> <template v-if="isMarketMode">
<template v-if="balance > 0"> <template v-if="balance > 0">
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="total-section"> <div class="total-section">
<template v-if="activeTab === 'buy'"> <template v-if="activeTab === 'buy'">
@ -462,41 +472,42 @@
</template> </template>
<template v-else> <template v-else>
<div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div> <div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div>
</template>
</div>
<v-btn class="action-btn" @click="submitOrder">{{ actionButtonText }}</v-btn>
</template>
<template v-else>
<div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn>
</div>
<div class="input-group">
<div class="amount-header">
<div><span class="label amount-label">Amount</span><span class="balance-label">Balance ${{ balance.toFixed(2) }}</span></div>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
</div>
<div class="amount-buttons">
<v-btn class="amount-btn" @click="adjustAmount(1)">+$1</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(20)">+$20</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(100)">+$100</v-btn>
<v-btn class="amount-btn" @click="setMaxAmount">Max</v-btn>
</div>
</div>
<v-btn class="deposit-btn" @click="deposit">Deposit</v-btn>
</template> </template>
</div>
<p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">{{ actionButtonText }}</v-btn>
</template> </template>
<template v-else> <template v-else>
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="input-group limit-price-group"> <div class="input-group">
<div class="amount-header">
<div><span class="label amount-label">Amount</span><span class="balance-label">Balance ${{ balance.toFixed(2) }}</span></div>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
</div>
<div class="amount-buttons">
<v-btn class="amount-btn" @click="adjustAmount(1)">+$1</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(20)">+$20</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(100)">+$100</v-btn>
<v-btn class="amount-btn" @click="setMaxAmount">Max</v-btn>
</div>
</div>
<v-btn class="deposit-btn" @click="deposit">Deposit</v-btn>
</template>
</template>
<template v-else>
<div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div>
<div class="input-group limit-price-group">
<div class="limit-price-header"> <div class="limit-price-header">
<span class="label">Limit Price</span> <span class="label">Limit Price</span>
<div class="price-input"> <div class="price-input">
<v-btn class="adjust-btn" icon @click="decreasePrice"><v-icon>mdi-minus</v-icon></v-btn> <v-btn class="adjust-btn" icon @click="decreasePrice"><v-icon>mdi-minus</v-icon></v-btn>
<v-text-field v-model.number="limitPrice" type="number" min="0.01" step="0.01" class="price-input-field" hide-details density="compact"></v-text-field> <v-text-field :model-value="limitPrice" type="number" min="0" max="1" step="0.01" class="price-input-field" hide-details density="compact" @update:model-value="onLimitPriceInput" @keydown="onLimitPriceKeydown" @paste="onLimitPricePaste"></v-text-field>
<v-btn class="adjust-btn" icon @click="increasePrice"><v-icon>mdi-plus</v-icon></v-btn> <v-btn class="adjust-btn" icon @click="increasePrice"><v-icon>mdi-plus</v-icon></v-btn>
</div> </div>
</div> </div>
@ -505,7 +516,7 @@
<div class="shares-header"> <div class="shares-header">
<span class="label">Shares</span> <span class="label">Shares</span>
<div class="shares-input"> <div class="shares-input">
<v-text-field v-model.number="shares" type="number" min="0" class="shares-input-field" hide-details density="compact"></v-text-field> <v-text-field :model-value="shares" type="number" min="1" class="shares-input-field" hide-details density="compact" @update:model-value="onSharesInput" @keydown="onSharesKeydown" @paste="onSharesPaste"></v-text-field>
</div> </div>
</div> </div>
<div v-if="activeTab === 'buy'" class="shares-buttons"> <div v-if="activeTab === 'buy'" class="shares-buttons">
@ -543,7 +554,8 @@
<div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div> <div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div>
</template> </template>
</div> </div>
<v-btn class="action-btn" @click="submitOrder">{{ actionButtonText }}</v-btn> <p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">{{ actionButtonText }}</v-btn>
</template> </template>
</div> </div>
</v-sheet> </v-sheet>
@ -579,47 +591,180 @@
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>
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- Split dialog对接 /PmMarket/split -->
<v-dialog v-model="splitDialogOpen" max-width="440" persistent content-class="split-dialog" transition="dialog-transition">
<v-card class="split-dialog-card" rounded="lg">
<div class="split-dialog-header">
<h3 class="split-dialog-title">Split</h3>
<v-btn icon variant="text" size="small" class="split-dialog-close" @click="splitDialogOpen = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-card-text class="split-dialog-body">
<p class="split-dialog-desc">
Use USDC to get one share of Yes and one share of No for this market. 1 USDC 1 complete set.
</p>
<div class="split-amount-row">
<label class="split-amount-label">Amount (USDC)</label>
<v-text-field
v-model.number="splitAmount"
type="number"
min="0"
step="1"
hide-details
density="compact"
variant="outlined"
class="split-amount-input"
/>
</div>
<p v-if="!props.market?.marketId" class="split-no-market">Please select a market first (e.g. click Buy Yes/No on a market).</p>
<p v-if="splitError" class="split-error">{{ splitError }}</p>
</v-card-text>
<v-card-actions class="split-dialog-actions">
<v-btn
color="primary"
variant="flat"
block
class="split-submit-btn"
:loading="splitLoading"
:disabled="splitLoading || splitAmount <= 0"
@click="submitSplit"
>
Split
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
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 { pmMarketMerge, pmMarketSplit, pmOrderPlace } from '../api/market'
import { OrderType, Side } from '../api/constants'
const { mobile } = useDisplay() const { mobile } = useDisplay()
const userStore = useUserStore()
export interface TradeMarketPayload {
marketId?: string
yesPrice: number
noPrice: number
title?: string
/** 与 outcomes/outcomePrices 顺序一致,用于下单 tokenId0=Yes 1=No */
clobTokenIds?: string[]
}
const props = withDefaults( const props = withDefaults(
defineProps<{ initialOption?: 'yes' | 'no'; embeddedInSheet?: boolean }>(), defineProps<{
{ initialOption: undefined, embeddedInSheet: false } initialOption?: 'yes' | 'no'
embeddedInSheet?: boolean
/** 从外部传入的市场数据(如 EventMarkets 点击 Yes/No 传入yesPrice/noPrice 为 01 */
market?: TradeMarketPayload
}>(),
{ initialOption: undefined, embeddedInSheet: false, market: undefined }
) )
// //
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() {
mergeDialogOpen.value = false 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
}
} }
const yesPriceCents = computed(() => 19) // Split dialog /PmMarket/split
const noPriceCents = computed(() => 82) const splitDialogOpen = ref(false)
const splitAmount = ref(0)
const splitLoading = ref(false)
const splitError = ref('')
function openSplitDialog() {
splitAmount.value = splitAmount.value > 0 ? splitAmount.value : 1
splitError.value = ''
splitDialogOpen.value = true
}
async function submitSplit() {
const marketId = props.market?.marketId
if (splitAmount.value <= 0) return
if (!marketId) return // split-no-market splitError
splitLoading.value = true
splitError.value = ''
try {
const res = await pmMarketSplit(
{ marketID: marketId, usdcAmount: String(splitAmount.value) },
{ headers: userStore.getAuthHeaders() }
)
if (res.code === 0 || res.code === 200) {
splitDialogOpen.value = false
} else {
splitError.value = res.msg || 'Split failed'
}
} catch (e) {
splitError.value = e instanceof Error ? e.message : 'Request failed'
} finally {
splitLoading.value = false
}
}
const yesPriceCents = computed(() =>
props.market ? Math.round(props.market.yesPrice * 100) : 19
)
const noPriceCents = computed(() =>
props.market ? Math.round(props.market.noPrice * 100) : 82
)
function openSheet(option: 'yes' | 'no') { function openSheet(option: 'yes' | 'no') {
handleOptionChange(option) handleOptionChange(option)
@ -632,7 +777,7 @@ const limitType = ref('Limit')
const expirationEnabled = ref(false) const expirationEnabled = ref(false)
const selectedOption = ref<'yes' | 'no'>(props.initialOption ?? 'no') const selectedOption = ref<'yes' | 'no'>(props.initialOption ?? 'no')
const limitPrice = ref(0.82) // const limitPrice = ref(0.82) //
const shares = ref(20) // const shares = ref(20) //
const expirationTime = ref('5m') // const expirationTime = ref('5m') //
const expirationOptions = ref(['5m', '15m', '30m', '1h', '2h', '4h', '8h', '12h', '1d', '2d', '3d']) // const expirationOptions = ref(['5m', '15m', '30m', '1h', '2h', '4h', '8h', '12h', '1d', '2d', '3d']) //
@ -641,6 +786,9 @@ const isMarketMode = computed(() => limitType.value === 'Market')
const amount = ref(0) // Market mode amount const amount = ref(0) // Market mode amount
const balance = ref(0) // Market mode balance const balance = ref(0) // Market mode balance
const orderLoading = ref(false)
const orderError = ref('')
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
optionChange: [option: 'yes' | 'no'] optionChange: [option: 'yes' | 'no']
@ -651,6 +799,7 @@ const emit = defineEmits<{
shares: number shares: number
expirationEnabled: boolean expirationEnabled: boolean
expirationTime: string expirationTime: string
marketId?: string
}] }]
}>() }>()
@ -669,51 +818,125 @@ const actionButtonText = computed(() => {
function applyInitialOption(option: 'yes' | 'no') { function applyInitialOption(option: 'yes' | 'no') {
selectedOption.value = option selectedOption.value = option
if (option === 'yes') { syncLimitPriceFromMarket()
limitPrice.value = 0.19 }
} else {
limitPrice.value = 0.82 function clampLimitPrice(v: number): number {
} return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0))
}
/** 根据当前 props.market 与 selectedOption 同步 limitPrice组件显示或 market 更新时调用) */
function syncLimitPriceFromMarket() {
const yesP = props.market?.yesPrice ?? 0.19
const noP = props.market?.noPrice ?? 0.82
limitPrice.value = clampLimitPrice(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) => {
if (m) {
orderError.value = ''
if (props.initialOption) applyInitialOption(props.initialOption)
else syncLimitPriceFromMarket()
}
}, { deep: true })
// Methods // Methods
const handleOptionChange = (option: 'yes' | 'no') => { const handleOptionChange = (option: 'yes' | 'no') => {
selectedOption.value = option selectedOption.value = option
// const yesP = props.market?.yesPrice ?? 0.19
if (option === 'yes') { const noP = props.market?.noPrice ?? 0.82
limitPrice.value = 0.19 limitPrice.value = clampLimitPrice(option === 'yes' ? yesP : noP)
} else {
limitPrice.value = 0.82
}
emit('optionChange', option) emit('optionChange', option)
} }
// /** 仅在值在 [0,1] 且为有效数字时更新,否则保持原值不变 */
function onLimitPriceInput(v: unknown) {
const num = v == null ? NaN : Number(v)
if (!Number.isFinite(num) || num < 0 || num > 1) return
limitPrice.value = num
}
/** 只允许数字和小数点输入 */
function onLimitPriceKeydown(e: KeyboardEvent) {
const key = e.key
const allowed = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End']
if (allowed.includes(key)) return
if (e.ctrlKey || e.metaKey) {
if (['a', 'c', 'v', 'x'].includes(key.toLowerCase())) return
}
if (key >= '0' && key <= '9') return
if (key === '.' && !String((e.target as HTMLInputElement)?.value ?? '').includes('.')) return
e.preventDefault()
}
/** 粘贴时只接受有效数字 */
function onLimitPricePaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData('text') ?? ''
const num = parseFloat(text)
if (!Number.isFinite(num) || num < 0 || num > 1) {
e.preventDefault()
}
}
// 01
const decreasePrice = () => { const decreasePrice = () => {
limitPrice.value = Math.max(0.01, limitPrice.value - 0.01) limitPrice.value = clampLimitPrice(limitPrice.value - 0.01)
} }
const increasePrice = () => { const increasePrice = () => {
limitPrice.value += 0.01 limitPrice.value = clampLimitPrice(limitPrice.value + 0.01)
} }
// /** 将 shares 限制为正整数(>= 1 */
function clampShares(v: number): number {
const n = Math.floor(Number.isFinite(v) ? v : 1)
return Math.max(1, n)
}
/** 仅在值为正整数时更新 shares */
function onSharesInput(v: unknown) {
const num = v == null ? NaN : Number(v)
const n = Math.floor(num)
if (!Number.isFinite(num) || n < 1 || num !== n) return
shares.value = n
}
/** 只允许数字输入Shares 为正整数) */
function onSharesKeydown(e: KeyboardEvent) {
const key = e.key
const allowed = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End']
if (allowed.includes(key)) return
if (e.ctrlKey || e.metaKey) {
if (['a', 'c', 'v', 'x'].includes(key.toLowerCase())) return
}
if (key >= '0' && key <= '9') return
e.preventDefault()
}
/** 粘贴时只接受正整数 */
function onSharesPaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData('text') ?? ''
const num = parseInt(text, 10)
if (!Number.isFinite(num) || num < 1) e.preventDefault()
}
//
const adjustShares = (amount: number) => { const adjustShares = (amount: number) => {
shares.value = Math.max(0, shares.value + amount) shares.value = clampShares(shares.value + amount)
} }
// Sell使 // Sell使
const setSharesPercentage = (percentage: number) => { const setSharesPercentage = (percentage: number) => {
// 100
const maxShares = 100 const maxShares = 100
shares.value = Math.round((maxShares * percentage) / 100) shares.value = clampShares(Math.round((maxShares * percentage) / 100))
} }
// Market mode methods // Market mode methods
@ -731,16 +954,95 @@ const deposit = () => {
// API // API
} }
// Set expiration /** 将 expirationTime 如 "5m" 转为 Unix 秒级时间戳GTD 用0 表示无过期 */
function submitOrder() { function parseExpirationTimestamp(expTime: string): number {
emit('submit', { const m = /^(\d+)(m|h|d)$/i.exec(expTime)
if (!m) return 0
const [, num, unit] = m
const n = parseInt(num ?? '0', 10)
if (!Number.isFinite(n) || n <= 0) return 0
const now = Date.now()
let ms = n
if (unit?.toLowerCase() === 'm') ms = n * 60 * 1000
else if (unit?.toLowerCase() === 'h') ms = n * 3600 * 1000
else if (unit?.toLowerCase() === 'd') ms = n * 86400 * 1000
return Math.floor((now + ms) / 1000)
}
// Set expiration /clob/gateway/submitOrder
async function submitOrder() {
const marketId = props.market?.marketId
const clobIds = props.market?.clobTokenIds
const outcomeIndex = selectedOption.value === 'yes' ? 0 : 1
const tokenId = clobIds?.[outcomeIndex]
const payload = {
side: activeTab.value as 'buy' | 'sell', side: activeTab.value as 'buy' | 'sell',
option: selectedOption.value, option: selectedOption.value,
limitPrice: limitPrice.value, limitPrice: limitPrice.value,
shares: shares.value, shares: shares.value,
expirationEnabled: expirationEnabled.value, expirationEnabled: expirationEnabled.value,
expirationTime: expirationTime.value, expirationTime: expirationTime.value,
}) ...(marketId != null && { marketId }),
}
emit('submit', payload)
if (!tokenId) {
orderError.value = '请先选择市场(需包含 clobTokenIds'
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
orderError.value = '请先登录'
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userIdNum = uid != null ? Number(uid) : 0
if (!Number.isFinite(userIdNum) || userIdNum <= 0) {
orderError.value = '用户信息异常'
return
}
const isMarket = limitType.value === 'Market'
const orderTypeNum = isMarket
? OrderType.Market
: expirationEnabled.value
? OrderType.GTD
: OrderType.GTC
const sideNum = activeTab.value === 'buy' ? Side.Buy : Side.Sell
const expiration =
orderTypeNum === OrderType.GTD && expirationEnabled.value
? parseExpirationTimestamp(expirationTime.value)
: 0
orderLoading.value = true
orderError.value = ''
try {
const res = await pmOrderPlace(
{
expiration,
feeRateBps: 0,
nonce: 0,
orderType: orderTypeNum,
price: limitPrice.value,
side: sideNum,
size: clampShares(shares.value),
taker: true,
tokenID: tokenId,
userID: userIdNum,
},
{ headers }
)
if (res.code === 0 || res.code === 200) {
userStore.fetchUsdcBalance()
} else {
orderError.value = res.msg || '下单失败'
}
} catch (e) {
orderError.value = e instanceof Error ? e.message : 'Request failed'
} finally {
orderLoading.value = false
}
} }
</script> </script>
@ -806,8 +1108,8 @@ function submitOrder() {
} }
.yes-btn.active { .yes-btn.active {
background-color: #e6f9e6; background-color: #b8e0b8;
color: #008000; color: #006600;
} }
.no-btn { .no-btn {
@ -820,7 +1122,7 @@ function submitOrder() {
} }
.no-btn.active { .no-btn.active {
background-color: #ff0000; background-color: #cc0000;
color: #ffffff; color: #ffffff;
} }
@ -960,7 +1262,7 @@ function submitOrder() {
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 12px; font-size: 12px;
color: #4caf50; color: #3d8b40;
} }
.expiration-header { .expiration-header {
@ -1003,7 +1305,7 @@ function submitOrder() {
.to-win-value { .to-win-value {
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: #4caf50; color: #3d8b40;
} }
.action-btn { .action-btn {
@ -1103,7 +1405,7 @@ function submitOrder() {
.mobile-bar-yes { .mobile-bar-yes {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
background: #22c55e; background: #16a34a;
color: #fff; color: #fff;
padding: 14px 16px; padding: 14px 16px;
} }
@ -1111,7 +1413,7 @@ function submitOrder() {
.mobile-bar-no { .mobile-bar-no {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
background: #ef4444; background: #dc2626;
color: #fff; color: #fff;
padding: 14px 16px; padding: 14px 16px;
} }
@ -1269,6 +1571,26 @@ 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;
}
.order-error {
font-size: 13px;
color: #dc2626;
margin: 8px 0 0;
}
.merge-dialog-actions { .merge-dialog-actions {
padding: 16px 20px 20px; padding: 16px 20px 20px;
padding-top: 8px; padding-top: 8px;
@ -1278,4 +1600,72 @@ function submitOrder() {
text-transform: none; text-transform: none;
font-weight: 600; font-weight: 600;
} }
/* Split dialog */
.split-dialog-card {
padding: 0;
overflow: hidden;
}
.split-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 0;
}
.split-dialog-title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0;
}
.split-dialog-close {
color: #6b7280;
}
.split-dialog-body {
padding: 16px 20px 8px;
}
.split-dialog-desc {
font-size: 14px;
color: #374151;
line-height: 1.5;
margin: 0 0 16px 0;
}
.split-amount-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.split-amount-label {
font-size: 14px;
font-weight: 600;
color: #111827;
flex-shrink: 0;
}
.split-amount-input {
flex: 1;
max-width: 160px;
}
.split-amount-input :deep(.v-field) {
font-size: 14px;
}
.split-no-market,
.split-error {
font-size: 13px;
margin: 8px 0 0;
}
.split-no-market {
color: #6b7280;
}
.split-error {
color: #dc2626;
}
.split-dialog-actions {
padding: 16px 20px 20px;
padding-top: 8px;
}
.split-submit-btn {
text-transform: none;
font-weight: 600;
}
</style> </style>

View File

@ -3,6 +3,7 @@ import Home from '../views/Home.vue'
import Trade from '../views/Trade.vue' import Trade from '../views/Trade.vue'
import Login from '../views/Login.vue' import Login from '../views/Login.vue'
import TradeDetail from '../views/TradeDetail.vue' import TradeDetail from '../views/TradeDetail.vue'
import EventMarkets from '../views/EventMarkets.vue'
import Wallet from '../views/Wallet.vue' import Wallet from '../views/Wallet.vue'
const router = createRouter({ const router = createRouter({
@ -28,12 +29,22 @@ const router = createRouter({
name: 'trade-detail', name: 'trade-detail',
component: TradeDetail component: TradeDetail
}, },
{
path: '/event/:id/markets',
name: 'event-markets',
component: EventMarkets
},
{ {
path: '/wallet', path: '/wallet',
name: 'wallet', name: 'wallet',
component: Wallet component: Wallet
} }
], ],
scrollBehavior(to, from, savedPosition) {
if (savedPosition && from?.name) return savedPosition
if (to.hash) return { el: to.hash }
return { top: 0 }
},
}) })
export default router export default router

View File

@ -1,7 +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
headerImg?: string headerImg?: string
nickName?: string nickName?: string
userName?: string userName?: string
@ -61,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 }
}) })

846
src/views/EventMarkets.vue Normal file
View File

@ -0,0 +1,846 @@
<template>
<v-container class="event-markets-container">
<v-card v-if="detailLoading && !eventDetail" class="loading-card" elevation="0" rounded="lg">
<div class="loading-placeholder">
<v-progress-circular indeterminate color="primary" size="48" />
<p>加载中...</p>
</div>
</v-card>
<template v-else-if="detailError">
<v-card class="error-card" elevation="0" rounded="lg">
<p class="error-text">{{ detailError }}</p>
</v-card>
</template>
<template v-else-if="eventDetail">
<div class="event-header">
<h1 class="event-title">{{ eventDetail.title }}</h1>
<p v-if="eventDetail.series?.length || eventDetail.tags?.length" class="event-meta">
{{ categoryText }}
</p>
<p v-if="eventVolume" class="event-volume">{{ eventVolume }}</p>
</div>
<v-row align="stretch" class="event-markets-row">
<!-- 左侧分时图 + 市场列表 -->
<v-col cols="12" class="chart-col">
<!-- 分时图卡片多市场多条线 -->
<v-card
v-if="markets.length > 0"
class="chart-card polymarket-chart"
elevation="0"
rounded="lg"
>
<div class="chart-header">
<h1 class="chart-title">{{ eventDetail?.title || 'All markets' }}</h1>
<div class="chart-controls-row">
<v-btn variant="text" size="small" class="past-btn">Past </v-btn>
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
</div>
<p class="chart-legend-hint">{{ markets.length }} 个市场</p>
</div>
<div class="chart-wrapper">
<div ref="chartContainerRef" class="chart-container"></div>
</div>
<div class="chart-footer">
<div class="chart-footer-left">
<span class="chart-volume">{{ eventVolume || '$0 Vol.' }}</span>
<span v-if="marketExpiresAt" class="chart-expires">| {{ marketExpiresAt }}</span>
</div>
<div class="chart-time-ranges">
<v-btn
v-for="r in timeRanges"
:key="r.value"
:class="['time-range-btn', { active: selectedTimeRange === r.value }]"
variant="text"
size="small"
@click="selectTimeRange(r.value)"
>
{{ r.label }}
</v-btn>
</div>
</div>
</v-card>
<!-- 市场列表 -->
<v-card class="markets-list-card" elevation="0" rounded="lg">
<div class="markets-list">
<div
v-for="(market, index) in markets"
:key="market.ID ?? index"
class="market-row"
:class="{ selected: selectedMarketIndex === index }"
@click="goToTradeDetail(market)"
>
<div class="market-row-main">
<span class="market-question">{{ market.question || 'Market' }}</span>
<span class="market-chance">{{ marketChance(market) }}%</span>
</div>
<div class="market-row-vol">{{ formatVolume(market.volume) }}</div>
<div class="market-row-actions" @click.stop>
<v-btn
class="buy-yes-btn"
variant="flat"
size="small"
@click="openTrade(market, index, 'yes')"
>
Buy Yes {{ yesPrice(market) }}
</v-btn>
<v-btn
class="buy-no-btn"
variant="flat"
size="small"
@click="openTrade(market, index, 'no')"
>
Buy No {{ noPrice(market) }}
</v-btn>
</div>
</div>
</div>
</v-card>
</v-col>
<!-- 右侧购买组件点击 Yes/No 时传入当前市场数据 -->
<v-col cols="12" class="trade-col">
<div v-if="markets.length > 0" class="trade-sidebar">
<TradeComponent
:market="tradeMarketPayload"
:initial-option="tradeInitialOption"
@submit="onTradeSubmit"
/>
</div>
</v-col>
</v-row>
</template>
</v-container>
</template>
<script setup lang="ts">
defineOptions({ name: 'EventMarkets' })
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import TradeComponent from '../components/TradeComponent.vue'
import { findPmEvent, getMarketId, type FindPmEventParams, type PmEventListItem, type PmEventMarketItem } from '../api/event'
import { MOCK_EVENT_LIST } from '../api/mockEventList'
import { useUserStore } from '../stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const eventDetail = ref<PmEventListItem | null>(null)
const detailLoading = ref(false)
const detailError = ref<string | null>(null)
const selectedMarketIndex = ref(0)
/** 点击 Buy Yes/No 时传给购买组件的初始方向,不点击则为 undefined 使用组件默认 */
const tradeInitialOption = ref<'yes' | 'no' | undefined>(undefined)
const markets = computed(() => {
const list = eventDetail.value?.markets ?? []
return list.length > 0 ? list : []
})
const selectedMarket = computed(() => markets.value[selectedMarketIndex.value] ?? null)
/** 传给购买组件的市场数据(当前选中的市场) */
const tradeMarketPayload = computed(() => {
const m = selectedMarket.value
if (!m) return undefined
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,
clobTokenIds: m.clobTokenIds,
}
})
const categoryText = computed(() => {
const s = eventDetail.value?.series?.[0]?.title
const t = eventDetail.value?.tags?.[0]?.label
return [s, t].filter(Boolean).join(' · ') || ''
})
function formatVolume(volume: number | undefined): string {
if (volume == null || !Number.isFinite(volume)) return '$0 Vol.'
if (volume >= 1000) return `$${(volume / 1000).toFixed(1)}k Vol.`
return `$${Math.round(volume)} Vol.`
}
function formatExpiresAt(endDate: string | undefined): string {
if (!endDate) return ''
try {
const d = new Date(endDate)
if (Number.isNaN(d.getTime())) return endDate
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return endDate
}
}
const eventVolume = computed(() => {
const v = eventDetail.value?.volume
return v != null ? formatVolume(v) : ''
})
const currentChance = computed(() => {
const m = selectedMarket.value
if (!m) return 0
return marketChance(m)
})
const selectedMarketVolume = computed(() => formatVolume(selectedMarket.value?.volume))
const marketExpiresAt = computed(() => {
const endDate = eventDetail.value?.endDate
return endDate ? formatExpiresAt(endDate) : ''
})
const resolutionDate = computed(() => {
const s = marketExpiresAt.value
return s ? s.replace(/,?\s*\d{4}$/, '').trim() || '' : ''
})
const timeRanges = [
{ label: '1H', value: '1H' },
{ label: '6H', value: '6H' },
{ label: '1D', value: '1D' },
{ label: '1W', value: '1W' },
{ label: '1M', value: '1M' },
{ label: 'ALL', value: 'ALL' },
]
const selectedTimeRange = ref('ALL')
const chartContainerRef = ref<HTMLElement | null>(null)
type ChartSeriesItem = { name: string; data: [number, number][] }
const chartData = ref<ChartSeriesItem[]>([])
let chartInstance: ECharts | null = null
let dynamicInterval: number | undefined
const LINE_COLORS = ['#2563eb', '#dc2626', '#16a34a', '#ca8a04', '#9333ea', '#0891b2', '#ea580c', '#4f46e5']
const MOBILE_BREAKPOINT = 600
function getStepAndCount(range: string): { stepMs: number; count: number } {
switch (range) {
case '1H':
return { stepMs: 60 * 1000, count: 60 }
case '6H':
return { stepMs: 10 * 60 * 1000, count: 36 }
case '1D':
return { stepMs: 60 * 60 * 1000, count: 24 }
case '1W':
return { stepMs: 24 * 60 * 60 * 1000, count: 7 }
case '1M':
case 'ALL':
return { stepMs: 24 * 60 * 60 * 1000, count: 30 }
default:
return { stepMs: 60 * 60 * 1000, count: 24 }
}
}
function generateDataForMarket(baseChance: number, range: string): [number, number][] {
const now = Date.now()
const data: [number, number][] = []
const { stepMs, count } = getStepAndCount(range)
let value = baseChance + (Math.random() - 0.5) * 10
for (let i = count; i >= 0; i--) {
const t = now - i * stepMs
value = Math.max(10, Math.min(90, value + (Math.random() - 0.5) * 6))
data.push([t, Math.round(value * 10) / 10])
}
return data
}
function generateAllData(): ChartSeriesItem[] {
const range = selectedTimeRange.value
return markets.value.map((market) => {
const chance = marketChance(market)
const name = (market.question || 'Market').slice(0, 32)
return {
name: name + (name.length >= 32 ? '…' : ''),
data: generateDataForMarket(chance || 20, range),
}
})
}
function buildOption(seriesArr: ChartSeriesItem[], containerWidth?: number) {
const width = containerWidth ?? chartContainerRef.value?.clientWidth ?? 400
const isMobile = width < MOBILE_BREAKPOINT
const hasData = seriesArr.some((s) => s.data.length >= 2)
const xAxisConfig: Record<string, unknown> = {
type: 'time',
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280', fontSize: isMobile ? 10 : 11 },
axisTick: { show: false },
splitLine: { show: false },
}
if (isMobile && hasData) {
const times = seriesArr.flatMap((s) => s.data.map((d) => d[0]))
const span = times.length ? Math.max(...times) - Math.min(...times) : 0
xAxisConfig.axisLabel = {
...(xAxisConfig.axisLabel as object),
interval: Math.max(span / 4, 60 * 1000),
formatter: (value: number) => {
const d = new Date(value)
return `${d.getMonth() + 1}/${d.getDate()}`
},
rotate: -25,
}
}
const series = seriesArr.map((s, i) => {
const color = LINE_COLORS[i % LINE_COLORS.length]
const lastIndex = s.data.length - 1
return {
name: s.name,
type: 'line' as const,
showSymbol: true,
symbol: 'circle',
symbolSize: (_: unknown, params: { dataIndex?: number }) =>
params?.dataIndex === lastIndex ? 8 : 0,
data: s.data,
smooth: true,
lineStyle: { width: 2, color },
itemStyle: { color, borderColor: '#fff', borderWidth: 2 },
}
})
return {
animation: false,
tooltip: {
trigger: 'axis',
formatter: (params: unknown) => {
const arr = Array.isArray(params) ? params : [params]
if (arr.length === 0) return ''
const p0 = arr[0] as { name: string | number; value: unknown }
const date = new Date(p0.name as number)
const dateStr = `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`
const lines = arr
.filter((x) => x != null && (x as { value?: unknown }).value != null)
.map((x) => {
const v = (x as { seriesName?: string; value: unknown }).value
const val = Array.isArray(v) ? v[1] : v
return `${(x as { seriesName?: string }).seriesName}: ${val}%`
})
return [dateStr, ...lines].join('<br/>')
},
axisPointer: { animation: false },
},
legend: {
type: 'scroll',
top: 0,
right: 0,
data: seriesArr.map((s) => s.name),
textStyle: { fontSize: 11, color: '#6b7280' },
itemWidth: 14,
itemHeight: 8,
itemGap: 12,
},
grid: {
left: 16,
right: 48,
top: 40,
bottom: isMobile ? 44 : 28,
containLabel: false,
},
xAxis: xAxisConfig,
yAxis: {
type: 'value',
position: 'right',
boundaryGap: [0, '100%'],
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#6b7280', fontSize: 11, formatter: '{value}%' },
splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e5e7eb' } },
},
series,
}
}
function initChart() {
if (!chartContainerRef.value || markets.value.length === 0) return
chartData.value = generateAllData()
chartInstance = echarts.init(chartContainerRef.value)
const w = chartContainerRef.value.clientWidth
chartInstance.setOption(buildOption(chartData.value, w))
}
function updateChartData() {
chartData.value = generateAllData()
const w = chartContainerRef.value?.clientWidth
if (chartInstance) chartInstance.setOption(buildOption(chartData.value, w), { replaceMerge: ['series'] })
}
function selectTimeRange(range: string) {
selectedTimeRange.value = range
updateChartData()
}
function getMaxPoints(range: string): number {
return getStepAndCount(range).count + 1
}
function startDynamicUpdate() {
dynamicInterval = window.setInterval(() => {
const nextData = chartData.value.map((s) => {
const list = [...s.data]
const last = list[list.length - 1]
if (!last) return s
const nextVal = Math.max(10, Math.min(90, last[1] + (Math.random() - 0.5) * 4))
list.push([Date.now(), Math.round(nextVal * 10) / 10])
const max = getMaxPoints(selectedTimeRange.value)
return { name: s.name, data: list.slice(-max) }
})
chartData.value = nextData
const w = chartContainerRef.value?.clientWidth
if (chartInstance) chartInstance.setOption(buildOption(chartData.value, w), { replaceMerge: ['series'] })
}, 3000)
}
function stopDynamicUpdate() {
if (dynamicInterval) {
clearInterval(dynamicInterval)
dynamicInterval = undefined
}
}
const handleResize = () => {
if (!chartInstance || !chartContainerRef.value) return
chartInstance.resize()
chartInstance.setOption(buildOption(chartData.value, chartContainerRef.value.clientWidth), {
replaceMerge: ['series'],
})
}
function selectMarket(index: number) {
selectedMarketIndex.value = index
}
/** 点击 Buy Yes/No选中该市场并把数据和方向传给购买组件不跳转 */
function openTrade(market: PmEventMarketItem, index: number, side: 'yes' | 'no') {
selectedMarketIndex.value = index
tradeInitialOption.value = side
}
function onTradeSubmit(payload: {
side: 'buy' | 'sell'
option: 'yes' | 'no'
limitPrice: number
shares: number
expirationEnabled: boolean
expirationTime: string
marketId?: string
}) {
// APIpayload marketId
console.log('Trade submit', payload)
}
function marketChance(market: PmEventMarketItem): number {
const raw = market?.outcomePrices?.[0]
if (raw == null) return 0
const yesPrice = parseFloat(String(raw))
if (!Number.isFinite(yesPrice)) return 0
return Math.min(100, Math.max(0, Math.round(yesPrice * 100)))
}
function yesPrice(market: PmEventMarketItem): string {
const raw = market?.outcomePrices?.[0]
if (raw == null) return '0¢'
const p = parseFloat(String(raw))
if (!Number.isFinite(p)) return '0¢'
return `${Math.round(p * 100)}¢`
}
function noPrice(market: PmEventMarketItem): string {
const raw = market?.outcomePrices?.[1]
if (raw == null) return '0¢'
const p = parseFloat(String(raw))
if (!Number.isFinite(p)) return '0¢'
return `${Math.round(p * 100)}¢`
}
function goToTradeDetail(market: PmEventMarketItem, side?: 'yes' | 'no') {
const eventId = route.params.id
const marketId = market.ID != null ? String(market.ID) : undefined
router.push({
path: `/trade-detail/${eventId}`,
query: {
title: market.question ?? eventDetail.value?.title,
marketId,
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 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(params, {
headers: userStore.getAuthHeaders(),
})
if (res.code === 0 || res.code === 200) {
eventDetail.value = res.data ?? null
detailError.value = null
} else {
const fallback = isNumericId ? getMockEventById(numId) : null
if (fallback) {
eventDetail.value = fallback
detailError.value = null
} else {
detailError.value = res.msg || '加载失败'
eventDetail.value = null
}
}
} catch (e) {
const fallback = isNumericId ? getMockEventById(numId) : null
if (fallback) {
eventDetail.value = fallback
detailError.value = null
} else {
detailError.value = e instanceof Error ? e.message : '加载失败'
eventDetail.value = null
}
} finally {
detailLoading.value = false
}
}
function getMockEventById(id: number): PmEventListItem | null {
const item = MOCK_EVENT_LIST.find((e) => e.ID === id)
return item && (item.markets?.length ?? 0) > 1 ? item : null
}
onMounted(() => {
loadEventDetail()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
stopDynamicUpdate()
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
chartInstance = null
})
watch(
() => markets.value.length,
(len) => {
if (len > 0) {
nextTick(() => {
initChart()
if (dynamicInterval == null) startDynamicUpdate()
})
}
}
)
watch(
() => route.params.id,
() => loadEventDetail()
)
</script>
<style scoped>
.event-markets-container {
padding: 24px;
min-height: 100vh;
}
.event-markets-row {
display: flex;
flex-wrap: wrap;
}
.chart-col {
flex: 1 1 0%;
min-width: 0;
}
.trade-col {
margin-top: 32px;
}
.trade-sidebar {
position: sticky;
top: 24px;
width: 400px;
max-width: 100%;
flex-shrink: 0;
}
@media (min-width: 960px) {
.event-markets-row {
flex-wrap: nowrap;
}
.chart-col {
flex: 1 1 0% !important;
min-width: 0;
max-width: none !important;
}
.trade-col {
flex: 0 0 400px !important;
max-width: 400px !important;
margin-top: 32px;
}
.trade-sidebar {
width: 400px;
}
}
@media (max-width: 959px) {
.trade-sidebar {
position: static;
width: 100%;
}
}
/* 分时图卡片 */
.chart-card.polymarket-chart {
margin-bottom: 24px;
padding: 20px 24px 16px;
background-color: #ffffff;
border: 1px solid #e7e7e7;
border-radius: 12px;
}
.chart-header {
margin-bottom: 16px;
}
.chart-title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0 0 12px 0;
line-height: 1.3;
}
.chart-controls-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.past-btn {
font-size: 13px;
color: #6b7280;
text-transform: none;
}
.date-pill {
background-color: #111827 !important;
color: #fff !important;
font-size: 12px;
font-weight: 500;
text-transform: none;
}
.chart-chance {
font-size: 1.5rem;
font-weight: 700;
color: #2563eb;
}
.chart-legend-hint {
font-size: 0.875rem;
color: #6b7280;
margin: 0 0 8px 0;
}
.chart-wrapper {
width: 100%;
margin-bottom: 12px;
}
.chart-container {
width: 100%;
height: 320px;
min-height: 260px;
}
.chart-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.chart-footer-left {
font-size: 12px;
color: #6b7280;
}
.chart-expires {
margin-left: 4px;
}
.chart-time-ranges {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.time-range-btn {
font-size: 12px;
text-transform: none;
min-width: 36px;
color: #6b7280;
}
.time-range-btn.active {
color: #111827;
font-weight: 600;
}
.time-range-btn:hover {
color: #111827;
}
.loading-card,
.error-card {
padding: 48px;
text-align: center;
}
.loading-placeholder p,
.error-text {
margin-top: 16px;
color: #6b7280;
}
.error-text {
color: #dc2626;
}
.event-header {
margin-bottom: 24px;
}
.event-title {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
margin: 0 0 8px 0;
line-height: 1.3;
}
.event-meta {
font-size: 14px;
color: #6b7280;
margin: 0 0 4px 0;
}
.event-volume {
font-size: 14px;
color: #6b7280;
margin: 0;
}
.markets-list-card {
border: 1px solid #e7e7e7;
border-radius: 12px;
overflow: hidden;
}
.markets-list {
display: flex;
flex-direction: column;
}
.market-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.15s ease;
}
.market-row:last-child {
border-bottom: none;
}
.market-row:hover {
background-color: #f9fafb;
}
.market-row.selected {
background-color: #eff6ff;
}
.market-row-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.market-question {
font-size: 15px;
font-weight: 500;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.market-chance {
font-size: 15px;
font-weight: 700;
color: #111827;
flex-shrink: 0;
}
.market-row-vol {
font-size: 14px;
color: #6b7280;
flex-shrink: 0;
}
.market-row-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.buy-yes-btn {
background-color: #b8e0b8 !important;
color: #006600 !important;
text-transform: none;
font-weight: 500;
}
.buy-no-btn {
background-color: #f0b8b8 !important;
color: #cc0000 !important;
text-transform: none;
font-weight: 500;
}
</style>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="home-page"> <div class="home-page">
<v-container class="home-container"> <v-container fluid class="home-container">
<v-row justify="center" align="center" class="home-tabs"> <v-row justify="center" align="center" class="home-tabs">
<v-tabs v-model="activeTab" class="home-tab-bar"> <v-tabs v-model="activeTab" class="home-tab-bar">
<v-tab value="overview">Market Overview</v-tab> <v-tab value="overview">Market Overview</v-tab>
@ -12,21 +12,38 @@
<div ref="scrollRef" class="home-list-scroll"> <div ref="scrollRef" class="home-list-scroll">
<v-pull-to-refresh class="pull-to-refresh" @load="onRefresh"> <v-pull-to-refresh class="pull-to-refresh" @load="onRefresh">
<div class="pull-to-refresh-inner"> <div class="pull-to-refresh-inner">
<div class="home-list"> <div ref="listRef" class="home-list" :style="gridListStyle">
<MarketCard <MarketCard
v-for="id in listLength" v-for="card in eventList"
:key="id" :key="card.id"
:id="String(id)" :id="card.id"
:slug="card.slug"
:market-title="card.marketTitle"
:chance-value="card.chanceValue"
:market-info="card.marketInfo"
:image-url="card.imageUrl"
:category="card.category"
:expires-at="card.expiresAt"
:display-type="card.displayType"
:outcomes="card.outcomes"
:yes-label="card.yesLabel"
:no-label="card.noLabel"
:is-new="card.isNew"
:market-id="card.marketId"
:clob-token-ids="card.clobTokenIds"
@open-trade="onCardOpenTrade" @open-trade="onCardOpenTrade"
/> />
<div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty">
暂无数据
</div>
</div> </div>
<div class="load-more-footer"> <div v-if="eventList.length > 0" class="load-more-footer">
<div ref="sentinelRef" class="load-more-sentinel" aria-hidden="true" /> <div ref="sentinelRef" class="load-more-sentinel" aria-hidden="true" />
<div v-if="loadingMore" class="load-more-indicator"> <div v-if="loadingMore" class="load-more-indicator">
<v-progress-circular indeterminate size="24" width="2" /> <v-progress-circular indeterminate size="24" width="2" />
<span>加载中...</span> <span>加载中...</span>
</div> </div>
<div v-else-if="listLength >= maxItems" class="no-more-tip">没有更多了</div> <div v-else-if="noMoreEvents" class="no-more-tip">没有更多了</div>
<v-btn <v-btn
v-else v-else
class="load-more-btn" class="load-more-btn"
@ -54,6 +71,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>
@ -64,6 +82,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
/> />
@ -146,79 +165,175 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'Home' })
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue' import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import MarketCard from '../components/MarketCard.vue' import MarketCard from '../components/MarketCard.vue'
import TradeComponent from '../components/TradeComponent.vue' import TradeComponent from '../components/TradeComponent.vue'
import {
getPmEventPublic,
mapEventItemToCard,
getEventListCache,
setEventListCache,
clearEventListCache,
type EventCardItem,
} from '../api/event'
const { mobile } = useDisplay() const { mobile } = useDisplay()
const isMobile = computed(() => mobile.value) const isMobile = computed(() => mobile.value)
const activeTab = ref('overview') const activeTab = ref('overview')
const INITIAL_COUNT = 10
const PAGE_SIZE = 10 const PAGE_SIZE = 10
const maxItems = 50
const listLength = ref(INITIAL_COUNT) /** 接口返回的列表(已映射为卡片所需结构) */
const eventList = ref<EventCardItem[]>([])
/** 当前页码(从 1 开始) */
const eventPage = ref(1)
/** 接口返回的 total */
const eventTotal = ref(0)
const eventPageSize = ref(PAGE_SIZE)
const loadingMore = ref(false) const loadingMore = ref(false)
const noMoreEvents = computed(() => {
if (eventList.value.length === 0) return false
return eventList.value.length >= eventTotal.value || eventPage.value * eventPageSize.value >= eventTotal.value
})
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; clobTokenIds?: 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、下单等使用 */
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, clobTokenIds: m.clobTokenIds }
})
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
const SCROLL_LOAD_THRESHOLD = 280 const SCROLL_LOAD_THRESHOLD = 280
function onRefresh({ done }: { done: () => void }) { /** 卡片最小宽度(与 MarketCard 一致),用于动态计算列数 */
setTimeout(() => { const CARD_MIN_WIDTH = 310
listLength.value = INITIAL_COUNT const GRID_GAP = 20
done()
}, 600) const listRef = ref<HTMLElement | null>(null)
const gridColumns = ref(1)
const gridListStyle = computed(() => ({
gridTemplateColumns: `repeat(${gridColumns.value}, 1fr)`,
}))
function updateGridColumns() {
const el = listRef.value
if (!el) return
const width = el.getBoundingClientRect().width
const n = Math.floor((width + GRID_GAP) / (CARD_MIN_WIDTH + GRID_GAP))
gridColumns.value = Math.max(1, n)
} }
function loadMore() { /** 请求事件列表并追加或覆盖到 eventList公开接口无需鉴权成功后会更新内存缓存 */
if (loadingMore.value || listLength.value >= maxItems) return async function loadEvents(page: number, append: boolean) {
loadingMore.value = true try {
setTimeout(() => { const res = await getPmEventPublic(
listLength.value = Math.min(listLength.value + PAGE_SIZE, maxItems) { page, pageSize: PAGE_SIZE }
loadingMore.value = false )
}, 400) if (res.code !== 0 && res.code !== 200) {
} throw new Error(res.msg || '请求失败')
}
function checkScrollLoad() { const data = res.data
const el = scrollRef.value if (!data?.list || !Array.isArray(data.list)) {
if (!el || loadingMore.value || listLength.value >= maxItems) return if (!append) eventList.value = []
const { scrollTop, clientHeight, scrollHeight } = el return
if (scrollHeight - scrollTop - clientHeight < SCROLL_LOAD_THRESHOLD) { }
loadMore() const mapped = data.list.map((item) => mapEventItemToCard(item))
eventTotal.value = data.total ?? 0
eventPageSize.value = data.pageSize && data.pageSize > 0 ? data.pageSize : PAGE_SIZE
eventPage.value = data.page ?? page
if (append) {
eventList.value = [...eventList.value, ...mapped]
} else {
eventList.value = mapped
}
setEventListCache({
list: eventList.value,
page: eventPage.value,
total: eventTotal.value,
pageSize: eventPageSize.value,
})
} catch (e) {
if (!append) eventList.value = []
console.warn('getPmEventList failed', e)
} }
} }
function onRefresh({ done }: { done: () => void }) {
clearEventListCache()
eventPage.value = 1
loadEvents(1, false).finally(() => done())
}
function loadMore() {
if (loadingMore.value || noMoreEvents.value || eventList.value.length === 0) return
loadingMore.value = true
const nextPage = eventPage.value + 1
loadEvents(nextPage, true).finally(() => {
loadingMore.value = false
})
}
function checkScrollLoad() {
if (loadingMore.value || eventList.value.length === 0 || noMoreEvents.value) return
const { scrollY, innerHeight } = window
const scrollHeight = document.documentElement.scrollHeight
if (scrollHeight - scrollY - innerHeight < SCROLL_LOAD_THRESHOLD) loadMore()
}
onMounted(() => { onMounted(() => {
const cached = getEventListCache()
if (cached && cached.list.length > 0) {
eventList.value = cached.list
eventPage.value = cached.page
eventTotal.value = cached.total
eventPageSize.value = cached.pageSize
} else {
loadEvents(1, false)
}
nextTick(() => { nextTick(() => {
const scrollEl = scrollRef.value
const sentinel = sentinelRef.value const sentinel = sentinelRef.value
if (!sentinel || !scrollEl) return if (sentinel) {
observer = new IntersectionObserver(
observer = new IntersectionObserver( (entries) => {
(entries) => { if (!entries[0]?.isIntersecting) return
if (!entries[0]?.isIntersecting) return loadMore()
loadMore() },
}, { root: null, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 }
{ root: scrollEl, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 } )
) observer.observe(sentinel)
observer.observe(sentinel) }
window.addEventListener('scroll', checkScrollLoad, { passive: true })
scrollEl.addEventListener('scroll', checkScrollLoad, { passive: true }) const listEl = listRef.value
if (listEl) {
updateGridColumns()
resizeObserver = new ResizeObserver(updateGridColumns)
resizeObserver.observe(listEl)
}
}) })
}) })
@ -226,13 +341,21 @@ onUnmounted(() => {
const sentinel = sentinelRef.value const sentinel = sentinelRef.value
if (observer && sentinel) observer.unobserve(sentinel) if (observer && sentinel) observer.unobserve(sentinel)
observer = null observer = null
scrollRef.value?.removeEventListener('scroll', checkScrollLoad) if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
window.removeEventListener('scroll', checkScrollLoad)
}) })
</script> </script>
<style scoped> <style scoped>
/* fluid 后无断点 max-width用自定义 max-width 让列表在 2137px 等宽屏下能算到 6 列 */
.home-container { .home-container {
min-height: 100vh; min-height: 100vh;
max-width: 2560px;
margin-left: auto;
margin-right: auto;
} }
.home-header { .home-header {
@ -256,23 +379,25 @@ onUnmounted(() => {
min-height: 100%; min-height: 100%;
} }
/* 不设固定高度与 overflow列表随页面窗口滚动便于 Vue Router scrollBehavior 自动恢复位置 */
.home-list-scroll { .home-list-scroll {
width: 100%; width: 100%;
overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch;
/* 占满剩余视口高度,内容多时在内部滚动 */
min-height: calc(100vh - 64px - 48px - 32px);
max-height: calc(100vh - 64px - 48px - 32px);
} }
/* 卡片最小宽度与 MarketCard 一致 310pxauto-fill 按可用宽度自动 14 列,避免第三列被裁 */ /* 列数由 JS 根据容器宽度与 CARD_MIN_WIDTH 连续计算,避免断点导致 6→4 跳变 */
.home-list { .home-list {
width: 100%; width: 100%;
display: grid; display: grid;
gap: 20px; gap: 20px;
padding-bottom: 8px; }
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
.home-list-empty {
grid-column: 1 / -1;
text-align: center;
color: #6b7280;
font-size: 14px;
padding: 48px 16px;
} }
.home-list > * { .home-list > * {
@ -327,12 +452,6 @@ onUnmounted(() => {
padding: 0; padding: 0;
} }
/* 大屏最多 4 列,避免过宽时出现 5 列以上 */
@media (min-width: 1320px) {
.home-list {
grid-template-columns: repeat(4, minmax(310px, 1fr));
}
}
.home-subtitle { .home-subtitle {
margin-bottom: 40px; margin-bottom: 40px;

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

@ -7,7 +7,10 @@
<v-card class="chart-card polymarket-chart" elevation="0" rounded="lg"> <v-card class="chart-card polymarket-chart" elevation="0" rounded="lg">
<!-- 顶部标题当前概率Past / 日期 --> <!-- 顶部标题当前概率Past / 日期 -->
<div class="chart-header"> <div class="chart-header">
<h1 class="chart-title">{{ marketTitle }}</h1> <h1 class="chart-title">
{{ detailLoading && !eventDetail ? '加载中...' : marketTitle }}
</h1>
<p v-if="detailError" class="chart-error">{{ detailError }}</p>
<div class="chart-controls-row"> <div class="chart-controls-row">
<v-btn variant="text" size="small" class="past-btn">Past </v-btn> <v-btn variant="text" size="small" class="past-btn">Past </v-btn>
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn> <v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
@ -107,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>
@ -124,6 +130,8 @@ 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, getMarketId, type FindPmEventParams, type PmEventListItem } from '../api/event'
import { useUserStore } from '../stores/user'
/** /**
* 分时图服务端推送数据格式约定 * 分时图服务端推送数据格式约定
@ -153,18 +161,134 @@ export type ChartSnapshot = { range?: string; data: ChartPoint[] }
export type ChartIncrement = { point: ChartPoint } export type ChartIncrement = { point: ChartPoint }
const route = useRoute() const route = useRoute()
const userStore = useUserStore()
// // GET /PmEvent/findPmEvent
const marketTitle = computed( const eventDetail = ref<PmEventListItem | null>(null)
() => (route.query.title as string) || 'U.S. anti-cartel ground operation in Mexico by March 31?' const detailLoading = ref(false)
) const detailError = ref<string | null>(null)
const marketVolume = computed(() => (route.query.marketInfo as string) || '$398,719')
const marketExpiresAt = computed(() => (route.query.expiresAt as string) || 'Mar 31, 2026') function formatVolume(volume: number | undefined): string {
if (volume == null || !Number.isFinite(volume)) return '$0 Vol.'
if (volume >= 1000) return `$${(volume / 1000).toFixed(1)}k Vol.`
return `$${Math.round(volume)} Vol.`
}
function formatExpiresAt(endDate: string | undefined): string {
if (!endDate) return ''
try {
const d = new Date(endDate)
if (Number.isNaN(d.getTime())) return endDate
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return endDate
}
}
async function loadEventDetail() {
const idRaw = route.params.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(params, {
headers: userStore.getAuthHeaders()
})
if (res.code === 0 || res.code === 200) {
eventDetail.value = res.data ?? null
} else {
detailError.value = res.msg || '加载失败'
eventDetail.value = null
}
} catch (e) {
detailError.value = e instanceof Error ? e.message : '加载失败'
eventDetail.value = null
} finally {
detailLoading.value = false
}
}
// query
const marketTitle = computed(() => {
if (eventDetail.value?.title) return eventDetail.value.title
return (route.query.title as string) || 'U.S. anti-cartel ground operation in Mexico by March 31?'
})
const marketVolume = computed(() => {
if (eventDetail.value?.volume != null) return formatVolume(eventDetail.value.volume)
return (route.query.marketInfo as string) || '$398,719'
})
const marketExpiresAt = computed(() => {
if (eventDetail.value?.endDate) return formatExpiresAt(eventDetail.value.endDate)
return (route.query.expiresAt as string) || 'Mar 31, 2026'
})
const resolutionDate = computed(() => { const resolutionDate = computed(() => {
const s = marketExpiresAt.value const s = marketExpiresAt.value
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,
clobTokenIds: m.clobTokenIds,
}
}
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')
@ -272,9 +396,21 @@ let chartInstance: ECharts | null = null
let dynamicInterval: number | undefined let dynamicInterval: number | undefined
const currentChance = computed(() => { const currentChance = computed(() => {
const ev = eventDetail.value
const market = ev?.markets?.[0]
if (market?.outcomePrices?.[0] != null) {
const p = parseFloat(String(market.outcomePrices[0]))
if (Number.isFinite(p)) return Math.min(100, Math.max(0, Math.round(p * 100)))
}
const d = data.value const d = data.value
const last = d.length > 0 ? d[d.length - 1] : undefined const last = d.length > 0 ? d[d.length - 1] : undefined
return last != null ? last[1] : 20 if (last != null) return last[1]
const q = route.query.chance
if (q != null) {
const n = Number(q)
if (Number.isFinite(n)) return Math.min(100, Math.max(0, Math.round(n)))
}
return 20
}) })
const lineColor = '#2563eb' const lineColor = '#2563eb'
@ -441,7 +577,14 @@ const handleResize = () => {
}) })
} }
watch(
() => route.params.id,
() => loadEventDetail(),
{ immediate: false }
)
onMounted(() => { onMounted(() => {
loadEventDetail()
initChart() initChart()
startDynamicUpdate() startDynamicUpdate()
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
@ -522,7 +665,7 @@ onUnmounted(() => {
.option-yes { .option-yes {
flex: 1; flex: 1;
background-color: #e6f9e6; background-color: #b8e0b8;
height: 56px; height: 56px;
font-size: 18px; font-size: 18px;
} }
@ -530,12 +673,12 @@ onUnmounted(() => {
.option-text-yes { .option-text-yes {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #008000; color: #006600;
} }
.option-no { .option-no {
flex: 1; flex: 1;
background-color: #ffe6e6; background-color: #f0b8b8;
height: 56px; height: 56px;
font-size: 18px; font-size: 18px;
} }
@ -543,7 +686,7 @@ onUnmounted(() => {
.option-text-no { .option-text-no {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #ff0000; color: #cc0000;
} }
.market-info-section { .market-info-section {
@ -623,6 +766,12 @@ onUnmounted(() => {
line-height: 1.3; line-height: 1.3;
} }
.chart-error {
font-size: 0.875rem;
color: #dc2626;
margin: 0 0 8px 0;
}
.chart-controls-row { .chart-controls-row {
display: flex; display: flex;
align-items: center; align-items: center;

File diff suppressed because it is too large Load Diff

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,允许局域网内其他设备访问
},
}) })