diff --git a/docs/api/cryptoChart.md b/docs/api/cryptoChart.md index ef1a4e2..7d568db 100644 --- a/docs/api/cryptoChart.md +++ b/docs/api/cryptoChart.md @@ -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 保活 ## 币种映射 diff --git a/docs/api/event.md b/docs/api/event.md index 71ed8f1..d054cfe 100644 --- a/docs/api/event.md +++ b/docs/api/event.md @@ -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) // 获取详情(需鉴权) diff --git a/docs/composables/useLightweightChart.md b/docs/composables/useLightweightChart.md index 5f1da01..28d6f3d 100644 --- a/docs/composables/useLightweightChart.md +++ b/docs/composables/useLightweightChart.md @@ -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`:添加面积系列(带渐变) diff --git a/docs/core/App.md b/docs/core/App.md index cf12145..e63af55 100644 --- a/docs/core/App.md +++ b/docs/core/App.md @@ -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` 后执行),确保钱包登录、刷新页面后头像和用户名正确显示 diff --git a/docs/core/router.md b/docs/core/router.md index 8c301d4..5cd7e5c 100644 --- a/docs/core/router.md +++ b/docs/core/router.md @@ -22,8 +22,10 @@ Vue Router 配置,定义路由表与滚动行为。 ## 滚动行为 -- 有 `savedPosition` 且来自已命名路由:恢复位置 -- 有 `to.hash`:滚动到锚点 +滚动目标为 `[data-main-scroll]`(App.vue 内主滚动容器),非 window: + +- 有 `savedPosition` 且来自已命名路由:恢复该容器的 `scrollTop` +- 有 `to.hash`:滚动到锚点元素 - 否则:滚动到顶部 ## 扩展方式 diff --git a/docs/scrollbar-nav-overlap.md b/docs/scrollbar-nav-overlap.md new file mode 100644 index 0000000..1eb7e71 --- /dev/null +++ b/docs/scrollbar-nav-overlap.md @@ -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 底部,不再被滚动条覆盖。 diff --git a/docs/views/EventMarkets.md b/docs/views/EventMarkets.md index 6fdbcde..9562a9d 100644 --- a/docs/views/EventMarkets.md +++ b/docs/views/EventMarkets.md @@ -8,6 +8,8 @@ 事件下的市场列表页,展示某个 Event 的多个 Market(如 NFL 多支队伍),支持选择并跳转交易详情。 - **多市场折线图**:按市场数量依次调用 `getPmPriceHistoryPublic`,每个市场使用 `clobTokenIds[0]`(YES token)作为 `market` 参数,展示多条分时曲线 +- 为避免“最新数据提前被截断”,不同时间范围会使用更大的 `pageSize` 拉取更多点 +- 图表交互已禁用拖动和滚轮缩放(`handleScroll/handleScale=false`) - **时间范围**:1H / 6H / 1D / 1W / 1M / ALL,与 TradeDetail 一致 ## 使用方式 diff --git a/docs/views/Home.md b/docs/views/Home.md index 57658c1..04b60f0 100644 --- a/docs/views/Home.md +++ b/docs/views/Home.md @@ -18,6 +18,8 @@ - **分类行分隔**:`.home-category-layer1-row` 底部有淡色投影,增强与下方内容的层次感 +- **第一层操作按钮**:已移除右侧的搜索/筛选图标;搜索浮层通过 `initialSearchExpanded`(供 `/search` 路由使用)控制展开 + ## 数据流 ``` diff --git a/docs/views/Search.md b/docs/views/Search.md index ba7409a..19d80b7 100644 --- a/docs/views/Search.md +++ b/docs/views/Search.md @@ -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 视觉层级。 diff --git a/docs/views/TradeDetail.md b/docs/views/TradeDetail.md index a6542a4..d080299 100644 --- a/docs/views/TradeDetail.md +++ b/docs/views/TradeDetail.md @@ -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) diff --git a/docs/views/Wallet.md b/docs/views/Wallet.md index 7df5e42..c57c471 100644 --- a/docs/views/Wallet.md +++ b/docs/views/Wallet.md @@ -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')`、取消按钮 diff --git a/src/App.vue b/src/App.vue index a2e6172..509afb8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -101,13 +101,15 @@ watch( - -
- - - - - + +
+
+ + + + + +
@@ -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; } diff --git a/src/api/cryptoChart.ts b/src/api/cryptoChart.ts index b3a7688..efd3967 100644 --- a/src/api/cryptoChart.ts +++ b/src/api/cryptoChart.ts @@ -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 diff --git a/src/api/event.ts b/src/api/event.ts index 9da7f86..e6cd8a1 100644 --- a/src/api/event.ts +++ b/src/api/event.ts @@ -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', diff --git a/src/composables/useLightweightChart.ts b/src/composables/useLightweightChart.ts index b7fc37f..0143016 100644 --- a/src/composables/useLightweightChart.ts +++ b/src/composables/useLightweightChart.ts @@ -90,6 +90,8 @@ export function createLineChart( timeVisible: true, secondsVisible: false, }, + handleScroll: false, + handleScale: false, crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true }, diff --git a/src/locales/en.json b/src/locales/en.json index 225e425..729cdd3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/ja.json b/src/locales/ja.json index bf9d9f8..07241d3 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -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": "読み込みに失敗しました", diff --git a/src/locales/ko.json b/src/locales/ko.json index 0eeb7e1..3c345cb 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -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": "로드 실패", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 8ae8fec..cee0c23 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -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": "加载失败", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index f3be6a7..7430fd0 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -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": "載入失敗", diff --git a/src/router/index.ts b/src/router/index.ts index e37fb09..4cbc309 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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 } }, }) diff --git a/src/views/EventMarkets.vue b/src/views/EventMarkets.vue index 6047983..cedad7e 100644 --- a/src/views/EventMarkets.vue +++ b/src/views/EventMarkets.vue @@ -366,6 +366,16 @@ const MOBILE_BREAKPOINT = 600 async function loadChartFromApi(): Promise { const list = markets.value const range = selectedTimeRange.value + // 增加每次请求的 points 数量,避免图表在较长时间粒度下出现“最新数据提前被截断” + const pageSizeByRange: Record = { + '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 { 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) diff --git a/src/views/Home.vue b/src/views/Home.vue index 1b2da4a..92e90be 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -12,27 +12,6 @@ {{ item.label }} -
- - mdi-magnify - - - mdi-filter-outline - -
@@ -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); */ diff --git a/src/views/Search.vue b/src/views/Search.vue index adf8ffb..d1204f1 100644 --- a/src/views/Search.vue +++ b/src/views/Search.vue @@ -1,7 +1,7 @@ @@ -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; +} diff --git a/src/views/TradeDetail.vue b/src/views/TradeDetail.vue index 6759de5..5ad35b4 100644 --- a/src/views/TradeDetail.vue +++ b/src/views/TradeDetail.vue @@ -184,99 +184,31 @@ - - {{ t('activity.rules') }} - {{ t('activity.topHolders') }} - {{ t('activity.activity') }} - - - -
-
+
+ {{ t('activity.rulesEmpty') }} +
+ +
@@ -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('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([ - { - 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 = { + '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 { diff --git a/src/views/Wallet.vue b/src/views/Wallet.vue index a466410..70351e2 100644 --- a/src/views/Wallet.vue +++ b/src/views/Wallet.vue @@ -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;