优化:搜索页面优化

This commit is contained in:
ivan 2026-03-20 19:01:14 +08:00
parent f1dfbf7d80
commit 51a5dcc89d
26 changed files with 550 additions and 365 deletions

View File

@ -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 **归集交易**aggTradeWebSocket实时推送每笔成交比 K 线(约 1s更细内部 80ms 节流 + RAF 批处理,避免频繁 setOption 卡顿;支持 PING/PONG 保活
## 币种映射

View File

@ -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)
// 获取详情(需鉴权)

View File

@ -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`:添加面积系列(带渐变)

View File

@ -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` 后执行),确保钱包登录、刷新页面后头像和用户名正确显示

View File

@ -22,8 +22,10 @@ Vue Router 配置,定义路由表与滚动行为。
## 滚动行为
- 有 `savedPosition` 且来自已命名路由:恢复位置
- 有 `to.hash`:滚动到锚点
滚动目标为 `[data-main-scroll]`App.vue 内主滚动容器),非 window
- 有 `savedPosition` 且来自已命名路由:恢复该容器的 `scrollTop`
- 有 `to.hash`:滚动到锚点元素
- 否则:滚动到顶部
## 扩展方式

View File

@ -0,0 +1,55 @@
# 滚动条覆盖底部导航栏 - 原因分析与解决方案
> **已解决**:将滚动从 viewport 移至 `app-main-scroll` 内部,滚动条不再覆盖底部导航。
## 现象
底部导航栏v-bottom-navigation被页面滚动条遮挡即使设置 `z-index: 1100``transform: translateZ(0)` 仍无法解决。
## 根本原因
### 1. 滚动发生在 viewportbody/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 底部,不再被滚动条覆盖。

View File

@ -8,6 +8,8 @@
事件下的市场列表页,展示某个 Event 的多个 Market如 NFL 多支队伍),支持选择并跳转交易详情。
- **多市场折线图**:按市场数量依次调用 `getPmPriceHistoryPublic`,每个市场使用 `clobTokenIds[0]`YES token作为 `market` 参数,展示多条分时曲线
- 为避免“最新数据提前被截断”,不同时间范围会使用更大的 `pageSize` 拉取更多点
- 图表交互已禁用拖动和滚轮缩放(`handleScroll/handleScale=false`
- **时间范围**1H / 6H / 1D / 1W / 1M / ALL与 TradeDetail 一致
## 使用方式

View File

@ -18,6 +18,8 @@
- **分类行分隔**`.home-category-layer1-row` 底部有淡色投影,增强与下方内容的层次感
- **第一层操作按钮**:已移除右侧的搜索/筛选图标;搜索浮层通过 `initialSearchExpanded`(供 `/search` 路由使用)控制展开
## 数据流
```

View File

@ -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 视觉层级。

View File

@ -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`01转成 `[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`01转成 `[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

View File

@ -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`
- TabPositions、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')`、取消按钮

View File

@ -101,7 +101,8 @@ watch(
</template>
</div>
</v-app-bar>
<v-main>
<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']">
@ -109,6 +110,7 @@ watch(
</keep-alive>
</router-view>
</div>
</div>
</v-main>
<v-bottom-navigation
@ -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>

View File

@ -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

View File

@ -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',

View File

@ -90,6 +90,8 @@ export function createLineChart(
timeVisible: true,
secondsVisible: false,
},
handleScroll: false,
handleScale: false,
crosshair: {
vertLine: { labelVisible: false },
horzLine: { labelVisible: true },

View File

@ -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",

View File

@ -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": "読み込みに失敗しました",

View File

@ -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": "로드 실패",

View File

@ -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": "加载失败",

View File

@ -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": "載入失敗",

View File

@ -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 }
},
})

View File

@ -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)

View File

@ -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-scrolltop: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); */

View File

@ -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>

View File

@ -184,18 +184,8 @@
<!-- 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 v-if="!eventDetail?.description && !eventDetail?.resolutionSource" class="placeholder-pane">
{{ t('activity.rulesEmpty') }}
</div>
<template v-else>
@ -219,64 +209,6 @@
</div>
</template>
</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-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 {

View File

@ -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;