diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue index af5896c..6c1aa33 100644 --- a/frontend/src/features/auth/views/MobileProfileView.vue +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -327,7 +327,7 @@ function resolveAvatarURL(url: string | undefined | null) { 全部订单 -
+
待支付 @@ -344,6 +344,10 @@ function resolveAvatarURL(url: string | undefined | null) {
已完成
+
+
+ 我的足迹 +
@@ -791,6 +795,10 @@ function resolveAvatarURL(url: string | undefined | null) { flex: 0 0 25%; } +.grid-menu.col-5 .grid-item { + flex: 0 0 20%; +} + .grid-menu.col-3 .grid-item { flex: 0 0 33.33%; } diff --git a/frontend/src/features/listings/composables/useListingCollections.ts b/frontend/src/features/listings/composables/useListingCollections.ts new file mode 100644 index 0000000..669a3ec --- /dev/null +++ b/frontend/src/features/listings/composables/useListingCollections.ts @@ -0,0 +1,122 @@ +import { computed, ref } from 'vue' + +import type { Listing } from '@/features/listings/api/listings' + +const historyKey = 'hfb.listing.view.history' +const favoriteKey = 'hfb.listing.favorites' +const maxHistoryItems = 80 + +export interface ListingCollectionItem { + listing: Listing + viewed_at?: string + favorited_at?: string +} + +const historyItems = ref(readItems(historyKey)) +const favoriteItems = ref(readItems(favoriteKey)) + +export function useListingCollections() { + const history = computed(() => historyItems.value) + const favorites = computed(() => favoriteItems.value) + const favoriteIDs = computed(() => new Set(favoriteItems.value.map(item => item.listing.id))) + + function recordListingView(listing: Listing) { + const nextItem: ListingCollectionItem = { + listing: snapshotListing(listing), + viewed_at: new Date().toISOString(), + } + historyItems.value = [ + nextItem, + ...historyItems.value.filter(item => item.listing.id !== listing.id), + ].slice(0, maxHistoryItems) + writeItems(historyKey, historyItems.value) + } + + function isFavorite(listingID: number) { + return favoriteIDs.value.has(listingID) + } + + function toggleFavorite(listing: Listing) { + if (isFavorite(listing.id)) { + favoriteItems.value = favoriteItems.value.filter(item => item.listing.id !== listing.id) + writeItems(favoriteKey, favoriteItems.value) + return false + } + favoriteItems.value = [ + { + listing: snapshotListing(listing), + favorited_at: new Date().toISOString(), + }, + ...favoriteItems.value.filter(item => item.listing.id !== listing.id), + ] + writeItems(favoriteKey, favoriteItems.value) + return true + } + + function removeHistory(listingID: number) { + historyItems.value = historyItems.value.filter(item => item.listing.id !== listingID) + writeItems(historyKey, historyItems.value) + } + + function removeFavorite(listingID: number) { + favoriteItems.value = favoriteItems.value.filter(item => item.listing.id !== listingID) + writeItems(favoriteKey, favoriteItems.value) + } + + function clearHistory() { + historyItems.value = [] + writeItems(historyKey, historyItems.value) + } + + return { + history, + favorites, + favoriteIDs, + recordListingView, + isFavorite, + toggleFavorite, + removeHistory, + removeFavorite, + clearHistory, + } +} + +function readItems(key: string): ListingCollectionItem[] { + if (typeof localStorage === 'undefined') return [] + try { + const raw = localStorage.getItem(key) + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed + .map(normalizeItem) + .filter((item): item is ListingCollectionItem => Boolean(item)) + } catch { + localStorage.removeItem(key) + return [] + } +} + +function normalizeItem(value: unknown): ListingCollectionItem | null { + if (!isRecord(value) || !isRecord(value.listing)) return null + const id = Number(value.listing.id) + if (!Number.isFinite(id) || id <= 0) return null + return { + listing: value.listing as unknown as Listing, + viewed_at: typeof value.viewed_at === 'string' ? value.viewed_at : undefined, + favorited_at: typeof value.favorited_at === 'string' ? value.favorited_at : undefined, + } +} + +function writeItems(key: string, items: ListingCollectionItem[]) { + if (typeof localStorage === 'undefined') return + localStorage.setItem(key, JSON.stringify(items)) +} + +function snapshotListing(listing: Listing): Listing { + return JSON.parse(JSON.stringify(listing)) as Listing +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/frontend/src/features/listings/index.ts b/frontend/src/features/listings/index.ts index 8e8603e..40f056f 100644 --- a/frontend/src/features/listings/index.ts +++ b/frontend/src/features/listings/index.ts @@ -5,3 +5,4 @@ export * from './api/homeConfig' export * from './composables/useHomeFilters' export * from './composables/useFilterOptions' export * from './composables/useListingQuery' +export * from './composables/useListingCollections' diff --git a/frontend/src/features/listings/views/ListingCollectionsView.vue b/frontend/src/features/listings/views/ListingCollectionsView.vue new file mode 100644 index 0000000..d55dd43 --- /dev/null +++ b/frontend/src/features/listings/views/ListingCollectionsView.vue @@ -0,0 +1,372 @@ + + + + + diff --git a/frontend/src/features/listings/views/ListingDetailView.vue b/frontend/src/features/listings/views/ListingDetailView.vue index 3f81cd2..a1d6213 100644 --- a/frontend/src/features/listings/views/ListingDetailView.vue +++ b/frontend/src/features/listings/views/ListingDetailView.vue @@ -3,8 +3,10 @@ import { readError } from '@/shared/utils/error' import { ElMessage, ElMessageBox } from 'element-plus' import { computed, nextTick, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { Star, StarFilled } from '@element-plus/icons-vue' import { fetchListing, type Listing } from '@/features/listings/api/listings' +import { useListingCollections } from '@/features/listings/composables/useListingCollections' import { createOrder, fetchOrderAgreements, @@ -50,6 +52,7 @@ const virtualAgreementChecked = ref(false) const renterAgreementChecked = ref(false) const virtualAgreementRef = ref(null) const renterAgreementRef = ref(null) +const { recordListingView, isFavorite, toggleFavorite } = useListingCollections() const canCreateOrderAfterAgreement = computed( () => @@ -64,11 +67,14 @@ onMounted(async () => { loading.value = true try { listing.value = await fetchListing(String(route.params.id)) + recordListingView(listing.value) } finally { loading.value = false } }) +const listingFavorited = computed(() => (listing.value ? isFavorite(listing.value.id) : false)) + const orderTotal = computed(() => { if (!listing.value) return '0.0' return formatMoney(getListingDisplayPrice(listing.value)) @@ -326,6 +332,12 @@ watch(agreementVisible, visible => { function listingPrice(item: Listing) { return formatMoney(getListingDisplayPrice(item)) } + +function handleToggleFavorite() { + if (!listing.value) return + const added = toggleFavorite(listing.value) + ElMessage.success(added ? '已收藏账号' : '已取消收藏') +}