feat(seller): 迁移商品发布编辑与审核链路
This commit is contained in:
@@ -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
@@ -381,7 +381,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 3:C 端 PC + 移动端(3 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心与聊天页已接通,卖家发布/编辑/审核链路待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心、聊天页与卖家发布/编辑/审核提交已接通,移动端卖家页面与分销长尾能力待迁移。
|
||||
|
||||
- 首页、商品列表、商品详情、撞车商品。
|
||||
- 登录、注册、找回密码、实名认证。
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? '修改成功,等待后台审核' : '发布成功,等待后台审核'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '退出登录',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -15,4 +15,5 @@
|
||||
@import './mobile-messages.css';
|
||||
@import './mobile-chat.css';
|
||||
@import './pc-messages.css';
|
||||
@import './seller.css';
|
||||
@import './components.css';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user