diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 32b6069..5d63001 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -17,8 +17,6 @@ declare module 'vue' { ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElButton: typeof import('element-plus/es')['ElButton'] - ElCarousel: typeof import('element-plus/es')['ElCarousel'] - ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElDialog: typeof import('element-plus/es')['ElDialog'] @@ -35,7 +33,6 @@ declare module 'vue' { ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] - ElPopover: typeof import('element-plus/es')['ElPopover'] ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] diff --git a/frontend/src/composables/home/useFilterOptions.ts b/frontend/src/composables/home/useFilterOptions.ts new file mode 100644 index 0000000..c6a1f76 --- /dev/null +++ b/frontend/src/composables/home/useFilterOptions.ts @@ -0,0 +1,51 @@ +export const coinRangeOptions = [ + { label: '全部区间', min: undefined, max: undefined }, + { label: '0-100', min: 0, max: 100 }, + { label: '100-300', min: 100, max: 300 }, + { label: '300-500', min: 300, max: 500 }, + { label: '500+', min: 500, max: undefined }, +] + +export const moneyRangeOptions = [ + { label: '全部区间', min: undefined, max: undefined }, + { label: '0-50', min: 0, max: 50 }, + { label: '50-100', min: 50, max: 100 }, + { label: '100-200', min: 100, max: 200 }, + { label: '200+', min: 200, max: undefined }, +] + +export const totalRangeOptions = [ + { label: '全部区间', min: undefined, max: undefined }, + { label: '0-500', min: 0, max: 500 }, + { label: '500-1000', min: 500, max: 1000 }, + { label: '1000-2000', min: 1000, max: 2000 }, + { label: '2000-5000', min: 2000, max: 5000 }, +] + +export const fireLevelRangeOptions = [ + { label: '全部等级', min: undefined, max: undefined }, + { label: '38-50', min: 38, max: 50 }, + { label: '50-60', min: 50, max: 60 }, + { label: '60-70', min: 60, max: 70 }, + { label: '70+', min: 70, max: undefined }, +] + +export function rangeLabel( + min: number | undefined, + max: number | undefined, + fallback: string +) { + if (min !== undefined && max !== undefined) return `${min}-${max}` + if (min !== undefined) return `${min}+` + if (max !== undefined) return `≤${max}` + return fallback +} + +export function isRangeSelected( + currentMin: number | undefined, + currentMax: number | undefined, + min: number | undefined, + max: number | undefined +) { + return currentMin === min && currentMax === max +} diff --git a/frontend/src/composables/home/useHomeFilters.ts b/frontend/src/composables/home/useHomeFilters.ts new file mode 100644 index 0000000..67f084e --- /dev/null +++ b/frontend/src/composables/home/useHomeFilters.ts @@ -0,0 +1,208 @@ +import { reactive, ref, computed, type Ref } from 'vue' +import type { ListingPublishOptions } from '@/api/listingOptions' +import type { Listing } from '@/api/listings' +import { assetRegions } from '@/utils/listingDisplay' + +export type FilterPopoverKey = + | 'insurance' + | 'stamina' + | 'load' + | 'region' + | 'coin' + | 'price' + | 'deposit' + | 'total' + | 'skin' + | 'rank' + | 'fireLevel' + | 'loginMethod' + +export type StringFilterKey = + | 'insurance' + | 'stamina' + | 'load' + | 'region' + | 'rank' + | 'loginMethod' + +export interface HomeFilters { + keyword: string + server: string + region: string + loginMethod: string + rank: string + insurance: string + stamina: string + load: string + skinGroup: string + skinName: string + minCoin: number | undefined + maxCoin: number | undefined + minPrice: number | undefined + maxPrice: number | undefined + minDeposit: number | undefined + maxDeposit: number | undefined + minTotal: number | undefined + maxTotal: number | undefined + minFireLevel: number | undefined + maxFireLevel: number | undefined +} + +export function useHomeFilters( + publishOptions: Ref, + listings: Ref +) { + const filters = reactive({ + keyword: '', + server: '', + region: '', + loginMethod: '', + rank: '', + insurance: '', + stamina: '', + load: '', + skinGroup: '', + skinName: '', + minCoin: undefined, + maxCoin: undefined, + minPrice: undefined, + maxPrice: undefined, + minDeposit: undefined, + maxDeposit: undefined, + minTotal: undefined, + maxTotal: undefined, + minFireLevel: undefined, + maxFireLevel: undefined, + }) + + const activeFilterPopover = ref('') + + const regionOptions = computed(() => + uniqueOptions([ + ...publishOptions.value.region_options, + ...listings.value.flatMap((item) => assetRegions(item)), + ]) + ) + + const loginMethodOptions = computed(() => + uniqueOptions( + publishOptions.value.login_method_options + .map((item) => item.trim()) + .filter(Boolean) + ) + ) + + const skinFilterGroups = computed(() => { + const preferred = ['operatorRed', 'operatorGold'] + return preferred + .map((key) => publishOptions.value.skin_groups.find((group) => group.key === key)) + .filter((group): group is ListingPublishOptions['skin_groups'][number] => Boolean(group)) + }) + + const skinChipLabel = computed(() => { + if (filters.skinName) return filters.skinName + if (filters.skinGroup) { + return skinFilterGroups.value.find((group) => group.key === filters.skinGroup)?.title || '皮肤' + } + return '皮肤' + }) + + function uniqueOptions(values: string[]) { + return [...new Set(values.map((item) => item.trim()).filter(Boolean))] + } + + function resetFilters() { + filters.keyword = '' + filters.server = '' + filters.region = '' + filters.loginMethod = '' + filters.rank = '' + filters.insurance = '' + filters.stamina = '' + filters.load = '' + filters.skinGroup = '' + filters.skinName = '' + filters.minCoin = undefined + filters.maxCoin = undefined + filters.minPrice = undefined + filters.maxPrice = undefined + filters.minDeposit = undefined + filters.maxDeposit = undefined + filters.minTotal = undefined + filters.maxTotal = undefined + filters.minFireLevel = undefined + filters.maxFireLevel = undefined + } + + function setStringFilter(key: StringFilterKey, value: string) { + filters[key] = value + closeFilterPopover() + } + + function setCoinRange(min: number | undefined, max: number | undefined) { + filters.minCoin = min + filters.maxCoin = max + } + + function setPriceRange(min: number | undefined, max: number | undefined) { + filters.minPrice = min + filters.maxPrice = max + } + + function setDepositRange(min: number | undefined, max: number | undefined) { + filters.minDeposit = min + filters.maxDeposit = max + } + + function setTotalRange(min: number | undefined, max: number | undefined) { + filters.minTotal = min + filters.maxTotal = max + } + + function setFireLevelRange(min: number | undefined, max: number | undefined) { + filters.minFireLevel = min + filters.maxFireLevel = max + } + + function setSkinFilter(group: string, name = '') { + filters.skinGroup = group + filters.skinName = name + } + + function resetSkinFilter() { + filters.skinGroup = '' + filters.skinName = '' + } + + function setFilterPopover(key: FilterPopoverKey, visible: boolean) { + if (visible) { + activeFilterPopover.value = key + return + } + if (activeFilterPopover.value === key) activeFilterPopover.value = '' + } + + function closeFilterPopover() { + activeFilterPopover.value = '' + } + + return { + filters, + activeFilterPopover, + regionOptions, + loginMethodOptions, + skinFilterGroups, + skinChipLabel, + resetFilters, + setStringFilter, + setCoinRange, + setPriceRange, + setDepositRange, + setTotalRange, + setFireLevelRange, + setSkinFilter, + resetSkinFilter, + setFilterPopover, + closeFilterPopover, + } +} diff --git a/frontend/src/composables/home/useListingQuery.ts b/frontend/src/composables/home/useListingQuery.ts new file mode 100644 index 0000000..07184ca --- /dev/null +++ b/frontend/src/composables/home/useListingQuery.ts @@ -0,0 +1,117 @@ +import { ref, onMounted, onBeforeUnmount, watch, type Ref } from 'vue' +import { fetchListingsPage, type Listing, type PublicListingQuery } from '@/api/listings' +import type { HomeFilters } from './useHomeFilters' + +const homePageSize = 12 + +export function useListingQuery( + filters: HomeFilters, + sortBy: Ref, + activeZone: Ref +) { + const loading = ref(false) + const loadingMore = ref(false) + const listings = ref([]) + const totalListings = ref(0) + const zoneCounts = ref>({}) + const currentPage = ref(1) + const hasMoreListings = ref(true) + let listingRequestSeq = 0 + + function buildListingQuery(page: number): PublicListingQuery { + return { + page, + page_size: homePageSize, + keyword: filters.keyword.trim(), + sort: sortBy.value, + zone: activeZone.value, + server: filters.server, + region: filters.region, + login_method: filters.loginMethod, + rank: filters.rank, + insurance: filters.insurance, + stamina: filters.stamina, + load: filters.load, + skin_group: filters.skinGroup, + skin_name: filters.skinName, + min_coin: filters.minCoin, + max_coin: filters.maxCoin, + min_price: filters.minPrice, + max_price: filters.maxPrice, + min_deposit: filters.minDeposit, + max_deposit: filters.maxDeposit, + min_total: filters.minTotal, + max_total: filters.maxTotal, + min_fire_level: filters.minFireLevel, + max_fire_level: filters.maxFireLevel, + } + } + + function listingQuerySignature() { + return JSON.stringify(buildListingQuery(1)) + } + + async function loadListingsPage(reset = false) { + if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return + const requestSeq = ++listingRequestSeq + if (reset) { + currentPage.value = 1 + hasMoreListings.value = true + } + loadingMore.value = true + try { + const page = await fetchListingsPage(buildListingQuery(currentPage.value)) + if (requestSeq !== listingRequestSeq) return + listings.value = reset ? page.items : [...listings.value, ...page.items] + totalListings.value = page.total + zoneCounts.value = page.zone_counts + hasMoreListings.value = listings.value.length < page.total + currentPage.value = page.page + 1 + requestAnimationFrame(handleWindowScroll) + } catch { + if (reset) { + listings.value = [] + totalListings.value = 0 + zoneCounts.value = {} + hasMoreListings.value = false + } + } finally { + if (requestSeq === listingRequestSeq) { + loadingMore.value = false + } + } + } + + function handleWindowScroll() { + if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 480) return + loadListingsPage(false) + } + + function zoneCount(key: string) { + if (key === 'all') return zoneCounts.value.all ?? totalListings.value + return zoneCounts.value[key] ?? 0 + } + + onMounted(() => { + window.addEventListener('scroll', handleWindowScroll, { passive: true }) + }) + + onBeforeUnmount(() => { + window.removeEventListener('scroll', handleWindowScroll) + }) + + watch(() => listingQuerySignature(), () => { + loadListingsPage(true) + }) + + return { + loading, + loadingMore, + listings, + totalListings, + zoneCounts, + hasMoreListings, + loadListingsPage, + zoneCount, + } +} diff --git a/frontend/src/styles/home-filters.css b/frontend/src/styles/home-filters.css new file mode 100644 index 0000000..d1ba2f1 --- /dev/null +++ b/frontend/src/styles/home-filters.css @@ -0,0 +1,230 @@ +/* 筛选器相关样式 */ +.horizontal-filter-card { + padding: 24px; + border: 1px solid #eef1f5; + border-radius: 16px; + background: #ffffff; + box-shadow: 0 4px 12px rgba(23, 35, 61, 0.03); +} + +.filter-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.filter-title { + display: flex; + align-items: center; + gap: 12px; +} + +.filter-title strong { + font-size: 18px; + font-weight: 800; + color: #17233d; +} + +.filter-title span { + padding: 4px 10px; + border-radius: 6px; + background: #f1f5f9; + font-size: 13px; + font-weight: 700; + color: #64748b; +} + +.filter-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.filter-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: 2px solid #e2e8f0; + border-radius: 8px; + background: #ffffff; + font-size: 13px; + font-weight: 600; + color: #334155; + cursor: pointer; + transition: all 0.2s; +} + +.filter-chip:hover { + border-color: #ff6a00; + background: #fff7ed; +} + +.filter-chip.active { + border-color: #ff6a00; + background: #fff7ed; + color: #ff6a00; +} + +.filter-chip.wide { + min-width: 140px; +} + +/* 筛选器弹窗样式 */ +:global(.home-filter-popover) { + padding: 8px !important; + border-radius: 12px !important; +} + +.filter-menu, +.range-menu { + display: flex; + flex-direction: column; + gap: 4px; +} + +.filter-menu button, +.range-menu button { + padding: 10px 14px; + border: none; + border-radius: 8px; + background: transparent; + text-align: left; + font-size: 13px; + font-weight: 600; + color: #334155; + cursor: pointer; + transition: all 0.15s; +} + +.filter-menu button:hover, +.range-menu button:hover { + background: #f1f5f9; +} + +.filter-menu button.active, +.range-menu button.active { + background: #fff7ed; + color: #ff6a00; + font-weight: 700; +} + +.range-manual { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid #e2e8f0; + margin-top: 4px; +} + +.range-manual :deep(.el-input-number) { + flex: 1; +} + +.range-manual span { + color: #94a3b8; + font-weight: 700; +} + +/* 皮肤筛选器 */ +.skin-filter-menu { + display: flex; + flex-direction: column; + gap: 16px; + max-height: 400px; + overflow-y: auto; +} + +.skin-reset { + padding: 10px 14px; + border: none; + border-radius: 8px; + background: transparent; + text-align: left; + font-size: 13px; + font-weight: 600; + color: #334155; + cursor: pointer; + transition: all 0.15s; +} + +.skin-reset:hover { + background: #f1f5f9; +} + +.skin-reset.active { + background: #fff7ed; + color: #ff6a00; + font-weight: 700; +} + +.skin-filter-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.skin-filter-title { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 4px; +} + +.skin-filter-title strong { + font-size: 13px; + font-weight: 700; + color: #17233d; +} + +.skin-filter-title button { + padding: 4px 8px; + border: none; + border-radius: 6px; + background: transparent; + font-size: 11px; + font-weight: 600; + color: #64748b; + cursor: pointer; + transition: all 0.15s; +} + +.skin-filter-title button:hover { + background: #f1f5f9; +} + +.skin-filter-title button.active { + background: #fff7ed; + color: #ff6a00; +} + +.skin-filter-options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px; +} + +.skin-filter-options button { + padding: 8px 12px; + border: none; + border-radius: 6px; + background: transparent; + text-align: left; + font-size: 12px; + font-weight: 600; + color: #334155; + cursor: pointer; + transition: all 0.15s; +} + +.skin-filter-options button:hover { + background: #f1f5f9; +} + +.skin-filter-options button.active { + background: #fff7ed; + color: #ff6a00; + font-weight: 700; +} diff --git a/frontend/src/views/account/OrdersView.vue b/frontend/src/views/account/OrdersView.vue index 5d7b591..dbe7339 100644 --- a/frontend/src/views/account/OrdersView.vue +++ b/frontend/src/views/account/OrdersView.vue @@ -177,7 +177,7 @@ function getCountdownMinutes(order: Order) { diff --git a/frontend/src/views/public/HomeView.vue b/frontend/src/views/public/HomeView.vue index a1ed134..8af136c 100644 --- a/frontend/src/views/public/HomeView.vue +++ b/frontend/src/views/public/HomeView.vue @@ -1,992 +1,192 @@