按文件名排序视觉评分切片

This commit is contained in:
chenpei 2026-07-13 17:59:13 +08:00
parent 365e79f3a0
commit aadea673d6
6 changed files with 96 additions and 17 deletions

View File

@ -7,6 +7,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { pinyin } from 'pinyin-pro'
import { createPortal } from 'react-dom'
import { sortPerformanceSurveyImagesByName } from '@/lib/performance-survey-image-order'
type ApiState<T> = {
loading: boolean
@ -183,7 +184,7 @@ function keepFocusInsideModal(event: KeyboardEvent, container: HTMLElement | nul
function validatePerformanceSurveyImageFiles(files: File[]) {
if (files.length === 0) return '请至少选择 1 张图片'
if (files.length > PERFORMANCE_SURVEY_MAX_IMAGE_COUNT) {
return `单个案例最多上传 ${PERFORMANCE_SURVEY_MAX_IMAGE_COUNT} 张图片`
return `单个案例最多上传 ${PERFORMANCE_SURVEY_MAX_IMAGE_COUNT} 张切片`
}
const invalidType = files.find((file) => !file.type.startsWith('image/'))
@ -199,6 +200,7 @@ async function uploadPerformanceSurveyImageFiles(
) {
const { chunkUpload } = await import('@/lib/chunk-upload')
const uploaded: Array<{ uploadId: string; mimeType: string }> = []
const orderedFiles = sortPerformanceSurveyImagesByName(files, (file) => file.name)
const dingtalkToken = getStoredDingtalkPerformanceToken()
const requestHeaders = dingtalkToken
? {
@ -207,9 +209,9 @@ async function uploadPerformanceSurveyImageFiles(
}
: undefined
for (let index = 0; index < files.length; index += 1) {
const file = files[index]
onProgress?.(`正在上传图片 ${index + 1}/${files.length}`)
for (let index = 0; index < orderedFiles.length; index += 1) {
const file = orderedFiles[index]
onProgress?.(`正在上传图片 ${index + 1}/${orderedFiles.length}`)
const result = await chunkUpload(
file,
{ materialType: '考核调研-视觉评分', fileName: file.name },
@ -271,7 +273,7 @@ function normalizePerformanceSurveyImageGroups(value: unknown): PerformanceSurve
}] : []
}
return value.flatMap((item, index): PerformanceSurveyImageGroupData[] => {
const groups = value.flatMap((item, index): PerformanceSurveyImageGroupData[] => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
const record = item as Record<string, unknown>
const width = Math.max(1, Number(record.width) || 1200)
@ -307,6 +309,22 @@ function normalizePerformanceSurveyImageGroups(value: unknown): PerformanceSurve
slices,
}]
})
if (groups.length <= 1) return groups
const orderedGroups = sortPerformanceSurveyImagesByName(groups, (group) => group.sourceName)
const slices = orderedGroups.flatMap((group) => group.slices)
const firstGroup = orderedGroups[0]
return [{
key: `ordered-slices-${orderedGroups.map((group) => group.key).join('-')}`,
sourceName: `详情切片(${orderedGroups.length} 张)`,
previewUrl: firstGroup.previewUrl,
previewWidth: firstGroup.previewWidth,
previewHeight: firstGroup.previewHeight,
width: Math.max(...orderedGroups.map((group) => group.width)),
height: slices.reduce((sum, slice) => sum + slice.height, 0),
slices,
}]
}
function getStoredDingtalkPerformanceToken() {
@ -3054,7 +3072,7 @@ function PerformanceSurveyImageDropzone({
>
<span className={`flex h-8 w-8 items-center justify-center rounded-full text-[20px] font-light ${dragging ? 'bg-[#0050cb] text-white' : 'bg-[#eef5ff] text-[#0050cb]'}`}>↑</span>
<span className="mt-2 text-[13px] font-bold text-[#0050cb]">{dragging ? '松开即可添加' : '拖拽图片到这里'}</span>
<span className="mt-0.5 text-[11px] text-[#98a2b3]">或点击选择 · 最多 60 张,单张 200MB</span>
<span className="mt-0.5 text-[11px] text-[#98a2b3]">或点击选择 · 单张长图,或最多 60 张有序切片;单张 200MB</span>
</button>
</div>
)
@ -3168,13 +3186,14 @@ function VisualCaseDraftEditor({
const key = `${file.name}-${file.size}-${file.lastModified}`
return items.findIndex((candidate) => `${candidate.name}-${candidate.size}-${candidate.lastModified}` === key) === fileIndex
})
const validationError = validatePerformanceSurveyImageFiles(deduplicated)
const orderedFiles = sortPerformanceSurveyImagesByName(deduplicated, (file) => file.name)
const validationError = validatePerformanceSurveyImageFiles(orderedFiles)
if (validationError) {
setMessage(validationError)
return
}
setMessage(null)
onChange({ ...draft, files: deduplicated })
onChange({ ...draft, files: orderedFiles })
}
return (
@ -3182,7 +3201,7 @@ function VisualCaseDraftEditor({
<div className="flex items-center justify-between gap-3">
<span className="text-[13px] font-bold text-[#111827]">案例 {index + 1}</span>
<span className={`text-[11px] font-semibold ${draft.files.length > 0 ? 'text-[#0050cb]' : 'text-[#98a2b3]'}`}>
{draft.files.length > 0 ? `${draft.files.length} 张` : '待上传'}
{draft.files.length > 1 ? `${draft.files.length} 张切片` : draft.files.length === 1 ? '1 张图片' : '待上传'}
</span>
</div>
@ -4349,12 +4368,13 @@ function WorkItemUploadForm({
const key = `${file.name}-${file.size}-${file.lastModified}`
return items.findIndex((candidate) => `${candidate.name}-${candidate.size}-${candidate.lastModified}` === key) === index
})
const validationError = validatePerformanceSurveyImageFiles(merged)
const orderedFiles = sortPerformanceSurveyImagesByName(merged, (file) => file.name)
const validationError = validatePerformanceSurveyImageFiles(orderedFiles)
if (validationError) {
setMessage(validationError)
return
}
setFiles(merged)
setFiles(orderedFiles)
setMessage(null)
}

View File

@ -0,0 +1,34 @@
import { sortPerformanceSurveyImagesByName } from '@/lib/performance-survey-image-order'
describe('performance survey image slice order', () => {
it('按文件名中的数字自然排序', () => {
const files = ['10.jpg', '2.jpg', '1.jpg', '11.jpg', '3.jpg']
expect(sortPerformanceSurveyImagesByName(files, (name) => name)).toEqual([
'1.jpg',
'2.jpg',
'3.jpg',
'10.jpg',
'11.jpg',
])
})
it('支持带前缀和补零的切片名称', () => {
const files = ['详情_10.png', '详情_02.png', '详情_1.png']
expect(sortPerformanceSurveyImagesByName(files, (name) => name)).toEqual([
'详情_1.png',
'详情_02.png',
'详情_10.png',
])
})
it('名称相同时保留用户选择顺序', () => {
const files = [
{ name: '1.jpg', id: 'first' },
{ name: '1.jpg', id: 'second' },
]
expect(sortPerformanceSurveyImagesByName(files, (file) => file.name).map((file) => file.id)).toEqual([
'first',
'second',
])
})
})

View File

@ -49,7 +49,7 @@ describe('performance survey image processing', () => {
sourceName: `${index}.png`,
}))
await expect(processPerformanceSurveyImages(inputs)).rejects.toThrow('最多上传 60 张图片')
await expect(processPerformanceSurveyImages(inputs)).rejects.toThrow('最多上传 60 张切片')
})
test('downscales wide sources before slicing and creates a lightweight top preview', async () => {

View File

@ -0,0 +1,24 @@
const performanceSurveyImageNameCollator = new Intl.Collator('zh-CN', {
numeric: true,
sensitivity: 'base',
})
/**
* 视觉评分的多图表示同一张详情长图的连续切片。
* 按文件名做自然数字排序,并在名称相同时保留原始顺序。
*/
export function sortPerformanceSurveyImagesByName<T>(
items: T[],
getName: (item: T) => string,
): T[] {
return items
.map((item, originalIndex) => ({ item, originalIndex }))
.sort((left, right) => {
const byName = performanceSurveyImageNameCollator.compare(
String(getName(left.item) || '').trim(),
String(getName(right.item) || '').trim(),
)
return byName || left.originalIndex - right.originalIndex
})
.map(({ item }) => item)
}

View File

@ -314,7 +314,7 @@ export async function processPerformanceSurveyImages(
}
if (inputs.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) {
throw new PerformanceSurveyImageProcessingError(
`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张图片`,
`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张切片`,
)
}
const totalBytes = inputs.reduce((sum, input) => sum + input.buffer.length, 0)

View File

@ -43,6 +43,7 @@ import {
type PerformanceSurveyStoredImageGroup,
} from '@/lib/performance-survey-image-processing'
import { getPerformanceSurveyTargetEmployeeIds } from '@/lib/performance-survey-target-policy'
import { sortPerformanceSurveyImagesByName } from '@/lib/performance-survey-image-order'
import { deleteFromS3, uploadToS3 } from '@/lib/s3'
import {
deleteOwnedUploadSession,
@ -2776,7 +2777,7 @@ async function createPerformanceSurveyWorkItemFromChunkedUploads(
const declaredImages = Array.isArray(body?.images) ? body.images as PerformanceSurveyChunkedImage[] : []
if (declaredImages.length === 0) throw new PerformanceSurveyApiError('每个案例至少上传 1 张图片')
if (declaredImages.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) {
throw new PerformanceSurveyApiError(`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张图片`)
throw new PerformanceSurveyApiError(`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张切片`)
}
const uploads: Array<{ uploadId: string; filePath: string }> = []
@ -2812,7 +2813,7 @@ async function createPerformanceSurveyWorkItemFromChunkedUploads(
targetId: clean(body?.targetId),
title: clean(body?.title),
description: clean(body?.description),
sources,
sources: sortPerformanceSurveyImagesByName(sources, (source) => source.name),
})
} finally {
await cleanupPerformanceSurveyChunkedUploads(userId, uploads)
@ -2830,7 +2831,7 @@ export async function createPerformanceSurveyWorkItem(session: Session | null, s
targetId: readFormText(formData, 'targetId'),
title: readFormText(formData, 'title'),
description: readFormText(formData, 'description'),
sources: files.map((file) => ({
sources: sortPerformanceSurveyImagesByName(files, (file) => file.name).map((file) => ({
name: file.name,
size: file.size,
mimeType: file.type,
@ -2878,7 +2879,7 @@ async function createPerformanceSurveyWorkItemFromSources(
const sources = input.sources
if (sources.length === 0) throw new PerformanceSurveyApiError('每个案例至少上传 1 张图片')
if (sources.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) {
throw new PerformanceSurveyApiError(`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张图片`)
throw new PerformanceSurveyApiError(`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张切片`)
}
const title = input.title || `案例 ${survey.workItems.length + 1}`