From 85bff95042b1a7063236d8530ab190825fc667b2 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 14:24:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(seller):=20=E8=BF=81=E7=A7=BB=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=8F=91=E5=B8=83=E7=BC=96=E8=BE=91=E4=B8=8E=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase2-卖家商品发布与编辑验收.md | 76 + docs/项目计划.md | 2 +- frontend/src/app/AppRouter.tsx | 19 + .../seller/SellerListingDetailPage.tsx | 537 +++++++ .../features/seller/SellerListingFormPage.tsx | 1422 +++++++++++++++++ .../features/seller/SellerListingsPage.tsx | 418 +++++ frontend/src/features/seller/api.ts | 61 + frontend/src/features/seller/options.ts | 479 ++++++ frontend/src/features/seller/types.ts | 108 ++ frontend/src/layouts/StorefrontLayout.tsx | 6 + frontend/src/shared/utils/pricing.ts | 353 ++++ frontend/src/styles/global.css | 1 + frontend/src/styles/seller.css | 781 +++++++++ 13 files changed, 4262 insertions(+), 1 deletion(-) create mode 100644 docs/Phase2-卖家商品发布与编辑验收.md create mode 100644 frontend/src/features/seller/SellerListingDetailPage.tsx create mode 100644 frontend/src/features/seller/SellerListingFormPage.tsx create mode 100644 frontend/src/features/seller/SellerListingsPage.tsx create mode 100644 frontend/src/features/seller/api.ts create mode 100644 frontend/src/features/seller/options.ts create mode 100644 frontend/src/features/seller/types.ts create mode 100644 frontend/src/shared/utils/pricing.ts create mode 100644 frontend/src/styles/seller.css diff --git a/docs/Phase2-卖家商品发布与编辑验收.md b/docs/Phase2-卖家商品发布与编辑验收.md new file mode 100644 index 0000000..bf797e4 --- /dev/null +++ b/docs/Phase2-卖家商品发布与编辑验收.md @@ -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` 字段不全时,详情页会显示 `—` 或回退截图列表。 +- 发布选项为空时页面不会崩溃,但会因无法计算买家价而阻止提交,需后台先配置发布选项。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 44cb31a..50e4277 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -381,7 +381,7 @@ hfb_jysh/ ### Phase 3:C 端 PC + 移动端(3 周) -> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心与聊天页已接通,卖家发布/编辑/审核链路待迁移。 +> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心、聊天页与卖家发布/编辑/审核提交已接通,移动端卖家页面与分销长尾能力待迁移。 - 首页、商品列表、商品详情、撞车商品。 - 登录、注册、找回密码、实名认证。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index c39cad0..c4a7731 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/features/seller/SellerListingDetailPage.tsx b/frontend/src/features/seller/SellerListingDetailPage.tsx new file mode 100644 index 0000000..3043841 --- /dev/null +++ b/frontend/src/features/seller/SellerListingDetailPage.tsx @@ -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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {} +} + +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 ( + + {meta.label} + + ) +} + +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) { + 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 = { + 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 = { + 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 ( +
+ + + +
+ ) + } + + if (!token) { + return ( +
+ + + + + +
+ ) + } + + if (listingQuery.isPending) { + return ( +
+ + + +
+ ) + } + + if (listingQuery.isError || !listing) { + return ( +
+ + + + + } + /> +
+ ) + } + + 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 ( +
+
+
+ + SELLER LISTING · 商品详情 + + 我的商品详情 + + + 仅本人可查看完整发布信息;他人无法通过此入口访问。 + +
+ + + {canEdit(listing) ? ( + + ) : null} + {canSubmit(listing) ? ( + + ) : null} + {canOffline(listing) ? ( + + ) : null} + +
+ + +
+
+ {listing.listing_no || `#${listing.id}`} + {statusTag(status)} + {listing.review_status !== 'none' ? statusTag(review) : null} +
+ + {listing.title || '未命名账号'} + + + {listing.server_region || '区服待定'} + {listing.login_platform || '登录方式待定'} + {listing.rank_level ? {listing.rank_level} : null} + + {listing.review_reason ? ( + + ) : null} +
+
+
+ 卖家到手价 + {formatMoney(sellerPriceCent(listing))} +
+
+ 买家挂牌价 + {formatMoney(listing.price_cent)} +
+
+ 押金 + {formatMoney(listing.deposit_amount_cent)} +
+
+
+ + + + + + {accountRows.map(([label, value]) => ( + + {value} + + ))} + + + + +
+
+ 参考比例 + {ratioText(breakdown.seller_reference_ratio)} +
+
+ 卖家比例 + {ratioText(breakdown.seller_ratio)} +
+
+ 纯币基础价 + {formatMoney(numberValue(breakdown.seller_coin_base_price) * 100)} +
+
+ 消耗品价值 + {formatMoney(numberValue(breakdown.consumable_price) * 100)} +
+
+ 买家比例 + {ratioText(breakdown.buyer_ratio)} +
+
+ 平台加价 + {formatMoney(numberValue(breakdown.platform_markup_amount) * 100)} +
+
+
+ + {resources.length > 0 ? ( + +
+ {resources.map((resource, index) => ( +
+ {stringValue(resource.label) || stringValue(resource.key)} + {numberValue(resource.quantity)} + + {stringValue(resource.mode) || '—'} + {stringValue(resource.price) ? ` · ${stringValue(resource.price)}` : ''} + +
+ ))} +
+
+ ) : null} + + {skinGroups.length > 0 ? ( + + {skinGroups.map(group => ( +
+ {group.title} + + {group.options.map(skin => ( + + {skin} + + ))} + +
+ ))} +
+ ) : null} + + {screenshots.length > 0 ? ( + + +
+ {screenshots.map((shot, index) => ( +
+ {shot.label} +
{shot.label}
+
+ ))} +
+
+
+ ) : null} + + + + + + + {formatDateTime(listing.created_at)} + + + {listing.published_at ? formatDateTime(listing.published_at) : '—'} + + + {formatDateTime(listing.updated_at)} + + + + + + + {listing.description || '无'} + + + + +
    +
  • 待审核、已上架的商品不能重复提审。
  • +
  • 下架后后台待审列表会同步移除。
  • +
  • 订单完成或租赁中的商品不可编辑、不可下架。
  • +
+
+ +
+ + {screenshots.length === 0 ? ( + + ) : null} +
+ ) +} diff --git a/frontend/src/features/seller/SellerListingFormPage.tsx b/frontend/src/features/seller/SellerListingFormPage.tsx new file mode 100644 index 0000000..d16d633 --- /dev/null +++ b/frontend/src/features/seller/SellerListingFormPage.tsx @@ -0,0 +1,1422 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { Dayjs } from 'dayjs' +import dayjs from 'dayjs' +import { + Alert, + App, + Button, + Card, + Checkbox, + Col, + Divider, + Empty, + Image, + Input, + InputNumber, + Row, + Segmented, + Select, + Space, + Spin, + Tag, + TimePicker, + Typography, +} from 'antd' +import { + ArrowLeftOutlined, + DeleteOutlined, + PictureOutlined, + ReloadOutlined, + SendOutlined, +} from '@ant-design/icons' +import { useNavigate, useParams } from 'react-router-dom' + +import { uploadFile } from '@/shared/api/files' +import { useSessionStore } from '@/stores/session' +import { + dailyLossOptions, + calculateConsumablePrice, + calculateDailyLossRatioAdjustment, + calculatePlatformPricing, + calculateRecommendedDeposit, + calculateSellerReferenceRatio, + buildDepositBreakdownItems, + formatNumber, + hasAcceleratedSaleRatioInput, + isQuantityItemDisabledForInsurance, + readFinalSaleRatio, + roundMoney, + roundRatio, +} from '@/shared/utils/pricing' +import { formatMoney } from '@/shared/utils/format' +import { confirmModal } from '@/shared/utils/confirm' + +import { createListing, fetchSellerListing, readApiError, updateListing } from './api' +import { + emptyListingPublishAgreements, + emptyListingPublishOptions, + emptyListingSalePriceConfig, + fetchListingPublishAgreements, + fetchListingPublishOptions, + fetchListingSalePriceConfig, + type ChargeMode, + type ListingPublishAgreements, + type ListingPublishOptions, + type PublishQuantityItem, + type PublishSalePriceConfig, + type PublishScreenshotSlot, +} from './options' +import { defaultPublishForm, type PublishForm } from './types' + +const COIN_UNIT = 1_000_000 +const MAX_SCREENSHOTS = 9 +const SCAN_ONLY_MARK = '微信' +const SCAN_LOGIN_METHOD = '扫码' +const PASSWORD_LOGIN_METHOD = '账号密码' + +type PublishPageData = { + options: ListingPublishOptions + salePriceConfig: PublishSalePriceConfig + agreements: ListingPublishAgreements +} + +function recordValue(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {} +} + +function stringValue(value: unknown) { + if (value === undefined || value === null) return '' + return String(value) +} + +function stringArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string' && Boolean(item)) + : [] +} + +function numberOrEmpty(value: unknown): number | '' { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : '' +} + +function numberOrDefault(value: unknown, fallback: number) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +function toTimeValue(value: string): Dayjs | null { + const match = /^(\d{1,2}):(\d{2})/.exec(value.trim()) + if (!match) return null + return dayjs() + .hour(Number(match[1])) + .minute(Number(match[2])) + .second(0) + .millisecond(0) +} + +function parseTimeMinute(value: string) { + const match = /^(\d{2}):(\d{2})$/.exec(value) + if (!match) return null + const hour = Number(match[1]) + const minute = Number(match[2]) + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null + return hour * 60 + minute +} + +function yesNo(value: string) { + return value === '是' || value === '否' +} + +function buildTitle(form: PublishForm, coinMAmount: number) { + const parts = [ + form.server_region, + form.rank_level, + coinMAmount > 0 ? `${coinMAmount}M哈夫币` : '', + ].filter(Boolean) + return parts.length ? parts.join(' ') : '待完善账号信息' +} + +function ChoiceField(props: { + label: string + required?: boolean + value: string + options: string[] + placeholder?: string + onChange: (value: string) => void +}) { + const { label, required, value, options, placeholder, onChange } = props + return ( + + ) +} + +export function SellerListingFormPage() { + const { id } = useParams() + const isEdit = Boolean(id) + const navigate = useNavigate() + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const token = useSessionStore(state => state.token) + const hydrated = useSessionStore(state => state.hydrated) + + const [form, setForm] = useState(defaultPublishForm) + const [quantityValues, setQuantityValues] = useState>({}) + const [quantityModes, setQuantityModes] = useState>({}) + const [screenshotFiles, setScreenshotFiles] = useState>({}) + const [selectedSkins, setSelectedSkins] = useState([]) + const [virtualAgreement, setVirtualAgreement] = useState(false) + const [sellerAgreement, setSellerAgreement] = useState(false) + const [passwordConfirmed, setPasswordConfirmed] = useState(false) + const [uploadingSlot, setUploadingSlot] = useState('') + const [hydratedId, setHydratedId] = useState(null) + + const fileInputRef = useRef(null) + const activeSlotRef = useRef('coin') + + const publishDataQuery = useQuery({ + queryKey: ['seller-publish-options'], + queryFn: async () => { + const [options, salePriceConfig, agreements] = await Promise.all([ + fetchListingPublishOptions(), + fetchListingSalePriceConfig(), + fetchListingPublishAgreements(), + ]) + return { options, salePriceConfig, agreements } + }, + staleTime: 60_000, + }) + + const options = publishDataQuery.data?.options ?? emptyListingPublishOptions + const salePriceConfig = publishDataQuery.data?.salePriceConfig ?? emptyListingSalePriceConfig + const agreements = publishDataQuery.data?.agreements ?? emptyListingPublishAgreements + + const listingQuery = useQuery({ + queryKey: ['seller-listing', id], + queryFn: () => fetchSellerListing(id as string), + enabled: isEdit && Boolean(token), + }) + + const listing = listingQuery.data + + function updateForm(key: K, value: PublishForm[K]) { + setForm(previous => ({ ...previous, [key]: value })) + } + + useEffect(() => { + setQuantityValues(previous => { + const next = { ...previous } + for (const item of options.quantity_items) { + if (next[item.key] === undefined) next[item.key] = 0 + } + return next + }) + }, [options.quantity_items]) + + const isScanOnlyServer = form.server_region.includes(SCAN_ONLY_MARK) + const effectiveLoginMethods = isScanOnlyServer + ? Array.from( + new Set([ + SCAN_LOGIN_METHOD, + ...options.login_method_options.filter(item => item !== PASSWORD_LOGIN_METHOD), + ]) + ) + : options.login_method_options + + useEffect(() => { + if (!isScanOnlyServer) return + setForm(previous => + previous.login_method === SCAN_LOGIN_METHOD + ? previous + : { ...previous, login_method: SCAN_LOGIN_METHOD } + ) + }, [isScanOnlyServer]) + + useEffect(() => { + if (!isEdit || !listing || hydratedId === listing.id) return + const summary = recordValue(listing.asset_summary) + const onlineTime = recordValue(summary.online_time) + const screenshotGroups = recordValue(summary.screenshot_groups) + const skinGroups = recordValue(summary.skin_groups) + const breakdown = recordValue(summary.price_breakdown) + + setForm({ + server_region: listing.server_region || '', + face_owner: stringValue(summary.face_owner), + haf_coin_amount: listing.haf_coin_amount > 0 ? listing.haf_coin_amount / COIN_UNIT : '', + rank_level: listing.rank_level || '', + secret_kd: stringValue(summary.secret_kd), + fire_level: numberOrEmpty(summary.fire_level), + daily_loss_m: numberOrDefault(summary.daily_loss_m, 10), + accelerated_sale_ratio: numberOrEmpty(breakdown.accelerated_sale_ratio), + season_insurance: stringValue(summary.season_insurance), + stamina_level: stringValue(summary.stamina_level), + load_level: stringValue(summary.load_level), + login_method: listing.login_platform || '', + online_start: stringValue(onlineTime.start), + online_end: stringValue(onlineTime.end), + early_morning_response: stringValue(summary.early_morning_response) || '否', + can_change_game_name: stringValue(summary.can_change_game_name), + all_hero: stringValue(summary.all_hero), + unlock_demulan: stringValue(summary.unlock_demulan), + next_season_pass: stringValue(summary.next_season_pass), + ban_record: stringValue(summary.ban_record), + common_regions: stringArray(summary.common_regions), + depositAmountYuan: listing.deposit_amount_cent > 0 ? listing.deposit_amount_cent / 100 : '', + remark: listing.description || stringValue(summary.remark), + }) + + const nextValues: Record = {} + const nextModes: Record = {} + const rawResources = Array.isArray(summary.resources) ? summary.resources : [] + for (const raw of rawResources) { + const resource = recordValue(raw) + const key = stringValue(resource.key) + if (!key) continue + nextValues[key] = numberOrDefault(resource.quantity, 0) + nextModes[key] = stringValue(resource.mode) === '赠送' ? '赠送' : '收费' + } + setQuantityValues(nextValues) + setQuantityModes(nextModes) + + const nextScreenshots: Record = {} + for (const [key, urls] of Object.entries(screenshotGroups)) { + const cleaned = stringArray(urls) + if (cleaned.length > 0) nextScreenshots[key] = cleaned + } + if (Object.keys(nextScreenshots).length === 0 && listing.screenshot_urls?.length > 0) { + nextScreenshots.coin = [...listing.screenshot_urls] + } + setScreenshotFiles(nextScreenshots) + + setSelectedSkins(Object.values(skinGroups).flatMap(value => stringArray(value))) + setVirtualAgreement(true) + setSellerAgreement(true) + setPasswordConfirmed(true) + setHydratedId(listing.id) + }, [isEdit, listing, hydratedId]) + + useEffect(() => { + if (!isEdit || !listing) return + if (listing.status === 'completed') { + message.error('该商品订单已完成,不能再次编辑') + navigate('/seller/listings') + } + }, [isEdit, listing, message, navigate]) + + const screenshotSlots: PublishScreenshotSlot[] = + options.screenshot_slots.length > 0 + ? options.screenshot_slots + : [ + { + key: 'coin', + label: '账号截图', + required: true, + hint: `至少上传 1 张,最多 ${MAX_SCREENSHOTS} 张,支持 JPG / PNG / WebP`, + }, + ] + + const screenshotUrls = useMemo(() => { + const urls = Object.values(screenshotFiles).flat().filter(Boolean) + return Array.from(new Set(urls)) + }, [screenshotFiles]) + + const totalScreenshotCount = Object.values(screenshotFiles) + .flat() + .filter(Boolean).length + + const pricing = useMemo(() => { + const coinMAmount = Number(form.haf_coin_amount || 0) + const coinWanAmount = coinMAmount * 100 + const dailyLossMAmount = Number(form.daily_loss_m || 10) + const dailyLossRatioAdjustment = calculateDailyLossRatioAdjustment(dailyLossMAmount) + const referenceRatio = calculateSellerReferenceRatio({ + coinMAmount, + form, + ratioConfig: options.ratio_config, + skinGroups: options.skin_groups, + selectedSkins, + levelOptions: options.level_options, + dailyLossRatioAdjustment, + }) + const maxAcceleratedSaleRatio = referenceRatio > 0 ? roundRatio(referenceRatio + 10) : 0 + const finalRatio = readFinalSaleRatio( + referenceRatio, + form.accelerated_sale_ratio, + maxAcceleratedSaleRatio + ) + const coinBasePrice = finalRatio > 0 ? roundMoney(coinWanAmount / finalRatio) : 0 + const consumablePrice = calculateConsumablePrice({ + quantityItems: options.quantity_items, + quantityValues, + quantityModes, + seasonInsurance: form.season_insurance, + }) + const sellerPrice = + finalRatio > 0 ? roundMoney(coinBasePrice + consumablePrice) : 0 + const platformPricing = calculatePlatformPricing({ + coinMAmount, + coinWanAmount, + sellerRatio: finalRatio, + sellerCoinBasePrice: coinBasePrice, + sellerTotalPrice: sellerPrice, + consumablePrice, + salePriceConfig, + }) + const recommendedDeposit = calculateRecommendedDeposit({ + depositRecommendConfig: options.deposit_recommend_config, + skinGroups: options.skin_groups, + selectedSkins, + }) + const depositBreakdown = buildDepositBreakdownItems({ + depositRecommendConfig: options.deposit_recommend_config, + skinGroups: options.skin_groups, + selectedSkins, + }) + return { + coinMAmount, + coinWanAmount, + dailyLossMAmount, + dailyLossRatioAdjustment, + referenceRatio, + maxAcceleratedSaleRatio, + finalRatio, + coinBasePrice, + consumablePrice, + sellerPrice, + platformPricing, + finalPrice: platformPricing.buyerTotalPrice, + recommendedDeposit, + depositBreakdown, + } + }, [form, options, salePriceConfig, quantityValues, quantityModes, selectedSkins]) + + const depositAmount = Number(form.depositAmountYuan) + const depositBelowRecommended = + pricing.recommendedDeposit > 0 && + (form.depositAmountYuan === '' || + !Number.isFinite(depositAmount) || + depositAmount < pricing.recommendedDeposit) + + function isQuantityDisabled(item: PublishQuantityItem) { + return isQuantityItemDisabledForInsurance(item, form.season_insurance) + } + + const banEvidenceRequired = options.ban_evidence_options.includes(form.ban_record) + + function isScreenshotRequired(slot: PublishScreenshotSlot) { + return slot.required || (slot.key === 'tencentSecurity' && banEvidenceRequired) + } + + function setQuantityMode(item: PublishQuantityItem, mode: ChargeMode) { + if (isQuantityDisabled(item)) return + setQuantityModes(previous => ({ ...previous, [item.key]: mode })) + } + + async function handleScriptReset() { + if (!isEdit) { + const confirmed = await confirmModal(modal, { + title: '重置发布内容', + content: '将清空当前填写内容和已上传截图。', + okText: '重置', + cancelText: '取消', + onOk: async () => undefined, + }) + if (!confirmed) return + setForm(defaultPublishForm()) + setQuantityValues(Object.fromEntries(options.quantity_items.map(item => [item.key, 0]))) + setQuantityModes({}) + setScreenshotFiles({}) + setSelectedSkins([]) + setVirtualAgreement(false) + setSellerAgreement(false) + setPasswordConfirmed(false) + message.success('已重置') + return + } + setHydratedId(null) + await listingQuery.refetch() + message.success('已恢复原商品信息') + } + + function triggerUpload(slotKey: string) { + if (totalScreenshotCount >= MAX_SCREENSHOTS) { + message.warning(`截图最多上传 ${MAX_SCREENSHOTS} 张`) + return + } + activeSlotRef.current = slotKey + fileInputRef.current?.click() + } + + async function handleScreenshotUpload(event: React.ChangeEvent) { + const input = event.target + const files = Array.from(input.files ?? []) + if (files.length === 0) return + const key = activeSlotRef.current + const available = MAX_SCREENSHOTS - totalScreenshotCount + if (available <= 0) { + message.warning(`截图最多上传 ${MAX_SCREENSHOTS} 张`) + input.value = '' + return + } + const uploads = files.slice(0, available) + if (files.length > available) { + message.warning(`截图最多上传 ${MAX_SCREENSHOTS} 张,本次仅上传 ${available} 张`) + } + setUploadingSlot(key) + try { + for (const file of uploads) { + if (!file.type.startsWith('image/')) { + throw new Error('只能上传图片文件') + } + const uploaded = await uploadFile(file, 'listing') + setScreenshotFiles(previous => ({ + ...previous, + [key]: [...(previous[key] ?? []), uploaded.url], + })) + } + message.success(uploads.length > 1 ? `已上传 ${uploads.length} 张截图` : '截图已上传') + } catch (error) { + message.error(readApiError(error, '截图上传失败')) + } finally { + setUploadingSlot('') + input.value = '' + } + } + + function removeScreenshot(slotKey: string, index: number) { + setScreenshotFiles(previous => { + const next = [...(previous[slotKey] ?? [])] + next.splice(index, 1) + return { ...previous, [slotKey]: next } + }) + } + + function toggleGroupSkin(groupKey: string, values: string[]) { + const group = options.skin_groups.find(item => item.key === groupKey) + if (!group) return + const untouched = selectedSkins.filter(skin => !group.options.includes(skin)) + setSelectedSkins([...untouched, ...values]) + } + + function buildScreenshotGroups() { + const groups: Record = {} + for (const [key, urls] of Object.entries(screenshotFiles)) { + groups[key] = urls.filter(Boolean) + } + for (const slot of screenshotSlots) { + if (!groups[slot.key]) groups[slot.key] = [] + } + return groups + } + + function buildAssetSummary(): Record { + return { + face_owner: form.face_owner, + secret_kd: form.secret_kd, + fire_level: Number(form.fire_level), + daily_loss_m: pricing.dailyLossMAmount, + publish_ratio: pricing.platformPricing.buyerRatio, + price_breakdown: { + seller_reference_ratio: pricing.referenceRatio, + seller_ratio: pricing.finalRatio, + seller_coin_base_price: pricing.coinBasePrice, + seller_total_price: pricing.sellerPrice, + consumable_price: pricing.consumablePrice, + daily_loss_ratio_adjustment: pricing.dailyLossRatioAdjustment, + accelerated_sale_ratio: hasAcceleratedSaleRatioInput(form.accelerated_sale_ratio) + ? Number(form.accelerated_sale_ratio) + : pricing.referenceRatio, + buyer_coin_base_price: pricing.platformPricing.buyerCoinBasePrice, + buyer_total_price: pricing.finalPrice, + buyer_ratio: pricing.platformPricing.buyerRatio, + platform_markup_amount: pricing.platformPricing.platformMarkupAmount, + platform_rule_type: pricing.platformPricing.ruleType, + }, + season_insurance: form.season_insurance, + stamina_level: form.stamina_level, + load_level: form.load_level, + resources: options.quantity_items + .map(item => ({ + key: item.key, + label: item.label, + price: item.price, + quantity: isQuantityDisabled(item) ? 0 : Number(quantityValues[item.key] || 0), + mode: isQuantityDisabled(item) ? '赠送' : quantityModes[item.key] || '收费', + })) + .filter(item => item.quantity > 0), + skin_groups: options.skin_groups.reduce>((groups, group) => { + groups[group.key] = group.options.filter(skin => selectedSkins.includes(skin)) + return groups + }, {}), + screenshot_groups: buildScreenshotGroups(), + online_time: { + start: form.online_start, + end: form.online_end, + }, + early_morning_response: form.early_morning_response, + can_change_game_name: form.can_change_game_name, + all_hero: form.all_hero, + unlock_demulan: form.unlock_demulan, + next_season_pass: form.next_season_pass, + ban_record: form.ban_record, + common_regions: form.common_regions, + remark: form.remark, + } + } + + function validateForm() { + if (!form.server_region) return '请选择区服' + if (!form.login_method) return '请选择上号方式' + if (!form.rank_level) return '请选择段位' + if (form.haf_coin_amount === '' || !Number.isFinite(Number(form.haf_coin_amount))) { + return '哈夫币/M格式不正确' + } + if (Number(form.haf_coin_amount) <= 0) return '请填写哈夫币/M' + if (!form.fire_level) return '请填写烽火等级' + if (Number(form.fire_level) < options.fire_level_min) { + return `烽火等级低于${options.fire_level_min}级的号无法发布` + } + if (!form.season_insurance) return '请选择赛季保险' + if (!form.stamina_level) return '请选择体力等级' + if (!form.load_level) return '请选择负重等级' + if (options.ban_record_options.length > 0 && !form.ban_record) return '请选择封禁记录' + if (!dailyLossOptions.includes(Number(form.daily_loss_m))) return '请选择每日损耗' + const start = parseTimeMinute(form.online_start) + const end = parseTimeMinute(form.online_end) + if (start === null) return '请选择在线开始时间' + if (end === null) return '请选择在线结束时间' + if (start === end) return '在线开始和结束时间不能相同(全天请用 00:00-23:59)' + if (!yesNo(form.early_morning_response)) return '请选择凌晨0-2点是否可响应上号' + if (!form.can_change_game_name) return '请选择是否可以修改游戏名' + if (!form.all_hero) return '请选择是否有全英雄' + if (!form.unlock_demulan) return '请选择是否解锁德穆兰' + if (options.next_season_pass_enabled && !form.next_season_pass) { + return '请选择下赛季是否购买通行证' + } + if (form.depositAmountYuan === '' || Number(form.depositAmountYuan) < 0) { + return '请填写押金' + } + if (!Number.isFinite(Number(form.depositAmountYuan))) return '押金格式不正确' + if (pricing.recommendedDeposit > 0 && Number(form.depositAmountYuan) < pricing.recommendedDeposit) { + return `押金不能低于智能推荐 ¥${pricing.recommendedDeposit}` + } + if (pricing.consumablePrice > 0 && Number(form.depositAmountYuan) <= pricing.consumablePrice) { + return `押金必须大于额外消耗品总价值 ¥${pricing.consumablePrice}` + } + if (pricing.finalPrice <= 0) { + return '暂无法计算买家价,请确认后台发布选项与价格规则已配置' + } + if (hasAcceleratedSaleRatioInput(form.accelerated_sale_ratio)) { + const ratio = Number(form.accelerated_sale_ratio) + if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确' + if (pricing.referenceRatio > 0 && ratio < pricing.referenceRatio) { + return `加速出售比例不能低于参考比例 1:${formatNumber(pricing.referenceRatio)}` + } + if (pricing.maxAcceleratedSaleRatio > 0 && ratio > pricing.maxAcceleratedSaleRatio) { + return `加速出售比例不能超过 1:${formatNumber(pricing.maxAcceleratedSaleRatio)}` + } + } + if (screenshotUrls.length === 0) return '请至少上传 1 张账号截图' + for (const slot of screenshotSlots) { + if ( + isScreenshotRequired(slot) && + (screenshotFiles[slot.key] ?? []).filter(Boolean).length === 0 + ) { + return `请上传${slot.label}` + } + } + for (const item of options.quantity_items) { + if (isQuantityDisabled(item)) { + if (Number(quantityValues[item.key] || 0) > 0) { + return '赛季保险选择 3*3 时不能填写 9格体验卡' + } + continue + } + const value = quantityValues[item.key] + if (value === undefined || value === null) return `请填写${item.label}的数量` + if (Number(value) < 0) return `${item.label}的数量不能为负数` + } + if (!virtualAgreement || !sellerAgreement) return '请先阅读并勾选两份发布协议' + if (!passwordConfirmed) return '请勾选确认已知晓改密与清除登录设备事项' + return '' + } + + const submitMutation = useMutation({ + mutationFn: async () => { + const payload = { + title: buildTitle(form, pricing.coinMAmount), + description: form.remark, + server_region: form.server_region, + login_platform: form.login_method, + rank_level: form.rank_level, + haf_coin_amount: Math.round(pricing.coinMAmount * COIN_UNIT), + asset_summary: buildAssetSummary(), + screenshot_urls: screenshotUrls, + price_cent: Math.round(pricing.finalPrice * 100), + deposit_amount_cent: Math.round(Number(form.depositAmountYuan) * 100), + agreed_virtual_asset_sale: virtualAgreement, + agreed_seller_agreement: sellerAgreement, + } + return isEdit ? updateListing(id as string, payload) : createListing(payload) + }, + onSuccess: async (listing, _payload, _context) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['seller-listings'] }), + queryClient.invalidateQueries({ queryKey: ['listings'] }), + queryClient.invalidateQueries({ queryKey: ['listing-detail', String(listing.id)] }), + queryClient.invalidateQueries({ queryKey: ['seller-listing', String(listing.id)] }), + ]) + message.success( + listing.status === 'published' && listing.review_status === 'approved' + ? isEdit + ? '修改成功,已上架' + : '发布成功,已上架' + : isEdit + ? '修改成功,等待后台审核' + : '发布成功,等待后台审核' + ) + if (!isEdit && listing.listing_group_conversation_id) { + navigate(`/messages/${listing.listing_group_conversation_id}`) + return + } + navigate('/seller/listings') + }, + }) + + async function handleSubmit() { + const error = validateForm() + if (error) { + message.warning(error) + return + } + try { + await submitMutation.mutateAsync() + } catch (error) { + message.error( + readApiError( + error, + isEdit ? '修改失败,请稍后重试' : '发布失败,请确认已登录并完成实名认证' + ) + ) + } + } + + const loading = publishDataQuery.isPending || (isEdit && listingQuery.isPending) + const title = isEdit ? '编辑账号' : '发布账号' + + if (!hydrated) { + return ( +
+ + + 正在恢复登录状态… + +
+ ) + } + + if (!token) { + return ( +
+ + + + + +
+ ) + } + + if (isEdit && listingQuery.isError) { + return ( +
+ + + + + } + /> +
+ ) + } + + if (loading) { + return ( +
+ + + 正在加载发布选项与商品资料… + +
+ ) + } + + return ( +
+
+
+ + SELLER PUBLISH · 号主发布 + + {title} + + + {pricing.coinMAmount > 0 + ? buildTitle(form, pricing.coinMAmount) + : '按后台选项填写账号资料,价格与押金将实时计算。'} + +
+ + + + +
+ + {publishDataQuery.isError ? ( + void publishDataQuery.refetch()}> + 重试 + + } + /> + ) : null} + + {options.server_options.length === 0 || options.ratio_config.insurance_base_ratios.length === 0 ? ( + + ) : null} + + + + +
+ updateForm('server_region', value)} + /> + + updateForm('rank_level', value)} + /> + + + + updateForm('season_insurance', value)} + /> + updateForm('stamina_level', value)} + /> + updateForm('load_level', value)} + /> + +
+
+ + {options.quantity_items.length > 0 ? ( + +
+
+ 物资 + 单价 + 数量 + 计费 +
+ {options.quantity_items.map(item => { + const disabled = isQuantityDisabled(item) + return ( +
+
+ {item.label} + {disabled ? '3*3 已包含,不可填写' : item.placeholder || '—'} +
+ {item.price || '—'} + + setQuantityValues(previous => ({ + ...previous, + [item.key]: Number(value ?? 0), + })) + } + className="seller-number-input" + /> + setQuantityMode(item, value as ChargeMode)} + /> +
+ ) + })} +
+
+ ) : null} + + + {options.skin_groups.length > 0 ? ( +
+ {options.skin_groups.map(group => ( +
+
+ {group.title} + + 已选 {group.options.filter(skin => selectedSkins.includes(skin)).length} 项 + +
+ selectedSkins.includes(skin))} + options={group.options} + onChange={values => toggleGroupSkin(group.key, values as string[])} + /> +
+ ))} +
+ ) : ( + + )} +
+ + + 已上传 {totalScreenshotCount}/{MAX_SCREENSHOTS} 张 + + } + > +
+ {screenshotSlots.map(slot => { + const urls = screenshotFiles[slot.key] ?? [] + return ( +
+
+ + {slot.label} + {isScreenshotRequired(slot) ? * : null} + + {slot.hint} +
+
+ {urls.map((url, index) => ( +
+ {`${slot.label}${index + +
+ ))} + +
+
+ ) + })} +
+ void handleScreenshotUpload(event)} + /> +
+ + +
+ + + + + +
+
+ + +
+
+
+ 《{agreements.virtual_asset_sale.title}》 + setVirtualAgreement(event.target.checked)} + > + 我已阅读并同意 + +
+
+ {agreements.virtual_asset_sale.content || '协议内容待后台配置。'} +
+
+
+
+ 《{agreements.seller_agreement.title}》 + setSellerAgreement(event.target.checked)} + > + 我已阅读并同意 + +
+
+ {agreements.seller_agreement.content || '协议内容待后台配置。'} +
+
+
+ + setPasswordConfirmed(event.target.checked)} + > + 我已知晓:完成后须改密并清除登录设备;若遇封号,须解封后查号方可结算,未经查号即结算者,后续账号问题商行概不负责。 + +
+ + + +
+ + BUYER PRICE · 买家挂牌价 +
+ {formatMoney(Math.round(pricing.finalPrice * 100))} + + {pricing.platformPricing.ruleType === 'fixed_markup' + ? '固定加价' + : pricing.platformPricing.ruleType === 'ratio_subtract' + ? '比例修正' + : '无加价'} + +
+
+
+ 参考比例 + + {pricing.referenceRatio > 0 + ? `1:${formatNumber(pricing.referenceRatio)}` + : '--'} + +
+
+ 卖家比例 + + {pricing.finalRatio > 0 ? `1:${formatNumber(pricing.finalRatio)}` : '--'} + +
+
+ 买家比例 + + {pricing.platformPricing.buyerRatio > 0 + ? `1:${formatNumber(pricing.platformPricing.buyerRatio)}` + : '--'} + +
+
+ 纯币基础价 + {formatMoney(Math.round(pricing.coinBasePrice * 100))} +
+
+ 消耗品价值 + {formatMoney(Math.round(pricing.consumablePrice * 100))} +
+
+ 卖家到手价 + {formatMoney(Math.round(pricing.sellerPrice * 100))} +
+
+ 推荐押金 + {formatMoney(Math.round(pricing.recommendedDeposit * 100))} +
+
+ +
+
+ 哈夫币 + {pricing.coinMAmount || '--'}M +
+
+ 截图 + + {totalScreenshotCount}/{MAX_SCREENSHOTS} + +
+
+ 每日损耗 + {pricing.dailyLossMAmount}M +
+
+ + + 提交后会进入后台审核;审核通过自动上架。买家价与押金以提交时的计算结果为准。 + +
+
+ +
+
+ ) +} diff --git a/frontend/src/features/seller/SellerListingsPage.tsx b/frontend/src/features/seller/SellerListingsPage.tsx new file mode 100644 index 0000000..0141b9c --- /dev/null +++ b/frontend/src/features/seller/SellerListingsPage.tsx @@ -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).seller_total_price) + if (Number.isFinite(value) && value > 0) return Math.round(value * 100) + } + return row.price_cent +} + +function statusTag(meta: StatusMeta) { + return ( + + {meta.label} + + ) +} + +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('all') + const [actingId, setActingId] = useState(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 ( +
+ + + +
+ ) + } + + if (!token) { + return ( +
+ + + + + +
+ ) + } + + return ( +
+
+
+ SELLER DESK · 号主柜台 + + 我的商品 + + + 查看账号状态、审核进度,并管理上架与下架。所有操作都会留痕。 + +
+ + + + +
+ +
+ {stats.map(item => ( + + ))} +
+ + {listingsQuery.isError ? ( + + + + + + ) : null} + + {listingsQuery.isPending ? ( + + {Array.from({ length: 4 }).map((_, index) => ( + + + + + + ))} + + ) : null} + + {!listingsQuery.isPending && !listingsQuery.isError && displayListings.length === 0 ? ( + + + 暂无符合条件的发布。 +
+ 可以先发布一个账号,审核通过后会自动上架。 + + } + > + +
+
+ ) : null} + + {displayListings.length > 0 ? ( + + {displayListings.map(item => { + const status = listingStatusMeta(item.status) + const review = listingReviewStatusMeta(reviewStatusOf(item)) + return ( + + +
+ + + {statusTag(status)} + {item.status !== 'completed' ? statusTag(review) : null} + +
+ + + + {item.title || '未命名账号'} + +
+
+ 区服 + {item.server_region || '—'} +
+
+ 哈夫币 + {coinText(item)} +
+
+ 售价 + {formatMoney(sellerReferencePrice(item))} +
+
+ 押金 + {formatMoney(item.deposit_amount_cent)} +
+
+ {item.review_status === 'rejected' && item.review_reason ? ( + + 驳回原因:{item.review_reason} + + ) : null} + + +
+ + + + +
+
+ + ) + })} +
+ ) : null} +
+ ) +} diff --git a/frontend/src/features/seller/api.ts b/frontend/src/features/seller/api.ts new file mode 100644 index 0000000..76f4974 --- /dev/null +++ b/frontend/src/features/seller/api.ts @@ -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 { + const data = await unwrapData<{ items?: SellerListing[] } | null>( + apiClient.get>('/seller/listings') + ) + return data?.items ?? [] +} + +export async function fetchSellerListing(id: number | string): Promise { + return unwrapData( + apiClient.get>(`/seller/listings/${id}`) + ) +} + +export async function createListing(payload: PublishPayload): Promise { + return unwrapData( + apiClient.post>('/listings', payload) + ) +} + +export async function updateListing( + id: number | string, + payload: PublishPayload +): Promise { + return unwrapData( + apiClient.put>(`/listings/${id}`, payload) + ) +} + +export async function submitListingReview(id: number | string): Promise { + return unwrapData( + apiClient.post>(`/listings/${id}/submit-review`) + ) +} + +export async function offlineListing(id: number | string): Promise { + return unwrapData( + apiClient.delete>(`/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 ? '修改成功,等待后台审核' : '发布成功,等待后台审核' +} diff --git a/frontend/src/features/seller/options.ts b/frontend/src/features/seller/options.ts new file mode 100644 index 0000000..b9eb4e7 --- /dev/null +++ b/frontend/src/features/seller/options.ts @@ -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 | null>( + apiClient.get | null>>('/listing-publish-options') + ) + return mergeListingPublishOptions(data ?? undefined) +} + +export async function fetchListingPublishAgreements() { + const data = await unwrapData | null>( + apiClient.get | null>>( + '/listing-publish-agreements' + ) + ) + return mergeListingPublishAgreements(data ?? undefined) +} + +export async function fetchListingSalePriceConfig() { + const data = await unwrapData | null>( + apiClient.get | null>>('/listing-sale-price-config') + ) + return mergeListingSalePriceConfig(data ?? undefined) +} + +export function mergeListingPublishOptions( + options?: Partial +): 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 { + return normalizeSalePriceConfig(options) +} + +export function mergeListingPublishAgreements( + options?: Partial +): 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 { + return typeof value === 'object' && value !== null +} diff --git a/frontend/src/features/seller/types.ts b/frontend/src/features/seller/types.ts new file mode 100644 index 0000000..bb63012 --- /dev/null +++ b/frontend/src/features/seller/types.ts @@ -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 + quantityModes: Record + screenshotFiles: Record + 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 + screenshot_urls: string[] + price_cent: number + deposit_amount_cent: number + agreed_virtual_asset_sale: boolean + agreed_seller_agreement: boolean +} + +/** 兼容历史资产摘要:所有字段都按可缺失处理。 */ +export type AssetSummary = Record + +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: '', + } +} diff --git a/frontend/src/layouts/StorefrontLayout.tsx b/frontend/src/layouts/StorefrontLayout.tsx index fc45379..1f2700a 100644 --- a/frontend/src/layouts/StorefrontLayout.tsx +++ b/frontend/src/layouts/StorefrontLayout.tsx @@ -31,6 +31,7 @@ export function StorefrontLayout() { 消息 + 卖家中心 流程 规则 @@ -51,6 +52,11 @@ export function StorefrontLayout() { label: '我的消息', onClick: () => navigate('/messages'), }, + { + key: 'seller', + label: '卖家中心', + onClick: () => navigate('/seller/listings'), + }, { key: 'logout', label: '退出登录', diff --git a/frontend/src/shared/utils/pricing.ts b/frontend/src/shared/utils/pricing.ts new file mode 100644 index 0000000..c6f9e02 --- /dev/null +++ b/frontend/src/shared/utils/pricing.ts @@ -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 + quantityModes: Record + 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, + insurance: string +) { + return config.insurance_base_ratios.find(item => item.insurance === insurance)?.ratio || 0 +} + +function calculateConfigPenalty( + config: Pick, + 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, + coinM: number +) { + return ( + [...config.coin_corrections] + .sort((a, b) => b.threshold_m - a.threshold_m) + .find(item => coinM > item.threshold_m)?.correction || 0 + ) +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 724e211..564536a 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -15,4 +15,5 @@ @import './mobile-messages.css'; @import './mobile-chat.css'; @import './pc-messages.css'; +@import './seller.css'; @import './components.css'; diff --git a/frontend/src/styles/seller.css b/frontend/src/styles/seller.css new file mode 100644 index 0000000..cfa162f --- /dev/null +++ b/frontend/src/styles/seller.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; + } +}