diff --git a/src/api/category.ts b/src/api/category.ts index 4cb4d90..15de132 100644 --- a/src/api/category.ts +++ b/src/api/category.ts @@ -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 { - 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(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 { - 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 } } diff --git a/src/api/categoryIcons.ts b/src/api/categoryIcons.ts new file mode 100644 index 0000000..257a287 --- /dev/null +++ b/src/api/categoryIcons.ts @@ -0,0 +1,212 @@ +/** + * 第二层分类图标自动匹配 + * 基于 label/slug 从 MDI(Material Design Icons)查找合适图标 + * 项目已引入 @mdi/font,图标名为 mdi-xxx 格式 + */ + +/** slug/label(小写) -> MDI 图标名 */ +const ICON_MAP: Record = { + // 通用 + 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 = { + 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 +} diff --git a/src/api/event.ts b/src/api/event.ts index f948a13..6d86b13 100644 --- a/src/api/event.ts +++ b/src/api/event.ts @@ -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 { - const { page = 1, pageSize = 10, keyword, createdAtRange, tokenid } = params + const { page = 1, pageSize = 10, keyword, createdAtRange, tokenid, tagId, tagSlug } = params const query: Record = { 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('/PmEvent/getPmEventPublic', query) } diff --git a/src/components/TradeComponent.vue b/src/components/TradeComponent.vue index 0795286..748f077 100644 --- a/src/components/TradeComponent.vue +++ b/src/components/TradeComponent.vue @@ -1067,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)) diff --git a/src/views/EventMarkets.vue b/src/views/EventMarkets.vue index 88a60b5..b996d44 100644 --- a/src/views/EventMarkets.vue +++ b/src/views/EventMarkets.vue @@ -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) }} - Buy No {{ noPrice(market) }} + No {{ noPrice(market) }} @@ -113,36 +115,60 @@ - +