feat(seller): 新增移动端卖家中心
This commit is contained in:
@@ -39,6 +39,13 @@ export function MobileProfilePage() {
|
||||
</List.Item>
|
||||
<List.Item onClick={() => navigate('/m/orders')}>我的订单</List.Item>
|
||||
<List.Item onClick={() => navigate('/m/messages')}>消息中心</List.Item>
|
||||
<List.Item
|
||||
onClick={() =>
|
||||
navigate(user ? '/m/seller/listings' : '/m/login?redirect=/m/seller/listings')
|
||||
}
|
||||
>
|
||||
卖家中心
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
<div className="mobile-profile__actions">
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SellerListingFormPage } from '@/features/seller/SellerListingFormPage'
|
||||
|
||||
export function MobileSellerFormPage() {
|
||||
return (
|
||||
<div className="mobile-seller-host">
|
||||
<SellerListingFormPage mobile />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SellerListingDetailPage } from '@/features/seller/SellerListingDetailPage'
|
||||
|
||||
export function MobileSellerListingDetailPage() {
|
||||
return (
|
||||
<div className="mobile-seller-host">
|
||||
<SellerListingDetailPage mobile />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Dialog, Empty, ErrorBlock, Skeleton, Tag, Toast } from 'antd-mobile'
|
||||
import { AddOutline, RightOutline } from 'antd-mobile-icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { listingReviewStatusMeta, listingStatusMeta } from '@/features/listings/labels'
|
||||
import {
|
||||
fetchSellerListings,
|
||||
offlineListing,
|
||||
submitListingReview,
|
||||
} from '@/features/seller/api'
|
||||
import type { SellerListing } from '@/features/seller/types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatMoney } from '@/shared/utils/format'
|
||||
|
||||
type Filter = 'all' | 'published' | 'pending' | 'offline' | 'completed'
|
||||
|
||||
const filters: Array<{ key: Filter; label: string }> = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'published', label: '已上架' },
|
||||
{ key: 'pending', label: '待审核' },
|
||||
{ key: 'offline', label: '已下架' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
]
|
||||
|
||||
function isTerminal(item: SellerListing) {
|
||||
return item.status === 'rented' || item.status === 'completed' || item.status === 'sealed'
|
||||
}
|
||||
|
||||
function isPending(item: SellerListing) {
|
||||
return !isTerminal(item) && item.status !== 'offline' && item.review_status === 'pending'
|
||||
}
|
||||
|
||||
function canEdit(item: SellerListing) {
|
||||
return !isTerminal(item) && !isPending(item) && item.status !== 'published'
|
||||
}
|
||||
|
||||
function canSubmit(item: SellerListing) {
|
||||
return canEdit(item)
|
||||
}
|
||||
|
||||
function canOffline(item: SellerListing) {
|
||||
return !isTerminal(item) && item.status !== 'offline'
|
||||
}
|
||||
|
||||
function sellerPriceCent(item: SellerListing) {
|
||||
const breakdown = item.asset_summary?.price_breakdown
|
||||
if (breakdown && typeof breakdown === 'object') {
|
||||
const record = breakdown as Record<string, unknown>
|
||||
const cent = Number(record.seller_total_price_cent)
|
||||
if (Number.isFinite(cent) && cent > 0) return cent
|
||||
const yuan = Number(record.seller_total_price)
|
||||
if (Number.isFinite(yuan) && yuan > 0) return Math.round(yuan * 100)
|
||||
}
|
||||
return item.price_cent
|
||||
}
|
||||
|
||||
function coinText(item: SellerListing) {
|
||||
const coinM = Number(item.haf_coin_amount || 0) / 1_000_000
|
||||
if (!coinM) return '—'
|
||||
return Number.isInteger(coinM) ? `${coinM}M` : `${coinM.toFixed(2)}M`
|
||||
}
|
||||
|
||||
function statusColor(tone: string) {
|
||||
if (tone === 'success') return 'success'
|
||||
if (tone === 'gold' || tone === 'warning') return 'warning'
|
||||
if (tone === 'red' || tone === 'danger') return 'danger'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function MobileSellerListingsPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const token = useSessionStore(state => state.token)
|
||||
const hydrated = useSessionStore(state => state.hydrated)
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [actingId, setActingId] = useState<number | null>(null)
|
||||
|
||||
const listingsQuery = useQuery({
|
||||
queryKey: ['seller-listings'],
|
||||
queryFn: fetchSellerListings,
|
||||
enabled: Boolean(token),
|
||||
})
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: submitListingReview,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['seller-listings'] })
|
||||
Toast.show({ icon: 'success', content: '已提交审核' })
|
||||
},
|
||||
})
|
||||
|
||||
const offlineMutation = useMutation({
|
||||
mutationFn: offlineListing,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['seller-listings'] })
|
||||
Toast.show({ icon: 'success', content: '已下架' })
|
||||
},
|
||||
})
|
||||
|
||||
const listings = listingsQuery.data ?? []
|
||||
const display = listings.filter(item => {
|
||||
if (filter === 'all') return true
|
||||
if (filter === 'pending') return isPending(item)
|
||||
return item.status === filter
|
||||
})
|
||||
|
||||
const submitReview = async (item: SellerListing) => {
|
||||
const confirmed = await Dialog.confirm({
|
||||
title: '提交审核',
|
||||
content: `确认提交「${item.title}」进入后台审核吗?`,
|
||||
confirmText: '提交审核',
|
||||
})
|
||||
if (!confirmed) return
|
||||
setActingId(item.id)
|
||||
try {
|
||||
await submitMutation.mutateAsync(item.id)
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: error instanceof Error ? error.message : '提审失败',
|
||||
})
|
||||
} finally {
|
||||
setActingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const offline = async (item: SellerListing) => {
|
||||
const confirmed = await Dialog.confirm({
|
||||
title: '下架确认',
|
||||
content: `确认下架「${item.title}」吗?`,
|
||||
confirmText: '确认下架',
|
||||
})
|
||||
if (!confirmed) return
|
||||
setActingId(item.id)
|
||||
try {
|
||||
await offlineMutation.mutateAsync(item.id)
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: error instanceof Error ? error.message : '下架失败',
|
||||
})
|
||||
} finally {
|
||||
setActingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<div className="mobile-seller">
|
||||
<Skeleton.Title animated />
|
||||
<Skeleton.Paragraph lineCount={6} animated />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="mobile-seller">
|
||||
<div className="mobile-orders__login">
|
||||
<BrandLogo height={40} variant="seal" />
|
||||
<h2>登录后管理商品</h2>
|
||||
<p>发布、提审与下架只对当前账号开放。</p>
|
||||
<Button block color="primary" onClick={() => navigate('/m/login?redirect=/m/seller/listings')}>
|
||||
去登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-seller">
|
||||
<section className="mobile-seller__hero">
|
||||
<div>
|
||||
<span className="mobile-seller__eyebrow">SELLER · 号主柜台</span>
|
||||
<h1>我的商品</h1>
|
||||
<p>查看审核进度,管理上架与下架。</p>
|
||||
</div>
|
||||
<Button color="primary" onClick={() => navigate('/m/seller/listings/new')}>
|
||||
<AddOutline /> 发布
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<div className="mobile-seller__filters">
|
||||
{filters.map(item => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.key}
|
||||
className={filter === item.key ? 'is-active' : ''}
|
||||
onClick={() => setFilter(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
<em>
|
||||
{item.key === 'all'
|
||||
? listings.length
|
||||
: item.key === 'pending'
|
||||
? listings.filter(isPending).length
|
||||
: listings.filter(row => row.status === item.key).length}
|
||||
</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{listingsQuery.isPending ? (
|
||||
<Skeleton.Paragraph lineCount={8} animated />
|
||||
) : listingsQuery.isError ? (
|
||||
<ErrorBlock status="disconnected" title="商品没有加载出来">
|
||||
<Button size="small" onClick={() => void listingsQuery.refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</ErrorBlock>
|
||||
) : display.length === 0 ? (
|
||||
<div className="mobile-seller__empty">
|
||||
<Empty description="暂无符合条件的商品" />
|
||||
<Button color="primary" onClick={() => navigate('/m/seller/listings/new')}>
|
||||
去发布
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mobile-seller__list">
|
||||
{display.map(item => {
|
||||
const status = listingStatusMeta(item.status)
|
||||
const review = listingReviewStatusMeta(item.review_status)
|
||||
return (
|
||||
<article
|
||||
className="mobile-seller-card"
|
||||
key={item.id}
|
||||
onClick={() => navigate(`/m/seller/listings/${item.id}`)}
|
||||
>
|
||||
<div className="mobile-seller-card__top">
|
||||
<span className="mobile-seller-card__no">{item.listing_no || `#${item.id}`}</span>
|
||||
<div className="mobile-seller-card__tags">
|
||||
<Tag color={statusColor(status.tone)}>{status.label}</Tag>
|
||||
{item.status !== 'completed' ? (
|
||||
<Tag color={statusColor(review.tone)}>{review.label}</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<h2>{item.title || '未命名账号'}</h2>
|
||||
<div className="mobile-seller-card__meta">
|
||||
<span>{item.server_region || '—'}</span>
|
||||
<span>{coinText(item)}</span>
|
||||
<span>押金 {formatMoney(item.deposit_amount_cent)}</span>
|
||||
</div>
|
||||
<div className="mobile-seller-card__bottom">
|
||||
<strong>{formatMoney(sellerPriceCent(item))}</strong>
|
||||
<RightOutline />
|
||||
</div>
|
||||
{item.review_status === 'rejected' && item.review_reason ? (
|
||||
<p className="mobile-seller-card__reason">驳回:{item.review_reason}</p>
|
||||
) : null}
|
||||
<div className="mobile-seller-card__actions" onClick={event => event.stopPropagation()}>
|
||||
<Button
|
||||
size="mini"
|
||||
fill="outline"
|
||||
disabled={!canEdit(item)}
|
||||
onClick={() => navigate(`/m/seller/listings/${item.id}/edit`)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="mini"
|
||||
fill="outline"
|
||||
disabled={!canSubmit(item) || actingId === item.id}
|
||||
loading={actingId === item.id && submitMutation.isPending}
|
||||
onClick={() => void submitReview(item)}
|
||||
>
|
||||
提审
|
||||
</Button>
|
||||
<Button
|
||||
size="mini"
|
||||
color="danger"
|
||||
fill="outline"
|
||||
disabled={!canOffline(item) || actingId === item.id}
|
||||
loading={actingId === item.id && offlineMutation.isPending}
|
||||
onClick={() => void offline(item)}
|
||||
>
|
||||
下架
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -165,13 +165,14 @@ function skinGroupsOf(row: SellerListing) {
|
||||
.filter(group => group.options.length > 0)
|
||||
}
|
||||
|
||||
export function SellerListingDetailPage() {
|
||||
export function SellerListingDetailPage({ mobile = false }: { mobile?: boolean } = {}) {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { modal, message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const token = useSessionStore(state => state.token)
|
||||
const hydrated = useSessionStore(state => state.hydrated)
|
||||
const basePath = mobile ? '/m/seller/listings' : '/seller/listings'
|
||||
|
||||
const listingQuery = useQuery({
|
||||
queryKey: ['seller-listing', id],
|
||||
@@ -262,7 +263,7 @@ export function SellerListingDetailPage() {
|
||||
<Empty description="登录后查看自己发布的商品">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate(`/login?redirect=/seller/listings/${id}`)}
|
||||
onClick={() => navigate(`/login?redirect=${basePath}/${id}`)}
|
||||
>
|
||||
去登录
|
||||
</Button>
|
||||
@@ -292,7 +293,7 @@ export function SellerListingDetailPage() {
|
||||
description="可能不是本人发布的商品,或商品已不存在。"
|
||||
action={
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/seller/listings')}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(basePath)}>
|
||||
返回列表
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void listingQuery.refetch()}>
|
||||
@@ -341,7 +342,7 @@ export function SellerListingDetailPage() {
|
||||
<main className="seller-page seller-detail-page">
|
||||
<header className="seller-detail-head">
|
||||
<div>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/seller/listings')}>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate(basePath)}>
|
||||
返回列表
|
||||
</Button>
|
||||
<Typography.Text className="seller-eyebrow">SELLER LISTING · 商品详情</Typography.Text>
|
||||
@@ -359,7 +360,7 @@ export function SellerListingDetailPage() {
|
||||
{canEdit(listing) ? (
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/seller/listings/${listing.id}/edit`)}
|
||||
onClick={() => navigate(`${basePath}/${listing.id}/edit`)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
@@ -189,7 +189,7 @@ function ChoiceField(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function SellerListingFormPage() {
|
||||
export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } = {}) {
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
const navigate = useNavigate()
|
||||
@@ -197,6 +197,7 @@ export function SellerListingFormPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const token = useSessionStore(state => state.token)
|
||||
const hydrated = useSessionStore(state => state.hydrated)
|
||||
const basePath = mobile ? '/m/seller/listings' : '/seller/listings'
|
||||
|
||||
const [form, setForm] = useState<PublishForm>(defaultPublishForm)
|
||||
const [quantityValues, setQuantityValues] = useState<Record<string, number>>({})
|
||||
@@ -342,7 +343,7 @@ export function SellerListingFormPage() {
|
||||
if (!isEdit || !listing) return
|
||||
if (listing.status === 'completed') {
|
||||
message.error('该商品订单已完成,不能再次编辑')
|
||||
navigate('/seller/listings')
|
||||
navigate(basePath)
|
||||
}
|
||||
}, [isEdit, listing, message, navigate])
|
||||
|
||||
@@ -737,10 +738,10 @@ export function SellerListingFormPage() {
|
||||
: '发布成功,等待后台审核'
|
||||
)
|
||||
if (!isEdit && listing.listing_group_conversation_id) {
|
||||
navigate(`/messages/${listing.listing_group_conversation_id}`)
|
||||
navigate(`${mobile ? '/m' : ''}/messages/${listing.listing_group_conversation_id}`)
|
||||
return
|
||||
}
|
||||
navigate('/seller/listings')
|
||||
navigate(basePath)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -784,7 +785,7 @@ export function SellerListingFormPage() {
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() =>
|
||||
navigate(`/login?redirect=${isEdit ? `/seller/listings/${id}/edit` : '/seller/listings/new'}`)
|
||||
navigate(`/login?redirect=${isEdit ? `${basePath}/${id}/edit` : `${basePath}/new`}`)
|
||||
}
|
||||
>
|
||||
去登录
|
||||
@@ -805,7 +806,7 @@ export function SellerListingFormPage() {
|
||||
description="可能不是本人发布的商品,或商品已不存在。请返回列表后重试。"
|
||||
action={
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/seller/listings')}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(basePath)}>
|
||||
返回列表
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void listingQuery.refetch()}>
|
||||
@@ -833,7 +834,7 @@ export function SellerListingFormPage() {
|
||||
<main className="seller-page seller-form-page">
|
||||
<header className="seller-form-head">
|
||||
<div>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/seller/listings')}>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate(basePath)}>
|
||||
返回列表
|
||||
</Button>
|
||||
<Typography.Text className="seller-eyebrow">SELLER PUBLISH · 号主发布</Typography.Text>
|
||||
|
||||
Reference in New Issue
Block a user