diff --git a/apps/backend/src/db/migrations/075_work_category_parent_and_icon.sql b/apps/backend/src/db/migrations/075_work_category_parent_and_icon.sql new file mode 100644 index 00000000..1f7dcb06 --- /dev/null +++ b/apps/backend/src/db/migrations/075_work_category_parent_and_icon.sql @@ -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,用于接单大厅等位置展示'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index b672e491..6f32f90f 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -256,6 +256,8 @@ export type WorkCategoryRow = { id: number category_key: string name: string + parent_id?: number | null + icon_url?: string | null sort_order: number status: string created_at: string @@ -402,6 +404,8 @@ export type WorkOrderRow = { active_share_quantity?: number pending_share_count?: number category_name?: string + parent_category_id?: number | null + parent_category_name?: string worker_username?: string worker_display_name?: string /** 取消信息,由工单查询从取消类事件推导,仅已取消工单有值。 */ diff --git a/apps/backend/src/repositories/worker-platform/work-category-repo.ts b/apps/backend/src/repositories/worker-platform/work-category-repo.ts index 746c74a6..7c99ff77 100644 --- a/apps/backend/src/repositories/worker-platform/work-category-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-category-repo.ts @@ -36,19 +36,30 @@ export async function listAllWorkCategories(): Promise { export async function upsertWorkCategory(input: { categoryKey: string name: string + parentId?: number | null + iconUrl?: string | null sortOrder: number status: string now: string }): Promise { const result = await query( ` - INSERT INTO work_categories (category_key, name, sort_order, status, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) + 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, $7, $8) 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 * `, - [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 } @@ -56,8 +67,9 @@ export async function upsertWorkCategory(input: { export async function countWorkCategoryUsages(categoryId: number | string): Promise<{ workOrderCount: number productRuleCount: number + childCategoryCount: number }> { - const [orders, rules] = await Promise.all([ + const [orders, rules, children] = await Promise.all([ query<{ total: number }>( 'SELECT COUNT(*)::int AS total FROM work_orders WHERE category_id = $1', [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', [Number(categoryId)], ), + query<{ total: number }>( + 'SELECT COUNT(*)::int AS total FROM work_categories WHERE parent_id = $1', + [Number(categoryId)], + ), ]) return { workOrderCount: Number(orders.rows[0]?.total || 0), productRuleCount: Number(rules.rows[0]?.total || 0), + childCategoryCount: Number(children.rows[0]?.total || 0), } } diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 8a39c9c7..408b5ff6 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -11,6 +11,8 @@ export const WORK_ORDER_SELECT = ` COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity, COALESCE(share_summary.pending_share_count, 0)::int AS pending_share_count, 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.display_name AS worker_display_name, cancel_info.cancel_reason, @@ -20,6 +22,7 @@ export const WORK_ORDER_SELECT = ` cancel_info.cancelled_at FROM work_orders wo 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 LATERAL ( SELECT @@ -862,7 +865,9 @@ export function buildWorkOrderWhere({ } if (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) { params.push(normalizeWorkOrderAcceptanceMode(acceptanceMode)) @@ -917,12 +922,21 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh return ` SELECT ranked.id 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 ( PARTITION BY candidate.category_id ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC - ) AS category_rank + ) AS sub_category_rank FROM work_orders candidate + LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id LEFT JOIN ( SELECT work_order_id, @@ -936,10 +950,15 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh OR COALESCE(candidate_shares.active_share_quantity, 0) < candidate.sharing_total_quantity ) ) ranked - WHERE ranked.category_rank <= COALESCE( - NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int, + WHERE ranked.primary_category_rank <= COALESCE( + NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.primary_category_id::text, '')::int, ${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 LIMIT ${limitPlaceholder} ` diff --git a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts index 00764ec3..ebc00e03 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts @@ -2,6 +2,7 @@ import { countWorkCategoryUsages, deleteWorkCategory, getWorkCategoryById, + getWorkCategoryByKey, listAllWorkCategories, upsertWorkCategory, } from '../../repositories/worker-platform/work-category-repo.js' @@ -75,10 +76,57 @@ export async function saveAdminWorkCategory(payload: JsonObject = {}) { DEFAULT_CATEGORY_KEY, ) 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({ categoryKey, name, - sortOrder: normalizeInteger(payload.sortOrder, 100), + parentId, + iconUrl, + sortOrder: normalizeInteger(payload.sortOrder ?? payload.sort_order, 100), status: normalizeEnabledStatus(payload.status), now, }) @@ -94,14 +142,15 @@ export async function deleteAdminWorkCategory(categoryId: number | string) { }) } const usages = await countWorkCategoryUsages(Number(categoryId)) - if (usages.workOrderCount > 0 || usages.productRuleCount > 0) { - throw createHttpError( - `该分类已被 ${usages.workOrderCount} 个工单和 ${usages.productRuleCount} 条物品规则使用,无法删除,请先停用`, - { - statusCode: 409, - errorCode: 'work_category_in_use', - }, - ) + if (usages.workOrderCount > 0 || usages.productRuleCount > 0 || usages.childCategoryCount > 0) { + const reasons: string[] = [] + if (usages.workOrderCount > 0) reasons.push(`${usages.workOrderCount} 个工单`) + if (usages.productRuleCount > 0) reasons.push(`${usages.productRuleCount} 条物品规则`) + if (usages.childCategoryCount > 0) reasons.push(`${usages.childCategoryCount} 个子分类`) + throw createHttpError(`该分类已被 ${reasons.join('、')} 引用,无法删除,请先处理关联数据`, { + statusCode: 409, + errorCode: 'work_category_in_use', + }) } const { deleted } = await deleteWorkCategory(Number(categoryId)) return { deleted } diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 37cb2ad1..681bd398 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -33,6 +33,8 @@ export function mapWorkCategory(category: WorkCategoryRow | null | undefined) { categoryId: Number(category.id), categoryKey: category.category_key, name: category.name, + parentId: category.parent_id ? Number(category.parent_id) : null, + iconUrl: category.icon_url || null, sortOrder: Number(category.sort_order || 100), status: category.status, createdAt: category.created_at, diff --git a/apps/backend/src/services/worker-platform/worker-hall-config-service.test.ts b/apps/backend/src/services/worker-platform/worker-hall-config-service.test.ts index e4b17e27..c0b0023a 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-config-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-config-service.test.ts @@ -9,29 +9,36 @@ import { test('大厅配置默认展示 50 个订单', () => { assert.deepEqual(createDefaultWorkerHallConfig(), { maxVisibleOrders: 50, + showSubcategories: true, categoryMaxVisibleOrders: {}, }) assert.deepEqual(normalizeWorkerHallConfig({}), { maxVisibleOrders: 50, + showSubcategories: true, categoryMaxVisibleOrders: {}, }) + assert.equal(normalizeWorkerHallConfig({ showSubcategories: false }).showSubcategories, false) }) test('大厅配置只接受 1 到 500 的整数', () => { assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 88 }), { maxVisibleOrders: 88, + showSubcategories: true, categoryMaxVisibleOrders: {}, }) assert.deepEqual(normalizeWorkerHallConfig({ max_visible_orders: 999 }), { maxVisibleOrders: 500, + showSubcategories: true, categoryMaxVisibleOrders: {}, }) assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 0 }), { maxVisibleOrders: 1, + showSubcategories: true, categoryMaxVisibleOrders: {}, }) assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 1.5 }), { maxVisibleOrders: 50, + showSubcategories: true, categoryMaxVisibleOrders: {}, }) assert.deepEqual( diff --git a/apps/backend/src/services/worker-platform/worker-hall-config-service.ts b/apps/backend/src/services/worker-platform/worker-hall-config-service.ts index 63821a37..63a80a48 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-config-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-config-service.ts @@ -4,6 +4,7 @@ import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-sto export type WorkerHallConfig = { maxVisibleOrders: number + showSubcategories: boolean categoryMaxVisibleOrders: Record } @@ -24,14 +25,21 @@ export async function saveWorkerHallConfig(rawValue: unknown): Promise() const [editingCategory, setEditingCategory] = useState(null) const [deletingCategory, setDeletingCategory] = useState(null) + const [uploadingIcon, setUploadingIcon] = useState(false) + const categoriesQuery = useQuery({ queryKey: ['admin-worker-platform-categories'], queryFn: () => fetchAdminWorkCategories(), @@ -37,11 +52,56 @@ export default function CategoriesPanel() { 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() + 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) { setEditingCategory(category) form.setFieldsValue({ categoryKey: category.categoryKey, name: category.name, + parentId: category.parentId || 0, + iconUrl: category.iconUrl || '', sortOrder: category.sortOrder, status: category.status, }) @@ -52,14 +112,30 @@ export default function CategoriesPanel() { form.resetFields() } - async function saveCategory(values: { - categoryKey: string - name: string - sortOrder?: number - status?: string - }) { + async function handleUploadIcon(file: File) { + setUploadingIcon(true) 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 ? '分类已更新' : '分类已保存') cancelEdit() await refreshCategories() @@ -73,6 +149,8 @@ export default function CategoriesPanel() { await saveAdminWorkCategory({ categoryKey: category.categoryKey, name: category.name, + parentId: category.parentId || null, + iconUrl: category.iconUrl || null, sortOrder: category.sortOrder, status: category.status === 'active' ? 'disabled' : 'active', }) @@ -97,12 +175,55 @@ export default function CategoriesPanel() { } const columns: TableColumnsType = [ - { title: '分类', dataIndex: 'name' }, - { title: '标识', dataIndex: 'categoryKey' }, - { title: '排序', dataIndex: 'sortOrder', width: 100 }, + { + title: '分类名称', + dataIndex: 'name', + render: (_, row) => ( + + {row.iconUrl ? ( + + ) : ( + + {row.name.slice(0, 1)} + + )} + {row.name} + {row.parentId ? ( + + 二级 + + ) : ( + + 一级 + + )} + + ), + }, + { + title: '上级分类', + width: 140, + render: (_, row) => { + if (!row.parentId) { + return - + } + const parent = rawCategories.find((c) => c.categoryId === row.parentId) + return {parent ? parent.name : `ID: ${row.parentId}`} + }, + }, + { title: '标识', dataIndex: 'categoryKey', width: 150 }, + { title: '排序', dataIndex: 'sortOrder', width: 90 }, { title: '状态', - width: 120, + width: 100, render: (_, row) => ( {row.status === 'active' ? '启用' : '停用'} @@ -111,7 +232,7 @@ export default function CategoriesPanel() { }, { title: '操作', - width: 240, + width: 220, render: (_, row) => ( + + + + + + + + - (null) const [leaderboardOpen, setLeaderboardOpen] = useState(false) @@ -74,14 +77,14 @@ export default function WorkerHallPage() { const loadMoreRef = useRef(null) const ordersQuery = useInfiniteQuery({ - queryKey: ['worker-hall-orders', keyword, categoryId], + queryKey: ['worker-hall-orders', keyword, effectiveCategoryId], initialPageParam: 1, queryFn: ({ pageParam }) => fetchWorkerHallOrders({ keyword, page: Number(pageParam), pageSize: HALL_PAGE_SIZE, - categoryId: categoryId || undefined, + categoryId: effectiveCategoryId || undefined, }), getNextPageParam: (lastPage) => { const pagination = lastPage.data.pagination @@ -127,7 +130,28 @@ export default function WorkerHallPage() { const orders = ordersQuery.data?.pages.flatMap((page) => page.data.items) || [] 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() + 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 activeCount = Number(profileQuery.data?.data.summary.activeOrderCount || 0) const pendingAcceptanceCount = hallSummaryQuery.data?.pendingAcceptanceCount || 0 @@ -331,28 +355,69 @@ export default function WorkerHallPage() { - {/* 移动端横向滑动分类条 */} - {categories.length > 0 ? ( -
+ {/* 移动端一级分类/游戏卡片横向滑动条 */} + {primaryCategories.length > 0 ? ( +
- {categories.map((category) => ( + {primaryCategories.map((category) => ( + ))} +
+ ) : null} + + {/* 移动端二级分类联动条 */} + {shouldShowSubcategories ? ( +
+ 业务类型: + + {currentSubCategories.map((sub) => ( + ))}
@@ -442,7 +507,9 @@ export default function WorkerHallPage() { ) : null} {order.categoryName ? ( - {order.categoryName} + {order.parentCategoryName + ? `${order.parentCategoryName} · ${order.categoryName}` + : order.categoryName} ) : null} {order.sharing?.enabled ? ( @@ -608,21 +675,75 @@ export default function WorkerHallPage() { ))}
- {categories.length > 0 ? ( - ({ - key: String(category.categoryId), - label: category.name, - })), - ]} - onChange={(nextKey) => { - setCategoryId(Number(nextKey) || 0) - }} - /> + {/* 桌面端一级分类卡片与二级联动 */} + {primaryCategories.length > 0 ? ( +
+
+ + {primaryCategories.map((category) => ( + + ))} +
+ + {shouldShowSubcategories ? ( +
+ 业务类型: + + {currentSubCategories.map((sub) => ( + + ))} +
+ ) : null} +
) : null} {shouldWarn ? ( @@ -682,7 +803,9 @@ export default function WorkerHallPage() { {productName} - {order.categoryName || '默认分类'} + {order.parentCategoryName + ? `${order.parentCategoryName} · ${order.categoryName}` + : order.categoryName || '默认分类'} 订单号:{getDisplayOrderNo(order)} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index a2056267..8dfd9c00 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -87,7 +87,10 @@ export function fetchAdminWorkerHallConfig() { } export function saveAdminWorkerHallConfig( - payload: Pick, + payload: Pick< + WorkerHallConfig, + 'maxVisibleOrders' | 'showSubcategories' | 'categoryMaxVisibleOrders' + >, ) { return apiPost('/api/v1/admin/worker-platform/hall-config', payload) } @@ -146,6 +149,8 @@ export function fetchAdminWorkCategories() { export function saveAdminWorkCategory(payload: { categoryKey: string name: string + parentId?: number | null + iconUrl?: string | null sortOrder?: number status?: string }) { diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index b4ab4000..0f875218 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -1827,6 +1827,134 @@ body { 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 { display: flex; diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index a0098f46..98a20684 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -303,6 +303,7 @@ export type WorkerAnnouncementConfig = { export type WorkerHallConfig = { maxVisibleOrders: number + showSubcategories?: boolean categoryMaxVisibleOrders: Record visibleOrderCount: number queuedOrderCount: number @@ -356,10 +357,14 @@ export type WorkCategory = { categoryId: number categoryKey: string name: string + parentId?: number | null + parentName?: string | null + iconUrl?: string | null sortOrder: number status: string createdAt: string updatedAt: string + children?: WorkCategory[] } export type WorkProductRule = { @@ -553,6 +558,8 @@ export type WorkOrder = { productName: string categoryId?: number | null categoryName: string + parentCategoryId?: number | null + parentCategoryName?: string status: WorkOrderStatus rawStatus?: WorkOrderStatus hallAvailable?: boolean @@ -710,6 +717,7 @@ export type WorkerHallOrdersResponse = { items: WorkOrder[] pagination: Pagination categories: WorkCategory[] + showSubcategories?: boolean visibleDelaySeconds?: number }