Compare commits

...

3 Commits

Author SHA1 Message Date
ivan
2174abc9d3 优化:UI调整 2026-02-14 17:13:16 +08:00
ivan
89ba32bdbc 优化:限价单按规则只取135个档位 2026-02-14 13:22:18 +08:00
ivan
f15e40d74c 优化:对多市场显示的优化 2026-02-14 12:25:19 +08:00
7 changed files with 775 additions and 75 deletions

View File

@ -1,4 +1,11 @@
import { get } from './request'
import {
DEFAULT_CATEGORY_ICON,
resolveCategoryIcon,
resolveCategoryIconColor,
} from './categoryIcons'
export { DEFAULT_CATEGORY_ICON, resolveCategoryIconColor }
/**
* PmTag definitions polymarket.PmTag
@ -22,6 +29,8 @@ export interface PmTagMainItem {
icon?: string
sectionTitle?: string
forceHide?: boolean
/** 排序值,有则按从小到大排序 */
sort?: number
}
/** 分类树节点(与后端返回结构一致) */
@ -39,6 +48,8 @@ export interface CategoryTreeNode {
updatedBy?: number
createdAt?: string
updatedAt?: string
/** 排序值,有则按从小到大排序 */
sort?: number
children?: CategoryTreeNode[]
}
@ -108,58 +119,89 @@ export interface CategoryTreeResponse {
msg: string
}
/**
*
* GET /PmTag/getPmTagPublic
*
* children
* data { list: [] } CategoryTreeNode[]
*/
export async function getCategoryTree(): Promise<CategoryTreeResponse> {
const res = await get<{
code: number
data: CategoryTreeNode[] | { list?: CategoryTreeNode[] }
msg: string
}>('/PmTag/getPmTagPublic')
let data: CategoryTreeNode[] = []
const raw = res.data
if (Array.isArray(raw)) {
data = raw
} else if (raw && typeof raw === 'object') {
if (Array.isArray((raw as { list?: CategoryTreeNode[] }).list)) {
data = (raw as { list: CategoryTreeNode[] }).list
} else if ((raw as CategoryTreeNode).id && (raw as CategoryTreeNode).label) {
data = [raw as CategoryTreeNode]
}
}
return { code: res.code, data, msg: res.msg }
/** 递归为树节点补充图标(无 icon 时自动匹配),可导出供 mock 等场景使用 */
export function enrichWithIcons(nodes: CategoryTreeNode[]): CategoryTreeNode[] {
return nodes.map((n) => {
const icon = n.icon ?? resolveCategoryIcon(n)
const children = n.children?.length ? enrichWithIcons(n.children) : undefined
return { ...n, icon: icon ?? n.icon, children }
})
}
/** 将 PmTagMainItem 转为 CategoryTreeNode */
/** 递归按 sort 字段从小到大排序(有 sort 的节点排前面,同层比较) */
function sortBySortField<T extends { sort?: number; children?: T[] }>(nodes: T[]): T[] {
const hasSort = nodes.some((n) => n.sort != null && Number.isFinite(n.sort))
if (!hasSort) return nodes
return [...nodes]
.sort((a, b) => {
const sa = a.sort
const sb = b.sort
if (sa != null && sb != null) return sa - sb
if (sa != null) return -1
if (sb != null) return 1
return 0
})
.map((n) => {
if (n.children?.length) {
return { ...n, children: sortBySortField(n.children) }
}
return n
})
}
/** 将 PmTagMainItem 转为 CategoryTreeNode无 icon 时自动匹配 MDI 图标 */
function mapPmTagToTreeNode(item: PmTagMainItem): CategoryTreeNode {
const rawId = item.ID
const id = rawId != null ? String(rawId) : (item.slug ?? item.label ?? '')
const children = Array.isArray(item.children) ? item.children.map(mapPmTagToTreeNode) : undefined
const icon = item.icon ?? resolveCategoryIcon({ label: item.label, slug: item.slug })
return {
id,
label: item.label ?? '',
slug: item.slug ?? '',
icon: item.icon,
icon,
sectionTitle: item.sectionTitle,
forceShow: item.forceShow,
forceHide: item.forceHide,
sort: item.sort,
children: children?.length ? children : undefined,
}
}
/** getPmTagList 响应data 可能为 { All: PmTag[] } 或 PmTag[] */
type GetPmTagListData = PmTagMainItem[] | { All?: PmTagMainItem[] }
/**
* Tab
* GET /PmTag/getPmTagMain
* GET /PmTag/getPmTagList getPmTagMain
*
* children
* data.All data.All
*/
export async function getPmTagMain(): Promise<CategoryTreeResponse> {
const res = await get<{ code: number; data: PmTagMainItem[]; msg: string }>('/PmTag/getPmTagMain')
const data: CategoryTreeNode[] = Array.isArray(res.data) ? res.data.map(mapPmTagToTreeNode) : []
const res = await get<{ code: number; data: GetPmTagListData; msg: string }>(
'/PmTag/getPmTagList'
)
// 调试:打印接口原始数据
console.log('[getPmTagList] 原始响应:', JSON.stringify(res, null, 2))
let raw: PmTagMainItem[] = []
const d = res.data
if (d && typeof d === 'object' && !Array.isArray(d) && 'All' in d) {
raw = Array.isArray((d as { All?: PmTagMainItem[] }).All)
? (d as { All: PmTagMainItem[] }).All
: []
} else if (Array.isArray(d)) {
raw = d
}
let mapped = raw.map(mapPmTagToTreeNode)
mapped = sortBySortField(mapped)
// 第一层不显示,取 All 菜单下的三层作为展示数据(政治、体育、加密等)
const allNode = mapped.find(
(n) =>
n.slug?.toLowerCase() === 'all' ||
n.label === 'All' ||
n.label === '全部'
)
const children = allNode?.children ?? mapped
const data: CategoryTreeNode[] = enrichWithIcons(sortBySortField(children))
return { code: res.code, data, msg: res.msg }
}

212
src/api/categoryIcons.ts Normal file
View File

@ -0,0 +1,212 @@
/**
*
* label/slug MDIMaterial Design Icons
* @mdi/font mdi-xxx
*/
/** slug/label小写 -> MDI 图标名 */
const ICON_MAP: Record<string, string> = {
// 通用
all: 'mdi-view-grid-outline',
'全部': 'mdi-view-grid-outline',
latest: 'mdi-clock-outline',
'最新': 'mdi-new-box',
new: 'mdi-new-box',
trending: 'mdi-trending-up',
// 政治
politics: 'mdi-gavel',
'政治': 'mdi-gavel',
election: 'mdi-vote-outline',
elections: 'mdi-vote-outline',
'选举': 'mdi-vote-outline',
// 体育
sports: 'mdi-basketball',
'体育': 'mdi-basketball',
football: 'mdi-soccer',
'足球': 'mdi-soccer',
basketball: 'mdi-basketball',
'篮球': 'mdi-basketball',
nba: 'mdi-basketball',
macro: 'mdi-earth',
// 加密 / 金融
crypto: 'mdi-currency-btc',
'加密': 'mdi-currency-btc',
btc: 'mdi-currency-btc',
bitcoin: 'mdi-currency-btc',
eth: 'mdi-currency-eth',
ethereum: 'mdi-currency-eth',
finance: 'mdi-chart-line',
'财务': 'mdi-chart-line',
'金融': 'mdi-chart-line',
'fed rates': 'mdi-chart-line-variant',
fedrates: 'mdi-chart-line-variant',
// 地缘 / 世界
geopolitics: 'mdi-earth',
'地缘政治': 'mdi-earth',
world: 'mdi-earth',
'世界': 'mdi-earth',
// 加密子类Above/Below、Up/Down、时间周期
'above-below': 'mdi-arrow-up-down',
'up-down': 'mdi-arrow-up-down',
'5m': 'mdi-clock-outline',
'15m': 'mdi-clock-outline',
'1h': 'mdi-refresh',
'4h': 'mdi-clock-outline',
'1d': 'mdi-calendar',
'5分钟': 'mdi-clock-outline',
'15分钟': 'mdi-clock-outline',
'每小时': 'mdi-refresh',
'4小时': 'mdi-clock-outline',
'每天': 'mdi-calendar',
// 文化 / 科技
culture: 'mdi-palette-outline',
'文化': 'mdi-palette-outline',
tech: 'mdi-laptop',
'科技': 'mdi-laptop',
ai: 'mdi-robot-outline',
climate: 'mdi-weather-partly-cloudy',
'气候': 'mdi-weather-partly-cloudy',
science: 'mdi-flask-outline',
'科学': 'mdi-flask-outline',
// Polymarket 风格(参考官网 dashboards
trump: 'mdi-account',
}
/** 无匹配时的默认图标 */
export const DEFAULT_CATEGORY_ICON = 'mdi-tag-outline'
/** slug/label -> 图标颜色Vuetify 颜色名或 CSS 颜色值) */
const ICON_COLOR_MAP: Record<string, string> = {
politics: 'primary',
'政治': 'primary',
election: 'primary',
elections: 'primary',
sports: 'success',
'体育': 'success',
football: 'success',
'足球': 'success',
basketball: 'success',
'篮球': 'success',
crypto: 'warning',
'加密': 'warning',
btc: 'warning',
eth: 'info',
finance: 'info',
'财务': 'info',
'金融': 'info',
geopolitics: 'secondary',
'地缘政治': 'secondary',
world: 'secondary',
culture: 'purple',
'文化': 'purple',
tech: 'info',
'科技': 'info',
ai: 'secondary',
climate: 'success',
'气候': 'success',
latest: 'primary',
'最新': 'primary',
all: 'grey',
'全部': 'grey',
}
/** 关键词 -> 颜色 */
const LABEL_COLOR_KEYWORDS: [string | RegExp, string][] = [
['政治', 'primary'],
['选举', 'primary'],
['足球', 'success'],
['篮球', 'success'],
['体育', 'success'],
['加密', 'warning'],
['比特', 'warning'],
['以太', 'info'],
['财务', 'info'],
['金融', 'info'],
['地缘', 'secondary'],
['世界', 'secondary'],
['文化', 'purple'],
['科技', 'info'],
['气候', 'success'],
]
/** 关键词label 包含) -> 图标 */
const LABEL_KEYWORDS: [string | RegExp, string][] = [
['政治', 'mdi-gavel'],
['选举', 'mdi-vote-outline'],
['足球', 'mdi-soccer'],
['篮球', 'mdi-basketball'],
['体育', 'mdi-basketball'],
['加密', 'mdi-currency-btc'],
['比特', 'mdi-currency-btc'],
['以太', 'mdi-currency-eth'],
['财务', 'mdi-chart-line'],
['金融', 'mdi-chart-line'],
['地缘', 'mdi-earth'],
['世界', 'mdi-earth'],
['全部', 'mdi-view-grid-outline'],
['最新', 'mdi-new-box'],
['文化', 'mdi-palette-outline'],
['科技', 'mdi-laptop'],
['气候', 'mdi-weather-partly-cloudy'],
['科学', 'mdi-flask-outline'],
[/^\d+[mhd]$/i, 'mdi-clock-outline'],
[/分钟|小时|每天/, 'mdi-clock-outline'],
]
function normalizeKey(s: string): string {
return s.trim().toLowerCase().replace(/\s+/g, '-')
}
/**
* label slug MDI
* slug label
*/
export function resolveCategoryIcon(node: { label?: string; slug?: string; icon?: string }): string {
if (node.icon && node.icon.startsWith('mdi-')) return node.icon
const slug = node.slug ? normalizeKey(node.slug) : ''
const label = node.label?.trim() ?? ''
// 1. 精确匹配 slug
const bySlug = slug && ICON_MAP[slug]
if (bySlug) return bySlug
// 2. 精确匹配 label
const byLabel = label && (ICON_MAP[label] ?? ICON_MAP[normalizeKey(label)])
if (byLabel) return byLabel
// 3. 关键词匹配 label
for (const [keyword, icon] of LABEL_KEYWORDS) {
if (typeof keyword === 'string' && label.includes(keyword)) return icon
if (keyword instanceof RegExp && keyword.test(label)) return icon
}
return DEFAULT_CATEGORY_ICON
}
/**
* label slug
* Vuetify primary/success/warning CSS undefined
*/
export function resolveCategoryIconColor(node: { label?: string; slug?: string }): string | undefined {
const slug = node.slug ? normalizeKey(node.slug) : ''
const label = node.label?.trim() ?? ''
if (slug && ICON_COLOR_MAP[slug]) return ICON_COLOR_MAP[slug]
if (label && ICON_COLOR_MAP[label]) return ICON_COLOR_MAP[label]
if (label && ICON_COLOR_MAP[normalizeKey(label)]) return ICON_COLOR_MAP[normalizeKey(label)]
for (const [keyword, color] of LABEL_COLOR_KEYWORDS) {
if (typeof keyword === 'string' && label.includes(keyword)) return color
if (keyword instanceof RegExp && keyword.test(label)) return color
}
return undefined
}

View File

@ -121,19 +121,22 @@ export interface GetPmEventListParams {
createdAtRange?: string[]
/** clobTokenIds 对应的值,用于按市场 token 筛选;可从 market.clobTokenIds 获取 */
tokenid?: string | string[]
/** 标签 ID按分类筛选 */
tagId?: number
/** 标签 slug按分类筛选 */
tagSlug?: string
}
/**
* Event
* GET /PmEvent/getPmEventPublic
*
* Query: page, pageSize, keyword, createdAtRange, tokenid
* tokenid market.clobTokenIds
* Query: page, pageSize, keyword, createdAtRange, tokenid, tagId, tagSlug
*/
export async function getPmEventPublic(
params: GetPmEventListParams = {},
): Promise<PmEventListResponse> {
const { page = 1, pageSize = 10, keyword, createdAtRange, tokenid } = params
const { page = 1, pageSize = 10, keyword, createdAtRange, tokenid, tagId, tagSlug } = params
const query: Record<string, string | number | string[] | undefined> = {
page,
pageSize,
@ -143,6 +146,8 @@ export async function getPmEventPublic(
if (tokenid != null) {
query.tokenid = Array.isArray(tokenid) ? tokenid : [tokenid]
}
if (tagId != null && Number.isFinite(tagId)) query.tagId = tagId
if (tagSlug != null && tagSlug !== '') query.tagSlug = tagSlug
return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
}

View File

@ -182,6 +182,7 @@
hide-details
density="compact"
@update:model-value="onLimitPriceInput"
@blur="onLimitPriceBlur"
@keydown="onLimitPriceKeydown"
@paste="onLimitPricePaste"
></v-text-field>
@ -439,6 +440,7 @@
hide-details
density="compact"
@update:model-value="onLimitPriceInput"
@blur="onLimitPriceBlur"
@keydown="onLimitPriceKeydown"
@paste="onLimitPricePaste"
></v-text-field>
@ -936,6 +938,42 @@ import { OrderType, Side } from '../api/constants'
const { mobile } = useDisplay()
const userStore = useUserStore()
/** 限价单允许的 135 个价格档位01 区间规则19/1090/1009900/99109990/99919999 */
function buildAllowedLimitPrices(): number[] {
const list: number[] = []
for (let i = 1; i <= 9; i++) list.push(i / 10000)
for (let i = 10; i <= 90; i += 10) list.push(i / 10000)
for (let i = 100; i <= 9900; i += 100) list.push(i / 10000)
for (let i = 9910; i <= 9990; i += 10) list.push(i / 10000)
for (let i = 9991; i <= 9999; i++) list.push(i / 10000)
return list
}
const ALLOWED_LIMIT_PRICES = buildAllowedLimitPrices()
/** 将限价吸附到最近的允许档位 */
function snapToAllowedPrice(v: number): number {
const clamped = Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0))
const list = ALLOWED_LIMIT_PRICES
if (list.length === 0) return clamped
let nearest = list[0] as number
let minDiff = Math.abs(clamped - nearest)
for (const p of list) {
const d = Math.abs(clamped - p)
if (d < minDiff) {
minDiff = d
nearest = p
}
}
return nearest
}
/** 获取当前价格在允许列表中的索引,-1 表示不在列表中 */
function indexOfAllowedPrice(v: number): number {
const snapped = snapToAllowedPrice(v)
const idx = ALLOWED_LIMIT_PRICES.findIndex((p) => Math.abs(p - snapped) < 1e-9)
return idx >= 0 ? idx : 0
}
export interface TradeMarketPayload {
marketId?: string
yesPrice: number
@ -1029,6 +1067,8 @@ async function submitSplit() {
}
}
defineExpose({ openMergeDialog, openSplitDialog })
const yesPriceCents = computed(() => (props.market ? Math.round(props.market.yesPrice * 100) : 19))
const noPriceCents = computed(() => (props.market ? Math.round(props.market.noPrice * 100) : 82))
@ -1089,8 +1129,9 @@ function applyInitialOption(option: 'yes' | 'no') {
syncLimitPriceFromMarket()
}
/** 限价限制到 [0,1] 并吸附到 135 个允许档位之一 */
function clampLimitPrice(v: number): number {
return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0))
return snapToAllowedPrice(Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0)))
}
/** 根据当前 props.market 与 selectedOption 同步 limitPrice组件显示或 market 更新时调用) */
@ -1133,11 +1174,16 @@ const handleOptionChange = (option: 'yes' | 'no') => {
emit('optionChange', option)
}
/** 仅在值在 [0,1] 且为有效数字时更新,否则保持原值不变 */
/** 仅接受 135 个允许档位:输入值吸附到最近档位,非法值忽略 */
function onLimitPriceInput(v: unknown) {
const num = v == null ? NaN : Number(v)
if (!Number.isFinite(num) || num < 0 || num > 1) return
limitPrice.value = num
limitPrice.value = snapToAllowedPrice(num)
}
/** 失焦时吸附到允许档位 */
function onLimitPriceBlur() {
limitPrice.value = snapToAllowedPrice(limitPrice.value)
}
/** 只允许数字和小数点输入 */
@ -1162,13 +1208,17 @@ function onLimitPricePaste(e: ClipboardEvent) {
}
}
// 01
// 135
const decreasePrice = () => {
limitPrice.value = clampLimitPrice(limitPrice.value - 0.01)
const idx = indexOfAllowedPrice(limitPrice.value)
const nextIdx = Math.max(0, idx - 1)
limitPrice.value = ALLOWED_LIMIT_PRICES[nextIdx] ?? limitPrice.value
}
const increasePrice = () => {
limitPrice.value = clampLimitPrice(limitPrice.value + 0.01)
const idx = indexOfAllowedPrice(limitPrice.value)
const nextIdx = Math.min(ALLOWED_LIMIT_PRICES.length - 1, idx + 1)
limitPrice.value = ALLOWED_LIMIT_PRICES[nextIdx] ?? limitPrice.value
}
/** 将 shares 限制为正整数(>= 1 */

View File

@ -83,17 +83,19 @@
class="buy-yes-btn"
variant="flat"
size="small"
rounded="sm"
@click="openTrade(market, index, 'yes')"
>
Buy Yes {{ yesPrice(market) }}
Yes {{ yesPrice(market) }}
</v-btn>
<v-btn
class="buy-no-btn"
variant="flat"
size="small"
rounded="sm"
@click="openTrade(market, index, 'no')"
>
Buy No {{ noPrice(market) }}
No {{ noPrice(market) }}
</v-btn>
</div>
</div>
@ -101,8 +103,8 @@
</v-card>
</v-col>
<!-- 右侧购买组件点击 Yes/No 时传入当前市场数据 -->
<v-col cols="12" class="trade-col">
<!-- 右侧购买组件桌面端显示移动端用底部栏+弹窗 -->
<v-col v-if="!isMobile" cols="12" class="trade-col">
<div v-if="markets.length > 0" class="trade-sidebar">
<TradeComponent
:market="tradeMarketPayload"
@ -112,6 +114,69 @@
</div>
</v-col>
</v-row>
<!-- 移动端 market 时显示固定底部 Yes/No + 三点菜单Merge/Split -->
<template v-if="isMobile && markets.length === 1">
<div class="mobile-trade-bar-spacer" aria-hidden="true"></div>
<div class="mobile-trade-bar">
<v-btn
class="mobile-bar-btn mobile-bar-yes"
variant="flat"
rounded="sm"
@click="openSheetWithOption('yes')"
>
Yes {{ barMarket ? yesPrice(barMarket) : '0¢' }}
</v-btn>
<v-btn
class="mobile-bar-btn mobile-bar-no"
variant="flat"
rounded="sm"
@click="openSheetWithOption('no')"
>
No {{ barMarket ? noPrice(barMarket) : '0¢' }}
</v-btn>
<v-menu
v-model="mobileMenuOpen"
:close-on-content-click="true"
location="top"
transition="scale-transition"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="mobile-bar-more-btn"
variant="flat"
icon
rounded="pill"
aria-label="更多操作"
>
<v-icon size="20">mdi-dots-horizontal</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item @click="openMergeFromBar">
<v-list-item-title>Merge</v-list-item-title>
</v-list-item>
<v-list-item @click="openSplitFromBar">
<v-list-item-title>Split</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<!-- 移动端交易弹窗 market 与多 market 均需 market 时通过列表 Buy Yes/No 打开 -->
<template v-if="isMobile && markets.length > 0">
<v-bottom-sheet v-model="tradeSheetOpen" content-class="event-markets-trade-sheet">
<TradeComponent
ref="tradeComponentRef"
:key="`trade-${selectedMarketIndex}-${tradeInitialOption}`"
:market="tradeMarketPayload"
:initial-option="tradeInitialOption"
embedded-in-sheet
@submit="onTradeSubmit"
/>
</v-bottom-sheet>
</template>
</template>
</v-container>
</template>
@ -120,6 +185,7 @@
defineOptions({ name: 'EventMarkets' })
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDisplay } from 'vuetify'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import TradeComponent from '../components/TradeComponent.vue'
@ -136,6 +202,8 @@ import { useUserStore } from '../stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const { mobile } = useDisplay()
const isMobile = computed(() => mobile.value)
const eventDetail = ref<PmEventListItem | null>(null)
const detailLoading = ref(false)
@ -144,11 +212,19 @@ const detailError = ref<string | null>(null)
const selectedMarketIndex = ref(0)
/** 点击 Buy Yes/No 时传给购买组件的初始方向,不点击则为 undefined 使用组件默认 */
const tradeInitialOption = ref<'yes' | 'no' | undefined>(undefined)
/** 移动端交易弹窗开关 */
const tradeSheetOpen = ref(false)
/** 移动端底部栏三点菜单开关 */
const mobileMenuOpen = ref(false)
/** TradeComponent 引用,用于从底部栏触发 Merge/Split */
const tradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
const markets = computed(() => {
const list = eventDetail.value?.markets ?? []
return list.length > 0 ? list : []
})
const selectedMarket = computed(() => markets.value[selectedMarketIndex.value] ?? null)
/** 移动端底部栏显示的市场(选中项或首个),仅在 markets.length > 0 时使用 */
const barMarket = computed(() => selectedMarket.value ?? markets.value[0])
/** 传给购买组件的市场数据(当前选中的市场) */
const tradeMarketPayload = computed(() => {
const m = selectedMarket.value
@ -436,10 +512,37 @@ function selectMarket(index: number) {
selectedMarketIndex.value = index
}
/** 点击 Buy Yes/No选中该市场并把数据和方向传给购买组件,不跳转 */
/** 点击 Buy Yes/No选中该市场并把数据和方向传给购买组件;移动端直接弹出交易弹窗 */
function openTrade(market: PmEventMarketItem, index: number, side: 'yes' | 'no') {
selectedMarketIndex.value = index
tradeInitialOption.value = side
if (isMobile.value) {
tradeSheetOpen.value = true
}
}
/** 移动端底部栏:点击 Yes/No 时打开交易弹窗 */
function openSheetWithOption(side: 'yes' | 'no') {
tradeInitialOption.value = side
tradeSheetOpen.value = true
}
/** 从底部栏三点菜单打开 Merge 弹窗 */
function openMergeFromBar() {
mobileMenuOpen.value = false
tradeSheetOpen.value = true
nextTick(() => {
tradeComponentRef.value?.openMergeDialog?.()
})
}
/** 从底部栏三点菜单打开 Split 弹窗 */
function openSplitFromBar() {
mobileMenuOpen.value = false
tradeSheetOpen.value = true
nextTick(() => {
tradeComponentRef.value?.openSplitDialog?.()
})
}
function onTradeSubmit(payload: {
@ -843,21 +946,98 @@ watch(
.market-row-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
gap: 10px;
flex: 1 1 100%;
min-width: 0;
margin-top: 2px;
}
/* 宽屏时按钮与问题同行,节省垂直空间 */
@media (min-width: 768px) {
.market-row-actions {
flex: 0 0 auto;
margin-top: 0;
margin-left: auto;
}
}
.buy-yes-btn,
.buy-no-btn {
flex: 1;
min-width: 72px;
max-width: 140px;
white-space: nowrap;
text-transform: none;
font-weight: 500;
}
.buy-yes-btn {
background-color: #b8e0b8 !important;
color: #006600 !important;
text-transform: none;
font-weight: 500;
}
.buy-no-btn {
background-color: #f0b8b8 !important;
color: #cc0000 !important;
text-transform: none;
}
/* 移动端底部交易栏 */
.mobile-trade-bar-spacer {
height: 72px;
}
.mobile-trade-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: stretch;
gap: 10px;
width: 100%;
padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #eee;
}
.mobile-bar-btn {
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
text-transform: none;
}
.mobile-bar-yes {
flex: 1;
min-width: 0;
background: #b8e0b8 !important;
color: #006600 !important;
padding: 14px 16px;
}
.mobile-bar-no {
flex: 1;
min-width: 0;
background: #f0b8b8 !important;
color: #cc0000 !important;
padding: 14px 16px;
}
.mobile-bar-more-btn {
flex-shrink: 0;
width: 46px;
height: 46px;
min-width: 46px;
min-height: 46px;
padding: 0 !important;
border-radius: 50%;
background: #e5e7eb !important;
color: #6b7280;
align-self: center;
}
</style>

View File

@ -116,8 +116,13 @@
:class="{ 'home-category-icon-item--active': layerActiveValues[1] === item.id }"
@click="onCategorySelect(1, item.id)"
>
<v-icon v-if="item.icon" size="24" class="home-category-icon">{{ item.icon }}</v-icon>
<span v-else class="home-category-icon-placeholder" aria-hidden="true" />
<v-icon
size="24"
class="home-category-icon"
:color="resolveCategoryIconColor(item) ?? 'grey'"
>
{{ item.icon || DEFAULT_CATEGORY_ICON }}
</v-icon>
<span class="home-category-icon-label">{{ item.label }}</span>
</button>
</div>
@ -307,7 +312,14 @@ import {
clearEventListCache,
type EventCardItem,
} from '../api/event'
import { getPmTagMain, MOCK_CATEGORY_TREE, type CategoryTreeNode } from '../api/category'
import {
DEFAULT_CATEGORY_ICON,
enrichWithIcons,
getPmTagMain,
MOCK_CATEGORY_TREE,
resolveCategoryIconColor,
type CategoryTreeNode,
} from '../api/category'
import { useSearchHistory } from '../composables/useSearchHistory'
const { mobile } = useDisplay()
@ -371,10 +383,10 @@ function doSearch(keyword: string) {
loadEvents(1, false, keyword)
}
/** 过滤 forceHide 的节点 */
/** 过滤:仅当 forceShow 明确为 false 时不显示其他null/undefined/true 等)均显示;排除 forceHide 的节点 */
function filterVisible(nodes: CategoryTreeNode[] | undefined): CategoryTreeNode[] {
if (!nodes?.length) return []
return nodes.filter((n) => !n.forceHide)
return nodes.filter((n) => n.forceShow !== false && !n.forceHide)
}
/** 第三层区块标题:取当前选中的第一层节点的 sectionTitle 或 label */
@ -385,6 +397,28 @@ const selectedLayer0SectionTitle = computed(() => {
return node?.sectionTitle ?? node?.label ?? ''
})
/** 从树中递归查找节点 */
function findNodeById(nodes: CategoryTreeNode[], id: string): CategoryTreeNode | undefined {
for (const n of nodes) {
if (n.id === id) return n
const found = n.children ? findNodeById(n.children, id) : undefined
if (found) return found
}
return undefined
}
/** 当前选中分类的 tag 筛选(取最后选中的层级,用于 API tagId/tagSlug */
const activeTagFilter = computed(() => {
const ids = layerActiveValues.value
if (ids.length === 0) return { tagId: undefined as number | undefined, tagSlug: undefined as string | undefined }
const lastId = ids[ids.length - 1]
const root = filterVisible(categoryTree.value)
const node = lastId ? findNodeById(root, lastId) : undefined
if (!node) return { tagId: undefined, tagSlug: undefined }
const tagId = /^\d+$/.test(node.id) ? parseInt(node.id, 10) : undefined
return { tagId, tagSlug: node.slug || undefined }
})
/** 当前展示的层级数据:[[layer0], [layer1]?, [layer2]?] */
const categoryLayers = computed(() => {
const root = filterVisible(categoryTree.value)
@ -405,7 +439,7 @@ const categoryLayers = computed(() => {
return layers
})
/** 分类选中时:若有 children 则展开下一层并默认选中第一个 */
/** 分类选中时:若有 children 则展开下一层并默认选中第一个,并重新加载列表 */
function onCategorySelect(layerIndex: number, selectedId: string) {
const nextValues = [...layerActiveValues.value]
nextValues[layerIndex] = selectedId
@ -421,6 +455,10 @@ function onCategorySelect(layerIndex: number, selectedId: string) {
nextValues.push(firstChild.id)
}
layerActiveValues.value = nextValues
clearEventListCache()
eventPage.value = 1
loadEvents(1, false)
}
/** 初始化分类选中:默认选中第一个,若有 children 则递归展开 */
@ -522,8 +560,15 @@ const activeSearchKeyword = ref('')
/** 请求事件列表并追加或覆盖到 eventList公开接口无需鉴权成功后会更新内存缓存 */
async function loadEvents(page: number, append: boolean, keyword?: string) {
const kw = keyword !== undefined ? keyword : activeSearchKeyword.value
const { tagId, tagSlug } = activeTagFilter.value
try {
const res = await getPmEventPublic({ page, pageSize: PAGE_SIZE, keyword: kw || undefined })
const res = await getPmEventPublic({
page,
pageSize: PAGE_SIZE,
keyword: kw || undefined,
tagId,
tagSlug,
})
if (res.code !== 0 && res.code !== 200) {
throw new Error(res.msg || '请求失败')
}
@ -576,37 +621,43 @@ function checkScrollLoad() {
}
onMounted(() => {
/** 分类树就绪后加载列表(确保 activeTagFilter 已计算,与下拉刷新参数一致) */
function loadEventListAfterCategoryReady() {
const cached = getEventListCache()
if (cached && cached.list.length > 0) {
eventList.value = cached.list
eventPage.value = cached.page
eventTotal.value = cached.total
eventPageSize.value = cached.pageSize
} else {
loadEvents(1, false)
}
}
/** 开发时设为 true 可始终使用模拟数据查看一二三层 UI */
const USE_MOCK_CATEGORY = false
if (USE_MOCK_CATEGORY) {
categoryTree.value = MOCK_CATEGORY_TREE
categoryTree.value = enrichWithIcons(MOCK_CATEGORY_TREE)
initCategorySelection()
loadEventListAfterCategoryReady()
} else {
getPmTagMain()
.then((res) => {
if (res.code === 0 || res.code === 200) {
const data = res.data
categoryTree.value = Array.isArray(data) && data.length > 0 ? data : MOCK_CATEGORY_TREE
categoryTree.value = Array.isArray(data) && data.length > 0 ? data : enrichWithIcons(MOCK_CATEGORY_TREE)
} else {
categoryTree.value = MOCK_CATEGORY_TREE
categoryTree.value = enrichWithIcons(MOCK_CATEGORY_TREE)
}
initCategorySelection()
loadEventListAfterCategoryReady()
})
.catch(() => {
categoryTree.value = MOCK_CATEGORY_TREE
categoryTree.value = enrichWithIcons(MOCK_CATEGORY_TREE)
initCategorySelection()
loadEventListAfterCategoryReady()
})
}
const cached = getEventListCache()
if (cached && cached.list.length > 0) {
eventList.value = cached.list
eventPage.value = cached.page
eventTotal.value = cached.total
eventPageSize.value = cached.pageSize
} else {
loadEvents(1, false)
}
nextTick(() => {
const sentinel = sentinelRef.value
if (sentinel) {

View File

@ -113,19 +113,78 @@
</v-card>
</v-col>
<!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口 -->
<v-col cols="12" class="trade-col">
<!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口移动端隐藏改用底部栏+弹窗 -->
<v-col v-if="!isMobile" cols="12" class="trade-col">
<div class="trade-sidebar">
<TradeComponent :market="tradeMarketPayload" :initial-option="tradeInitialOption" />
<TradeComponent ref="tradeComponentRef" :market="tradeMarketPayload" :initial-option="tradeInitialOption" />
</div>
</v-col>
<!-- 移动端固定底部 Yes/No + 三点菜单Merge/Split -->
<template v-if="isMobile && tradeMarketPayload">
<div class="mobile-trade-bar-spacer" aria-hidden="true"></div>
<div class="mobile-trade-bar">
<v-btn
class="mobile-bar-btn mobile-bar-yes"
variant="flat"
rounded="sm"
@click="openSheetWithOption('yes')"
>
Yes {{ yesPriceCents }}¢
</v-btn>
<v-btn
class="mobile-bar-btn mobile-bar-no"
variant="flat"
rounded="sm"
@click="openSheetWithOption('no')"
>
No {{ noPriceCents }}¢
</v-btn>
<v-menu
v-model="mobileMenuOpen"
:close-on-content-click="true"
location="top"
transition="scale-transition"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="mobile-bar-more-btn"
variant="flat"
icon
rounded="pill"
aria-label="更多操作"
>
<v-icon size="20">mdi-dots-horizontal</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item @click="openMergeFromBar">
<v-list-item-title>Merge</v-list-item-title>
</v-list-item>
<v-list-item @click="openSplitFromBar">
<v-list-item-title>Split</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<v-bottom-sheet v-model="tradeSheetOpen" content-class="trade-detail-trade-sheet">
<TradeComponent
ref="mobileTradeComponentRef"
:market="tradeMarketPayload"
:initial-option="tradeInitialOptionFromBar"
embedded-in-sheet
/>
</v-bottom-sheet>
</template>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useDisplay } from 'vuetify'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import OrderBook from '../components/OrderBook.vue'
@ -167,6 +226,8 @@ export type ChartIncrement = { point: ChartPoint }
const route = useRoute()
const userStore = useUserStore()
const { mobile } = useDisplay()
const isMobile = computed(() => mobile.value)
// GET /PmEvent/findPmEvent
const eventDetail = ref<PmEventListItem | null>(null)
@ -294,6 +355,45 @@ const tradeInitialOption = computed(() => {
return undefined
})
/** 移动端底部栏点击 Yes/No 时传给弹窗内 TradeComponent 的初始选项 */
const tradeInitialOptionFromBar = ref<'yes' | 'no' | undefined>(undefined)
/** 移动端交易弹窗开关 */
const tradeSheetOpen = ref(false)
/** 移动端三点菜单开关 */
const mobileMenuOpen = ref(false)
/** 桌面端 TradeComponent 引用Merge/Split */
const tradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
/** 移动端弹窗内 TradeComponent 引用 */
const mobileTradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
const yesPriceCents = computed(() =>
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.yesPrice * 100) : 0
)
const noPriceCents = computed(() =>
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.noPrice * 100) : 0
)
function openSheetWithOption(side: 'yes' | 'no') {
tradeInitialOptionFromBar.value = side
tradeSheetOpen.value = true
}
function openMergeFromBar() {
mobileMenuOpen.value = false
tradeSheetOpen.value = true
nextTick(() => {
mobileTradeComponentRef.value?.openMergeDialog?.()
})
}
function openSplitFromBar() {
mobileMenuOpen.value = false
tradeSheetOpen.value = true
nextTick(() => {
mobileTradeComponentRef.value?.openSplitDialog?.()
})
}
// Comments / Top Holders / Activity
const detailTab = ref('activity')
const activityMinAmount = ref<string>('0')
@ -1230,4 +1330,64 @@ onUnmounted(() => {
.activity-link:hover {
color: #6b7280;
}
/* 移动端底部交易栏(与 EventMarkets 一致) */
.mobile-trade-bar-spacer {
height: 72px;
}
.mobile-trade-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: stretch;
gap: 10px;
width: 100%;
padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #eee;
}
.mobile-bar-btn {
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
text-transform: none;
}
.mobile-bar-yes {
flex: 1;
min-width: 0;
background: #b8e0b8 !important;
color: #006600 !important;
height: 46px;
}
.mobile-bar-no {
flex: 1;
min-width: 0;
background: #f0b8b8 !important;
color: #cc0000 !important;
height: 46px;
}
.mobile-bar-more-btn {
flex-shrink: 0;
width: 46px;
height: 46px;
min-width: 46px;
min-height: 46px;
padding: 0 !important;
border-radius: 50%;
background: #e5e7eb !important;
color: #6b7280;
align-self: center;
}
</style>