From 1cf790d6ab73d9203e27a06bf3ec9083f1c66352 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 12 Sep 2026 20:14:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(worker-hall):=20=E6=8A=A2=E5=8D=95?= =?UTF-8?q?=E5=A4=A7=E5=8E=85=E6=94=AF=E6=8C=81=E5=88=86=E7=B1=BB=E4=B8=8E?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E6=8C=89=E9=92=AE=E5=9B=BE=E6=A0=87=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=8F=8APNG=E9=80=8F=E6=98=8E=E9=80=9A?= =?UTF-8?q?=E9=81=93=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file-storage/file-storage-service.ts | 37 ++++++--- .../admin-worker-platform-summary-service.ts | 2 + .../work-order-display-mappers.ts | 7 +- .../worker-hall-config-service.test.ts | 10 +++ .../worker-hall-config-service.ts | 19 ++++- .../worker-platform/worker-hall-service.ts | 2 + .../pages/admin/panels/HallConfigPanel.tsx | 51 +++++++++++- .../src/pages/worker/WorkerHallPage.tsx | 82 ++++++++++++++----- .../src/services/admin/worker-platform.ts | 2 +- apps/frontend/src/types/worker-platform.ts | 2 + apps/frontend/src/utils/image-upload.ts | 14 +++- 11 files changed, 187 insertions(+), 41 deletions(-) diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts index bbc89f7e..9527be86 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -225,12 +225,23 @@ export function refreshUploadedFileUrls(value: unknown) { } } +export const PUBLIC_SCENES = new Set(['category_icon', 'hall_icon', 'public', 'announcement']) + +export function isPublicObjectKey(objectKey: string) { + const normalized = normalizeObjectKey(objectKey) + const firstSegment = normalized.split('/')[0] || '' + return PUBLIC_SCENES.has(firstSegment) +} + export function verifyFileAccessSignature( objectKey: string, expiresValue: unknown, signatureValue: unknown, nowSeconds = Math.floor(Date.now() / 1000), ) { + if (isPublicObjectKey(objectKey)) { + return + } const expires = Number(expiresValue || 0) const signature = String(signatureValue || '').trim() if (!Number.isSafeInteger(expires) || expires < nowSeconds || !signature) { @@ -388,22 +399,24 @@ async function generateImageVariants(objectKey: string, data: Buffer, contentTyp for (const config of configs) { try { - const content = await sharp(data) - .rotate() - .resize({ - width: config.maxSide, - height: config.maxSide, - fit: 'inside', - withoutEnlargement: true, - }) - .flatten({ background: '#ffffff' }) - .jpeg({ quality: config.quality, mozjpeg: true }) - .toBuffer() + const isAlpha = contentType === 'image/png' || contentType === 'image/webp' + const pipeline = sharp(data).rotate().resize({ + width: config.maxSide, + height: config.maxSide, + fit: 'inside', + withoutEnlargement: true, + }) + const content = isAlpha + ? await pipeline.webp({ quality: config.quality, alphaQuality: 95 }).toBuffer() + : await pipeline + .flatten({ background: '#ffffff' }) + .jpeg({ quality: config.quality, mozjpeg: true }) + .toBuffer() variants.push({ name: config.name, key: imageVariantKey(objectKey, config.name), content, - contentType: 'image/jpeg', + contentType: isAlpha ? 'image/webp' : 'image/jpeg', }) } catch { return variants diff --git a/apps/backend/src/services/worker-platform/admin-worker-platform-summary-service.ts b/apps/backend/src/services/worker-platform/admin-worker-platform-summary-service.ts index cb35f61a..c9f93545 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-platform-summary-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-platform-summary-service.ts @@ -1,5 +1,6 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { countWorkOrders, listWorkerUsers } from '../../repositories/worker-platform/index.js' +import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js' import { publishWorkerHallRealtimeRefresh } from '../realtime/realtime-event-service.js' import { getWorkerHallConfig, saveWorkerHallConfig } from './worker-hall-config-service.js' @@ -55,6 +56,7 @@ export async function getAdminWorkerHallConfig() { ]) return { ...config, + allIconUrl: config.allIconUrl ? refreshStoredFileUrl(config.allIconUrl) : null, visibleOrderCount: visibleOpenOrderCount, queuedOrderCount: Math.max(0, openOrderCount - visibleOpenOrderCount), } 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 681bd398..52f36b77 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 @@ -17,7 +17,10 @@ import { } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { safeParseJson } from '../admin/admin-query-utils.js' -import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' +import { + refreshStoredFileUrl, + refreshUploadedFileUrls, +} from '../file-storage/file-storage-service.js' import { DEFAULT_DEPOSIT_THRESHOLD_AMOUNT } from './worker-finance-mappers.js' import { normalizeUploadedFiles } from './worker-input-normalizers.js' import { resolveFreezeDepositAmount } from './worker-permission-utils.js' @@ -34,7 +37,7 @@ export function mapWorkCategory(category: WorkCategoryRow | null | undefined) { categoryKey: category.category_key, name: category.name, parentId: category.parent_id ? Number(category.parent_id) : null, - iconUrl: category.icon_url || null, + iconUrl: category.icon_url ? refreshStoredFileUrl(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 c0b0023a..38695aed 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 @@ -10,35 +10,45 @@ test('大厅配置默认展示 50 个订单', () => { assert.deepEqual(createDefaultWorkerHallConfig(), { maxVisibleOrders: 50, showSubcategories: true, + allIconUrl: null, categoryMaxVisibleOrders: {}, }) assert.deepEqual(normalizeWorkerHallConfig({}), { maxVisibleOrders: 50, showSubcategories: true, + allIconUrl: null, categoryMaxVisibleOrders: {}, }) assert.equal(normalizeWorkerHallConfig({ showSubcategories: false }).showSubcategories, false) + assert.equal( + normalizeWorkerHallConfig({ allIconUrl: ' https://example.com/icon.png ' }).allIconUrl, + 'https://example.com/icon.png', + ) }) test('大厅配置只接受 1 到 500 的整数', () => { assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 88 }), { maxVisibleOrders: 88, showSubcategories: true, + allIconUrl: null, categoryMaxVisibleOrders: {}, }) assert.deepEqual(normalizeWorkerHallConfig({ max_visible_orders: 999 }), { maxVisibleOrders: 500, showSubcategories: true, + allIconUrl: null, categoryMaxVisibleOrders: {}, }) assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 0 }), { maxVisibleOrders: 1, showSubcategories: true, + allIconUrl: null, categoryMaxVisibleOrders: {}, }) assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 1.5 }), { maxVisibleOrders: 50, showSubcategories: true, + allIconUrl: null, 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 63a80a48..dd6a4d01 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 @@ -5,6 +5,7 @@ import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-sto export type WorkerHallConfig = { maxVisibleOrders: number showSubcategories: boolean + allIconUrl: string | null categoryMaxVisibleOrders: Record } @@ -25,7 +26,12 @@ export async function saveWorkerHallConfig(rawValue: unknown): Promise } @@ -32,6 +37,9 @@ export default function HallConfigPanel() { const queryClient = useQueryClient() const [form] = Form.useForm() 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 ( - + {config?.visibleOrderCount || 0} {config?.queuedOrderCount || 0} @@ -110,7 +134,7 @@ export default function HallConfigPanel() { rules={[{ required: true, message: '请输入大厅订单总数' }]} style={{ marginBottom: 12 }} > - + + + + {currentAllIconUrl ? ( + + ) : null} + + + + + + diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index 6471671e..f34e5e5a 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -79,6 +79,8 @@ export default function WorkerHallPage() { const [leaderboardPeriod, setLeaderboardPeriod] = useState('all') const [sharingQuantity, setSharingQuantity] = useState(1) const [submittingSharing, setSubmittingSharing] = useState(false) + const [failedImageKeys, setFailedImageKeys] = useState>({}) + const [confirmOpen, setConfirmOpen] = useState(false) const [grabConfirmEnabled, setGrabConfirmEnabled] = useState(isGrabConfirmEnabled) const loadMoreRef = useRef(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() for (const c of allCategories) { if (c.parentId) { @@ -365,7 +375,7 @@ export default function WorkerHallPage() { {/* 移动端一级分类/游戏卡片横向滑动条 */} - {primaryCategories.length > 0 ? ( + {hasCategoryBar ? (
全部 @@ -399,16 +418,22 @@ export default function WorkerHallPage() {
- {category.iconUrl ? ( + {category.iconUrl && !failedImageKeys[category.categoryId] ? ( {category.name} + setFailedImageKeys((prev) => ({ + ...prev, + [category.categoryId]: true, + })) + } /> ) : ( {category.name.slice(0, 1)} @@ -793,7 +818,7 @@ export default function WorkerHallPage() { {/* 桌面端一级分类卡片与二级联动 */} - {primaryCategories.length > 0 ? ( + {hasCategoryBar ? (
全部 @@ -828,18 +862,24 @@ export default function WorkerHallPage() {
- {category.iconUrl ? ( + {category.iconUrl && !failedImageKeys[category.categoryId] ? ( {category.name} + setFailedImageKeys((prev) => ({ + ...prev, + [category.categoryId]: true, + })) + } /> ) : ( diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 8dfd9c00..c01fe423 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -89,7 +89,7 @@ export function fetchAdminWorkerHallConfig() { export function saveAdminWorkerHallConfig( payload: Pick< WorkerHallConfig, - 'maxVisibleOrders' | 'showSubcategories' | 'categoryMaxVisibleOrders' + 'maxVisibleOrders' | 'showSubcategories' | 'categoryMaxVisibleOrders' | 'allIconUrl' >, ) { return apiPost('/api/v1/admin/worker-platform/hall-config', payload) diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 98a20684..47b16acb 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -304,6 +304,7 @@ export type WorkerAnnouncementConfig = { export type WorkerHallConfig = { maxVisibleOrders: number showSubcategories?: boolean + allIconUrl?: string | null categoryMaxVisibleOrders: Record visibleOrderCount: number queuedOrderCount: number @@ -718,6 +719,7 @@ export type WorkerHallOrdersResponse = { pagination: Pagination categories: WorkCategory[] showSubcategories?: boolean + allIconUrl?: string | null visibleDelaySeconds?: number } diff --git a/apps/frontend/src/utils/image-upload.ts b/apps/frontend/src/utils/image-upload.ts index 1fc230bc..4d1a09ca 100644 --- a/apps/frontend/src/utils/image-upload.ts +++ b/apps/frontend/src/utils/image-upload.ts @@ -11,6 +11,11 @@ const IMAGE_UPLOAD_QUALITY: Record = { } 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