feat(worker-platform): 寄卖价格改由接单模板只读定价,我的寄卖支持服务端搜索与分页

1. 寄售价不再由卖家填写:打手端改为只读展示模板价(与履约押金同款只读框),服务端在发布时按同一套规则复算金额,保证展示口径与落库口径一致,直连接口也无法随意定价;
2. 模板缺档按「最接近的已配置档位」报价、距离相同取更低档:后台只配了 1/3/7 钻时,号主出 2 钻报 1 钻价、出 6 钻报 7 钻价,不再因为中间档没配价而拿不到价格;
3. 我的寄卖列表把分组、关键字、翻页全部下沉到 SQL:新增 listWorkerConsignmentPage 用 UNION ALL 合成「提报单行 + 无提报单库存兜底行」,CASE 推导 待审核/履约中/已完成 分组,返回当前分组 total 与三个分组的角标计数;
4. 关键字覆盖寄卖单号、规格名、系统/区服/系列/钻级、卖家角色名与数字 ID、联系方式、买家昵称与游戏 ID(读取 work_orders.material_json.collect.fields 的多种历史键名);
5. 前端搜索框 300ms 防抖后才请求服务端、换分组或换关键字自动回到第 1 页、列表底部增加分页器,并移除原来固定 pageSize 200 的隐形截断;
6. 新增仓库层与匹配层单测,其中包含 SQL 分组 CASE 与 domain/consignment-item-stage.ts 的 group 映射交叉断言,防止两处状态机漂移;后端 516 项、前端 66 项测试全部通过。
This commit is contained in:
yml2213
2026-10-01 11:07:28 +08:00
parent ef491660e7
commit 1636616ec1
10 changed files with 853 additions and 265 deletions
@@ -6,6 +6,7 @@ import {
DollarCircleOutlined,
HistoryOutlined,
InboxOutlined,
SearchOutlined,
PictureOutlined,
PlusOutlined,
ReloadOutlined,
@@ -27,6 +28,7 @@ import {
Input,
InputNumber,
Modal,
Pagination,
Radio,
Row,
Space,
@@ -116,12 +118,19 @@ function getInitialRecordFilter(): RecordFilter {
/** 赠送冷却倒计时刷新间隔:30 秒足够展示,不需要秒级跳动 */
const COOLDOWN_TICK_MS = 30_000
/** 「我的寄卖」每页条数:桌面上够看又不至于一屏拉太长 */
const RECORD_PAGE_SIZE = 10
const SEARCH_DEBOUNCE_MS = 300
export default function WorkerConsignmentPage() {
const isMobile = useIsMobile()
const { message, modal } = App.useApp()
const [activeTab, setActiveTabState] = useState<'publish' | 'records'>(getInitialActiveTab)
const [recordFilter, setRecordFilterState] = useState<RecordFilter>(getInitialRecordFilter)
/** 搜索框里的原始输入(立刻回显),防抖后才作为服务端查询条件 */
const [recordKeywordInput, setRecordKeywordInput] = useState('')
const [recordKeyword, setRecordKeyword] = useState('')
const [recordPage, setRecordPage] = useState(1)
const setActiveTab = (tab: 'publish' | 'records') => {
setActiveTabState(tab)
@@ -159,12 +168,13 @@ export default function WorkerConsignmentPage() {
const [cooldownNow, setCooldownNow] = useState(() => Date.now())
const [form] = Form.useForm()
const currentDiamondLevel = Form.useWatch('diamondLevel', form)
// 建议价:规格选齐后从「接单模板」带出(安卓+暗潮+1钻+0璀 → ¥120);卖家手动改价则只提示不覆盖
const [priceSuggestion, setPriceSuggestion] = useState<{
fen: number
matchedCount: number
} | null>(null)
const priceTouchedRef = useRef(false)
// 寄售价:完全由后台「接单模板」决定(规格选齐后自动带出),卖家只读不可改。
const [priceState, setPriceState] = useState<
| { status: 'idle' }
| { status: 'loading' }
| { status: 'matched'; fen: number }
| { status: 'empty' }
>({ status: 'idle' })
const priceFetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastPriceQueryKeyRef = useRef('')
@@ -179,8 +189,14 @@ export default function WorkerConsignmentPage() {
const financeConfig = profileQuery.data?.data.financeConfig
const myConsignmentsQuery = useQuery({
queryKey: ['worker-my-consignments'],
queryFn: () => fetchWorkerConsignments(),
queryKey: ['worker-my-consignments', recordFilter, recordKeyword, recordPage],
queryFn: () =>
fetchWorkerConsignments({
group: recordFilter,
keyword: recordKeyword,
page: recordPage,
pageSize: RECORD_PAGE_SIZE,
}),
})
// 切换标签页时自动刷新最新数据
@@ -191,9 +207,15 @@ export default function WorkerConsignmentPage() {
void queryClient.invalidateQueries({ queryKey: ['worker-profile'] })
}, [activeTab, queryClient])
// 后端已按「一件货一行」聚合,前端不再合并提报单与库存
// 后端已按「一件货一行」聚合,分组/搜索/翻页都在服务端完成
const records = myConsignmentsQuery.data?.data.items || []
const stageCounts = myConsignmentsQuery.data?.data.stageCounts || {}
const recordTotal = Number(myConsignmentsQuery.data?.data.total || 0)
const groupCounts = myConsignmentsQuery.data?.data.counts || {
all: 0,
reviewing: 0,
active: 0,
finished: 0,
}
const publishDepositFen = Number(
myConsignmentsQuery.data?.data.publishDepositFen ??
financeConfig?.consignmentPublishDepositFen ??
@@ -209,29 +231,6 @@ export default function WorkerConsignmentPage() {
})
const [contactOpen, setContactOpen] = useState(false)
// 阶段 → 分组由后端下发(item.group),这里只把它映射到筛选角标计数上
const groupCounts = useMemo(() => {
const stageGroups = new Map<string, string>()
records.forEach((item) => stageGroups.set(item.stage, item.group))
const counts: Record<'reviewing' | 'active' | 'finished', number> = {
reviewing: 0,
active: 0,
finished: 0,
}
Object.entries(stageCounts).forEach(([stage, count]) => {
const group = stageGroups.get(stage)
if (group === 'reviewing' || group === 'active' || group === 'finished') {
counts[group] += Number(count) || 0
}
})
return counts
}, [records, stageCounts])
const filteredRecords = useMemo(() => {
if (recordFilter === 'all') return records
return records.filter((item) => item.group === recordFilter)
}, [records, recordFilter])
const hasCooldownItem = records.some(
(item) => item.stage === 'gift_cooldown' && Boolean(item.giftAvailableAt),
)
@@ -243,6 +242,19 @@ export default function WorkerConsignmentPage() {
return () => window.clearInterval(timer)
}, [hasCooldownItem])
// 搜索防抖:输入停下 300ms 才去请求服务端,避免每敲一个字都打接口
useEffect(() => {
const timer = window.setTimeout(() => {
setRecordKeyword(recordKeywordInput.trim())
}, SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [recordKeywordInput])
// 换分组或换关键字都要回到第 1 页,否则会停在一个不存在的页码上
useEffect(() => {
setRecordPage(1)
}, [recordFilter, recordKeyword])
useEffect(() => {
if (worker?.phone) {
form.setFieldsValue({ sellerPhone: worker.phone })
@@ -276,10 +288,6 @@ export default function WorkerConsignmentPage() {
}, [profileQuery.isLoading, modal, publishDepositYuan])
function handleFormValuesChange(changed: Record<string, unknown>) {
// 卖家手动改过价格后,本规格内不再自动覆盖(换规格会重新带出)
if (changed && changed.askingPrice !== undefined) {
priceTouchedRef.current = true
}
// 钻级改变时:璀璨数不能超过钻石等级(如 5钻调成 2钻,原 5璀自动收敛到 2璀)
if (changed && changed.diamondLevel !== undefined) {
const newDia = Number(changed.diamondLevel || 0)
@@ -303,44 +311,50 @@ export default function WorkerConsignmentPage() {
})
}
// 规格选齐(系统+系列+钻级+璀璨)后从「接单模板」带出建议价:安卓+暗潮+1钻+0璀 → 模板价 ¥120。
// 防抖 + 同规格去重 + 过期响应丢弃;建议价拿不到不影响发布(静默)。
if (priceFetchTimer.current) clearTimeout(priceFetchTimer.current)
if (sys && ser && dia != null && Number(dia) > 0 && bri != null) {
const queryKey = `${sys}|${ser}|${dia}|${bri}`
if (lastPriceQueryKeyRef.current !== queryKey) {
lastPriceQueryKeyRef.current = queryKey
// 换了规格:重新允许自动带价(上一规格的手改标记不再拦新规格)
priceTouchedRef.current = false
// 规格选齐(系统+系列+钻级+璀璨)后从「接单模板」带出寄售价:安卓+暗潮+1钻+0璀 → 模板价 ¥120。
// 价格只读、卖家不用填;只有规格真的变了才重新匹配(改联系方式等字段不会触发)。
const specKey =
sys && ser && dia != null && Number(dia) > 0 && bri != null
? `${sys}|${ser}|${dia}|${bri}`
: ''
if (!specKey) {
if (lastPriceQueryKeyRef.current !== '') {
lastPriceQueryKeyRef.current = ''
setPriceState({ status: 'idle' })
form.setFieldsValue({ askingPrice: undefined })
}
priceFetchTimer.current = setTimeout(() => {
void (async () => {
try {
const res = await fetchWorkerConsignmentPriceSuggestion({
system: String(sys),
series: String(ser),
diamondLevel: Number(dia),
brillianceCount: Number(bri),
})
if (lastPriceQueryKeyRef.current !== queryKey) return
const fen = Number(res.data?.suggestedPriceFen || 0)
if (fen > 0) {
setPriceSuggestion({ fen, matchedCount: Number(res.data?.matchedCount || 0) })
if (!priceTouchedRef.current) {
form.setFieldsValue({ askingPrice: fen / 100 })
}
} else {
setPriceSuggestion(null)
}
} catch {
// 建议价是锦上添花,失败不打扰卖家
}
})()
}, 300)
} else {
lastPriceQueryKeyRef.current = ''
setPriceSuggestion(null)
return
}
if (specKey === lastPriceQueryKeyRef.current) return
lastPriceQueryKeyRef.current = specKey
setPriceState({ status: 'loading' })
// 换规格先把旧价清掉,避免用上一档价格提交
form.setFieldsValue({ askingPrice: undefined })
if (priceFetchTimer.current) clearTimeout(priceFetchTimer.current)
priceFetchTimer.current = setTimeout(() => {
void (async () => {
try {
const res = await fetchWorkerConsignmentPriceSuggestion({
system: String(sys),
series: String(ser),
diamondLevel: Number(dia),
brillianceCount: Number(bri),
})
if (lastPriceQueryKeyRef.current !== specKey) return
const fen = Number(res.data?.suggestedPriceFen || 0)
if (fen > 0) {
setPriceState({ status: 'matched', fen })
form.setFieldsValue({ askingPrice: fen / 100 })
} else {
setPriceState({ status: 'empty' })
}
} catch {
if (lastPriceQueryKeyRef.current === specKey) {
setPriceState({ status: 'empty' })
}
}
})()
}, 300)
}
async function handlePublishSubmit(values: Record<string, any>) {
@@ -360,7 +374,16 @@ export default function WorkerConsignmentPage() {
return
}
const askingPriceFen = Math.round(Number(values.askingPrice || 0) * 100)
// 寄售价由后台接单模板决定,卖家不用填;没匹配到模板价就不让发,避免产出没有定价依据的单
if (priceState.status === 'loading') {
message.warning('正在匹配接单模板价,请稍候')
return
}
if (priceState.status !== 'matched') {
message.error('该规格暂无接单模板价,请联系客服补充模板后再提报')
return
}
const askingPriceFen = priceState.fen
if (askingPriceFen < 100) {
message.error('寄售价格不能少于1元')
return
@@ -393,8 +416,7 @@ export default function WorkerConsignmentPage() {
message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`)
form.resetFields()
setPriceSuggestion(null)
priceTouchedRef.current = false
setPriceState({ status: 'idle' })
lastPriceQueryKeyRef.current = ''
setScreenshots([])
await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()])
@@ -493,7 +515,7 @@ export default function WorkerConsignmentPage() {
}
}
const recordTotal = filteredRecords.length
const recordKeywordTrimmed = recordKeywordInput.trim()
return (
<section className="page-stack worker-consignment-page">
@@ -739,24 +761,27 @@ export default function WorkerConsignmentPage() {
<div className="worker-consignment-price-grid">
<Form.Item
name="askingPrice"
label="寄售价格"
rules={[{ required: true, message: '请填写寄售价格' }]}
extra={
priceSuggestion
? `已按接单模板匹配 ${priceSuggestion.matchedCount} 条规则,建议价 ¥${(priceSuggestion.fen / 100).toFixed(0)}(可修改)`
: undefined
}
tooltip="由后台「接单模板」按系统/系列/钻级/璀璨自动匹配,卖家无需填写;需要调整请联系客服。"
className="worker-consignment-spec-card is-price"
>
<InputNumber
size="large"
min={1}
step={1}
style={{ width: '100%' }}
placeholder="请输入"
addonBefore="¥"
/>
<div className="worker-consignment-price-readonly">
{priceState.status === 'loading' ? (
<span className="is-pending">
<Spin size="small" /> 匹配模板价…
</span>
) : priceState.status === 'matched' ? (
<span className="is-amount">¥{(priceState.fen / 100).toFixed(0)}</span>
) : priceState.status === 'empty' ? (
<span className="is-empty">该规格暂无模板价,请联系客服</span>
) : (
<span className="is-empty">选完规格后自动带出</span>
)}
</div>
</Form.Item>
{/* 价格只读展示,但仍要随表单提交给后端(后端也会按模板复算) */}
<Form.Item name="askingPrice" hidden>
<InputNumber />
</Form.Item>
<Form.Item
label="履约押金"
@@ -869,8 +894,8 @@ export default function WorkerConsignmentPage() {
label: (
<span>
<HistoryOutlined /> 我的寄卖
{records.length > 0 ? (
<span className="worker-order-tab-count">{records.length}</span>
{groupCounts.all > 0 ? (
<span className="worker-order-tab-count">{groupCounts.all}</span>
) : null}
</span>
),
@@ -879,7 +904,7 @@ export default function WorkerConsignmentPage() {
<div className="worker-consignment-filter-row">
{(
[
{ key: 'all', label: '全部', count: records.length },
{ key: 'all', label: '全部', count: groupCounts.all },
{ key: 'reviewing', label: '待审核', count: groupCounts.reviewing },
{ key: 'active', label: '履约中', count: groupCounts.active },
{ key: 'finished', label: '已完成', count: groupCounts.finished },
@@ -895,28 +920,39 @@ export default function WorkerConsignmentPage() {
{item.count > 0 ? ` (${item.count})` : ''}
</Button>
))}
<Input
className="worker-consignment-search"
allowClear
prefix={<SearchOutlined />}
placeholder="搜单号 / 角色名 / 区服 / 买家"
value={recordKeywordInput}
onChange={(event) => setRecordKeywordInput(event.target.value)}
/>
</div>
{myConsignmentsQuery.isLoading ? (
<Typography.Text type="secondary">正在加载寄卖记录…</Typography.Text>
) : groupCounts.all === 0 && !recordKeywordTrimmed ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无寄卖记录,去发布一单苏尔南吧"
>
<Button type="primary" onClick={() => setActiveTab('publish')}>
发布寄卖
</Button>
</Empty>
) : recordTotal === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
recordFilter === 'all'
? '暂无寄卖记录,去发布一单苏尔南吧'
recordKeywordTrimmed
? `没有找到与「${recordKeywordTrimmed}」匹配的寄卖记录`
: '当前筛选下没有寄卖记录'
}
>
{recordFilter === 'all' ? (
<Button type="primary" onClick={() => setActiveTab('publish')}>
发布寄卖
</Button>
) : null}
</Empty>
/>
) : (
<div className="worker-consignment-record-list">
{filteredRecords.map((record) => (
{records.map((record) => (
<ConsignmentRecordCard
key={record.consignmentNo}
record={record}
@@ -927,6 +963,20 @@ export default function WorkerConsignmentPage() {
))}
</div>
)}
{recordTotal > RECORD_PAGE_SIZE ? (
<div className="worker-consignment-pagination">
<Pagination
size={isMobile ? 'small' : undefined}
current={recordPage}
pageSize={RECORD_PAGE_SIZE}
total={recordTotal}
showSizeChanger={false}
showLessItems={isMobile}
onChange={(nextPage) => setRecordPage(nextPage)}
/>
</div>
) : null}
</Space>
),
},
+25 -4
View File
@@ -458,19 +458,40 @@ export type WorkerConsignmentRecord = {
export type { ConsignmentBuyerInfo, ConsignmentSellerInfo } from '@/types/consignment'
export type WorkerConsignmentListCounts = {
all: number
reviewing: number
active: number
finished: number
}
export type WorkerConsignmentListResponse = {
publishDepositFen: number
items: WorkerConsignmentRecord[]
/** 各阶段件数,用于筛选 tab 角标 */
stageCounts: Record<string, number>
/** 当前分组 + 关键字下的总条数(服务端分页用) */
total: number
page: number
pageSize: number
/** 各分组条数,用于筛选角标;受关键字影响、不受当前分组影响 */
counts: WorkerConsignmentListCounts
}
export type WorkerConsignmentListParams = {
group?: 'all' | 'reviewing' | 'active' | 'finished'
keyword?: string
page?: number
pageSize?: number
}
export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayload) {
return apiPost<WorkerConsignmentItem>('/api/v1/worker/consignment/publish', payload)
}
export function fetchWorkerConsignments() {
return apiGet<WorkerConsignmentListResponse>('/api/v1/worker/consignment/my-items')
export function fetchWorkerConsignments(params: WorkerConsignmentListParams = {}) {
return apiGet<WorkerConsignmentListResponse>(
'/api/v1/worker/consignment/my-items',
params as Record<string, unknown>,
)
}
/**
+51
View File
@@ -4520,6 +4520,37 @@ body {
font-weight: 600;
}
/* 寄售价只读展示:做成和「履约押金」同款的只读框,避免卡片里只剩一个数字显得空 */
.worker-consignment-price-readonly {
display: flex;
flex-wrap: wrap;
align-items: center;
align-content: center;
gap: 2px 8px;
min-height: 40px;
padding: 0 11px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f1f5f9;
}
.worker-consignment-price-readonly .is-amount {
font-size: 16px;
font-weight: 700;
color: #ff6a00;
line-height: 1.2;
}
.worker-consignment-price-readonly .is-pending,
.worker-consignment-price-readonly .is-empty {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #94a3b8;
line-height: 1.4;
}
.worker-consignment-actions {
display: flex;
flex-wrap: wrap;
@@ -4557,12 +4588,32 @@ body {
gap: 8px;
}
/* 「我的寄卖」搜索:单号/角色名/区服/买家,靠右不与筛选按钮抢位 */
.worker-consignment-search {
margin-left: auto;
width: 260px;
}
@media (max-width: 640px) {
.worker-consignment-search {
margin-left: 0;
width: 100%;
}
}
.worker-consignment-record-list {
display: flex;
flex-direction: column;
gap: 10px;
}
/* 「我的寄卖」服务端分页 */
.worker-consignment-pagination {
display: flex;
justify-content: center;
padding-top: 4px;
}
.worker-consignment-record-card {
display: flex;
justify-content: space-between;