feat(seller): 优化发布页布局并增加草稿自动保存

This commit is contained in:
yml2213
2026-10-31 13:55:13 +08:00
parent 8bd6d7fe8f
commit 3113426d8f
3 changed files with 412 additions and 103 deletions
@@ -408,10 +408,6 @@ export function SellerListingDetailPage({ mobile = false }: { mobile?: boolean }
<span>卖家到手价</span>
<strong>{formatMoney(sellerPriceCent(listing))}</strong>
</div>
<div>
<span>买家挂牌价</span>
<strong>{formatMoney(listing.price_cent)}</strong>
</div>
<div>
<span>押金</span>
<strong>{formatMoney(listing.deposit_amount_cent)}</strong>
@@ -457,21 +453,6 @@ export function SellerListingDetailPage({ mobile = false }: { mobile?: boolean }
{formatMoney(moneyCent(breakdown.consumable_price_cent, breakdown.consumable_price))}
</strong>
</div>
<div>
<span>买家比例</span>
<strong>{ratioText(breakdown.buyer_ratio_bps, breakdown.buyer_ratio)}</strong>
</div>
<div>
<span>平台加价</span>
<strong>
{formatMoney(
moneyCent(
breakdown.platform_markup_amount_cent,
breakdown.platform_markup_amount
)
)}
</strong>
</div>
</div>
</Card>
@@ -8,13 +8,11 @@ import {
Button,
Card,
Checkbox,
Col,
Divider,
Empty,
Image,
Input,
InputNumber,
Row,
Segmented,
Select,
Space,
@@ -72,7 +70,7 @@ import {
type PublishSalePriceConfig,
type PublishScreenshotSlot,
} from './options'
import { defaultPublishForm, type PublishForm } from './types'
import { defaultPublishForm, type PublishDraft, type PublishForm } from './types'
const MAX_SCREENSHOTS = 9
const SCAN_ONLY_MARK = '微信'
@@ -164,20 +162,36 @@ function ChoiceField(props: {
}) {
const { label, required, value, options, placeholder, onChange } = props
return (
<label className="seller-field">
<div className="seller-field">
<span className="seller-field__label">
{label}
{required ? <b>*</b> : null}
</span>
{options.length > 0 ? (
<Select
value={value || undefined}
placeholder={placeholder || `请选择${label}`}
options={options.map(option => ({ value: option, label: option }))}
onChange={value => onChange(value ?? '')}
allowClear
showSearch
/>
<div className="seller-pill-group" role="radiogroup" aria-label={label}>
{options.map(option => {
const active = value === option
return (
<button
key={option}
type="button"
role="radio"
aria-checked={active}
className={`seller-pill${active ? ' is-active' : ''}`}
onClick={() => {
if (active && !required) {
onChange('')
return
}
onChange(option)
}}
>
{option}
</button>
)
})}
{!value && placeholder ? <small className="seller-pill-hint">{placeholder}</small> : null}
</div>
) : (
<Input
value={value}
@@ -185,10 +199,71 @@ function ChoiceField(props: {
onChange={event => onChange(event.target.value)}
/>
)}
</label>
</div>
)
}
const PUBLISH_DRAFT_KEY_PREFIX = 'jieyou:seller:publish-draft:v1'
interface StoredPublishDraft extends PublishDraft {
savedAt: number
}
function draftHasContent(draft: PublishDraft) {
const defaults = defaultPublishForm()
const formChanged = (Object.keys(defaults) as (keyof PublishForm)[]).some(key => {
const current = draft.form[key]
const fallback = defaults[key]
if (Array.isArray(current) || Array.isArray(fallback)) {
return JSON.stringify(current) !== JSON.stringify(fallback)
}
return current !== fallback
})
return (
formChanged ||
draft.selectedSkins.length > 0 ||
Object.values(draft.screenshotFiles).some(urls => (urls ?? []).length > 0) ||
Object.values(draft.quantityValues).some(value => Number(value) > 0)
)
}
function readPublishDraft(key: string): StoredPublishDraft | null {
try {
const raw = window.localStorage.getItem(key)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<StoredPublishDraft>
if (!parsed || typeof parsed !== 'object' || !parsed.form) return null
return {
form: { ...defaultPublishForm(), ...parsed.form },
quantityValues: parsed.quantityValues ?? {},
quantityModes: parsed.quantityModes ?? {},
screenshotFiles: parsed.screenshotFiles ?? {},
selectedSkins: parsed.selectedSkins ?? [],
savedAt: Number(parsed.savedAt) || 0,
}
} catch {
return null
}
}
function writePublishDraft(key: string, draft: PublishDraft) {
try {
const savedAt = Date.now()
window.localStorage.setItem(key, JSON.stringify({ ...draft, savedAt }))
return savedAt
} catch {
return 0
}
}
function removePublishDraft(key: string) {
try {
window.localStorage.removeItem(key)
} catch {
// 忽略隐私模式等场景下的存储失败
}
}
export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } = {}) {
const { id } = useParams()
const isEdit = Boolean(id)
@@ -209,9 +284,21 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
const [passwordConfirmed, setPasswordConfirmed] = useState(false)
const [uploadingSlot, setUploadingSlot] = useState('')
const [hydratedId, setHydratedId] = useState<number | null>(null)
const userId = useSessionStore(state => state.user?.id ?? 0)
const draftKey = `${PUBLISH_DRAFT_KEY_PREFIX}:${userId || 'guest'}`
const [draftReady, setDraftReady] = useState(false)
const [draftRestoredAt, setDraftRestoredAt] = useState(0)
const [draftSavedAt, setDraftSavedAt] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const activeSlotRef = useRef('coin')
const draftRef = useRef<PublishDraft>({
form: defaultPublishForm(),
quantityValues: {},
quantityModes: {},
screenshotFiles: {},
selectedSkins: [],
})
const publishDataQuery = useQuery<PublishPageData>({
queryKey: ['seller-publish-options'],
@@ -238,6 +325,76 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
const listing = listingQuery.data
function clearPublishDraft() {
removePublishDraft(draftKey)
setDraftSavedAt(0)
setDraftRestoredAt(0)
}
// 新建发布页恢复本机草稿;编辑已有商品时不使用草稿。
useEffect(() => {
if (!hydrated) return
if (isEdit) {
setDraftReady(true)
return
}
const stored = readPublishDraft(draftKey)
if (stored) {
setForm(stored.form)
setQuantityValues(stored.quantityValues)
setQuantityModes(stored.quantityModes)
setScreenshotFiles(stored.screenshotFiles)
setSelectedSkins(stored.selectedSkins)
setDraftRestoredAt(stored.savedAt)
setDraftSavedAt(stored.savedAt)
}
setDraftReady(true)
}, [draftKey, hydrated, isEdit])
// 输入停顿 1 秒后自动写入本地草稿,避免频繁写存储。
useEffect(() => {
if (isEdit || !hydrated || !draftReady) return
draftRef.current = { form, quantityValues, quantityModes, screenshotFiles, selectedSkins }
const timer = window.setTimeout(() => {
if (!draftHasContent(draftRef.current)) {
removePublishDraft(draftKey)
setDraftSavedAt(0)
return
}
const savedAt = writePublishDraft(draftKey, draftRef.current)
if (savedAt > 0) setDraftSavedAt(savedAt)
}, 1000)
return () => window.clearTimeout(timer)
}, [
form,
quantityValues,
quantityModes,
screenshotFiles,
selectedSkins,
draftKey,
hydrated,
isEdit,
draftReady,
])
// 关闭或切走页面时同步落盘,防止最后一次输入丢失。
useEffect(() => {
if (isEdit) return
const flush = () => {
if (!hydrated || !draftReady || !draftHasContent(draftRef.current)) return
writePublishDraft(draftKey, draftRef.current)
}
const handleVisibility = () => {
if (document.visibilityState === 'hidden') flush()
}
window.addEventListener('beforeunload', flush)
document.addEventListener('visibilitychange', handleVisibility)
return () => {
window.removeEventListener('beforeunload', flush)
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [draftKey, hydrated, isEdit, draftReady])
function updateForm<K extends keyof PublishForm>(key: K, value: PublishForm[K]) {
setForm(previous => ({ ...previous, [key]: value }))
}
@@ -463,6 +620,7 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
onOk: async () => undefined,
})
if (!confirmed) return
clearPublishDraft()
setForm(defaultPublishForm())
setQuantityValues(Object.fromEntries(options.quantity_items.map(item => [item.key, 0])))
setQuantityModes({})
@@ -722,6 +880,7 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
return isEdit ? updateListing(id as string, payload) : createListing(payload)
},
onSuccess: async (listing, _payload, _context) => {
if (!isEdit) clearPublishDraft()
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['seller-listings'] }),
queryClient.invalidateQueries({ queryKey: ['listings'] }),
@@ -848,6 +1007,13 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
</Typography.Paragraph>
</div>
<Space wrap>
{!isEdit && draftReady ? (
<Typography.Text className="seller-draft-status">
{draftSavedAt
? `草稿已自动保存 ${dayjs(draftSavedAt).format('HH:mm:ss')}`
: '草稿自动保存已开启'}
</Typography.Text>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => void handleScriptReset()}>
{isEdit ? '恢复原信息' : '重置'}
</Button>
@@ -862,6 +1028,21 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
</Space>
</header>
{!isEdit && draftRestoredAt > 0 ? (
<Alert
type="info"
showIcon
className="seller-draft-alert"
message={`已恢复 ${dayjs(draftRestoredAt).format('MM-DD HH:mm')} 自动保存的草稿`}
description="内容会持续自动保存;发布成功或点击「重置」后草稿会被清除。"
action={
<Button size="small" onClick={() => void handleScriptReset()}>
清空重填
</Button>
}
/>
) : null}
{publishDataQuery.isError ? (
<Alert
type="error"
@@ -885,8 +1066,8 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
/>
) : null}
<Row gutter={[18, 18]} align="top">
<Col xs={24} xl={16}>
<div className="seller-form-layout">
<div className="seller-form-main">
<Card title="基础资料" className="seller-panel">
<div className="seller-form-grid">
<ChoiceField
@@ -896,16 +1077,23 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
options={options.server_options}
onChange={value => updateForm('server_region', value)}
/>
<label className="seller-field">
<div className="seller-field">
<span className="seller-field__label">上号方式</span>
{effectiveLoginMethods.length > 0 ? (
<Select
value={form.login_method || undefined}
placeholder={isScanOnlyServer ? '微信区固定为扫码' : '请选择上号方式'}
disabled={isScanOnlyServer}
options={effectiveLoginMethods.map(item => ({ value: item, label: item }))}
onChange={value => updateForm('login_method', value)}
/>
<div className="seller-pill-group" role="radiogroup" aria-label="上号方式">
{effectiveLoginMethods.map(item => (
<button
key={item}
type="button"
role="radio"
aria-checked={form.login_method === item}
className={`seller-pill${form.login_method === item ? ' is-active' : ''}`}
onClick={() => updateForm('login_method', item)}
>
{item}
</button>
))}
</div>
) : (
<Input
value={form.login_method}
@@ -919,7 +1107,7 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
? '微信区账号仅支持扫码登录,上号方式已自动固定为扫码。'
: '优先推荐账密登录,出售速度通常更快。'}
</small>
</label>
</div>
<ChoiceField
label="段位"
required
@@ -961,13 +1149,6 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
onChange={event => updateForm('secret_kd', event.target.value)}
/>
</label>
<ChoiceField
label="赛季保险"
required
value={form.season_insurance}
options={options.insurance_options}
onChange={value => updateForm('season_insurance', value)}
/>
<ChoiceField
label="体力等级"
required
@@ -975,6 +1156,13 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
options={options.level_options}
onChange={value => updateForm('stamina_level', value)}
/>
<ChoiceField
label="赛季保险"
required
value={form.season_insurance}
options={options.insurance_options}
onChange={value => updateForm('season_insurance', value)}
/>
<ChoiceField
label="负重等级"
required
@@ -982,19 +1170,25 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
options={options.level_options}
onChange={value => updateForm('load_level', value)}
/>
<label className="seller-field">
<div className="seller-field">
<span className="seller-field__label">
每日损耗<b>*</b>
</span>
<Select
value={form.daily_loss_m === '' ? undefined : Number(form.daily_loss_m)}
options={dailyLossOptions.map(item => ({
value: item,
label: `${item}M/天`,
}))}
onChange={value => updateForm('daily_loss_m', value)}
/>
</label>
<div className="seller-pill-group" role="radiogroup" aria-label="每日损耗">
{dailyLossOptions.map(item => (
<button
key={item}
type="button"
role="radio"
aria-checked={Number(form.daily_loss_m) === item}
className={`seller-pill${Number(form.daily_loss_m) === item ? ' is-active' : ''}`}
onClick={() => updateForm('daily_loss_m', item)}
>
{item}M/天
</button>
))}
</div>
</div>
<ChoiceField
label="人脸情况"
value={form.face_owner}
@@ -1159,11 +1353,36 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
已选 {group.options.filter(skin => selectedSkins.includes(skin)).length} 项
</span>
</div>
<Checkbox.Group
value={group.options.filter(skin => selectedSkins.includes(skin))}
options={group.options}
onChange={values => toggleGroupSkin(group.key, values as string[])}
/>
<div
className="seller-pill-group seller-skin-pills"
role="group"
aria-label={group.title}
>
{group.options.map(skin => {
const active = selectedSkins.includes(skin)
return (
<button
key={skin}
type="button"
aria-pressed={active}
className={`seller-pill seller-skin-pill${active ? ' is-active' : ''}`}
onClick={() => {
const current = group.options.filter(item =>
selectedSkins.includes(item),
)
toggleGroupSkin(
group.key,
active
? current.filter(item => item !== skin)
: [...current, skin],
)
}}
>
{skin}
</button>
)
})}
</div>
</div>
))}
</div>
@@ -1369,23 +1588,31 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
我已知晓:完成后须改密并清除登录设备;若遇封号,须解封后查号方可结算,未经查号即结算者,后续账号问题商行概不负责。
</Checkbox>
</Card>
</Col>
</div>
<Col xs={24} xl={8}>
<aside className="seller-form-aside">
<div className="seller-summary">
<Card className="seller-price-panel">
<Typography.Text className="seller-eyebrow">BUYER PRICE · 买家挂牌价</Typography.Text>
<Typography.Text className="seller-eyebrow">SELLER PRICE · 卖家发布价格</Typography.Text>
<div className="seller-price-panel__main">
<strong>{formatMoney(pricing.finalPriceCent)}</strong>
<span>
{pricing.platformPricing.ruleType === 'fixed_markup'
? '固定加价'
: pricing.platformPricing.ruleType === 'ratio_subtract'
? '比例修正'
: '无加价'}
</span>
<strong>{formatMoney(pricing.sellerPriceCent)}</strong>
<span>账号价格,押金另计</span>
</div>
<div className="seller-price-lines">
<div>
<span>纯币价格</span>
<strong>{formatMoney(pricing.coinBasePriceCent)}</strong>
</div>
<div>
<span>额外商品价格</span>
<strong>{formatMoney(pricing.consumablePriceCent)}</strong>
</div>
<div>
<span>押金价格</span>
<strong>
{formatMoney(depositAmountCent || pricing.recommendedDepositCent)}
</strong>
</div>
<div>
<span>参考比例</span>
<strong>
@@ -1400,30 +1627,6 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
{pricing.finalRatioBPS > 0 ? `1:${formatRatioBPS(pricing.finalRatioBPS)}` : '--'}
</strong>
</div>
<div>
<span>买家比例</span>
<strong>
{pricing.platformPricing.buyerRatioBPS > 0
? `1:${formatRatioBPS(pricing.platformPricing.buyerRatioBPS)}`
: '--'}
</strong>
</div>
<div>
<span>纯币基础价</span>
<strong>{formatMoney(pricing.coinBasePriceCent)}</strong>
</div>
<div>
<span>消耗品价值</span>
<strong>{formatMoney(pricing.consumablePriceCent)}</strong>
</div>
<div>
<span>卖家到手价</span>
<strong>{formatMoney(pricing.sellerPriceCent)}</strong>
</div>
<div>
<span>推荐押金</span>
<strong>{formatMoney(pricing.recommendedDepositCent)}</strong>
</div>
</div>
<Divider />
<div className="seller-summary-meta">
@@ -1457,8 +1660,8 @@ export function SellerListingFormPage({ mobile = false }: { mobile?: boolean } =
</Typography.Paragraph>
</Card>
</div>
</Col>
</Row>
</aside>
</div>
</main>
)
}
+126 -1
View File
@@ -365,6 +365,12 @@ button.seller-code:not(:disabled):hover {
gap: 18px;
}
.seller-skin-pills .seller-pill {
padding: 3px 12px;
border-radius: 999px;
font-size: 12px;
}
.seller-shot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
@@ -508,7 +514,9 @@ button.seller-code:not(:disabled):hover {
.seller-upload-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
align-items: start;
}
.seller-upload-slot {
@@ -650,6 +658,117 @@ button.seller-code:not(:disabled):hover {
top: 92px;
}
/* 发布页:主表单 + 右侧悬浮价格栏 */
.seller-form-page {
max-width: 1240px;
}
.seller-draft-status {
color: var(--ink-muted) !important;
font-size: 12px;
white-space: nowrap;
}
.seller-draft-alert {
margin-bottom: 16px;
}
/* 发布页:主体占满内容宽度,结算栏在大屏时独立到右侧留白 */
.seller-form-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px;
}
.seller-form-main {
min-width: 0;
}
.seller-form-aside {
width: 100%;
max-width: 320px;
min-width: 0;
margin-left: auto;
}
@media (min-width: 1920px) {
.seller-form-layout {
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
margin-right: -340px;
}
.seller-form-aside {
max-width: none;
}
}
/* 点选式选项,替代下拉框 */
.seller-pill-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 34px;
}
.seller-pill {
padding: 5px 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink-secondary);
font-size: 13px;
line-height: 1.5;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease;
}
.seller-pill:hover {
border-color: var(--brand-ink);
color: var(--brand-ink);
}
.seller-pill.is-active {
border-color: var(--brand-ink);
background: var(--brand-ink);
color: #fff;
font-weight: 600;
}
.seller-pill:focus-visible {
outline: 2px solid var(--brand-brass);
outline-offset: 2px;
}
.seller-pill-hint {
color: var(--ink-muted);
font-size: 12px;
}
.seller-form-page .seller-summary {
top: 88px;
max-height: calc(100vh - 104px);
overflow-y: auto;
padding-right: 2px;
}
.seller-form-page .seller-summary::-webkit-scrollbar {
width: 6px;
}
.seller-form-page .seller-summary::-webkit-scrollbar-thumb {
border-radius: 3px;
background: rgba(30, 77, 66, 0.18);
}
.seller-form-page .seller-summary::-webkit-scrollbar-track {
background: transparent;
}
.seller-price-panel {
border-color: var(--brand-seal-line);
background:
@@ -724,6 +843,10 @@ button.seller-code:not(:disabled):hover {
}
@media (max-width: 1100px) {
.seller-form-layout {
grid-template-columns: 1fr;
}
.seller-stat-strip {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -739,6 +862,8 @@ button.seller-code:not(:disabled):hover {
.seller-summary {
position: static;
max-height: none;
overflow: visible;
}
}