feat(category): 接单平台支持二级分类目录与接单大厅联动展示及限额优化

This commit is contained in:
yml2213
2026-09-12 18:45:56 +08:00
parent 111f400bdb
commit d1e06f362e
17 changed files with 819 additions and 135 deletions
@@ -0,0 +1,11 @@
-- 为接单分类表增加二级目录支持与图标支持
ALTER TABLE work_categories
ADD COLUMN IF NOT EXISTS parent_id bigint REFERENCES work_categories(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS icon_url text;
CREATE INDEX IF NOT EXISTS idx_work_categories_parent_id
ON work_categories(parent_id);
COMMENT ON COLUMN work_categories.parent_id IS '父级分类 ID,NULL 表示一级分类,非空表示二级分类';
COMMENT ON COLUMN work_categories.icon_url IS '分类图标 URL,用于接单大厅等位置展示';
@@ -256,6 +256,8 @@ export type WorkCategoryRow = {
id: number id: number
category_key: string category_key: string
name: string name: string
parent_id?: number | null
icon_url?: string | null
sort_order: number sort_order: number
status: string status: string
created_at: string created_at: string
@@ -402,6 +404,8 @@ export type WorkOrderRow = {
active_share_quantity?: number active_share_quantity?: number
pending_share_count?: number pending_share_count?: number
category_name?: string category_name?: string
parent_category_id?: number | null
parent_category_name?: string
worker_username?: string worker_username?: string
worker_display_name?: string worker_display_name?: string
/** 取消信息,由工单查询从取消类事件推导,仅已取消工单有值。 */ /** 取消信息,由工单查询从取消类事件推导,仅已取消工单有值。 */
@@ -36,19 +36,30 @@ export async function listAllWorkCategories(): Promise<WorkCategoryRow[]> {
export async function upsertWorkCategory(input: { export async function upsertWorkCategory(input: {
categoryKey: string categoryKey: string
name: string name: string
parentId?: number | null
iconUrl?: string | null
sortOrder: number sortOrder: number
status: string status: string
now: string now: string
}): Promise<WorkCategoryRow | null> { }): Promise<WorkCategoryRow | null> {
const result = await query<WorkCategoryRow>( const result = await query<WorkCategoryRow>(
` `
INSERT INTO work_categories (category_key, name, sort_order, status, created_at, updated_at) INSERT INTO work_categories (category_key, name, parent_id, icon_url, sort_order, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (category_key) DO UPDATE ON CONFLICT (category_key) DO UPDATE
SET name = EXCLUDED.name, sort_order = EXCLUDED.sort_order, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at SET name = EXCLUDED.name, parent_id = EXCLUDED.parent_id, icon_url = EXCLUDED.icon_url, sort_order = EXCLUDED.sort_order, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at
RETURNING * RETURNING *
`, `,
[input.categoryKey, input.name, input.sortOrder, input.status, input.now, input.now], [
input.categoryKey,
input.name,
input.parentId ?? null,
input.iconUrl ?? null,
input.sortOrder,
input.status,
input.now,
input.now,
],
) )
return result.rows[0] || null return result.rows[0] || null
} }
@@ -56,8 +67,9 @@ export async function upsertWorkCategory(input: {
export async function countWorkCategoryUsages(categoryId: number | string): Promise<{ export async function countWorkCategoryUsages(categoryId: number | string): Promise<{
workOrderCount: number workOrderCount: number
productRuleCount: number productRuleCount: number
childCategoryCount: number
}> { }> {
const [orders, rules] = await Promise.all([ const [orders, rules, children] = await Promise.all([
query<{ total: number }>( query<{ total: number }>(
'SELECT COUNT(*)::int AS total FROM work_orders WHERE category_id = $1', 'SELECT COUNT(*)::int AS total FROM work_orders WHERE category_id = $1',
[Number(categoryId)], [Number(categoryId)],
@@ -66,10 +78,15 @@ export async function countWorkCategoryUsages(categoryId: number | string): Prom
'SELECT COUNT(*)::int AS total FROM work_product_rules WHERE category_id = $1', 'SELECT COUNT(*)::int AS total FROM work_product_rules WHERE category_id = $1',
[Number(categoryId)], [Number(categoryId)],
), ),
query<{ total: number }>(
'SELECT COUNT(*)::int AS total FROM work_categories WHERE parent_id = $1',
[Number(categoryId)],
),
]) ])
return { return {
workOrderCount: Number(orders.rows[0]?.total || 0), workOrderCount: Number(orders.rows[0]?.total || 0),
productRuleCount: Number(rules.rows[0]?.total || 0), productRuleCount: Number(rules.rows[0]?.total || 0),
childCategoryCount: Number(children.rows[0]?.total || 0),
} }
} }
@@ -11,6 +11,8 @@ export const WORK_ORDER_SELECT = `
COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity, COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity,
COALESCE(share_summary.pending_share_count, 0)::int AS pending_share_count, COALESCE(share_summary.pending_share_count, 0)::int AS pending_share_count,
wc.name AS category_name, wc.name AS category_name,
parent_wc.id AS parent_category_id,
parent_wc.name AS parent_category_name,
wu.username AS worker_username, wu.username AS worker_username,
wu.display_name AS worker_display_name, wu.display_name AS worker_display_name,
cancel_info.cancel_reason, cancel_info.cancel_reason,
@@ -20,6 +22,7 @@ export const WORK_ORDER_SELECT = `
cancel_info.cancelled_at cancel_info.cancelled_at
FROM work_orders wo FROM work_orders wo
LEFT JOIN work_categories wc ON wc.id = wo.category_id LEFT JOIN work_categories wc ON wc.id = wo.category_id
LEFT JOIN work_categories parent_wc ON parent_wc.id = wc.parent_id
LEFT JOIN worker_users wu ON wu.id = wo.assigned_worker_id LEFT JOIN worker_users wu ON wu.id = wo.assigned_worker_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
@@ -862,7 +865,9 @@ export function buildWorkOrderWhere({
} }
if (categoryId) { if (categoryId) {
params.push(categoryId) params.push(categoryId)
filters.push(`wo.category_id = $${params.length}`) filters.push(
`(wo.category_id = $${params.length} OR wo.category_id IN (SELECT id FROM work_categories WHERE parent_id = $${params.length}))`,
)
} }
if (acceptanceMode) { if (acceptanceMode) {
params.push(normalizeWorkOrderAcceptanceMode(acceptanceMode)) params.push(normalizeWorkOrderAcceptanceMode(acceptanceMode))
@@ -917,12 +922,21 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh
return ` return `
SELECT ranked.id SELECT ranked.id
FROM ( FROM (
SELECT candidate.id, candidate.category_id, candidate.hall_queued_at, SELECT
candidate.id,
candidate.category_id,
candidate.hall_queued_at,
COALESCE(candidate_wc.parent_id, candidate.category_id) AS primary_category_id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(candidate_wc.parent_id, candidate.category_id)
ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
) AS primary_category_rank,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY candidate.category_id PARTITION BY candidate.category_id
ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
) AS category_rank ) AS sub_category_rank
FROM work_orders candidate FROM work_orders candidate
LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
work_order_id, work_order_id,
@@ -936,10 +950,15 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh
OR COALESCE(candidate_shares.active_share_quantity, 0) < candidate.sharing_total_quantity OR COALESCE(candidate_shares.active_share_quantity, 0) < candidate.sharing_total_quantity
) )
) ranked ) ranked
WHERE ranked.category_rank <= COALESCE( WHERE ranked.primary_category_rank <= COALESCE(
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int, NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.primary_category_id::text, '')::int,
${limitPlaceholder} ${limitPlaceholder}
) )
AND (
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '') IS NULL
OR ranked.category_id = ranked.primary_category_id
OR ranked.sub_category_rank <= NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int
)
ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC
LIMIT ${limitPlaceholder} LIMIT ${limitPlaceholder}
` `
@@ -2,6 +2,7 @@ import {
countWorkCategoryUsages, countWorkCategoryUsages,
deleteWorkCategory, deleteWorkCategory,
getWorkCategoryById, getWorkCategoryById,
getWorkCategoryByKey,
listAllWorkCategories, listAllWorkCategories,
upsertWorkCategory, upsertWorkCategory,
} from '../../repositories/worker-platform/work-category-repo.js' } from '../../repositories/worker-platform/work-category-repo.js'
@@ -75,10 +76,57 @@ export async function saveAdminWorkCategory(payload: JsonObject = {}) {
DEFAULT_CATEGORY_KEY, DEFAULT_CATEGORY_KEY,
) )
const name = String(payload.name || '').trim() || '默认分类' const name = String(payload.name || '').trim() || '默认分类'
const rawParentId = payload.parentId ?? payload.parent_id
const parentId =
rawParentId !== undefined && rawParentId !== null && String(rawParentId).trim() !== ''
? Number(rawParentId)
: null
const iconUrl =
payload.iconUrl !== undefined
? String(payload.iconUrl || '').trim() || null
: payload.icon_url !== undefined
? String(payload.icon_url || '').trim() || null
: null
if (parentId) {
const parent = await getWorkCategoryById(parentId)
if (!parent) {
throw createHttpError('所选上级分类不存在', {
statusCode: 400,
errorCode: 'parent_category_not_found',
})
}
if (parent.category_key === categoryKey) {
throw createHttpError('上级分类不能是当前分类自身', {
statusCode: 400,
errorCode: 'invalid_parent_category',
})
}
if (parent.parent_id) {
throw createHttpError('暂不支持三级及以上分类,上级分类必须是一级分类', {
statusCode: 400,
errorCode: 'parent_cannot_be_subcategory',
})
}
}
const existing = await getWorkCategoryByKey(categoryKey)
if (existing && parentId) {
const usages = await countWorkCategoryUsages(existing.id)
if (usages.childCategoryCount > 0) {
throw createHttpError('该分类下已有子分类,无法再作为二级分类', {
statusCode: 400,
errorCode: 'category_has_children',
})
}
}
const category = await upsertWorkCategory({ const category = await upsertWorkCategory({
categoryKey, categoryKey,
name, name,
sortOrder: normalizeInteger(payload.sortOrder, 100), parentId,
iconUrl,
sortOrder: normalizeInteger(payload.sortOrder ?? payload.sort_order, 100),
status: normalizeEnabledStatus(payload.status), status: normalizeEnabledStatus(payload.status),
now, now,
}) })
@@ -94,14 +142,15 @@ export async function deleteAdminWorkCategory(categoryId: number | string) {
}) })
} }
const usages = await countWorkCategoryUsages(Number(categoryId)) const usages = await countWorkCategoryUsages(Number(categoryId))
if (usages.workOrderCount > 0 || usages.productRuleCount > 0) { if (usages.workOrderCount > 0 || usages.productRuleCount > 0 || usages.childCategoryCount > 0) {
throw createHttpError( const reasons: string[] = []
`该分类已被 ${usages.workOrderCount} 个工单和 ${usages.productRuleCount} 条物品规则使用,无法删除,请先停用`, if (usages.workOrderCount > 0) reasons.push(`${usages.workOrderCount} 个工单`)
{ if (usages.productRuleCount > 0) reasons.push(`${usages.productRuleCount} 条物品规则`)
statusCode: 409, if (usages.childCategoryCount > 0) reasons.push(`${usages.childCategoryCount} 个子分类`)
errorCode: 'work_category_in_use', throw createHttpError(`该分类已被 ${reasons.join('、')} 引用,无法删除,请先处理关联数据`, {
}, statusCode: 409,
) errorCode: 'work_category_in_use',
})
} }
const { deleted } = await deleteWorkCategory(Number(categoryId)) const { deleted } = await deleteWorkCategory(Number(categoryId))
return { deleted } return { deleted }
@@ -33,6 +33,8 @@ export function mapWorkCategory(category: WorkCategoryRow | null | undefined) {
categoryId: Number(category.id), categoryId: Number(category.id),
categoryKey: category.category_key, categoryKey: category.category_key,
name: category.name, name: category.name,
parentId: category.parent_id ? Number(category.parent_id) : null,
iconUrl: category.icon_url || null,
sortOrder: Number(category.sort_order || 100), sortOrder: Number(category.sort_order || 100),
status: category.status, status: category.status,
createdAt: category.created_at, createdAt: category.created_at,
@@ -9,29 +9,36 @@ import {
test('大厅配置默认展示 50 个订单', () => { test('大厅配置默认展示 50 个订单', () => {
assert.deepEqual(createDefaultWorkerHallConfig(), { assert.deepEqual(createDefaultWorkerHallConfig(), {
maxVisibleOrders: 50, maxVisibleOrders: 50,
showSubcategories: true,
categoryMaxVisibleOrders: {}, categoryMaxVisibleOrders: {},
}) })
assert.deepEqual(normalizeWorkerHallConfig({}), { assert.deepEqual(normalizeWorkerHallConfig({}), {
maxVisibleOrders: 50, maxVisibleOrders: 50,
showSubcategories: true,
categoryMaxVisibleOrders: {}, categoryMaxVisibleOrders: {},
}) })
assert.equal(normalizeWorkerHallConfig({ showSubcategories: false }).showSubcategories, false)
}) })
test('大厅配置只接受 1 到 500 的整数', () => { test('大厅配置只接受 1 到 500 的整数', () => {
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 88 }), { assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 88 }), {
maxVisibleOrders: 88, maxVisibleOrders: 88,
showSubcategories: true,
categoryMaxVisibleOrders: {}, categoryMaxVisibleOrders: {},
}) })
assert.deepEqual(normalizeWorkerHallConfig({ max_visible_orders: 999 }), { assert.deepEqual(normalizeWorkerHallConfig({ max_visible_orders: 999 }), {
maxVisibleOrders: 500, maxVisibleOrders: 500,
showSubcategories: true,
categoryMaxVisibleOrders: {}, categoryMaxVisibleOrders: {},
}) })
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 0 }), { assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 0 }), {
maxVisibleOrders: 1, maxVisibleOrders: 1,
showSubcategories: true,
categoryMaxVisibleOrders: {}, categoryMaxVisibleOrders: {},
}) })
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 1.5 }), { assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 1.5 }), {
maxVisibleOrders: 50, maxVisibleOrders: 50,
showSubcategories: true,
categoryMaxVisibleOrders: {}, categoryMaxVisibleOrders: {},
}) })
assert.deepEqual( assert.deepEqual(
@@ -4,6 +4,7 @@ import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-sto
export type WorkerHallConfig = { export type WorkerHallConfig = {
maxVisibleOrders: number maxVisibleOrders: number
showSubcategories: boolean
categoryMaxVisibleOrders: Record<string, number> categoryMaxVisibleOrders: Record<string, number>
} }
@@ -24,14 +25,21 @@ export async function saveWorkerHallConfig(rawValue: unknown): Promise<WorkerHal
} }
export function createDefaultWorkerHallConfig(): WorkerHallConfig { export function createDefaultWorkerHallConfig(): WorkerHallConfig {
return { maxVisibleOrders: 50, categoryMaxVisibleOrders: {} } return { maxVisibleOrders: 50, showSubcategories: true, categoryMaxVisibleOrders: {} }
} }
export function normalizeWorkerHallConfig(rawValue: unknown): WorkerHallConfig { export function normalizeWorkerHallConfig(rawValue: unknown): WorkerHallConfig {
const source = isPlainObject(rawValue) ? rawValue : {} const source = isPlainObject(rawValue) ? rawValue : {}
const value = Number(source.maxVisibleOrders ?? source.max_visible_orders) const value = Number(source.maxVisibleOrders ?? source.max_visible_orders)
const showSubcategories =
source.showSubcategories !== undefined
? Boolean(source.showSubcategories)
: source.show_subcategories !== undefined
? Boolean(source.show_subcategories)
: true
return { return {
maxVisibleOrders: Number.isInteger(value) ? Math.min(500, Math.max(1, value)) : 50, maxVisibleOrders: Number.isInteger(value) ? Math.min(500, Math.max(1, value)) : 50,
showSubcategories,
categoryMaxVisibleOrders: normalizeCategoryMaxVisibleOrders( categoryMaxVisibleOrders: normalizeCategoryMaxVisibleOrders(
source.categoryMaxVisibleOrders ?? source.category_max_visible_orders, source.categoryMaxVisibleOrders ?? source.category_max_visible_orders,
), ),
@@ -90,6 +90,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
}), }),
pagination: { page, pageSize, total }, pagination: { page, pageSize, total },
categories, categories,
showSubcategories: hallConfig.showSubcategories,
visibleDelaySeconds, visibleDelaySeconds,
} }
}) })
@@ -1,6 +1,8 @@
import { UploadOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { import {
App, App,
Avatar,
Button, Button,
Card, Card,
Form, Form,
@@ -12,22 +14,35 @@ import {
Table, Table,
Tag, Tag,
Typography, Typography,
Upload,
} from 'antd' } from 'antd'
import type { TableColumnsType } from 'antd' import type { TableColumnsType } from 'antd'
import { useState } from 'react' import { useMemo, useState } from 'react'
import { import {
deleteAdminWorkCategory, deleteAdminWorkCategory,
fetchAdminWorkCategories, fetchAdminWorkCategories,
saveAdminWorkCategory, saveAdminWorkCategory,
} from '@/services/admin' } from '@/services/admin'
import { uploadFile } from '@/services/files'
import type { WorkCategory } from '@/types/worker-platform' import type { WorkCategory } from '@/types/worker-platform'
type CategoryFormValues = {
categoryKey: string
name: string
parentId?: number
iconUrl?: string
sortOrder?: number
status?: string
}
export default function CategoriesPanel() { export default function CategoriesPanel() {
const { message } = App.useApp() const { message } = App.useApp()
const [form] = Form.useForm() const [form] = Form.useForm<CategoryFormValues>()
const [editingCategory, setEditingCategory] = useState<WorkCategory | null>(null) const [editingCategory, setEditingCategory] = useState<WorkCategory | null>(null)
const [deletingCategory, setDeletingCategory] = useState<WorkCategory | null>(null) const [deletingCategory, setDeletingCategory] = useState<WorkCategory | null>(null)
const [uploadingIcon, setUploadingIcon] = useState(false)
const categoriesQuery = useQuery({ const categoriesQuery = useQuery({
queryKey: ['admin-worker-platform-categories'], queryKey: ['admin-worker-platform-categories'],
queryFn: () => fetchAdminWorkCategories(), queryFn: () => fetchAdminWorkCategories(),
@@ -37,11 +52,56 @@ export default function CategoriesPanel() {
await categoriesQuery.refetch() await categoriesQuery.refetch()
} }
const rawCategories = categoriesQuery.data?.data.items || []
// 一级分类选项(排除正在编辑的分类自身,防止自引用)
const parentOptions = useMemo(() => {
const level1Categories = rawCategories.filter(
(c) => !c.parentId && (!editingCategory || c.categoryId !== editingCategory.categoryId),
)
return [
{ value: 0, label: '无(作为一级分类)' },
...level1Categories.map((c) => ({
value: c.categoryId,
label: `${c.name} (${c.categoryKey})`,
})),
]
}, [rawCategories, editingCategory])
// 构建树形表格数据(一级分类包含 children,二级分类挂在下面)
const treeData = useMemo(() => {
const rootCategories = rawCategories.filter((item) => !item.parentId)
const childrenMap = new Map<number, WorkCategory[]>()
for (const item of rawCategories) {
if (item.parentId) {
const list = childrenMap.get(item.parentId) || []
list.push(item)
childrenMap.set(item.parentId, list)
}
}
return rootCategories.map((root) => {
const children = childrenMap.get(root.categoryId) || []
return {
...root,
children:
children.length > 0
? children.map((c) => ({
...c,
parentName: root.name,
}))
: undefined,
}
})
}, [rawCategories])
function startEdit(category: WorkCategory) { function startEdit(category: WorkCategory) {
setEditingCategory(category) setEditingCategory(category)
form.setFieldsValue({ form.setFieldsValue({
categoryKey: category.categoryKey, categoryKey: category.categoryKey,
name: category.name, name: category.name,
parentId: category.parentId || 0,
iconUrl: category.iconUrl || '',
sortOrder: category.sortOrder, sortOrder: category.sortOrder,
status: category.status, status: category.status,
}) })
@@ -52,14 +112,30 @@ export default function CategoriesPanel() {
form.resetFields() form.resetFields()
} }
async function saveCategory(values: { async function handleUploadIcon(file: File) {
categoryKey: string setUploadingIcon(true)
name: string
sortOrder?: number
status?: string
}) {
try { try {
await saveAdminWorkCategory(values) const uploaded = await uploadFile(file, 'category_icon', 'admin')
form.setFieldValue('iconUrl', uploaded.url)
message.success('图标上传成功')
} catch (error) {
message.error(error instanceof Error ? error.message : '图标上传失败')
} finally {
setUploadingIcon(false)
}
return false
}
async function saveCategory(values: CategoryFormValues) {
try {
await saveAdminWorkCategory({
categoryKey: values.categoryKey,
name: values.name,
parentId: values.parentId ? Number(values.parentId) : null,
iconUrl: values.iconUrl ? String(values.iconUrl).trim() : null,
sortOrder: values.sortOrder,
status: values.status,
})
message.success(editingCategory ? '分类已更新' : '分类已保存') message.success(editingCategory ? '分类已更新' : '分类已保存')
cancelEdit() cancelEdit()
await refreshCategories() await refreshCategories()
@@ -73,6 +149,8 @@ export default function CategoriesPanel() {
await saveAdminWorkCategory({ await saveAdminWorkCategory({
categoryKey: category.categoryKey, categoryKey: category.categoryKey,
name: category.name, name: category.name,
parentId: category.parentId || null,
iconUrl: category.iconUrl || null,
sortOrder: category.sortOrder, sortOrder: category.sortOrder,
status: category.status === 'active' ? 'disabled' : 'active', status: category.status === 'active' ? 'disabled' : 'active',
}) })
@@ -97,12 +175,55 @@ export default function CategoriesPanel() {
} }
const columns: TableColumnsType<WorkCategory> = [ const columns: TableColumnsType<WorkCategory> = [
{ title: '分类', dataIndex: 'name' }, {
{ title: '标识', dataIndex: 'categoryKey' }, title: '分类名称',
{ title: '排序', dataIndex: 'sortOrder', width: 100 }, dataIndex: 'name',
render: (_, row) => (
<Space align="center">
{row.iconUrl ? (
<Avatar src={row.iconUrl} size={28} shape="square" />
) : (
<Avatar
size={28}
shape="square"
style={{
backgroundColor: row.parentId ? '#bae0ff' : '#ffd591',
color: '#1f1f1f',
fontSize: 13,
}}
>
{row.name.slice(0, 1)}
</Avatar>
)}
<span>{row.name}</span>
{row.parentId ? (
<Tag color="blue" style={{ marginLeft: 4 }}>
二级
</Tag>
) : (
<Tag color="orange" style={{ marginLeft: 4 }}>
一级
</Tag>
)}
</Space>
),
},
{
title: '上级分类',
width: 140,
render: (_, row) => {
if (!row.parentId) {
return <Typography.Text type="secondary">-</Typography.Text>
}
const parent = rawCategories.find((c) => c.categoryId === row.parentId)
return <Tag color="geekblue">{parent ? parent.name : `ID: ${row.parentId}`}</Tag>
},
},
{ title: '标识', dataIndex: 'categoryKey', width: 150 },
{ title: '排序', dataIndex: 'sortOrder', width: 90 },
{ {
title: '状态', title: '状态',
width: 120, width: 100,
render: (_, row) => ( render: (_, row) => (
<Tag color={row.status === 'active' ? 'green' : 'default'}> <Tag color={row.status === 'active' ? 'green' : 'default'}>
{row.status === 'active' ? '启用' : '停用'} {row.status === 'active' ? '启用' : '停用'}
@@ -111,7 +232,7 @@ export default function CategoriesPanel() {
}, },
{ {
title: '操作', title: '操作',
width: 240, width: 220,
render: (_, row) => ( render: (_, row) => (
<Space wrap> <Space wrap>
<Button size="small" onClick={() => startEdit(row)}> <Button size="small" onClick={() => startEdit(row)}>
@@ -136,33 +257,61 @@ export default function CategoriesPanel() {
> >
<Form <Form
form={form} form={form}
layout="inline" layout="vertical"
initialValues={{ status: 'active', sortOrder: 100 }} initialValues={{ parentId: 0, status: 'active', sortOrder: 100 }}
onFinish={saveCategory} onFinish={saveCategory}
> >
<Form.Item <div
label="标识" style={{
name="categoryKey" display: 'grid',
rules={[{ required: true, message: '请输入标识' }]} gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '12px 16px',
}}
> >
<Input /> <Form.Item
</Form.Item> label="上级分类"
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}> name="parentId"
<Input /> tooltip="选择一级分类即可设为该分类下的二级目录;留空或选「无」则作为独立一级分类。"
</Form.Item> >
<Form.Item label="排序" name="sortOrder"> <Select options={parentOptions} />
<InputNumber min={0} max={9999} /> </Form.Item>
</Form.Item> <Form.Item
<Form.Item label="状态" name="status"> label="标识"
<Select name="categoryKey"
style={{ width: 110 }} rules={[{ required: true, message: '请输入标识' }]}
options={[ >
{ value: 'active', label: '启用' }, <Input placeholder="如 hpjy、dtts" />
{ value: 'disabled', label: '停用' }, </Form.Item>
]} <Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
/> <Input placeholder="如 和平精英、地铁装备" />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item
label="分类图标"
name="iconUrl"
tooltip="在接单大厅等位置展示的图标。支持直接填写链接,或点击右侧按钮上传。"
>
<Space.Compact style={{ width: '100%' }}>
<Input placeholder="图标 URL(可选)" />
<Upload showUploadList={false} beforeUpload={handleUploadIcon} accept="image/*">
<Button icon={<UploadOutlined />} loading={uploadingIcon}>
上传
</Button>
</Upload>
</Space.Compact>
</Form.Item>
<Form.Item label="排序" name="sortOrder">
<InputNumber min={0} max={9999} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="状态" name="status">
<Select
options={[
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '停用' },
]}
/>
</Form.Item>
</div>
<Form.Item style={{ marginBottom: 0 }}>
<Space> <Space>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
保存 保存
@@ -172,13 +321,14 @@ export default function CategoriesPanel() {
</Form.Item> </Form.Item>
</Form> </Form>
</Card> </Card>
<Card title="分类列表" bordered={false}> <Card title="分类列表(树形层级)" bordered={false}>
<Table<WorkCategory> <Table<WorkCategory>
rowKey="categoryId" rowKey="categoryId"
loading={categoriesQuery.isLoading} loading={categoriesQuery.isLoading}
dataSource={categoriesQuery.data?.data.items || []} dataSource={treeData}
columns={columns} columns={columns}
pagination={false} pagination={false}
defaultExpandAllRows
/> />
</Card> </Card>
<Modal <Modal
@@ -192,7 +342,7 @@ export default function CategoriesPanel() {
> >
<Typography.Text> <Typography.Text>
确定删除分类「{deletingCategory?.name} 确定删除分类「{deletingCategory?.name}
」?已引用该分类的工单和物品规则将无法删除,需先处理。 」?已引用该分类的工单、接单模板或下属子分类将无法删除,需先处理关联。
</Typography.Text> </Typography.Text>
</Modal> </Modal>
</section> </section>
@@ -1,6 +1,18 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { App, Button, Card, Descriptions, Form, InputNumber, Space, Table, Tag } from 'antd' import {
import { useEffect, useState } from 'react' App,
Avatar,
Button,
Card,
Descriptions,
Form,
InputNumber,
Space,
Switch,
Table,
Tag,
} from 'antd'
import { useEffect, useMemo, useState } from 'react'
import { import {
fetchAdminWorkCategories, fetchAdminWorkCategories,
@@ -11,6 +23,7 @@ import type { WorkCategory } from '@/types/worker-platform'
type HallConfigFormValues = { type HallConfigFormValues = {
maxVisibleOrders: number maxVisibleOrders: number
showSubcategories: boolean
categoryMaxVisibleOrders: Record<string, number> categoryMaxVisibleOrders: Record<string, number>
} }
@@ -29,20 +42,44 @@ export default function HallConfigPanel() {
if (config) { if (config) {
form.setFieldsValue({ form.setFieldsValue({
maxVisibleOrders: config.maxVisibleOrders, maxVisibleOrders: config.maxVisibleOrders,
showSubcategories: config.showSubcategories ?? true,
categoryMaxVisibleOrders: config.categoryMaxVisibleOrders || {}, categoryMaxVisibleOrders: config.categoryMaxVisibleOrders || {},
}) })
} }
}, [config, form]) }, [config, form])
const categoriesQuery = useQuery({ const categoriesQuery = useQuery({
queryKey: ['admin-worker-platform-categories'], queryKey: ['admin-worker-platform-categories'],
queryFn: fetchAdminWorkCategories, queryFn: fetchAdminWorkCategories,
}) })
const treeData = useMemo(() => {
const rawCategories = categoriesQuery.data?.data.items || []
const rootCategories = rawCategories.filter((item) => !item.parentId)
const childrenMap = new Map<number, WorkCategory[]>()
for (const item of rawCategories) {
if (item.parentId) {
const list = childrenMap.get(item.parentId) || []
list.push(item)
childrenMap.set(item.parentId, list)
}
}
return rootCategories.map((root) => {
const children = childrenMap.get(root.categoryId) || []
return {
...root,
children: children.length > 0 ? children : undefined,
}
})
}, [categoriesQuery.data?.data.items])
async function saveConfig(values: HallConfigFormValues) { async function saveConfig(values: HallConfigFormValues) {
setSaving(true) setSaving(true)
try { try {
await saveAdminWorkerHallConfig({ await saveAdminWorkerHallConfig({
maxVisibleOrders: Number(values.maxVisibleOrders), maxVisibleOrders: Number(values.maxVisibleOrders),
showSubcategories: Boolean(values.showSubcategories),
categoryMaxVisibleOrders: values.categoryMaxVisibleOrders || {}, categoryMaxVisibleOrders: values.categoryMaxVisibleOrders || {},
}) })
await Promise.all([ await Promise.all([
@@ -59,29 +96,43 @@ export default function HallConfigPanel() {
return ( return (
<Card title="大厅配置" bordered={false} loading={configQuery.isLoading}> <Card title="大厅配置" bordered={false} loading={configQuery.isLoading}>
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 560 }}> <Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 640 }}>
<Descriptions size="small" column={3}> <Descriptions size="small" column={3}>
<Descriptions.Item label="展示中">{config?.visibleOrderCount || 0}</Descriptions.Item> <Descriptions.Item label="展示中">{config?.visibleOrderCount || 0}</Descriptions.Item>
<Descriptions.Item label="等待入厅">{config?.queuedOrderCount || 0}</Descriptions.Item> <Descriptions.Item label="等待入厅">{config?.queuedOrderCount || 0}</Descriptions.Item>
<Descriptions.Item label="当前上限">{config?.maxVisibleOrders || '-'}</Descriptions.Item> <Descriptions.Item label="当前上限">{config?.maxVisibleOrders || '-'}</Descriptions.Item>
</Descriptions> </Descriptions>
<Form form={form} layout="vertical" onFinish={saveConfig}> <Form form={form} layout="vertical" onFinish={saveConfig}>
<Form.Item <Space size="large" align="start" wrap style={{ marginBottom: 8 }}>
name="maxVisibleOrders" <Form.Item
label="大厅订单总数" name="maxVisibleOrders"
rules={[{ required: true, message: '请输入大厅订单总数' }]} label="大厅订单总数"
> rules={[{ required: true, message: '请输入大厅订单总数' }]}
<InputNumber min={1} max={500} precision={0} style={{ width: 160 }} /> style={{ marginBottom: 12 }}
</Form.Item> >
<InputNumber min={1} max={500} precision={0} style={{ width: 160 }} />
</Form.Item>
<Form.Item
name="showSubcategories"
label="大厅二级目录展示"
valuePropName="checked"
tooltip="开启后,接单大厅一级分类下方联动展示其二级分类选项;关闭后大厅只展示一级分类。"
style={{ marginBottom: 12 }}
>
<Switch checkedChildren="开启展示" unCheckedChildren="关闭展示" />
</Form.Item>
</Space>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" loading={saving}> <Button type="primary" htmlType="submit" loading={saving}>
保存 保存配置
</Button> </Button>
</Form.Item> </Form.Item>
<div> <div>
<Tag color="blue">分类上限</Tag> <Tag color="blue">分类最多展示上限</Tag>
<span style={{ marginLeft: 8, color: '#666' }}> <span style={{ marginLeft: 8, color: '#666', fontSize: 13 }}>
未填写的分类沿用大厅总上限;填写后仅限制该分类最多展示数量。 一级分类限额将限制该游戏及旗下所有子分类的总单量;二级分类可单独设置细化限额。
</span> </span>
</div> </div>
<Table<WorkCategory> <Table<WorkCategory>
@@ -89,11 +140,39 @@ export default function HallConfigPanel() {
size="small" size="small"
pagination={false} pagination={false}
loading={categoriesQuery.isLoading} loading={categoriesQuery.isLoading}
dataSource={categoriesQuery.data?.data.items || []} dataSource={treeData}
defaultExpandAllRows
columns={[ columns={[
{ title: '分类', dataIndex: 'name' }, {
title: '分类名称',
dataIndex: 'name',
render: (_, category) => (
<Space size="small">
{category.iconUrl ? (
<Avatar src={category.iconUrl} size={20} shape="square" />
) : null}
<span>{category.name}</span>
{category.parentId ? (
<Tag
color="blue"
style={{ fontSize: 11, lineHeight: '18px', padding: '0 4px' }}
>
二级
</Tag>
) : (
<Tag
color="orange"
style={{ fontSize: 11, lineHeight: '18px', padding: '0 4px' }}
>
一级
</Tag>
)}
</Space>
),
},
{ {
title: '状态', title: '状态',
width: 90,
render: (_, category) => ( render: (_, category) => (
<Tag color={category.status === 'active' ? 'green' : 'default'}> <Tag color={category.status === 'active' ? 'green' : 'default'}>
{category.status === 'active' ? '启用' : '停用'} {category.status === 'active' ? '启用' : '停用'}
@@ -102,12 +181,19 @@ export default function HallConfigPanel() {
}, },
{ {
title: '最多展示', title: '最多展示',
width: 180,
render: (_, category) => ( render: (_, category) => (
<Form.Item <Form.Item
name={['categoryMaxVisibleOrders', String(category.categoryId)]} name={['categoryMaxVisibleOrders', String(category.categoryId)]}
style={{ margin: 0 }} style={{ margin: 0 }}
> >
<InputNumber min={1} max={500} precision={0} placeholder="跟随总上限" /> <InputNumber
min={1}
max={500}
precision={0}
placeholder={category.parentId ? '跟随父级' : '跟随总上限'}
style={{ width: '100%' }}
/>
</Form.Item> </Form.Item>
), ),
}, },
@@ -41,7 +41,12 @@ import {
saveAdminWorkProductRule, saveAdminWorkProductRule,
syncAdminWorkProductRulePrice, syncAdminWorkProductRulePrice,
} from '@/services/admin' } from '@/services/admin'
import type { CollectField, WorkProductRule, WorkProductRuleMapping } from '@/types/worker-platform' import type {
CollectField,
WorkCategory,
WorkProductRule,
WorkProductRuleMapping,
} from '@/types/worker-platform'
import type { AdminKuaishouIndustryShopOption } from '@/types/admin' import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatMoney } from './shared' import { formatMoney } from './shared'
@@ -92,6 +97,36 @@ export default function ProductRulesPanel() {
queryKey: ['admin-worker-platform-categories'], queryKey: ['admin-worker-platform-categories'],
queryFn: () => fetchAdminWorkCategories(), queryFn: () => fetchAdminWorkCategories(),
}) })
const categoryOptions = useMemo(() => {
const raw = categoriesQuery.data?.data.items || []
const rootCategories = raw.filter((c) => !c.parentId)
const childrenMap = new Map<number, typeof raw>()
for (const c of raw) {
if (c.parentId) {
const list = childrenMap.get(c.parentId) || []
list.push(c)
childrenMap.set(c.parentId, list)
}
}
const result: Array<{ label: string; value: number }> = []
for (const root of rootCategories) {
const children = childrenMap.get(root.categoryId) || []
result.push({ label: root.name, value: root.categoryId })
for (const child of children) {
result.push({ label: ` └ ${child.name}`, value: child.categoryId })
}
}
return result
}, [categoriesQuery.data?.data.items])
const categoryMap = useMemo(() => {
const map = new Map<number, WorkCategory>()
for (const item of categoriesQuery.data?.data.items || []) {
map.set(item.categoryId, item)
}
return map
}, [categoriesQuery.data?.data.items])
const shopsQuery = useQuery({ const shopsQuery = useQuery({
queryKey: ['admin-kuaishou-industry-shops', 'product-rules'], queryKey: ['admin-kuaishou-industry-shops', 'product-rules'],
queryFn: fetchAdminKuaishouIndustryShops, queryFn: fetchAdminKuaishouIndustryShops,
@@ -777,10 +812,7 @@ export default function ProductRulesPanel() {
allowClear allowClear
placeholder="选择分类" placeholder="选择分类"
loading={categoriesQuery.isLoading} loading={categoriesQuery.isLoading}
options={(categoriesQuery.data?.data.items || []).map((item) => ({ options={categoryOptions}
value: item.categoryId,
label: item.name,
}))}
/> />
</Form.Item> </Form.Item>
</div> </div>
@@ -1113,9 +1145,11 @@ export default function ProductRulesPanel() {
{ key: 'all', label: `全部 (${rulesPagination?.total || 0})` }, { key: 'all', label: `全部 (${rulesPagination?.total || 0})` },
...(categoriesQuery.data?.data.items || []).map((item) => { ...(categoriesQuery.data?.data.items || []).map((item) => {
const count = categoryCounts.get(String(item.categoryId)) || 0 const count = categoryCounts.get(String(item.categoryId)) || 0
const parent = item.parentId ? categoryMap.get(item.parentId) : null
const displayName = parent ? `${parent.name} · ${item.name}` : item.name
return { return {
key: String(item.categoryId), key: String(item.categoryId),
label: `${item.name}${count ? ` (${count})` : ''}`, label: `${displayName}${count ? ` (${count})` : ''}`,
} }
}), }),
]} ]}
@@ -35,7 +35,7 @@ import {
Typography, Typography,
} from 'antd' } from 'antd'
import type { TableColumnsType } from 'antd' import type { TableColumnsType } from 'antd'
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router' import { useNavigate, useSearchParams } from 'react-router'
import JsonPreview from '@/components/admin/JsonPreview' import JsonPreview from '@/components/admin/JsonPreview'
@@ -325,6 +325,50 @@ export default function WorkOrdersPanel() {
queryKey: ['admin-worker-platform-categories'], queryKey: ['admin-worker-platform-categories'],
queryFn: () => fetchAdminWorkCategories(), queryFn: () => fetchAdminWorkCategories(),
}) })
const categoryOptions = useMemo(() => {
const raw = categoriesQuery.data?.data.items || []
const rootCategories = raw.filter((c) => !c.parentId)
const childrenMap = new Map<number, typeof raw>()
for (const c of raw) {
if (c.parentId) {
const list = childrenMap.get(c.parentId) || []
list.push(c)
childrenMap.set(c.parentId, list)
}
}
const result: Array<{ label: string; value: number }> = []
for (const root of rootCategories) {
const children = childrenMap.get(root.categoryId) || []
result.push({ label: root.name, value: root.categoryId })
for (const child of children) {
result.push({ label: ` └ ${child.name}`, value: child.categoryId })
}
}
return result
}, [categoriesQuery.data?.data.items])
const activeCategoryOptions = useMemo(() => {
const raw = (categoriesQuery.data?.data.items || []).filter((c) => c.status === 'active')
const rootCategories = raw.filter((c) => !c.parentId)
const childrenMap = new Map<number, typeof raw>()
for (const c of raw) {
if (c.parentId) {
const list = childrenMap.get(c.parentId) || []
list.push(c)
childrenMap.set(c.parentId, list)
}
}
const result: Array<{ label: string; value: number }> = []
for (const root of rootCategories) {
const children = childrenMap.get(root.categoryId) || []
result.push({ label: root.name, value: root.categoryId })
for (const child of children) {
result.push({ label: ` └ ${child.name}`, value: child.categoryId })
}
}
return result
}, [categoriesQuery.data?.data.items])
const workerLevelsQuery = useQuery({ const workerLevelsQuery = useQuery({
queryKey: ['admin-worker-platform-levels'], queryKey: ['admin-worker-platform-levels'],
queryFn: () => fetchAdminWorkerLevels(), queryFn: () => fetchAdminWorkerLevels(),
@@ -1376,10 +1420,7 @@ export default function WorkOrdersPanel() {
placeholder="全部接单分类" placeholder="全部接单分类"
style={{ width: 180 }} style={{ width: 180 }}
loading={categoriesQuery.isLoading} loading={categoriesQuery.isLoading}
options={(categoriesQuery.data?.data.items || []).map((category) => ({ options={categoryOptions}
value: category.categoryId,
label: category.name,
}))}
onChange={(nextCategoryId) => { onChange={(nextCategoryId) => {
setCategoryId(nextCategoryId) setCategoryId(nextCategoryId)
setPage(1) setPage(1)
@@ -1892,16 +1933,7 @@ export default function WorkOrdersPanel() {
<Input placeholder="留空表示无平台订单号" /> <Input placeholder="留空表示无平台订单号" />
</Form.Item> </Form.Item>
<Form.Item label="分类" name="categoryId"> <Form.Item label="分类" name="categoryId">
<Select <Select allowClear placeholder="选择接单分类" options={activeCategoryOptions} />
allowClear
placeholder="选择接单分类"
options={(categoriesQuery.data?.data.items || [])
.filter((category) => category.status === 'active')
.map((category) => ({
label: category.name,
value: category.categoryId,
}))}
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label="接单金额" label="接单金额"
+158 -35
View File
@@ -1,4 +1,5 @@
import { import {
AppstoreOutlined,
ClockCircleOutlined, ClockCircleOutlined,
PushpinOutlined, PushpinOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
@@ -63,7 +64,9 @@ export default function WorkerHallPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [keywordInput, setKeywordInput] = useState('') const [keywordInput, setKeywordInput] = useState('')
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
const [categoryId, setCategoryId] = useState(0) const [primaryCategoryId, setPrimaryCategoryId] = useState(0)
const [subCategoryId, setSubCategoryId] = useState(0)
const effectiveCategoryId = subCategoryId || primaryCategoryId || 0
const [grabbingId, setGrabbingId] = useState(0) const [grabbingId, setGrabbingId] = useState(0)
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null) const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
const [leaderboardOpen, setLeaderboardOpen] = useState(false) const [leaderboardOpen, setLeaderboardOpen] = useState(false)
@@ -74,14 +77,14 @@ export default function WorkerHallPage() {
const loadMoreRef = useRef<HTMLDivElement | null>(null) const loadMoreRef = useRef<HTMLDivElement | null>(null)
const ordersQuery = useInfiniteQuery({ const ordersQuery = useInfiniteQuery({
queryKey: ['worker-hall-orders', keyword, categoryId], queryKey: ['worker-hall-orders', keyword, effectiveCategoryId],
initialPageParam: 1, initialPageParam: 1,
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
fetchWorkerHallOrders({ fetchWorkerHallOrders({
keyword, keyword,
page: Number(pageParam), page: Number(pageParam),
pageSize: HALL_PAGE_SIZE, pageSize: HALL_PAGE_SIZE,
categoryId: categoryId || undefined, categoryId: effectiveCategoryId || undefined,
}), }),
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
const pagination = lastPage.data.pagination const pagination = lastPage.data.pagination
@@ -127,7 +130,28 @@ export default function WorkerHallPage() {
const orders = ordersQuery.data?.pages.flatMap((page) => page.data.items) || [] const orders = ordersQuery.data?.pages.flatMap((page) => page.data.items) || []
const firstPage = ordersQuery.data?.pages[0]?.data const firstPage = ordersQuery.data?.pages[0]?.data
const categories = firstPage?.categories || [] const allCategories = firstPage?.categories || []
const showSubcategoriesConfig = firstPage?.showSubcategories ?? true
const primaryCategories = allCategories.filter((c) => !c.parentId)
const subCategoriesMap = new Map<number, typeof allCategories>()
for (const c of allCategories) {
if (c.parentId) {
const list = subCategoriesMap.get(c.parentId) || []
list.push(c)
subCategoriesMap.set(c.parentId, list)
}
}
const currentSubCategories = primaryCategoryId
? subCategoriesMap.get(primaryCategoryId) || []
: []
const shouldShowSubcategories = showSubcategoriesConfig && currentSubCategories.length > 0
function handleSelectPrimaryCategory(nextId: number) {
setPrimaryCategoryId(nextId)
setSubCategoryId(0)
}
const worker = profileQuery.data?.data.worker const worker = profileQuery.data?.data.worker
const activeCount = Number(profileQuery.data?.data.summary.activeOrderCount || 0) const activeCount = Number(profileQuery.data?.data.summary.activeOrderCount || 0)
const pendingAcceptanceCount = hallSummaryQuery.data?.pendingAcceptanceCount || 0 const pendingAcceptanceCount = hallSummaryQuery.data?.pendingAcceptanceCount || 0
@@ -331,28 +355,69 @@ export default function WorkerHallPage() {
</div> </div>
</div> </div>
{/* 移动端横向滑动分类条 */} {/* 移动端一级分类/游戏卡片横向滑动条 */}
{categories.length > 0 ? ( {primaryCategories.length > 0 ? (
<div className="worker-hall-mobile-category-bar"> <div className="worker-hall-game-cards-scroll">
<button <button
type="button" type="button"
className={`worker-hall-mobile-cat-pill ${categoryId === 0 ? 'active' : ''}`} className={`worker-hall-game-card ${primaryCategoryId === 0 ? 'active' : ''}`}
onClick={() => { onClick={() => handleSelectPrimaryCategory(0)}
setCategoryId(0)
}}
> >
全部 <div className="worker-hall-game-icon-box">
<AppstoreOutlined
style={{
fontSize: 20,
color: primaryCategoryId === 0 ? 'var(--theme-primary)' : '#64748b',
}}
/>
</div>
<span className="worker-hall-game-title">全部</span>
</button> </button>
{categories.map((category) => ( {primaryCategories.map((category) => (
<button <button
key={category.categoryId} key={category.categoryId}
type="button" type="button"
className={`worker-hall-mobile-cat-pill ${categoryId === category.categoryId ? 'active' : ''}`} className={`worker-hall-game-card ${primaryCategoryId === category.categoryId ? 'active' : ''}`}
onClick={() => { onClick={() => handleSelectPrimaryCategory(category.categoryId)}
setCategoryId(category.categoryId)
}}
> >
{category.name} <div className="worker-hall-game-icon-box">
{category.iconUrl ? (
<img
src={category.iconUrl}
alt={category.name}
className="worker-hall-game-img"
/>
) : (
<span className="worker-hall-game-initial">{category.name.slice(0, 1)}</span>
)}
</div>
<span className="worker-hall-game-title" title={category.name}>
{category.name}
</span>
</button>
))}
</div>
) : null}
{/* 移动端二级分类联动条 */}
{shouldShowSubcategories ? (
<div className="worker-hall-subcat-bar">
<span className="worker-hall-subcat-label">业务类型:</span>
<button
type="button"
className={`worker-hall-mobile-cat-pill ${subCategoryId === 0 ? 'active' : ''}`}
onClick={() => setSubCategoryId(0)}
>
全部
</button>
{currentSubCategories.map((sub) => (
<button
key={sub.categoryId}
type="button"
className={`worker-hall-mobile-cat-pill ${subCategoryId === sub.categoryId ? 'active' : ''}`}
onClick={() => setSubCategoryId(sub.categoryId)}
>
{sub.name}
</button> </button>
))} ))}
</div> </div>
@@ -442,7 +507,9 @@ export default function WorkerHallPage() {
) : null} ) : null}
{order.categoryName ? ( {order.categoryName ? (
<Tag color="blue" className="worker-hall-mobile-tag"> <Tag color="blue" className="worker-hall-mobile-tag">
{order.categoryName} {order.parentCategoryName
? `${order.parentCategoryName} · ${order.categoryName}`
: order.categoryName}
</Tag> </Tag>
) : null} ) : null}
{order.sharing?.enabled ? ( {order.sharing?.enabled ? (
@@ -608,21 +675,75 @@ export default function WorkerHallPage() {
))} ))}
</div> </div>
{categories.length > 0 ? ( {/* 桌面端一级分类卡片与二级联动 */}
<Tabs {primaryCategories.length > 0 ? (
size="small" <div style={{ marginBottom: 12 }}>
activeKey={String(categoryId)} <div className="worker-hall-game-cards-scroll">
items={[ <button
{ key: '0', label: '全部' }, type="button"
...categories.map((category) => ({ className={`worker-hall-game-card ${primaryCategoryId === 0 ? 'active' : ''}`}
key: String(category.categoryId), onClick={() => handleSelectPrimaryCategory(0)}
label: category.name, >
})), <div className="worker-hall-game-icon-box">
]} <AppstoreOutlined
onChange={(nextKey) => { style={{
setCategoryId(Number(nextKey) || 0) fontSize: 20,
}} color: primaryCategoryId === 0 ? 'var(--theme-primary)' : '#64748b',
/> }}
/>
</div>
<span className="worker-hall-game-title">全部</span>
</button>
{primaryCategories.map((category) => (
<button
key={category.categoryId}
type="button"
className={`worker-hall-game-card ${primaryCategoryId === category.categoryId ? 'active' : ''}`}
onClick={() => handleSelectPrimaryCategory(category.categoryId)}
>
<div className="worker-hall-game-icon-box">
{category.iconUrl ? (
<img
src={category.iconUrl}
alt={category.name}
className="worker-hall-game-img"
/>
) : (
<span className="worker-hall-game-initial">
{category.name.slice(0, 1)}
</span>
)}
</div>
<span className="worker-hall-game-title" title={category.name}>
{category.name}
</span>
</button>
))}
</div>
{shouldShowSubcategories ? (
<div className="worker-hall-subcat-bar" style={{ marginTop: 8 }}>
<span className="worker-hall-subcat-label">业务类型:</span>
<button
type="button"
className={`worker-hall-mobile-cat-pill ${subCategoryId === 0 ? 'active' : ''}`}
onClick={() => setSubCategoryId(0)}
>
全部
</button>
{currentSubCategories.map((sub) => (
<button
key={sub.categoryId}
type="button"
className={`worker-hall-mobile-cat-pill ${subCategoryId === sub.categoryId ? 'active' : ''}`}
onClick={() => setSubCategoryId(sub.categoryId)}
>
{sub.name}
</button>
))}
</div>
) : null}
</div>
) : null} ) : null}
{shouldWarn ? ( {shouldWarn ? (
@@ -682,7 +803,9 @@ export default function WorkerHallPage() {
{productName} {productName}
</strong> </strong>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
{order.categoryName || '默认分类'} {order.parentCategoryName
? `${order.parentCategoryName} · ${order.categoryName}`
: order.categoryName || '默认分类'}
</Typography.Text> </Typography.Text>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
订单号:{getDisplayOrderNo(order)} 订单号:{getDisplayOrderNo(order)}
@@ -87,7 +87,10 @@ export function fetchAdminWorkerHallConfig() {
} }
export function saveAdminWorkerHallConfig( export function saveAdminWorkerHallConfig(
payload: Pick<WorkerHallConfig, 'maxVisibleOrders' | 'categoryMaxVisibleOrders'>, payload: Pick<
WorkerHallConfig,
'maxVisibleOrders' | 'showSubcategories' | 'categoryMaxVisibleOrders'
>,
) { ) {
return apiPost<WorkerHallConfig>('/api/v1/admin/worker-platform/hall-config', payload) return apiPost<WorkerHallConfig>('/api/v1/admin/worker-platform/hall-config', payload)
} }
@@ -146,6 +149,8 @@ export function fetchAdminWorkCategories() {
export function saveAdminWorkCategory(payload: { export function saveAdminWorkCategory(payload: {
categoryKey: string categoryKey: string
name: string name: string
parentId?: number | null
iconUrl?: string | null
sortOrder?: number sortOrder?: number
status?: string status?: string
}) { }) {
+128
View File
@@ -1827,6 +1827,134 @@ body {
gap: 8px; gap: 8px;
} }
/* 游戏 / 一级分类横向卡片条 */
.worker-hall-game-cards-scroll {
display: flex;
align-items: flex-start;
gap: 10px;
overflow-x: auto;
padding: 4px 0 6px 0;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.worker-hall-game-cards-scroll::-webkit-scrollbar {
display: none;
}
.worker-hall-game-card {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 8px 8px 6px 8px;
min-width: 66px;
max-width: 82px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.worker-hall-game-card:hover {
border-color: #cbd5e1;
transform: translateY(-1px);
}
.worker-hall-game-card.active {
border-color: var(--theme-primary);
background: #fff7ed;
box-shadow: 0 3px 10px rgba(255, 106, 0, 0.15);
}
.worker-hall-game-icon-box {
width: 42px;
height: 42px;
border-radius: 10px;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.worker-hall-game-card.active .worker-hall-game-icon-box {
background: #ffedd5;
}
.worker-hall-game-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
}
.worker-hall-game-initial {
font-size: 18px;
font-weight: 700;
color: #64748b;
}
.worker-hall-game-card.active .worker-hall-game-initial {
color: var(--theme-primary);
}
.worker-hall-game-title {
font-size: 12px;
font-weight: 500;
color: #334155;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.worker-hall-game-card.active .worker-hall-game-title {
color: var(--theme-primary);
font-weight: 600;
}
/* 二级分类联动条 */
.worker-hall-subcat-bar {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding: 4px 0 2px 0;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
animation: fadeInSubcat 0.2s ease;
}
.worker-hall-subcat-bar::-webkit-scrollbar {
display: none;
}
.worker-hall-subcat-label {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: #64748b;
margin-right: 2px;
}
@keyframes fadeInSubcat {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 分类横向滑动胶囊条 */ /* 分类横向滑动胶囊条 */
.worker-hall-mobile-category-bar { .worker-hall-mobile-category-bar {
display: flex; display: flex;
@@ -303,6 +303,7 @@ export type WorkerAnnouncementConfig = {
export type WorkerHallConfig = { export type WorkerHallConfig = {
maxVisibleOrders: number maxVisibleOrders: number
showSubcategories?: boolean
categoryMaxVisibleOrders: Record<string, number> categoryMaxVisibleOrders: Record<string, number>
visibleOrderCount: number visibleOrderCount: number
queuedOrderCount: number queuedOrderCount: number
@@ -356,10 +357,14 @@ export type WorkCategory = {
categoryId: number categoryId: number
categoryKey: string categoryKey: string
name: string name: string
parentId?: number | null
parentName?: string | null
iconUrl?: string | null
sortOrder: number sortOrder: number
status: string status: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
children?: WorkCategory[]
} }
export type WorkProductRule = { export type WorkProductRule = {
@@ -553,6 +558,8 @@ export type WorkOrder = {
productName: string productName: string
categoryId?: number | null categoryId?: number | null
categoryName: string categoryName: string
parentCategoryId?: number | null
parentCategoryName?: string
status: WorkOrderStatus status: WorkOrderStatus
rawStatus?: WorkOrderStatus rawStatus?: WorkOrderStatus
hallAvailable?: boolean hallAvailable?: boolean
@@ -710,6 +717,7 @@ export type WorkerHallOrdersResponse = {
items: WorkOrder[] items: WorkOrder[]
pagination: Pagination pagination: Pagination
categories: WorkCategory[] categories: WorkCategory[]
showSubcategories?: boolean
visibleDelaySeconds?: number visibleDelaySeconds?: number
} }