feat(worker-hall): 抢单大厅支持分类与全部按钮图标自定义及PNG透明通道修复

This commit is contained in:
yml2213
2026-09-12 20:16:27 +08:00
parent 694d7fff40
commit 1cf790d6ab
11 changed files with 187 additions and 41 deletions
@@ -1,4 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { UploadOutlined } from '@ant-design/icons'
import {
App,
Avatar,
@@ -6,11 +7,13 @@ import {
Card,
Descriptions,
Form,
Input,
InputNumber,
Space,
Switch,
Table,
Tag,
Upload,
} from 'antd'
import { useEffect, useMemo, useState } from 'react'
@@ -19,11 +22,13 @@ import {
fetchAdminWorkerHallConfig,
saveAdminWorkerHallConfig,
} from '@/services/admin'
import { uploadFile } from '@/services/files'
import type { WorkCategory } from '@/types/worker-platform'
type HallConfigFormValues = {
maxVisibleOrders: number
showSubcategories: boolean
allIconUrl?: string
categoryMaxVisibleOrders: Record<string, number>
}
@@ -32,6 +37,9 @@ export default function HallConfigPanel() {
const queryClient = useQueryClient()
const [form] = Form.useForm<HallConfigFormValues>()
const [saving, setSaving] = useState(false)
const [uploadingAllIcon, setUploadingAllIcon] = useState(false)
const currentAllIconUrl = Form.useWatch('allIconUrl', form)
const configQuery = useQuery({
queryKey: ['admin-worker-hall-config'],
queryFn: fetchAdminWorkerHallConfig,
@@ -43,6 +51,7 @@ export default function HallConfigPanel() {
form.setFieldsValue({
maxVisibleOrders: config.maxVisibleOrders,
showSubcategories: config.showSubcategories ?? true,
allIconUrl: config.allIconUrl || '',
categoryMaxVisibleOrders: config.categoryMaxVisibleOrders || {},
})
}
@@ -74,12 +83,27 @@ export default function HallConfigPanel() {
})
}, [categoriesQuery.data?.data.items])
async function handleUploadAllIcon(file: File) {
setUploadingAllIcon(true)
try {
const uploaded = await uploadFile(file, 'hall_icon', 'admin')
form.setFieldValue('allIconUrl', uploaded.url)
message.success('「全部」图标上传成功')
} catch (error) {
message.error(error instanceof Error ? error.message : '图标上传失败')
} finally {
setUploadingAllIcon(false)
}
return false
}
async function saveConfig(values: HallConfigFormValues) {
setSaving(true)
try {
await saveAdminWorkerHallConfig({
maxVisibleOrders: Number(values.maxVisibleOrders),
showSubcategories: Boolean(values.showSubcategories),
allIconUrl: values.allIconUrl ? String(values.allIconUrl).trim() : null,
categoryMaxVisibleOrders: values.categoryMaxVisibleOrders || {},
})
await Promise.all([
@@ -96,7 +120,7 @@ export default function HallConfigPanel() {
return (
<Card title="大厅配置" bordered={false} loading={configQuery.isLoading}>
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 640 }}>
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 700 }}>
<Descriptions size="small" column={3}>
<Descriptions.Item label="展示中">{config?.visibleOrderCount || 0}</Descriptions.Item>
<Descriptions.Item label="等待入厅">{config?.queuedOrderCount || 0}</Descriptions.Item>
@@ -110,7 +134,7 @@ export default function HallConfigPanel() {
rules={[{ required: true, message: '请输入大厅订单总数' }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={1} max={500} precision={0} style={{ width: 160 }} />
<InputNumber min={1} max={500} precision={0} style={{ width: 140 }} />
</Form.Item>
<Form.Item
name="showSubcategories"
@@ -121,6 +145,29 @@ export default function HallConfigPanel() {
>
<Switch checkedChildren="开启展示" unCheckedChildren="关闭展示" />
</Form.Item>
<Form.Item
name="allIconUrl"
label="「全部」按钮图标"
tooltip="接单大厅第一个「全部」按钮展示的自定义图标。留空则显示默认应用图标。"
style={{ marginBottom: 12, minWidth: 280 }}
>
<Space.Compact style={{ width: '100%' }}>
{currentAllIconUrl ? (
<Avatar
src={currentAllIconUrl}
size={32}
shape="square"
style={{ marginRight: 8, flexShrink: 0 }}
/>
) : null}
<Input placeholder="图标 URL(可选)" allowClear />
<Upload showUploadList={false} beforeUpload={handleUploadAllIcon} accept="image/*">
<Button icon={<UploadOutlined />} loading={uploadingAllIcon}>
上传
</Button>
</Upload>
</Space.Compact>
</Form.Item>
</Space>
<Form.Item>
@@ -79,6 +79,8 @@ export default function WorkerHallPage() {
const [leaderboardPeriod, setLeaderboardPeriod] = useState<WorkerLeaderboardPeriod>('all')
const [sharingQuantity, setSharingQuantity] = useState(1)
const [submittingSharing, setSubmittingSharing] = useState(false)
const [failedImageKeys, setFailedImageKeys] = useState<Record<string | number, boolean>>({})
const [confirmOpen, setConfirmOpen] = useState(false)
const [grabConfirmEnabled, setGrabConfirmEnabled] = useState(isGrabConfirmEnabled)
const loadMoreRef = useRef<HTMLDivElement | null>(null)
@@ -139,7 +141,15 @@ export default function WorkerHallPage() {
const allCategories = firstPage?.categories || []
const showSubcategoriesConfig = firstPage?.showSubcategories ?? true
const primaryCategories = allCategories.filter((c) => !c.parentId)
const allCategoryFromList = allCategories.find(
(c) => !c.parentId && (c.categoryKey === 'all' || c.name === '全部'),
)
const allIconUrl = firstPage?.allIconUrl || allCategoryFromList?.iconUrl || null
const primaryCategories = allCategories.filter(
(c) => !c.parentId && c.categoryKey !== 'all' && c.name !== '全部',
)
const hasCategoryBar = primaryCategories.length > 0 || Boolean(allIconUrl)
const subCategoriesMap = new Map<number, typeof allCategories>()
for (const c of allCategories) {
if (c.parentId) {
@@ -365,7 +375,7 @@ export default function WorkerHallPage() {
</div>
{/* 移动端一级分类/游戏卡片横向滑动条 */}
{primaryCategories.length > 0 ? (
{hasCategoryBar ? (
<div className="worker-hall-game-cards-scroll">
<button
type="button"
@@ -375,17 +385,26 @@ export default function WorkerHallPage() {
<div
className="worker-hall-game-icon-box"
style={
primaryCategoryId === 0
primaryCategoryId === 0 && (!allIconUrl || failedImageKeys['all'])
? { background: 'linear-gradient(135deg, #ff6a00 0%, #ff8e3c 100%)' }
: undefined
}
>
<AppstoreOutlined
style={{
fontSize: 20,
color: primaryCategoryId === 0 ? '#ffffff' : '#64748b',
}}
/>
{allIconUrl && !failedImageKeys['all'] ? (
<img
src={allIconUrl}
alt="全部"
className="worker-hall-game-img"
onError={() => setFailedImageKeys((prev) => ({ ...prev, all: true }))}
/>
) : (
<AppstoreOutlined
style={{
fontSize: 20,
color: primaryCategoryId === 0 ? '#ffffff' : '#64748b',
}}
/>
)}
</div>
<span className="worker-hall-game-title">全部</span>
</button>
@@ -399,16 +418,22 @@ export default function WorkerHallPage() {
<div
className="worker-hall-game-icon-box"
style={
!category.iconUrl
!category.iconUrl || failedImageKeys[category.categoryId]
? { background: getCategoryGradient(category.name, category.categoryId) }
: undefined
}
>
{category.iconUrl ? (
{category.iconUrl && !failedImageKeys[category.categoryId] ? (
<img
src={category.iconUrl}
alt={category.name}
className="worker-hall-game-img"
onError={() =>
setFailedImageKeys((prev) => ({
...prev,
[category.categoryId]: true,
}))
}
/>
) : (
<span className="worker-hall-game-initial">{category.name.slice(0, 1)}</span>
@@ -793,7 +818,7 @@ export default function WorkerHallPage() {
<Card className="worker-hall-board-card" bordered={false}>
{/* 桌面端一级分类卡片与二级联动 */}
{primaryCategories.length > 0 ? (
{hasCategoryBar ? (
<div style={{ marginBottom: 14 }}>
<div className="worker-hall-game-cards-scroll">
<button
@@ -804,17 +829,26 @@ export default function WorkerHallPage() {
<div
className="worker-hall-game-icon-box"
style={
primaryCategoryId === 0
primaryCategoryId === 0 && (!allIconUrl || failedImageKeys['all'])
? { background: 'linear-gradient(135deg, #ff6a00 0%, #ff8e3c 100%)' }
: undefined
}
>
<AppstoreOutlined
style={{
fontSize: 20,
color: primaryCategoryId === 0 ? '#ffffff' : '#64748b',
}}
/>
{allIconUrl && !failedImageKeys['all'] ? (
<img
src={allIconUrl}
alt="全部"
className="worker-hall-game-img"
onError={() => setFailedImageKeys((prev) => ({ ...prev, all: true }))}
/>
) : (
<AppstoreOutlined
style={{
fontSize: 20,
color: primaryCategoryId === 0 ? '#ffffff' : '#64748b',
}}
/>
)}
</div>
<span className="worker-hall-game-title">全部</span>
</button>
@@ -828,18 +862,24 @@ export default function WorkerHallPage() {
<div
className="worker-hall-game-icon-box"
style={
!category.iconUrl
!category.iconUrl || failedImageKeys[category.categoryId]
? {
background: getCategoryGradient(category.name, category.categoryId),
}
: undefined
}
>
{category.iconUrl ? (
{category.iconUrl && !failedImageKeys[category.categoryId] ? (
<img
src={category.iconUrl}
alt={category.name}
className="worker-hall-game-img"
onError={() =>
setFailedImageKeys((prev) => ({
...prev,
[category.categoryId]: true,
}))
}
/>
) : (
<span className="worker-hall-game-initial">
@@ -89,7 +89,7 @@ export function fetchAdminWorkerHallConfig() {
export function saveAdminWorkerHallConfig(
payload: Pick<
WorkerHallConfig,
'maxVisibleOrders' | 'showSubcategories' | 'categoryMaxVisibleOrders'
'maxVisibleOrders' | 'showSubcategories' | 'categoryMaxVisibleOrders' | 'allIconUrl'
>,
) {
return apiPost<WorkerHallConfig>('/api/v1/admin/worker-platform/hall-config', payload)
@@ -304,6 +304,7 @@ export type WorkerAnnouncementConfig = {
export type WorkerHallConfig = {
maxVisibleOrders: number
showSubcategories?: boolean
allIconUrl?: string | null
categoryMaxVisibleOrders: Record<string, number>
visibleOrderCount: number
queuedOrderCount: number
@@ -718,6 +719,7 @@ export type WorkerHallOrdersResponse = {
pagination: Pagination
categories: WorkCategory[]
showSubcategories?: boolean
allIconUrl?: string | null
visibleDelaySeconds?: number
}
+12 -2
View File
@@ -11,6 +11,11 @@ const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
}
export async function optimizeImageForUpload(file: File, scene: string) {
// 分类图标与大厅图标属于透明或矢量UI素材,必须保留原始格式与透明通道,不进行有损压缩与白底填充
if (['category_icon', 'hall_icon'].includes(scene)) {
return file
}
if (!file.type.startsWith('image/')) return file
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) return file
if (typeof document === 'undefined') return file
@@ -26,8 +31,13 @@ export async function optimizeImageForUpload(file: File, scene: string) {
canvas.height = height
const context = canvas.getContext('2d')
if (!context) return file
context.fillStyle = '#ffffff'
context.fillRect(0, 0, width, height)
// 仅在原图为 JPEG 且不支持透明度时才填充白底;PNG 与 WebP 必须保留透明通道
if (file.type === 'image/jpeg') {
context.fillStyle = '#ffffff'
context.fillRect(0, 0, width, height)
}
context.drawImage(image, 0, 0, width, height)
const blob = await canvasToBlob(canvas, 'image/webp', quality)
if (!blob || blob.size >= file.size) return file