优化:搜索页面优化
This commit is contained in:
parent
f1dfbf7d80
commit
51a5dcc89d
@ -10,7 +10,7 @@
|
||||
|
||||
- `isCryptoEvent`:判断事件是否为加密货币类型(通过 tags/series slug、ticker)
|
||||
- `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 保活
|
||||
|
||||
## 币种映射
|
||||
|
||||
@ -8,7 +8,7 @@ Event(预测市场事件)相关接口与类型定义,对接 XTrader API
|
||||
|
||||
## 核心能力
|
||||
|
||||
- `getPmEventPublic`:分页获取公开事件列表(无需鉴权);请求时固定传入 **startDateMax**、**endDateMin** 为当前时间戳(Unix 秒),**startDateMin**、**endDateMax** 不传
|
||||
- `getPmEventPublic`:分页获取公开事件列表(无需鉴权);请求时固定传入 **startDateMax**、**endDateMin** 为当前时间戳(Unix 秒),**startDateMin**、**endDateMax** 不传;当后端支持入参 `q` 时,`q` 用作搜索关键词
|
||||
- `findPmEvent`:按 id/slug 查询事件详情(需鉴权)
|
||||
- `mapEventItemToCard`:将 `PmEventListItem` 转为 `EventCardItem`(供 MarketCard 使用)
|
||||
- 内存缓存:`getEventListCache`、`setEventListCache`、`clearEventListCache`,用于列表切换页面时复用
|
||||
@ -34,7 +34,7 @@ import {
|
||||
} 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)
|
||||
|
||||
// 获取详情(需鉴权)
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
- `toLwcData`:将 `[timestamp_ms, value][]` 转为 `{ time: UTCTimestamp, value: number }[]`,输入为毫秒(>=1e12)时自动除以 1000 转为秒,按时间升序排序并去重(同时间戳保留最新价格),时间轴显示用户当地时间
|
||||
- `toLwcPoint`:将单点 `[timestamp_ms, value]` 转为 `{ time, value }`,用于 `series.update()` 增量更新
|
||||
- `createLineChart`:创建基础折线图实例(`attributionLogo: false` 隐藏 TradingView 图标)
|
||||
- `createLineChart`:创建基础折线图实例(`attributionLogo: false` 隐藏 TradingView 图标),并禁用拖动/滚轮缩放交互(`handleScroll/handleScale=false`)
|
||||
- `addLineSeries`:添加折线系列(支持 percent/price 格式)
|
||||
- `addAreaSeries`:添加面积系列(带渐变)
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
- 顶部导航栏:返回、TestMarket 标题、Login 或余额+头像入口
|
||||
- 头像入口:登录态点击头像直接跳转 `/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` 控制展示
|
||||
- 用户名:`nickName` 或 `userName` 显示在头像左侧(有值时)
|
||||
- 挂载时与 `isLoggedIn` 变为 true 时:拉取用户信息与余额(`router.isReady()` + `nextTick` 后执行),确保钱包登录、刷新页面后头像和用户名正确显示
|
||||
|
||||
@ -22,8 +22,10 @@ Vue Router 配置,定义路由表与滚动行为。
|
||||
|
||||
## 滚动行为
|
||||
|
||||
- 有 `savedPosition` 且来自已命名路由:恢复位置
|
||||
- 有 `to.hash`:滚动到锚点
|
||||
滚动目标为 `[data-main-scroll]`(App.vue 内主滚动容器),非 window:
|
||||
|
||||
- 有 `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 多支队伍),支持选择并跳转交易详情。
|
||||
|
||||
- **多市场折线图**:按市场数量依次调用 `getPmPriceHistoryPublic`,每个市场使用 `clobTokenIds[0]`(YES token)作为 `market` 参数,展示多条分时曲线
|
||||
- 为避免“最新数据提前被截断”,不同时间范围会使用更大的 `pageSize` 拉取更多点
|
||||
- 图表交互已禁用拖动和滚轮缩放(`handleScroll/handleScale=false`)
|
||||
- **时间范围**:1H / 6H / 1D / 1W / 1M / ALL,与 TradeDetail 一致
|
||||
|
||||
## 使用方式
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
|
||||
- **分类行分隔**:`.home-category-layer1-row` 底部有淡色投影,增强与下方内容的层次感
|
||||
|
||||
- **第一层操作按钮**:已移除右侧的搜索/筛选图标;搜索浮层通过 `initialSearchExpanded`(供 `/search` 路由使用)控制展开
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
|
||||
@ -14,20 +14,21 @@
|
||||
|
||||
- 访问路由 `/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` 结构:
|
||||
- 顶部标题:`搜索`
|
||||
- 搜索框:真实输入框,占位文案 `搜索市场、话题、地址`
|
||||
- 搜索记录卡:3 条示例记录,每行右侧带关闭图标
|
||||
- 推荐标签卡:`ETH`、`科技股`、`总统大选`
|
||||
- 顶部标题、占位、记录/推荐标题等取自 i18n
|
||||
- 搜索记录卡:从本地真实读取的搜索历史(最多 10 条),每行右侧带关闭图标
|
||||
- 推荐标签卡:三项标签文案同样走 i18n
|
||||
- 搜索后切换到 `mN9t2` 结构:
|
||||
- 顶部同样保留标题与搜索框
|
||||
- 下方展示结果卡片列表(图标、标题、百分比、时间)
|
||||
- 下方展示结果卡片列表(图标、标题、百分比、时间);无结果时展示空状态文案
|
||||
- 切换结果态时不会因滚动条变化导致顶部/底部导航轻微位移
|
||||
- 点击某条结果:`mapEventItemToCard` 后按与 `MarketCard` 相同规则跳转——**多 market** 进 `/event/:id/markets`(可带 `slug` query);**单 market** 进 `/trade-detail/:id`(query 含 `title`、`marketInfo`、`chance`、`marketId`、`slug` 等与首页一致)
|
||||
|
||||
## 扩展方式
|
||||
|
||||
1. **接入真实搜索记录**:将 `searchRecords` 替换为用户历史接口数据。
|
||||
2. **接入搜索行为**:将搜索框改为可输入组件并绑定查询 API。
|
||||
3. **标签联动搜索**:点击标签触发关键词搜索并跳转到结果页。
|
||||
4. **响应式优化**:可按断点进一步细分字号与间距,但保持 1:1 视觉层级。
|
||||
1. **结果跳转**:已与 `MarketCard` 对齐;若后端字段变化可只调 `mapEventItemToCard`
|
||||
2. **标签联动搜索**:点击标签触发关键词搜索并跳转到结果页。
|
||||
3. **响应式优化**:可按断点进一步细分字号与间距,但保持 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` 转为展示值
|
||||
- 订单簿外层容器(`order-book-card`)已去除重复外边框,仅保留 `OrderBook` 组件单层描边,避免视觉上出现双层边线
|
||||
- 交易:`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` 消息
|
||||
2. **分时图**:Yes/NO 折线图仅使用真实接口 `getPmPriceHistoryPublic`,无模拟数据与定时器;事件详情加载完成后自动请求并展示,无 marketId 或接口无数据时展示空图;加密货币事件已支持 YES/NO 分时与加密货币价格图切换(`src/api/cryptoChart.ts`);加密货币实时推送时使用 `series.update()` 增量更新单点,配合 `LastPriceAnimationMode.OnDataUpdate` 实现新数据加入的过渡动画;30S 范围在未过滤掉旧点时同样使用 `update()` 实现平滑动画
|
||||
3. **Comments**:对接评论接口,替换 placeholder
|
||||
4. **Top Holders**:对接持仓接口
|
||||
5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录
|
||||
4. **Top Holders**:当前页面已移除显示(仅保留 Rules)
|
||||
5. **Activity**:当前页面已移除显示(仅保留 Rules)
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
## 核心能力
|
||||
|
||||
- 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(提现记录)
|
||||
- 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')`、取消按钮
|
||||
|
||||
43
src/App.vue
43
src/App.vue
@ -101,13 +101,15 @@ watch(
|
||||
</template>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
<v-main>
|
||||
<div class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="['Home']">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
<v-main class="app-main">
|
||||
<div class="app-main-scroll" data-main-scroll>
|
||||
<div class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="['Home']">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</v-main>
|
||||
|
||||
@ -147,6 +149,19 @@ watch(
|
||||
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 {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
@ -172,9 +187,12 @@ watch(
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
/* 底部导航:整条栏上方淡投影 */
|
||||
/* 底部导航:整条栏上方淡投影;z-index 确保覆盖滚动条,显示在滚动条上层 */
|
||||
:deep(.v-bottom-navigation) {
|
||||
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) {
|
||||
@ -208,13 +226,18 @@ watch(
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 全局背景:统一为 rgb(252,252,252) */
|
||||
/* 全局:禁止 body 滚动,由 app-main-scroll 内部滚动,滚动条不覆盖底部导航 */
|
||||
:global(html),
|
||||
:global(body) {
|
||||
background: rgb(252, 252, 252);
|
||||
scrollbar-gutter: stable;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.v-application) {
|
||||
background: rgb(252, 252, 252);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -235,10 +235,14 @@ export async function fetchCryptoChart(
|
||||
const range = params.range
|
||||
const is30S = range === '30S'
|
||||
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 {
|
||||
if (binanceSymbol) {
|
||||
if (useBinance) {
|
||||
let points = await fetchBinanceKlines(binanceSymbol, limit)
|
||||
if (is30S) {
|
||||
const cutoff = Date.now() - THIRTY_SEC_MS
|
||||
|
||||
@ -115,6 +115,11 @@ export interface PmEventListResponse {
|
||||
export interface GetPmEventListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
/**
|
||||
* 搜索关键词:当后端提供入参 `q` 时,代表搜索模式
|
||||
* (保留 keyword 以兼容旧调用方)
|
||||
*/
|
||||
q?: string
|
||||
keyword?: string
|
||||
/** 创建时间范围,如 ['2025-01-01', '2025-12-31'] */
|
||||
createdAtRange?: string[]
|
||||
@ -132,7 +137,7 @@ export interface GetPmEventListParams {
|
||||
* 分页获取 Event 列表(公开接口,不需要鉴权)
|
||||
* GET /PmEvent/getPmEventPublic
|
||||
*
|
||||
* Query: page, pageSize, keyword, createdAtRange, tagIds
|
||||
* Query: page, pageSize, keyword 或 q(搜索), createdAtRange, tagIds
|
||||
* doc.json: paths["/PmEvent/getPmEventPublic"].get.parameters
|
||||
*/
|
||||
export async function getPmEventPublic(
|
||||
@ -141,6 +146,7 @@ export async function getPmEventPublic(
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
q,
|
||||
keyword,
|
||||
createdAtRange,
|
||||
tagIds,
|
||||
@ -152,7 +158,8 @@ export async function getPmEventPublic(
|
||||
const query = buildQuery({
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
q,
|
||||
keyword: q ? undefined : keyword,
|
||||
createdAtRange,
|
||||
tagIds,
|
||||
active: active ? 'true' : 'false',
|
||||
|
||||
@ -90,6 +90,8 @@ export function createLineChart(
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
},
|
||||
handleScroll: false,
|
||||
handleScale: false,
|
||||
crosshair: {
|
||||
vertLine: { labelVisible: false },
|
||||
horzLine: { labelVisible: true },
|
||||
|
||||
@ -88,6 +88,23 @@
|
||||
"searchPlaceholder": "Search",
|
||||
"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": {
|
||||
"requestFailed": "Request failed",
|
||||
"loadFailed": "Load failed",
|
||||
|
||||
@ -88,6 +88,23 @@
|
||||
"searchPlaceholder": "検索",
|
||||
"loadMore": "もっと読み込む"
|
||||
},
|
||||
"searchPage": {
|
||||
"title": "検索",
|
||||
"placeholder": "マーケット、トピック、住所で検索",
|
||||
"recordsTitle": "検索履歴",
|
||||
"recommendTitle": "おすすめタグ",
|
||||
"searching": "検索中…",
|
||||
"loadMore": "さらに読み込み中…",
|
||||
"noResults": "該当する結果がありません",
|
||||
"errorFailed": "検索に失敗しました。しばらくしてから再度お試しください。",
|
||||
"untitledEvent": "無題のイベント",
|
||||
"timeMinutes": "{n}分前",
|
||||
"timeHours": "{n}時間前",
|
||||
"timeDays": "{n}日前",
|
||||
"tagEth": "ETH",
|
||||
"tagTech": "ハイテク株",
|
||||
"tagElection": "大統領選"
|
||||
},
|
||||
"error": {
|
||||
"requestFailed": "リクエストに失敗しました",
|
||||
"loadFailed": "読み込みに失敗しました",
|
||||
|
||||
@ -88,6 +88,23 @@
|
||||
"searchPlaceholder": "검색",
|
||||
"loadMore": "더 불러오기"
|
||||
},
|
||||
"searchPage": {
|
||||
"title": "검색",
|
||||
"placeholder": "마켓, 주제, 주소 검색",
|
||||
"recordsTitle": "검색 기록",
|
||||
"recommendTitle": "추천 태그",
|
||||
"searching": "검색 중…",
|
||||
"loadMore": "더 불러오는 중…",
|
||||
"noResults": "결과가 없습니다",
|
||||
"errorFailed": "검색에 실패했습니다. 잠시 후 다시 시도하세요.",
|
||||
"untitledEvent": "제목 없는 이벤트",
|
||||
"timeMinutes": "{n}분 전",
|
||||
"timeHours": "{n}시간 전",
|
||||
"timeDays": "{n}일 전",
|
||||
"tagEth": "ETH",
|
||||
"tagTech": "테크주",
|
||||
"tagElection": "대선"
|
||||
},
|
||||
"error": {
|
||||
"requestFailed": "요청 실패",
|
||||
"loadFailed": "로드 실패",
|
||||
|
||||
@ -88,6 +88,23 @@
|
||||
"searchPlaceholder": "Search",
|
||||
"loadMore": "加载更多"
|
||||
},
|
||||
"searchPage": {
|
||||
"title": "搜索",
|
||||
"placeholder": "搜索市场、话题、地址",
|
||||
"recordsTitle": "搜索记录",
|
||||
"recommendTitle": "推荐标签",
|
||||
"searching": "搜索中...",
|
||||
"loadMore": "加载更多…",
|
||||
"noResults": "暂无相关结果",
|
||||
"errorFailed": "搜索失败,请稍后重试",
|
||||
"untitledEvent": "未命名事件",
|
||||
"timeMinutes": "{n} 分钟前",
|
||||
"timeHours": "{n} 小时前",
|
||||
"timeDays": "{n} 天前",
|
||||
"tagEth": "ETH",
|
||||
"tagTech": "科技股",
|
||||
"tagElection": "总统大选"
|
||||
},
|
||||
"error": {
|
||||
"requestFailed": "请求失败",
|
||||
"loadFailed": "加载失败",
|
||||
|
||||
@ -88,6 +88,23 @@
|
||||
"searchPlaceholder": "Search",
|
||||
"loadMore": "載入更多"
|
||||
},
|
||||
"searchPage": {
|
||||
"title": "搜尋",
|
||||
"placeholder": "搜尋市場、話題、地址",
|
||||
"recordsTitle": "搜尋紀錄",
|
||||
"recommendTitle": "推薦標籤",
|
||||
"searching": "搜尋中…",
|
||||
"loadMore": "載入更多…",
|
||||
"noResults": "暫無相關結果",
|
||||
"errorFailed": "搜尋失敗,請稍後再試",
|
||||
"untitledEvent": "未命名事件",
|
||||
"timeMinutes": "{n} 分鐘前",
|
||||
"timeHours": "{n} 小時前",
|
||||
"timeDays": "{n} 天前",
|
||||
"tagEth": "ETH",
|
||||
"tagTech": "科技股",
|
||||
"tagElection": "總統大選"
|
||||
},
|
||||
"error": {
|
||||
"requestFailed": "請求失敗",
|
||||
"loadFailed": "載入失敗",
|
||||
|
||||
@ -59,8 +59,22 @@ const router = createRouter({
|
||||
},
|
||||
],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition && from?.name) return savedPosition
|
||||
if (to.hash) return { el: to.hash }
|
||||
const el = document.querySelector('[data-main-scroll]')
|
||||
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 }
|
||||
},
|
||||
})
|
||||
|
||||
@ -366,6 +366,16 @@ const MOBILE_BREAKPOINT = 600
|
||||
async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
|
||||
const list = markets.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[] = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const market = list[i]
|
||||
@ -384,7 +394,7 @@ async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
|
||||
const res = await getPmPriceHistoryPublic({
|
||||
market: yesTokenId,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
pageSize,
|
||||
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
|
||||
})
|
||||
const points = priceHistoryToChartData(res.data?.list ?? [])
|
||||
@ -435,6 +445,8 @@ async function initChart() {
|
||||
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
|
||||
rightPriceScale: { borderColor: '#e5e7eb', scaleMargins: { top: 0.1, bottom: 0.1 } },
|
||||
timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: false },
|
||||
handleScroll: false,
|
||||
handleScale: false,
|
||||
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
||||
})
|
||||
setChartSeries(chartData.value)
|
||||
|
||||
@ -12,27 +12,6 @@
|
||||
{{ item.label }}
|
||||
</v-tab>
|
||||
</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>
|
||||
<!-- 搜索展开时:浮层输入框 + 历史记录 -->
|
||||
<transition name="home-search-overlay">
|
||||
@ -640,29 +619,35 @@ function loadMore() {
|
||||
})
|
||||
}
|
||||
|
||||
function getMainScrollEl(): Element | null {
|
||||
return document.querySelector('[data-main-scroll]')
|
||||
}
|
||||
|
||||
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()
|
||||
const el = getMainScrollEl()
|
||||
if (!el) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = el
|
||||
if (scrollHeight - scrollTop - clientHeight < SCROLL_LOAD_THRESHOLD) loadMore()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.initialSearchExpanded) expandSearch()
|
||||
loadCategory()
|
||||
nextTick(() => {
|
||||
const scrollEl = getMainScrollEl()
|
||||
const sentinel = sentinelRef.value
|
||||
if (sentinel) {
|
||||
if (sentinel && scrollEl) {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries[0]?.isIntersecting) return
|
||||
loadMore()
|
||||
},
|
||||
{ root: null, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 },
|
||||
{ root: scrollEl, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
}
|
||||
window.addEventListener('scroll', checkScrollLoad, { passive: true })
|
||||
scrollEl?.addEventListener('scroll', checkScrollLoad, { passive: true })
|
||||
const listEl = listRef.value
|
||||
if (listEl) {
|
||||
updateGridColumns()
|
||||
@ -680,7 +665,7 @@ function removeScrollListeners() {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
window.removeEventListener('scroll', checkScrollLoad)
|
||||
getMainScrollEl()?.removeEventListener('scroll', checkScrollLoad)
|
||||
}
|
||||
|
||||
// keep-alive 时离开页面不会触发 onUnmounted,需在 onDeactivated 移除监听,否则详情页滚动到底会误触发 loadMore
|
||||
@ -690,17 +675,18 @@ onUnmounted(removeScrollListeners)
|
||||
// 从详情页返回时重新注册监听(仅当 observer 已被 removeScrollListeners 清空时)
|
||||
onActivated(() => {
|
||||
nextTick(() => {
|
||||
const scrollEl = getMainScrollEl()
|
||||
const sentinel = sentinelRef.value
|
||||
if (sentinel && !observer) {
|
||||
if (sentinel && scrollEl && !observer) {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries[0]?.isIntersecting) return
|
||||
loadMore()
|
||||
},
|
||||
{ root: null, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 },
|
||||
{ root: scrollEl, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
window.addEventListener('scroll', checkScrollLoad, { passive: true })
|
||||
scrollEl.addEventListener('scroll', checkScrollLoad, { passive: true })
|
||||
}
|
||||
const listEl = listRef.value
|
||||
if (listEl && !resizeObserver) {
|
||||
@ -831,13 +817,13 @@ onActivated(() => {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
/* 第一层:置顶、全宽 */
|
||||
/* 第一层:置顶、全宽;sticky 参照 app-main-scroll,top:0 贴容器顶(即 app-bar 下方) */
|
||||
.home-category-layer1-wrap {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
margin-left: calc(50% - 50vw);
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: white;
|
||||
/* box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); */
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="search-page">
|
||||
<div class="search-screen">
|
||||
<h1 class="search-header">搜索</h1>
|
||||
<h1 class="search-header">{{ t('searchPage.title') }}</h1>
|
||||
|
||||
<form class="search-box" @submit.prevent="onSearch">
|
||||
<input
|
||||
@ -11,13 +11,13 @@
|
||||
enterkeyhint="search"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
placeholder="搜索市场、话题、地址"
|
||||
:placeholder="t('searchPage.placeholder')"
|
||||
@keydown.enter.prevent="onSearch"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<template v-if="showResults">
|
||||
<section class="result-list">
|
||||
<section v-if="resultItems.length > 0" class="result-list">
|
||||
<article
|
||||
v-for="item in resultItems"
|
||||
:key="item.id"
|
||||
@ -25,7 +25,10 @@
|
||||
@click="openResult(item)"
|
||||
>
|
||||
<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>
|
||||
<div class="result-right">
|
||||
@ -33,25 +36,29 @@
|
||||
<div class="result-time">{{ item.timeAgo }}</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<section class="card records-card">
|
||||
<h2 class="card-title">搜索记录</h2>
|
||||
<h2 class="card-title">{{ t('searchPage.recordsTitle') }}</h2>
|
||||
<button
|
||||
v-for="record in searchRecords"
|
||||
v-for="(record, idx) in searchRecords"
|
||||
:key="record"
|
||||
type="button"
|
||||
class="record-row"
|
||||
@click="useRecord(record)"
|
||||
>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="card tags-card">
|
||||
<h2 class="card-title">推荐标签</h2>
|
||||
<h2 class="card-title">{{ t('searchPage.recommendTitle') }}</h2>
|
||||
<div class="tag-row">
|
||||
<button v-for="tag in recommendTags" :key="tag" type="button" class="tag-chip" @click="useTag(tag)">
|
||||
{{ tag }}
|
||||
@ -59,60 +66,105 @@
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<div v-if="searching" class="search-loading">搜索中...</div>
|
||||
<div v-if="searchError" class="search-error">{{ searchError }}</div>
|
||||
<div v-if="searching" class="search-loading">{{ t('searchPage.searching') }}</div>
|
||||
<div v-else-if="loadingMore" class="search-loading">{{ t('searchPage.loadMore') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { getPmEventPublic, type PmEventListItem } from '../api/event'
|
||||
import { computed, nextTick, ref, watch, onUnmounted } from 'vue'
|
||||
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 {
|
||||
id: string
|
||||
title: string
|
||||
iconText: string
|
||||
iconClass: string
|
||||
imageUrl?: string
|
||||
percent: string
|
||||
pctClass: string
|
||||
timeAgo: string
|
||||
raw: PmEventListItem
|
||||
}
|
||||
|
||||
const searchRecords = ref(['BTC ETF approval odds', 'US election winner', 'ETH above $4k'])
|
||||
const recommendTags = ref(['ETH', '科技股', '总统大选'])
|
||||
const { t } = useI18n()
|
||||
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 searching = ref(false)
|
||||
const searchError = ref('')
|
||||
const hasSearched = ref(false)
|
||||
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) {
|
||||
searchRecords.value = searchRecords.value.filter((item) => item !== record)
|
||||
const showResults = computed(
|
||||
() => 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 {
|
||||
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()
|
||||
if (!Number.isFinite(time)) return '1d ago'
|
||||
if (!Number.isFinite(time)) return t('searchPage.timeDays', { n: 1 })
|
||||
const diff = Date.now() - time
|
||||
if (diff < 60 * 60 * 1000) return `${Math.max(1, Math.floor(diff / (60 * 1000)))}m ago`
|
||||
if (diff < 24 * 60 * 60 * 1000) return `${Math.max(1, Math.floor(diff / (60 * 60 * 1000)))}h ago`
|
||||
return `${Math.max(1, Math.floor(diff / (24 * 60 * 60 * 1000)))}d ago`
|
||||
if (diff < 60 * 60 * 1000)
|
||||
return t('searchPage.timeMinutes', { n: Math.max(1, Math.floor(diff / (60 * 1000))) })
|
||||
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 {
|
||||
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 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 percentNum = Number.isFinite(chance) ? Math.round(chance * 100) : 50
|
||||
return {
|
||||
@ -120,6 +172,7 @@ function mapEventToResultItem(event: PmEventListItem, idx: number): SearchResult
|
||||
title,
|
||||
iconText,
|
||||
iconClass,
|
||||
imageUrl,
|
||||
percent: `${percentNum}%`,
|
||||
pctClass: idx === 0 ? 'pct-primary' : 'pct-default',
|
||||
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() {
|
||||
const keyword = searchKeyword.value.trim()
|
||||
if (!keyword || searching.value) return
|
||||
|
||||
clearDebounce()
|
||||
searching.value = true
|
||||
searchError.value = ''
|
||||
|
||||
// 写入本地真实搜索历史(最多保留 10 条)
|
||||
searchHistory.add(keyword)
|
||||
searchPage.value = 1
|
||||
try {
|
||||
const res = await getPmEventPublic({ page: 1, pageSize: 10, keyword })
|
||||
const list = res.data?.list ?? []
|
||||
resultItems.value = (list.length > 0 ? list : []).slice(0, 12).map(mapEventToResultItem)
|
||||
if (resultItems.value.length === 0) {
|
||||
resultItems.value = buildFallbackResults()
|
||||
const res = await getPmEventPublic({ page: 1, pageSize: PAGE_SIZE, q: keyword })
|
||||
if (res.code !== 0 && res.code !== 200) {
|
||||
resultItems.value = []
|
||||
searchTotal.value = 0
|
||||
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
|
||||
} catch {
|
||||
resultItems.value = buildFallbackResults()
|
||||
resultItems.value = []
|
||||
searchTotal.value = 0
|
||||
hasSearched.value = true
|
||||
searchError.value = '搜索失败,已展示示例结果'
|
||||
searchError.value = t('searchPage.errorFailed')
|
||||
} finally {
|
||||
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) {
|
||||
ignoreNextDebounceOnce = true
|
||||
clearDebounce()
|
||||
searchKeyword.value = record
|
||||
onSearch()
|
||||
}
|
||||
|
||||
function useTag(tag: string) {
|
||||
ignoreNextDebounceOnce = true
|
||||
clearDebounce()
|
||||
searchKeyword.value = tag
|
||||
onSearch()
|
||||
}
|
||||
|
||||
function openResult(_item: SearchResultItem) {
|
||||
// Placeholder: can route to detail page later.
|
||||
function openResult(item: SearchResultItem) {
|
||||
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>
|
||||
|
||||
@ -323,6 +442,13 @@ function openResult(_item: SearchResultItem) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.load-more-sentinel {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
@ -348,12 +474,21 @@ function openResult(_item: SearchResultItem) {
|
||||
.result-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
min-width: 38px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-icon-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-dark {
|
||||
@ -418,4 +553,27 @@ function openResult(_item: SearchResultItem) {
|
||||
.search-error {
|
||||
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>
|
||||
|
||||
@ -184,99 +184,31 @@
|
||||
|
||||
<!-- Comments / Top Holders / Activity(与左侧图表、订单簿同宽) -->
|
||||
<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
|
||||
v-if="!eventDetail?.description && !eventDetail?.resolutionSource"
|
||||
class="placeholder-pane"
|
||||
<div class="rules-pane">
|
||||
<div v-if="!eventDetail?.description && !eventDetail?.resolutionSource" class="placeholder-pane">
|
||||
{{ t('activity.rulesEmpty') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="eventDetail?.description" class="rules-section">
|
||||
<h3 class="rules-title">{{ t('activity.rulesDescription') }}</h3>
|
||||
<div class="rules-text">{{ eventDetail.description }}</div>
|
||||
</div>
|
||||
<div v-if="eventDetail?.resolutionSource" class="rules-section">
|
||||
<h3 class="rules-title">{{ t('activity.rulesSource') }}</h3>
|
||||
<a
|
||||
v-if="isResolutionSourceUrl"
|
||||
:href="eventDetail.resolutionSource"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rules-link"
|
||||
>
|
||||
{{ t('activity.rulesEmpty') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="eventDetail?.description" class="rules-section">
|
||||
<h3 class="rules-title">{{ t('activity.rulesDescription') }}</h3>
|
||||
<div class="rules-text">{{ eventDetail.description }}</div>
|
||||
</div>
|
||||
<div v-if="eventDetail?.resolutionSource" class="rules-section">
|
||||
<h3 class="rules-title">{{ t('activity.rulesSource') }}</h3>
|
||||
<a
|
||||
v-if="isResolutionSourceUrl"
|
||||
:href="eventDetail.resolutionSource"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rules-link"
|
||||
>
|
||||
{{ eventDetail.resolutionSource }}
|
||||
<v-icon size="14">mdi-open-in-new</v-icon>
|
||||
</a>
|
||||
<div v-else class="rules-text">{{ eventDetail.resolutionSource }}</div>
|
||||
</div>
|
||||
</template>
|
||||
{{ eventDetail.resolutionSource }}
|
||||
<v-icon size="14">mdi-open-in-new</v-icon>
|
||||
</a>
|
||||
<div v-else class="rules-text">{{ eventDetail.resolutionSource }}</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>
|
||||
</template>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
@ -420,7 +352,7 @@ import {
|
||||
type OpenOrderDisplayItem,
|
||||
} from '../api/order'
|
||||
import { cancelOrder as apiCancelOrder } from '../api/order'
|
||||
import type { ChartDataPoint, ChartTimeRange } from '../api/chart'
|
||||
import type { ChartDataPoint } from '../api/chart'
|
||||
import {
|
||||
getPmPriceHistoryPublic,
|
||||
getTimeRangeSeconds,
|
||||
@ -787,20 +719,6 @@ function connectClob(tokenIds: string[]) {
|
||||
const tokenIdx = side === 'buy' ? 0 : 1
|
||||
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
|
||||
@ -1150,115 +1068,7 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Comments / Top Holders / Activity
|
||||
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) })
|
||||
}
|
||||
// Rules(仅保留 Rules 面板)
|
||||
|
||||
// 时间粒度
|
||||
const selectedTimeRange = ref('1D')
|
||||
@ -1346,6 +1156,8 @@ function initChart() {
|
||||
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
|
||||
rightPriceScale: { borderColor: '#e5e7eb', scaleMargins: { top: 0.1, bottom: 0.1 } },
|
||||
timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: false },
|
||||
handleScroll: false,
|
||||
handleScale: false,
|
||||
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
||||
})
|
||||
ensureChartSeries()
|
||||
@ -1358,10 +1170,20 @@ async function loadChartFromApi(marketParam: string, range: string): Promise<Cha
|
||||
range,
|
||||
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({
|
||||
market: marketParam,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
pageSize,
|
||||
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
|
||||
})
|
||||
const list = res.data?.list ?? []
|
||||
@ -1390,8 +1212,9 @@ function applyCryptoRealtimePoint(point: [number, number]) {
|
||||
const last = list[list.length - 1]
|
||||
const minuteStart = Math.floor(ts / MINUTE_MS) * MINUTE_MS
|
||||
const sameMinute = last && Math.floor(last[0] / MINUTE_MS) === Math.floor(ts / MINUTE_MS)
|
||||
const max =
|
||||
{ '1H': 60, '6H': 360, '1D': 1440, '1W': 1680, '1M': 43200, ALL: 10080 }[range] ?? 60
|
||||
const baseMax = { '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) {
|
||||
list[list.length - 1] = [last![0], price]
|
||||
data.value = list.slice(-max)
|
||||
@ -1983,7 +1806,10 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.rules-pane {
|
||||
padding: 4px 0;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.rules-section {
|
||||
|
||||
@ -1265,6 +1265,8 @@ function initPlChart() {
|
||||
grid: { vertLines: { visible: false }, horzLines: { color: '#f3f4f6' } },
|
||||
rightPriceScale: { borderVisible: false },
|
||||
timeScale: { borderVisible: false },
|
||||
handleScroll: false,
|
||||
handleScale: false,
|
||||
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
||||
})
|
||||
const lastVal = last != null ? last[1] : 0
|
||||
@ -1638,6 +1640,10 @@ async function submitAuthorize() {
|
||||
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,
|
||||
.orders-mobile-list .order-mobile-card.design-order-card:hover,
|
||||
.history-mobile-list .history-mobile-card.design-trade-card:hover,
|
||||
@ -2522,12 +2528,11 @@ async function submitAuthorize() {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 提现记录 */
|
||||
/* 提现记录:与 history 一致,用 margin-bottom 控制间距,不用 gap */
|
||||
.withdrawals-mobile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
.withdrawal-mobile-card {
|
||||
padding: 12px 16px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user