feat(seller): 新增移动端卖家中心

This commit is contained in:
yml2213
2026-10-30 14:57:02 +08:00
parent ce93f1636d
commit 98d0d35830
11 changed files with 565 additions and 13 deletions
@@ -0,0 +1,36 @@
# Phase 2 移动端卖家中心验收
> 日期:2026-10-30
> 范围:移动端卖家商品列表、详情、发布/编辑入口。
## 1. 交付内容
- `/m/seller/listings`:移动端卖家商品列表。
- 全部、已上架、待审核、已下架、已完成筛选与数量。
- 商品卡片展示编号、状态、审核状态、区服、哈夫币、押金、卖家到手价。
- 详情、编辑、提审、下架操作;提审/下架二次确认。
- `/m/seller/listings/:id`:移动端商品详情。
- `/m/seller/listings/new`、`/m/seller/listings/:id/edit`:复用 PC 发布/编辑表单及整数分/基点定价。
- `SellerListingFormPage` / `SellerListingDetailPage` 增加 `mobile` 参数:
- 回跳 `/m/seller/listings`。
- 编辑/登录回跳使用移动端路径。
- 发布成功后的群聊进入 `/m/messages/:id`。
- 移动个人中心新增「卖家中心」入口,未登录先登录并回跳。
- 新增 `frontend/src/styles/mobile-seller.css`,并加入 `global.css` 分块导入。
## 2. 验证
| 检查 | 结果 |
|---|---|
| `cd frontend && npx tsc --noEmit` | 通过 |
| `cd frontend && npm run build` | 通过,主入口 520.27 kB / gzip 167.48 kB |
| `GET /m/seller/listings` | 200 |
| `GET /m/seller/listings/new` | 200 |
| `GET /m/seller/listings/1` | 200 |
| `GET /m/seller/listings/1/edit` | 200 |
## 3. 说明
- 移动端发布/编辑复用 PC 表单和整数定价工具,避免两套价格算法。
- 表单在移动容器下会单列堆叠;复杂字段仍保持完整。
- 未执行真实发布写操作,避免污染开发库。
+1 -1
View File
@@ -381,7 +381,7 @@ hfb_jysh/
### Phase 3:C 端 PC + 移动端(3 周)
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心、聊天页与卖家发布/编辑/审核提交已接通,移动端卖家页面与分销长尾能力待迁移。
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心、聊天与卖家中心,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心、聊天页与卖家发布/编辑/审核提交已接通,剩余分销长尾能力与真实订单端到端回归。
- 首页、商品列表、商品详情、撞车商品。
- 登录、注册、找回密码、实名认证。
+19
View File
@@ -84,6 +84,21 @@ const MobileRealnamePage = lazy(() =>
default: module.MobileRealnamePage,
}))
)
const MobileSellerListingsPage = lazy(() =>
import('@/features/mobile/MobileSellerListingsPage').then(module => ({
default: module.MobileSellerListingsPage,
}))
)
const MobileSellerListingDetailPage = lazy(() =>
import('@/features/mobile/MobileSellerListingDetailPage').then(module => ({
default: module.MobileSellerListingDetailPage,
}))
)
const MobileSellerFormPage = lazy(() =>
import('@/features/mobile/MobileSellerFormPage').then(module => ({
default: module.MobileSellerFormPage,
}))
)
const AdminLoginPage = lazy(() =>
import('@/features/admin/AdminLoginPage').then(module => ({ default: module.AdminLoginPage }))
)
@@ -203,6 +218,10 @@ export function AppRouter() {
<Route path="messages" element={<MobileMessagesPage />} />
<Route path="messages/:id" element={<MobileChatPage />} />
<Route path="realname" element={<MobileRealnamePage />} />
<Route path="seller/listings" element={<MobileSellerListingsPage />} />
<Route path="seller/listings/new" element={<MobileSellerFormPage />} />
<Route path="seller/listings/:id" element={<MobileSellerListingDetailPage />} />
<Route path="seller/listings/:id/edit" element={<MobileSellerFormPage />} />
<Route path="profile" element={<MobileProfilePage />} />
</Route>
@@ -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>
+1
View File
@@ -16,4 +16,5 @@
@import './mobile-chat.css';
@import './pc-messages.css';
@import './seller.css';
@import './mobile-seller.css';
@import './components.css';
+178
View File
@@ -0,0 +1,178 @@
.mobile-seller {
padding: 16px 16px 40px;
}
.mobile-seller__hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 18px;
border-radius: 12px;
background: var(--brand-ink);
color: var(--paper-warm);
}
.mobile-seller__eyebrow {
font-size: 10px;
letter-spacing: 0.2em;
opacity: 0.7;
}
.mobile-seller__hero h1 {
margin: 5px 0 6px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 24px;
}
.mobile-seller__hero p {
margin: 0;
color: rgba(246, 242, 232, 0.72);
font-size: 12px;
}
.mobile-seller__filters {
display: flex;
gap: 8px;
margin: 14px -16px 12px;
padding: 0 16px 2px;
overflow-x: auto;
}
.mobile-seller__filters button {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--paper-warm);
color: var(--ink-secondary);
font-size: 12px;
}
.mobile-seller__filters button.is-active {
border-color: var(--brand-ink);
background: var(--brand-ink);
color: #fff;
}
.mobile-seller__filters em {
font-style: normal;
opacity: 0.7;
}
.mobile-seller__list {
display: grid;
gap: 12px;
}
.mobile-seller__empty {
display: grid;
gap: 14px;
justify-items: center;
padding: 24px 0;
}
.mobile-seller-card {
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--paper-warm);
}
.mobile-seller-card__top,
.mobile-seller-card__bottom,
.mobile-seller-card__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mobile-seller-card__no {
color: var(--ink-muted);
font-family: 'Inter', monospace;
font-size: 11px;
}
.mobile-seller-card__tags {
display: flex;
gap: 4px;
}
.mobile-seller-card h2 {
margin: 9px 0 7px;
color: var(--ink);
font-size: 16px;
line-height: 1.45;
}
.mobile-seller-card__meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
color: var(--ink-muted);
font-size: 12px;
}
.mobile-seller-card__bottom {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--line);
}
.mobile-seller-card__bottom strong {
color: var(--brand-seal);
font-family: 'Inter', monospace;
font-size: 20px;
}
.mobile-seller-card__reason {
margin: 9px 0 0;
color: var(--danger);
font-size: 11px;
line-height: 1.6;
}
.mobile-seller-card__actions {
justify-content: flex-start;
margin-top: 10px;
flex-wrap: wrap;
}
/* 移动端复用 PC 详情/发布组件时的容器适配 */
.mobile-seller-host .seller-page {
padding: 14px 12px 28px;
}
.mobile-seller-host .seller-hero,
.mobile-seller-host .seller-form-head,
.mobile-seller-host .seller-detail-head {
padding: 14px;
}
.mobile-seller-host .seller-title {
font-size: 22px !important;
}
.mobile-seller-host .seller-stat-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mobile-seller-host .seller-form-grid,
.mobile-seller-host .seller-meta-grid,
.mobile-seller-host .seller-agreements,
.mobile-seller-host .seller-price-grid,
.mobile-seller-host .seller-quantity-row {
grid-template-columns: 1fr;
}
.mobile-seller-host .seller-quantity-head {
display: none;
}
.mobile-seller-host .seller-summary {
position: static;
}