feat(category): 接单平台支持二级分类目录与接单大厅联动展示及限额优化
This commit is contained in:
@@ -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="接单金额"
|
||||||
|
|||||||
@@ -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
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user