feat(seller): 迁移商品发布编辑与审核链路

This commit is contained in:
yml2213
2026-10-30 14:24:51 +08:00
parent 398bf28d7d
commit 85bff95042
13 changed files with 4262 additions and 1 deletions
@@ -0,0 +1,76 @@
# Phase 2 卖家商品发布与编辑验收
> 日期:2026-10-30
> 范围:PC 卖家商品列表、详情、发布、编辑、提交审核、上下架。
## 1. 交付内容
### 1.1 路由与入口
- `/seller/listings`:我的商品
- `/seller/listings/new`:发布账号
- `/seller/listings/:id`:卖家商品详情
- `/seller/listings/:id/edit`:编辑账号
- 商城顶部导航与用户下拉新增「卖家中心」。
- 未登录访问卖家页面时展示登录引导,登录后回跳原页面。
### 1.2 卖家商品列表
- 统计:全部发布、已上架、待审核、已下架、已完成。
- 状态筛选、商品编号复制、刷新。
- 商品卡片展示区服、哈夫币、卖家到手价、押金、审核状态与驳回原因。
- 操作:详情、编辑、提审、下架;提审与下架均使用 `confirmModal` 二次确认。
### 1.3 发布 / 编辑表单
- 基础资料:区服、上号方式、段位、哈夫币、烽火等级、绝密 KD、赛季保险、体力、负重、每日损耗、人脸情况、封禁记录。
- 微信区自动固定为「扫码」,并过滤「账号密码」。
- 在线时间:开始/结束 TimePicker,支持「全天」快捷设置。
- 交接信息:凌晨响应、可改游戏名、全英雄、解锁德穆兰、下赛季通行证、常玩地区、备注。
- 资源:按后台 `quantity_items` 生成数量与「赠送 / 收费」模式;赛季保险为 `3*3` 时禁用 9 格体验卡。
- 皮肤:按后台 `skin_groups` 分组多选。
- 截图:按后台 `screenshot_slots` 分槽上传,单张/多张、删除、预览,最多 9 张。
- 协议:展示「虚拟资产出售协议」与「号主协议」,必须勾选;另需确认改密与清除登录设备事项。
### 1.4 定价与校验
- 移植原项目纯计算逻辑到 `frontend/src/shared/utils/pricing.ts`:
- 金额/比例取整、单位价格解析、每日损耗调整。
- 消耗品计价、推荐押金与押金明细。
- 卖家参考比例、加速出售比例、固定加价/比例修正平台定价。
- 表单实时展示参考比例、卖家比例、买家比例、纯币基础价、消耗品价值、买家挂牌价、推荐押金。
- 提交前校验:
- 区服、上号方式、段位、哈夫币、烽火等级下限。
- 赛季保险、体力、负重、每日损耗。
- 后台配置了封禁记录选项时必选封禁记录。
- 在线时间合法且起止不相同。
- 押金不低于推荐值,且必须大于消耗品总价值。
- 每个必填截图上传承载位必须有图片。
- 封禁记录命中 `ban_evidence_options` 时,腾讯安全中心截图变为必填。
- 两份协议与改密确认必须勾选。
### 1.5 编辑回填
- 回填 `listing.asset_summary` 中的在线时间、resources、skin_groups、`price_breakdown.accelerated_sale_ratio`。
- 优先使用 `screenshot_groups` 回填截图;历史数据缺失时回退 `screenshot_urls`。
- 编辑保存成功后返回列表;新建且自动建立发布群时跳转 `/messages/:conversationId`。
## 2. 验证记录
| 检查 | 结果 |
|---|---|
| `cd frontend && npx tsc --noEmit` | 通过 |
| `cd frontend && npm run build` | 通过,主入口 519.27 kB / gzip 167.33 kB |
| `GET /seller/listings` | 200 |
| `GET /seller/listings/new` | 200 |
| `GET /api/seller/listings`(联调用户) | 200,返回现有商品 |
| `GET /api/listing-publish-options` | 200,4685 bytes |
| `GET /api/listing-publish-agreements` | 200,2700 bytes |
| `GET /api/listing-sale-price-config` | 200,563 bytes |
## 3. 未覆盖与风险
- 未执行真实发布写操作,避免污染开发库;发布接口与表单已通过类型检查与构建。
- 卖家页面当前仅实现 PC 端,移动端卖家页面未迁移。
- 历史商品 `asset_summary` 字段不全时,详情页会显示 `—` 或回退截图列表。
- 发布选项为空时页面不会崩溃,但会因无法计算买家价而阻止提交,需后台先配置发布选项。
+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
@@ -13,6 +13,21 @@ const ListingDetailPage = lazy(() =>
default: module.ListingDetailPage,
}))
)
const SellerListingsPage = lazy(() =>
import('@/features/seller/SellerListingsPage').then(module => ({
default: module.SellerListingsPage,
}))
)
const SellerListingDetailPage = lazy(() =>
import('@/features/seller/SellerListingDetailPage').then(module => ({
default: module.SellerListingDetailPage,
}))
)
const SellerListingFormPage = lazy(() =>
import('@/features/seller/SellerListingFormPage').then(module => ({
default: module.SellerListingFormPage,
}))
)
const OrderListPage = lazy(() =>
import('@/features/orders/OrderListPage').then(module => ({ default: module.OrderListPage }))
)
@@ -170,6 +185,10 @@ export function AppRouter() {
<Route path="orders/:id" element={<OrderDetailPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="messages/:id" element={<ChatPage />} />
<Route path="seller/listings" element={<SellerListingsPage />} />
<Route path="seller/listings/new" element={<SellerListingFormPage />} />
<Route path="seller/listings/:id" element={<SellerListingDetailPage />} />
<Route path="seller/listings/:id/edit" element={<SellerListingFormPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
@@ -0,0 +1,537 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Col,
Descriptions,
Empty,
Image,
Row,
Skeleton,
Space,
Tag,
Typography,
} from 'antd'
import {
ArrowLeftOutlined,
CopyOutlined,
EditOutlined,
ReloadOutlined,
SendOutlined,
StopOutlined,
} from '@ant-design/icons'
import { useNavigate, useParams } from 'react-router-dom'
import { listingReviewStatusMeta, listingStatusMeta } from '@/features/listings/labels'
import type { StatusMeta } from '@/features/admin/labels'
import { useSessionStore } from '@/stores/session'
import { confirmModal } from '@/shared/utils/confirm'
import { formatDateTime, formatMoney } from '@/shared/utils/format'
import { fetchSellerListing, offlineListing, readApiError, submitListingReview } from './api'
import type { SellerListing } from './types'
function recordValue(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function stringValue(value: unknown) {
if (value === undefined || value === null) return ''
return String(value)
}
function numberValue(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function stringArray(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string' && Boolean(item))
: []
}
function isTerminal(row: SellerListing) {
return row.status === 'rented' || row.status === 'completed' || row.status === 'sealed'
}
function isPendingReview(row: SellerListing) {
return !isTerminal(row) && row.status !== 'offline' && row.review_status === 'pending'
}
function canEdit(row: SellerListing) {
return !isTerminal(row) && !isPendingReview(row) && row.status !== 'published'
}
function canSubmit(row: SellerListing) {
return !isTerminal(row) && !isPendingReview(row) && row.status !== 'published'
}
function canOffline(row: SellerListing) {
return !isTerminal(row) && row.status !== 'offline'
}
function statusTag(meta: StatusMeta) {
return (
<Tag color={meta.tone} bordered={false}>
{meta.label}
</Tag>
)
}
function sellerPriceCent(row: SellerListing) {
const breakdown = recordValue(row.asset_summary?.price_breakdown)
const price = numberValue(breakdown.seller_total_price)
return price > 0 ? Math.round(price * 100) : row.price_cent
}
function coinText(row: SellerListing) {
const coinM = Number(row.haf_coin_amount || 0) / 1_000_000
if (!coinM) return '—'
return Number.isInteger(coinM) ? `${coinM}M` : `${coinM.toFixed(2)}M`
}
function onlineTimeText(summary: Record<string, unknown>) {
const online = recordValue(summary.online_time)
const start = stringValue(online.start)
const end = stringValue(online.end)
if (!start && !end) return '—'
return `${start || '—'} 至 ${end || '—'}`
}
function ratioText(value: unknown) {
const ratio = Number(value)
return Number.isFinite(ratio) && ratio > 0 ? `1:${ratio.toFixed(1)}` : '—'
}
function screenshotsOf(row: SellerListing) {
const summary = row.asset_summary ?? {}
const groups = recordValue(summary.screenshot_groups)
const slotLabels: Record<string, string> = {
coin: '纯币截图',
gameId: '游戏ID截图',
totalAsset: '总资产截图',
tencentSecurity: '腾讯安全中心截图',
skin: '皮肤截图',
}
const grouped = Object.entries(groups).flatMap(([key, value]) => {
const urls = stringArray(value)
return urls.map((url, index) => ({
label: urls.length > 1 ? `${slotLabels[key] || key}${index + 1}` : slotLabels[key] || key,
url,
}))
})
if (grouped.length > 0) return grouped
const fallbackLabels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return stringArray(row.screenshot_urls).map((url, index) => ({
label: fallbackLabels[index] || `账号截图${index + 1}`,
url,
}))
}
function skinGroupsOf(row: SellerListing) {
const summary = row.asset_summary ?? {}
const groups = recordValue(summary.skin_groups)
const titles: Record<string, string> = {
melee: '近战皮肤',
operator: '干员皮肤',
operatorGold: '干员金皮',
operatorRed: '干员红皮',
weapon: '武器皮肤',
other: '其他皮肤',
imported: '其他皮肤',
}
return Object.entries(groups)
.map(([key, value]) => ({
key,
title: titles[key] || key,
options: stringArray(value),
}))
.filter(group => group.options.length > 0)
}
export function SellerListingDetailPage() {
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 listingQuery = useQuery({
queryKey: ['seller-listing', id],
queryFn: () => fetchSellerListing(id as string),
enabled: Boolean(id) && Boolean(token),
})
const listing = listingQuery.data
async function refreshLists() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['seller-listings'] }),
queryClient.invalidateQueries({ queryKey: ['listings'] }),
])
}
async function handleSubmitReview() {
if (!listing) return
const confirmed = await confirmModal(modal, {
title: '提交审核',
content: `确认提交「${listing.title}」进入后台审核吗?审核通过后将自动上架。`,
okText: '提交审核',
cancelText: '取消',
onOk: async () => {
try {
const next = await submitListingReview(listing.id)
queryClient.setQueryData(['seller-listing', id], next)
await refreshLists()
message.success(
next.status === 'published' && next.review_status === 'approved'
? '已上架'
: '已提交审核,等待后台处理'
)
} catch (error) {
message.error(readApiError(error, '提审失败,请稍后重试'))
}
},
})
if (!confirmed) return
}
async function handleOffline() {
if (!listing) return
const confirmed = await confirmModal(modal, {
title: '下架确认',
content: `确认下架「${listing.title}」吗?下架后后台待审列表会同步移除。`,
okText: '确认下架',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
try {
const next = await offlineListing(listing.id)
queryClient.setQueryData(['seller-listing', id], next)
await refreshLists()
message.success('已下架')
} catch (error) {
message.error(readApiError(error, '下架失败,请稍后重试'))
}
},
})
if (!confirmed) return
}
async function copyCode() {
if (!listing) return
try {
await navigator.clipboard.writeText(listing.listing_no)
message.success('商品编号已复制')
} catch {
message.error('复制失败')
}
}
if (!hydrated) {
return (
<main className="seller-page">
<Card className="seller-panel">
<Skeleton active paragraph={{ rows: 8 }} />
</Card>
</main>
)
}
if (!token) {
return (
<main className="seller-page">
<Card className="seller-panel">
<Empty description="登录后查看自己发布的商品">
<Button
type="primary"
onClick={() => navigate(`/login?redirect=/seller/listings/${id}`)}
>
去登录
</Button>
</Empty>
</Card>
</main>
)
}
if (listingQuery.isPending) {
return (
<main className="seller-page">
<Card className="seller-panel">
<Skeleton active paragraph={{ rows: 8 }} />
</Card>
</main>
)
}
if (listingQuery.isError || !listing) {
return (
<main className="seller-page">
<Alert
type="error"
showIcon
message="商品没有加载出来"
description="可能不是本人发布的商品,或商品已不存在。"
action={
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/seller/listings')}>
返回列表
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void listingQuery.refetch()}>
重试
</Button>
</Space>
}
/>
</main>
)
}
const summary = listing.asset_summary ?? {}
const breakdown = recordValue(summary.price_breakdown)
const status = listingStatusMeta(listing.status)
const review = listingReviewStatusMeta(listing.review_status)
const screenshots = screenshotsOf(listing)
const skinGroups = skinGroupsOf(listing)
const resources = Array.isArray(summary.resources)
? summary.resources.map(item => recordValue(item)).filter(item => numberValue(item.quantity) > 0)
: []
const commonRegions = stringArray(summary.common_regions)
const accountRows: Array<[string, string]> = [
['所属区服', listing.server_region || '—'],
['上号方式', listing.login_platform || '—'],
['游戏段位', listing.rank_level || '—'],
['烽火等级', summary.fire_level ? `${numberValue(summary.fire_level)}级` : '—'],
['赛季保险', stringValue(summary.season_insurance) || '—'],
['体力等级', stringValue(summary.stamina_level) || '—'],
['负重等级', stringValue(summary.load_level) || '—'],
['绝密KD', stringValue(summary.secret_kd) || '—'],
['哈夫币', coinText(listing)],
['每日损耗', summary.daily_loss_m ? `${numberValue(summary.daily_loss_m)}M/天` : '—'],
['在线时间', onlineTimeText(summary)],
['凌晨响应', stringValue(summary.early_morning_response) || '—'],
['可改游戏名', stringValue(summary.can_change_game_name) || '—'],
['全英雄', stringValue(summary.all_hero) || '—'],
['解锁德穆兰', stringValue(summary.unlock_demulan) || '—'],
['下赛季通行证', stringValue(summary.next_season_pass) || '—'],
['封禁记录', stringValue(summary.ban_record) || '无'],
['常用登录地区', commonRegions.length > 0 ? commonRegions.join('、') : '—'],
]
return (
<main className="seller-page seller-detail-page">
<header className="seller-detail-head">
<div>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/seller/listings')}>
返回列表
</Button>
<Typography.Text className="seller-eyebrow">SELLER LISTING · 商品详情</Typography.Text>
<Typography.Title level={1} className="seller-title">
我的商品详情
</Typography.Title>
<Typography.Paragraph className="seller-lead">
仅本人可查看完整发布信息;他人无法通过此入口访问。
</Typography.Paragraph>
</div>
<Space wrap>
<Button icon={<CopyOutlined />} onClick={() => void copyCode()}>
复制编号
</Button>
{canEdit(listing) ? (
<Button
icon={<EditOutlined />}
onClick={() => navigate(`/seller/listings/${listing.id}/edit`)}
>
编辑
</Button>
) : null}
{canSubmit(listing) ? (
<Button icon={<SendOutlined />} onClick={() => void handleSubmitReview()}>
提审
</Button>
) : null}
{canOffline(listing) ? (
<Button danger icon={<StopOutlined />} onClick={() => void handleOffline()}>
下架
</Button>
) : null}
</Space>
</header>
<Card className="seller-detail-hero">
<div className="seller-detail-hero__main">
<div className="seller-detail-hero__tags">
<span className="seller-code">{listing.listing_no || `#${listing.id}`}</span>
{statusTag(status)}
{listing.review_status !== 'none' ? statusTag(review) : null}
</div>
<Typography.Title level={2} className="seller-detail-title">
{listing.title || '未命名账号'}
</Typography.Title>
<Space size={6} wrap>
<Tag bordered={false}>{listing.server_region || '区服待定'}</Tag>
<Tag bordered={false}>{listing.login_platform || '登录方式待定'}</Tag>
{listing.rank_level ? <Tag bordered={false}>{listing.rank_level}</Tag> : null}
</Space>
{listing.review_reason ? (
<Alert
className="seller-detail-reason"
type={listing.review_status === 'rejected' ? 'error' : 'info'}
showIcon
message={listing.review_status === 'rejected' ? '审核驳回原因' : '审核备注'}
description={listing.review_reason}
/>
) : null}
</div>
<div className="seller-detail-prices">
<div>
<span>卖家到手价</span>
<strong>{formatMoney(sellerPriceCent(listing))}</strong>
</div>
<div>
<span>买家挂牌价</span>
<strong>{formatMoney(listing.price_cent)}</strong>
</div>
<div>
<span>押金</span>
<strong>{formatMoney(listing.deposit_amount_cent)}</strong>
</div>
</div>
</Card>
<Row gutter={[16, 16]}>
<Col xs={24} lg={15}>
<Card title="账号资料" className="seller-panel">
<Descriptions column={2} size="small" colon={false}>
{accountRows.map(([label, value]) => (
<Descriptions.Item key={label} label={label}>
{value}
</Descriptions.Item>
))}
</Descriptions>
</Card>
<Card title="价格与押金" className="seller-panel seller-detail-pricing">
<div className="seller-price-grid">
<div>
<span>参考比例</span>
<strong>{ratioText(breakdown.seller_reference_ratio)}</strong>
</div>
<div>
<span>卖家比例</span>
<strong>{ratioText(breakdown.seller_ratio)}</strong>
</div>
<div>
<span>纯币基础价</span>
<strong>{formatMoney(numberValue(breakdown.seller_coin_base_price) * 100)}</strong>
</div>
<div>
<span>消耗品价值</span>
<strong>{formatMoney(numberValue(breakdown.consumable_price) * 100)}</strong>
</div>
<div>
<span>买家比例</span>
<strong>{ratioText(breakdown.buyer_ratio)}</strong>
</div>
<div>
<span>平台加价</span>
<strong>{formatMoney(numberValue(breakdown.platform_markup_amount) * 100)}</strong>
</div>
</div>
</Card>
{resources.length > 0 ? (
<Card title="额外消耗品" className="seller-panel">
<div className="seller-resource-grid">
{resources.map((resource, index) => (
<div key={`${stringValue(resource.key)}-${index}`} className="seller-resource">
<span>{stringValue(resource.label) || stringValue(resource.key)}</span>
<strong>{numberValue(resource.quantity)}</strong>
<small>
{stringValue(resource.mode) || '—'}
{stringValue(resource.price) ? ` · ${stringValue(resource.price)}` : ''}
</small>
</div>
))}
</div>
</Card>
) : null}
{skinGroups.length > 0 ? (
<Card title="皮肤" className="seller-panel">
{skinGroups.map(group => (
<div key={group.key} className="seller-skin-group">
<strong>{group.title}</strong>
<Space size={6} wrap>
{group.options.map(skin => (
<Tag key={skin} bordered={false}>
{skin}
</Tag>
))}
</Space>
</div>
))}
</Card>
) : null}
{screenshots.length > 0 ? (
<Card title="账号截图" className="seller-panel">
<Image.PreviewGroup>
<div className="seller-shot-grid">
{screenshots.map((shot, index) => (
<figure key={`${shot.url}-${index}`}>
<Image src={shot.url} alt={shot.label} width="100%" height={130} />
<figcaption>{shot.label}</figcaption>
</figure>
))}
</div>
</Image.PreviewGroup>
</Card>
) : null}
</Col>
<Col xs={24} lg={9}>
<Card title="时间信息" className="seller-panel">
<Descriptions column={1} size="small" colon={false}>
<Descriptions.Item label="创建时间">
{formatDateTime(listing.created_at)}
</Descriptions.Item>
<Descriptions.Item label="上架时间">
{listing.published_at ? formatDateTime(listing.published_at) : '—'}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{formatDateTime(listing.updated_at)}
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="备注" className="seller-panel">
<Typography.Paragraph className="seller-remark">
{listing.description || '无'}
</Typography.Paragraph>
</Card>
<Card title="操作提示" className="seller-panel seller-tip-panel">
<ul>
<li>待审核、已上架的商品不能重复提审。</li>
<li>下架后后台待审列表会同步移除。</li>
<li>订单完成或租赁中的商品不可编辑、不可下架。</li>
</ul>
</Card>
</Col>
</Row>
{screenshots.length === 0 ? (
<Empty description="该商品没有截图材料" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : null}
</main>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,418 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Col,
Empty,
Row,
Skeleton,
Space,
Tag,
Typography,
} from 'antd'
import {
CopyOutlined,
EditOutlined,
EyeOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
StopOutlined,
} from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { listingReviewStatusMeta, listingStatusMeta } from '@/features/listings/labels'
import type { StatusMeta } from '@/features/admin/labels'
import { useSessionStore } from '@/stores/session'
import { confirmModal } from '@/shared/utils/confirm'
import { formatMoney } from '@/shared/utils/format'
import {
fetchSellerListings,
offlineListing,
readApiError,
submitListingReview,
} from './api'
import type { SellerListing } from './types'
type StatusFilter = 'all' | 'published' | 'pending' | 'offline' | 'completed'
function isTerminal(row: SellerListing) {
return row.status === 'rented' || row.status === 'completed' || row.status === 'sealed'
}
function isPendingReview(row: SellerListing) {
return !isTerminal(row) && row.status !== 'offline' && row.review_status === 'pending'
}
function canEdit(row: SellerListing) {
return !isTerminal(row) && !isPendingReview(row) && row.status !== 'published'
}
function canSubmit(row: SellerListing) {
return !isTerminal(row) && !isPendingReview(row) && row.status !== 'published'
}
function canOffline(row: SellerListing) {
return !isTerminal(row) && row.status !== 'offline'
}
function reviewStatusOf(row: SellerListing) {
return row.status === 'offline' ? 'none' : row.review_status
}
function coinText(row: SellerListing) {
const coinM = Number(row.haf_coin_amount || 0) / 1_000_000
if (!coinM) return '—'
return Number.isInteger(coinM) ? `${coinM}M` : `${coinM.toFixed(2)}M`
}
function sellerReferencePrice(row: SellerListing) {
const summary = row.asset_summary ?? {}
const breakdown = summary.price_breakdown
if (breakdown && typeof breakdown === 'object') {
const value = Number((breakdown as Record<string, unknown>).seller_total_price)
if (Number.isFinite(value) && value > 0) return Math.round(value * 100)
}
return row.price_cent
}
function statusTag(meta: StatusMeta) {
return (
<Tag color={meta.tone} bordered={false}>
{meta.label}
</Tag>
)
}
export function SellerListingsPage() {
const navigate = useNavigate()
const { modal, message } = App.useApp()
const queryClient = useQueryClient()
const token = useSessionStore(state => state.token)
const hydrated = useSessionStore(state => state.hydrated)
const [filter, setFilter] = useState<StatusFilter>('all')
const [actingId, setActingId] = useState<number | null>(null)
const listingsQuery = useQuery({
queryKey: ['seller-listings'],
queryFn: fetchSellerListings,
enabled: Boolean(token),
})
const listings = listingsQuery.data ?? []
const stats = [
{ key: 'all' as const, label: '全部发布', value: listings.length },
{
key: 'published' as const,
label: '已上架',
value: listings.filter(item => item.status === 'published').length,
},
{
key: 'pending' as const,
label: '待审核',
value: listings.filter(isPendingReview).length,
},
{
key: 'offline' as const,
label: '已下架',
value: listings.filter(item => item.status === 'offline').length,
},
{
key: 'completed' as const,
label: '已完成',
value: listings.filter(item => item.status === 'completed').length,
},
]
const displayListings = listings.filter(item => {
if (filter === 'all') return true
if (filter === 'pending') return isPendingReview(item)
return item.status === filter
})
async function refreshAfterAction() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['seller-listings'] }),
queryClient.invalidateQueries({ queryKey: ['listings'] }),
])
}
const submitMutation = useMutation({
mutationFn: submitListingReview,
onSuccess: async listing => {
await Promise.all([
refreshAfterAction(),
queryClient.invalidateQueries({ queryKey: ['seller-listing', String(listing.id)] }),
])
message.success(
listing.status === 'published' && listing.review_status === 'approved'
? '已上架'
: '已提交审核,等待后台处理'
)
},
})
const offlineMutation = useMutation({
mutationFn: offlineListing,
onSuccess: async () => {
await refreshAfterAction()
message.success('已下架')
},
})
async function handleSubmitReview(row: SellerListing) {
const confirmed = await confirmModal(modal, {
title: '提交审核',
content: `确认提交「${row.title}」进入后台审核吗?审核通过后将自动上架。`,
okText: '提交审核',
cancelText: '取消',
onOk: async () => {
setActingId(row.id)
try {
await submitMutation.mutateAsync(row.id)
} catch (error) {
message.error(readApiError(error, '提审失败,请稍后重试'))
} finally {
setActingId(null)
}
},
})
if (!confirmed) return
}
async function handleOffline(row: SellerListing) {
const confirmed = await confirmModal(modal, {
title: '下架确认',
content: `确认下架「${row.title}」吗?下架后后台待审列表会同步移除。`,
okText: '确认下架',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
setActingId(row.id)
try {
await offlineMutation.mutateAsync(row.id)
} catch (error) {
message.error(readApiError(error, '下架失败,请稍后重试'))
} finally {
setActingId(null)
}
},
})
if (!confirmed) return
}
async function copyCode(row: SellerListing) {
try {
await navigator.clipboard.writeText(row.listing_no)
message.success('商品编号已复制')
} catch {
message.error('复制失败')
}
}
if (!hydrated) {
return (
<main className="seller-page seller-list-page">
<Card className="seller-panel">
<Skeleton active paragraph={{ rows: 8 }} />
</Card>
</main>
)
}
if (!token) {
return (
<main className="seller-page seller-list-page">
<Card className="seller-panel">
<Empty description="登录后管理自己发布的账号">
<Button
type="primary"
onClick={() => navigate('/login?redirect=/seller/listings')}
>
去登录
</Button>
</Empty>
</Card>
</main>
)
}
return (
<main className="seller-page seller-list-page">
<header className="seller-hero">
<div>
<Typography.Text className="seller-eyebrow">SELLER DESK · 号主柜台</Typography.Text>
<Typography.Title level={1} className="seller-title">
我的商品
</Typography.Title>
<Typography.Paragraph className="seller-lead">
查看账号状态、审核进度,并管理上架与下架。所有操作都会留痕。
</Typography.Paragraph>
</div>
<Space wrap>
<Button
icon={<ReloadOutlined />}
loading={listingsQuery.isFetching}
onClick={() => void listingsQuery.refetch()}
>
刷新
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/seller/listings/new')}>
发布账号
</Button>
</Space>
</header>
<section className="seller-stat-strip" aria-label="商品统计">
{stats.map(item => (
<button
key={item.key}
type="button"
className={`seller-stat${filter === item.key ? ' is-active' : ''}`}
onClick={() => setFilter(item.key)}
>
<span>{item.label}</span>
<strong>{item.value}</strong>
</button>
))}
</section>
{listingsQuery.isError ? (
<Card className="seller-panel">
<Empty
description="商品列表没有加载出来,请确认登录状态后重试。"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button onClick={() => void listingsQuery.refetch()}>重新加载</Button>
</Empty>
</Card>
) : null}
{listingsQuery.isPending ? (
<Row gutter={[16, 16]}>
{Array.from({ length: 4 }).map((_, index) => (
<Col xs={24} lg={12} key={index}>
<Card className="seller-panel">
<Skeleton active paragraph={{ rows: 4 }} />
</Card>
</Col>
))}
</Row>
) : null}
{!listingsQuery.isPending && !listingsQuery.isError && displayListings.length === 0 ? (
<Card className="seller-panel">
<Empty
description={
<span>
暂无符合条件的发布。
<br />
可以先发布一个账号,审核通过后会自动上架。
</span>
}
>
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/seller/listings/new')}>
去发布
</Button>
</Empty>
</Card>
) : null}
{displayListings.length > 0 ? (
<Row gutter={[16, 16]}>
{displayListings.map(item => {
const status = listingStatusMeta(item.status)
const review = listingReviewStatusMeta(reviewStatusOf(item))
return (
<Col xs={24} lg={12} key={item.id}>
<Card className="seller-listing-card">
<div className="seller-listing-card__head">
<button
type="button"
className="seller-code"
onClick={() => void copyCode(item)}
title="复制商品编号"
>
{item.listing_no || `#${item.id}`}
<CopyOutlined />
</button>
<Space size={6} wrap>
{statusTag(status)}
{item.status !== 'completed' ? statusTag(review) : null}
</Space>
</div>
<Link to={`/seller/listings/${item.id}`} className="seller-listing-card__body">
<Typography.Title level={3} ellipsis={{ rows: 2 }}>
{item.title || '未命名账号'}
</Typography.Title>
<div className="seller-meta-grid">
<div>
<span>区服</span>
<strong>{item.server_region || '—'}</strong>
</div>
<div>
<span>哈夫币</span>
<strong>{coinText(item)}</strong>
</div>
<div>
<span>售价</span>
<strong className="is-price">{formatMoney(sellerReferencePrice(item))}</strong>
</div>
<div>
<span>押金</span>
<strong>{formatMoney(item.deposit_amount_cent)}</strong>
</div>
</div>
{item.review_status === 'rejected' && item.review_reason ? (
<Typography.Paragraph className="seller-reject-reason" ellipsis={{ rows: 2 }}>
驳回原因:{item.review_reason}
</Typography.Paragraph>
) : null}
</Link>
<div className="seller-listing-card__actions">
<Button
icon={<EyeOutlined />}
onClick={() => navigate(`/seller/listings/${item.id}`)}
>
详情
</Button>
<Button
icon={<EditOutlined />}
disabled={!canEdit(item)}
onClick={() => navigate(`/seller/listings/${item.id}/edit`)}
>
编辑
</Button>
<Button
icon={<SendOutlined />}
disabled={!canSubmit(item) || actingId === item.id}
loading={actingId === item.id && submitMutation.isPending}
onClick={() => void handleSubmitReview(item)}
>
提审
</Button>
<Button
danger
icon={<StopOutlined />}
disabled={!canOffline(item) || actingId === item.id}
loading={actingId === item.id && offlineMutation.isPending}
onClick={() => void handleOffline(item)}
>
下架
</Button>
</div>
</Card>
</Col>
)
})}
</Row>
) : null}
</main>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse, Listing } from '@/shared/api/types'
import type { PublishPayload, SellerListing } from './types'
export async function fetchSellerListings(): Promise<SellerListing[]> {
const data = await unwrapData<{ items?: SellerListing[] } | null>(
apiClient.get<ApiResponse<{ items?: SellerListing[] } | null>>('/seller/listings')
)
return data?.items ?? []
}
export async function fetchSellerListing(id: number | string): Promise<SellerListing> {
return unwrapData<SellerListing>(
apiClient.get<ApiResponse<SellerListing>>(`/seller/listings/${id}`)
)
}
export async function createListing(payload: PublishPayload): Promise<SellerListing> {
return unwrapData<SellerListing>(
apiClient.post<ApiResponse<SellerListing>>('/listings', payload)
)
}
export async function updateListing(
id: number | string,
payload: PublishPayload
): Promise<SellerListing> {
return unwrapData<SellerListing>(
apiClient.put<ApiResponse<SellerListing>>(`/listings/${id}`, payload)
)
}
export async function submitListingReview(id: number | string): Promise<SellerListing> {
return unwrapData<SellerListing>(
apiClient.post<ApiResponse<SellerListing>>(`/listings/${id}/submit-review`)
)
}
export async function offlineListing(id: number | string): Promise<SellerListing> {
return unwrapData<SellerListing>(
apiClient.delete<ApiResponse<SellerListing>>(`/listings/${id}`)
)
}
/** 提取接口错误提示,页面用于 message 反馈。 */
export function readApiError(error: unknown, fallback: string) {
if (typeof error === 'object' && error !== null) {
const response = (error as { response?: { data?: { message?: string } } }).response
if (response?.data?.message) return response.data.message
}
if (error instanceof Error && error.message) return error.message
return fallback
}
/** 发布成功后的状态文案,与后端审核状态保持一致。 */
export function publishResultText(listing: Listing, editing: boolean) {
const published = listing.status === 'published' && listing.review_status === 'approved'
if (published) return editing ? '修改成功,已上架' : '发布成功,已上架'
return editing ? '修改成功,等待后台审核' : '发布成功,等待后台审核'
}
+479
View File
@@ -0,0 +1,479 @@
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/api/types'
export type ChargeMode = '赠送' | '收费'
export type QuantityKey = string
export type ScreenshotKey = string
export type SkinCategoryKey = string
export interface PublishOptionGroup {
key: SkinCategoryKey | string
title: string
options: string[]
}
export interface PublishQuantityItem {
key: QuantityKey
label: string
price: string
placeholder?: string
}
export interface PublishScreenshotSlot {
key: ScreenshotKey
label: string
required: boolean
hint: string
}
export interface PublishPriceConfig {
deposit_placeholder: string
deposit_hint: string
price_placeholder: string
ratio_description: string
}
export interface PublishDepositSkinGroupRule {
group_key: string
label: string
amount_per_item: number
}
export interface PublishDepositRecommendConfig {
base_amount: number
skin_group_rules: PublishDepositSkinGroupRule[]
}
export interface PublishInsuranceBaseRatio {
insurance: string
ratio: number
}
export interface PublishRatioConfigItem {
key: string
label: string
kind: string
group_key?: string
missing_penalty: number
}
export interface PublishCoinCorrection {
threshold_m: number
correction: number
}
export interface PublishSaleFixedMarkupRule {
min_m: number
max_m: number
markup_amount: number
}
export interface PublishSaleRatioAdjustmentRule {
min_m: number
max_m: number
ratio_subtract: number
}
export interface PublishRatioConfig {
insurance_base_ratios: PublishInsuranceBaseRatio[]
config_items: PublishRatioConfigItem[]
coin_corrections: PublishCoinCorrection[]
}
export interface PublishSalePriceConfig {
fixed_markup_rules: PublishSaleFixedMarkupRule[]
ratio_adjustment_rules: PublishSaleRatioAdjustmentRule[]
}
export interface AgreementContent {
title: string
content: string
}
export interface ListingPublishAgreements {
virtual_asset_sale: AgreementContent
seller_agreement: AgreementContent
}
export interface ListingPublishOptions {
next_season_pass_enabled: boolean
server_options: string[]
face_options: string[]
rank_options: string[]
insurance_options: string[]
level_options: string[]
login_method_options: string[]
region_options: string[]
skin_groups: PublishOptionGroup[]
quantity_items: PublishQuantityItem[]
screenshot_slots: PublishScreenshotSlot[]
ban_record_options: string[]
ban_evidence_options: string[]
fire_level_min: number
price_config: PublishPriceConfig
deposit_recommend_config: PublishDepositRecommendConfig
ratio_config: PublishRatioConfig
}
export const emptyListingPublishOptions: ListingPublishOptions = {
next_season_pass_enabled: false,
server_options: [],
face_options: [],
rank_options: [],
insurance_options: [],
level_options: [],
login_method_options: [],
region_options: [],
skin_groups: [],
quantity_items: [],
screenshot_slots: [],
ban_record_options: [],
ban_evidence_options: [],
fire_level_min: 38,
price_config: {
deposit_placeholder: '',
deposit_hint: '租客缴纳,封号必赔付',
price_placeholder: '',
ratio_description: '',
},
deposit_recommend_config: {
base_amount: 50,
skin_group_rules: [
{ group_key: 'melee', label: '刀皮', amount_per_item: 5 },
{ group_key: 'operatorGold', label: '干员金皮', amount_per_item: 10 },
{ group_key: 'operatorRed', label: '干员红皮', amount_per_item: 30 },
],
},
ratio_config: {
insurance_base_ratios: [],
config_items: [],
coin_corrections: [],
},
}
export const emptyListingSalePriceConfig: PublishSalePriceConfig = {
fixed_markup_rules: [
{ min_m: 10, max_m: 30, markup_amount: 28 },
{ min_m: 30, max_m: 50, markup_amount: 31 },
{ min_m: 50, max_m: 70, markup_amount: 34 },
{ min_m: 70, max_m: 90, markup_amount: 38 },
],
ratio_adjustment_rules: [
{ min_m: 90, max_m: 150, ratio_subtract: 5 },
{ min_m: 150, max_m: 230, ratio_subtract: 4 },
{ min_m: 230, max_m: 310, ratio_subtract: 3.5 },
{ min_m: 310, max_m: 390, ratio_subtract: 3 },
{ min_m: 390, max_m: 470, ratio_subtract: 0 },
{ min_m: 470, max_m: 550, ratio_subtract: 0 },
{ min_m: 550, max_m: 0, ratio_subtract: 0 },
],
}
export const emptyListingPublishAgreements: ListingPublishAgreements = {
virtual_asset_sale: {
title: '虚拟资产出售协议',
content: '',
},
seller_agreement: {
title: '号主协议',
content: '',
},
}
export async function fetchListingPublishOptions() {
const data = await unwrapData<Partial<ListingPublishOptions> | null>(
apiClient.get<ApiResponse<Partial<ListingPublishOptions> | null>>('/listing-publish-options')
)
return mergeListingPublishOptions(data ?? undefined)
}
export async function fetchListingPublishAgreements() {
const data = await unwrapData<Partial<ListingPublishAgreements> | null>(
apiClient.get<ApiResponse<Partial<ListingPublishAgreements> | null>>(
'/listing-publish-agreements'
)
)
return mergeListingPublishAgreements(data ?? undefined)
}
export async function fetchListingSalePriceConfig() {
const data = await unwrapData<Partial<PublishSalePriceConfig> | null>(
apiClient.get<ApiResponse<Partial<PublishSalePriceConfig> | null>>('/listing-sale-price-config')
)
return mergeListingSalePriceConfig(data ?? undefined)
}
export function mergeListingPublishOptions(
options?: Partial<ListingPublishOptions>
): ListingPublishOptions {
return {
next_season_pass_enabled: options?.next_season_pass_enabled === true,
server_options: normalizeStringList(options?.server_options),
face_options: normalizeStringList(options?.face_options),
rank_options: normalizeStringList(options?.rank_options),
insurance_options: normalizeStringList(options?.insurance_options),
level_options: normalizeStringList(options?.level_options),
login_method_options: normalizeStringList(options?.login_method_options),
region_options: normalizeStringList(options?.region_options),
skin_groups: normalizeOptionGroups(options?.skin_groups),
quantity_items: normalizeQuantityItems(options?.quantity_items),
screenshot_slots: normalizeScreenshotSlots(options?.screenshot_slots),
ban_record_options: normalizeStringList(options?.ban_record_options),
ban_evidence_options: normalizeStringList(options?.ban_evidence_options),
fire_level_min: readPositiveInteger(options?.fire_level_min, 38),
price_config: normalizePriceConfig(options?.price_config),
deposit_recommend_config: normalizeDepositRecommendConfig(options?.deposit_recommend_config),
ratio_config: normalizeRatioConfig(options?.ratio_config),
}
}
export function mergeListingSalePriceConfig(
options?: Partial<PublishSalePriceConfig>
): PublishSalePriceConfig {
return normalizeSalePriceConfig(options)
}
export function mergeListingPublishAgreements(
options?: Partial<ListingPublishAgreements>
): ListingPublishAgreements {
return {
virtual_asset_sale: normalizeAgreementContent(
options?.virtual_asset_sale,
emptyListingPublishAgreements.virtual_asset_sale
),
seller_agreement: normalizeAgreementContent(
options?.seller_agreement,
emptyListingPublishAgreements.seller_agreement
),
}
}
function normalizeAgreementContent(value: unknown, fallback: AgreementContent): AgreementContent {
const row = isRecord(value) ? value : {}
const title =
typeof row.title === 'string' && row.title.trim() ? row.title.trim() : fallback.title
const content =
typeof row.content === 'string' && row.content.trim() ? row.content.trim() : fallback.content
return { title, content }
}
function normalizeStringList(values?: unknown[]) {
return Array.isArray(values)
? values.map(item => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
: []
}
function normalizeOptionGroups(values?: unknown[]): PublishOptionGroup[] {
if (!Array.isArray(values)) return []
return values
.map(item => {
const group = isRecord(item) ? item : {}
return {
key: typeof group.key === 'string' ? group.key.trim() : '',
title: typeof group.title === 'string' ? group.title.trim() : '',
options: normalizeStringList(Array.isArray(group.options) ? group.options : []),
}
})
.filter(item => item.key && item.title)
}
function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] {
if (!Array.isArray(values)) return []
return values
.map(item => {
const row = isRecord(item) ? item : {}
return {
key: typeof row.key === 'string' ? row.key.trim() : '',
label: typeof row.label === 'string' ? row.label.trim() : '',
price: typeof row.price === 'string' ? row.price.trim() : '',
placeholder: typeof row.placeholder === 'string' ? row.placeholder.trim() : undefined,
}
})
.filter(item => item.key && item.label)
}
function normalizeScreenshotSlots(values?: unknown[]): PublishScreenshotSlot[] {
if (!Array.isArray(values)) return []
return values
.map(item => {
const row = isRecord(item) ? item : {}
return {
key: typeof row.key === 'string' ? row.key.trim() : '',
label: typeof row.label === 'string' ? row.label.trim() : '',
required: row.required === true,
hint: typeof row.hint === 'string' ? row.hint.trim() : '',
}
})
.filter(item => item.key && item.label)
}
function normalizePriceConfig(value?: unknown): PublishPriceConfig {
const row = isRecord(value) ? value : {}
return {
deposit_placeholder:
typeof row.deposit_placeholder === 'string' ? row.deposit_placeholder.trim() : '',
deposit_hint:
typeof row.deposit_hint === 'string' && row.deposit_hint.trim()
? row.deposit_hint.trim()
: emptyListingPublishOptions.price_config.deposit_hint,
price_placeholder:
typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '',
ratio_description:
typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '',
}
}
function normalizeDepositRecommendConfig(value?: unknown): PublishDepositRecommendConfig {
const row = isRecord(value) ? value : {}
const config = {
base_amount: readNumber(row.base_amount),
skin_group_rules: normalizeDepositSkinGroupRules(
Array.isArray(row.skin_group_rules) ? row.skin_group_rules : []
),
}
if (config.base_amount <= 0) {
config.base_amount = emptyListingPublishOptions.deposit_recommend_config.base_amount
}
if (config.skin_group_rules.length === 0) {
config.skin_group_rules = [
...emptyListingPublishOptions.deposit_recommend_config.skin_group_rules,
]
}
return config
}
function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGroupRule[] {
return values
.map(item => {
const row = isRecord(item) ? item : {}
return {
group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '',
label: typeof row.label === 'string' ? row.label.trim() : '',
amount_per_item: readNumber(row.amount_per_item),
}
})
.filter(item => item.group_key && item.label && item.amount_per_item >= 0)
}
function normalizeRatioConfig(value?: unknown): PublishRatioConfig {
const row = isRecord(value) ? value : {}
return {
insurance_base_ratios: normalizeInsuranceBaseRatios(
Array.isArray(row.insurance_base_ratios) ? row.insurance_base_ratios : []
),
config_items: normalizeRatioConfigItems(
Array.isArray(row.config_items) ? row.config_items : []
),
coin_corrections: normalizeCoinCorrections(
Array.isArray(row.coin_corrections) ? row.coin_corrections : []
),
}
}
function normalizeSalePriceConfig(value?: unknown): PublishSalePriceConfig {
const row = isRecord(value) ? value : {}
const config = {
fixed_markup_rules: normalizeSaleFixedMarkupRules(
Array.isArray(row.fixed_markup_rules) ? row.fixed_markup_rules : []
),
ratio_adjustment_rules: normalizeSaleRatioAdjustmentRules(
Array.isArray(row.ratio_adjustment_rules) ? row.ratio_adjustment_rules : []
),
}
if (config.fixed_markup_rules.length === 0 && config.ratio_adjustment_rules.length === 0) {
return {
fixed_markup_rules: emptyListingSalePriceConfig.fixed_markup_rules.map(item => ({ ...item })),
ratio_adjustment_rules: emptyListingSalePriceConfig.ratio_adjustment_rules.map(item => ({
...item,
})),
}
}
return config
}
function normalizeInsuranceBaseRatios(values: unknown[]): PublishInsuranceBaseRatio[] {
return values
.map(item => {
const row = isRecord(item) ? item : {}
return {
insurance: typeof row.insurance === 'string' ? row.insurance.trim() : '',
ratio: readNumber(row.ratio),
}
})
.filter(item => item.insurance && item.ratio > 0)
}
function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[] {
return values
.map(item => {
const row = isRecord(item) ? item : {}
return {
key: typeof row.key === 'string' ? row.key.trim() : '',
label: typeof row.label === 'string' ? row.label.trim() : '',
kind: typeof row.kind === 'string' ? row.kind.trim() : '',
group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '',
missing_penalty: readNumber(row.missing_penalty),
}
})
.filter(item => item.key && item.label && item.kind)
}
function normalizeCoinCorrections(values: unknown[]): PublishCoinCorrection[] {
return values
.map(item => {
const row = isRecord(item) ? item : {}
return {
threshold_m: readNumber(row.threshold_m),
correction: readNumber(row.correction),
}
})
.filter(item => item.threshold_m >= 0 && item.correction > 0)
}
function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarkupRule[] {
return values
.map(item => {
const row = isRecord(item) ? item : {}
return {
min_m: readNumber(row.min_m),
max_m: readNumber(row.max_m),
markup_amount: readNumber(row.markup_amount),
}
})
.filter(item => item.min_m >= 0 && item.max_m >= item.min_m && item.markup_amount >= 0)
}
function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioAdjustmentRule[] {
return values
.map(item => {
const row = isRecord(item) ? item : {}
return {
min_m: readNumber(row.min_m),
max_m: readNumber(row.max_m),
ratio_subtract: readNumber(row.ratio_subtract),
}
})
.filter(
item =>
item.min_m >= 0 &&
(item.max_m === 0 || item.max_m >= item.min_m) &&
item.ratio_subtract >= 0
)
}
function readNumber(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function readPositiveInteger(value: unknown, fallback: number) {
const parsed = Math.trunc(readNumber(value))
return parsed > 0 ? parsed : fallback
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
+108
View File
@@ -0,0 +1,108 @@
import type { Listing } from '@/shared/api/types'
import type { ChargeMode, QuantityKey, ScreenshotKey } from './options'
export type PublishForm = {
server_region: string
face_owner: string
haf_coin_amount: number | ''
rank_level: string
secret_kd: string
fire_level: number | ''
daily_loss_m: number | ''
accelerated_sale_ratio: number | ''
season_insurance: string
stamina_level: string
load_level: string
login_method: string
online_start: string
online_end: string
/** 是否可在凌晨 0-2 点响应上号(是/否) */
early_morning_response: string
/** 是否可以修改游戏名(是/否) */
can_change_game_name: string
/** 是否有全英雄(是/否) */
all_hero: string
/** 是否解锁德穆兰(是/否) */
unlock_demulan: string
/** 下赛季是否购买通行证(购买/不购买) */
next_season_pass: string
ban_record: string
common_regions: string[]
depositAmountYuan: number | ''
remark: string
}
export interface PublishDraft {
form: PublishForm
quantityValues: Record<QuantityKey, number>
quantityModes: Record<QuantityKey, ChargeMode>
screenshotFiles: Record<ScreenshotKey, string[]>
selectedSkins: string[]
}
export interface DepositBreakdownItem {
label: string
amount: number
count: number
}
export interface PublishPlatformPricing {
buyerCoinBasePrice: number
buyerTotalPrice: number
buyerRatio: number
platformMarkupAmount: number
ruleType: 'fixed_markup' | 'ratio_subtract' | 'none' | string
}
/** 卖家列表/详情返回的 ListingDTO,额外包含发布群会话信息。 */
export interface SellerListing extends Listing {
listing_group_conversation_id?: number
owner_phone?: string
}
export interface PublishPayload {
title: string
description: string
server_region: string
login_platform: string
rank_level: string
haf_coin_amount: number
asset_summary: Record<string, unknown>
screenshot_urls: string[]
price_cent: number
deposit_amount_cent: number
agreed_virtual_asset_sale: boolean
agreed_seller_agreement: boolean
}
/** 兼容历史资产摘要:所有字段都按可缺失处理。 */
export type AssetSummary = Record<string, unknown>
export function defaultPublishForm(): PublishForm {
return {
server_region: '',
face_owner: '',
haf_coin_amount: '',
rank_level: '',
secret_kd: '',
fire_level: '',
daily_loss_m: 10,
accelerated_sale_ratio: '',
season_insurance: '',
stamina_level: '',
load_level: '',
login_method: '',
online_start: '',
online_end: '',
early_morning_response: '否',
can_change_game_name: '',
all_hero: '',
unlock_demulan: '',
next_season_pass: '',
ban_record: '',
common_regions: [],
depositAmountYuan: '',
remark: '',
}
}
@@ -31,6 +31,7 @@ export function StorefrontLayout() {
<span>消息</span>
</Badge>
</Link>
<Link to="/seller/listings">卖家中心</Link>
<a href="/#process">流程</a>
<a href="/#rules">规则</a>
</nav>
@@ -51,6 +52,11 @@ export function StorefrontLayout() {
label: '我的消息',
onClick: () => navigate('/messages'),
},
{
key: 'seller',
label: '卖家中心',
onClick: () => navigate('/seller/listings'),
},
{
key: 'logout',
label: '退出登录',
+353
View File
@@ -0,0 +1,353 @@
import type {
ChargeMode,
ListingPublishOptions,
PublishDepositRecommendConfig,
PublishOptionGroup,
PublishQuantityItem,
PublishRatioConfig,
PublishSalePriceConfig,
} from '@/features/seller/options'
import type {
DepositBreakdownItem,
PublishForm,
PublishPlatformPricing,
} from '@/features/seller/types'
export const dailyLossOptions = [10, 20, 30, 40, 50]
/**
* 将金额四舍五入到角精度(0.1 元)。
* 统一全项目金额处理规范。
*/
export function roundMoney(value: number) {
return Math.round(value * 10) / 10
}
/**
* 将比例四舍五入到一位小数。
*/
export function roundRatio(value: number) {
return Math.round(value * 10) / 10
}
/**
* 格式化数字为字符串(保留 1 位小数)。
*/
export function formatNumber(value: number) {
const rounded = Math.round(value * 10) / 10
return rounded.toFixed(1)
}
/** 解析「8元/个」「8元」「8元 / 3个」形式的资源单价。 */
export function readUnitPrice(priceText: string) {
const normalized = priceText.replace(/,/g, ',').trim()
const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/)
if (fractionMatch) {
const amount = Number(fractionMatch[1])
const count = Number(fractionMatch[2])
return count > 0 ? amount / count : 0
}
const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/)
return singleMatch ? Number(singleMatch[1]) : 0
}
export function calculateDailyLossRatioAdjustment(dailyLossMAmount: number) {
return Math.min(Math.max(Math.floor((dailyLossMAmount - 10) / 10), 0), 4)
}
export function isGridCardQuantityItem(item: { key: string; label: string }) {
return item.key === 'gridCard9' || item.label.includes('9格体验卡')
}
export function isQuantityItemDisabledForInsurance(
item: { key: string; label: string },
seasonInsurance: string
) {
return seasonInsurance === '3*3' && isGridCardQuantityItem(item)
}
export function calculateConsumablePrice(options: {
quantityItems: PublishQuantityItem[]
quantityValues: Record<string, number>
quantityModes: Record<string, ChargeMode>
seasonInsurance: string
}) {
const total = options.quantityItems.reduce((sum, item) => {
const quantity = Number(options.quantityValues[item.key] || 0)
const mode = options.quantityModes[item.key] || '收费'
if (isQuantityItemDisabledForInsurance(item, options.seasonInsurance)) return sum
if (quantity <= 0 || mode !== '收费') return sum
return sum + quantity * readUnitPrice(item.price)
}, 0)
return roundMoney(total)
}
export function calculateRecommendedDeposit(options: {
depositRecommendConfig: PublishDepositRecommendConfig
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
}) {
const baseAmount = Number(options.depositRecommendConfig.base_amount || 0)
const skinAmount = options.depositRecommendConfig.skin_group_rules.reduce((sum, rule) => {
const group = options.skinGroups.find(item => item.key === rule.group_key)
if (!group) return sum
const selectedCount = group.options.filter(skin => options.selectedSkins.includes(skin)).length
return sum + selectedCount * Number(rule.amount_per_item || 0)
}, 0)
return roundMoney(baseAmount + skinAmount)
}
export function buildDepositBreakdownItems(options: {
depositRecommendConfig: PublishDepositRecommendConfig
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
}): DepositBreakdownItem[] {
const items: DepositBreakdownItem[] = [
{
label: '基础押金',
amount: Number(options.depositRecommendConfig.base_amount || 0),
count: 1,
},
]
for (const rule of options.depositRecommendConfig.skin_group_rules) {
const group = options.skinGroups.find(item => item.key === rule.group_key)
if (!group) continue
const count = group.options.filter(skin => options.selectedSkins.includes(skin)).length
if (count <= 0) continue
items.push({
label: rule.label,
amount: Number(rule.amount_per_item || 0) * count,
count,
})
}
return items
}
export function calculateSellerReferenceRatio(options: {
coinMAmount: number
form: PublishForm
ratioConfig: PublishRatioConfig
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
levelOptions: string[]
dailyLossRatioAdjustment: number
}) {
const { coinMAmount, form, ratioConfig } = options
if (coinMAmount <= 0 || !form.season_insurance || !form.stamina_level || !form.load_level) {
return 0
}
const baseRatio = getInsuranceBaseRatio(ratioConfig, form.season_insurance)
if (baseRatio <= 0) return 0
return (
baseRatio +
calculateConfigPenalty(ratioConfig, options) +
getCoinCorrection(ratioConfig, coinMAmount) +
options.dailyLossRatioAdjustment
)
}
export function readFinalSaleRatio(
defaultRatio: number,
acceleratedSaleRatio: number | '',
maxAcceleratedSaleRatio: number
) {
if (defaultRatio <= 0) return 0
if (!hasAcceleratedSaleRatioInput(acceleratedSaleRatio)) return defaultRatio
const ratio = Number(acceleratedSaleRatio)
if (!Number.isFinite(ratio) || ratio <= 0) return defaultRatio
return roundRatio(Math.min(Math.max(ratio, defaultRatio), maxAcceleratedSaleRatio))
}
export function applySiteReceiveRatioDelta(referenceRatio: number, delta: number) {
if (referenceRatio <= 0) return 0
const safeDelta = Math.min(20, Math.max(0, Number(delta) || 0))
return roundRatio(Math.max(referenceRatio - safeDelta, 0.01))
}
/**
* 分站收号压价(新口径):收号比例 = 主站参考比例 R + 点数,比例变大 → 收号价更低。
* 买家价已解耦,不再从收号比例推导,始终锚定主站参考口径。
*/
export function applySiteReceiveCostDelta(referenceRatio: number, delta: number) {
if (referenceRatio <= 0) return 0
const safeDelta = Math.min(20, Math.max(0, Number(delta) || 0))
return roundRatio(referenceRatio + safeDelta)
}
export function hasAcceleratedSaleRatioInput(value: number | '' | null | undefined) {
const ratio = Number(value)
return (
value !== '' && value !== null && value !== undefined && Number.isFinite(ratio) && ratio > 0
)
}
export function calculatePlatformPricing(options: {
coinMAmount: number
coinWanAmount: number
sellerRatio: number
sellerCoinBasePrice: number
sellerTotalPrice: number
consumablePrice: number
salePriceConfig: PublishSalePriceConfig
}): PublishPlatformPricing {
if (options.sellerRatio <= 0 || options.sellerCoinBasePrice <= 0) return emptyPlatformPricing()
const fixedRule = findSaleFixedMarkupRule(options.salePriceConfig, options.coinMAmount)
if (fixedRule) {
return buildPlatformPricing(
roundMoney(options.sellerCoinBasePrice + Number(fixedRule.markup_amount || 0)),
'fixed_markup',
options
)
}
const ratioRule = findSaleRatioAdjustmentRule(options.salePriceConfig, options.coinMAmount)
const ratioSubtract = ratioRule ? Number(ratioRule.ratio_subtract || 0) : 0
const buyerRatio = options.sellerRatio - ratioSubtract
if (buyerRatio > 0 && ratioRule) {
return buildPlatformPricing(
roundMoney(options.coinWanAmount / buyerRatio),
'ratio_subtract',
options
)
}
return buildPlatformPricing(options.sellerCoinBasePrice, 'none', options)
}
export function emptyPlatformPricing(): PublishPlatformPricing {
return {
buyerCoinBasePrice: 0,
buyerTotalPrice: 0,
buyerRatio: 0,
platformMarkupAmount: 0,
ruleType: 'none',
}
}
function buildPlatformPricing(
buyerCoinBasePrice: number,
ruleType: string,
options: {
coinWanAmount: number
sellerTotalPrice: number
consumablePrice: number
}
): PublishPlatformPricing {
const buyerTotalPrice = roundMoney(buyerCoinBasePrice + options.consumablePrice)
return {
buyerCoinBasePrice,
buyerTotalPrice,
buyerRatio: calculateEffectiveRatio(options.coinWanAmount, buyerCoinBasePrice),
platformMarkupAmount: roundMoney(buyerTotalPrice - options.sellerTotalPrice),
ruleType,
}
}
function findSaleFixedMarkupRule(config: PublishSalePriceConfig, coinMAmount: number) {
return [...config.fixed_markup_rules]
.sort((a, b) => a.min_m - b.min_m)
.find((item, index, rules) =>
isCoinInSaleRange(item, index, rules, coinMAmount, { includeLastMax: true })
)
}
function findSaleRatioAdjustmentRule(config: PublishSalePriceConfig, coinMAmount: number) {
return [...config.ratio_adjustment_rules]
.sort((a, b) => a.min_m - b.min_m)
.find((item, index, rules) =>
isCoinInSaleRange(item, index, rules, coinMAmount, { excludeFirstMin: true })
)
}
function isCoinInSaleRange(
item: { min_m: number; max_m: number },
index: number,
rules: Array<{ min_m: number; max_m: number }>,
coinMAmount: number,
options: { includeLastMax?: boolean; excludeFirstMin?: boolean } = {}
) {
const maxM = Number(item.max_m || 0)
const minM = Number(item.min_m || 0)
const minMatched =
options.excludeFirstMin && index === 0 ? coinMAmount > minM : coinMAmount >= minM
const isLastRule = index === rules.length - 1
const maxMatched =
maxM <= 0 || coinMAmount < maxM || (options.includeLastMax && isLastRule && coinMAmount <= maxM)
return minMatched && maxMatched
}
function calculateEffectiveRatio(coinWanAmount: number, price: number) {
if (price <= 0) return 0
return roundRatio(coinWanAmount / price)
}
function getInsuranceBaseRatio(
config: Pick<ListingPublishOptions['ratio_config'], 'insurance_base_ratios'>,
insurance: string
) {
return config.insurance_base_ratios.find(item => item.insurance === insurance)?.ratio || 0
}
function calculateConfigPenalty(
config: Pick<ListingPublishOptions['ratio_config'], 'config_items'>,
options: {
form: PublishForm
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
levelOptions: string[]
}
) {
return config.config_items.reduce((sum, item) => {
return isRatioConfigItemMatched(item, options) ? sum : sum + Number(item.missing_penalty || 0)
}, 0)
}
function isRatioConfigItemMatched(
item: { kind: string; group_key?: string },
options: {
form: PublishForm
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
levelOptions: string[]
}
) {
if (item.kind === 'skin_group') return hasSelectedSkinGroup(item.group_key || '', options)
if (item.kind === 'max_stamina') {
return isMaxLevel(options.form.stamina_level, options.levelOptions)
}
if (item.kind === 'max_load') return isMaxLevel(options.form.load_level, options.levelOptions)
return false
}
function hasSelectedSkinGroup(
groupKey: string,
options: {
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
}
) {
const group = options.skinGroups.find(item => item.key === groupKey)
if (!group) return false
return group.options.some(skin => options.selectedSkins.includes(skin))
}
function isMaxLevel(value: string, levelOptions: string[]) {
const currentLevel = readLevelNumber(value)
const maxLevel = Math.max(...levelOptions.map(readLevelNumber).filter(Boolean))
if (currentLevel > 0 && maxLevel > 0) return currentLevel >= maxLevel
return value === levelOptions[levelOptions.length - 1]
}
function readLevelNumber(value: string) {
const match = value.match(/\d+/)
return match ? Number(match[0]) : 0
}
function getCoinCorrection(
config: Pick<ListingPublishOptions['ratio_config'], 'coin_corrections'>,
coinM: number
) {
return (
[...config.coin_corrections]
.sort((a, b) => b.threshold_m - a.threshold_m)
.find(item => coinM > item.threshold_m)?.correction || 0
)
}
+1
View File
@@ -15,4 +15,5 @@
@import './mobile-messages.css';
@import './mobile-chat.css';
@import './pc-messages.css';
@import './seller.css';
@import './components.css';
+781
View File
@@ -0,0 +1,781 @@
/* Seller center: deep green / rice paper / cinnabar, matching the storefront shell. */
.seller-page {
max-width: 1240px;
margin: 0 auto;
padding: 28px 24px 72px;
}
.seller-page .ant-typography {
color: var(--ink);
}
.seller-eyebrow {
display: inline-block;
color: var(--brand-brass) !important;
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.seller-title {
margin: 8px 0 6px !important;
color: var(--brand-ink-deep) !important;
font-family: 'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', serif !important;
font-size: 30px !important;
font-weight: 600 !important;
letter-spacing: 0.04em;
}
.seller-lead {
max-width: 640px;
margin: 0 !important;
color: var(--ink-secondary) !important;
line-height: 1.7;
}
.seller-hero,
.seller-form-head,
.seller-detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 24px 26px;
border: 1px solid var(--line);
border-radius: 10px;
background:
linear-gradient(120deg, rgba(30, 77, 66, 0.07), transparent 46%),
var(--paper-warm);
}
.seller-form-head,
.seller-detail-head {
padding: 20px 24px;
}
.seller-stat-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin: 16px 0;
}
.seller-stat {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 13px 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper-warm);
color: var(--ink-secondary);
cursor: pointer;
transition:
border-color 160ms ease,
background 160ms ease;
}
.seller-stat:hover {
border-color: var(--sage);
}
.seller-stat.is-active {
border-color: var(--brand-ink);
background: #edf3ee;
color: var(--brand-ink-deep);
}
.seller-stat span {
font-size: 13px;
}
.seller-stat strong {
color: var(--brand-seal);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 21px;
}
.seller-panel {
margin-bottom: 16px;
border-color: var(--line);
background: var(--paper-warm);
}
.seller-panel .ant-card-head {
border-bottom-color: var(--line);
}
.seller-panel .ant-card-head-title {
color: var(--brand-ink-deep);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.03em;
}
.seller-listing-card {
height: 100%;
border-color: var(--line);
background: var(--paper-warm);
transition:
border-color 160ms ease,
transform 160ms ease;
}
.seller-listing-card:hover {
border-color: var(--sage);
transform: translateY(-2px);
}
.seller-listing-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px dashed var(--line);
}
.seller-code {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 3px 10px;
border: 1px solid var(--brand-seal-line);
border-radius: 999px;
background: #fbf3e6;
color: var(--brand-seal);
font-family: 'Inter', monospace;
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
}
button.seller-code:not(:disabled):hover {
border-color: var(--brand-seal);
}
.seller-listing-card__body {
display: block;
padding: 14px 0 4px;
color: inherit;
}
.seller-listing-card__body:hover {
color: inherit;
}
.seller-listing-card__body h3 {
margin: 0 0 14px !important;
color: var(--ink) !important;
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 19px !important;
}
.seller-meta-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 13px 14px;
border-radius: 8px;
background: #f7f4ec;
}
.seller-meta-grid div {
display: grid;
gap: 4px;
}
.seller-meta-grid span {
color: var(--ink-muted);
font-size: 11px;
letter-spacing: 0.08em;
}
.seller-meta-grid strong {
color: var(--ink);
font-size: 14px;
font-weight: 600;
}
.seller-meta-grid strong.is-price {
color: var(--brand-seal);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 18px;
}
.seller-reject-reason {
margin: 12px 0 0 !important;
padding: 8px 10px;
border-left: 2px solid var(--danger);
background: #fbf0ee;
color: var(--danger) !important;
font-size: 12px;
}
.seller-listing-card__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
padding-top: 13px;
border-top: 1px solid var(--line);
}
.seller-detail-hero {
display: flex;
justify-content: space-between;
gap: 24px;
margin: 16px 0;
border-color: var(--line);
background: var(--paper-warm);
}
.seller-detail-hero__tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.seller-detail-title {
margin: 10px 0 8px !important;
color: var(--brand-ink-deep) !important;
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 24px !important;
}
.seller-detail-reason {
margin-top: 14px;
}
.seller-detail-prices {
display: grid;
min-width: 300px;
gap: 8px;
padding: 16px 18px;
border: 1px solid var(--brand-seal-line);
border-radius: 8px;
background: #fbf5e9;
}
.seller-detail-prices div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24px;
}
.seller-detail-prices span {
color: #8d6f4d;
font-size: 12px;
}
.seller-detail-prices strong {
color: var(--brand-seal);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 21px;
}
.seller-detail-prices div:not(:first-child) strong {
color: var(--brand-ink);
font-size: 15px;
}
.seller-price-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.seller-price-grid div {
display: grid;
gap: 5px;
padding: 12px;
border-radius: 6px;
background: #f7f4ec;
}
.seller-price-grid span {
color: var(--ink-muted);
font-size: 11px;
}
.seller-price-grid strong {
color: var(--brand-ink-deep);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 16px;
}
.seller-resource-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
.seller-resource {
display: grid;
gap: 4px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #faf8f2;
}
.seller-resource span,
.seller-resource small {
color: var(--ink-muted);
font-size: 12px;
}
.seller-resource strong {
color: var(--brand-ink);
font-size: 18px;
}
.seller-skin-group {
display: grid;
gap: 7px;
margin-bottom: 14px;
}
.seller-skin-group > strong,
.seller-skin-group__head strong {
color: var(--ink);
font-size: 14px;
}
.seller-skin-group__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.seller-skin-group__head span {
color: var(--ink-muted);
font-size: 12px;
}
.seller-skin-groups {
display: grid;
gap: 18px;
}
.seller-shot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.seller-shot-grid figure {
margin: 0;
}
.seller-shot-grid .ant-image,
.seller-shot-grid img {
width: 100% !important;
border-radius: 6px;
object-fit: cover;
}
.seller-shot-grid figcaption {
margin-top: 6px;
color: var(--ink-muted);
font-size: 12px;
text-align: center;
}
.seller-remark {
margin: 0 !important;
color: var(--ink-secondary) !important;
line-height: 1.75;
white-space: pre-wrap;
}
.seller-tip-panel ul {
margin: 0;
padding-left: 18px;
color: var(--ink-secondary);
line-height: 1.9;
font-size: 13px;
}
.seller-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 18px;
}
.seller-field {
display: grid;
align-content: start;
gap: 7px;
min-width: 0;
}
.seller-field--wide {
grid-column: 1 / -1;
}
.seller-field__label {
color: var(--ink-secondary);
font-size: 13px;
font-weight: 500;
}
.seller-field__label b,
.seller-upload-slot__head b {
margin-left: 3px;
color: var(--brand-seal);
font-weight: 700;
}
.seller-field > small {
color: var(--ink-muted);
font-size: 12px;
line-height: 1.6;
}
.seller-field .ant-select,
.seller-field .ant-input-number,
.seller-field .ant-picker {
width: 100%;
}
.seller-number-input {
width: 100%;
}
.seller-time-row {
display: flex;
align-items: center;
gap: 8px;
}
.seller-time-row .ant-picker {
flex: 1;
}
.seller-quantity-table {
display: grid;
gap: 1px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--line);
}
.seller-quantity-head,
.seller-quantity-row {
display: grid;
grid-template-columns: minmax(150px, 1.7fr) 0.9fr 110px 140px;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--paper-warm);
}
.seller-quantity-head {
background: #f3efe4;
color: var(--ink-muted);
font-size: 12px;
letter-spacing: 0.05em;
}
.seller-quantity-row.is-disabled {
opacity: 0.55;
}
.seller-quantity-meta {
display: grid;
gap: 3px;
}
.seller-quantity-meta strong {
color: var(--ink);
font-size: 13px;
}
.seller-quantity-meta small,
.seller-quantity-row > span {
color: var(--ink-muted);
font-size: 12px;
}
.seller-upload-grid {
display: grid;
gap: 16px;
}
.seller-upload-slot {
display: grid;
gap: 10px;
padding: 14px;
border: 1px dashed var(--line);
border-radius: 8px;
background: #fbf9f3;
}
.seller-upload-slot__head {
display: grid;
gap: 4px;
}
.seller-upload-slot__head strong {
color: var(--ink);
font-size: 14px;
}
.seller-upload-slot__head small {
color: var(--ink-muted);
font-size: 12px;
}
.seller-upload-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.seller-upload-item {
position: relative;
}
.seller-upload-item .ant-image img {
border-radius: 5px;
object-fit: cover;
}
.seller-upload-item button {
position: absolute;
top: -6px;
right: -6px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 1px solid var(--brand-seal-line);
border-radius: 50%;
background: var(--paper-warm);
color: var(--brand-seal);
cursor: pointer;
}
.seller-upload-add {
display: grid;
width: 104px;
height: 78px;
place-items: center;
gap: 2px;
border: 1px dashed var(--sage);
border-radius: 5px;
background: #f1f5f0;
color: var(--brand-ink);
font-size: 12px;
cursor: pointer;
}
.seller-upload-add:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.seller-deposit-row,
.seller-ratio-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.seller-deposit-row .ant-input-number,
.seller-ratio-row .ant-input-number {
flex: 1 1 140px;
width: auto;
}
.seller-deposit-breakdown {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.seller-agreements {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.seller-agreement {
display: grid;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 6px;
background: #faf8f2;
}
.seller-agreement__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.seller-agreement__head strong {
color: var(--brand-ink-deep);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 14px;
}
.seller-agreement__content {
max-height: 190px;
overflow-y: auto;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--paper-warm);
color: var(--ink-secondary);
font-size: 12px;
line-height: 1.8;
white-space: pre-wrap;
}
.seller-summary {
position: sticky;
top: 92px;
}
.seller-price-panel {
border-color: var(--brand-seal-line);
background:
linear-gradient(160deg, rgba(166, 67, 50, 0.06), transparent 42%),
var(--paper-warm);
}
.seller-price-panel__main {
display: grid;
gap: 4px;
margin: 10px 0 14px;
padding-bottom: 14px;
border-bottom: 1px dashed var(--brand-seal-line);
}
.seller-price-panel__main strong {
color: var(--brand-seal);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 34px;
line-height: 1.1;
}
.seller-price-panel__main span {
color: #92745a;
font-size: 12px;
}
.seller-price-lines {
display: grid;
gap: 8px;
}
.seller-price-lines div,
.seller-summary-meta div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
color: var(--ink-secondary);
font-size: 12px;
}
.seller-price-lines strong {
color: var(--ink);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 14px;
}
.seller-summary-meta {
display: grid;
gap: 6px;
margin-bottom: 16px;
}
.seller-summary-meta strong {
color: var(--brand-ink);
}
.seller-summary-hint {
margin: 12px 0 0 !important;
color: var(--ink-muted) !important;
font-size: 12px;
line-height: 1.7;
}
.seller-loading {
display: grid;
min-height: 320px;
place-items: center;
gap: 12px;
color: var(--ink-muted);
}
@media (max-width: 1100px) {
.seller-stat-strip {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.seller-detail-hero {
flex-direction: column;
}
.seller-detail-prices {
width: 100%;
min-width: 0;
}
.seller-summary {
position: static;
}
}
@media (max-width: 768px) {
.seller-page {
padding: 18px 14px 48px;
}
.seller-hero,
.seller-form-head,
.seller-detail-head {
flex-direction: column;
padding: 18px;
}
.seller-title {
font-size: 24px !important;
}
.seller-stat-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.seller-meta-grid,
.seller-form-grid,
.seller-agreements,
.seller-price-grid {
grid-template-columns: 1fr;
}
.seller-quantity-head {
display: none;
}
.seller-quantity-row {
grid-template-columns: 1fr;
gap: 8px;
padding: 12px;
}
}