Merge branch 'dev'

This commit is contained in:
2026-11-02 15:03:01 +08:00
30 changed files with 1922 additions and 50 deletions
@@ -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>