feat(consignment): 支持复制寄卖模板配置与一二级分类折叠展示

This commit is contained in:
2026-11-09 16:00:09 +08:00
parent 6867431a72
commit 89a3d23874
10 changed files with 969 additions and 38 deletions
@@ -1,6 +1,7 @@
import {
ArrowDownOutlined,
ArrowUpOutlined,
CopyOutlined,
DeleteOutlined,
DownOutlined,
PlusOutlined,
@@ -34,6 +35,10 @@ import {
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
import PercentTable from '@/components/common/PercentTable'
import ConsignmentTemplateCopyModal, {
type ConsignmentTemplateCopyMode,
type ConsignmentTemplateCopySelection,
} from '@/components/admin/ConsignmentTemplateCopyModal'
import {
archiveAdminConsignmentProfile,
fetchAdminConsignmentProfiles,
@@ -580,7 +585,7 @@ const fieldCellStyle: CSSProperties = {
}
export default function ConsignmentConfigPanel() {
const { message } = App.useApp()
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
const [categoryKeyword, setCategoryKeyword] = useState('')
@@ -589,6 +594,7 @@ export default function ConsignmentConfigPanel() {
const [specDrafts, setSpecDrafts] = useState<ConsignmentSpecField[]>([])
const [intakeDrafts, setIntakeDrafts] = useState<ConsignmentIntakeField[]>([])
const [specSource, setSpecSource] = useState<'manual' | 'template'>('manual')
const [copyTemplateMode, setCopyTemplateMode] = useState<ConsignmentTemplateCopyMode | null>(null)
const [proposalOpen, setProposalOpen] = useState(false)
// 按数量(规格)保证金:弹窗里选字段 + 配每个选项的每件保证金
const [depositSpecOpen, setDepositSpecOpen] = useState(false)
@@ -608,6 +614,7 @@ export default function ConsignmentConfigPanel() {
() => new Map(profiles.map((profile) => [profile.categoryId, profile])),
[profiles],
)
const hasCopyableProfiles = profiles.some((profile) => profile.categoryId !== selectedCategoryId)
const normalizedKeyword = categoryKeyword.trim().toLowerCase()
const categoryTree = useMemo<CategoryNode[]>(() => {
@@ -756,6 +763,43 @@ export default function ConsignmentConfigPanel() {
setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS))
}
function handleCopyTemplate(selection: ConsignmentTemplateCopySelection) {
const mode = copyTemplateMode
if (!mode) return
const sourceLabel = [
selection.profile.categoryParentName,
selection.profile.categoryName || selection.profile.name,
]
.filter(Boolean)
.join(' / ')
const apply = () => {
if (mode === 'specs') {
setSpecDrafts(cloneSpecs(selection.config.specs))
setSpecSource(selection.config.specSource === 'template' ? 'template' : 'manual')
message.success(
`已复制「${sourceLabel}」的 ${selection.config.specs.length} 个规格字段,请保存草稿`,
)
} else {
const fields = selection.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
setIntakeDrafts(cloneIntakeFields(fields))
message.success(`已复制「${sourceLabel}」的 ${fields.length} 个联系方式字段,请保存草稿`)
}
setCopyTemplateMode(null)
}
const existingCount = mode === 'specs' ? specDrafts.length : intakeDrafts.length
if (existingCount > 0) {
modal.confirm({
title: '覆盖当前字段?',
content: `当前区块已有 ${existingCount} 个字段,复制模板会全部替换,未保存的修改将丢失。`,
okText: '覆盖并复制',
cancelText: '取消',
onOk: apply,
})
return
}
apply()
}
async function handleSave() {
if (!selectedCategoryId) return
const specs = specDrafts.map((spec) =>
@@ -1248,6 +1292,14 @@ export default function ConsignmentConfigPanel() {
description="初始为空;字段随配置版本发布,并写入提报与库存快照"
/>
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!hasCopyableProfiles}
onClick={() => setCopyTemplateMode('specs')}
>
复制模板配置
</Button>
<Button
size="small"
icon={<ThunderboltOutlined />}
@@ -1437,15 +1489,25 @@ export default function ConsignmentConfigPanel() {
title="联系方式与游戏凭据"
description="打手发布页按这里配置的字段动态渲染;字段为空时该模块不显示"
/>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined />}
onClick={addIntakeField}
>
添加字段
</Button>
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!hasCopyableProfiles}
onClick={() => setCopyTemplateMode('intake')}
>
复制模板配置
</Button>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined />}
onClick={addIntakeField}
>
添加字段
</Button>
</Space>
</div>
<Text type="secondary">
默认字段与旧发布页一致;手机号字段会校验 11
@@ -2051,6 +2113,16 @@ export default function ConsignmentConfigPanel() {
</div>
</Form>
<ConsignmentTemplateCopyModal
open={copyTemplateMode !== null}
mode={copyTemplateMode ?? 'specs'}
currentCategoryId={selectedCategoryId}
categories={categories}
profiles={profiles}
onCancel={() => setCopyTemplateMode(null)}
onApply={handleCopyTemplate}
/>
<Modal
title="从接单模板生成规格字段"
open={proposalOpen}
@@ -29,6 +29,7 @@ import {
Switch,
Tag,
Tooltip,
TreeSelect,
Typography,
} from 'antd'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
@@ -55,12 +56,14 @@ import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
import type {
ConsignmentInventory,
ConsignmentInventoryFilterCategory,
ConsignmentInventoryFilterField,
GiftEvidenceReviewStatus,
} from '@/types/consignment'
import { fetchAdminSession } from '@/services/admin/auth'
import { resolveAdminPermission } from '@/utils/admin-auth'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { buildConsignmentCategoryGroups } from '@/utils/consignment-category-tree'
import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence'
import {
CONSIGNMENT_SPEC_TAG_COLORS,
@@ -123,12 +126,24 @@ function resolveGiftEvidenceEmptyText(record: {
return '未上传赠送截图'
}
type InventoryFilterTreeNode = {
key: string
value: number
title: string
selectable: boolean
kind: 'group' | 'root' | 'child'
item?: ConsignmentInventoryFilterCategory
groupCount?: number
children?: InventoryFilterTreeNode[]
}
export default function ConsignmentInventoryPanel() {
const { message } = App.useApp()
// 筛选条件:分类与规格项全部来自「寄卖配置」已发布版本
const [status, setStatus] = useState<string>('in_stock')
const [categoryId, setCategoryId] = useState<number | null>(null)
const [categoryExpandedKeys, setCategoryExpandedKeys] = useState<Array<string | number>>([])
const [specFilters, setSpecFilters] = useState<Record<string, string>>({})
const [allowMatching, setAllowMatching] = useState<string>('')
const [inboundRange, setInboundRange] = useState<[string, string] | null>(null)
@@ -189,14 +204,68 @@ export default function ConsignmentInventoryPanel() {
() => filterOptionsQuery.data?.data.items || [],
[filterOptionsQuery.data],
)
const categoryOptions = useMemo(
() =>
filterCategories.map((item) => ({
// 一级分类直接显示分类名;二级分类带上父级,避免同名歧义
label: item.parentName ? `${item.parentName} / ${item.name}` : item.name,
const categoryTreeData = useMemo<InventoryFilterTreeNode[]>(() => {
const groups = buildConsignmentCategoryGroups({
items: filterCategories,
getCategoryId: (item) => item.categoryId,
getParentId: (item) => item.parentId,
getParentName: (item) => item.parentName,
getFallbackName: (item) => item.name,
})
return groups.flatMap((group) => {
const root = group.root
const rootLabel = root?.name || group.groupName || '未命名分类'
const childNodes: InventoryFilterTreeNode[] = group.children.map((item) => ({
key: `child-${item.categoryId}`,
value: item.categoryId,
})),
[filterCategories],
title: item.name,
selectable: true,
kind: 'child',
item,
}))
if (root && childNodes.length === 0) {
return [
{
key: `root-${root.categoryId}`,
value: root.categoryId,
title: rootLabel,
selectable: true,
kind: 'root',
item: root,
},
]
}
if (!root) {
if (childNodes.length === 0) return []
const groupNode: InventoryFilterTreeNode = {
key: `group-${group.groupCategoryId}`,
value: -group.groupCategoryId,
title: rootLabel,
selectable: false,
kind: 'group',
groupCount: childNodes.length,
children: childNodes,
}
return [groupNode]
}
return [
{
key: `group-${group.groupCategoryId}`,
value: root.categoryId,
title: rootLabel,
selectable: true,
kind: 'group',
item: root,
groupCount: childNodes.length,
children: childNodes,
},
]
})
}, [filterCategories])
const categoryGroupKeys = useMemo(
() => categoryTreeData.filter((node) => node.children?.length).map((node) => node.key),
[categoryTreeData],
)
// 默认「苏尔南手枪」;该分类未开启寄卖时回退到第一个已开启分类
@@ -893,14 +962,53 @@ export default function ConsignmentInventoryPanel() {
</Radio.Group>
{/* 寄卖分类:选项与规格筛选项全部来自该类别的已发布寄卖配置 */}
<Select
style={{ width: 200 }}
<TreeSelect<number, InventoryFilterTreeNode>
style={{ width: 240 }}
loading={filterOptionsQuery.isLoading}
value={categoryId ?? undefined}
placeholder="寄卖分类"
options={categoryOptions}
treeData={categoryTreeData}
treeDefaultExpandAll={false}
treeExpandedKeys={categoryExpandedKeys}
onTreeExpand={(keys) => setCategoryExpandedKeys(keys as Array<string | number>)}
showSearch={{
treeNodeFilterProp: 'title',
onSearch: (value) => setCategoryExpandedKeys(value.trim() ? categoryGroupKeys : []),
}}
onOpenChange={(open) => {
if (!open) setCategoryExpandedKeys([])
}}
popupMatchSelectWidth={false}
listHeight={320}
treeTitleRender={(node) => {
const item = node as InventoryFilterTreeNode
if (item.kind === 'group') {
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag color="blue">一级</Tag>
</span>
)
}
if (item.kind === 'root') {
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag color="blue">一级</Tag>
</span>
)
}
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag>二级</Tag>
{item.item?.source === 'inherited' ? <Tag color="blue">继承</Tag> : null}
</span>
)
}}
onChange={(value) => {
setCategoryId(Number(value))
if (typeof value !== 'number' || value <= 0) return
setCategoryId(value)
setSpecFilters({})
setPage(1)
}}
@@ -35,10 +35,11 @@ import {
Space,
Tabs,
Tag,
TreeSelect,
Typography,
Spin,
} from 'antd'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import ImageUpload from '@/components/files/ImageUpload'
import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
@@ -62,6 +63,7 @@ import type {
} from '@/types/consignment'
import type { UploadedFile } from '@/types/worker-platform'
import { DEFAULT_CONSIGNMENT_INTAKE_FIELDS, buildSpecItemName } from '@/utils/consignment-spec'
import { buildConsignmentCategoryGroups } from '@/utils/consignment-category-tree'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
import { resolveConsignmentDepositFen } from '@/utils/consignment-deposit'
@@ -119,12 +121,56 @@ const COOLDOWN_TICK_MS = 30_000
const RECORD_PAGE_SIZE = 10
const SEARCH_DEBOUNCE_MS = 300
/** 一级在前、其二级紧随其后;只有二级配置时按原顺序追加。 */
function orderWorkerProfiles(profiles: WorkerConsignmentProfile[]): WorkerConsignmentProfile[] {
const childrenByParent = new Map<number, WorkerConsignmentProfile[]>()
const roots: WorkerConsignmentProfile[] = []
for (const profile of profiles) {
if (profile.categoryParentId) {
const list = childrenByParent.get(profile.categoryParentId) || []
list.push(profile)
childrenByParent.set(profile.categoryParentId, list)
continue
}
roots.push(profile)
}
const ordered: WorkerConsignmentProfile[] = []
const included = new Set<number>()
for (const root of roots) {
ordered.push(root)
included.add(root.profileId)
for (const child of childrenByParent.get(root.categoryId) || []) {
if (included.has(child.profileId)) continue
ordered.push(child)
included.add(child.profileId)
}
}
for (const profile of profiles) {
if (included.has(profile.profileId)) continue
ordered.push(profile)
}
return ordered
}
type WorkerProfileTreeNode = {
key: string
value: number
title: string
selectable: boolean
kind: 'group' | 'root' | 'child'
profile?: WorkerConsignmentProfile
groupCount?: number
children?: WorkerProfileTreeNode[]
}
export default function WorkerConsignmentPage() {
const isMobile = useIsMobile()
const { message, modal } = App.useApp()
const [activeTab, setActiveTabState] = useState<'publish' | 'records'>(getInitialActiveTab)
/** 当前选中的寄卖配置(每个已发布配置一个 tab) */
const [selectedProfileId, setSelectedProfileId] = useState<number | null>(null)
const [profileExpandedKeys, setProfileExpandedKeys] = useState<Array<string | number>>([])
const [recordFilter, setRecordFilterState] = useState<RecordFilter>(getInitialRecordFilter)
/** 搜索框里的原始输入(立刻回显),防抖后才作为服务端查询条件 */
const [recordKeywordInput, setRecordKeywordInput] = useState('')
@@ -206,9 +252,78 @@ export default function WorkerConsignmentPage() {
queryFn: () => fetchWorkerConsignmentProfiles(),
})
const consignmentProfiles = consignmentProfilesQuery.data?.data.profiles || []
const orderedConsignmentProfiles = useMemo(
() => orderWorkerProfiles(consignmentProfiles),
[consignmentProfiles],
)
const profileTreeData = useMemo<WorkerProfileTreeNode[]>(() => {
const groups = buildConsignmentCategoryGroups({
items: orderedConsignmentProfiles,
getCategoryId: (profile) => profile.categoryId,
getParentId: (profile) => profile.categoryParentId,
getParentName: (profile) => profile.categoryParentName,
getFallbackName: (profile) => profile.tabTitle || profile.categoryName || profile.name,
})
return groups.flatMap((group) => {
const root = group.root
const rootLabel = root
? root.tabTitle || root.categoryName || root.name
: group.groupName || '未命名分类'
const childNodes: WorkerProfileTreeNode[] = group.children.map((child) => ({
key: `child-${child.profileId}`,
value: child.profileId,
title: child.tabTitle || child.categoryName || child.name,
selectable: true,
kind: 'child',
profile: child,
}))
if (root && childNodes.length === 0) {
return [
{
key: `root-${root.profileId}`,
value: root.profileId,
title: rootLabel,
selectable: true,
kind: 'root',
profile: root,
},
]
}
if (!root) {
if (childNodes.length === 0) return []
const groupNode: WorkerProfileTreeNode = {
key: `group-${group.groupCategoryId}`,
value: -group.groupCategoryId,
title: rootLabel,
selectable: false,
kind: 'group',
groupCount: childNodes.length,
children: childNodes,
}
return [groupNode]
}
return [
{
key: `group-${group.groupCategoryId}`,
value: root.profileId,
title: rootLabel,
selectable: true,
kind: 'group',
profile: root,
groupCount: childNodes.length,
children: childNodes,
},
]
})
}, [orderedConsignmentProfiles])
const profileGroupKeys = useMemo(
() => profileTreeData.filter((node) => node.children?.length).map((node) => node.key),
[profileTreeData],
)
const selectedProfile: WorkerConsignmentProfile | null =
consignmentProfiles.find((profile) => profile.profileId === selectedProfileId) ||
consignmentProfiles[0] ||
orderedConsignmentProfiles.find((profile) => profile.profileId === selectedProfileId) ||
orderedConsignmentProfiles[0] ||
null
const selectedSpecs: ConsignmentSpecField[] = selectedProfile?.config.specs ?? []
// 「允许修改寄售数量」关闭时:数量固定 1、输入框禁用,价格/押金一律按 1 件计算。
@@ -229,15 +344,19 @@ export default function WorkerConsignmentPage() {
const intakeFields: ConsignmentIntakeField[] =
selectedProfile?.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
useEffect(() => {
if (selectedProfileId === null && consignmentProfiles.length > 0) {
// 默认苏尔南手枪;没有该分类时退回第一个已发布配置。
if (selectedProfileId === null && orderedConsignmentProfiles.length > 0) {
// 默认苏尔南手枪一级配置;没有该分类时退回第一个一级配置,再退回第一条。
const preferred =
consignmentProfiles.find((profile) =>
/苏尔南|苏里南/.test(`${profile.categoryName}${profile.tabTitle}${profile.name}`),
) || consignmentProfiles[0]
orderedConsignmentProfiles.find(
(profile) =>
!profile.categoryParentId &&
/苏尔南|苏里南/.test(`${profile.categoryName}${profile.tabTitle}${profile.name}`),
) ||
orderedConsignmentProfiles.find((profile) => !profile.categoryParentId) ||
orderedConsignmentProfiles[0]
if (preferred) setSelectedProfileId(preferred.profileId)
}
}, [consignmentProfiles, selectedProfileId])
}, [orderedConsignmentProfiles, selectedProfileId])
// 发布寄卖必填「对接客服」:只取启用名单,默认选第一个
const consignmentContactsQuery = useQuery({
@@ -769,15 +888,51 @@ export default function WorkerConsignmentPage() {
}}
>
<Typography.Text strong>寄卖分类</Typography.Text>
<Select
<TreeSelect<number, WorkerProfileTreeNode>
value={selectedProfile?.profileId}
style={{ minWidth: 220 }}
placeholder={consignmentProfiles.length === 0 ? '暂无可发布的寄卖分类' : '请选择寄卖分类'}
options={consignmentProfiles.map((profile) => ({
value: profile.profileId,
label: profile.tabTitle || profile.categoryName || profile.name,
}))}
onChange={(value) => handleProfileChange(Number(value))}
treeData={profileTreeData}
treeDefaultExpandAll={false}
treeExpandedKeys={profileExpandedKeys}
onTreeExpand={(keys) => setProfileExpandedKeys(keys as Array<string | number>)}
showSearch={{
treeNodeFilterProp: 'title',
onSearch: (value) => setProfileExpandedKeys(value.trim() ? profileGroupKeys : []),
}}
onOpenChange={(open) => {
if (!open) setProfileExpandedKeys([])
}}
placeholder={profileTreeData.length === 0 ? '暂无可发布的寄卖分类' : '请选择寄卖分类'}
popupMatchSelectWidth={false}
listHeight={320}
treeTitleRender={(node) => {
const item = node as WorkerProfileTreeNode
if (item.kind === 'group') {
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag color="blue">一级</Tag>
</span>
)
}
if (item.kind === 'root') {
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag color="blue">一级</Tag>
</span>
)
}
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag>二级</Tag>
</span>
)
}}
onChange={(value) => {
if (typeof value === 'number' && value > 0) handleProfileChange(value)
}}
/>
{selectedProfile?.tabDescription ? (
<Typography.Text type="secondary">{selectedProfile.tabDescription}</Typography.Text>