diff --git a/app/performance-survey/[[...path]]/page.tsx b/app/performance-survey/[[...path]]/page.tsx index 4932605..e513183 100644 --- a/app/performance-survey/[[...path]]/page.tsx +++ b/app/performance-survey/[[...path]]/page.tsx @@ -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 = { 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 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({ > ↑ {dragging ? '松开即可添加' : '拖拽图片到这里'} - 或点击选择 · 最多 60 张,单张 200MB + 或点击选择 · 单张长图,或最多 60 张有序切片;单张 200MB ) @@ -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({
案例 {index + 1} 0 ? 'text-[#0050cb]' : 'text-[#98a2b3]'}`}> - {draft.files.length > 0 ? `${draft.files.length} 张` : '待上传'} + {draft.files.length > 1 ? `${draft.files.length} 张切片` : draft.files.length === 1 ? '1 张图片' : '待上传'}
@@ -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) } diff --git a/lib/__tests__/performance-survey-image-order.test.ts b/lib/__tests__/performance-survey-image-order.test.ts new file mode 100644 index 0000000..cd39052 --- /dev/null +++ b/lib/__tests__/performance-survey-image-order.test.ts @@ -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', + ]) + }) +}) diff --git a/lib/__tests__/performance-survey-image-processing.test.ts b/lib/__tests__/performance-survey-image-processing.test.ts index 095e6ff..179d68a 100644 --- a/lib/__tests__/performance-survey-image-processing.test.ts +++ b/lib/__tests__/performance-survey-image-processing.test.ts @@ -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 () => { diff --git a/lib/performance-survey-image-order.ts b/lib/performance-survey-image-order.ts new file mode 100644 index 0000000..a7021c2 --- /dev/null +++ b/lib/performance-survey-image-order.ts @@ -0,0 +1,24 @@ +const performanceSurveyImageNameCollator = new Intl.Collator('zh-CN', { + numeric: true, + sensitivity: 'base', +}) + +/** + * 视觉评分的多图表示同一张详情长图的连续切片。 + * 按文件名做自然数字排序,并在名称相同时保留原始顺序。 + */ +export function sortPerformanceSurveyImagesByName( + 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) +} diff --git a/lib/performance-survey-image-processing.ts b/lib/performance-survey-image-processing.ts index 05b6878..88f5c33 100644 --- a/lib/performance-survey-image-processing.ts +++ b/lib/performance-survey-image-processing.ts @@ -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) diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index a128ebc..59a3eee 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -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}`