Merge branch 'dev'
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Checkbox, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import { fetchPendingPistolMatches } from '@/services/admin/consignment'
|
||||
import type { PendingPistolMatchPair } from '@/types/consignment'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
function resolveScoreTag(pair: PendingPistolMatchPair) {
|
||||
if (pair.matchType === 'exact') return { color: 'green', label: '精确' }
|
||||
if (pair.matchType === 'conflict') return { color: 'default', label: '不匹配' }
|
||||
if (pair.score >= 90) return { color: 'blue', label: '非精确' }
|
||||
if (pair.score >= 70) return { color: 'orange', label: '非精确' }
|
||||
return { color: 'gold', label: '需核对' }
|
||||
}
|
||||
|
||||
export default function ConsignmentPendingMatchModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [includeConflicts, setIncludeConflicts] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-pending-pistol-matches', keyword, includeConflicts],
|
||||
queryFn: () => fetchPendingPistolMatches({ keyword, includeConflicts, limit: 300 }),
|
||||
enabled: open,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
})
|
||||
const data = query.data?.data
|
||||
const pairs = data?.pairs || []
|
||||
|
||||
const columns: TableColumnsType<PendingPistolMatchPair> = [
|
||||
{
|
||||
title: '匹配打分',
|
||||
key: 'score',
|
||||
width: 90,
|
||||
render: (_, record) => {
|
||||
const tag = resolveScoreTag(record)
|
||||
return (
|
||||
<Space direction="vertical" size={3} style={{ width: '100%' }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, lineHeight: 1 }}>{record.score}</span>
|
||||
<Tag color={tag.color} style={{ marginInlineEnd: 0 }}>
|
||||
{tag.label}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '订单信息',
|
||||
key: 'order',
|
||||
width: 280,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
工单
|
||||
</Text>
|
||||
{record.workOrderNo ? (
|
||||
<Text
|
||||
copyable={{ text: record.workOrderNo }}
|
||||
style={{ fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{record.workOrderNo}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
平台
|
||||
</Text>
|
||||
{record.platformOrderId ? (
|
||||
<Text
|
||||
copyable={{ text: record.platformOrderId }}
|
||||
style={{ fontSize: 11, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{record.platformOrderId}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品名',
|
||||
key: 'productName',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Text ellipsis={{ tooltip: record.productName }} style={{ maxWidth: '100%' }}>
|
||||
{record.productName || '-'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '寄售信息',
|
||||
key: 'consignment',
|
||||
width: 280,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
寄售
|
||||
</Text>
|
||||
<Text
|
||||
copyable={{ text: record.consignmentNo || record.inventoryNo }}
|
||||
style={{ fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{record.consignmentNo || record.inventoryNo || '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
{record.inventoryNo && record.inventoryNo !== record.consignmentNo ? (
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
仓库
|
||||
</Text>
|
||||
<Text
|
||||
copyable={{ text: record.inventoryNo }}
|
||||
style={{ fontSize: 11, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{record.inventoryNo}
|
||||
</Text>
|
||||
</Space>
|
||||
) : null}
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
入库 {record.inventoryInboundAt ? formatDateTime(record.inventoryInboundAt) : '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '寄售物品',
|
||||
key: 'itemName',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Text ellipsis={{ tooltip: record.itemName }} style={{ maxWidth: '100%' }}>
|
||||
{record.itemName || '-'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '匹配说明',
|
||||
key: 'reason',
|
||||
width: 300,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text style={{ fontSize: 12 }}>{record.reason || '-'}</Text>
|
||||
{record.conflicts.length > 0 ? (
|
||||
<Text type="danger" style={{ fontSize: 11 }}>
|
||||
{record.conflicts.join(';')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="匹配 · 待抢单手枪 × 在库现货"
|
||||
open={open}
|
||||
width="min(1320px, 94vw)"
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="只查询非拼单的待抢单手枪单 × 在库待匹配现货,不直接改单。"
|
||||
description="分数 0-100:系统 20 / 区服 20 / 系列 20 / 钻级 25 / 璀璨 15。非精确项仍需按现有规则确认;不按录单、入库时间先后过滤。"
|
||||
/>
|
||||
|
||||
<Space wrap size={12}>
|
||||
<Tag>待抢单 {data?.pendingOrderCount ?? 0} 单</Tag>
|
||||
<Tag>在库待匹配 {data?.inStockInventoryCount ?? 0} 件</Tag>
|
||||
<Tag color="blue">匹配组合 {data?.pairCount ?? 0} 条</Tag>
|
||||
<Tag color="green">精确 {data?.exactCount ?? 0}</Tag>
|
||||
<Tag color="orange">非精确 {data?.partialCount ?? 0}</Tag>
|
||||
{includeConflicts ? <Tag>不匹配 {data?.conflictCount ?? 0}</Tag> : null}
|
||||
</Space>
|
||||
|
||||
<Space wrap size={12}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索平台号 / 工单号 / 商品名 / 寄售单号 / 寄售物品"
|
||||
style={{ width: 420 }}
|
||||
onSearch={(value) => setKeyword(value.trim())}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={includeConflicts}
|
||||
onChange={(event) => setIncludeConflicts(event.target.checked)}
|
||||
>
|
||||
显示 0 分不匹配项
|
||||
</Checkbox>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={query.isFetching}
|
||||
onClick={() => void query.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{query.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="读取匹配结果失败"
|
||||
description={query.error instanceof Error ? query.error.message : '请稍后重试'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data?.truncated ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`结果较多,仅展示前 ${pairs.length} 条,请用搜索缩小范围`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PercentTable<PendingPistolMatchPair>
|
||||
rowKey={(record) => `${record.workOrderId}-${record.inventoryId}`}
|
||||
size="small"
|
||||
loading={query.isFetching}
|
||||
dataSource={pairs}
|
||||
columns={columns}
|
||||
scroll={{ y: 460 }}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description={
|
||||
includeConflicts
|
||||
? '没有可展示的配对'
|
||||
: '没有有分匹配;可勾选「显示 0 分不匹配项」查看冲突'
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ReloadOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
addAdminMockCustomShop,
|
||||
@@ -22,14 +23,18 @@ import {
|
||||
fetchAdminMockCustomShops,
|
||||
fetchAdminScheduledJobsConfig,
|
||||
fetchAdminWorkerAnnouncement,
|
||||
fetchAdminWorkerProductNameSuffix,
|
||||
fetchAdminWorkerProductNameSuffixSources,
|
||||
renameAdminMockCustomShop,
|
||||
runAdminScheduledJob,
|
||||
saveAdminWorkerAnnouncement,
|
||||
saveAdminWorkerProductNameSuffix,
|
||||
saveAdminScheduledJobsConfig,
|
||||
} from '@/services/admin'
|
||||
import type { AdminScheduledJobItem, AdminScheduledJobRuntimeState } from '@/types/admin'
|
||||
import type { WorkerAnnouncementConfig } from '@/types/worker-platform'
|
||||
import type { WorkerAnnouncementConfig, WorkerProductNameSuffixItem } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { normalizeProductNameSuffixText } from '@/utils/product-name-source-suffix'
|
||||
|
||||
import HallConfigPanel from './HallConfigPanel'
|
||||
|
||||
@@ -41,6 +46,7 @@ export default function AnnouncementPanel() {
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<AnnouncementCard />
|
||||
<ProductNameSourceSuffixCard />
|
||||
<DepositUnfreezeCard />
|
||||
<MockCustomShopsCard />
|
||||
<HallConfigPanel />
|
||||
@@ -241,6 +247,233 @@ function AnnouncementCard() {
|
||||
)
|
||||
}
|
||||
|
||||
type ProductNameSuffixDraft = WorkerProductNameSuffixItem & { rowKey: string }
|
||||
|
||||
let productNameSuffixRowSeed = 0
|
||||
|
||||
function createProductNameSuffixRow(
|
||||
source = '',
|
||||
suffix = '',
|
||||
enabled = false,
|
||||
note = '',
|
||||
): ProductNameSuffixDraft {
|
||||
productNameSuffixRowSeed += 1
|
||||
return {
|
||||
rowKey: `product-name-suffix-${productNameSuffixRowSeed}`,
|
||||
source,
|
||||
suffix,
|
||||
enabled,
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
/** 商品名来源后缀:按工单来源在商品名后追加「(文字)」,只影响展示,不改库里的商品名。 */
|
||||
function ProductNameSourceSuffixCard() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [items, setItems] = useState<ProductNameSuffixDraft[]>([])
|
||||
const suffixQuery = useQuery({
|
||||
queryKey: ['admin-worker-product-name-suffix'],
|
||||
queryFn: fetchAdminWorkerProductNameSuffix,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: ['admin-worker-product-name-suffix-sources'],
|
||||
queryFn: fetchAdminWorkerProductNameSuffixSources,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const remoteItems = suffixQuery.data?.data.items || []
|
||||
setItems(
|
||||
remoteItems.map((item) =>
|
||||
createProductNameSuffixRow(item.source, item.suffix, item.enabled, item.note),
|
||||
),
|
||||
)
|
||||
}, [suffixQuery.data])
|
||||
|
||||
// 下拉选项来自订单来源字段;已配置但暂时没有工单的来源也保留,避免编辑时丢值。
|
||||
const sourceOptions = useMemo(() => {
|
||||
const values = new Map<string, string>()
|
||||
for (const item of items) {
|
||||
const text = String(item.source || '').trim()
|
||||
if (!text) continue
|
||||
const key = text.toLowerCase()
|
||||
if (!values.has(key)) values.set(key, text)
|
||||
}
|
||||
for (const value of sourcesQuery.data?.data.items || []) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) continue
|
||||
const key = text.toLowerCase()
|
||||
if (!values.has(key)) values.set(key, text)
|
||||
}
|
||||
return [...values.values()]
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((value) => ({ value, label: value }))
|
||||
}, [items, sourcesQuery.data])
|
||||
|
||||
function patchRow(rowKey: string, patch: Partial<WorkerProductNameSuffixItem>) {
|
||||
setItems((prev) => prev.map((item) => (item.rowKey === rowKey ? { ...item, ...patch } : item)))
|
||||
}
|
||||
|
||||
function toggleRow(rowKey: string, enabled: boolean) {
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.rowKey === rowKey) return { ...item, enabled }
|
||||
// 同一时间只能启用一个来源:启用当前行时自动停用其他行。
|
||||
return enabled ? { ...item, enabled: false } : item
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removeRow(rowKey: string) {
|
||||
setItems((prev) => prev.filter((item) => item.rowKey !== rowKey))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const normalized: WorkerProductNameSuffixItem[] = []
|
||||
const seenSources = new Set<string>()
|
||||
let hasEnabledItem = false
|
||||
for (const [index, item] of items.entries()) {
|
||||
const source = String(item.source || '').trim()
|
||||
const suffix = normalizeProductNameSuffixText(item.suffix)
|
||||
if (!source && !suffix) continue
|
||||
if (!source || !suffix) {
|
||||
message.error(`第 ${index + 1} 行:来源和后缀都要填写`)
|
||||
return
|
||||
}
|
||||
const normalizedSource = source.toLowerCase()
|
||||
if (seenSources.has(normalizedSource)) {
|
||||
message.error(`来源「${source}」重复,一行只能配置一个来源`)
|
||||
return
|
||||
}
|
||||
seenSources.add(normalizedSource)
|
||||
const enabled = item.enabled === true && !hasEnabledItem
|
||||
if (enabled) hasEnabledItem = true
|
||||
normalized.push({
|
||||
source,
|
||||
suffix,
|
||||
enabled,
|
||||
note: String(item.note || '').trim(),
|
||||
})
|
||||
}
|
||||
if (normalized.length > 50) {
|
||||
message.error('最多配置 50 条来源后缀')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await saveAdminWorkerProductNameSuffix({ items: normalized })
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-worker-product-name-suffix'] })
|
||||
message.success('商品名来源后缀已保存')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存商品名来源后缀失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="商品名来源后缀"
|
||||
bordered={false}
|
||||
extra={
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void save()}>
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
来源下拉数据取自工单的「来源」字段;选中来源后填写后缀,启用后展示为「商品名(后缀)」。
|
||||
同一时间只能启用一个来源,启用新来源会自动停用其他行;未启用或没有匹配配置的来源不追加。
|
||||
备注仅后台可见。只影响列表/详情的展示,不会改写工单里的商品名。
|
||||
</Typography.Paragraph>
|
||||
{sourcesQuery.isError ? (
|
||||
<Typography.Text type="danger" style={{ display: 'block', marginBottom: 8 }}>
|
||||
读取工单来源选项失败,已配置的来源仍可编辑;请稍后刷新重试。
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{items.length === 0 ? (
|
||||
<Typography.Text type="secondary">
|
||||
还没有配置,所有商品名保持原样;点击「添加一行」开始配置。
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{items.map((item, index) => {
|
||||
const suffix = normalizeProductNameSuffixText(item.suffix)
|
||||
return (
|
||||
<Space
|
||||
key={item.rowKey}
|
||||
size={8}
|
||||
wrap
|
||||
align="center"
|
||||
style={{ width: '100%', padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}
|
||||
>
|
||||
<Switch
|
||||
checked={item.enabled === true}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
onChange={(checked) => toggleRow(item.rowKey, checked)}
|
||||
/>
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
loading={sourcesQuery.isLoading}
|
||||
optionFilterProp="label"
|
||||
style={{ width: 220 }}
|
||||
placeholder="选择来源"
|
||||
options={sourceOptions}
|
||||
value={item.source || undefined}
|
||||
onChange={(value) => patchRow(item.rowKey, { source: String(value || '') })}
|
||||
/>
|
||||
<Input
|
||||
addonBefore="后缀"
|
||||
style={{ width: 200 }}
|
||||
maxLength={34}
|
||||
placeholder="如 91卡券"
|
||||
value={item.suffix}
|
||||
onChange={(event) => patchRow(item.rowKey, { suffix: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
style={{ width: 220 }}
|
||||
maxLength={200}
|
||||
placeholder="备注(选填)"
|
||||
value={item.note}
|
||||
onChange={(event) => patchRow(item.rowKey, { note: event.target.value })}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{suffix
|
||||
? item.enabled
|
||||
? `示例:商品名(${suffix})`
|
||||
: '未启用,不追加'
|
||||
: '未填写后缀时不追加'}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={`删除第 ${index + 1} 行`}
|
||||
onClick={() => removeRow(item.rowKey)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
disabled={items.length >= 50}
|
||||
onClick={() => setItems((prev) => [...prev, createProductNameSuffixRow()])}
|
||||
>
|
||||
添加一行
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function MockCustomShopsCard() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { EyeOutlined, ReloadOutlined, RollbackOutlined, StopOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
RollbackOutlined,
|
||||
StopOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
@@ -27,6 +33,7 @@ import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
|
||||
import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal'
|
||||
import {
|
||||
archiveAdminConsignmentInventory,
|
||||
fetchAdminConsignmentContacts,
|
||||
@@ -149,6 +156,7 @@ export default function ConsignmentInventoryPanel() {
|
||||
const [viewingItem, setViewingItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [returnItem, setReturnItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [manualMatchItem, setManualMatchItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [pendingMatchOpen, setPendingMatchOpen] = useState(false)
|
||||
const [returnReason, setReturnReason] = useState<string>('')
|
||||
|
||||
const [editForm] = Form.useForm()
|
||||
@@ -836,6 +844,10 @@ export default function ConsignmentInventoryPanel() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button icon={<ThunderboltOutlined />} onClick={() => setPendingMatchOpen(true)}>
|
||||
一键查询待匹配订单
|
||||
</Button>
|
||||
|
||||
<Button icon={<ReloadOutlined />} onClick={refresh}>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -1305,6 +1317,12 @@ export default function ConsignmentInventoryPanel() {
|
||||
}
|
||||
onMatched={() => void inventoriesQuery.refetch()}
|
||||
/>
|
||||
|
||||
{/* 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货 */}
|
||||
<ConsignmentPendingMatchModal
|
||||
open={pendingMatchOpen}
|
||||
onClose={() => setPendingMatchOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react'
|
||||
|
||||
import { updateAdminWorkOrder, updateAdminWorkOrderPaymentAmount } from '@/services/admin'
|
||||
import type { WorkOrder } from '@/types/worker-platform'
|
||||
import { appendProductNameSourceSuffix } from '@/utils/product-name-source-suffix'
|
||||
|
||||
import { formatMoney } from './shared'
|
||||
import {
|
||||
@@ -15,10 +16,13 @@ import {
|
||||
/** 商品名:单击看详情,双击原地修改(仅未接走工单) */
|
||||
export function ProductNameEditableCell({
|
||||
order,
|
||||
sourceSuffix = '',
|
||||
onOpenDetail,
|
||||
onSaved,
|
||||
}: {
|
||||
order: WorkOrder
|
||||
/** 按来源匹配到的后缀(不含括号):只用于展示,编辑态仍显示原始商品名 */
|
||||
sourceSuffix?: string
|
||||
onOpenDetail: () => void
|
||||
onSaved: () => Promise<void> | void
|
||||
}) {
|
||||
@@ -69,13 +73,15 @@ export function ProductNameEditableCell({
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
const displayName =
|
||||
appendProductNameSourceSuffix(order.productName, sourceSuffix) || '未命名商品'
|
||||
return (
|
||||
<Typography.Link
|
||||
title={editable ? '双击修改商品名' : undefined}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{order.productName || '未命名商品'}
|
||||
{displayName}
|
||||
</Typography.Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
fetchAdminWorkOrderSharing,
|
||||
fetchAdminWorkOrderShopOptions,
|
||||
fetchAdminWorkerLevels,
|
||||
fetchAdminWorkerProductNameSuffix,
|
||||
fetchAdminWorkerUsers,
|
||||
fetchAdminWorkOrders,
|
||||
markAdminWorkOrderProblem,
|
||||
@@ -107,6 +108,10 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi
|
||||
import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { buildCollectMaterialLinkUrl } from '@/utils/collect-link'
|
||||
import {
|
||||
applyProductNameSourceSuffix,
|
||||
resolveProductNameSourceSuffix,
|
||||
} from '@/utils/product-name-source-suffix'
|
||||
import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders'
|
||||
import {
|
||||
DeadlineCountdown,
|
||||
@@ -285,6 +290,13 @@ export default function WorkOrdersPanel() {
|
||||
queryFn: fetchAdminSession,
|
||||
retry: false,
|
||||
})
|
||||
// 商品名来源后缀(杂项配置维护):只影响列表/详情展示,编辑与复制仍用原始商品名。
|
||||
const productNameSuffixQuery = useQuery({
|
||||
queryKey: ['admin-worker-product-name-suffix'],
|
||||
queryFn: fetchAdminWorkerProductNameSuffix,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const productNameSuffixItems = productNameSuffixQuery.data?.data.items || []
|
||||
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
|
||||
const canReturnToHall = sessionPermissions
|
||||
? sessionPermissions.includes('worker_order.return_to_hall')
|
||||
@@ -436,6 +448,13 @@ export default function WorkOrdersPanel() {
|
||||
|
||||
// 详情抽屉里的寄售履约信息(有寄售履约才展示寄卖单号)
|
||||
const detailConsignment = detailOrder ? getConsignmentFulfillment(detailOrder) : null
|
||||
const detailProductName = detailOrder
|
||||
? applyProductNameSourceSuffix(
|
||||
detailOrder.productName,
|
||||
asRecord(detailOrder.material?.source).provider,
|
||||
productNameSuffixItems,
|
||||
)
|
||||
: ''
|
||||
|
||||
// 仅单独筛选「待验收」时提供批量验收入口(多选状态下不启用勾选,避免误操作)
|
||||
const isPendingAcceptanceOnly = statuses.length === 1 && statuses[0] === 'pending_acceptance'
|
||||
@@ -1676,11 +1695,13 @@ export default function WorkOrdersPanel() {
|
||||
const source = asRecord(row.material?.source)
|
||||
const shopName = String(source.shopName || '').trim()
|
||||
const provider = String(source.provider || '').trim()
|
||||
const sourceSuffix = resolveProductNameSourceSuffix(productNameSuffixItems, provider)
|
||||
const consignment = getConsignmentFulfillment(row)
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<ProductNameEditableCell
|
||||
order={row}
|
||||
sourceSuffix={sourceSuffix}
|
||||
onOpenDetail={() => openDetailDrawer(row)}
|
||||
onSaved={refreshAll}
|
||||
/>
|
||||
@@ -2360,7 +2381,7 @@ export default function WorkOrdersPanel() {
|
||||
className="worker-order-detail-drawer"
|
||||
title={
|
||||
detailOrder
|
||||
? `${detailOrder.productName || '工单详情'} · ${getDisplayOrderNo(detailOrder)}`
|
||||
? `${detailProductName || '工单详情'} · ${getDisplayOrderNo(detailOrder)}`
|
||||
: '工单详情'
|
||||
}
|
||||
width={860}
|
||||
@@ -2408,7 +2429,7 @@ export default function WorkOrdersPanel() {
|
||||
<Descriptions.Item label="订单子项 ID">
|
||||
{detailOrder.orderItemId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品">{detailOrder.productName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="商品">{detailProductName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">
|
||||
{detailOrder.categoryName || '-'}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -33,7 +33,15 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { memo, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
|
||||
@@ -44,6 +52,7 @@ import {
|
||||
fetchWorkerHallLeaderboard,
|
||||
fetchWorkerMyOrders,
|
||||
fetchWorkerMyOrderOverview,
|
||||
fetchWorkerProductNameSuffix,
|
||||
fetchWorkerProfile,
|
||||
grabWorkerOrder,
|
||||
joinWorkerSharingOrder,
|
||||
@@ -67,6 +76,10 @@ import {
|
||||
resolveHallRemainingQuantity,
|
||||
resolveHallRewardAmount,
|
||||
} from '@/utils/worker-product-name'
|
||||
import {
|
||||
applyProductNameSourceSuffix,
|
||||
type ProductNameSourceSuffixItem,
|
||||
} from '@/utils/product-name-source-suffix'
|
||||
import { formatDateTime, formatPublishedElapsedTime } from '@/utils/date-time'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
@@ -161,6 +174,28 @@ export default function WorkerHallPage() {
|
||||
const orders = ordersQuery.data?.pages.flatMap((page) => page.data.items) || []
|
||||
const firstPage = ordersQuery.data?.pages[0]?.data
|
||||
|
||||
// 商品名来源后缀(后台「杂项配置」维护):只影响展示,不改商品名本身。
|
||||
const productNameSuffixQuery = useQuery({
|
||||
queryKey: ['worker-product-name-suffix'],
|
||||
queryFn: fetchWorkerProductNameSuffix,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
})
|
||||
const productNameSuffixItems = useMemo(
|
||||
() => productNameSuffixQuery.data?.data.items || [],
|
||||
[productNameSuffixQuery.data],
|
||||
)
|
||||
const resolveDisplayProductName = useCallback(
|
||||
(order: WorkOrder) =>
|
||||
applyProductNameSourceSuffix(
|
||||
resolveHallProductName(order),
|
||||
order.sourceProvider,
|
||||
productNameSuffixItems,
|
||||
) || '未命名商品',
|
||||
[productNameSuffixItems],
|
||||
)
|
||||
|
||||
const [categoryMeta, setCategoryMeta] = useState<{
|
||||
categories: WorkCategory[]
|
||||
showSubcategories: boolean
|
||||
@@ -337,7 +372,7 @@ export default function WorkerHallPage() {
|
||||
modal.confirm({
|
||||
title: '确认抢单',
|
||||
icon: <QuestionCircleOutlined />,
|
||||
content: `确认抢下「${resolveHallProductName(order)}」?奖励 ${formatMoney(
|
||||
content: `确认抢下「${resolveDisplayProductName(order)}」?奖励 ${formatMoney(
|
||||
resolveHallRewardAmount(order),
|
||||
)}`,
|
||||
okText: '确认抢单',
|
||||
@@ -345,7 +380,7 @@ export default function WorkerHallPage() {
|
||||
onOk: () => submitGrab(order),
|
||||
})
|
||||
},
|
||||
[grabConfirmEnabled, modal, submitGrab],
|
||||
[grabConfirmEnabled, modal, submitGrab, resolveDisplayProductName],
|
||||
)
|
||||
|
||||
const openSharingModal = useCallback((order: WorkOrder) => {
|
||||
@@ -679,6 +714,7 @@ export default function WorkerHallPage() {
|
||||
remainingSlots={remainingSlots}
|
||||
isInternalWorker={isInternalWorker}
|
||||
grabbing={grabbingId === order.workOrderId}
|
||||
productNameSuffixItems={productNameSuffixItems}
|
||||
onGrab={requestGrab}
|
||||
onOpenSharing={openSharingModal}
|
||||
/>
|
||||
@@ -1017,6 +1053,7 @@ export default function WorkerHallPage() {
|
||||
remainingSlots={remainingSlots}
|
||||
isInternalWorker={isInternalWorker}
|
||||
grabbing={grabbingId === order.workOrderId}
|
||||
productNameSuffixItems={productNameSuffixItems}
|
||||
onGrab={requestGrab}
|
||||
onOpenSharing={openSharingModal}
|
||||
/>
|
||||
@@ -1031,7 +1068,7 @@ export default function WorkerHallPage() {
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={`拼单参与 · ${sharingOrder ? resolveHallProductName(sharingOrder) : ''}`}
|
||||
title={`拼单参与 · ${sharingOrder ? resolveDisplayProductName(sharingOrder) : ''}`}
|
||||
width={isMobile ? '92%' : 520}
|
||||
open={Boolean(sharingOrder)}
|
||||
confirmLoading={submittingSharing}
|
||||
@@ -1169,6 +1206,8 @@ type HallCardProps = {
|
||||
remainingSlots: number | null
|
||||
isInternalWorker: boolean
|
||||
grabbing: boolean
|
||||
/** 后台「杂项配置」维护的来源后缀;仅展示追加,不影响复制内容 */
|
||||
productNameSuffixItems: ProductNameSourceSuffixItem[]
|
||||
onGrab: (order: WorkOrder) => void
|
||||
onOpenSharing: (order: WorkOrder) => void
|
||||
}
|
||||
@@ -1202,11 +1241,17 @@ const HallDesktopCard = memo(function HallDesktopCard({
|
||||
remainingSlots,
|
||||
isInternalWorker,
|
||||
grabbing,
|
||||
productNameSuffixItems,
|
||||
onGrab,
|
||||
onOpenSharing,
|
||||
}: HallCardProps) {
|
||||
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
||||
const productName = resolveHallProductName(order) || '未命名商品'
|
||||
const productName =
|
||||
applyProductNameSourceSuffix(
|
||||
resolveHallProductName(order),
|
||||
order.sourceProvider,
|
||||
productNameSuffixItems,
|
||||
) || '未命名商品'
|
||||
const sharingRemaining = resolveHallRemainingQuantity(order)
|
||||
const depositAmount = resolveHallDepositAmount(order)
|
||||
const rewardAmount = resolveHallRewardAmount(order)
|
||||
@@ -1326,11 +1371,17 @@ const HallMobileCard = memo(function HallMobileCard({
|
||||
remainingSlots,
|
||||
isInternalWorker,
|
||||
grabbing,
|
||||
productNameSuffixItems,
|
||||
onGrab,
|
||||
onOpenSharing,
|
||||
}: HallCardProps) {
|
||||
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
||||
const productName = resolveHallProductName(order) || '未命名商品'
|
||||
const productName =
|
||||
applyProductNameSourceSuffix(
|
||||
resolveHallProductName(order),
|
||||
order.sourceProvider,
|
||||
productNameSuffixItems,
|
||||
) || '未命名商品'
|
||||
const sharingRemaining = resolveHallRemainingQuantity(order)
|
||||
const myShareSummary = resolveHallMyShareSummary(order)
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/
|
||||
import type { WorkOrder } from '@/types/worker-platform'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display'
|
||||
import {
|
||||
applyProductNameSourceSuffix,
|
||||
type ProductNameSourceSuffixItem,
|
||||
} from '@/utils/product-name-source-suffix'
|
||||
|
||||
import {
|
||||
canEditAcceptanceImages,
|
||||
@@ -35,6 +39,7 @@ export function WorkerOrderDetailDrawer({
|
||||
isMobile,
|
||||
order,
|
||||
orderTimelineVisible,
|
||||
productNameSuffixItems,
|
||||
onClose,
|
||||
onOpenAcceptance,
|
||||
onOpenGiftMaterial,
|
||||
@@ -44,6 +49,8 @@ export function WorkerOrderDetailDrawer({
|
||||
isMobile: boolean
|
||||
order: WorkOrder | null
|
||||
orderTimelineVisible: boolean
|
||||
/** 后台「杂项配置」维护的来源后缀;仅展示追加,不影响复制内容 */
|
||||
productNameSuffixItems: ProductNameSourceSuffixItem[]
|
||||
onClose: () => void
|
||||
onOpenAcceptance: (order: WorkOrder) => void
|
||||
onOpenGiftMaterial: (order: WorkOrder) => void
|
||||
@@ -51,6 +58,13 @@ export function WorkerOrderDetailDrawer({
|
||||
onOpenFeedback: (order: WorkOrder) => void
|
||||
}) {
|
||||
const isTimedOut = Boolean(order && (order.status === 'open' || order.timeoutNotice?.isTimedOut))
|
||||
const displayProductName = order
|
||||
? applyProductNameSourceSuffix(
|
||||
order.productName,
|
||||
order.sourceProvider,
|
||||
productNameSuffixItems,
|
||||
) || '-'
|
||||
: '-'
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -121,7 +135,7 @@ export function WorkerOrderDetailDrawer({
|
||||
</div>
|
||||
<Card title="基础信息" size="small">
|
||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||
<Descriptions.Item label="商品">{order.productName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="商品">{displayProductName}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">{order.categoryName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="工单编号">{getDisplayOrderNo(order)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
|
||||
@@ -20,6 +20,10 @@ import type {
|
||||
WorkOrder,
|
||||
} from '@/types/worker-platform'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import {
|
||||
applyProductNameSourceSuffix,
|
||||
type ProductNameSourceSuffixItem,
|
||||
} from '@/utils/product-name-source-suffix'
|
||||
|
||||
import type { WorkerOrderStatusOption } from './WorkerOrderDesktopList'
|
||||
import {
|
||||
@@ -56,6 +60,7 @@ export function WorkerOrderMobileList({
|
||||
pagination,
|
||||
page,
|
||||
pageSize,
|
||||
productNameSuffixItems,
|
||||
renderShareTable,
|
||||
loading,
|
||||
searching,
|
||||
@@ -85,6 +90,8 @@ export function WorkerOrderMobileList({
|
||||
pagination: PaginationData | undefined
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 后台「杂项配置」维护的来源后缀;仅展示追加,不影响复制内容 */
|
||||
productNameSuffixItems: ProductNameSourceSuffixItem[]
|
||||
renderShareTable?: (order: WorkOrder) => ReactNode
|
||||
loading: boolean
|
||||
searching: boolean
|
||||
@@ -178,6 +185,7 @@ export function WorkerOrderMobileList({
|
||||
<WorkerOrderMobileCard
|
||||
key={order.workOrderId}
|
||||
order={order}
|
||||
productNameSuffixItems={productNameSuffixItems}
|
||||
renderShareTable={renderShareTable}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenAcceptance={onOpenAcceptance}
|
||||
@@ -209,6 +217,7 @@ export function WorkerOrderMobileList({
|
||||
|
||||
function WorkerOrderMobileCard({
|
||||
order,
|
||||
productNameSuffixItems,
|
||||
renderShareTable,
|
||||
onOpenDetail,
|
||||
onOpenAcceptance,
|
||||
@@ -251,6 +260,9 @@ function WorkerOrderMobileCard({
|
||||
const workerNote = String(order.workerNote || '').trim()
|
||||
const statusDisplay = getOrderStatusDisplay(order)
|
||||
const flagTags = getOrderFlagTags(order)
|
||||
const displayProductName =
|
||||
applyProductNameSourceSuffix(order.productName, order.sourceProvider, productNameSuffixItems) ||
|
||||
'未命名商品'
|
||||
|
||||
return (
|
||||
<article className="worker-order-mobile-card">
|
||||
@@ -308,7 +320,7 @@ function WorkerOrderMobileCard({
|
||||
</div>
|
||||
|
||||
<div className="worker-order-mobile-main-row">
|
||||
<div className="worker-order-mobile-product-title">{order.productName || '未命名商品'}</div>
|
||||
<div className="worker-order-mobile-product-title">{displayProductName}</div>
|
||||
{order.status === 'open' || order.timeoutNotice?.isTimedOut ? (
|
||||
<div className="worker-order-mobile-price-badge">
|
||||
<span className="worker-order-mobile-price-label" style={{ color: '#cf1322' }}>
|
||||
|
||||
@@ -12,6 +12,10 @@ import { DeadlineCountdown } from '@/components/DeadlineCountdown'
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import type { WorkOrder } from '@/types/worker-platform'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import {
|
||||
applyProductNameSourceSuffix,
|
||||
type ProductNameSourceSuffixItem,
|
||||
} from '@/utils/product-name-source-suffix'
|
||||
|
||||
import {
|
||||
canEditAcceptanceImages,
|
||||
@@ -40,6 +44,7 @@ import {
|
||||
type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default'
|
||||
|
||||
export function buildWorkerOrderTableColumns({
|
||||
productNameSuffixItems,
|
||||
onOpenDetail,
|
||||
onOpenAcceptance,
|
||||
onOpenGiftMaterial,
|
||||
@@ -50,6 +55,7 @@ export function buildWorkerOrderTableColumns({
|
||||
onCopyOrderInfo,
|
||||
onCopyMaterialImages,
|
||||
}: {
|
||||
productNameSuffixItems: ProductNameSourceSuffixItem[]
|
||||
onOpenDetail: (order: WorkOrder) => void
|
||||
onOpenAcceptance: (order: WorkOrder) => void
|
||||
onOpenGiftMaterial: (order: WorkOrder) => void
|
||||
@@ -64,41 +70,49 @@ export function buildWorkerOrderTableColumns({
|
||||
{
|
||||
title: '订单信息',
|
||||
width: 270,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Space size={4} align="center">
|
||||
{row.isUnread ? (
|
||||
<Tag color="purple" className="worker-order-unread-tag">
|
||||
新
|
||||
</Tag>
|
||||
) : null}
|
||||
<Typography.Link
|
||||
strong
|
||||
onClick={() => onOpenDetail(row)}
|
||||
title={row.productName || '未命名商品'}
|
||||
ellipsis
|
||||
render: (_, row) => {
|
||||
const displayProductName =
|
||||
applyProductNameSourceSuffix(
|
||||
row.productName,
|
||||
row.sourceProvider,
|
||||
productNameSuffixItems,
|
||||
) || '未命名商品'
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<Space size={4} align="center">
|
||||
{row.isUnread ? (
|
||||
<Tag color="purple" className="worker-order-unread-tag">
|
||||
新
|
||||
</Tag>
|
||||
) : null}
|
||||
<Typography.Link
|
||||
strong
|
||||
onClick={() => onOpenDetail(row)}
|
||||
title={displayProductName}
|
||||
ellipsis
|
||||
>
|
||||
{displayProductName}
|
||||
</Typography.Link>
|
||||
</Space>
|
||||
<Space size={[4, 4]} wrap>
|
||||
{row.categoryName ? <Tag color="blue">{row.categoryName}</Tag> : null}
|
||||
{row.myShares?.length || row.myShare ? (
|
||||
<Tag color="purple">拼单 {row.myShares?.length || 1} 次</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12 }}
|
||||
ellipsis={{ tooltip: getDisplayOrderNo(row) }}
|
||||
>
|
||||
{row.productName || '未命名商品'}
|
||||
</Typography.Link>
|
||||
</Space>
|
||||
<Space size={[4, 4]} wrap>
|
||||
{row.categoryName ? <Tag color="blue">{row.categoryName}</Tag> : null}
|
||||
{row.myShares?.length || row.myShare ? (
|
||||
<Tag color="purple">拼单 {row.myShares?.length || 1} 次</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12 }}
|
||||
ellipsis={{ tooltip: getDisplayOrderNo(row) }}
|
||||
>
|
||||
{getDisplayOrderNo(row)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
接单:{formatDateTime(row.assignedAt)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
{getDisplayOrderNo(row)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
接单:{formatDateTime(row.assignedAt)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
fetchWorkerAfterSalesCases,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
fetchWorkerOrderFeedbacks,
|
||||
fetchWorkerMyOrderOverview,
|
||||
fetchWorkerMyOrders,
|
||||
fetchWorkerProductNameSuffix,
|
||||
fetchWorkerProfile,
|
||||
markWorkerOrderRead,
|
||||
remindWorkerAcceptance,
|
||||
@@ -206,6 +207,18 @@ export default function WorkerOrdersPage() {
|
||||
const pagination = ordersQuery.data?.data.pagination
|
||||
const orders = ordersQuery.data?.data.items || []
|
||||
|
||||
// 商品名来源后缀(后台「杂项配置」维护):仅展示追加,复制资料仍用原始商品名。
|
||||
const productNameSuffixQuery = useQuery({
|
||||
queryKey: ['worker-product-name-suffix'],
|
||||
queryFn: fetchWorkerProductNameSuffix,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
const productNameSuffixItems = useMemo(
|
||||
() => productNameSuffixQuery.data?.data.items || [],
|
||||
[productNameSuffixQuery.data],
|
||||
)
|
||||
|
||||
// 流转记录对打手是否可见(后台配置开关,默认关闭)
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['worker-profile'],
|
||||
@@ -626,6 +639,7 @@ export default function WorkerOrdersPage() {
|
||||
}
|
||||
|
||||
const columns = buildWorkerOrderTableColumns({
|
||||
productNameSuffixItems,
|
||||
onOpenDetail: openOrderDetail,
|
||||
onOpenAcceptance: openAcceptanceModal,
|
||||
onOpenGiftMaterial: openGiftMaterialModal,
|
||||
@@ -686,6 +700,7 @@ export default function WorkerOrdersPage() {
|
||||
pagination={pagination}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
productNameSuffixItems={productNameSuffixItems}
|
||||
renderShareTable={renderShareTable}
|
||||
loading={ordersQuery.isLoading}
|
||||
searching={ordersQuery.isFetching}
|
||||
@@ -767,6 +782,7 @@ export default function WorkerOrdersPage() {
|
||||
isMobile={isMobile}
|
||||
order={detailOrder}
|
||||
orderTimelineVisible={orderTimelineVisible}
|
||||
productNameSuffixItems={productNameSuffixItems}
|
||||
onClose={() => setDetailOrder(null)}
|
||||
onOpenAcceptance={openAcceptanceModal}
|
||||
onOpenGiftMaterial={openGiftMaterialModal}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ConsignmentStatsSummary,
|
||||
ConsignmentSubmission,
|
||||
InventoryMatchableOrders,
|
||||
PendingPistolMatchResult,
|
||||
WorkOrderInventoryMatchCheck,
|
||||
WorkOrderInventoryMatchOptions,
|
||||
} from '@/types/consignment'
|
||||
@@ -158,6 +159,22 @@ export function fetchInventoryMatchableOrders(
|
||||
)
|
||||
}
|
||||
|
||||
/** 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货,按细粒度评分排序。 */
|
||||
export function fetchPendingPistolMatches(params?: {
|
||||
keyword?: string
|
||||
includeConflicts?: boolean
|
||||
limit?: number
|
||||
}) {
|
||||
return apiGet<PendingPistolMatchResult>(
|
||||
'/api/v1/admin/worker-platform/consignment/pending-pistol-matches',
|
||||
{
|
||||
keyword: params?.keyword?.trim() || undefined,
|
||||
includeConflicts: params?.includeConflicts ? 'true' : undefined,
|
||||
limit: params?.limit,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 履约/指派返回的电子凭证核销结果:ok=false 时必须提示运营人工核销,不能报成功 */
|
||||
export type WorkOrderVoucherConsumeResult = {
|
||||
ok: boolean
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
WorkerPlatformNotificationConfig,
|
||||
WorkerProductMatchConfig,
|
||||
WorkerAnnouncementConfig,
|
||||
WorkerProductNameSuffixConfig,
|
||||
WorkerLevel,
|
||||
WorkerListResponse,
|
||||
WorkerUserListResponse,
|
||||
@@ -109,6 +110,22 @@ export function saveAdminWorkerAnnouncement(payload: WorkerAnnouncementConfig) {
|
||||
return apiPost<WorkerAnnouncementConfig>('/api/v1/admin/worker-platform/announcement', payload)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkerProductNameSuffix() {
|
||||
return apiGet<WorkerProductNameSuffixConfig>('/api/v1/admin/worker-platform/product-name-suffix')
|
||||
}
|
||||
|
||||
/** 来源下拉选项:来自工单资料里的 source.provider */
|
||||
export function fetchAdminWorkerProductNameSuffixSources() {
|
||||
return apiGet<{ items: string[] }>('/api/v1/admin/worker-platform/product-name-suffix/sources')
|
||||
}
|
||||
|
||||
export function saveAdminWorkerProductNameSuffix(payload: WorkerProductNameSuffixConfig) {
|
||||
return apiPost<WorkerProductNameSuffixConfig>(
|
||||
'/api/v1/admin/worker-platform/product-name-suffix',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkerCancelRequests(
|
||||
status: 'pending' | 'approved' | 'rejected' = 'pending',
|
||||
) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
WorkerCancelRequest,
|
||||
WorkerCancelRequestRisk,
|
||||
WorkerAnnouncementConfig,
|
||||
WorkerProductNameSuffixConfig,
|
||||
WorkerProductNameSuffixDisplayConfig,
|
||||
WorkerAcceptanceConfig,
|
||||
WorkerAcceptanceVideo,
|
||||
WorkerOrderFeedback,
|
||||
@@ -152,6 +154,11 @@ export function fetchWorkerAnnouncement() {
|
||||
return apiGet<WorkerAnnouncementConfig>('/api/v1/worker/announcement')
|
||||
}
|
||||
|
||||
/** 商品名来源后缀映射(后台「杂项配置」维护,只返回已启用项),打手端列表/详情/大厅展示用。 */
|
||||
export function fetchWorkerProductNameSuffix() {
|
||||
return apiGet<WorkerProductNameSuffixDisplayConfig>('/api/v1/worker/product-name-suffix')
|
||||
}
|
||||
|
||||
/** 验收视频配置(后台可改):时长上限、目标高度、码率等,压缩前读取。 */
|
||||
export function fetchWorkerAcceptanceConfig() {
|
||||
return apiGet<WorkerAcceptanceConfig>('/api/v1/worker/acceptance/config')
|
||||
|
||||
@@ -240,3 +240,32 @@ export type WorkOrderInventoryMatchCheck = {
|
||||
partialCount: number
|
||||
matches: ConsignmentMatchResult[]
|
||||
}
|
||||
|
||||
/** 后台「寄售仓库 → 匹配」:待抢单手枪 × 在库待匹配现货的配对明细 */
|
||||
export type PendingPistolMatchPair = {
|
||||
workOrderId: number
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
workOrderCreatedAt: string | null
|
||||
inventoryId: number
|
||||
consignmentNo: string
|
||||
inventoryNo: string
|
||||
itemName: string
|
||||
inventoryInboundAt: string | null
|
||||
matchType: 'exact' | 'partial' | 'conflict'
|
||||
score: number
|
||||
reason: string
|
||||
conflicts: string[]
|
||||
}
|
||||
|
||||
export type PendingPistolMatchResult = {
|
||||
pendingOrderCount: number
|
||||
inStockInventoryCount: number
|
||||
pairCount: number
|
||||
exactCount: number
|
||||
partialCount: number
|
||||
conflictCount: number
|
||||
truncated: boolean
|
||||
pairs: PendingPistolMatchPair[]
|
||||
}
|
||||
|
||||
@@ -325,6 +325,26 @@ export type WorkerAnnouncementConfig = {
|
||||
content: string
|
||||
}
|
||||
|
||||
export type WorkerProductNameSuffixItem = {
|
||||
/** 来源标识(精确匹配、不区分大小写),如 91kaquan */
|
||||
source: string
|
||||
/** 括号内的后缀文字,如 91卡券 */
|
||||
suffix: string
|
||||
/** 是否启用;同一时间只允许启用一条 */
|
||||
enabled: boolean
|
||||
/** 后台备注(仅后台配置页可见) */
|
||||
note: string
|
||||
}
|
||||
|
||||
export type WorkerProductNameSuffixConfig = {
|
||||
items: WorkerProductNameSuffixItem[]
|
||||
}
|
||||
|
||||
/** 打手端只拿启用的来源 -> 后缀映射 */
|
||||
export type WorkerProductNameSuffixDisplayConfig = {
|
||||
items: Array<Pick<WorkerProductNameSuffixItem, 'source' | 'suffix'>>
|
||||
}
|
||||
|
||||
export type WorkerHallConfig = {
|
||||
maxVisibleOrders: number
|
||||
showSubcategories?: boolean
|
||||
@@ -622,6 +642,8 @@ export type WorkOrder = {
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
/** 来源平台标识(如 91kaquan),仅打手端返回;用于按后台配置追加展示后缀 */
|
||||
sourceProvider?: string
|
||||
categoryId?: number | null
|
||||
categoryName: string
|
||||
parentCategoryId?: number | null
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
export type ProductNameSourceSuffixItem = {
|
||||
/** 来源标识,如 91kaquan */
|
||||
source: string
|
||||
/** 括号内的后缀文字,如 91卡券 */
|
||||
suffix: string
|
||||
/** 未启用(false)的配置不参与展示;缺省按启用处理,兼容打手端精简数据 */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/** 后缀只保留括号内的文字:填了外层全角/半角括号时自动剥离。 */
|
||||
export function normalizeProductNameSuffixText(value: unknown): string {
|
||||
let text = String(value ?? '').trim()
|
||||
if (!text) return ''
|
||||
const wrappedFullWidth = text.startsWith('(') && text.endsWith(')')
|
||||
const wrappedHalfWidth = text.startsWith('(') && text.endsWith(')')
|
||||
if ((wrappedFullWidth || wrappedHalfWidth) && text.length >= 2) {
|
||||
text = text.slice(1, -1).trim()
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* 按来源取后缀:来源精确匹配、不区分大小写,空来源或空后缀不命中。
|
||||
* 同一来源配置多条时以第一条有效配置为准。
|
||||
*/
|
||||
export function resolveProductNameSourceSuffix(
|
||||
items: ProductNameSourceSuffixItem[] | null | undefined,
|
||||
provider: unknown,
|
||||
): string {
|
||||
const normalizedProvider = String(provider ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!normalizedProvider) return ''
|
||||
|
||||
for (const item of items ?? []) {
|
||||
if (item?.enabled === false) continue
|
||||
const source = String(item?.source ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!source || source !== normalizedProvider) continue
|
||||
const suffix = normalizeProductNameSuffixText(item?.suffix)
|
||||
if (suffix) return suffix
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 在商品名后追加「(后缀)」;没有后缀时原样返回。
|
||||
* 商品名已以相同后缀结尾(全角/半角括号均可)时不重复追加。
|
||||
*/
|
||||
export function appendProductNameSourceSuffix(productName: unknown, suffix: unknown): string {
|
||||
const name = String(productName ?? '').trim()
|
||||
const normalizedSuffix = normalizeProductNameSuffixText(suffix)
|
||||
if (!name || !normalizedSuffix) return name
|
||||
|
||||
if (name.endsWith(`(${normalizedSuffix})`) || name.endsWith(`(${normalizedSuffix})`)) {
|
||||
return name
|
||||
}
|
||||
return `${name}(${normalizedSuffix})`
|
||||
}
|
||||
|
||||
/** 展示用:按来源匹配后一次性完成后缀追加;无匹配时返回原商品名。 */
|
||||
export function applyProductNameSourceSuffix(
|
||||
productName: unknown,
|
||||
provider: unknown,
|
||||
items: ProductNameSourceSuffixItem[] | null | undefined,
|
||||
): string {
|
||||
return appendProductNameSourceSuffix(productName, resolveProductNameSourceSuffix(items, provider))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
appendProductNameSourceSuffix,
|
||||
applyProductNameSourceSuffix,
|
||||
normalizeProductNameSuffixText,
|
||||
resolveProductNameSourceSuffix,
|
||||
} from '../src/utils/product-name-source-suffix.ts'
|
||||
|
||||
const items = [
|
||||
{ source: '91kaquan', suffix: '91卡券' },
|
||||
{ source: 'douyin', suffix: '抖音' },
|
||||
]
|
||||
|
||||
test('来源后缀:空配置不追加', () => {
|
||||
assert.equal(applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', []), '苏尔南手枪')
|
||||
assert.equal(applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', null), '苏尔南手枪')
|
||||
})
|
||||
|
||||
test('来源后缀:精确匹配后追加全角括号后缀', () => {
|
||||
assert.equal(
|
||||
applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', items),
|
||||
'苏尔南手枪(91卡券)',
|
||||
)
|
||||
})
|
||||
|
||||
test('来源后缀:来源匹配不区分大小写并去除首尾空格', () => {
|
||||
assert.equal(resolveProductNameSourceSuffix(items, ' 91KAQUAN '), '91卡券')
|
||||
assert.equal(
|
||||
applyProductNameSourceSuffix('苏尔南手枪', ' 91Kaquan ', items),
|
||||
'苏尔南手枪(91卡券)',
|
||||
)
|
||||
})
|
||||
|
||||
test('来源后缀:没有匹配来源时不追加', () => {
|
||||
assert.equal(applyProductNameSourceSuffix('苏尔南手枪', 'kuaishou', items), '苏尔南手枪')
|
||||
assert.equal(applyProductNameSourceSuffix('苏尔南手枪', '', items), '苏尔南手枪')
|
||||
})
|
||||
|
||||
test('来源后缀:商品名已带相同后缀时不重复追加', () => {
|
||||
assert.equal(
|
||||
appendProductNameSourceSuffix('苏尔南手枪(91卡券)', '91卡券'),
|
||||
'苏尔南手枪(91卡券)',
|
||||
)
|
||||
assert.equal(appendProductNameSourceSuffix('苏尔南手枪(91卡券)', '91卡券'), '苏尔南手枪(91卡券)')
|
||||
})
|
||||
|
||||
test('来源后缀:后缀为空或商品名为空时不做处理', () => {
|
||||
assert.equal(appendProductNameSourceSuffix('苏尔南手枪', ''), '苏尔南手枪')
|
||||
assert.equal(appendProductNameSourceSuffix('', '91卡券'), '')
|
||||
assert.equal(applyProductNameSourceSuffix('', '91kaquan', items), '')
|
||||
})
|
||||
|
||||
test('来源后缀:配置里的外层括号会被剥离后再拼接', () => {
|
||||
assert.equal(normalizeProductNameSuffixText('(91卡券)'), '91卡券')
|
||||
assert.equal(normalizeProductNameSuffixText('(91卡券)'), '91卡券')
|
||||
assert.equal(
|
||||
applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', [
|
||||
{ source: '91kaquan', suffix: '(91卡券)' },
|
||||
]),
|
||||
'苏尔南手枪(91卡券)',
|
||||
)
|
||||
})
|
||||
|
||||
test('来源后缀:停用的配置不参与展示,启用后才追加', () => {
|
||||
assert.equal(
|
||||
applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', [
|
||||
{ source: '91kaquan', suffix: '91卡券', enabled: false },
|
||||
]),
|
||||
'苏尔南手枪',
|
||||
)
|
||||
assert.equal(
|
||||
applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', [
|
||||
{ source: '91kaquan', suffix: '91卡券', enabled: true },
|
||||
]),
|
||||
'苏尔南手枪(91卡券)',
|
||||
)
|
||||
})
|
||||
|
||||
test('来源后缀:同一来源多条配置时取第一条有效后缀', () => {
|
||||
assert.equal(
|
||||
resolveProductNameSourceSuffix(
|
||||
[
|
||||
{ source: '91kaquan', suffix: '第一' },
|
||||
{ source: '91kaquan', suffix: '第二' },
|
||||
],
|
||||
'91kaquan',
|
||||
),
|
||||
'第一',
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user