feat(listings): 新增我的足迹浏览记录与收藏账号页

This commit is contained in:
yml2213
2026-10-30 18:04:11 +08:00
parent 20ba072494
commit 82352444d5
8 changed files with 542 additions and 4 deletions
+6
View File
@@ -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() {
<Routes>
<Route element={<StorefrontLayout />}>
<Route index element={<ListingListPage />} />
<Route path="listings/collections" element={<ListingCollectionsPage />} />
<Route path="listings/:id" element={<ListingDetailPage />} />
<Route path="orders" element={<OrderListPage />} />
<Route path="orders/:id" element={<OrderDetailPage />} />
@@ -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<TabKey>('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 (
<div className="collections-page">
<header className="collections-page__head">
<div>
<span className="collections-page__eyebrow">ACCOUNT MEMORY</span>
<Typography.Title level={2} className="brand-title">
我的足迹
</Typography.Title>
<Typography.Paragraph type="secondary">
找回看过和收藏过的账号,数据保存在当前浏览器。
</Typography.Paragraph>
</div>
{tab === 'history' && history.length > 0 ? (
<Button icon={<DeleteOutlined />} onClick={onClear}>
清空记录
</Button>
) : null}
</header>
<Segmented
className="collections-page__tabs"
value={tab}
onChange={value => setTab(value as TabKey)}
options={[
{ label: `浏览记录 ${history.length}`, value: 'history' },
{ label: `收藏账号 ${favorites.length}`, value: 'favorites' },
]}
/>
{items.length === 0 ? (
<Card className="collections-page__empty">
<Empty
description={tab === 'favorites' ? '暂无收藏账号' : '暂无浏览记录'}
>
<Button type="primary" onClick={() => navigate('/')}>
去逛柜选
</Button>
</Empty>
</Card>
) : (
<div className="collections-list">
{items.map(item => (
<Card key={item.listing.id} className="collections-item" hoverable={false}>
<div className="collections-item__time">{timeText(item, tab)}</div>
<div className="collections-item__body">
<button
type="button"
className="collections-item__cover"
onClick={() => navigate(`/listings/${item.listing.id}`)}
>
{item.listing.cover_url ? (
<img src={item.listing.cover_url} alt={item.listing.title} loading="lazy" />
) : (
<span>JIEYOU</span>
)}
</button>
<div className="collections-item__main">
<Typography.Text className="collections-item__no">
{item.listing.listing_no}
</Typography.Text>
<Typography.Title
level={4}
className="collections-item__title"
ellipsis={{ rows: 2 }}
onClick={() => navigate(`/listings/${item.listing.id}`)}
>
{item.listing.title}
</Typography.Title>
<Space size={6} wrap>
<Tag bordered={false}>{item.listing.server_region || '区服待定'}</Tag>
<Tag bordered={false}>{item.listing.login_platform || '登录方式待定'}</Tag>
{item.listing.rank_level ? (
<Tag bordered={false}>{item.listing.rank_level}</Tag>
) : null}
<Tag color={item.listing.in_transaction ? 'default' : 'green'} bordered={false}>
{item.listing.in_transaction ? '租赁中' : '可租'}
</Tag>
</Space>
<div className="collections-item__meta">
<span>哈夫币 {item.listing.haf_coin_amount?.toLocaleString() ?? '—'}</span>
<span>押金 {formatMoney(item.listing.deposit_amount_cent)}</span>
</div>
</div>
<div className="collections-item__side">
<strong className="collections-item__price">
{formatMoney(item.listing.price_cent)}
</strong>
<Space size={8} wrap>
<Button
size="small"
icon={isFavorite(item.listing.id) ? <StarFilled /> : <StarOutlined />}
onClick={() => onToggle(item)}
>
{isFavorite(item.listing.id) ? '已收藏' : '收藏'}
</Button>
<Button size="small" type="text" onClick={() => onRemove(item)}>
移除
</Button>
</Space>
<Button type="primary" onClick={() => navigate(`/listings/${item.listing.id}`)}>
立即租用
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
)
}
@@ -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() {
<Card className="listing-buy-card">
<div className="listing-buy-card__top">
<Typography.Text className="listing-card__no">{data.listing_no}</Typography.Text>
<Tag color={data.in_transaction ? 'default' : 'green'} bordered={false}>
{data.in_transaction ? '租赁中' : '可租'}
</Tag>
<Space size={8}>
<Tag color={data.in_transaction ? 'default' : 'green'} bordered={false}>
{data.in_transaction ? '租赁中' : '可租'}
</Tag>
<Button
size="small"
icon={isFavorite(data.id) ? <StarFilled /> : <StarOutlined />}
onClick={() => {
const added = toggleFavorite(data)
message.success(added ? '已收藏账号' : '已取消收藏')
}}
>
{isFavorite(data.id) ? '已收藏' : '收藏'}
</Button>
</Space>
</div>
<Typography.Title level={2} className="brand-title listing-buy-card__title">
{data.title}
@@ -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<string, unknown> {
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<CollectionItem[]>([])
const [favorites, setFavorites] = useState<CollectionItem[]>([])
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,
}
}
@@ -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<string, { label: string; color: string }> = {
}
const quickEntries = [
{ key: 'collections', label: '我的足迹', hint: '浏览记录与收藏账号', icon: <HistoryOutlined />, to: '/listings/collections' },
{ key: 'orders', label: '我的订单', hint: '查看租号进度与交接记录', icon: <ProfileOutlined />, to: '/orders' },
{ key: 'messages', label: '消息中心', hint: '账号群聊与客服会话', icon: <MessageOutlined />, to: '/messages' },
{ key: 'wallet', label: '我的钱包', hint: '余额、流水与提现', icon: <WalletOutlined />, to: '/wallet' },
@@ -54,6 +54,11 @@ export function StorefrontLayout() {
label: '实名认证',
onClick: () => navigate('/realname'),
},
{
key: 'collections',
label: '我的足迹',
onClick: () => navigate('/listings/collections'),
},
{
key: 'orders',
label: '我的订单',
+1
View File
@@ -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';
+161
View File
@@ -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;
}
}