From 82352444d5335026ceefa520e7249478f3bc6e0f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 18:04:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(listings):=20=E6=96=B0=E5=A2=9E=E6=88=91?= =?UTF-8?q?=E7=9A=84=E8=B6=B3=E8=BF=B9=E6=B5=8F=E8=A7=88=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E4=B8=8E=E6=94=B6=E8=97=8F=E8=B4=A6=E5=8F=B7=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/AppRouter.tsx | 6 + .../listings/ListingCollectionsPage.tsx | 168 +++++++++++++++++ .../features/listings/ListingDetailPage.tsx | 29 ++- frontend/src/features/listings/collections.ts | 174 ++++++++++++++++++ frontend/src/features/profile/ProfilePage.tsx | 2 + frontend/src/layouts/StorefrontLayout.tsx | 5 + frontend/src/styles/global.css | 1 + frontend/src/styles/pc-collections.css | 161 ++++++++++++++++ 8 files changed, 542 insertions(+), 4 deletions(-) create mode 100644 frontend/src/features/listings/ListingCollectionsPage.tsx create mode 100644 frontend/src/features/listings/collections.ts create mode 100644 frontend/src/styles/pc-collections.css diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 2efe437..89b605b 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -13,6 +13,11 @@ const ListingDetailPage = lazy(() => default: module.ListingDetailPage, })) ) +const ListingCollectionsPage = lazy(() => + import('@/features/listings/ListingCollectionsPage').then(module => ({ + default: module.ListingCollectionsPage, + })) +) const SellerListingsPage = lazy(() => import('@/features/seller/SellerListingsPage').then(module => ({ default: module.SellerListingsPage, @@ -481,6 +486,7 @@ export function AppRouter() { }> } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/listings/ListingCollectionsPage.tsx b/frontend/src/features/listings/ListingCollectionsPage.tsx new file mode 100644 index 0000000..8f71051 --- /dev/null +++ b/frontend/src/features/listings/ListingCollectionsPage.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react' +import { App, Button, Card, Empty, Segmented, Space, Tag, Typography } from 'antd' +import { DeleteOutlined, StarFilled, StarOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router-dom' + +import { + useListingCollections, + type CollectionItem, +} from '@/features/listings/collections' +import { formatDateTime, formatMoney } from '@/shared/utils/format' + +type TabKey = 'history' | 'favorites' + +function timeText(item: CollectionItem, tab: TabKey) { + return formatDateTime(tab === 'favorites' ? item.favorited_at : item.viewed_at) +} + +export function ListingCollectionsPage() { + const navigate = useNavigate() + const { message, modal } = App.useApp() + const { history, favorites, isFavorite, toggleFavorite, removeHistory, removeFavorite, clearHistory } = + useListingCollections() + const [tab, setTab] = useState('history') + + const items = tab === 'favorites' ? favorites : history + + const onClear = () => { + modal.confirm({ + title: '清空浏览记录', + content: '确认清空全部浏览记录吗?收藏的账号不受影响。', + okText: '清空', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: () => { + clearHistory() + message.success('浏览记录已清空') + }, + }) + } + + const onToggle = (item: CollectionItem) => { + const added = toggleFavorite(item.listing) + message.success(added ? '已收藏' : '已取消收藏') + } + + const onRemove = (item: CollectionItem) => { + if (tab === 'favorites') { + removeFavorite(item.listing.id) + message.success('已取消收藏') + return + } + removeHistory(item.listing.id) + message.success('已移除记录') + } + + return ( +
+
+
+ ACCOUNT MEMORY + + 我的足迹 + + + 找回看过和收藏过的账号,数据保存在当前浏览器。 + +
+ {tab === 'history' && history.length > 0 ? ( + + ) : null} +
+ + setTab(value as TabKey)} + options={[ + { label: `浏览记录 ${history.length}`, value: 'history' }, + { label: `收藏账号 ${favorites.length}`, value: 'favorites' }, + ]} + /> + + {items.length === 0 ? ( + + + + + + ) : ( +
+ {items.map(item => ( + +
{timeText(item, tab)}
+
+ + +
+ + {item.listing.listing_no} + + navigate(`/listings/${item.listing.id}`)} + > + {item.listing.title} + + + {item.listing.server_region || '区服待定'} + {item.listing.login_platform || '登录方式待定'} + {item.listing.rank_level ? ( + {item.listing.rank_level} + ) : null} + + {item.listing.in_transaction ? '租赁中' : '可租'} + + +
+ 哈夫币 {item.listing.haf_coin_amount?.toLocaleString() ?? '—'} + 押金 {formatMoney(item.listing.deposit_amount_cent)} +
+
+ +
+ + {formatMoney(item.listing.price_cent)} + + + + + + +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/features/listings/ListingDetailPage.tsx b/frontend/src/features/listings/ListingDetailPage.tsx index 3147893..ee3e78f 100644 --- a/frontend/src/features/listings/ListingDetailPage.tsx +++ b/frontend/src/features/listings/ListingDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Alert, App, @@ -20,12 +20,15 @@ import { ArrowLeftOutlined, SafetyCertificateOutlined, ShoppingOutlined, + StarFilled, + StarOutlined, WalletOutlined, } from '@ant-design/icons' import { Link, useNavigate, useParams } from 'react-router-dom' import { BrandLogo } from '@/components/BrandLogo' import { useListingDetail } from '@/features/listings/api' +import { recordListingView, useListingCollections } from '@/features/listings/collections' import { createOrder, useOrderAgreements, @@ -60,8 +63,10 @@ function galleryImages(item: Listing) { export function ListingDetailPage() { const { id } = useParams() const navigate = useNavigate() + const { message } = App.useApp() const { modal } = App.useApp() const { data, isPending, isError, refetch } = useListingDetail(id) + const { isFavorite, toggleFavorite } = useListingCollections() const user = useSessionStore(state => state.user) const token = useSessionStore(state => state.token) const agreements = useOrderAgreements() @@ -73,6 +78,10 @@ export function ListingDetailPage() { const [agreementTitle, setAgreementTitle] = useState('') const [agreementContent, setAgreementContent] = useState('') + useEffect(() => { + if (data) recordListingView(data) + }, [data]) + const showAgreement = (title: string, content?: string) => { setAgreementTitle(title) setAgreementContent(content || '暂无内容') @@ -206,9 +215,21 @@ export function ListingDetailPage() {
{data.listing_no} - - {data.in_transaction ? '租赁中' : '可租'} - + + + {data.in_transaction ? '租赁中' : '可租'} + + +
{data.title} diff --git a/frontend/src/features/listings/collections.ts b/frontend/src/features/listings/collections.ts new file mode 100644 index 0000000..5661cb2 --- /dev/null +++ b/frontend/src/features/listings/collections.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useState } from 'react' + +import type { Listing } from '@/shared/api/types' + +const HISTORY_KEY = 'jieyou.listing.view.history' +const FAVORITE_KEY = 'jieyou.listing.favorites' +const MAX_HISTORY = 80 +const SYNC_EVENT = 'jieyou:listing-collections' + +/** 足迹只保存展示所需的字段快照,避免商品下线后列表整块空白。 */ +export interface ListingSnapshot { + id: number + listing_no: string + title: string + cover_url: string + price_cent: number + deposit_amount_cent: number + haf_coin_amount: number + server_region: string + login_platform: string + rank_level: string + in_transaction: boolean + status: string +} + +export interface CollectionItem { + listing: ListingSnapshot + viewed_at?: string + favorited_at?: string +} + +function toSnapshot(listing: Listing): ListingSnapshot { + return { + id: listing.id, + listing_no: listing.listing_no, + title: listing.title, + cover_url: listing.cover_url, + price_cent: listing.price_cent, + deposit_amount_cent: listing.deposit_amount_cent, + haf_coin_amount: listing.haf_coin_amount, + server_region: listing.server_region, + login_platform: listing.login_platform, + rank_level: listing.rank_level, + in_transaction: listing.in_transaction, + status: listing.status, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function normalizeItem(value: unknown): CollectionItem | null { + if (!isRecord(value) || !isRecord(value.listing)) return null + const listing = value.listing as unknown as ListingSnapshot + const id = Number(listing.id) + if (!Number.isFinite(id) || id <= 0) return null + return { + listing: { + ...listing, + id, + title: typeof listing.title === 'string' ? listing.title : '', + listing_no: typeof listing.listing_no === 'string' ? listing.listing_no : '', + cover_url: typeof listing.cover_url === 'string' ? listing.cover_url : '', + }, + viewed_at: typeof value.viewed_at === 'string' ? value.viewed_at : undefined, + favorited_at: typeof value.favorited_at === 'string' ? value.favorited_at : undefined, + } +} + +function readItems(key: string): CollectionItem[] { + 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 CollectionItem => Boolean(item)) + } catch { + localStorage.removeItem(key) + return [] + } +} + +function writeItems(key: string, items: CollectionItem[]) { + if (typeof localStorage === 'undefined') return + localStorage.setItem(key, JSON.stringify(items)) + window.dispatchEvent(new Event(SYNC_EVENT)) +} + +export function recordListingView(listing: Listing) { + const items = readItems(HISTORY_KEY).filter(item => item.listing.id !== listing.id) + writeItems( + HISTORY_KEY, + [{ listing: toSnapshot(listing), viewed_at: new Date().toISOString() }, ...items].slice( + 0, + MAX_HISTORY + ) + ) +} + +export function toggleListingFavorite(listing: Listing | ListingSnapshot) { + const favorites = readItems(FAVORITE_KEY) + const exists = favorites.some(item => item.listing.id === listing.id) + if (exists) { + writeItems( + FAVORITE_KEY, + favorites.filter(item => item.listing.id !== listing.id) + ) + return false + } + const snapshot = 'owner_id' in listing ? toSnapshot(listing) : listing + writeItems( + FAVORITE_KEY, + [ + { listing: snapshot, favorited_at: new Date().toISOString() }, + ...favorites.filter(item => item.listing.id !== listing.id), + ].slice(0, MAX_HISTORY) + ) + return true +} + +export function removeHistoryItem(listingID: number) { + writeItems( + HISTORY_KEY, + readItems(HISTORY_KEY).filter(item => item.listing.id !== listingID) + ) +} + +export function removeFavoriteItem(listingID: number) { + writeItems( + FAVORITE_KEY, + readItems(FAVORITE_KEY).filter(item => item.listing.id !== listingID) + ) +} + +export function clearListingHistory() { + writeItems(HISTORY_KEY, []) +} + +export function useListingCollections() { + const [history, setHistory] = useState([]) + const [favorites, setFavorites] = useState([]) + + const sync = useCallback(() => { + setHistory(readItems(HISTORY_KEY)) + setFavorites(readItems(FAVORITE_KEY)) + }, []) + + useEffect(() => { + sync() + window.addEventListener(SYNC_EVENT, sync) + window.addEventListener('storage', sync) + return () => { + window.removeEventListener(SYNC_EVENT, sync) + window.removeEventListener('storage', sync) + } + }, [sync]) + + const favoriteIDs = new Set(favorites.map(item => item.listing.id)) + + return { + history, + favorites, + isFavorite: (id: number) => favoriteIDs.has(id), + recordView: recordListingView, + toggleFavorite: toggleListingFavorite, + removeHistory: removeHistoryItem, + removeFavorite: removeFavoriteItem, + clearHistory: clearListingHistory, + } +} diff --git a/frontend/src/features/profile/ProfilePage.tsx b/frontend/src/features/profile/ProfilePage.tsx index 42cefa6..2d85f19 100644 --- a/frontend/src/features/profile/ProfilePage.tsx +++ b/frontend/src/features/profile/ProfilePage.tsx @@ -3,6 +3,7 @@ import { App, Avatar, Button, Card, Col, Descriptions, Row, Space, Tag, Typograp import { ArrowRightOutlined, BellOutlined, + HistoryOutlined, IdcardOutlined, MessageOutlined, ProfileOutlined, @@ -25,6 +26,7 @@ const realnameMeta: Record = { } const quickEntries = [ + { key: 'collections', label: '我的足迹', hint: '浏览记录与收藏账号', icon: , to: '/listings/collections' }, { key: 'orders', label: '我的订单', hint: '查看租号进度与交接记录', icon: , to: '/orders' }, { key: 'messages', label: '消息中心', hint: '账号群聊与客服会话', icon: , to: '/messages' }, { key: 'wallet', label: '我的钱包', hint: '余额、流水与提现', icon: , to: '/wallet' }, diff --git a/frontend/src/layouts/StorefrontLayout.tsx b/frontend/src/layouts/StorefrontLayout.tsx index f8c15d2..094bfa4 100644 --- a/frontend/src/layouts/StorefrontLayout.tsx +++ b/frontend/src/layouts/StorefrontLayout.tsx @@ -54,6 +54,11 @@ export function StorefrontLayout() { label: '实名认证', onClick: () => navigate('/realname'), }, + { + key: 'collections', + label: '我的足迹', + onClick: () => navigate('/listings/collections'), + }, { key: 'orders', label: '我的订单', diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index a40e1e0..63d5e38 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -19,6 +19,7 @@ @import './mobile-chat.css'; @import './pc-messages.css'; @import './pc-account.css'; +@import './pc-collections.css'; @import './seller.css'; @import './mobile-seller.css'; @import './components.css'; diff --git a/frontend/src/styles/pc-collections.css b/frontend/src/styles/pc-collections.css new file mode 100644 index 0000000..bb33b5d --- /dev/null +++ b/frontend/src/styles/pc-collections.css @@ -0,0 +1,161 @@ +/* PC 我的足迹(浏览记录 / 收藏账号) */ +.collections-page { + max-width: 1180px; + margin: 0 auto; + padding: 32px 24px 56px; +} + +.collections-page__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 28px 32px; + border: 1px solid var(--line); + border-radius: 18px; + background: linear-gradient(135deg, rgba(166, 67, 50, 0.05), rgba(30, 77, 66, 0.06)); +} + +.collections-page__eyebrow { + display: inline-block; + font-size: 12px; + letter-spacing: 0.24em; + color: var(--ink-muted); + text-transform: uppercase; +} + +.collections-page__head h2 { + margin: 6px 0 4px; +} + +.collections-page__tabs { + margin: 20px 0 16px; + padding: 4px; + border-radius: 12px; + background: #fff; + border: 1px solid var(--line); +} + +.collections-page__empty { + border-radius: 16px; + border-color: var(--line); + background: var(--paper-warm); + padding: 32px 0; +} + +.collections-list { + display: flex; + flex-direction: column; + gap: 14px; +} + +.collections-item { + border-radius: 16px; + border-color: var(--line); + background: var(--paper-warm); +} + +.collections-item .ant-card-body { + padding: 16px 20px 18px; +} + +.collections-item__time { + font-size: 12px; + color: var(--ink-muted); + margin-bottom: 10px; +} + +.collections-item__body { + display: grid; + grid-template-columns: 132px minmax(0, 1fr) 168px; + gap: 18px; + align-items: center; +} + +.collections-item__cover { + width: 132px; + height: 92px; + padding: 0; + border: 1px solid var(--line); + border-radius: 12px; + overflow: hidden; + background: rgba(30, 77, 66, 0.06); + color: var(--ink-muted); + font-size: 12px; + letter-spacing: 0.16em; + cursor: pointer; +} + +.collections-item__cover img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.collections-item__no { + font-size: 12px; + color: var(--ink-muted); + letter-spacing: 0.06em; +} + +.collections-item__title { + margin: 2px 0 8px !important; + cursor: pointer; +} + +.collections-item__title:hover { + color: var(--brand-seal); +} + +.collections-item__meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-top: 10px; + font-size: 12px; + color: var(--ink-secondary); +} + +.collections-item__side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 10px; +} + +.collections-item__price { + font-size: 22px; + color: var(--brand-seal); + letter-spacing: 0.02em; +} + +@media (max-width: 900px) { + .collections-item__body { + grid-template-columns: 96px minmax(0, 1fr); + } + + .collections-item__cover { + width: 96px; + height: 72px; + } + + .collections-item__side { + grid-column: 1 / -1; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + } +} + +@media (max-width: 640px) { + .collections-page { + padding: 20px 16px 40px; + } + + .collections-page__head { + flex-direction: column; + padding: 22px; + } +}