fix(worker-hall): 优化抢单大厅两级分类联动体验并支持分类图片浏览器长效缓存
This commit is contained in:
@@ -1,19 +1,49 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getStoredFileObject } from '../services/file-storage/file-storage-service.js'
|
||||
import {
|
||||
getStoredFileObject,
|
||||
isPublicObjectKey,
|
||||
} from '../services/file-storage/file-storage-service.js'
|
||||
import { buildNotFoundPayload, sendRouteError } from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/object', async (req, res) => {
|
||||
try {
|
||||
const rawKey = String(req.query.key || '').trim()
|
||||
const isPublic = isPublicObjectKey(rawKey)
|
||||
const object = await getStoredFileObject(req.query.key, req.query.variant, {
|
||||
expires: req.query.expires,
|
||||
signature: req.query.signature,
|
||||
})
|
||||
|
||||
if (object.etag) {
|
||||
res.setHeader('ETag', object.etag)
|
||||
if (req.headers['if-none-match'] === object.etag) {
|
||||
res.status(304).end()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (object.lastModified) {
|
||||
res.setHeader('Last-Modified', object.lastModified.toUTCString())
|
||||
if (req.headers['if-modified-since']) {
|
||||
const ifModifiedSince = new Date(req.headers['if-modified-since'])
|
||||
if (!isNaN(ifModifiedSince.getTime()) && object.lastModified <= ifModifiedSince) {
|
||||
res.status(304).end()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', object.contentType || 'application/octet-stream')
|
||||
res.setHeader('Cache-Control', 'private, max-age=300')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
|
||||
if (isPublic) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=2592000, stale-while-revalidate=604800')
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'private, max-age=300')
|
||||
}
|
||||
|
||||
object.reader.on('error', (error) => {
|
||||
if (!res.headersSent) {
|
||||
sendRouteError(res, error, '读取文件失败', '[files/object]')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
getWorkerHallCategories,
|
||||
getWorkerHallLeaderboard,
|
||||
grabWorkerHallOrder,
|
||||
joinWorkerSharingOrder,
|
||||
@@ -11,6 +12,16 @@ import { createRouteHandler } from '../../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get(
|
||||
'/hall/categories',
|
||||
requireActiveWorker,
|
||||
createRouteHandler((req) => getWorkerHallCategories(getRequiredWorkerSession(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取抢单大厅分类失败',
|
||||
scope: '[worker/hall/categories]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/hall/orders',
|
||||
requireActiveWorker,
|
||||
|
||||
@@ -50,3 +50,17 @@ test('stored file URL drops signatures and refreshes them for responses', () =>
|
||||
'https://example.com/image.jpg',
|
||||
)
|
||||
})
|
||||
|
||||
test('public file scenes return stable unsigned URLs for browser caching', () => {
|
||||
const publicObjectKey = 'category_icon/2026/09/13/game-icon.png'
|
||||
const url = buildFileUrl(publicObjectKey, NOW_SECONDS)
|
||||
assert.equal(url, `/api/v1/files/object?key=${encodeURIComponent(publicObjectKey)}`)
|
||||
assert.equal(url.includes('expires='), false)
|
||||
assert.equal(url.includes('signature='), false)
|
||||
|
||||
const hallIconKey = 'hall_icon/2026/09/13/all-icon.png'
|
||||
const hallUrl = refreshStoredFileUrl(hallIconKey)
|
||||
assert.equal(hallUrl, `/api/v1/files/object?key=${encodeURIComponent(hallIconKey)}`)
|
||||
assert.equal(hallUrl.includes('expires='), false)
|
||||
assert.equal(hallUrl.includes('signature='), false)
|
||||
})
|
||||
|
||||
@@ -192,6 +192,9 @@ export function getMaxUploadSizeBytes() {
|
||||
|
||||
export function buildFileUrl(objectKey: string, nowSeconds = Math.floor(Date.now() / 1000)) {
|
||||
const normalizedKey = normalizeObjectKey(objectKey)
|
||||
if (isPublicObjectKey(normalizedKey)) {
|
||||
return buildUnsignedFileUrl(normalizedKey)
|
||||
}
|
||||
const expires = nowSeconds + FILE_URL_TTL_SECONDS
|
||||
const signature = createFileAccessSignature(normalizedKey, expires)
|
||||
return `${buildUnsignedFileUrl(normalizedKey)}&expires=${expires}&signature=${encodeURIComponent(signature)}`
|
||||
@@ -316,11 +319,17 @@ function extractObjectKeyFromFileUrl(value: string) {
|
||||
if (!value) return ''
|
||||
try {
|
||||
const parsed = new URL(value, 'http://localhost')
|
||||
if (parsed.pathname !== '/api/v1/files/object') return ''
|
||||
return normalizeObjectKey(parsed.searchParams.get('key'))
|
||||
if (parsed.pathname === '/api/v1/files/object') {
|
||||
return normalizeObjectKey(parsed.searchParams.get('key'))
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
// ignore
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.includes('://') && !trimmed.startsWith('/') && trimmed.includes('/')) {
|
||||
return normalizeObjectKey(trimmed)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeContentType(mimeType: unknown, data: Buffer) {
|
||||
|
||||
@@ -41,6 +41,8 @@ export type StoredObject = {
|
||||
reader: Readable
|
||||
contentType: string
|
||||
size: number
|
||||
etag?: string | undefined
|
||||
lastModified?: Date | undefined
|
||||
}
|
||||
|
||||
export class ObjectStorage {
|
||||
@@ -114,6 +116,8 @@ export class ObjectStorage {
|
||||
reader,
|
||||
contentType: String(output.ContentType || 'application/octet-stream'),
|
||||
size: Number(output.ContentLength || 0),
|
||||
...(output.ETag ? { etag: String(output.ETag) } : {}),
|
||||
...(output.LastModified ? { lastModified: output.LastModified } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,17 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
|
||||
})
|
||||
}
|
||||
|
||||
export async function getWorkerHallCategories(session: WorkerSession) {
|
||||
requireActiveWorkerSession(session)
|
||||
const hallConfig = getWorkerHallConfig()
|
||||
const categories = (await listWorkCategories()).map(mapWorkCategory)
|
||||
return {
|
||||
categories,
|
||||
showSubcategories: hallConfig.showSubcategories,
|
||||
allIconUrl: hallConfig.allIconUrl ? refreshStoredFileUrl(hallConfig.allIconUrl) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkerHallLeaderboard(session: WorkerSession, period: unknown = 'all') {
|
||||
requireActiveWorkerSession(session)
|
||||
const leaderboardPeriod = period === 'daily' ? 'daily' : 'all'
|
||||
|
||||
@@ -44,6 +44,7 @@ export {
|
||||
} from './worker-profile-service.js'
|
||||
|
||||
export {
|
||||
getWorkerHallCategories,
|
||||
getWorkerHallLeaderboard,
|
||||
listWorkerHallOrders,
|
||||
maskWorkerLeaderboardDisplayName,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TrophyOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
@@ -36,6 +36,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import {
|
||||
fetchWorkerHallCategories,
|
||||
fetchWorkerHallOrders,
|
||||
fetchWorkerHallLeaderboard,
|
||||
fetchWorkerMyOrders,
|
||||
@@ -44,7 +45,12 @@ import {
|
||||
grabWorkerOrder,
|
||||
joinWorkerSharingOrder,
|
||||
} from '@/services/worker'
|
||||
import type { WorkOrder, WorkerLeaderboardPeriod, WorkerUser } from '@/types/worker-platform'
|
||||
import type {
|
||||
WorkCategory,
|
||||
WorkOrder,
|
||||
WorkerLeaderboardPeriod,
|
||||
WorkerUser,
|
||||
} from '@/types/worker-platform'
|
||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||
import {
|
||||
isGrabConfirmEnabled,
|
||||
@@ -84,6 +90,15 @@ export default function WorkerHallPage() {
|
||||
const [grabConfirmEnabled, setGrabConfirmEnabled] = useState(isGrabConfirmEnabled)
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ['worker-hall-categories'],
|
||||
queryFn: () => fetchWorkerHallCategories(),
|
||||
staleTime: 5 * 60_000,
|
||||
gcTime: 30 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const ordersQuery = useInfiniteQuery({
|
||||
queryKey: ['worker-hall-orders', keyword, effectiveCategoryId],
|
||||
initialPageParam: 1,
|
||||
@@ -100,6 +115,7 @@ export default function WorkerHallPage() {
|
||||
? pagination.page + 1
|
||||
: undefined
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 2_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
@@ -138,13 +154,51 @@ export default function WorkerHallPage() {
|
||||
|
||||
const orders = ordersQuery.data?.pages.flatMap((page) => page.data.items) || []
|
||||
const firstPage = ordersQuery.data?.pages[0]?.data
|
||||
const allCategories = firstPage?.categories || []
|
||||
const showSubcategoriesConfig = firstPage?.showSubcategories ?? true
|
||||
|
||||
const [categoryMeta, setCategoryMeta] = useState<{
|
||||
categories: WorkCategory[]
|
||||
showSubcategories: boolean
|
||||
allIconUrl: string | null
|
||||
}>({
|
||||
categories: [],
|
||||
showSubcategories: true,
|
||||
allIconUrl: null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fromApi = categoriesQuery.data?.data
|
||||
if (fromApi && fromApi.categories && fromApi.categories.length > 0) {
|
||||
setCategoryMeta({
|
||||
categories: fromApi.categories,
|
||||
showSubcategories: fromApi.showSubcategories ?? true,
|
||||
allIconUrl: fromApi.allIconUrl || null,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (firstPage && firstPage.categories && firstPage.categories.length > 0) {
|
||||
setCategoryMeta({
|
||||
categories: firstPage.categories,
|
||||
showSubcategories: firstPage.showSubcategories ?? true,
|
||||
allIconUrl: firstPage.allIconUrl || null,
|
||||
})
|
||||
}
|
||||
}, [categoriesQuery.data, firstPage])
|
||||
|
||||
const allCategories =
|
||||
categoryMeta.categories.length > 0
|
||||
? categoryMeta.categories
|
||||
: categoriesQuery.data?.data?.categories || firstPage?.categories || []
|
||||
const showSubcategoriesConfig = categoryMeta.showSubcategories
|
||||
|
||||
const allCategoryFromList = allCategories.find(
|
||||
(c) => !c.parentId && (c.categoryKey === 'all' || c.name === '全部'),
|
||||
)
|
||||
const allIconUrl = firstPage?.allIconUrl || allCategoryFromList?.iconUrl || null
|
||||
const allIconUrl =
|
||||
categoryMeta.allIconUrl ||
|
||||
categoriesQuery.data?.data?.allIconUrl ||
|
||||
firstPage?.allIconUrl ||
|
||||
allCategoryFromList?.iconUrl ||
|
||||
null
|
||||
|
||||
const primaryCategories = allCategories.filter(
|
||||
(c) => !c.parentId && c.categoryKey !== 'all' && c.name !== '全部',
|
||||
@@ -168,6 +222,10 @@ export default function WorkerHallPage() {
|
||||
setPrimaryCategoryId(nextId)
|
||||
setSubCategoryId(0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFailedImageKeys({})
|
||||
}, [allCategories.length, allIconUrl])
|
||||
const worker = profileQuery.data?.data.worker
|
||||
const activeCount = Number(profileQuery.data?.data.summary.activeOrderCount || 0)
|
||||
const pendingAcceptanceCount = hallSummaryQuery.data?.pendingAcceptanceCount || 0
|
||||
@@ -208,7 +266,8 @@ export default function WorkerHallPage() {
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
queryClient.resetQueries({ queryKey: ['worker-hall-orders'] }),
|
||||
ordersQuery.refetch(),
|
||||
queryClient.invalidateQueries({ queryKey: ['worker-hall-categories'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['worker-hall-summary'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['worker-profile'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }),
|
||||
@@ -395,6 +454,8 @@ export default function WorkerHallPage() {
|
||||
src={allIconUrl}
|
||||
alt="全部"
|
||||
className="worker-hall-game-img"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setFailedImageKeys((prev) => ({ ...prev, all: true }))}
|
||||
/>
|
||||
) : (
|
||||
@@ -428,6 +489,8 @@ export default function WorkerHallPage() {
|
||||
src={category.iconUrl}
|
||||
alt={category.name}
|
||||
className="worker-hall-game-img"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() =>
|
||||
setFailedImageKeys((prev) => ({
|
||||
...prev,
|
||||
@@ -839,6 +902,8 @@ export default function WorkerHallPage() {
|
||||
src={allIconUrl}
|
||||
alt="全部"
|
||||
className="worker-hall-game-img"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setFailedImageKeys((prev) => ({ ...prev, all: true }))}
|
||||
/>
|
||||
) : (
|
||||
@@ -874,6 +939,8 @@ export default function WorkerHallPage() {
|
||||
src={category.iconUrl}
|
||||
alt={category.name}
|
||||
className="worker-hall-game-img"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() =>
|
||||
setFailedImageKeys((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
WorkerAnnouncementConfig,
|
||||
WorkerOrderFeedback,
|
||||
WorkerAfterSalesCase,
|
||||
WorkerHallCategoriesResponse,
|
||||
WorkerHallOrdersResponse,
|
||||
WorkerLeaderboardResponse,
|
||||
WorkerLeaderboardPeriod,
|
||||
@@ -205,6 +206,10 @@ export function renameWorkerAccount(payload: { displayName: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchWorkerHallCategories() {
|
||||
return apiGet<WorkerHallCategoriesResponse>('/api/v1/worker/hall/categories')
|
||||
}
|
||||
|
||||
export function fetchWorkerHallOrders(params?: Record<string, unknown>) {
|
||||
return apiGet<WorkerHallOrdersResponse>('/api/v1/worker/hall/orders', params)
|
||||
}
|
||||
|
||||
@@ -723,6 +723,12 @@ export type WorkerHallOrdersResponse = {
|
||||
visibleDelaySeconds?: number
|
||||
}
|
||||
|
||||
export type WorkerHallCategoriesResponse = {
|
||||
categories: WorkCategory[]
|
||||
showSubcategories?: boolean
|
||||
allIconUrl?: string | null
|
||||
}
|
||||
|
||||
export type WorkerLeaderboardEntry = {
|
||||
rank: number
|
||||
workerId: number
|
||||
|
||||
Reference in New Issue
Block a user