Compare commits
10 Commits
acc7d485e1
...
8d103e2d98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d103e2d98 | ||
|
|
b73a910b43 | ||
|
|
297d2d1c56 | ||
|
|
e14bd3bc23 | ||
|
|
c6e896efc3 | ||
|
|
a572fdfa99 | ||
|
|
f0c1be71cb | ||
|
|
def6b95b5b | ||
|
|
e603e04d0f | ||
|
|
08ae68b767 |
47
.cursor/skills/deploy/SKILL.md
Normal file
47
.cursor/skills/deploy/SKILL.md
Normal 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`
|
||||||
85
.cursor/skills/project-framework/SKILL.md
Normal file
85
.cursor/skills/project-framework/SKILL.md
Normal 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 3(Composition API)+ TypeScript
|
||||||
|
- **构建**:Vite 7,路径别名 `@/*` → `./src/*`
|
||||||
|
- **UI**:Vuetify 4(`v-app`、`v-card`、`v-btn` 等)
|
||||||
|
- **状态**:Pinia
|
||||||
|
- **路由**:Vue Router 5(createWebHistory)
|
||||||
|
- **代码质量**:ESLint + Prettier + oxlint;单测 Vitest,E2E 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 使用。
|
||||||
136
.cursor/skills/xtrader-api-docs/SKILL.md
Normal file
136
.cursor/skills/xtrader-api-docs/SKILL.md
Normal 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**
|
||||||
|
例如 findPmEvent:path = `/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、createdAtRange(array)、tokenid(可选,来自 market.clobTokenIds 的值,可传单个或数组)。
|
||||||
|
- **响应 200**:`data` 为 `response.PageResult`(list、page、pageSize、total);list 项为 `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
8
.env
Normal 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
11
.env.example
Normal 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
2
.env.production
Normal 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
148
AGENTS.md
Normal 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>
|
||||||
@ -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
75
scripts/deploy.mjs
Normal 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_HOST、DEPLOY_USER、DEPLOY_PATH
|
||||||
|
* 依赖:rsync(系统自带)、ssh 免密或密钥
|
||||||
|
*/
|
||||||
|
import { execSync, spawnSync } from 'child_process'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const projectRoot = join(__dirname, '..')
|
||||||
|
const distDir = join(projectRoot, 'dist')
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
host: process.env.DEPLOY_HOST || '38.246.250.238',
|
||||||
|
user: process.env.DEPLOY_USER || 'root',
|
||||||
|
path: process.env.DEPLOY_PATH || '/opt/1panel/www/sites/pm.xtrader.vip/index',
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = `${config.user}@${config.host}:${config.path}`
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
const apiUrl = process.env.VITE_API_BASE_URL || 'https://api.xtrader.vip'
|
||||||
|
console.log(`📦 正在打包项目(.env.production API: ${apiUrl})...`)
|
||||||
|
execSync('npm run build', {
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
// 继承 process.env(已由 --env-file=.env.production 加载生产配置)
|
||||||
|
env: process.env,
|
||||||
|
})
|
||||||
|
console.log('✅ 打包完成\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function deploy() {
|
||||||
|
const rsyncCheck = spawnSync('which', ['rsync'], { encoding: 'utf8' })
|
||||||
|
if (rsyncCheck.status !== 0) {
|
||||||
|
console.error('❌ 未找到 rsync')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔌 正在通过 SSH 部署到 ${target} ...`)
|
||||||
|
const result = spawnSync(
|
||||||
|
'rsync',
|
||||||
|
[
|
||||||
|
'-avz',
|
||||||
|
'--delete',
|
||||||
|
'--exclude=.DS_Store',
|
||||||
|
`${distDir}/`,
|
||||||
|
`${target}/`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
encoding: 'utf8',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
console.error('❌ 部署失败')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 部署成功!')
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
build()
|
||||||
|
deploy()
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
14
src/App.vue
14
src/App.vue
@ -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
30
src/api/constants.ts
Normal 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
344
src/api/event.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从市场项取 clobTokenId,outcomeIndex 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-token、x-user-id
|
||||||
|
*
|
||||||
|
* 响应(200):PmEventDetailResponse { 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 返回结构一致,用于缓存)
|
||||||
|
* displayType:single = 单一 Yes/No,multi = 多个选项左右滑动
|
||||||
|
*/
|
||||||
|
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 或无 market:displayType single,当前逻辑
|
||||||
|
* - 多个 markets:displayType multi,outcomes 为每项标题+概率,卡片内左右滑动切换
|
||||||
|
*/
|
||||||
|
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
103
src/api/market.ts
Normal 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.clobTokenIds,outcomeIndex 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 份额得到 USDC(1 Yes + 1 No ≈ 1 USDC)
|
||||||
|
*/
|
||||||
|
export interface PmMarketMergeRequest {
|
||||||
|
/** 市场 ID */
|
||||||
|
marketID: string
|
||||||
|
/** 合并份额数量(字符串) */
|
||||||
|
amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /PmMarket/merge
|
||||||
|
* 合并 Yes、No 份额为 USDC,需鉴权
|
||||||
|
*/
|
||||||
|
export async function pmMarketMerge(
|
||||||
|
data: PmMarketMergeRequest,
|
||||||
|
config?: { headers?: Record<string, string> }
|
||||||
|
): Promise<ApiResponse> {
|
||||||
|
return post<ApiResponse>('/PmMarket/merge', data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用测试服务器 /PmMarket/split 接口
|
||||||
|
* - 需鉴权:请求头 x-token、x-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
175
src/api/mockEventList.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import type { PmEventListItem } from './event'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟事件列表,用于本地测试:
|
||||||
|
* - 动态按钮文案(Up/Down、Yes/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. 单一 market,Yes/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. 多 market:Elon 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. 多 market:NBA 分区冠军(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
65
src/api/request.ts
Normal 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
34
src/api/user.ts
Normal 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)
|
||||||
|
* amount、available 需除以 1000000 得到实际 USDC
|
||||||
|
*/
|
||||||
|
export async function getUsdcBalance(authHeaders: Record<string, string>): Promise<GetUsdcBalanceResponse> {
|
||||||
|
const res = await get<GetUsdcBalanceResponse>('/user/getUsdcBalance', undefined, {
|
||||||
|
headers: authHeaders,
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
@ -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/No,multi 多选项左右滑动 */
|
||||||
},
|
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 }
|
||||||
// 红色在HSL中是0度,绿色是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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 顺序一致,用于下单 tokenId:0=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 为 0–1 */
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限价调整方法(0–1 区间)
|
||||||
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>
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
/** 用户 ID(API 可能返回 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
846
src/views/EventMarkets.vue
Normal 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
|
||||||
|
}) {
|
||||||
|
// 可在此调用下单 API,payload 含 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>
|
||||||
@ -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 的 market(Home 弹窗/底部栏),供 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 一致 310px;auto-fill 按可用宽度自动 1~4 列,避免第三列被裁 */
|
/* 列数由 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;
|
||||||
|
|||||||
@ -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('/')
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
1233
src/views/Wallet.vue
1233
src/views/Wallet.vue
File diff suppressed because it is too large
Load Diff
@ -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,允许局域网内其他设备访问
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user