feat(listings): 足迹与收藏改为账号同步并新增后端表与接口
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
ListingCollectionKindHistory = "history"
|
||||
ListingCollectionKindFavorite = "favorite"
|
||||
)
|
||||
|
||||
// ListingCollection 记录用户对商品的浏览足迹或收藏关系。
|
||||
// 同一用户 + 商品 + 类型唯一,重复浏览只更新时间,不新增行。
|
||||
type ListingCollection struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
UserID uint64 `gorm:"not null;uniqueIndex:uk_listing_collections_user_listing_kind,priority:1;index:idx_listing_collections_user_kind_time,priority:1" json:"user_id"`
|
||||
ListingID uint64 `gorm:"not null;uniqueIndex:uk_listing_collections_user_listing_kind,priority:2;index:idx_listing_collections_user_kind_time,priority:3;index:idx_listing_collections_listing" json:"listing_id"`
|
||||
Kind string `gorm:"size:16;not null;uniqueIndex:uk_listing_collections_user_listing_kind,priority:3;index:idx_listing_collections_user_kind_time,priority:2" json:"kind"`
|
||||
OccurredAt time.Time `gorm:"not null;index:idx_listing_collections_user_kind_time,priority:4" json:"occurred_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ListingCollection) TableName() string {
|
||||
return "listing_collections"
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
// CollectionKindHistory 浏览记录;CollectionKindFavorite 收藏。
|
||||
CollectionKindHistory = "history"
|
||||
CollectionKindFavorite = "favorite"
|
||||
|
||||
maxCollectionItems = 100
|
||||
)
|
||||
|
||||
var ErrCollectionKindInvalid = errors.New("invalid collection kind")
|
||||
|
||||
type collectionEntry struct {
|
||||
ListingID uint64
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
// CollectionItem 返回给前端的足迹项:商品快照 + 发生时间。
|
||||
type CollectionItem struct {
|
||||
Listing ListingDTO `json:"listing"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type CollectionCounts struct {
|
||||
History int64 `json:"history"`
|
||||
Favorite int64 `json:"favorite"`
|
||||
}
|
||||
|
||||
type CollectionList struct {
|
||||
Items []CollectionItem `json:"items"`
|
||||
Counts CollectionCounts `json:"counts"`
|
||||
}
|
||||
|
||||
func validCollectionKind(kind string) bool {
|
||||
return kind == CollectionKindHistory || kind == CollectionKindFavorite
|
||||
}
|
||||
|
||||
// UpsertCollection 写入浏览或收藏记录;同一用户+商品+类型只更新时间。
|
||||
func (r *Repository) UpsertCollection(ctx context.Context, userID, listingID uint64, kind string) error {
|
||||
now := time.Now()
|
||||
entry := model.ListingCollection{
|
||||
UserID: userID,
|
||||
ListingID: listingID,
|
||||
Kind: kind,
|
||||
OccurredAt: now,
|
||||
}
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "listing_id"}, {Name: "kind"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"occurred_at": now,
|
||||
"updated_at": now,
|
||||
}),
|
||||
}).Create(&entry).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteCollection(ctx context.Context, userID, listingID uint64, kind string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND listing_id = ? AND kind = ?", userID, listingID, kind).
|
||||
Delete(&model.ListingCollection{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ClearCollection(ctx context.Context, userID uint64, kind string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND kind = ?", userID, kind).
|
||||
Delete(&model.ListingCollection{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListCollectionEntries(ctx context.Context, userID uint64, kind string, limit int) ([]collectionEntry, error) {
|
||||
if limit <= 0 || limit > maxCollectionItems {
|
||||
limit = maxCollectionItems
|
||||
}
|
||||
var rows []model.ListingCollection
|
||||
if err := r.db.WithContext(ctx).
|
||||
Select("listing_id, occurred_at").
|
||||
Where("user_id = ? AND kind = ?", userID, kind).
|
||||
Order("occurred_at DESC, id DESC").
|
||||
Limit(limit).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]collectionEntry, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
entries = append(entries, collectionEntry{ListingID: row.ListingID, OccurredAt: row.OccurredAt})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CountCollections(ctx context.Context, userID uint64) (CollectionCounts, error) {
|
||||
var rows []struct {
|
||||
Kind string
|
||||
Total int64
|
||||
}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.ListingCollection{}).
|
||||
Select("kind, COUNT(*) AS total").
|
||||
Where("user_id = ?", userID).
|
||||
Group("kind").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return CollectionCounts{}, err
|
||||
}
|
||||
counts := CollectionCounts{}
|
||||
for _, row := range rows {
|
||||
switch row.Kind {
|
||||
case CollectionKindHistory:
|
||||
counts.History = row.Total
|
||||
case CollectionKindFavorite:
|
||||
counts.Favorite = row.Total
|
||||
}
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListingExists(ctx context.Context, listingID uint64) (bool, error) {
|
||||
var count int64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.RentalListing{}).
|
||||
Where("id = ?", listingID).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// listingsByIDs 复用公开列表的联表与出图逻辑,保证足迹页展示与详情页一致。
|
||||
func (r *Repository) listingsByIDs(ctx context.Context, ids []uint64) (map[uint64]ListingDTO, error) {
|
||||
result := make(map[uint64]ListingDTO, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []listingRow
|
||||
if err := r.baseQuery(ctx).Where("l.id IN ?", ids).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range publicListings(rowsToDTO(rows)) {
|
||||
result[item.ID] = item
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) RecordCollectionView(ctx context.Context, userID, listingID uint64) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
exists, err := s.repo.ListingExists(ctx, listingID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return s.repo.UpsertCollection(ctx, userID, listingID, CollectionKindHistory)
|
||||
}
|
||||
|
||||
func (s *Service) AddCollectionFavorite(ctx context.Context, userID, listingID uint64) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
exists, err := s.repo.ListingExists(ctx, listingID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return s.repo.UpsertCollection(ctx, userID, listingID, CollectionKindFavorite)
|
||||
}
|
||||
|
||||
func (s *Service) RemoveCollectionFavorite(ctx context.Context, userID, listingID uint64) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.DeleteCollection(ctx, userID, listingID, CollectionKindFavorite)
|
||||
}
|
||||
|
||||
func (s *Service) RemoveCollectionHistory(ctx context.Context, userID, listingID uint64) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.DeleteCollection(ctx, userID, listingID, CollectionKindHistory)
|
||||
}
|
||||
|
||||
func (s *Service) ClearCollectionHistory(ctx context.Context, userID uint64) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.ClearCollection(ctx, userID, CollectionKindHistory)
|
||||
}
|
||||
|
||||
func (s *Service) ListCollections(ctx context.Context, userID uint64, kind string) (*CollectionList, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if !validCollectionKind(kind) {
|
||||
return nil, ErrCollectionKindInvalid
|
||||
}
|
||||
entries, err := s.repo.ListCollectionEntries(ctx, userID, kind, maxCollectionItems)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]uint64, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
ids = append(ids, entry.ListingID)
|
||||
}
|
||||
listings, err := s.repo.listingsByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]CollectionItem, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
listing, ok := listings[entry.ListingID]
|
||||
if !ok {
|
||||
// 商品被物理删除时跳过,避免整页 500。
|
||||
continue
|
||||
}
|
||||
items = append(items, CollectionItem{Listing: listing, OccurredAt: entry.OccurredAt})
|
||||
}
|
||||
counts, err := s.repo.CountCollections(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CollectionList{Items: items, Counts: counts}, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func parseListingIDParam(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("listingId"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, "商品 ID 不正确")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func writeCollectionError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrCollectionKindInvalid):
|
||||
response.BadRequest(c, "足迹类型不正确")
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
response.NotFound(c, "商品不存在或已删除")
|
||||
default:
|
||||
writeListingError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListCollections(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
kind := c.DefaultQuery("kind", CollectionKindHistory)
|
||||
result, err := h.service.ListCollections(c.Request.Context(), userID, kind)
|
||||
if err != nil {
|
||||
writeCollectionError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
type collectionBody struct {
|
||||
ListingID uint64 `json:"listing_id" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) RecordCollectionView(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
var req collectionBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "商品 ID 不正确")
|
||||
return
|
||||
}
|
||||
if err := h.service.RecordCollectionView(c.Request.Context(), userID, req.ListingID); err != nil {
|
||||
writeCollectionError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"recorded": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AddCollectionFavorite(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
var req collectionBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "商品 ID 不正确")
|
||||
return
|
||||
}
|
||||
if err := h.service.AddCollectionFavorite(c.Request.Context(), userID, req.ListingID); err != nil {
|
||||
writeCollectionError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"favorited": true})
|
||||
}
|
||||
|
||||
func (h *Handler) RemoveCollectionFavorite(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
listingID, ok := parseListingIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.RemoveCollectionFavorite(c.Request.Context(), userID, listingID); err != nil {
|
||||
writeCollectionError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *Handler) RemoveCollectionHistory(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
listingID, ok := parseListingIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.RemoveCollectionHistory(c.Request.Context(), userID, listingID); err != nil {
|
||||
writeCollectionError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *Handler) ClearCollectionHistory(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
if err := h.service.ClearCollectionHistory(c.Request.Context(), userID); err != nil {
|
||||
writeCollectionError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"cleared": true})
|
||||
}
|
||||
@@ -502,6 +502,17 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
listingRoutes.DELETE("/:id", requireAuth, requireRealname, listingHandler.Offline)
|
||||
}
|
||||
|
||||
// 我的足迹与收藏:跟账号走,登录后跨设备同步;未登录时前端回落本地存储。
|
||||
collectionRoutes := api.Group("/me/listing-collections", requireAuth)
|
||||
{
|
||||
collectionRoutes.GET("", listingHandler.ListCollections)
|
||||
collectionRoutes.POST("/view", listingHandler.RecordCollectionView)
|
||||
collectionRoutes.POST("/favorites", listingHandler.AddCollectionFavorite)
|
||||
collectionRoutes.DELETE("/favorites/:listingId", listingHandler.RemoveCollectionFavorite)
|
||||
collectionRoutes.DELETE("/history", listingHandler.ClearCollectionHistory)
|
||||
collectionRoutes.DELETE("/history/:listingId", listingHandler.RemoveCollectionHistory)
|
||||
}
|
||||
|
||||
sellerRoutes := api.Group("/seller", requireAuth, requireRealname)
|
||||
{
|
||||
sellerRoutes.GET("/listings", listingHandler.ListMine)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +goose Up
|
||||
|
||||
-- 用户足迹与收藏:跟账号走,不再只存在浏览器本地。
|
||||
-- kind=history 由商品详情页浏览上报,kind=favorite 由收藏按钮切换。
|
||||
CREATE TABLE IF NOT EXISTS listing_collections (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
listing_id BIGINT UNSIGNED NOT NULL,
|
||||
kind VARCHAR(16) NOT NULL COMMENT 'history=浏览记录, favorite=收藏',
|
||||
occurred_at DATETIME NOT NULL COMMENT '最近一次浏览或收藏时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_listing_collections_user_listing_kind (user_id, listing_id, kind),
|
||||
KEY idx_listing_collections_user_kind_time (user_id, kind, occurred_at),
|
||||
KEY idx_listing_collections_listing (listing_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户浏览足迹与收藏';
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP TABLE IF EXISTS listing_collections;
|
||||
@@ -1,28 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import { App, Button, Card, Empty, Segmented, Space, Tag, Typography } from 'antd'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { App, Button, Card, Empty, Segmented, Skeleton, 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'
|
||||
addRemoteFavorite,
|
||||
clearRemoteHistory,
|
||||
fetchRemoteCollections,
|
||||
removeRemoteFavorite,
|
||||
removeRemoteHistory,
|
||||
} from '@/features/listings/collectionsApi'
|
||||
import { useListingCollections } from '@/features/listings/collections'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatDateTime, formatMoney } from '@/shared/utils/format'
|
||||
|
||||
type TabKey = 'history' | 'favorites'
|
||||
type TabKey = 'history' | 'favorite'
|
||||
|
||||
function timeText(item: CollectionItem, tab: TabKey) {
|
||||
return formatDateTime(tab === 'favorites' ? item.favorited_at : item.viewed_at)
|
||||
interface RowListing {
|
||||
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
|
||||
}
|
||||
|
||||
interface RowItem {
|
||||
listing: RowListing
|
||||
occurredAt?: string
|
||||
}
|
||||
|
||||
export function ListingCollectionsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { message, modal } = App.useApp()
|
||||
const { history, favorites, isFavorite, toggleFavorite, removeHistory, removeFavorite, clearHistory } =
|
||||
useListingCollections()
|
||||
const queryClient = useQueryClient()
|
||||
const token = useSessionStore(state => state.token)
|
||||
const local = useListingCollections()
|
||||
const [tab, setTab] = useState<TabKey>('history')
|
||||
|
||||
const items = tab === 'favorites' ? favorites : history
|
||||
const remote = useQuery({
|
||||
queryKey: ['listing-collections'],
|
||||
queryFn: async () => {
|
||||
const [history, favorites] = await Promise.all([
|
||||
fetchRemoteCollections('history'),
|
||||
fetchRemoteCollections('favorite'),
|
||||
])
|
||||
return { history: history.items, favorites: favorites.items, counts: history.counts }
|
||||
},
|
||||
enabled: Boolean(token),
|
||||
})
|
||||
|
||||
const remoteHistory: RowItem[] = (remote.data?.history ?? []).map(item => ({
|
||||
listing: item.listing,
|
||||
occurredAt: item.occurred_at,
|
||||
}))
|
||||
const remoteFavorites: RowItem[] = (remote.data?.favorites ?? []).map(item => ({
|
||||
listing: item.listing,
|
||||
occurredAt: item.occurred_at,
|
||||
}))
|
||||
const localHistory: RowItem[] = local.history.map(item => ({
|
||||
listing: item.listing,
|
||||
occurredAt: item.viewed_at,
|
||||
}))
|
||||
const localFavorites: RowItem[] = local.favorites.map(item => ({
|
||||
listing: item.listing,
|
||||
occurredAt: item.favorited_at,
|
||||
}))
|
||||
|
||||
const historyItems = token ? remoteHistory : localHistory
|
||||
const favoriteItems = token ? remoteFavorites : localFavorites
|
||||
const historyCount = token ? remote.data?.counts.history ?? 0 : local.history.length
|
||||
const favoriteCount = token ? remote.data?.counts.favorite ?? 0 : local.favorites.length
|
||||
const favoriteIDs = new Set(favoriteItems.map(item => item.listing.id))
|
||||
const items = tab === 'favorite' ? favoriteItems : historyItems
|
||||
const loading = Boolean(token) && remote.isPending
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['listing-collections'] })
|
||||
|
||||
const onClear = () => {
|
||||
modal.confirm({
|
||||
@@ -31,26 +91,47 @@ export function ListingCollectionsPage() {
|
||||
okText: '清空',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
clearHistory()
|
||||
onOk: async () => {
|
||||
if (token) {
|
||||
await clearRemoteHistory()
|
||||
await refresh()
|
||||
} else {
|
||||
local.clearHistory()
|
||||
}
|
||||
message.success('浏览记录已清空')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const onToggle = (item: CollectionItem) => {
|
||||
const added = toggleFavorite(item.listing)
|
||||
message.success(added ? '已收藏' : '已取消收藏')
|
||||
const onToggle = async (item: RowItem) => {
|
||||
const favorited = favoriteIDs.has(item.listing.id)
|
||||
if (token) {
|
||||
if (favorited) {
|
||||
await removeRemoteFavorite(item.listing.id)
|
||||
} else {
|
||||
await addRemoteFavorite(item.listing.id)
|
||||
}
|
||||
await refresh()
|
||||
} else {
|
||||
local.toggleFavorite(item.listing)
|
||||
}
|
||||
message.success(favorited ? '已取消收藏' : '已收藏')
|
||||
}
|
||||
|
||||
const onRemove = (item: CollectionItem) => {
|
||||
if (tab === 'favorites') {
|
||||
removeFavorite(item.listing.id)
|
||||
message.success('已取消收藏')
|
||||
return
|
||||
const onRemove = async (item: RowItem) => {
|
||||
if (token) {
|
||||
if (tab === 'favorite') {
|
||||
await removeRemoteFavorite(item.listing.id)
|
||||
} else {
|
||||
await removeRemoteHistory(item.listing.id)
|
||||
}
|
||||
await refresh()
|
||||
} else if (tab === 'favorite') {
|
||||
local.removeFavorite(item.listing.id)
|
||||
} else {
|
||||
local.removeHistory(item.listing.id)
|
||||
}
|
||||
removeHistory(item.listing.id)
|
||||
message.success('已移除记录')
|
||||
message.success(tab === 'favorite' ? '已取消收藏' : '已移除记录')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -62,10 +143,12 @@ export function ListingCollectionsPage() {
|
||||
我的足迹
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
找回看过和收藏过的账号,数据保存在当前浏览器。
|
||||
{token
|
||||
? '浏览记录与收藏已同步到账号,换设备登录也能找回。'
|
||||
: '当前未登录,记录仅保存在本浏览器;登录后会同步到账号。'}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
{tab === 'history' && history.length > 0 ? (
|
||||
{tab === 'history' && historyCount > 0 ? (
|
||||
<Button icon={<DeleteOutlined />} onClick={onClear}>
|
||||
清空记录
|
||||
</Button>
|
||||
@@ -77,16 +160,18 @@ export function ListingCollectionsPage() {
|
||||
value={tab}
|
||||
onChange={value => setTab(value as TabKey)}
|
||||
options={[
|
||||
{ label: `浏览记录 ${history.length}`, value: 'history' },
|
||||
{ label: `收藏账号 ${favorites.length}`, value: 'favorites' },
|
||||
{ label: `浏览记录 ${historyCount}`, value: 'history' },
|
||||
{ label: `收藏账号 ${favoriteCount}`, value: 'favorite' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{items.length === 0 ? (
|
||||
{loading ? (
|
||||
<Card className="collections-page__empty">
|
||||
<Empty
|
||||
description={tab === 'favorites' ? '暂无收藏账号' : '暂无浏览记录'}
|
||||
>
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</Card>
|
||||
) : items.length === 0 ? (
|
||||
<Card className="collections-page__empty">
|
||||
<Empty description={tab === 'favorite' ? '暂无收藏账号' : '暂无浏览记录'}>
|
||||
<Button type="primary" onClick={() => navigate('/')}>
|
||||
去逛柜选
|
||||
</Button>
|
||||
@@ -95,8 +180,8 @@ export function ListingCollectionsPage() {
|
||||
) : (
|
||||
<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>
|
||||
<Card key={item.listing.id} className="collections-item">
|
||||
<div className="collections-item__time">{formatDateTime(item.occurredAt)}</div>
|
||||
<div className="collections-item__body">
|
||||
<button
|
||||
type="button"
|
||||
@@ -145,12 +230,12 @@ export function ListingCollectionsPage() {
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
icon={isFavorite(item.listing.id) ? <StarFilled /> : <StarOutlined />}
|
||||
onClick={() => onToggle(item)}
|
||||
icon={favoriteIDs.has(item.listing.id) ? <StarFilled /> : <StarOutlined />}
|
||||
onClick={() => void onToggle(item)}
|
||||
>
|
||||
{isFavorite(item.listing.id) ? '已收藏' : '收藏'}
|
||||
{favoriteIDs.has(item.listing.id) ? '已收藏' : '收藏'}
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={() => onRemove(item)}>
|
||||
<Button size="small" type="text" onClick={() => void onRemove(item)}>
|
||||
移除
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
@@ -29,6 +30,12 @@ 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 {
|
||||
addRemoteFavorite,
|
||||
fetchFavoriteIDs,
|
||||
recordRemoteView,
|
||||
removeRemoteFavorite,
|
||||
} from '@/features/listings/collectionsApi'
|
||||
import {
|
||||
createOrder,
|
||||
useOrderAgreements,
|
||||
@@ -65,10 +72,16 @@ export function ListingDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const { message } = App.useApp()
|
||||
const { modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isPending, isError, refetch } = useListingDetail(id)
|
||||
const { isFavorite, toggleFavorite } = useListingCollections()
|
||||
const user = useSessionStore(state => state.user)
|
||||
const token = useSessionStore(state => state.token)
|
||||
const favoriteIDsQuery = useQuery({
|
||||
queryKey: ['listing-favorite-ids'],
|
||||
queryFn: fetchFavoriteIDs,
|
||||
enabled: Boolean(token),
|
||||
})
|
||||
const agreements = useOrderAgreements()
|
||||
const postRentalNotice = usePostRentalNotice()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
@@ -79,8 +92,15 @@ export function ListingDetailPage() {
|
||||
const [agreementContent, setAgreementContent] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (data) recordListingView(data)
|
||||
}, [data])
|
||||
if (!data) return
|
||||
if (token) {
|
||||
void recordRemoteView(data.id)
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ['listing-collections'] }))
|
||||
.catch(() => undefined)
|
||||
return
|
||||
}
|
||||
recordListingView(data)
|
||||
}, [data, token, queryClient])
|
||||
|
||||
const showAgreement = (title: string, content?: string) => {
|
||||
setAgreementTitle(title)
|
||||
@@ -170,6 +190,23 @@ export function ListingDetailPage() {
|
||||
)
|
||||
const onlineTime = data.asset_summary?.online_time
|
||||
const isOwner = user?.id === data.owner_id
|
||||
const favorited = token ? Boolean(favoriteIDsQuery.data?.has(data.id)) : isFavorite(data.id)
|
||||
|
||||
const toggleFavoriteState = async () => {
|
||||
if (token) {
|
||||
if (favorited) {
|
||||
await removeRemoteFavorite(data.id)
|
||||
} else {
|
||||
await addRemoteFavorite(data.id)
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['listing-favorite-ids'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['listing-collections'] })
|
||||
message.success(favorited ? '已取消收藏' : '已收藏账号')
|
||||
return
|
||||
}
|
||||
const added = toggleFavorite(data)
|
||||
message.success(added ? '已收藏账号' : '已取消收藏')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="listing-detail-page">
|
||||
@@ -221,13 +258,10 @@ export function ListingDetailPage() {
|
||||
</Tag>
|
||||
<Button
|
||||
size="small"
|
||||
icon={isFavorite(data.id) ? <StarFilled /> : <StarOutlined />}
|
||||
onClick={() => {
|
||||
const added = toggleFavorite(data)
|
||||
message.success(added ? '已收藏账号' : '已取消收藏')
|
||||
}}
|
||||
icon={favorited ? <StarFilled /> : <StarOutlined />}
|
||||
onClick={() => void toggleFavoriteState()}
|
||||
>
|
||||
{isFavorite(data.id) ? '已收藏' : '收藏'}
|
||||
{favorited ? '已收藏' : '收藏'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { apiClient, unwrapData } from '@/shared/api/client'
|
||||
import type { ApiResponse, Listing } from '@/shared/api/types'
|
||||
|
||||
export type CollectionKind = 'history' | 'favorite'
|
||||
|
||||
export interface RemoteCollectionItem {
|
||||
listing: Listing
|
||||
occurred_at: string
|
||||
}
|
||||
|
||||
export interface RemoteCollectionCounts {
|
||||
history: number
|
||||
favorite: number
|
||||
}
|
||||
|
||||
export interface RemoteCollectionList {
|
||||
items: RemoteCollectionItem[]
|
||||
counts: RemoteCollectionCounts
|
||||
}
|
||||
|
||||
export function fetchRemoteCollections(kind: CollectionKind) {
|
||||
return unwrapData<RemoteCollectionList>(
|
||||
apiClient.get<ApiResponse<RemoteCollectionList>>('/me/listing-collections', {
|
||||
params: { kind },
|
||||
})
|
||||
).then(data => ({
|
||||
items: data.items ?? [],
|
||||
counts: data.counts ?? { history: 0, favorite: 0 },
|
||||
}))
|
||||
}
|
||||
|
||||
export async function recordRemoteView(listingID: number) {
|
||||
await apiClient.post('/me/listing-collections/view', { listing_id: listingID })
|
||||
}
|
||||
|
||||
export async function addRemoteFavorite(listingID: number) {
|
||||
await apiClient.post('/me/listing-collections/favorites', { listing_id: listingID })
|
||||
}
|
||||
|
||||
export async function removeRemoteFavorite(listingID: number) {
|
||||
await apiClient.delete(`/me/listing-collections/favorites/${listingID}`)
|
||||
}
|
||||
|
||||
export async function removeRemoteHistory(listingID: number) {
|
||||
await apiClient.delete(`/me/listing-collections/history/${listingID}`)
|
||||
}
|
||||
|
||||
export async function clearRemoteHistory() {
|
||||
await apiClient.delete('/me/listing-collections/history')
|
||||
}
|
||||
|
||||
/** 收藏状态下拉用:只取收藏列表的 id 集合。 */
|
||||
export async function fetchFavoriteIDs() {
|
||||
const data = await fetchRemoteCollections('favorite')
|
||||
return new Set(data.items.map(item => item.listing.id))
|
||||
}
|
||||
Reference in New Issue
Block a user