From 98d0d3583008c158c9f05c7f68639b8b8c5cc73b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 14:57:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(seller):=20=E6=96=B0=E5=A2=9E=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E5=8D=96=E5=AE=B6=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase2-移动端卖家中心验收.md | 36 +++ docs/项目计划.md | 2 +- frontend/src/app/AppRouter.tsx | 19 ++ .../src/features/mobile/MobileProfilePage.tsx | 7 + .../features/mobile/MobileSellerFormPage.tsx | 9 + .../mobile/MobileSellerListingDetailPage.tsx | 9 + .../mobile/MobileSellerListingsPage.tsx | 291 ++++++++++++++++++ .../seller/SellerListingDetailPage.tsx | 11 +- .../features/seller/SellerListingFormPage.tsx | 15 +- frontend/src/styles/global.css | 1 + frontend/src/styles/mobile-seller.css | 178 +++++++++++ 11 files changed, 565 insertions(+), 13 deletions(-) create mode 100644 docs/Phase2-移动端卖家中心验收.md create mode 100644 frontend/src/features/mobile/MobileSellerFormPage.tsx create mode 100644 frontend/src/features/mobile/MobileSellerListingDetailPage.tsx create mode 100644 frontend/src/features/mobile/MobileSellerListingsPage.tsx create mode 100644 frontend/src/styles/mobile-seller.css diff --git a/docs/Phase2-移动端卖家中心验收.md b/docs/Phase2-移动端卖家中心验收.md new file mode 100644 index 0000000..b4ab126 --- /dev/null +++ b/docs/Phase2-移动端卖家中心验收.md @@ -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 表单和整数定价工具,避免两套价格算法。 +- 表单在移动容器下会单列堆叠;复杂字段仍保持完整。 +- 未执行真实发布写操作,避免污染开发库。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 50e4277..e62521d 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -381,7 +381,7 @@ hfb_jysh/ ### Phase 3:C 端 PC + 移动端(3 周) -> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心、聊天页与卖家发布/编辑/审核提交已接通,移动端卖家页面与分销长尾能力待迁移。 +> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心、聊天与卖家中心,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心、聊天页与卖家发布/编辑/审核提交已接通,剩余分销长尾能力与真实订单端到端回归。 - 首页、商品列表、商品详情、撞车商品。 - 登录、注册、找回密码、实名认证。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index c4a7731..af4b0d8 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/features/mobile/MobileProfilePage.tsx b/frontend/src/features/mobile/MobileProfilePage.tsx index b318d88..08f8dcc 100644 --- a/frontend/src/features/mobile/MobileProfilePage.tsx +++ b/frontend/src/features/mobile/MobileProfilePage.tsx @@ -39,6 +39,13 @@ export function MobileProfilePage() { navigate('/m/orders')}>我的订单 navigate('/m/messages')}>消息中心 + + navigate(user ? '/m/seller/listings' : '/m/login?redirect=/m/seller/listings') + } + > + 卖家中心 +
diff --git a/frontend/src/features/mobile/MobileSellerFormPage.tsx b/frontend/src/features/mobile/MobileSellerFormPage.tsx new file mode 100644 index 0000000..11b107f --- /dev/null +++ b/frontend/src/features/mobile/MobileSellerFormPage.tsx @@ -0,0 +1,9 @@ +import { SellerListingFormPage } from '@/features/seller/SellerListingFormPage' + +export function MobileSellerFormPage() { + return ( +
+ +
+ ) +} diff --git a/frontend/src/features/mobile/MobileSellerListingDetailPage.tsx b/frontend/src/features/mobile/MobileSellerListingDetailPage.tsx new file mode 100644 index 0000000..ba11331 --- /dev/null +++ b/frontend/src/features/mobile/MobileSellerListingDetailPage.tsx @@ -0,0 +1,9 @@ +import { SellerListingDetailPage } from '@/features/seller/SellerListingDetailPage' + +export function MobileSellerListingDetailPage() { + return ( +
+ +
+ ) +} diff --git a/frontend/src/features/mobile/MobileSellerListingsPage.tsx b/frontend/src/features/mobile/MobileSellerListingsPage.tsx new file mode 100644 index 0000000..83d4e99 --- /dev/null +++ b/frontend/src/features/mobile/MobileSellerListingsPage.tsx @@ -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 + 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('all') + const [actingId, setActingId] = useState(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 ( +
+ + +
+ ) + } + + if (!token) { + return ( +
+
+ +

登录后管理商品

+

发布、提审与下架只对当前账号开放。

+ +
+
+ ) + } + + return ( +
+
+
+ SELLER · 号主柜台 +

我的商品

+

查看审核进度,管理上架与下架。

+
+ +
+ +
+ {filters.map(item => ( + + ))} +
+ + {listingsQuery.isPending ? ( + + ) : listingsQuery.isError ? ( + + + + ) : display.length === 0 ? ( +
+ + +
+ ) : ( +
+ {display.map(item => { + const status = listingStatusMeta(item.status) + const review = listingReviewStatusMeta(item.review_status) + return ( +
navigate(`/m/seller/listings/${item.id}`)} + > +
+ {item.listing_no || `#${item.id}`} +
+ {status.label} + {item.status !== 'completed' ? ( + {review.label} + ) : null} +
+
+

{item.title || '未命名账号'}

+
+ {item.server_region || '—'} + {coinText(item)} + 押金 {formatMoney(item.deposit_amount_cent)} +
+
+ {formatMoney(sellerPriceCent(item))} + +
+ {item.review_status === 'rejected' && item.review_reason ? ( +

驳回:{item.review_reason}

+ ) : null} +
event.stopPropagation()}> + + + +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/features/seller/SellerListingDetailPage.tsx b/frontend/src/features/seller/SellerListingDetailPage.tsx index 62997df..b9b99a1 100644 --- a/frontend/src/features/seller/SellerListingDetailPage.tsx +++ b/frontend/src/features/seller/SellerListingDetailPage.tsx @@ -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() { @@ -292,7 +293,7 @@ export function SellerListingDetailPage() { description="可能不是本人发布的商品,或商品已不存在。" action={ - SELLER LISTING · 商品详情 @@ -359,7 +360,7 @@ export function SellerListingDetailPage() { {canEdit(listing) ? ( diff --git a/frontend/src/features/seller/SellerListingFormPage.tsx b/frontend/src/features/seller/SellerListingFormPage.tsx index 4ed5464..a1995a0 100644 --- a/frontend/src/features/seller/SellerListingFormPage.tsx +++ b/frontend/src/features/seller/SellerListingFormPage.tsx @@ -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(defaultPublishForm) const [quantityValues, setQuantityValues] = useState>({}) @@ -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() { SELLER PUBLISH · 号主发布 diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 564536a..2e753bd 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -16,4 +16,5 @@ @import './mobile-chat.css'; @import './pc-messages.css'; @import './seller.css'; +@import './mobile-seller.css'; @import './components.css'; diff --git a/frontend/src/styles/mobile-seller.css b/frontend/src/styles/mobile-seller.css new file mode 100644 index 0000000..0a2c025 --- /dev/null +++ b/frontend/src/styles/mobile-seller.css @@ -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; +}