Compare commits

..

No commits in common. "f83f0100e0c9ba44298859c97efe00298424b6f5" and "8d103e2d989569f84e40724d34e8c55ae8c367d1" have entirely different histories.

3 changed files with 18 additions and 684 deletions

View File

@ -1,110 +0,0 @@
import { get } from './request'
/** 分类树节点(与后端返回结构一致) */
export interface CategoryTreeNode {
id: string
label: string
slug: string
/** 第二层专用MDI 图标名,如 mdi-view-grid-outline */
icon?: string
/** 第三层展示时的区块标题,如「加密货币」 */
sectionTitle?: string
forceShow?: boolean
forceHide?: boolean
publishedAt?: string
updatedBy?: number
createdAt?: string
updatedAt?: string
children?: CategoryTreeNode[]
}
/** 模拟分类数据:一层(财经)、二层(体育/加密带图标)、三层(政治) */
export const MOCK_CATEGORY_TREE: CategoryTreeNode[] = [
{
id: '1',
label: '政治',
slug: 'politics',
children: [
{
id: '11',
label: '政治1',
slug: 'politics1',
children: [
{ id: '111', label: '政治1-A', slug: 'politics1a', children: [] },
{ id: '112', label: '政治1-B', slug: 'politics1b', children: [] },
],
},
{ id: '12', label: '政治2', slug: 'politics2', children: [] },
],
},
{
id: '2',
label: '体育',
slug: 'sports',
children: [
{ id: '21', label: '足球', slug: 'football', children: [] },
{ id: '22', label: '篮球', slug: 'basketball', children: [] },
],
},
{
id: '3',
label: '加密',
slug: 'crypto',
sectionTitle: '加密货币',
children: [
{
id: '31',
label: '全部',
slug: 'all',
icon: 'mdi-view-grid-outline',
children: [
{ id: '311', label: '全部', slug: 'all', children: [] },
{ id: '312', label: 'Above / Below', slug: 'above-below', children: [] },
{ id: '313', label: 'Up / Down', slug: 'up-down', children: [] },
{ id: '314', label: 'BTC', slug: 'btc', children: [] },
{ id: '315', label: 'ETH', slug: 'eth', children: [] },
],
},
{ id: '32', label: '5分钟', slug: '5m', icon: 'mdi-format-list-bulleted', children: [] },
{ id: '33', label: '15分钟', slug: '15m', icon: 'mdi-clock-outline', children: [] },
{ id: '34', label: '每小时', slug: '1h', icon: 'mdi-refresh', children: [] },
{ id: '35', label: '4小时', slug: '4h', icon: 'mdi-clock-outline', children: [] },
{ id: '36', label: '每天', slug: '1d', icon: 'mdi-calendar', children: [] },
],
},
{ id: '4', label: '财务', slug: 'finance', children: [] },
{ id: '5', label: '地缘政治', slug: 'geopolitics', children: [] },
{ id: '0', label: '最新', slug: 'latest', children: [] },
]
/** 分类树接口响应 */
export interface CategoryTreeResponse {
code: number
data: CategoryTreeNode[]
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 }
}

View File

@ -1,48 +0,0 @@
import { ref, readonly } from 'vue'
const STORAGE_KEY = 'polyclient_search_history'
const MAX_HISTORY = 10
const history = ref<string[]>([])
function loadHistory() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
history.value = raw ? JSON.parse(raw) : []
} catch {
history.value = []
}
}
function saveHistory() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history.value))
} catch {
// ignore
}
}
export function useSearchHistory() {
const list = readonly(history)
function add(keyword: string) {
const k = keyword.trim()
if (!k) return
history.value = [k, ...history.value.filter((h) => h !== k)].slice(0, MAX_HISTORY)
saveHistory()
}
function remove(index: number) {
history.value = history.value.filter((_, i) => i !== index)
saveHistory()
}
function clearAll() {
history.value = []
saveHistory()
}
loadHistory()
return { list, add, remove, clearAll }
}

View File

@ -1,122 +1,13 @@
<template> <template>
<div class="home-page"> <div class="home-page">
<!-- 第一层单行紧凑布局tabs + 搜索/筛选图标 -->
<div v-if="categoryLayers.length > 0" class="home-category-layer1-wrap">
<div class="home-category-layer1-row">
<v-tabs
:model-value="layerActiveValues[0]"
class="home-tab-bar home-tab-bar--inline"
@update:model-value="onCategorySelect(0, $event)"
>
<v-tab v-for="item in categoryLayers[0]" :key="item.id" :value="item.id">
{{ item.label }}
</v-tab>
</v-tabs>
<div class="home-category-layer1-actions">
<v-btn
icon
variant="text"
size="small"
class="home-category-action-btn"
aria-label="搜索"
@click="expandSearch"
>
<v-icon size="20">mdi-magnify</v-icon>
</v-btn>
<v-btn icon variant="text" size="small" class="home-category-action-btn" aria-label="筛选">
<v-icon size="20">mdi-filter-outline</v-icon>
</v-btn>
</div>
</div>
<!-- 搜索展开时浮层输入框 + 历史记录 -->
<transition name="home-search-overlay">
<div v-if="searchExpanded" class="home-search-overlay">
<div class="home-search-overlay-inner">
<v-text-field
ref="searchInputRef"
v-model="searchKeyword"
density="compact"
hide-details
placeholder="Search"
prepend-inner-icon="mdi-magnify"
variant="outlined"
class="home-search-overlay-field"
@blur="onSearchBlur"
@keydown.enter="onSearchSubmit"
/>
<v-btn icon variant="text" size="small" class="home-search-close-btn" aria-label="收起" @click="collapseSearch">
<v-icon size="18">mdi-close</v-icon>
</v-btn>
</div>
<div v-if="searchHistoryList.length > 0" class="home-search-history">
<div class="home-search-history-header">
<span class="home-search-history-title">搜索历史</span>
<v-btn
variant="text"
size="x-small"
color="primary"
class="home-search-history-clear"
@click="searchHistory.clearAll"
>
清空
</v-btn>
</div>
<ul class="home-search-history-list">
<li
v-for="(item, idx) in searchHistoryList"
:key="`${item}-${idx}`"
class="home-search-history-item"
>
<button type="button" class="home-search-history-text" @click="selectHistoryItem(item)">
{{ item }}
</button>
<v-btn
icon
variant="text"
size="x-small"
class="home-search-history-delete"
aria-label="删除"
@click.stop="searchHistory.remove(idx)"
>
<v-icon size="16">mdi-close</v-icon>
</v-btn>
</li>
</ul>
</div>
</div>
</transition>
</div>
<v-container fluid class="home-container"> <v-container fluid class="home-container">
<!-- 第二三层随内容滚动回顶部时与列表第一行一起出现 --> <v-row justify="center" align="center" class="home-tabs">
<div v-if="categoryLayers.length >= 2" class="home-category-layers-23-scroll"> <v-tabs v-model="activeTab" class="home-tab-bar">
<div class="home-category-layer home-category-layer--icon"> <v-tab value="overview">Market Overview</v-tab>
<div class="home-category-icon-row"> <v-tab value="trending">Trending</v-tab>
<button <v-tab value="portfolio">Portfolio</v-tab>
v-for="item in categoryLayers[1]" </v-tabs>
:key="item.id" </v-row>
type="button"
class="home-category-icon-item"
: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" />
<span class="home-category-icon-label">{{ item.label }}</span>
</button>
</div>
</div>
<div v-if="categoryLayers.length >= 3" class="home-category-layer home-category-layer--third">
<v-tabs
:model-value="layerActiveValues[2]"
class="home-tab-bar home-tab-bar--compact"
@update:model-value="onCategorySelect(2, $event)"
>
<v-tab v-for="item in categoryLayers[2]" :key="item.id" :value="item.id">
{{ item.label }}
</v-tab>
</v-tabs>
</div>
</div>
<!-- 可滚动容器作为 v-pull-to-refresh 的父元素组件据此判断 scrollTop 仅在顶部时才响应下拉 --> <!-- 可滚动容器作为 v-pull-to-refresh 的父元素组件据此判断 scrollTop 仅在顶部时才响应下拉 -->
<div ref="scrollRef" class="home-list-scroll"> <div ref="scrollRef" class="home-list-scroll">
<v-pull-to-refresh class="pull-to-refresh" @load="onRefresh"> <v-pull-to-refresh class="pull-to-refresh" @load="onRefresh">
@ -287,139 +178,11 @@ import {
clearEventListCache, clearEventListCache,
type EventCardItem, type EventCardItem,
} from '../api/event' } from '../api/event'
import { getCategoryTree, MOCK_CATEGORY_TREE, type CategoryTreeNode } from '../api/category'
import { useSearchHistory } from '../composables/useSearchHistory'
const { mobile } = useDisplay() const { mobile } = useDisplay()
const searchHistory = useSearchHistory()
const searchHistoryList = computed(() => searchHistory.list.value)
const isMobile = computed(() => mobile.value) const isMobile = computed(() => mobile.value)
/** 分类树(顶层) */ const activeTab = ref('overview')
const categoryTree = ref<CategoryTreeNode[]>([])
/** 每层选中的 id[layer0, layer1?, layer2?] */
const layerActiveValues = ref<string[]>([])
/** 第三层搜索框是否展开 */
const searchExpanded = ref(false)
/** 搜索关键词 */
const searchKeyword = ref('')
const searchInputRef = ref<{ focus: () => void } | null>(null)
function expandSearch() {
searchExpanded.value = true
nextTick(() => {
const el = searchInputRef.value as { focus?: () => void } | null
el?.focus?.()
})
}
function collapseSearch() {
searchExpanded.value = false
searchKeyword.value = ''
if (activeSearchKeyword.value) {
activeSearchKeyword.value = ''
clearEventListCache()
eventPage.value = 1
loadEvents(1, false, '')
}
}
function onSearchBlur() {
setTimeout(() => {
if (!searchKeyword.value.trim()) searchExpanded.value = false
}, 100)
}
function onSearchSubmit() {
const k = searchKeyword.value.trim()
if (k) {
searchHistory.add(k)
doSearch(k)
}
}
function selectHistoryItem(item: string) {
searchKeyword.value = item
searchHistory.add(item)
doSearch(item)
}
function doSearch(keyword: string) {
activeSearchKeyword.value = keyword
clearEventListCache()
eventPage.value = 1
loadEvents(1, false, keyword)
}
/** 过滤 forceHide 的节点 */
function filterVisible(nodes: CategoryTreeNode[] | undefined): CategoryTreeNode[] {
if (!nodes?.length) return []
return nodes.filter((n) => !n.forceHide)
}
/** 第三层区块标题:取当前选中的第一层节点的 sectionTitle 或 label */
const selectedLayer0SectionTitle = computed(() => {
const root = filterVisible(categoryTree.value)
const id = layerActiveValues.value[0]
const node = id ? root.find((n) => n.id === id) : root[0]
return node?.sectionTitle ?? node?.label ?? ''
})
/** 当前展示的层级数据:[[layer0], [layer1]?, [layer2]?] */
const categoryLayers = computed(() => {
const root = filterVisible(categoryTree.value)
if (root.length === 0) return []
const layers: CategoryTreeNode[][] = [root]
const active = layerActiveValues.value
let currentNodes = root
for (let i = 0; i < 2; i++) {
const selectedId = active[i]
const node = selectedId ? currentNodes.find((n) => n.id === selectedId) : currentNodes[0]
const children = filterVisible(node?.children)
if (children.length === 0) break
layers.push(children)
currentNodes = children
}
return layers
})
/** 分类选中时:若有 children 则展开下一层并默认选中第一个 */
function onCategorySelect(layerIndex: number, selectedId: string) {
const nextValues = [...layerActiveValues.value]
nextValues[layerIndex] = selectedId
nextValues.length = layerIndex + 1
const layers = categoryLayers.value
const layer = layers[layerIndex]
const node = layer?.find((n) => n.id === selectedId)
const children = filterVisible(node?.children)
const firstChild = children[0]
if (firstChild && layerIndex < 2) {
nextValues.push(firstChild.id)
}
layerActiveValues.value = nextValues
}
/** 初始化分类选中:默认选中第一个,若有 children 则递归展开 */
function initCategorySelection() {
const root = filterVisible(categoryTree.value)
if (root.length === 0) return
const values: string[] = []
let current = root
for (let i = 0; i < 3; i++) {
const first = current[0]
if (!first) break
values.push(first.id)
const children = filterVisible(first.children)
if (children.length === 0) break
current = children
}
layerActiveValues.value = values
}
const PAGE_SIZE = 10 const PAGE_SIZE = 10
@ -485,15 +248,11 @@ function updateGridColumns() {
gridColumns.value = Math.max(1, n) gridColumns.value = Math.max(1, n)
} }
/** 当前生效的搜索关键词(用于分页加载) */
const activeSearchKeyword = ref('')
/** 请求事件列表并追加或覆盖到 eventList公开接口无需鉴权成功后会更新内存缓存 */ /** 请求事件列表并追加或覆盖到 eventList公开接口无需鉴权成功后会更新内存缓存 */
async function loadEvents(page: number, append: boolean, keyword?: string) { async function loadEvents(page: number, append: boolean) {
const kw = keyword !== undefined ? keyword : activeSearchKeyword.value
try { try {
const res = await getPmEventPublic( const res = await getPmEventPublic(
{ page, pageSize: PAGE_SIZE, keyword: kw || undefined } { page, pageSize: PAGE_SIZE }
) )
if (res.code !== 0 && res.code !== 200) { if (res.code !== 0 && res.code !== 200) {
throw new Error(res.msg || '请求失败') throw new Error(res.msg || '请求失败')
@ -527,7 +286,7 @@ async function loadEvents(page: number, append: boolean, keyword?: string) {
function onRefresh({ done }: { done: () => void }) { function onRefresh({ done }: { done: () => void }) {
clearEventListCache() clearEventListCache()
eventPage.value = 1 eventPage.value = 1
loadEvents(1, false, activeSearchKeyword.value).finally(() => done()) loadEvents(1, false).finally(() => done())
} }
function loadMore() { function loadMore() {
@ -547,28 +306,6 @@ function checkScrollLoad() {
} }
onMounted(() => { onMounted(() => {
/** 开发时设为 true 可始终使用模拟数据查看一二三层 UI */
const USE_MOCK_CATEGORY = true
if (USE_MOCK_CATEGORY) {
categoryTree.value = MOCK_CATEGORY_TREE
initCategorySelection()
} else {
getCategoryTree()
.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
@ -730,261 +467,16 @@ onUnmounted(() => {
margin-top: 40px; margin-top: 40px;
} }
/* 第一层:置顶、全宽 */
.home-category-layer1-wrap {
width: 100vw;
max-width: 100vw;
margin-left: calc(50% - 50vw);
position: sticky;
top: 64px;
z-index: 10;
background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.home-category-layer1-row {
display: flex;
align-items: center;
min-height: 48px;
}
.home-tab-bar--inline {
flex: 1;
min-width: 0;
}
.home-tab-bar--inline :deep(.v-tabs) {
min-height: 48px;
}
.home-tab-bar--inline :deep(.v-tab) {
min-height: 48px;
}
.home-category-layer1-actions {
display: flex;
flex-shrink: 0;
gap: 0;
}
/* 搜索浮层:绝对定位,遮挡第二三层,不顶开布局 */
.home-search-overlay {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 11;
padding: 8px 12px 12px;
background-color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.home-search-overlay-inner {
display: flex;
align-items: center;
gap: 8px;
}
.home-search-overlay-field {
flex: 1;
min-width: 0;
font-size: 14px;
}
.home-search-close-btn {
flex-shrink: 0;
}
.home-search-overlay-field :deep(.v-field) {
background-color: #f8fafc;
}
.home-search-overlay-enter-active,
.home-search-overlay-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.home-search-overlay-enter-from,
.home-search-overlay-leave-to {
opacity: 0;
}
.home-search-history {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.home-search-history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.home-search-history-title {
font-size: 12px;
color: #64748b;
}
.home-search-history-clear {
min-width: auto;
padding: 0 4px;
font-size: 12px;
}
.home-search-history-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 160px;
overflow-y: auto;
}
.home-search-history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.home-search-history-item:last-child {
border-bottom: none;
}
.home-search-history-text {
flex: 1;
min-width: 0;
padding: 0;
border: none;
background: none;
font-size: 14px;
color: #1e293b;
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-search-history-text:hover {
color: rgb(var(--v-theme-primary));
}
.home-search-history-delete {
flex-shrink: 0;
min-width: 28px;
color: #94a3b8;
}
.home-search-history-delete:hover {
color: #ef4444;
}
/* 第二三层:随内容滚动,全宽 */
.home-category-layers-23-scroll {
width: 100vw;
max-width: 100vw;
margin-left: calc(50% - 50vw);
margin-bottom: 0;
background-color: white;
}
.home-category-layer {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.home-category-layer:last-child {
border-bottom: none;
}
.home-category-layer--text :deep(.v-tabs) {
min-height: 48px;
}
.home-category-layer--icon {
padding: 12px 16px;
}
.home-category-icon-row {
display: flex;
flex-wrap: nowrap;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
scrollbar-width: none;
}
.home-category-icon-row::-webkit-scrollbar {
display: none;
}
.home-category-icon-item {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 56px;
padding: 8px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.home-category-icon-item:hover {
background-color: rgba(0, 0, 0, 0.04);
color: #334155;
}
.home-category-icon-item--active {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
.home-category-icon {
flex-shrink: 0;
}
.home-category-icon-placeholder {
display: block;
width: 24px;
height: 24px;
flex-shrink: 0;
}
.home-category-icon-label {
white-space: nowrap;
}
.home-category-layer--third {
padding: 12px 16px 0;
}
.home-category-action-btn {
min-width: 36px;
}
.home-tab-bar { .home-tab-bar {
position: relative; position: fixed;
top: auto; top: 64px; /* Adjust based on app bar height */
left: auto; left: 0;
transform: none; transform: none;
width: 100%; width: 100%;
background-color: transparent; z-index: 10;
margin-bottom: 0; background-color: white;
box-shadow: none; margin-bottom: 20px;
} box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.home-tab-bar--compact :deep(.v-tab) {
min-height: 40px;
font-size: 14px;
} }
.home-card { .home-card {