diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 14be5b7e..90e30e90 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -2767,6 +2767,38 @@ describe('QuickStartPage', () => { await waitFor(() => expect(service.dispose).toHaveBeenCalledTimes(2)) }) + it('accepts a supported role template dropped anywhere on the entry composer', () => { + renderAt('/quick-start', serviceFor(null)) + const dropzone = screen.getByRole('form', { name: '角色母版图片上传区' }) + const file = new File(['pixels'], 'hero.webp', { type: 'image/webp' }) + + expect(dropzone.textContent).toContain('PNG、JPG、GIF、WEBP') + expect(dropzone.textContent).toContain('10 MB') + + fireEvent.dragEnter(dropzone, { + dataTransfer: { files: [file], types: ['Files'] }, + }) + expect(screen.getByText('松开以添加角色母版')).toBeTruthy() + + fireEvent.drop(dropzone, { + dataTransfer: { files: [file], types: ['Files'] }, + }) + expect(screen.getByText('hero.webp')).toBeTruthy() + }) + + it('rejects unsupported files dropped on the entry composer', () => { + renderAt('/quick-start', serviceFor(null)) + const dropzone = screen.getByRole('form', { name: '角色母版图片上传区' }) + const file = new File(['not-an-image'], 'notes.txt', { type: 'text/plain' }) + + fireEvent.drop(dropzone, { + dataTransfer: { files: [file], types: ['Files'] }, + }) + + expect(screen.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、GIF、WEBP') + expect(screen.queryByText('notes.txt')).toBeNull() + }) + it('shows entry errors and supports removing an uploaded template', async () => { const service = serviceFor(null) const agent = agentFor({ diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index de032d69..ae94221e 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -68,9 +68,12 @@ import { FrameAnimationPlayer, GenerationPreviewCard, GenerationProgressCopy, + IMAGE_UPLOAD_ACCEPT, + IMAGE_UPLOAD_HINT, KineticCopyCycle, productPopoverClass, productPopoverMotionClass, + useImageDropTarget, useProductPopoverMotion, type KineticCopyMessage, } from '@/shared/ui' @@ -1034,8 +1037,8 @@ function QuickStartInput({ function selectTemplateFile(event: ChangeEvent) { if (entryBusy) return const selected = event.target.files?.[0] ?? null - setTemplateFile(selected) - setError(null) + event.target.value = '' + if (selected) templateDropTarget.selectFile(selected) } function removeTemplateFile() { @@ -1043,6 +1046,15 @@ function QuickStartInput({ if (fileInput.current) fileInput.current.value = '' } + const templateDropTarget = useImageDropTarget({ + disabled: entryBusy || hasConversation, + onFile(file) { + setTemplateFile(file) + setError(null) + }, + onError: setError, + }) + function chooseGameStyle(next: ArtStyle) { gameStyleRef.current = next setGameStyle(next) @@ -1355,13 +1367,21 @@ function QuickStartInput({
void submit(event)} autoComplete="off" + aria-label="角色母版图片上传区" data-prompt-state={promptState} + data-drag-active={templateDropTarget.isDragging} + {...templateDropTarget.dropTargetProps} className={`quick-start-agent-composer relative flex flex-col ${ hasConversation ? 'mt-12 rounded-app-surface border border-app-line-strong bg-app-surface-raised shadow-app-panel transition-[border-color,box-shadow] focus-within:border-app-accent focus-within:shadow-[var(--shadow-app-composer-focus)]' : '' }`} > + {templateDropTarget.isDragging ? ( +
+ 松开以添加角色母版 +
+ ) : null}
角色参考图(选填) - { - const file = event.currentTarget.files?.[0] - event.currentTarget.value = '' - if (file) uploadReferenceImage(file) - }} - /> +
+ { + const file = event.currentTarget.files?.[0] + event.currentTarget.value = '' + if (file) referenceDropTarget.selectFile(file) + }} + /> + +
{uploadingReference ? ( 正在上传参考图… diff --git a/frontend/src/shared/ui/image-drop-target.ts b/frontend/src/shared/ui/image-drop-target.ts new file mode 100644 index 00000000..dd791c01 --- /dev/null +++ b/frontend/src/shared/ui/image-drop-target.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react' + +export const IMAGE_UPLOAD_ACCEPT = 'image/png,image/jpeg,image/gif,image/webp' +export const IMAGE_UPLOAD_HINT = '支持 PNG、JPG、GIF、WEBP,单张不超过 10 MB' + +const IMAGE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024 +const IMAGE_UPLOAD_TYPES = new Set(IMAGE_UPLOAD_ACCEPT.split(',')) + +export interface ImageDropTargetOptions { + disabled?: boolean + onFile(file: File): void + onError(message: string): void +} + +export function imageUploadError(file: File): string | null { + if (!IMAGE_UPLOAD_TYPES.has(file.type)) return '仅支持 PNG、JPG、GIF、WEBP 图片' + if (file.size > IMAGE_UPLOAD_MAX_BYTES) return '图片不能超过 10 MB' + return null +} + +function isFileDrag(event: DragEvent) { + return Array.from(event.dataTransfer.types).includes('Files') +} + +export function useImageDropTarget({ disabled = false, onFile, onError }: ImageDropTargetOptions) { + const [isDragging, setIsDragging] = useState(false) + const dragDepth = useRef(0) + + useEffect(() => { + if (!disabled) return + dragDepth.current = 0 + setIsDragging(false) + }, [disabled]) + + const selectFile = useCallback( + (file: File) => { + if (disabled) return false + const message = imageUploadError(file) + if (message) { + onError(message) + return false + } + onFile(file) + return true + }, + [disabled, onError, onFile], + ) + + const onDragEnter = useCallback( + (event: DragEvent) => { + if (!isFileDrag(event)) return + event.preventDefault() + if (disabled) return + dragDepth.current += 1 + setIsDragging(true) + }, + [disabled], + ) + + const onDragOver = useCallback((event: DragEvent) => { + if (!isFileDrag(event)) return + event.preventDefault() + }, []) + + const onDragLeave = useCallback((event: DragEvent) => { + if (!isFileDrag(event)) return + event.preventDefault() + dragDepth.current = Math.max(0, dragDepth.current - 1) + if (dragDepth.current === 0) setIsDragging(false) + }, []) + + const onDrop = useCallback( + (event: DragEvent) => { + if (!isFileDrag(event)) return + event.preventDefault() + dragDepth.current = 0 + setIsDragging(false) + const file = event.dataTransfer.files[0] + if (file) selectFile(file) + }, + [selectFile], + ) + + return { + isDragging, + selectFile, + dropTargetProps: { onDragEnter, onDragOver, onDragLeave, onDrop }, + } as const +} diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index b7635a9d..931f933e 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -26,3 +26,10 @@ export { } from './product-control' export type { ProductControlVariant, ProductPopoverMotionState } from './product-control' export { useProductPopoverMotion } from './product-popover-motion' +export { + IMAGE_UPLOAD_ACCEPT, + IMAGE_UPLOAD_HINT, + imageUploadError, + useImageDropTarget, +} from './image-drop-target' +export type { ImageDropTargetOptions } from './image-drop-target'