优化:UI调整

This commit is contained in:
ivan 2026-02-14 17:13:16 +08:00
parent 89ba32bdbc
commit 2174abc9d3
7 changed files with 634 additions and 84 deletions

View File

@ -1,4 +1,11 @@
import { get } from './request' import { get } from './request'
import {
DEFAULT_CATEGORY_ICON,
resolveCategoryIcon,
resolveCategoryIconColor,
} from './categoryIcons'
export { DEFAULT_CATEGORY_ICON, resolveCategoryIconColor }
/** /**
* PmTag definitions polymarket.PmTag * PmTag definitions polymarket.PmTag
@ -22,6 +29,8 @@ export interface PmTagMainItem {
icon?: string icon?: string
sectionTitle?: string sectionTitle?: string
forceHide?: boolean forceHide?: boolean
/** 排序值,有则按从小到大排序 */
sort?: number
} }
/** 分类树节点(与后端返回结构一致) */ /** 分类树节点(与后端返回结构一致) */
@ -39,6 +48,8 @@ export interface CategoryTreeNode {
updatedBy?: number updatedBy?: number
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
/** 排序值,有则按从小到大排序 */
sort?: number
children?: CategoryTreeNode[] children?: CategoryTreeNode[]
} }
@ -108,58 +119,89 @@ export interface CategoryTreeResponse {
msg: string msg: string
} }
/** /** 递归为树节点补充图标(无 icon 时自动匹配),可导出供 mock 等场景使用 */
* export function enrichWithIcons(nodes: CategoryTreeNode[]): CategoryTreeNode[] {
* GET /PmTag/getPmTagPublic return nodes.map((n) => {
* const icon = n.icon ?? resolveCategoryIcon(n)
* children const children = n.children?.length ? enrichWithIcons(n.children) : undefined
* data { list: [] } CategoryTreeNode[] return { ...n, icon: icon ?? n.icon, children }
*/ })
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 }
} }
/** 将 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 { function mapPmTagToTreeNode(item: PmTagMainItem): CategoryTreeNode {
const rawId = item.ID const rawId = item.ID
const id = rawId != null ? String(rawId) : (item.slug ?? item.label ?? '') const id = rawId != null ? String(rawId) : (item.slug ?? item.label ?? '')
const children = Array.isArray(item.children) ? item.children.map(mapPmTagToTreeNode) : undefined const children = Array.isArray(item.children) ? item.children.map(mapPmTagToTreeNode) : undefined
const icon = item.icon ?? resolveCategoryIcon({ label: item.label, slug: item.slug })
return { return {
id, id,
label: item.label ?? '', label: item.label ?? '',
slug: item.slug ?? '', slug: item.slug ?? '',
icon: item.icon, icon,
sectionTitle: item.sectionTitle, sectionTitle: item.sectionTitle,
forceShow: item.forceShow, forceShow: item.forceShow,
forceHide: item.forceHide, forceHide: item.forceHide,
sort: item.sort,
children: children?.length ? children : undefined, children: children?.length ? children : undefined,
} }
} }
/** getPmTagList 响应data 可能为 { All: PmTag[] } 或 PmTag[] */
type GetPmTagListData = PmTagMainItem[] | { All?: PmTagMainItem[] }
/** /**
* Tab * Tab
* GET /PmTag/getPmTagMain * GET /PmTag/getPmTagList getPmTagMain
* *
* children * data.All data.All
*/ */
export async function getPmTagMain(): Promise<CategoryTreeResponse> { export async function getPmTagMain(): Promise<CategoryTreeResponse> {
const res = await get<{ code: number; data: PmTagMainItem[]; msg: string }>('/PmTag/getPmTagMain') const res = await get<{ code: number; data: GetPmTagListData; msg: string }>(
const data: CategoryTreeNode[] = Array.isArray(res.data) ? res.data.map(mapPmTagToTreeNode) : [] '/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 } 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[] createdAtRange?: string[]
/** clobTokenIds 对应的值,用于按市场 token 筛选;可从 market.clobTokenIds 获取 */ /** clobTokenIds 对应的值,用于按市场 token 筛选;可从 market.clobTokenIds 获取 */
tokenid?: string | string[] tokenid?: string | string[]
/** 标签 ID按分类筛选 */
tagId?: number
/** 标签 slug按分类筛选 */
tagSlug?: string
} }
/** /**
* Event * Event
* GET /PmEvent/getPmEventPublic * GET /PmEvent/getPmEventPublic
* *
* Query: page, pageSize, keyword, createdAtRange, tokenid * Query: page, pageSize, keyword, createdAtRange, tokenid, tagId, tagSlug
* tokenid market.clobTokenIds
*/ */
export async function getPmEventPublic( export async function getPmEventPublic(
params: GetPmEventListParams = {}, params: GetPmEventListParams = {},
): Promise<PmEventListResponse> { ): 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> = { const query: Record<string, string | number | string[] | undefined> = {
page, page,
pageSize, pageSize,
@ -143,6 +146,8 @@ export async function getPmEventPublic(
if (tokenid != null) { if (tokenid != null) {
query.tokenid = Array.isArray(tokenid) ? tokenid : [tokenid] 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) return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
} }

View File

@ -1067,6 +1067,8 @@ async function submitSplit() {
} }
} }
defineExpose({ openMergeDialog, openSplitDialog })
const yesPriceCents = computed(() => (props.market ? Math.round(props.market.yesPrice * 100) : 19)) const yesPriceCents = computed(() => (props.market ? Math.round(props.market.yesPrice * 100) : 19))
const noPriceCents = computed(() => (props.market ? Math.round(props.market.noPrice * 100) : 82)) const noPriceCents = computed(() => (props.market ? Math.round(props.market.noPrice * 100) : 82))

View File

@ -83,17 +83,19 @@
class="buy-yes-btn" class="buy-yes-btn"
variant="flat" variant="flat"
size="small" size="small"
rounded="sm"
@click="openTrade(market, index, 'yes')" @click="openTrade(market, index, 'yes')"
> >
Buy Yes {{ yesPrice(market) }} Yes {{ yesPrice(market) }}
</v-btn> </v-btn>
<v-btn <v-btn
class="buy-no-btn" class="buy-no-btn"
variant="flat" variant="flat"
size="small" size="small"
rounded="sm"
@click="openTrade(market, index, 'no')" @click="openTrade(market, index, 'no')"
> >
Buy No {{ noPrice(market) }} No {{ noPrice(market) }}
</v-btn> </v-btn>
</div> </div>
</div> </div>
@ -113,36 +115,60 @@
</v-col> </v-col>
</v-row> </v-row>
<!-- 移动端 market 时显示固定底部 Buy Yes/No market 时仅通过列表点击弹出 --> <!-- 移动端 market 时显示固定底部 Yes/No + 三点菜单Merge/Split -->
<template v-if="isMobile && markets.length === 1"> <template v-if="isMobile && markets.length === 1">
<div class="mobile-trade-bar-spacer" aria-hidden="true"></div> <div class="mobile-trade-bar-spacer" aria-hidden="true"></div>
<div class="mobile-trade-bar"> <div class="mobile-trade-bar">
<v-btn <v-btn
class="mobile-bar-btn mobile-bar-yes" class="mobile-bar-btn mobile-bar-yes"
variant="flat" variant="flat"
color="success" rounded="sm"
rounded="pill"
block
@click="openSheetWithOption('yes')" @click="openSheetWithOption('yes')"
> >
Buy Yes {{ barMarket ? yesPrice(barMarket) : '0¢' }} Yes {{ barMarket ? yesPrice(barMarket) : '0¢' }}
</v-btn> </v-btn>
<v-btn <v-btn
class="mobile-bar-btn mobile-bar-no" class="mobile-bar-btn mobile-bar-no"
variant="flat" variant="flat"
color="error" rounded="sm"
rounded="pill"
block
@click="openSheetWithOption('no')" @click="openSheetWithOption('no')"
> >
Buy No {{ barMarket ? noPrice(barMarket) : '0¢' }} No {{ barMarket ? noPrice(barMarket) : '0¢' }}
</v-btn> </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> </div>
</template> </template>
<!-- 移动端交易弹窗 market 与多 market 均需 market 时通过列表 Buy Yes/No 打开 --> <!-- 移动端交易弹窗 market 与多 market 均需 market 时通过列表 Buy Yes/No 打开 -->
<template v-if="isMobile && markets.length > 0"> <template v-if="isMobile && markets.length > 0">
<v-bottom-sheet v-model="tradeSheetOpen" content-class="event-markets-trade-sheet"> <v-bottom-sheet v-model="tradeSheetOpen" content-class="event-markets-trade-sheet">
<TradeComponent <TradeComponent
ref="tradeComponentRef"
:key="`trade-${selectedMarketIndex}-${tradeInitialOption}`" :key="`trade-${selectedMarketIndex}-${tradeInitialOption}`"
:market="tradeMarketPayload" :market="tradeMarketPayload"
:initial-option="tradeInitialOption" :initial-option="tradeInitialOption"
@ -188,6 +214,10 @@ const selectedMarketIndex = ref(0)
const tradeInitialOption = ref<'yes' | 'no' | undefined>(undefined) const tradeInitialOption = ref<'yes' | 'no' | undefined>(undefined)
/** 移动端交易弹窗开关 */ /** 移动端交易弹窗开关 */
const tradeSheetOpen = ref(false) const tradeSheetOpen = ref(false)
/** 移动端底部栏三点菜单开关 */
const mobileMenuOpen = ref(false)
/** TradeComponent 引用,用于从底部栏触发 Merge/Split */
const tradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
const markets = computed(() => { const markets = computed(() => {
const list = eventDetail.value?.markets ?? [] const list = eventDetail.value?.markets ?? []
return list.length > 0 ? list : [] return list.length > 0 ? list : []
@ -491,12 +521,30 @@ function openTrade(market: PmEventMarketItem, index: number, side: 'yes' | 'no')
} }
} }
/** 移动端底部栏:点击 Buy Yes/No 时打开交易弹窗 */ /** 移动端底部栏:点击 Yes/No 时打开交易弹窗 */
function openSheetWithOption(side: 'yes' | 'no') { function openSheetWithOption(side: 'yes' | 'no') {
tradeInitialOption.value = side tradeInitialOption.value = side
tradeSheetOpen.value = true 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: { function onTradeSubmit(payload: {
side: 'buy' | 'sell' side: 'buy' | 'sell'
option: 'yes' | 'no' option: 'yes' | 'no'
@ -898,22 +946,39 @@ watch(
.market-row-actions { .market-row-actions {
display: flex; display: flex;
gap: 8px; gap: 10px;
flex-shrink: 0; 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 { .buy-yes-btn {
background-color: #b8e0b8 !important; background-color: #b8e0b8 !important;
color: #006600 !important; color: #006600 !important;
text-transform: none;
font-weight: 500;
} }
.buy-no-btn { .buy-no-btn {
background-color: #f0b8b8 !important; background-color: #f0b8b8 !important;
color: #cc0000 !important; color: #cc0000 !important;
text-transform: none;
font-weight: 500;
} }
/* 移动端底部交易栏 */ /* 移动端底部交易栏 */
@ -929,7 +994,7 @@ watch(
z-index: 100; z-index: 100;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 8px; gap: 10px;
width: 100%; width: 100%;
padding: 12px 16px; padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom)); padding-bottom: max(12px, env(safe-area-inset-bottom));
@ -939,9 +1004,9 @@ watch(
.mobile-bar-btn { .mobile-bar-btn {
border: none; border: none;
border-radius: 9999px; border-radius: 6px;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 500;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
text-transform: none; text-transform: none;
@ -950,16 +1015,29 @@ watch(
.mobile-bar-yes { .mobile-bar-yes {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
background: #16a34a !important; background: #b8e0b8 !important;
color: #fff !important; color: #006600 !important;
padding: 14px 16px; padding: 14px 16px;
} }
.mobile-bar-no { .mobile-bar-no {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
background: #dc2626 !important; background: #f0b8b8 !important;
color: #fff !important; color: #cc0000 !important;
padding: 14px 16px; 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> </style>

View File

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

View File

@ -113,19 +113,78 @@
</v-card> </v-card>
</v-col> </v-col>
<!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口 --> <!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口移动端隐藏改用底部栏+弹窗 -->
<v-col cols="12" class="trade-col"> <v-col v-if="!isMobile" cols="12" class="trade-col">
<div class="trade-sidebar"> <div class="trade-sidebar">
<TradeComponent :market="tradeMarketPayload" :initial-option="tradeInitialOption" /> <TradeComponent ref="tradeComponentRef" :market="tradeMarketPayload" :initial-option="tradeInitialOption" />
</div> </div>
</v-col> </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-row>
</v-container> </v-container>
</template> </template>
<script setup lang="ts"> <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 { useRoute } from 'vue-router'
import { useDisplay } from 'vuetify'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import type { ECharts } from 'echarts' import type { ECharts } from 'echarts'
import OrderBook from '../components/OrderBook.vue' import OrderBook from '../components/OrderBook.vue'
@ -167,6 +226,8 @@ export type ChartIncrement = { point: ChartPoint }
const route = useRoute() const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
const { mobile } = useDisplay()
const isMobile = computed(() => mobile.value)
// GET /PmEvent/findPmEvent // GET /PmEvent/findPmEvent
const eventDetail = ref<PmEventListItem | null>(null) const eventDetail = ref<PmEventListItem | null>(null)
@ -294,6 +355,45 @@ const tradeInitialOption = computed(() => {
return undefined 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 // Comments / Top Holders / Activity
const detailTab = ref('activity') const detailTab = ref('activity')
const activityMinAmount = ref<string>('0') const activityMinAmount = ref<string>('0')
@ -1230,4 +1330,64 @@ onUnmounted(() => {
.activity-link:hover { .activity-link:hover {
color: #6b7280; 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> </style>