feat: P2阶段完成 - listings和auth模块迁移

## P2.1: 商品浏览模块(listings)

### 完整迁移(23个文件)
- API: listings.ts, listingOptions.ts, homeConfig.ts
- Views: 5个页面(HomeView, ListingsView, ListingDetailView + 移动端2个)
- Composables: 3个(useHomeFilters, useFilterOptions, useListingQuery)
- Components: 9个(ListingCard, 各种过滤器组件)
- Tests: 2个测试文件

**功能:**
- 首页浏览、商品列表、详情查看
- 高级筛选(服务器、等级、哈夫币、皮肤等)
- 排序和分区功能
- 横幅、公告、统计展示

**技术改进:**
- 更新所有导入路径到 @/shared/
- 建立清晰的模块导出

---

## P2.2: 用户认证模块(auth)

### 完整迁移(12个文件)
- API: auth.ts, realname.ts, notifications.ts
- Views: 8个页面(登录、注册、个人资料、实名认证、通知 + 移动端)
- 模块导出

**功能:**
- 用户注册、登录、Token管理
- 实名认证、风险检查
- 个人资料编辑、头像上传
- 消息/通知中心

**技术改进:**
- 统一 API 导入路径
- 类型安全增强

---

## 里程碑

🎉 **P2 阶段完成!**

**累计完成:** 6个模块
-  P0: shared(基础设施)- 22个文件
-  P1: wallet, chats, orders - 24个文件
-  P2: listings, auth - 35个文件

**总计:** 81个文件已迁移

**剩余:** P3阶段(seller, disputes, admin)+ 清理工作

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-06-04 08:56:42 +08:00
co-authored by Claude Opus 4.7
parent f415832c19
commit 405abfa4f7
35 changed files with 9932 additions and 0 deletions
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { rangeLabel, isRangeSelected } from '@/composables/home/useFilterOptions'
describe('useFilterOptions', () => {
describe('rangeLabel', () => {
it('应该返回双向范围标签', () => {
expect(rangeLabel(100, 500, '哈夫币')).toBe('100-500')
})
it('应该返回最小值+标签', () => {
expect(rangeLabel(500, undefined, '哈夫币')).toBe('500+')
})
it('应该返回最大值标签', () => {
expect(rangeLabel(undefined, 500, '哈夫币')).toBe('≤500')
})
it('应该返回默认标签', () => {
expect(rangeLabel(undefined, undefined, '哈夫币')).toBe('哈夫币')
})
})
describe('isRangeSelected', () => {
it('应该正确判断范围是否选中', () => {
expect(isRangeSelected(100, 500, 100, 500)).toBe(true)
expect(isRangeSelected(100, 500, 200, 500)).toBe(false)
expect(isRangeSelected(undefined, undefined, undefined, undefined)).toBe(true)
})
})
})
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { useHomeFilters } from '@/composables/home/useHomeFilters'
import { ref } from 'vue'
import { emptyListingPublishOptions } from '@/api/listingOptions'
describe('useHomeFilters', () => {
it('应该初始化空的筛选器', () => {
const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([])
const { filters } = useHomeFilters(publishOptions, listings)
expect(filters.keyword).toBe('')
expect(filters.minCoin).toBeUndefined()
expect(filters.maxCoin).toBeUndefined()
})
it('应该正确重置筛选器', () => {
const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([])
const { filters, resetFilters } = useHomeFilters(publishOptions, listings)
filters.keyword = '测试'
filters.minCoin = 100
filters.region = '北京'
resetFilters()
expect(filters.keyword).toBe('')
expect(filters.minCoin).toBeUndefined()
expect(filters.region).toBe('')
})
it('应该正确设置字符串筛选器', () => {
const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([])
const { filters, setStringFilter, closeFilterPopover } = useHomeFilters(publishOptions, listings)
setStringFilter('region', '上海')
expect(filters.region).toBe('上海')
})
it('应该正确设置范围筛选器', () => {
const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([])
const { filters, setCoinRange } = useHomeFilters(publishOptions, listings)
setCoinRange(100, 500)
expect(filters.minCoin).toBe(100)
expect(filters.maxCoin).toBe(500)
})
})
@@ -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
}
@@ -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 '@/shared/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<ListingPublishOptions>,
listings: Ref<Listing[]>
) {
const filters = reactive<HomeFilters>({
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<FilterPopoverKey | ''>('')
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,
}
}
@@ -0,0 +1,122 @@
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<string>,
activeZone: Ref<string>
) {
const loading = ref(false)
const loadingMore = ref(false)
const listings = ref<Listing[]>([])
const totalListings = ref(0)
const zoneCounts = ref<Record<string, number>>({})
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)
})
// 使用防抖优化搜索
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => listingQuerySignature(), () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
loadListingsPage(true)
}, 300)
})
return {
loading,
loadingMore,
listings,
totalListings,
zoneCounts,
hasMoreListings,
loadListingsPage,
zoneCount,
}
}