优化:搜索页面优化
This commit is contained in:
parent
f1dfbf7d80
commit
51a5dcc89d
@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
- `isCryptoEvent`:判断事件是否为加密货币类型(通过 tags/series slug、ticker)
|
- `isCryptoEvent`:判断事件是否为加密货币类型(通过 tags/series slug、ticker)
|
||||||
- `inferCryptoSymbol`:从事件信息推断币种符号(btc、eth 等)
|
- `inferCryptoSymbol`:从事件信息推断币种符号(btc、eth 等)
|
||||||
- `fetchCryptoChart`:从 Binance 获取 1 分钟 K 线历史(优先),不支持时回退 CoinGecko
|
- `fetchCryptoChart`:从 Binance 获取 1 分钟 K 线历史(优先);当范围超出 Binance 单次限制时,自动切换到 CoinGecko
|
||||||
- `subscribeCryptoRealtime`:订阅 Binance **归集交易**(aggTrade)WebSocket,实时推送每笔成交,比 K 线(约 1s)更细;内部 80ms 节流 + RAF 批处理,避免频繁 setOption 卡顿;支持 PING/PONG 保活
|
- `subscribeCryptoRealtime`:订阅 Binance **归集交易**(aggTrade)WebSocket,实时推送每笔成交,比 K 线(约 1s)更细;内部 80ms 节流 + RAF 批处理,避免频繁 setOption 卡顿;支持 PING/PONG 保活
|
||||||
|
|
||||||
## 币种映射
|
## 币种映射
|
||||||
|
|||||||
@ -8,7 +8,7 @@ Event(预测市场事件)相关接口与类型定义,对接 XTrader API
|
|||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- `getPmEventPublic`:分页获取公开事件列表(无需鉴权);请求时固定传入 **startDateMax**、**endDateMin** 为当前时间戳(Unix 秒),**startDateMin**、**endDateMax** 不传
|
- `getPmEventPublic`:分页获取公开事件列表(无需鉴权);请求时固定传入 **startDateMax**、**endDateMin** 为当前时间戳(Unix 秒),**startDateMin**、**endDateMax** 不传;当后端支持入参 `q` 时,`q` 用作搜索关键词
|
||||||
- `findPmEvent`:按 id/slug 查询事件详情(需鉴权)
|
- `findPmEvent`:按 id/slug 查询事件详情(需鉴权)
|
||||||
- `mapEventItemToCard`:将 `PmEventListItem` 转为 `EventCardItem`(供 MarketCard 使用)
|
- `mapEventItemToCard`:将 `PmEventListItem` 转为 `EventCardItem`(供 MarketCard 使用)
|
||||||
- 内存缓存:`getEventListCache`、`setEventListCache`、`clearEventListCache`,用于列表切换页面时复用
|
- 内存缓存:`getEventListCache`、`setEventListCache`、`clearEventListCache`,用于列表切换页面时复用
|
||||||
@ -34,7 +34,7 @@ import {
|
|||||||
} from '@/api/event'
|
} from '@/api/event'
|
||||||
|
|
||||||
// 获取列表
|
// 获取列表
|
||||||
const res = await getPmEventPublic({ page: 1, pageSize: 10, tagIds: [1, 2] })
|
const res = await getPmEventPublic({ page: 1, pageSize: 10, q: 'btc' })
|
||||||
const cards = res.data.list.map(mapEventItemToCard)
|
const cards = res.data.list.map(mapEventItemToCard)
|
||||||
|
|
||||||
// 获取详情(需鉴权)
|
// 获取详情(需鉴权)
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
- `toLwcData`:将 `[timestamp_ms, value][]` 转为 `{ time: UTCTimestamp, value: number }[]`,输入为毫秒(>=1e12)时自动除以 1000 转为秒,按时间升序排序并去重(同时间戳保留最新价格),时间轴显示用户当地时间
|
- `toLwcData`:将 `[timestamp_ms, value][]` 转为 `{ time: UTCTimestamp, value: number }[]`,输入为毫秒(>=1e12)时自动除以 1000 转为秒,按时间升序排序并去重(同时间戳保留最新价格),时间轴显示用户当地时间
|
||||||
- `toLwcPoint`:将单点 `[timestamp_ms, value]` 转为 `{ time, value }`,用于 `series.update()` 增量更新
|
- `toLwcPoint`:将单点 `[timestamp_ms, value]` 转为 `{ time, value }`,用于 `series.update()` 增量更新
|
||||||
- `createLineChart`:创建基础折线图实例(`attributionLogo: false` 隐藏 TradingView 图标)
|
- `createLineChart`:创建基础折线图实例(`attributionLogo: false` 隐藏 TradingView 图标),并禁用拖动/滚轮缩放交互(`handleScroll/handleScale=false`)
|
||||||
- `addLineSeries`:添加折线系列(支持 percent/price 格式)
|
- `addLineSeries`:添加折线系列(支持 percent/price 格式)
|
||||||
- `addAreaSeries`:添加面积系列(带渐变)
|
- `addAreaSeries`:添加面积系列(带渐变)
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
- 顶部导航栏:返回、TestMarket 标题、Login 或余额+头像入口
|
- 顶部导航栏:返回、TestMarket 标题、Login 或余额+头像入口
|
||||||
- 头像入口:登录态点击头像直接跳转 `/profile`
|
- 头像入口:登录态点击头像直接跳转 `/profile`
|
||||||
- 移动端底部导航:Home / Search / Mine(三个 tab 等分屏宽,与路由联动;Mine 点击跳转 `/profile`;选中态仅加粗、无底色;未选中项图标与文字偏灰;底部导航上方有淡投影);仅在 `/`、`/search`、`/profile` 三个主页面显示,其他页面隐藏
|
- 移动端底部导航:Home / Search / Mine(三个 tab 等分屏宽,与路由联动;Mine 点击跳转 `/profile`;选中态仅加粗、无底色;未选中项图标与文字偏灰;底部导航上方有淡投影);仅在 `/`、`/search`、`/profile` 三个主页面显示,其他页面隐藏
|
||||||
- 全局滚动稳定:通过 `scrollbar-gutter: stable` 保留滚动条占位,避免页面从搜索态切到结果态时出现顶部/底部导航轻微横向抖动
|
- 内部滚动:`html`/`body` 与 `.v-application` 禁止滚动;`app-main-scroll`(`data-main-scroll`)作为主滚动容器,`overflow-y: auto`,滚动条仅出现在内容区,不覆盖底部导航
|
||||||
- 登录态:`userStore.isLoggedIn` 控制展示
|
- 登录态:`userStore.isLoggedIn` 控制展示
|
||||||
- 用户名:`nickName` 或 `userName` 显示在头像左侧(有值时)
|
- 用户名:`nickName` 或 `userName` 显示在头像左侧(有值时)
|
||||||
- 挂载时与 `isLoggedIn` 变为 true 时:拉取用户信息与余额(`router.isReady()` + `nextTick` 后执行),确保钱包登录、刷新页面后头像和用户名正确显示
|
- 挂载时与 `isLoggedIn` 变为 true 时:拉取用户信息与余额(`router.isReady()` + `nextTick` 后执行),确保钱包登录、刷新页面后头像和用户名正确显示
|
||||||
|
|||||||
@ -22,8 +22,10 @@ Vue Router 配置,定义路由表与滚动行为。
|
|||||||
|
|
||||||
## 滚动行为
|
## 滚动行为
|
||||||
|
|
||||||
- 有 `savedPosition` 且来自已命名路由:恢复位置
|
滚动目标为 `[data-main-scroll]`(App.vue 内主滚动容器),非 window:
|
||||||
- 有 `to.hash`:滚动到锚点
|
|
||||||
|
- 有 `savedPosition` 且来自已命名路由:恢复该容器的 `scrollTop`
|
||||||
|
- 有 `to.hash`:滚动到锚点元素
|
||||||
- 否则:滚动到顶部
|
- 否则:滚动到顶部
|
||||||
|
|
||||||
## 扩展方式
|
## 扩展方式
|
||||||
|
|||||||
55
docs/scrollbar-nav-overlap.md
Normal file
55
docs/scrollbar-nav-overlap.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# 滚动条覆盖底部导航栏 - 原因分析与解决方案
|
||||||
|
|
||||||
|
> **已解决**:将滚动从 viewport 移至 `app-main-scroll` 内部,滚动条不再覆盖底部导航。
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
底部导航栏(v-bottom-navigation)被页面滚动条遮挡,即使设置 `z-index: 1100` 和 `transform: translateZ(0)` 仍无法解决。
|
||||||
|
|
||||||
|
## 根本原因
|
||||||
|
|
||||||
|
### 1. 滚动发生在 viewport(body/html)
|
||||||
|
|
||||||
|
当前布局中,页面滚动发生在 **文档根** 上:
|
||||||
|
|
||||||
|
- `Home.vue` 使用 `window.scrollY`、`document.documentElement.scrollHeight` 监听滚动
|
||||||
|
- `router/index.ts` 的 `scrollBehavior` 返回 `{ top: 0 }`,作用于 `window`
|
||||||
|
- `.home-list-scroll` 未设置 `overflow`,内容随 **窗口** 滚动
|
||||||
|
|
||||||
|
因此,**滚动条属于 viewport**,由浏览器在 html/body 上绘制。
|
||||||
|
|
||||||
|
### 2. 原生 viewport 滚动条不受 z-index 控制
|
||||||
|
|
||||||
|
- 滚动条是浏览器原生 UI,不是普通 DOM 元素
|
||||||
|
- 它由浏览器在单独图层渲染,与文档层叠上下文分离
|
||||||
|
- 不同浏览器行为不同:
|
||||||
|
- **Chrome**:fixed 元素有时能盖住滚动条
|
||||||
|
- **Firefox / Edge**:滚动条通常绘制在 fixed 元素之上
|
||||||
|
- 对这类原生滚动条,**z-index 无法可靠控制其与 fixed 元素的层级**
|
||||||
|
|
||||||
|
### 3. 当前 DOM 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
v-app
|
||||||
|
├── v-app-bar (fixed top)
|
||||||
|
├── v-main (flex: 1,内容区)
|
||||||
|
│ └── main-content → router-view → Home/Search/Profile
|
||||||
|
└── v-bottom-navigation (fixed bottom)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 底部导航是 `v-app` 的子节点,`position: fixed`
|
||||||
|
- 滚动发生在 body,滚动条在 viewport 右侧贯穿全屏
|
||||||
|
- 底部导航与滚动条在视觉上重叠,且无法通过 z-index 调整
|
||||||
|
|
||||||
|
## 解决方案(已实现)
|
||||||
|
|
||||||
|
**核心思路**:把滚动从 viewport 移到 **内部滚动容器** `app-main-scroll`,让滚动条只出现在内容区,不延伸到底部导航区域。
|
||||||
|
|
||||||
|
### 实现要点
|
||||||
|
|
||||||
|
1. **App.vue**:`html, body` 与 `.v-application` 设置 `overflow: hidden`;v-main 内新增 `app-main-scroll`(`data-main-scroll`)作为滚动容器,`overflow-y: auto`
|
||||||
|
2. **router**:`scrollBehavior` 改为滚动 `[data-main-scroll]` 元素
|
||||||
|
3. **Home.vue**:`checkScrollLoad` 使用 scroll 容器的 `scrollTop`/`scrollHeight`/`clientHeight`;`IntersectionObserver` 的 root 改为 scroll 容器;监听 scroll 容器的 `scroll` 事件
|
||||||
|
4. **Search.vue**:`IntersectionObserver` 的 root 改为 scroll 容器
|
||||||
|
|
||||||
|
效果:滚动条仅出现在 `app-main-scroll` 内,底部导航栏固定于 viewport 底部,不再被滚动条覆盖。
|
||||||
@ -8,6 +8,8 @@
|
|||||||
事件下的市场列表页,展示某个 Event 的多个 Market(如 NFL 多支队伍),支持选择并跳转交易详情。
|
事件下的市场列表页,展示某个 Event 的多个 Market(如 NFL 多支队伍),支持选择并跳转交易详情。
|
||||||
|
|
||||||
- **多市场折线图**:按市场数量依次调用 `getPmPriceHistoryPublic`,每个市场使用 `clobTokenIds[0]`(YES token)作为 `market` 参数,展示多条分时曲线
|
- **多市场折线图**:按市场数量依次调用 `getPmPriceHistoryPublic`,每个市场使用 `clobTokenIds[0]`(YES token)作为 `market` 参数,展示多条分时曲线
|
||||||
|
- 为避免“最新数据提前被截断”,不同时间范围会使用更大的 `pageSize` 拉取更多点
|
||||||
|
- 图表交互已禁用拖动和滚轮缩放(`handleScroll/handleScale=false`)
|
||||||
- **时间范围**:1H / 6H / 1D / 1W / 1M / ALL,与 TradeDetail 一致
|
- **时间范围**:1H / 6H / 1D / 1W / 1M / ALL,与 TradeDetail 一致
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
- **分类行分隔**:`.home-category-layer1-row` 底部有淡色投影,增强与下方内容的层次感
|
- **分类行分隔**:`.home-category-layer1-row` 底部有淡色投影,增强与下方内容的层次感
|
||||||
|
|
||||||
|
- **第一层操作按钮**:已移除右侧的搜索/筛选图标;搜索浮层通过 `initialSearchExpanded`(供 `/search` 路由使用)控制展开
|
||||||
|
|
||||||
## 数据流
|
## 数据流
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -14,20 +14,21 @@
|
|||||||
|
|
||||||
- 访问路由 `/search`
|
- 访问路由 `/search`
|
||||||
- 在搜索输入框中输入关键词后,手机键盘回车键会显示为“搜索”(`enterkeyhint="search"`)
|
- 在搜索输入框中输入关键词后,手机键盘回车键会显示为“搜索”(`enterkeyhint="search"`)
|
||||||
- 点击键盘“搜索”会请求 `GET /PmEvent/getPmEventPublic`(`keyword` 参数),并将关键词写入搜索记录顶部
|
- 输入停止一小段时间后会自动发起请求 `GET /PmEvent/getPmEventPublic`(入参 `q`),并将关键词写入本地搜索历史;当展示搜索结果时会隐藏搜索记录
|
||||||
|
- **文案国际化**:页面文案与推荐标签使用 `vue-i18n` 键 `searchPage.*`(`zh-CN` / `zh-TW` / `en` / `ko` / `ja`)
|
||||||
|
- **无结果**:接口成功但列表为空时展示 `searchPage.noResults`,**不使用示例假数据**;请求失败或业务错误码时展示 `searchPage.errorFailed` 或接口返回的 `msg`
|
||||||
- 搜索前展示 `p4Kcp` 结构:
|
- 搜索前展示 `p4Kcp` 结构:
|
||||||
- 顶部标题:`搜索`
|
- 顶部标题、占位、记录/推荐标题等取自 i18n
|
||||||
- 搜索框:真实输入框,占位文案 `搜索市场、话题、地址`
|
- 搜索记录卡:从本地真实读取的搜索历史(最多 10 条),每行右侧带关闭图标
|
||||||
- 搜索记录卡:3 条示例记录,每行右侧带关闭图标
|
- 推荐标签卡:三项标签文案同样走 i18n
|
||||||
- 推荐标签卡:`ETH`、`科技股`、`总统大选`
|
|
||||||
- 搜索后切换到 `mN9t2` 结构:
|
- 搜索后切换到 `mN9t2` 结构:
|
||||||
- 顶部同样保留标题与搜索框
|
- 顶部同样保留标题与搜索框
|
||||||
- 下方展示结果卡片列表(图标、标题、百分比、时间)
|
- 下方展示结果卡片列表(图标、标题、百分比、时间);无结果时展示空状态文案
|
||||||
- 切换结果态时不会因滚动条变化导致顶部/底部导航轻微位移
|
- 切换结果态时不会因滚动条变化导致顶部/底部导航轻微位移
|
||||||
|
- 点击某条结果:`mapEventItemToCard` 后按与 `MarketCard` 相同规则跳转——**多 market** 进 `/event/:id/markets`(可带 `slug` query);**单 market** 进 `/trade-detail/:id`(query 含 `title`、`marketInfo`、`chance`、`marketId`、`slug` 等与首页一致)
|
||||||
|
|
||||||
## 扩展方式
|
## 扩展方式
|
||||||
|
|
||||||
1. **接入真实搜索记录**:将 `searchRecords` 替换为用户历史接口数据。
|
1. **结果跳转**:已与 `MarketCard` 对齐;若后端字段变化可只调 `mapEventItemToCard`
|
||||||
2. **接入搜索行为**:将搜索框改为可输入组件并绑定查询 API。
|
2. **标签联动搜索**:点击标签触发关键词搜索并跳转到结果页。
|
||||||
3. **标签联动搜索**:点击标签触发关键词搜索并跳转到结果页。
|
3. **响应式优化**:可按断点进一步细分字号与间距,但保持 1:1 视觉层级。
|
||||||
4. **响应式优化**:可按断点进一步细分字号与间距,但保持 1:1 视觉层级。
|
|
||||||
|
|||||||
@ -5,11 +5,12 @@
|
|||||||
|
|
||||||
## 功能用途
|
## 功能用途
|
||||||
|
|
||||||
交易详情页,展示单个市场的分时图、订单簿、Comments/Top Holders/Activity 标签页,以及右侧交易组件(Buy/Sell、Merge/Split)。支持桌面端与移动端布局,移动端使用底部 Yes/No 栏 + 弹窗。
|
交易详情页,展示单个市场的分时图、订单簿、Rules 内容以及右侧交易组件(Buy/Sell、Merge/Split)。支持桌面端与移动端布局,移动端使用底部 Yes/No 栏 + 弹窗。
|
||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- 分时图:TradingView Lightweight Charts 渲染,支持 Past、时间粒度切换(1H/6H/1D/1W/1M/ALL);**Yes/No 模式**数据来自 **GET /pmPriceHistory/getPmPriceHistoryPublic**(market 传 clobTokenIds[0]),接口返回 `time`(Unix 秒)、`price`(0–1)转成 `[timestamp_ms, value_0_100][]` 后缓存在 `rawChartData`,**分时**为前端按当前选中范围过滤:1H=最近 1 小时、6H=6 小时、1D=1 天、1W=7 天、1M=30 天、ALL=全部,切换时间范围不重复请求;**加密货币事件**可切换 YES/NO 分时图与加密货币价格走势图(CoinGecko 实时数据),**加密货币模式默认显示 30S 分时走势图**
|
- 分时图:TradingView Lightweight Charts 渲染,支持 Past、时间粒度切换(1H/6H/1D/1W/1M/ALL);**Yes/No 模式**数据来自 **GET /pmPriceHistory/getPmPriceHistoryPublic**(market 传 clobTokenIds[0]),接口返回 `time`(Unix 秒)、`price`(0–1)转成 `[timestamp_ms, value_0_100][]` 后缓存在 `rawChartData`,**分时**为前端按当前选中范围过滤:1H=最近 1 小时、6H=6 小时、1D=1 天、1W=7 天、1M=30 天、ALL=全部,切换时间范围不重复请求;为避免“最新数据提前被截断”,不同时间范围会使用更大的 `pageSize` 拉取更多点;**加密货币事件**可切换 YES/NO 分时图与加密货币价格走势图(CoinGecko 实时数据),**加密货币模式默认显示 30S 分时走势图**;图表交互已禁用拖动和滚轮缩放(`handleScroll/handleScale=false`)
|
||||||
|
- Rules 内容容器(`.rules-pane`):内边距从 `4px` 提升到 `16px`,改善文本留白与可读性
|
||||||
- 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送);份额接口按 6 位小数传(1_000_000 = 1 share),`priceSizeToRows` 与 `mergeDelta` 会将 raw 值除以 `ORDER_BOOK_SIZE_SCALE` 转为展示值
|
- 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送);份额接口按 6 位小数传(1_000_000 = 1 share),`priceSizeToRows` 与 `mergeDelta` 会将 raw 值除以 `ORDER_BOOK_SIZE_SCALE` 转为展示值
|
||||||
- 订单簿外层容器(`order-book-card`)已去除重复外边框,仅保留 `OrderBook` 组件单层描边,避免视觉上出现双层边线
|
- 订单簿外层容器(`order-book-card`)已去除重复外边框,仅保留 `OrderBook` 组件单层描边,避免视觉上出现双层边线
|
||||||
- 交易:`TradeComponent`,传入 `market`、`initialOption`、`positions`(持仓数据)
|
- 交易:`TradeComponent`,传入 `market`、`initialOption`、`positions`(持仓数据)
|
||||||
@ -39,5 +40,5 @@
|
|||||||
1. **订单簿**:已通过 `sdk/clobSocket.ts` 的 ClobSdk 对接 CLOB WebSocket,使用 **Yes/No token ID** 订阅 `price_size_all`、`price_size_delta`、`trade` 消息
|
1. **订单簿**:已通过 `sdk/clobSocket.ts` 的 ClobSdk 对接 CLOB WebSocket,使用 **Yes/No token ID** 订阅 `price_size_all`、`price_size_delta`、`trade` 消息
|
||||||
2. **分时图**:Yes/NO 折线图仅使用真实接口 `getPmPriceHistoryPublic`,无模拟数据与定时器;事件详情加载完成后自动请求并展示,无 marketId 或接口无数据时展示空图;加密货币事件已支持 YES/NO 分时与加密货币价格图切换(`src/api/cryptoChart.ts`);加密货币实时推送时使用 `series.update()` 增量更新单点,配合 `LastPriceAnimationMode.OnDataUpdate` 实现新数据加入的过渡动画;30S 范围在未过滤掉旧点时同样使用 `update()` 实现平滑动画
|
2. **分时图**:Yes/NO 折线图仅使用真实接口 `getPmPriceHistoryPublic`,无模拟数据与定时器;事件详情加载完成后自动请求并展示,无 marketId 或接口无数据时展示空图;加密货币事件已支持 YES/NO 分时与加密货币价格图切换(`src/api/cryptoChart.ts`);加密货币实时推送时使用 `series.update()` 增量更新单点,配合 `LastPriceAnimationMode.OnDataUpdate` 实现新数据加入的过渡动画;30S 范围在未过滤掉旧点时同样使用 `update()` 实现平滑动画
|
||||||
3. **Comments**:对接评论接口,替换 placeholder
|
3. **Comments**:对接评论接口,替换 placeholder
|
||||||
4. **Top Holders**:对接持仓接口
|
4. **Top Holders**:当前页面已移除显示(仅保留 Rules)
|
||||||
5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录
|
5. **Activity**:当前页面已移除显示(仅保留 Rules)
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- Portfolio 卡片:余额、Deposit/Withdraw 按钮
|
- Portfolio 卡片:余额、Deposit/Withdraw 按钮
|
||||||
- Profit/Loss 卡片:时间范围切换(1D/1W/1M/ALL)、Lightweight Charts 资产变化面积图;数据格式为 `[timestamp_ms, pnl][]`,**暂无接口时全部显示为 0**(真实时间轴 + 数值 0),有接口后在此处对接
|
- Profit/Loss 卡片:时间范围切换(1D/1W/1M/ALL)、Lightweight Charts 资产变化面积图;数据格式为 `[timestamp_ms, pnl][]`,**暂无接口时全部显示为 0**(真实时间轴 + 数值 0),有接口后在此处对接;图表交互已禁用拖动和滚轮缩放(`handleScroll/handleScale=false`)
|
||||||
- Tab:Positions、Open orders、**History**(历史记录来自 **GET /hr/getHistoryRecordListClient**,`src/api/historyRecord.ts`,需鉴权、按当前用户分页)、Withdrawals(提现记录)
|
- Tab:Positions、Open orders、**History**(历史记录来自 **GET /hr/getHistoryRecordListClient**,`src/api/historyRecord.ts`,需鉴权、按当前用户分页)、Withdrawals(提现记录)
|
||||||
- Positions:卡片展示市场图标、标题、YES/NO outcome、价值、盈亏、`t('wallet.sharesLabel')`、`t('wallet.avgPriceLabel')`、`t('wallet.currentPriceLabel')`
|
- Positions:卡片展示市场图标、标题、YES/NO outcome、价值、盈亏、`t('wallet.sharesLabel')`、`t('wallet.avgPriceLabel')`、`t('wallet.currentPriceLabel')`
|
||||||
- Open orders:卡片展示图标、标题、BUY/SELL 标签、YES/NO 标签、`t('wallet.openOrderPriceLabel')`、`t('wallet.filledTotalLabel')`、`t('wallet.orderValueLabel')`、取消按钮
|
- Open orders:卡片展示图标、标题、BUY/SELL 标签、YES/NO 标签、`t('wallet.openOrderPriceLabel')`、`t('wallet.filledTotalLabel')`、`t('wallet.orderValueLabel')`、取消按钮
|
||||||
|
|||||||
31
src/App.vue
31
src/App.vue
@ -101,7 +101,8 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<v-main>
|
<v-main class="app-main">
|
||||||
|
<div class="app-main-scroll" data-main-scroll>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<keep-alive :include="['Home']">
|
<keep-alive :include="['Home']">
|
||||||
@ -109,6 +110,7 @@ watch(
|
|||||||
</keep-alive>
|
</keep-alive>
|
||||||
</router-view>
|
</router-view>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
<v-bottom-navigation
|
<v-bottom-navigation
|
||||||
@ -147,6 +149,19 @@ watch(
|
|||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.app-main-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
.main-content {
|
.main-content {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@ -172,9 +187,12 @@ watch(
|
|||||||
color: rgba(0, 0, 0, 0.87);
|
color: rgba(0, 0, 0, 0.87);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 底部导航:整条栏上方淡投影 */
|
/* 底部导航:整条栏上方淡投影;z-index 确保覆盖滚动条,显示在滚动条上层 */
|
||||||
:deep(.v-bottom-navigation) {
|
:deep(.v-bottom-navigation) {
|
||||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 1100;
|
||||||
|
background: #fff !important;
|
||||||
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
/* 底部导航:三个入口等分屏幕宽度 */
|
/* 底部导航:三个入口等分屏幕宽度 */
|
||||||
:deep(.v-bottom-navigation__content) {
|
:deep(.v-bottom-navigation__content) {
|
||||||
@ -208,13 +226,18 @@ watch(
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 全局背景:统一为 rgb(252,252,252) */
|
/* 全局:禁止 body 滚动,由 app-main-scroll 内部滚动,滚动条不覆盖底部导航 */
|
||||||
:global(html),
|
:global(html),
|
||||||
:global(body) {
|
:global(body) {
|
||||||
background: rgb(252, 252, 252);
|
background: rgb(252, 252, 252);
|
||||||
scrollbar-gutter: stable;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
:global(.v-application) {
|
:global(.v-application) {
|
||||||
background: rgb(252, 252, 252);
|
background: rgb(252, 252, 252);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -235,10 +235,14 @@ export async function fetchCryptoChart(
|
|||||||
const range = params.range
|
const range = params.range
|
||||||
const is30S = range === '30S'
|
const is30S = range === '30S'
|
||||||
const fetchRange = is30S ? '1H' : range
|
const fetchRange = is30S ? '1H' : range
|
||||||
const limit = Math.min(RANGE_TO_LIMIT[fetchRange] ?? 60, 1000)
|
const limitBase = RANGE_TO_LIMIT[fetchRange] ?? 60
|
||||||
|
// Binance 单次 1000 K 线限制:当范围过大时切到 CoinGecko,避免“最新数据提前被截断”
|
||||||
|
const needsMoreThanBinance = limitBase > 1000
|
||||||
|
const useBinance = !!binanceSymbol && !needsMoreThanBinance
|
||||||
|
const limit = Math.min(limitBase, 1000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (binanceSymbol) {
|
if (useBinance) {
|
||||||
let points = await fetchBinanceKlines(binanceSymbol, limit)
|
let points = await fetchBinanceKlines(binanceSymbol, limit)
|
||||||
if (is30S) {
|
if (is30S) {
|
||||||
const cutoff = Date.now() - THIRTY_SEC_MS
|
const cutoff = Date.now() - THIRTY_SEC_MS
|
||||||
|
|||||||
@ -115,6 +115,11 @@ export interface PmEventListResponse {
|
|||||||
export interface GetPmEventListParams {
|
export interface GetPmEventListParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
/**
|
||||||
|
* 搜索关键词:当后端提供入参 `q` 时,代表搜索模式
|
||||||
|
* (保留 keyword 以兼容旧调用方)
|
||||||
|
*/
|
||||||
|
q?: string
|
||||||
keyword?: string
|
keyword?: string
|
||||||
/** 创建时间范围,如 ['2025-01-01', '2025-12-31'] */
|
/** 创建时间范围,如 ['2025-01-01', '2025-12-31'] */
|
||||||
createdAtRange?: string[]
|
createdAtRange?: string[]
|
||||||
@ -132,7 +137,7 @@ export interface GetPmEventListParams {
|
|||||||
* 分页获取 Event 列表(公开接口,不需要鉴权)
|
* 分页获取 Event 列表(公开接口,不需要鉴权)
|
||||||
* GET /PmEvent/getPmEventPublic
|
* GET /PmEvent/getPmEventPublic
|
||||||
*
|
*
|
||||||
* Query: page, pageSize, keyword, createdAtRange, tagIds
|
* Query: page, pageSize, keyword 或 q(搜索), createdAtRange, tagIds
|
||||||
* doc.json: paths["/PmEvent/getPmEventPublic"].get.parameters
|
* doc.json: paths["/PmEvent/getPmEventPublic"].get.parameters
|
||||||
*/
|
*/
|
||||||
export async function getPmEventPublic(
|
export async function getPmEventPublic(
|
||||||
@ -141,6 +146,7 @@ export async function getPmEventPublic(
|
|||||||
const {
|
const {
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
|
q,
|
||||||
keyword,
|
keyword,
|
||||||
createdAtRange,
|
createdAtRange,
|
||||||
tagIds,
|
tagIds,
|
||||||
@ -152,7 +158,8 @@ export async function getPmEventPublic(
|
|||||||
const query = buildQuery({
|
const query = buildQuery({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
keyword,
|
q,
|
||||||
|
keyword: q ? undefined : keyword,
|
||||||
createdAtRange,
|
createdAtRange,
|
||||||
tagIds,
|
tagIds,
|
||||||
active: active ? 'true' : 'false',
|
active: active ? 'true' : 'false',
|
||||||
|
|||||||
@ -90,6 +90,8 @@ export function createLineChart(
|
|||||||
timeVisible: true,
|
timeVisible: true,
|
||||||
secondsVisible: false,
|
secondsVisible: false,
|
||||||
},
|
},
|
||||||
|
handleScroll: false,
|
||||||
|
handleScale: false,
|
||||||
crosshair: {
|
crosshair: {
|
||||||
vertLine: { labelVisible: false },
|
vertLine: { labelVisible: false },
|
||||||
horzLine: { labelVisible: true },
|
horzLine: { labelVisible: true },
|
||||||
|
|||||||
@ -88,6 +88,23 @@
|
|||||||
"searchPlaceholder": "Search",
|
"searchPlaceholder": "Search",
|
||||||
"loadMore": "Load more"
|
"loadMore": "Load more"
|
||||||
},
|
},
|
||||||
|
"searchPage": {
|
||||||
|
"title": "Search",
|
||||||
|
"placeholder": "Markets, topics, addresses…",
|
||||||
|
"recordsTitle": "Search history",
|
||||||
|
"recommendTitle": "Suggested tags",
|
||||||
|
"searching": "Searching…",
|
||||||
|
"loadMore": "Loading more…",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"errorFailed": "Search failed. Please try again.",
|
||||||
|
"untitledEvent": "Untitled event",
|
||||||
|
"timeMinutes": "{n}m ago",
|
||||||
|
"timeHours": "{n}h ago",
|
||||||
|
"timeDays": "{n}d ago",
|
||||||
|
"tagEth": "ETH",
|
||||||
|
"tagTech": "Tech stocks",
|
||||||
|
"tagElection": "US election"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"requestFailed": "Request failed",
|
"requestFailed": "Request failed",
|
||||||
"loadFailed": "Load failed",
|
"loadFailed": "Load failed",
|
||||||
|
|||||||
@ -88,6 +88,23 @@
|
|||||||
"searchPlaceholder": "検索",
|
"searchPlaceholder": "検索",
|
||||||
"loadMore": "もっと読み込む"
|
"loadMore": "もっと読み込む"
|
||||||
},
|
},
|
||||||
|
"searchPage": {
|
||||||
|
"title": "検索",
|
||||||
|
"placeholder": "マーケット、トピック、住所で検索",
|
||||||
|
"recordsTitle": "検索履歴",
|
||||||
|
"recommendTitle": "おすすめタグ",
|
||||||
|
"searching": "検索中…",
|
||||||
|
"loadMore": "さらに読み込み中…",
|
||||||
|
"noResults": "該当する結果がありません",
|
||||||
|
"errorFailed": "検索に失敗しました。しばらくしてから再度お試しください。",
|
||||||
|
"untitledEvent": "無題のイベント",
|
||||||
|
"timeMinutes": "{n}分前",
|
||||||
|
"timeHours": "{n}時間前",
|
||||||
|
"timeDays": "{n}日前",
|
||||||
|
"tagEth": "ETH",
|
||||||
|
"tagTech": "ハイテク株",
|
||||||
|
"tagElection": "大統領選"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"requestFailed": "リクエストに失敗しました",
|
"requestFailed": "リクエストに失敗しました",
|
||||||
"loadFailed": "読み込みに失敗しました",
|
"loadFailed": "読み込みに失敗しました",
|
||||||
|
|||||||
@ -88,6 +88,23 @@
|
|||||||
"searchPlaceholder": "검색",
|
"searchPlaceholder": "검색",
|
||||||
"loadMore": "더 불러오기"
|
"loadMore": "더 불러오기"
|
||||||
},
|
},
|
||||||
|
"searchPage": {
|
||||||
|
"title": "검색",
|
||||||
|
"placeholder": "마켓, 주제, 주소 검색",
|
||||||
|
"recordsTitle": "검색 기록",
|
||||||
|
"recommendTitle": "추천 태그",
|
||||||
|
"searching": "검색 중…",
|
||||||
|
"loadMore": "더 불러오는 중…",
|
||||||
|
"noResults": "결과가 없습니다",
|
||||||
|
"errorFailed": "검색에 실패했습니다. 잠시 후 다시 시도하세요.",
|
||||||
|
"untitledEvent": "제목 없는 이벤트",
|
||||||
|
"timeMinutes": "{n}분 전",
|
||||||
|
"timeHours": "{n}시간 전",
|
||||||
|
"timeDays": "{n}일 전",
|
||||||
|
"tagEth": "ETH",
|
||||||
|
"tagTech": "테크주",
|
||||||
|
"tagElection": "대선"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"requestFailed": "요청 실패",
|
"requestFailed": "요청 실패",
|
||||||
"loadFailed": "로드 실패",
|
"loadFailed": "로드 실패",
|
||||||
|
|||||||
@ -88,6 +88,23 @@
|
|||||||
"searchPlaceholder": "Search",
|
"searchPlaceholder": "Search",
|
||||||
"loadMore": "加载更多"
|
"loadMore": "加载更多"
|
||||||
},
|
},
|
||||||
|
"searchPage": {
|
||||||
|
"title": "搜索",
|
||||||
|
"placeholder": "搜索市场、话题、地址",
|
||||||
|
"recordsTitle": "搜索记录",
|
||||||
|
"recommendTitle": "推荐标签",
|
||||||
|
"searching": "搜索中...",
|
||||||
|
"loadMore": "加载更多…",
|
||||||
|
"noResults": "暂无相关结果",
|
||||||
|
"errorFailed": "搜索失败,请稍后重试",
|
||||||
|
"untitledEvent": "未命名事件",
|
||||||
|
"timeMinutes": "{n} 分钟前",
|
||||||
|
"timeHours": "{n} 小时前",
|
||||||
|
"timeDays": "{n} 天前",
|
||||||
|
"tagEth": "ETH",
|
||||||
|
"tagTech": "科技股",
|
||||||
|
"tagElection": "总统大选"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"requestFailed": "请求失败",
|
"requestFailed": "请求失败",
|
||||||
"loadFailed": "加载失败",
|
"loadFailed": "加载失败",
|
||||||
|
|||||||
@ -88,6 +88,23 @@
|
|||||||
"searchPlaceholder": "Search",
|
"searchPlaceholder": "Search",
|
||||||
"loadMore": "載入更多"
|
"loadMore": "載入更多"
|
||||||
},
|
},
|
||||||
|
"searchPage": {
|
||||||
|
"title": "搜尋",
|
||||||
|
"placeholder": "搜尋市場、話題、地址",
|
||||||
|
"recordsTitle": "搜尋紀錄",
|
||||||
|
"recommendTitle": "推薦標籤",
|
||||||
|
"searching": "搜尋中…",
|
||||||
|
"loadMore": "載入更多…",
|
||||||
|
"noResults": "暫無相關結果",
|
||||||
|
"errorFailed": "搜尋失敗,請稍後再試",
|
||||||
|
"untitledEvent": "未命名事件",
|
||||||
|
"timeMinutes": "{n} 分鐘前",
|
||||||
|
"timeHours": "{n} 小時前",
|
||||||
|
"timeDays": "{n} 天前",
|
||||||
|
"tagEth": "ETH",
|
||||||
|
"tagTech": "科技股",
|
||||||
|
"tagElection": "總統大選"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"requestFailed": "請求失敗",
|
"requestFailed": "請求失敗",
|
||||||
"loadFailed": "載入失敗",
|
"loadFailed": "載入失敗",
|
||||||
|
|||||||
@ -59,8 +59,22 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
scrollBehavior(to, from, savedPosition) {
|
scrollBehavior(to, from, savedPosition) {
|
||||||
if (savedPosition && from?.name) return savedPosition
|
const el = document.querySelector('[data-main-scroll]')
|
||||||
if (to.hash) return { el: to.hash }
|
if (el) {
|
||||||
|
if (savedPosition && from?.name) {
|
||||||
|
el.scrollTo({ top: savedPosition.top, left: savedPosition.left ?? 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (to.hash) {
|
||||||
|
const target = document.querySelector(to.hash)
|
||||||
|
if (target) {
|
||||||
|
const top = (target as HTMLElement).offsetTop
|
||||||
|
el.scrollTo({ top })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.scrollTo({ top: 0, left: 0 })
|
||||||
|
}
|
||||||
return { top: 0 }
|
return { top: 0 }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -366,6 +366,16 @@ const MOBILE_BREAKPOINT = 600
|
|||||||
async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
|
async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
|
||||||
const list = markets.value
|
const list = markets.value
|
||||||
const range = selectedTimeRange.value
|
const range = selectedTimeRange.value
|
||||||
|
// 增加每次请求的 points 数量,避免图表在较长时间粒度下出现“最新数据提前被截断”
|
||||||
|
const pageSizeByRange: Record<string, number> = {
|
||||||
|
'1H': 200,
|
||||||
|
'6H': 600,
|
||||||
|
'1D': 800,
|
||||||
|
'1W': 1200,
|
||||||
|
'1M': 1500,
|
||||||
|
ALL: 2000,
|
||||||
|
}
|
||||||
|
const pageSize = pageSizeByRange[range] ?? 500
|
||||||
const results: ChartSeriesItem[] = []
|
const results: ChartSeriesItem[] = []
|
||||||
for (let i = 0; i < list.length; i++) {
|
for (let i = 0; i < list.length; i++) {
|
||||||
const market = list[i]
|
const market = list[i]
|
||||||
@ -384,7 +394,7 @@ async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
|
|||||||
const res = await getPmPriceHistoryPublic({
|
const res = await getPmPriceHistoryPublic({
|
||||||
market: yesTokenId,
|
market: yesTokenId,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 500,
|
pageSize,
|
||||||
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
|
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
|
||||||
})
|
})
|
||||||
const points = priceHistoryToChartData(res.data?.list ?? [])
|
const points = priceHistoryToChartData(res.data?.list ?? [])
|
||||||
@ -435,6 +445,8 @@ async function initChart() {
|
|||||||
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
|
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
|
||||||
rightPriceScale: { borderColor: '#e5e7eb', scaleMargins: { top: 0.1, bottom: 0.1 } },
|
rightPriceScale: { borderColor: '#e5e7eb', scaleMargins: { top: 0.1, bottom: 0.1 } },
|
||||||
timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: false },
|
timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: false },
|
||||||
|
handleScroll: false,
|
||||||
|
handleScale: false,
|
||||||
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
||||||
})
|
})
|
||||||
setChartSeries(chartData.value)
|
setChartSeries(chartData.value)
|
||||||
|
|||||||
@ -12,27 +12,6 @@
|
|||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</v-tab>
|
</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
<div class="home-category-layer1-actions">
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
class="home-category-action-btn"
|
|
||||||
:aria-label="t('common.search')"
|
|
||||||
@click="expandSearch"
|
|
||||||
>
|
|
||||||
<v-icon size="24">mdi-magnify</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
class="home-category-action-btn"
|
|
||||||
:aria-label="t('common.filter')"
|
|
||||||
>
|
|
||||||
<v-icon size="20">mdi-filter-outline</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 搜索展开时:浮层输入框 + 历史记录 -->
|
<!-- 搜索展开时:浮层输入框 + 历史记录 -->
|
||||||
<transition name="home-search-overlay">
|
<transition name="home-search-overlay">
|
||||||
@ -640,29 +619,35 @@ function loadMore() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMainScrollEl(): Element | null {
|
||||||
|
return document.querySelector('[data-main-scroll]')
|
||||||
|
}
|
||||||
|
|
||||||
function checkScrollLoad() {
|
function checkScrollLoad() {
|
||||||
if (loadingMore.value || eventList.value.length === 0 || noMoreEvents.value) return
|
if (loadingMore.value || eventList.value.length === 0 || noMoreEvents.value) return
|
||||||
const { scrollY, innerHeight } = window
|
const el = getMainScrollEl()
|
||||||
const scrollHeight = document.documentElement.scrollHeight
|
if (!el) return
|
||||||
if (scrollHeight - scrollY - innerHeight < SCROLL_LOAD_THRESHOLD) loadMore()
|
const { scrollTop, scrollHeight, clientHeight } = el
|
||||||
|
if (scrollHeight - scrollTop - clientHeight < SCROLL_LOAD_THRESHOLD) loadMore()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.initialSearchExpanded) expandSearch()
|
if (props.initialSearchExpanded) expandSearch()
|
||||||
loadCategory()
|
loadCategory()
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
const scrollEl = getMainScrollEl()
|
||||||
const sentinel = sentinelRef.value
|
const sentinel = sentinelRef.value
|
||||||
if (sentinel) {
|
if (sentinel && scrollEl) {
|
||||||
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
|
const listEl = listRef.value
|
||||||
if (listEl) {
|
if (listEl) {
|
||||||
updateGridColumns()
|
updateGridColumns()
|
||||||
@ -680,7 +665,7 @@ function removeScrollListeners() {
|
|||||||
resizeObserver.disconnect()
|
resizeObserver.disconnect()
|
||||||
resizeObserver = null
|
resizeObserver = null
|
||||||
}
|
}
|
||||||
window.removeEventListener('scroll', checkScrollLoad)
|
getMainScrollEl()?.removeEventListener('scroll', checkScrollLoad)
|
||||||
}
|
}
|
||||||
|
|
||||||
// keep-alive 时离开页面不会触发 onUnmounted,需在 onDeactivated 移除监听,否则详情页滚动到底会误触发 loadMore
|
// keep-alive 时离开页面不会触发 onUnmounted,需在 onDeactivated 移除监听,否则详情页滚动到底会误触发 loadMore
|
||||||
@ -690,17 +675,18 @@ onUnmounted(removeScrollListeners)
|
|||||||
// 从详情页返回时重新注册监听(仅当 observer 已被 removeScrollListeners 清空时)
|
// 从详情页返回时重新注册监听(仅当 observer 已被 removeScrollListeners 清空时)
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
const scrollEl = getMainScrollEl()
|
||||||
const sentinel = sentinelRef.value
|
const sentinel = sentinelRef.value
|
||||||
if (sentinel && !observer) {
|
if (sentinel && scrollEl && !observer) {
|
||||||
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
|
const listEl = listRef.value
|
||||||
if (listEl && !resizeObserver) {
|
if (listEl && !resizeObserver) {
|
||||||
@ -831,13 +817,13 @@ onActivated(() => {
|
|||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 第一层:置顶、全宽 */
|
/* 第一层:置顶、全宽;sticky 参照 app-main-scroll,top:0 贴容器顶(即 app-bar 下方) */
|
||||||
.home-category-layer1-wrap {
|
.home-category-layer1-wrap {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
margin-left: calc(50% - 50vw);
|
margin-left: calc(50% - 50vw);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 64px;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
/* box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); */
|
/* box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); */
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="search-page">
|
<div class="search-page">
|
||||||
<div class="search-screen">
|
<div class="search-screen">
|
||||||
<h1 class="search-header">搜索</h1>
|
<h1 class="search-header">{{ t('searchPage.title') }}</h1>
|
||||||
|
|
||||||
<form class="search-box" @submit.prevent="onSearch">
|
<form class="search-box" @submit.prevent="onSearch">
|
||||||
<input
|
<input
|
||||||
@ -11,13 +11,13 @@
|
|||||||
enterkeyhint="search"
|
enterkeyhint="search"
|
||||||
inputmode="search"
|
inputmode="search"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
placeholder="搜索市场、话题、地址"
|
:placeholder="t('searchPage.placeholder')"
|
||||||
@keydown.enter.prevent="onSearch"
|
@keydown.enter.prevent="onSearch"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template v-if="showResults">
|
<template v-if="showResults">
|
||||||
<section class="result-list">
|
<section v-if="resultItems.length > 0" class="result-list">
|
||||||
<article
|
<article
|
||||||
v-for="item in resultItems"
|
v-for="item in resultItems"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@ -25,7 +25,10 @@
|
|||||||
@click="openResult(item)"
|
@click="openResult(item)"
|
||||||
>
|
>
|
||||||
<div class="result-left">
|
<div class="result-left">
|
||||||
<div class="result-icon" :class="item.iconClass">{{ item.iconText }}</div>
|
<div class="result-icon" :class="item.iconClass">
|
||||||
|
<img v-if="item.imageUrl" :src="item.imageUrl" alt="" class="result-icon-img" />
|
||||||
|
<span v-else>{{ item.iconText }}</span>
|
||||||
|
</div>
|
||||||
<div class="result-title">{{ item.title }}</div>
|
<div class="result-title">{{ item.title }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-right">
|
<div class="result-right">
|
||||||
@ -33,25 +36,29 @@
|
|||||||
<div class="result-time">{{ item.timeAgo }}</div>
|
<div class="result-time">{{ item.timeAgo }}</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
<div ref="loadMoreSentinelRef" class="load-more-sentinel" aria-hidden="true"></div>
|
||||||
|
</section>
|
||||||
|
<div v-else-if="searchError" class="search-error search-error--block">{{ searchError }}</div>
|
||||||
|
<section v-else class="search-empty">
|
||||||
|
<p class="search-empty-text">{{ t('searchPage.noResults') }}</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="card records-card">
|
<section class="card records-card">
|
||||||
<h2 class="card-title">搜索记录</h2>
|
<h2 class="card-title">{{ t('searchPage.recordsTitle') }}</h2>
|
||||||
<button
|
<button
|
||||||
v-for="record in searchRecords"
|
v-for="(record, idx) in searchRecords"
|
||||||
:key="record"
|
:key="record"
|
||||||
type="button"
|
type="button"
|
||||||
class="record-row"
|
class="record-row"
|
||||||
@click="useRecord(record)"
|
@click="useRecord(record)"
|
||||||
>
|
>
|
||||||
<span class="record-text">{{ record }}</span>
|
<span class="record-text">{{ record }}</span>
|
||||||
<v-icon size="16" class="record-close" @click.stop="removeRecord(record)">mdi-close</v-icon>
|
<v-icon size="16" class="record-close" @click.stop="removeRecord(idx)">mdi-close</v-icon>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card tags-card">
|
<section class="card tags-card">
|
||||||
<h2 class="card-title">推荐标签</h2>
|
<h2 class="card-title">{{ t('searchPage.recommendTitle') }}</h2>
|
||||||
<div class="tag-row">
|
<div class="tag-row">
|
||||||
<button v-for="tag in recommendTags" :key="tag" type="button" class="tag-chip" @click="useTag(tag)">
|
<button v-for="tag in recommendTags" :key="tag" type="button" class="tag-chip" @click="useTag(tag)">
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
@ -59,60 +66,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="searching" class="search-loading">搜索中...</div>
|
<div v-if="searching" class="search-loading">{{ t('searchPage.searching') }}</div>
|
||||||
<div v-if="searchError" class="search-error">{{ searchError }}</div>
|
<div v-else-if="loadingMore" class="search-loading">{{ t('searchPage.loadMore') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, nextTick, ref, watch, onUnmounted } from 'vue'
|
||||||
import { getPmEventPublic, type PmEventListItem } from '../api/event'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { getPmEventPublic, mapEventItemToCard, type PmEventListItem } from '../api/event'
|
||||||
|
import { useSearchHistory } from '../composables/useSearchHistory'
|
||||||
|
|
||||||
interface SearchResultItem {
|
interface SearchResultItem {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
iconText: string
|
iconText: string
|
||||||
iconClass: string
|
iconClass: string
|
||||||
|
imageUrl?: string
|
||||||
percent: string
|
percent: string
|
||||||
pctClass: string
|
pctClass: string
|
||||||
timeAgo: string
|
timeAgo: string
|
||||||
raw: PmEventListItem
|
raw: PmEventListItem
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchRecords = ref(['BTC ETF approval odds', 'US election winner', 'ETH above $4k'])
|
const { t } = useI18n()
|
||||||
const recommendTags = ref(['ETH', '科技股', '总统大选'])
|
const router = useRouter()
|
||||||
|
const searchHistory = useSearchHistory()
|
||||||
|
const searchRecords = computed(() => searchHistory.list.value)
|
||||||
|
const recommendTags = computed(() => [t('searchPage.tagEth'), t('searchPage.tagTech'), t('searchPage.tagElection')])
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const searching = ref(false)
|
const searching = ref(false)
|
||||||
const searchError = ref('')
|
const searchError = ref('')
|
||||||
const hasSearched = ref(false)
|
const hasSearched = ref(false)
|
||||||
const resultItems = ref<SearchResultItem[]>([])
|
const resultItems = ref<SearchResultItem[]>([])
|
||||||
|
const searchPage = ref(1)
|
||||||
|
const searchTotal = ref(0)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const loadMoreSentinelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const showResults = computed(() => hasSearched.value)
|
const PAGE_SIZE = 12
|
||||||
|
|
||||||
function removeRecord(record: string) {
|
const showResults = computed(
|
||||||
searchRecords.value = searchRecords.value.filter((item) => item !== record)
|
() => hasSearched.value && searchKeyword.value.trim() !== '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const noMoreResults = computed(
|
||||||
|
() =>
|
||||||
|
resultItems.value.length >= searchTotal.value ||
|
||||||
|
(resultItems.value.length > 0 && resultItems.value.length < PAGE_SIZE),
|
||||||
|
)
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let ignoreNextDebounceOnce = false
|
||||||
|
|
||||||
|
function clearDebounce() {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertRecord(keyword: string) {
|
// 输入停止一段时间后自动发起搜索(避免输入过程中频繁请求)
|
||||||
searchRecords.value = [keyword, ...searchRecords.value.filter((item) => item !== keyword)].slice(0, 10)
|
watch(searchKeyword, (next) => {
|
||||||
|
const kw = next.trim()
|
||||||
|
if (ignoreNextDebounceOnce) {
|
||||||
|
ignoreNextDebounceOnce = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!kw) {
|
||||||
|
clearDebounce()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearDebounce()
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
onSearch()
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
function removeRecord(index: number) {
|
||||||
|
searchHistory.remove(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTimeAgoLabel(event: PmEventListItem): string {
|
function getTimeAgoLabel(event: PmEventListItem): string {
|
||||||
const source = event.updatedAt || event.createdAt || event.startDate
|
const source = event.updatedAt || event.createdAt || event.startDate
|
||||||
if (!source) return '1d ago'
|
if (!source) return t('searchPage.timeDays', { n: 1 })
|
||||||
const time = new Date(source).getTime()
|
const time = new Date(source).getTime()
|
||||||
if (!Number.isFinite(time)) return '1d ago'
|
if (!Number.isFinite(time)) return t('searchPage.timeDays', { n: 1 })
|
||||||
const diff = Date.now() - time
|
const diff = Date.now() - time
|
||||||
if (diff < 60 * 60 * 1000) return `${Math.max(1, Math.floor(diff / (60 * 1000)))}m ago`
|
if (diff < 60 * 60 * 1000)
|
||||||
if (diff < 24 * 60 * 60 * 1000) return `${Math.max(1, Math.floor(diff / (60 * 60 * 1000)))}h ago`
|
return t('searchPage.timeMinutes', { n: Math.max(1, Math.floor(diff / (60 * 1000))) })
|
||||||
return `${Math.max(1, Math.floor(diff / (24 * 60 * 60 * 1000)))}d ago`
|
if (diff < 24 * 60 * 60 * 1000)
|
||||||
|
return t('searchPage.timeHours', { n: Math.max(1, Math.floor(diff / (60 * 60 * 1000))) })
|
||||||
|
return t('searchPage.timeDays', { n: Math.max(1, Math.floor(diff / (24 * 60 * 60 * 1000))) })
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapEventToResultItem(event: PmEventListItem, idx: number): SearchResultItem {
|
function mapEventToResultItem(event: PmEventListItem, idx: number): SearchResultItem {
|
||||||
const title = event.title || event.slug || 'Untitled Event'
|
const title = event.title || event.slug || t('searchPage.untitledEvent')
|
||||||
const iconText = title.trim().charAt(0).toUpperCase() || 'M'
|
const iconText = title.trim().charAt(0).toUpperCase() || 'M'
|
||||||
const iconClass = idx === 1 ? 'icon-red' : idx === 2 ? 'icon-primary' : 'icon-dark'
|
const iconClass = idx === 1 ? 'icon-red' : idx === 2 ? 'icon-primary' : 'icon-dark'
|
||||||
|
const imageUrl = (event.image || event.icon || '').trim() || undefined
|
||||||
const chance = Number(event.markets?.[0]?.outcomePrices?.[0] ?? 0.5)
|
const chance = Number(event.markets?.[0]?.outcomePrices?.[0] ?? 0.5)
|
||||||
const percentNum = Number.isFinite(chance) ? Math.round(chance * 100) : 50
|
const percentNum = Number.isFinite(chance) ? Math.round(chance * 100) : 50
|
||||||
return {
|
return {
|
||||||
@ -120,6 +172,7 @@ function mapEventToResultItem(event: PmEventListItem, idx: number): SearchResult
|
|||||||
title,
|
title,
|
||||||
iconText,
|
iconText,
|
||||||
iconClass,
|
iconClass,
|
||||||
|
imageUrl,
|
||||||
percent: `${percentNum}%`,
|
percent: `${percentNum}%`,
|
||||||
pctClass: idx === 0 ? 'pct-primary' : 'pct-default',
|
pctClass: idx === 0 ? 'pct-primary' : 'pct-default',
|
||||||
timeAgo: getTimeAgoLabel(event),
|
timeAgo: getTimeAgoLabel(event),
|
||||||
@ -127,77 +180,143 @@ function mapEventToResultItem(event: PmEventListItem, idx: number): SearchResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFallbackResults(): SearchResultItem[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'btc',
|
|
||||||
title: 'BTC > $85k this year',
|
|
||||||
iconText: 'B',
|
|
||||||
iconClass: 'icon-dark',
|
|
||||||
percent: '72%',
|
|
||||||
pctClass: 'pct-primary',
|
|
||||||
timeAgo: '2h ago',
|
|
||||||
raw: { ID: 1, title: 'BTC > $85k this year' } as PmEventListItem,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'eth',
|
|
||||||
title: 'ETH above $4k by Q4',
|
|
||||||
iconText: 'E',
|
|
||||||
iconClass: 'icon-red',
|
|
||||||
percent: '61%',
|
|
||||||
pctClass: 'pct-default',
|
|
||||||
timeAgo: '5h ago',
|
|
||||||
raw: { ID: 2, title: 'ETH above $4k by Q4' } as PmEventListItem,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'us',
|
|
||||||
title: 'US Election Winner',
|
|
||||||
iconText: 'U',
|
|
||||||
iconClass: 'icon-primary',
|
|
||||||
percent: '54%',
|
|
||||||
pctClass: 'pct-default',
|
|
||||||
timeAgo: '1d ago',
|
|
||||||
raw: { ID: 3, title: 'US Election Winner' } as PmEventListItem,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSearch() {
|
async function onSearch() {
|
||||||
const keyword = searchKeyword.value.trim()
|
const keyword = searchKeyword.value.trim()
|
||||||
if (!keyword || searching.value) return
|
if (!keyword || searching.value) return
|
||||||
|
|
||||||
|
clearDebounce()
|
||||||
searching.value = true
|
searching.value = true
|
||||||
searchError.value = ''
|
searchError.value = ''
|
||||||
|
|
||||||
|
// 写入本地真实搜索历史(最多保留 10 条)
|
||||||
|
searchHistory.add(keyword)
|
||||||
|
searchPage.value = 1
|
||||||
try {
|
try {
|
||||||
const res = await getPmEventPublic({ page: 1, pageSize: 10, keyword })
|
const res = await getPmEventPublic({ page: 1, pageSize: PAGE_SIZE, q: keyword })
|
||||||
const list = res.data?.list ?? []
|
if (res.code !== 0 && res.code !== 200) {
|
||||||
resultItems.value = (list.length > 0 ? list : []).slice(0, 12).map(mapEventToResultItem)
|
resultItems.value = []
|
||||||
if (resultItems.value.length === 0) {
|
searchTotal.value = 0
|
||||||
resultItems.value = buildFallbackResults()
|
searchError.value = (res.msg && String(res.msg).trim()) || t('searchPage.errorFailed')
|
||||||
|
hasSearched.value = true
|
||||||
|
return
|
||||||
}
|
}
|
||||||
upsertRecord(keyword)
|
const data = res.data
|
||||||
|
const list = data?.list ?? []
|
||||||
|
searchTotal.value = data?.total ?? 0
|
||||||
|
resultItems.value = list.map((e, i) => mapEventToResultItem(e, i))
|
||||||
hasSearched.value = true
|
hasSearched.value = true
|
||||||
} catch {
|
} catch {
|
||||||
resultItems.value = buildFallbackResults()
|
resultItems.value = []
|
||||||
|
searchTotal.value = 0
|
||||||
hasSearched.value = true
|
hasSearched.value = true
|
||||||
searchError.value = '搜索失败,已展示示例结果'
|
searchError.value = t('searchPage.errorFailed')
|
||||||
} finally {
|
} finally {
|
||||||
searching.value = false
|
searching.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOAD_MORE_THRESHOLD = 200
|
||||||
|
let loadMoreObserver: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
const keyword = searchKeyword.value.trim()
|
||||||
|
if (
|
||||||
|
!keyword ||
|
||||||
|
loadingMore.value ||
|
||||||
|
noMoreResults.value ||
|
||||||
|
resultItems.value.length === 0
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
loadingMore.value = true
|
||||||
|
try {
|
||||||
|
const nextPage = searchPage.value + 1
|
||||||
|
const res = await getPmEventPublic({
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
q: keyword,
|
||||||
|
})
|
||||||
|
if (res.code !== 0 && res.code !== 200) return
|
||||||
|
const list = res.data?.list ?? []
|
||||||
|
const offset = resultItems.value.length
|
||||||
|
const appended = list.map((e, i) => mapEventToResultItem(e, offset + i))
|
||||||
|
resultItems.value = [...resultItems.value, ...appended]
|
||||||
|
searchPage.value = nextPage
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupLoadMoreObserver() {
|
||||||
|
const sentinel = loadMoreSentinelRef.value
|
||||||
|
const scrollEl = document.querySelector('[data-main-scroll]')
|
||||||
|
if (!sentinel || !scrollEl || loadMoreObserver) return
|
||||||
|
loadMoreObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries[0]?.isIntersecting) return
|
||||||
|
loadMore()
|
||||||
|
},
|
||||||
|
{ root: scrollEl, rootMargin: `${LOAD_MORE_THRESHOLD}px`, threshold: 0 },
|
||||||
|
)
|
||||||
|
loadMoreObserver.observe(sentinel)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLoadMoreObserver() {
|
||||||
|
const sentinel = loadMoreSentinelRef.value
|
||||||
|
if (loadMoreObserver && sentinel) loadMoreObserver.unobserve(sentinel)
|
||||||
|
loadMoreObserver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => showResults.value && resultItems.value.length > 0 && !noMoreResults.value,
|
||||||
|
(shouldObserve) => {
|
||||||
|
removeLoadMoreObserver()
|
||||||
|
if (shouldObserve) nextTick(setupLoadMoreObserver)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(removeLoadMoreObserver)
|
||||||
|
|
||||||
function useRecord(record: string) {
|
function useRecord(record: string) {
|
||||||
|
ignoreNextDebounceOnce = true
|
||||||
|
clearDebounce()
|
||||||
searchKeyword.value = record
|
searchKeyword.value = record
|
||||||
onSearch()
|
onSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTag(tag: string) {
|
function useTag(tag: string) {
|
||||||
|
ignoreNextDebounceOnce = true
|
||||||
|
clearDebounce()
|
||||||
searchKeyword.value = tag
|
searchKeyword.value = tag
|
||||||
onSearch()
|
onSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openResult(_item: SearchResultItem) {
|
function openResult(item: SearchResultItem) {
|
||||||
// Placeholder: can route to detail page later.
|
const card = mapEventItemToCard(item.raw)
|
||||||
|
if (!card.id) return
|
||||||
|
|
||||||
|
if (card.displayType === 'multi' && (card.outcomes?.length ?? 0) > 1) {
|
||||||
|
router.push({
|
||||||
|
path: `/event/${card.id}/markets`,
|
||||||
|
query: { ...(card.slug && { slug: card.slug }) },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
path: `/trade-detail/${card.id}`,
|
||||||
|
query: {
|
||||||
|
title: card.marketTitle,
|
||||||
|
imageUrl: card.imageUrl || undefined,
|
||||||
|
category: card.category || undefined,
|
||||||
|
marketInfo: card.marketInfo || undefined,
|
||||||
|
expiresAt: card.expiresAt || undefined,
|
||||||
|
chance: String(card.chanceValue),
|
||||||
|
...(card.marketId && { marketId: card.marketId }),
|
||||||
|
...(card.slug && { slug: card.slug }),
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -323,6 +442,13 @@ function openResult(_item: SearchResultItem) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-more-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.result-item {
|
.result-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@ -348,12 +474,21 @@ function openResult(_item: SearchResultItem) {
|
|||||||
.result-icon {
|
.result-icon {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
|
min-width: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-dark {
|
.icon-dark {
|
||||||
@ -418,4 +553,27 @@ function openResult(_item: SearchResultItem) {
|
|||||||
.search-error {
|
.search-error {
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-error--block {
|
||||||
|
padding: 12px 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #fff;
|
||||||
|
padding: 24px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty-text {
|
||||||
|
margin: 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -184,18 +184,8 @@
|
|||||||
|
|
||||||
<!-- Comments / Top Holders / Activity(与左侧图表、订单簿同宽) -->
|
<!-- Comments / Top Holders / Activity(与左侧图表、订单簿同宽) -->
|
||||||
<v-card class="activity-card" elevation="0" rounded="lg">
|
<v-card class="activity-card" elevation="0" rounded="lg">
|
||||||
<v-tabs v-model="detailTab" class="detail-tabs" density="comfortable">
|
|
||||||
<v-tab value="rules">{{ t('activity.rules') }}</v-tab>
|
|
||||||
<v-tab value="holders">{{ t('activity.topHolders') }}</v-tab>
|
|
||||||
<v-tab value="activity">{{ t('activity.activity') }}</v-tab>
|
|
||||||
</v-tabs>
|
|
||||||
<v-window v-model="detailTab" class="detail-window">
|
|
||||||
<v-window-item value="rules" class="detail-pane">
|
|
||||||
<div class="rules-pane">
|
<div class="rules-pane">
|
||||||
<div
|
<div v-if="!eventDetail?.description && !eventDetail?.resolutionSource" class="placeholder-pane">
|
||||||
v-if="!eventDetail?.description && !eventDetail?.resolutionSource"
|
|
||||||
class="placeholder-pane"
|
|
||||||
>
|
|
||||||
{{ t('activity.rulesEmpty') }}
|
{{ t('activity.rulesEmpty') }}
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@ -219,64 +209,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</v-window-item>
|
|
||||||
<v-window-item value="holders" class="detail-pane">
|
|
||||||
<div class="placeholder-pane">{{ t('activity.topHoldersPlaceholder') }}</div>
|
|
||||||
</v-window-item>
|
|
||||||
<v-window-item value="activity" class="detail-pane">
|
|
||||||
<div class="activity-toolbar">
|
|
||||||
<v-select
|
|
||||||
v-model="activityMinAmount"
|
|
||||||
:items="minAmountOptions"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
variant="outlined"
|
|
||||||
:label="t('activity.minAmount')"
|
|
||||||
class="min-amount-select"
|
|
||||||
/>
|
|
||||||
<span class="live-badge">
|
|
||||||
<span class="live-dot"></span>
|
|
||||||
{{ t('activity.live') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="activity-list">
|
|
||||||
<div v-for="item in filteredActivity" :key="item.id" class="activity-item">
|
|
||||||
<div class="activity-avatar" :class="item.avatarClass">
|
|
||||||
<img
|
|
||||||
v-if="item.avatarUrl"
|
|
||||||
:src="item.avatarUrl"
|
|
||||||
:alt="item.user"
|
|
||||||
class="avatar-img"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="activity-body">
|
|
||||||
<span class="activity-user">{{ item.user }}</span>
|
|
||||||
<span class="activity-action">{{
|
|
||||||
t(item.action === 'bought' ? 'activity.bought' : 'activity.sold')
|
|
||||||
}}</span>
|
|
||||||
<span
|
|
||||||
:class="['activity-amount', item.side === 'Yes' ? 'amount-yes' : 'amount-no']"
|
|
||||||
>
|
|
||||||
{{ item.amount }} {{ item.side }}
|
|
||||||
</span>
|
|
||||||
<span class="activity-price">{{ t('activity.at') }} {{ item.price }}</span>
|
|
||||||
<span class="activity-total">({{ item.total }})</span>
|
|
||||||
</div>
|
|
||||||
<div class="activity-meta">
|
|
||||||
<span class="activity-time">{{ formatTimeAgo(item.time) }}</span>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="activity-link"
|
|
||||||
:aria-label="t('activity.viewTransaction')"
|
|
||||||
@click.prevent
|
|
||||||
>
|
|
||||||
<v-icon size="16">mdi-open-in-new</v-icon>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-window-item>
|
|
||||||
</v-window>
|
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
@ -420,7 +352,7 @@ import {
|
|||||||
type OpenOrderDisplayItem,
|
type OpenOrderDisplayItem,
|
||||||
} from '../api/order'
|
} from '../api/order'
|
||||||
import { cancelOrder as apiCancelOrder } from '../api/order'
|
import { cancelOrder as apiCancelOrder } from '../api/order'
|
||||||
import type { ChartDataPoint, ChartTimeRange } from '../api/chart'
|
import type { ChartDataPoint } from '../api/chart'
|
||||||
import {
|
import {
|
||||||
getPmPriceHistoryPublic,
|
getPmPriceHistoryPublic,
|
||||||
getTimeRangeSeconds,
|
getTimeRangeSeconds,
|
||||||
@ -787,20 +719,6 @@ function connectClob(tokenIds: string[]) {
|
|||||||
const tokenIdx = side === 'buy' ? 0 : 1
|
const tokenIdx = side === 'buy' ? 0 : 1
|
||||||
clobLastPriceByToken.value = { ...clobLastPriceByToken.value, [tokenIdx]: priceCents }
|
clobLastPriceByToken.value = { ...clobLastPriceByToken.value, [tokenIdx]: priceCents }
|
||||||
}
|
}
|
||||||
// 追加到 Activity 列表
|
|
||||||
const side = msg.side?.toLowerCase() === 'buy' ? 'Yes' : 'No'
|
|
||||||
const action = side === 'Yes' ? 'bought' : 'sold'
|
|
||||||
activityList.value.unshift({
|
|
||||||
id: `clob-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
user: '0x...',
|
|
||||||
avatarClass: 'avatar-gradient-1',
|
|
||||||
action: action as 'bought' | 'sold',
|
|
||||||
side: side as 'Yes' | 'No',
|
|
||||||
amount: Math.round(parseFloat(msg.s) || 0),
|
|
||||||
price: `${Math.round((priceNum || 0) * 100)}¢`,
|
|
||||||
total: `$${((parseFloat(msg.s) || 0) * (priceNum || 0)).toFixed(2)}`,
|
|
||||||
time: Date.now(),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
clobSdkRef.value = sdk
|
clobSdkRef.value = sdk
|
||||||
@ -1150,115 +1068,7 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Comments / Top Holders / Activity
|
// Rules(仅保留 Rules 面板)
|
||||||
const detailTab = ref('rules')
|
|
||||||
const activityMinAmount = ref<string>('0')
|
|
||||||
|
|
||||||
const minAmountOptions = computed(() => [
|
|
||||||
{ title: t('activity.any'), value: '0' },
|
|
||||||
{ title: '$1', value: '1' },
|
|
||||||
{ title: '$10', value: '10' },
|
|
||||||
{ title: '$100', value: '100' },
|
|
||||||
{ title: '$500', value: '500' },
|
|
||||||
])
|
|
||||||
|
|
||||||
interface ActivityItem {
|
|
||||||
id: string
|
|
||||||
user: string
|
|
||||||
avatarClass: string
|
|
||||||
avatarUrl?: string
|
|
||||||
action: 'bought' | 'sold'
|
|
||||||
side: 'Yes' | 'No'
|
|
||||||
amount: number
|
|
||||||
price: string
|
|
||||||
total: string
|
|
||||||
time: number
|
|
||||||
}
|
|
||||||
const activityList = ref<ActivityItem[]>([
|
|
||||||
{
|
|
||||||
id: 'a1',
|
|
||||||
user: 'Scottp1887',
|
|
||||||
avatarClass: 'avatar-gradient-1',
|
|
||||||
action: 'bought',
|
|
||||||
side: 'Yes',
|
|
||||||
amount: 914,
|
|
||||||
price: '32.8¢',
|
|
||||||
total: '$300',
|
|
||||||
time: Date.now() - 10 * 60 * 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a2',
|
|
||||||
user: 'Outrageous-Budd...',
|
|
||||||
avatarClass: 'avatar-gradient-2',
|
|
||||||
action: 'sold',
|
|
||||||
side: 'No',
|
|
||||||
amount: 20,
|
|
||||||
price: '70.0¢',
|
|
||||||
total: '$14',
|
|
||||||
time: Date.now() - 29 * 60 * 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a3',
|
|
||||||
user: '0xbc7db42d5ea9a...',
|
|
||||||
avatarClass: 'avatar-gradient-3',
|
|
||||||
action: 'bought',
|
|
||||||
side: 'Yes',
|
|
||||||
amount: 5,
|
|
||||||
price: '68.0¢',
|
|
||||||
total: '$3.40',
|
|
||||||
time: Date.now() - 60 * 60 * 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a4',
|
|
||||||
user: 'FINNISH-FEMBOY',
|
|
||||||
avatarClass: 'avatar-gradient-4',
|
|
||||||
action: 'sold',
|
|
||||||
side: 'No',
|
|
||||||
amount: 57,
|
|
||||||
price: '35.0¢',
|
|
||||||
total: '$20',
|
|
||||||
time: Date.now() - 2 * 60 * 60 * 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a5',
|
|
||||||
user: 'CryptoWhale',
|
|
||||||
avatarClass: 'avatar-gradient-1',
|
|
||||||
action: 'bought',
|
|
||||||
side: 'No',
|
|
||||||
amount: 143,
|
|
||||||
price: '28.0¢',
|
|
||||||
total: '$40',
|
|
||||||
time: Date.now() - 3 * 60 * 60 * 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a6',
|
|
||||||
user: 'PolyTrader',
|
|
||||||
avatarClass: 'avatar-gradient-2',
|
|
||||||
action: 'sold',
|
|
||||||
side: 'Yes',
|
|
||||||
amount: 30,
|
|
||||||
price: '72.0¢',
|
|
||||||
total: '$21.60',
|
|
||||||
time: Date.now() - 5 * 60 * 60 * 1000,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const filteredActivity = computed(() => {
|
|
||||||
const min = Number(activityMinAmount.value) || 0
|
|
||||||
return activityList.value.filter((item) => {
|
|
||||||
const totalNum = parseFloat(item.total.replace(/[$,]/g, ''))
|
|
||||||
return totalNum >= min
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatTimeAgo(ts: number): string {
|
|
||||||
const sec = Math.floor((Date.now() - ts) / 1000)
|
|
||||||
if (sec < 60) return t('activity.justNow')
|
|
||||||
if (sec < 3600) return t('activity.minutesAgo', { n: Math.floor(sec / 60) })
|
|
||||||
if (sec < 86400) return t('activity.hoursAgo', { n: Math.floor(sec / 3600) })
|
|
||||||
if (sec < 604800) return t('activity.daysAgo', { n: Math.floor(sec / 86400) })
|
|
||||||
return t('activity.weeksAgo', { n: Math.floor(sec / 604800) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 时间粒度
|
// 时间粒度
|
||||||
const selectedTimeRange = ref('1D')
|
const selectedTimeRange = ref('1D')
|
||||||
@ -1346,6 +1156,8 @@ function initChart() {
|
|||||||
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
|
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
|
||||||
rightPriceScale: { borderColor: '#e5e7eb', scaleMargins: { top: 0.1, bottom: 0.1 } },
|
rightPriceScale: { borderColor: '#e5e7eb', scaleMargins: { top: 0.1, bottom: 0.1 } },
|
||||||
timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: false },
|
timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: false },
|
||||||
|
handleScroll: false,
|
||||||
|
handleScale: false,
|
||||||
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
||||||
})
|
})
|
||||||
ensureChartSeries()
|
ensureChartSeries()
|
||||||
@ -1358,10 +1170,20 @@ async function loadChartFromApi(marketParam: string, range: string): Promise<Cha
|
|||||||
range,
|
range,
|
||||||
ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined,
|
ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined,
|
||||||
)
|
)
|
||||||
|
// 增加每次请求的 points 数量,避免图表在较长时间粒度下出现“最新数据提前被截断”
|
||||||
|
const pageSizeByRange: Record<string, number> = {
|
||||||
|
'1H': 200,
|
||||||
|
'6H': 600,
|
||||||
|
'1D': 1200,
|
||||||
|
'1W': 2000,
|
||||||
|
'1M': 2000,
|
||||||
|
ALL: 2500,
|
||||||
|
}
|
||||||
|
const pageSize = pageSizeByRange[range] ?? 500
|
||||||
const res = await getPmPriceHistoryPublic({
|
const res = await getPmPriceHistoryPublic({
|
||||||
market: marketParam,
|
market: marketParam,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 500,
|
pageSize,
|
||||||
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
|
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
|
||||||
})
|
})
|
||||||
const list = res.data?.list ?? []
|
const list = res.data?.list ?? []
|
||||||
@ -1390,8 +1212,9 @@ function applyCryptoRealtimePoint(point: [number, number]) {
|
|||||||
const last = list[list.length - 1]
|
const last = list[list.length - 1]
|
||||||
const minuteStart = Math.floor(ts / MINUTE_MS) * MINUTE_MS
|
const minuteStart = Math.floor(ts / MINUTE_MS) * MINUTE_MS
|
||||||
const sameMinute = last && Math.floor(last[0] / MINUTE_MS) === Math.floor(ts / MINUTE_MS)
|
const sameMinute = last && Math.floor(last[0] / MINUTE_MS) === Math.floor(ts / MINUTE_MS)
|
||||||
const max =
|
const baseMax = { '1H': 60, '6H': 360, '1D': 1440, '1W': 1680, '1M': 43200, ALL: 10080 }[range] ?? 60
|
||||||
{ '1H': 60, '6H': 360, '1D': 1440, '1W': 1680, '1M': 43200, ALL: 10080 }[range] ?? 60
|
const extra = Math.min(200, Math.round(baseMax * 0.1))
|
||||||
|
const max = baseMax + extra
|
||||||
if (sameMinute) {
|
if (sameMinute) {
|
||||||
list[list.length - 1] = [last![0], price]
|
list[list.length - 1] = [last![0], price]
|
||||||
data.value = list.slice(-max)
|
data.value = list.slice(-max)
|
||||||
@ -1983,7 +1806,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rules-pane {
|
.rules-pane {
|
||||||
padding: 4px 0;
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rules-section {
|
.rules-section {
|
||||||
|
|||||||
@ -1265,6 +1265,8 @@ function initPlChart() {
|
|||||||
grid: { vertLines: { visible: false }, horzLines: { color: '#f3f4f6' } },
|
grid: { vertLines: { visible: false }, horzLines: { color: '#f3f4f6' } },
|
||||||
rightPriceScale: { borderVisible: false },
|
rightPriceScale: { borderVisible: false },
|
||||||
timeScale: { borderVisible: false },
|
timeScale: { borderVisible: false },
|
||||||
|
handleScroll: false,
|
||||||
|
handleScale: false,
|
||||||
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
||||||
})
|
})
|
||||||
const lastVal = last != null ? last[1] : 0
|
const lastVal = last != null ? last[1] : 0
|
||||||
@ -1638,6 +1640,10 @@ async function submitAuthorize() {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.withdrawals-mobile-list .withdrawal-mobile-card.design-withdraw-card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.positions-mobile-list .position-mobile-card.design-pos-card:hover,
|
.positions-mobile-list .position-mobile-card.design-pos-card:hover,
|
||||||
.orders-mobile-list .order-mobile-card.design-order-card:hover,
|
.orders-mobile-list .order-mobile-card.design-order-card:hover,
|
||||||
.history-mobile-list .history-mobile-card.design-trade-card:hover,
|
.history-mobile-list .history-mobile-card.design-trade-card:hover,
|
||||||
@ -2522,12 +2528,11 @@ async function submitAuthorize() {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 提现记录 */
|
/* 提现记录:与 history 一致,用 margin-bottom 控制间距,不用 gap */
|
||||||
.withdrawals-mobile-list {
|
.withdrawals-mobile-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
padding: 0;
|
||||||
padding: 16px;
|
|
||||||
}
|
}
|
||||||
.withdrawal-mobile-card {
|
.withdrawal-mobile-card {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user