diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx
index 14256b11..e277e826 100644
--- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx
+++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx
@@ -2,14 +2,18 @@ import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
+ DownOutlined,
PlusOutlined,
ReloadOutlined,
+ RightOutlined,
SaveOutlined,
+ SearchOutlined,
SendOutlined,
StopOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
+ Alert,
App,
Button,
Card,
@@ -22,6 +26,7 @@ import {
Space,
Switch,
Tag,
+ Tooltip,
Typography,
} from 'antd'
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
@@ -33,6 +38,7 @@ import {
saveAdminConsignmentProfile,
} from '@/services/admin/consignment'
import type {
+ AdminConsignmentProfilesResponse,
ConsignmentProfile,
ConsignmentSpecField,
ConsignmentSpecFieldType,
@@ -42,6 +48,9 @@ import { SPEC_FIELD_TYPE_OPTIONS, isOptionSpecField } from '@/utils/consignment-
const { Text, Title } = Typography
+type Category = AdminConsignmentProfilesResponse['categories'][number]
+type CategoryNode = { category: Category; children: Category[] }
+
type ConfigFormValues = {
name: string
title: string
@@ -76,6 +85,11 @@ type ConfigFormValues = {
priceDrop: { enabled: boolean; hours: number; mode: 'percent' | 'fixed'; value: number }
}
+type ProfileStatusInfo = {
+ label: string
+ tone: 'success' | 'warning' | 'inherited' | 'muted'
+}
+
function yuan(fen: number | undefined): number {
return Number(((Number(fen || 0) || 0) / 100).toFixed(2))
}
@@ -233,24 +247,86 @@ function buildPayload(categoryId: number, values: ConfigFormValues, specs: Consi
}
}
+function resolveProfileStatus(profile: ConsignmentProfile | undefined): ProfileStatusInfo {
+ if (!profile) return { label: '未配置', tone: 'muted' }
+ if (profile.status === 'published') {
+ return { label: `v${profile.currentVersionNo || 1}`, tone: 'success' }
+ }
+ if (profile.status === 'draft') {
+ return {
+ label: profile.currentVersionNo ? `草稿 · 线上 v${profile.currentVersionNo}` : '草稿',
+ tone: 'warning',
+ }
+ }
+ return { label: '已归档', tone: 'muted' }
+}
+
+function resolveInheritedProfileStatus(
+ profile: ConsignmentProfile | undefined,
+ parentProfile: ConsignmentProfile | undefined,
+): ProfileStatusInfo {
+ const parentPublished =
+ parentProfile?.status === 'published' && Boolean(parentProfile.currentVersionNo)
+ if (profile) {
+ if (profile.status === 'archived' && parentPublished) {
+ return { label: `已归档 · 继承 v${parentProfile!.currentVersionNo}`, tone: 'muted' }
+ }
+ if (profile.status !== 'published' && !profile.currentVersionNo && parentPublished) {
+ return { label: `草稿 · 继承 v${parentProfile!.currentVersionNo}`, tone: 'warning' }
+ }
+ return resolveProfileStatus(profile)
+ }
+ if (parentPublished) {
+ return { label: `继承 v${parentProfile!.currentVersionNo}`, tone: 'inherited' }
+ }
+ return { label: '未配置', tone: 'muted' }
+}
+
+function statusTagColor(tone: ProfileStatusInfo['tone']): string {
+ if (tone === 'success') return 'green'
+ if (tone === 'warning') return 'orange'
+ if (tone === 'inherited') return 'blue'
+ return 'default'
+}
+
+function ConsignmentSectionTitle({
+ index,
+ title,
+ description,
+}: {
+ index: string
+ title: string
+ description: string
+}) {
+ return (
+
+ {index}
+
+ {title}
+ {description}
+
+
+ )
+}
+
const gridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 16,
}
-const stackStyle: CSSProperties = {
- display: 'flex',
- flexDirection: 'column',
- gap: 16,
+const fieldCellStyle: CSSProperties = {
+ display: 'grid',
+ gap: 4,
+ minWidth: 0,
}
-const cardStyle: CSSProperties = { borderRadius: 12 }
-
export default function ConsignmentConfigPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [selectedCategoryId, setSelectedCategoryId] = useState(null)
+ const [categoryKeyword, setCategoryKeyword] = useState('')
+ const [expandedCategoryIds, setExpandedCategoryIds] = useState>(() => new Set())
const [saving, setSaving] = useState(false)
const [specDrafts, setSpecDrafts] = useState([])
const [form] = Form.useForm()
@@ -260,19 +336,64 @@ export default function ConsignmentConfigPanel() {
queryFn: () => fetchAdminConsignmentProfiles(),
})
const data = profilesQuery.data?.data
- const categories = useMemo(
- () => (data?.categories || []).filter((category) => category.status === 'active'),
- [data?.categories],
- )
+ const categories = data?.categories || []
const profiles = data?.profiles || []
const profileByCategoryId = useMemo(
() => new Map(profiles.map((profile) => [profile.categoryId, profile])),
[profiles],
)
+
+ const normalizedKeyword = categoryKeyword.trim().toLowerCase()
+ const categoryTree = useMemo(() => {
+ const childrenByParent = new Map()
+ const roots: Category[] = []
+ for (const category of categories) {
+ if (category.parentId) {
+ const children = childrenByParent.get(category.parentId) || []
+ children.push(category)
+ childrenByParent.set(category.parentId, children)
+ } else {
+ roots.push(category)
+ }
+ }
+ const matches = (category: Category) =>
+ !normalizedKeyword ||
+ category.name.toLowerCase().includes(normalizedKeyword) ||
+ category.categoryKey.toLowerCase().includes(normalizedKeyword)
+
+ return roots.flatMap((category) => {
+ const children = childrenByParent.get(category.categoryId) || []
+ if (!normalizedKeyword) return [{ category, children }]
+ if (matches(category)) return [{ category, children }]
+ const matchedChildren = children.filter(matches)
+ return matchedChildren.length > 0 ? [{ category, children: matchedChildren }] : []
+ })
+ }, [categories, normalizedKeyword])
+
+ const filteredCategoryCount = categoryTree.reduce(
+ (total, node) => total + 1 + node.children.length,
+ 0,
+ )
+ const configuredCount = new Set(
+ profiles
+ .filter((profile) => profile.status !== 'archived')
+ .map((profile) => profile.categoryId),
+ ).size
+
const selectedCategory = categories.find((category) => category.categoryId === selectedCategoryId)
const selectedProfile = selectedCategoryId
? profileByCategoryId.get(selectedCategoryId)
: undefined
+ const parentCategory = selectedCategory?.parentId
+ ? categories.find((category) => category.categoryId === selectedCategory.parentId)
+ : undefined
+ const parentProfile = parentCategory
+ ? profileByCategoryId.get(parentCategory.categoryId)
+ : undefined
+ const runtimeInheritsParent =
+ (!selectedProfile || selectedProfile.status !== 'published') &&
+ parentProfile?.status === 'published' &&
+ Boolean(parentProfile.currentVersionNo)
useEffect(() => {
if (selectedCategoryId === null && categories.length > 0) {
@@ -299,10 +420,32 @@ export default function ConsignmentConfigPanel() {
await queryClient.invalidateQueries({ queryKey: ['admin-consignment-profiles'] })
}
+ function handleReset() {
+ if (selectedProfile) {
+ form.setFieldsValue(toFormValues(selectedProfile))
+ setSpecDrafts(cloneSpecs(selectedProfile.config.specs))
+ return
+ }
+ form.setFieldsValue({
+ ...emptyFormValues(),
+ name: selectedCategory?.name ? `${selectedCategory.name}寄卖` : '寄卖配置',
+ tabTitle: selectedCategory?.name || '',
+ })
+ setSpecDrafts([])
+ }
+
async function handleSave() {
if (!selectedCategoryId) return
+ const specs = specDrafts.map((spec) =>
+ isOptionSpecField(spec)
+ ? {
+ ...spec,
+ options: spec.options.filter((option) => option.label.trim() || option.value.trim()),
+ }
+ : spec,
+ )
const keys = new Set()
- for (const spec of specDrafts) {
+ for (const spec of specs) {
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(spec.key)) {
message.error(`字段 Key 不合法:${spec.key || '(空)'}`)
return
@@ -323,7 +466,7 @@ export default function ConsignmentConfigPanel() {
const values = await form.validateFields()
setSaving(true)
try {
- await saveAdminConsignmentProfile(buildPayload(selectedCategoryId, values, specDrafts))
+ await saveAdminConsignmentProfile(buildPayload(selectedCategoryId, values, specs))
message.success('寄卖配置已保存')
await refresh()
} catch (error) {
@@ -358,35 +501,38 @@ export default function ConsignmentConfigPanel() {
function changeSpecType(index: number, type: ConsignmentSpecFieldType) {
setSpecDrafts((current) =>
- current.map((spec, specIndex) => {
- if (specIndex !== index) return spec
- if (isOptionSpecField({ type }) && spec.options.length === 0) {
- return {
- ...spec,
- type,
- options: [
- { label: '选项1', value: '选项1', enabled: true },
- { label: '选项2', value: '选项2', enabled: true },
- ],
- }
- }
- return { ...spec, type }
- }),
+ current.map((spec, specIndex) =>
+ specIndex === index
+ ? { ...spec, type, options: isOptionSpecField({ type }) ? spec.options : [] }
+ : spec,
+ ),
)
}
- function updateOption(index: number, optionIndex: number, patch: Partial) {
+ /** 选项按订单服务风格用逗号分隔:保留已有选项的 label/value,新增项直接以文本生成。 */
+ function updateOptionText(index: number, text: string) {
+ const tokens = text.split(/[,,]/).map((token) => token.trim())
setSpecDrafts((current) =>
- current.map((spec, specIndex) =>
- specIndex === index
- ? {
- ...spec,
- options: spec.options.map((option, currentOptionIndex) =>
- currentOptionIndex === optionIndex ? { ...option, ...patch } : option,
- ),
- }
- : spec,
- ),
+ current.map((spec, specIndex) => {
+ if (specIndex !== index) return spec
+ const currentOptions = spec.options.filter((option) => option.enabled !== false)
+ const usedOptionIndexes = new Set()
+ const options = tokens.map((token) => {
+ if (!token) return { label: '', value: '', enabled: true }
+ const optionIndex = currentOptions.findIndex(
+ (option, currentOptionIndex) =>
+ !usedOptionIndexes.has(currentOptionIndex) &&
+ (option.label === token || option.value === token),
+ )
+ const matchedOption = optionIndex >= 0 ? currentOptions[optionIndex] : undefined
+ if (matchedOption) {
+ usedOptionIndexes.add(optionIndex)
+ return { ...matchedOption, enabled: true }
+ }
+ return { label: token, value: token, enabled: true }
+ })
+ return { ...spec, options }
+ }),
)
}
@@ -414,130 +560,227 @@ export default function ConsignmentConfigPanel() {
setSpecDrafts((current) => [...current, newCustomSpec(current)])
}
- function addOption(index: number) {
- setSpecDrafts((current) =>
- current.map((spec, specIndex) => {
- if (specIndex !== index) return spec
- const nextValue = `选项${spec.options.length + 1}`
- return {
- ...spec,
- options: [...spec.options, { label: nextValue, value: nextValue, enabled: true }],
- }
- }),
- )
- }
-
- function removeOption(index: number, optionIndex: number) {
- setSpecDrafts((current) =>
- current.map((spec, specIndex) =>
- specIndex === index
- ? {
- ...spec,
- options: spec.options.filter((_, currentIndex) => currentIndex !== optionIndex),
- }
- : spec,
- ),
- )
- }
-
- const typeLabelMap = useMemo(
- () => new Map(SPEC_FIELD_TYPE_OPTIONS.map((item) => [item.value, item.label])),
- [],
- )
-
const priceType = Form.useWatch(['pricing', 'priceType'], form)
+ const headerStatus: ProfileStatusInfo = selectedProfile
+ ? resolveProfileStatus(selectedProfile)
+ : runtimeInheritsParent
+ ? {
+ label: `继承「${parentCategory?.name || '父级'}」v${parentProfile?.currentVersionNo}`,
+ tone: 'inherited',
+ }
+ : { label: '尚未配置', tone: 'muted' }
return (
-
- 寄卖配置
- {profiles.filter((p) => p.status === 'published').length} 个已发布
-
- }
- extra={
- }
- loading={profilesQuery.isFetching}
- onClick={() => void refresh()}
- >
- 刷新
-
- }
- >
-