Skip to content

Commit 2f1b69f

Browse files
committed
feat: implement photo upload workflow with processing and error handling
- Add ProcessingPanel component to display processing stages and logs. - Create UploadFileList component to show upload progress for each file. - Define constants for workflow steps, file statuses, and processing stages. - Implement steps for completed, error, processing, review, uploading, and uploading steps. - Create a Zustand store for managing photo upload state and actions. - Add utility functions for file handling, error messages, and tag sanitization. - Update hooks to support new upload options and progress tracking. - Integrate new components and store into the photo upload module. Signed-off-by: Innei <tukon479@gmail.com>
1 parent 796f9c9 commit 2f1b69f

25 files changed

Lines changed: 1577 additions & 206 deletions

be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export interface UploadAssetInput {
4949
filename: string
5050
buffer: Buffer
5151
contentType?: string
52+
directory?: string | null
5253
}
5354

5455
const VIDEO_EXTENSIONS = new Set(['mov', 'mp4'])
@@ -723,9 +724,11 @@ export class PhotoAssetService {
723724
const base = path.basename(input.filename, ext).trim()
724725

725726
const timestamp = Date.now().toString()
726-
const directory = this.resolveStorageDirectory(storageConfig)
727+
const storageDirectory = this.resolveStorageDirectory(storageConfig)
728+
const customDirectory = this.normalizeDirectory(input.directory)
729+
const combinedDirectory = this.joinStorageSegments(storageDirectory, customDirectory)
727730
const keySegment = base || timestamp
728-
const normalized = directory ? `${directory}/${keySegment}${ext}` : `${keySegment}${ext}`
731+
const normalized = combinedDirectory ? `${combinedDirectory}/${keySegment}${ext}` : `${keySegment}${ext}`
729732
return this.normalizeKeyPath(normalized)
730733
}
731734

be/apps/core/src/modules/content/photo/assets/photo.controller.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export class PhotoController {
3838
@Post('assets/upload')
3939
async uploadAssets(@ContextParam() context: Context) {
4040
const payload = await context.req.parseBody()
41+
let directory: string | null = null
42+
43+
if (typeof payload['directory'] === 'string') {
44+
directory = payload['directory']
45+
} else if (Array.isArray(payload['directory'])) {
46+
const candidate = payload['directory'].find((entry) => typeof entry === 'string')
47+
directory = typeof candidate === 'string' ? candidate : null
48+
}
4149

4250
const files: File[] = []
4351
for (const value of Object.values(payload)) {
@@ -63,6 +71,7 @@ export class PhotoController {
6371
filename: file.name,
6472
buffer: Buffer.from(await file.arrayBuffer()),
6573
contentType: file.type || undefined,
74+
directory,
6675
})),
6776
)
6877

be/apps/dashboard/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
"react-scan": "0.4.3",
5555
"sonner": "2.0.7",
5656
"tailwind-merge": "3.4.0",
57-
"usehooks-ts": "3.1.1"
57+
"usehooks-ts": "3.1.1",
58+
"zustand": "5.0.8"
5859
},
5960
"devDependencies": {
6061
"@egoist/tailwindcss-icons": "1.9.0",
@@ -101,4 +102,4 @@
101102
"eslint --fix"
102103
]
103104
}
104-
}
105+
}

be/apps/dashboard/src/modules/photos/api.ts

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,26 @@ type RunPhotoSyncOptions = {
2020
onEvent?: (event: PhotoSyncProgressEvent) => void
2121
}
2222

23+
export type PhotoUploadFileProgress = {
24+
index: number
25+
name: string
26+
size: number
27+
uploadedBytes: number
28+
progress: number
29+
}
30+
31+
export type PhotoUploadProgressSnapshot = {
32+
totalBytes: number
33+
uploadedBytes: number
34+
files: PhotoUploadFileProgress[]
35+
}
36+
37+
export type UploadPhotoAssetsOptions = {
38+
directory?: string
39+
signal?: AbortSignal
40+
onProgress?: (snapshot: PhotoUploadProgressSnapshot) => void
41+
}
42+
2343
export async function runPhotoSync(
2444
payload: RunPhotoSyncPayload,
2545
options?: RunPhotoSyncOptions,
@@ -176,8 +196,12 @@ export async function deletePhotoAssets(ids: string[], options?: { deleteFromSto
176196

177197
export async function uploadPhotoAssets(
178198
files: File[],
179-
options?: { directory?: string },
199+
options?: UploadPhotoAssetsOptions,
180200
): Promise<PhotoAssetListItem[]> {
201+
if (files.length === 0) {
202+
return []
203+
}
204+
181205
const formData = new FormData()
182206

183207
if (options?.directory) {
@@ -188,14 +212,116 @@ export async function uploadPhotoAssets(
188212
formData.append('files', file)
189213
}
190214

191-
const response = await coreApi<{ assets: PhotoAssetListItem[] }>('/photos/assets/upload', {
192-
method: 'POST',
193-
body: formData,
194-
})
215+
if (typeof XMLHttpRequest === 'undefined') {
216+
const fallbackResponse = await coreApi<{ assets: PhotoAssetListItem[] }>('/photos/assets/upload', {
217+
method: 'POST',
218+
body: formData,
219+
})
220+
const fallbackData = camelCaseKeys<{ assets: PhotoAssetListItem[] }>(fallbackResponse)
221+
return fallbackData.assets
222+
}
223+
224+
const fileMetadata = files.map((file, index) => ({
225+
index,
226+
name: file.name,
227+
size: file.size,
228+
}))
229+
const totalBytes = fileMetadata.reduce((sum, file) => sum + file.size, 0)
230+
231+
const snapshotFromLoaded = (loaded: number): PhotoUploadProgressSnapshot => {
232+
let remaining = loaded
233+
const filesProgress: PhotoUploadFileProgress[] = fileMetadata.map((meta) => {
234+
const uploadedForFile = Math.max(0, Math.min(meta.size, remaining))
235+
remaining -= uploadedForFile
236+
return {
237+
index: meta.index,
238+
name: meta.name,
239+
size: meta.size,
240+
uploadedBytes: uploadedForFile,
241+
progress: meta.size === 0 ? 1 : Math.min(1, uploadedForFile / meta.size),
242+
}
243+
})
244+
245+
return {
246+
totalBytes,
247+
uploadedBytes: Math.min(loaded, totalBytes),
248+
files: filesProgress,
249+
}
250+
}
251+
252+
return await new Promise<PhotoAssetListItem[]>((resolve, reject) => {
253+
const xhr = new XMLHttpRequest()
254+
xhr.open('POST', `${coreApiBaseURL}/photos/assets/upload`, true)
255+
xhr.withCredentials = true
256+
xhr.responseType = 'json'
257+
258+
const handleAbort = () => {
259+
xhr.abort()
260+
}
261+
262+
const cleanup = () => {
263+
if (options?.signal) {
264+
options.signal.removeEventListener('abort', handleAbort)
265+
}
266+
}
267+
268+
if (options?.signal) {
269+
if (options.signal.aborted) {
270+
cleanup()
271+
reject(new DOMException('Upload aborted', 'AbortError'))
272+
return
273+
}
274+
options.signal.addEventListener('abort', handleAbort)
275+
}
195276

196-
const data = camelCaseKeys<{ assets: PhotoAssetListItem[] }>(response)
277+
xhr.upload.onprogress = (event: ProgressEvent<EventTarget>) => {
278+
if (!options?.onProgress) {
279+
return
280+
}
281+
const loaded = event.lengthComputable ? event.loaded : totalBytes
282+
options.onProgress(snapshotFromLoaded(loaded))
283+
}
197284

198-
return data.assets
285+
xhr.onerror = () => {
286+
cleanup()
287+
reject(new Error('上传过程中出现网络错误,请稍后再试。'))
288+
}
289+
290+
xhr.onabort = () => {
291+
cleanup()
292+
reject(new DOMException('Upload aborted', 'AbortError'))
293+
}
294+
295+
xhr.onload = () => {
296+
cleanup()
297+
if (xhr.status >= 200 && xhr.status < 300) {
298+
try {
299+
const rawResponse = typeof xhr.response === 'string' ? JSON.parse(xhr.response) : xhr.response
300+
const parsed = camelCaseKeys<{ assets: PhotoAssetListItem[] }>(rawResponse)
301+
resolve(parsed.assets)
302+
} catch (error) {
303+
reject(error instanceof Error ? error : new Error('无法解析上传响应'))
304+
}
305+
return
306+
}
307+
308+
let message = `上传失败:${xhr.status}`
309+
const {responseText} = xhr
310+
if (responseText) {
311+
try {
312+
const parsed = JSON.parse(responseText)
313+
if (parsed && typeof parsed.message === 'string') {
314+
message = parsed.message
315+
}
316+
} catch {
317+
// ignore parse error
318+
}
319+
}
320+
reject(new Error(message))
321+
}
322+
323+
xhr.send(formData)
324+
})
199325
}
200326

201327
export async function getPhotoStorageUrl(storageKey: string): Promise<string> {

be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
import { DeleteFromStorageOption } from './library/DeleteFromStorageOption'
3232
import type { DeleteAssetOptions } from './library/PhotoLibraryGrid'
3333
import { PhotoLibraryGrid } from './library/PhotoLibraryGrid'
34+
import type { PhotoUploadRequestOptions } from './library/upload.types'
3435
import { PhotoPageActions } from './PhotoPageActions'
3536
import { PhotoSyncConflictsPanel } from './sync/PhotoSyncConflictsPanel'
3637
import { PhotoSyncProgressPanel } from './sync/PhotoSyncProgressPanel'
@@ -106,6 +107,25 @@ export function PhotoPage() {
106107
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
107108
const isListLoading = listQuery.isLoading || listQuery.isFetching
108109
const libraryAssetCount = listQuery.data?.length ?? 0
110+
const availableTags = useMemo(() => {
111+
if (!listQuery.data || listQuery.data.length === 0) {
112+
return []
113+
}
114+
const tagSet = new Set<string>()
115+
for (const asset of listQuery.data) {
116+
const tags = asset.manifest?.data?.tags
117+
if (!Array.isArray(tags)) {
118+
continue
119+
}
120+
for (const tag of tags) {
121+
const normalized = typeof tag === 'string' ? tag.trim() : ''
122+
if (normalized) {
123+
tagSet.add(normalized)
124+
}
125+
}
126+
}
127+
return Array.from(tagSet).sort((a, b) => a.localeCompare(b))
128+
}, [listQuery.data])
109129

110130
const handleToggleSelect = (id: string) => {
111131
setSelectedIds((prev) => {
@@ -277,16 +297,22 @@ export function PhotoPage() {
277297
)
278298

279299
const handleUploadAssets = useCallback(
280-
async (files: FileList) => {
300+
async (files: FileList, options?: PhotoUploadRequestOptions) => {
281301
const fileArray = Array.from(files)
282302
if (fileArray.length === 0) return
283303
try {
284-
await uploadMutation.mutateAsync(fileArray)
304+
await uploadMutation.mutateAsync({
305+
files: fileArray,
306+
onProgress: options?.onUploadProgress,
307+
signal: options?.signal,
308+
directory: options?.directory ?? undefined,
309+
})
285310
toast.success(`成功上传 ${fileArray.length} 张图片`)
286311
void listQuery.refetch()
287312
} catch (error) {
288313
const message = getRequestErrorMessage(error, '上传失败,请稍后重试。')
289314
toast.error('上传失败', { description: message })
315+
throw error
290316
}
291317
},
292318
[listQuery, uploadMutation],
@@ -518,6 +544,7 @@ export function PhotoPage() {
518544
onDeleteSelected={handleDeleteSelected}
519545
onClearSelection={handleClearSelection}
520546
onSelectAll={handleSelectAll}
547+
availableTags={availableTags}
521548
onSyncCompleted={handleSyncCompleted}
522549
onSyncProgress={handleProgressEvent}
523550
onSyncError={handleSyncError}

be/apps/dashboard/src/modules/photos/components/PhotoPageActions.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MainPageLayout } from '~/components/layouts/MainPageLayout'
44

55
import type { PhotoSyncProgressEvent, PhotoSyncResult } from '../types'
66
import { PhotoLibraryActionBar } from './library/PhotoLibraryActionBar'
7+
import type { PhotoUploadRequestOptions } from './library/upload.types'
78
import type { PhotoPageTab } from './PhotoPage'
89
import { PhotoSyncActions } from './sync/PhotoSyncActions'
910

@@ -13,7 +14,8 @@ type PhotoPageActionsProps = {
1314
libraryTotalCount: number
1415
isUploading: boolean
1516
isDeleting: boolean
16-
onUpload: (files: FileList) => void | Promise<void>
17+
availableTags: string[]
18+
onUpload: (files: FileList, options?: PhotoUploadRequestOptions) => void | Promise<void>
1719
onDeleteSelected: () => void
1820
onClearSelection: () => void
1921
onSelectAll: () => void
@@ -28,6 +30,7 @@ export function PhotoPageActions({
2830
libraryTotalCount,
2931
isUploading,
3032
isDeleting,
33+
availableTags,
3134
onUpload,
3235
onDeleteSelected,
3336
onClearSelection,
@@ -56,6 +59,7 @@ export function PhotoPageActions({
5659
totalCount={libraryTotalCount}
5760
isUploading={isUploading}
5861
isDeleting={isDeleting}
62+
availableTags={availableTags}
5963
onUpload={onUpload}
6064
onDeleteSelected={onDeleteSelected}
6165
onClearSelection={onClearSelection}

be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import type { ChangeEventHandler } from 'react'
55
import { useRef } from 'react'
66

77
import { PhotoUploadConfirmModal } from './PhotoUploadConfirmModal'
8+
import type { PhotoUploadRequestOptions } from './upload.types'
89

910
type PhotoLibraryActionBarProps = {
1011
selectionCount: number
1112
totalCount: number
1213
isUploading: boolean
1314
isDeleting: boolean
14-
onUpload: (files: FileList) => void | Promise<void>
15+
availableTags: string[]
16+
onUpload: (files: FileList, options?: PhotoUploadRequestOptions) => void | Promise<void>
1517
onDeleteSelected: () => void
1618
onClearSelection: () => void
1719
onSelectAll: () => void
@@ -22,6 +24,7 @@ export function PhotoLibraryActionBar({
2224
totalCount,
2325
isUploading,
2426
isDeleting,
27+
availableTags,
2528
onUpload,
2629
onDeleteSelected,
2730
onClearSelection,
@@ -44,9 +47,8 @@ export function PhotoLibraryActionBar({
4447

4548
Modal.present(PhotoUploadConfirmModal, {
4649
files: selectedFiles,
47-
onConfirm: (confirmedFiles) => {
48-
void onUpload(confirmedFiles)
49-
},
50+
availableTags,
51+
onUpload,
5052
})
5153

5254
if (fileInputRef.current) {
@@ -118,7 +120,7 @@ export function PhotoLibraryActionBar({
118120
onClick={onSelectAll}
119121
className="flex items-center gap-1 text-text-secondary hover:text-text"
120122
>
121-
<DynamicIcon name={canSelectAll ? 'square' : 'check-square'} className="h-3.5 w-3.5" />
123+
<DynamicIcon name={canSelectAll ? 'square' : 'check-square'} className="size-4" />
122124
{hasAssets ? (canSelectAll ? '全选' : '已全选') : '全选'}
123125
</Button>
124126
</div>

0 commit comments

Comments
 (0)