Skip to content
32 changes: 32 additions & 0 deletions frontend/src/pages/quick-start/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3175,6 +3175,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({
Expand Down
31 changes: 28 additions & 3 deletions frontend/src/pages/quick-start/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,12 @@ import {
FrameAnimationPlayer,
GenerationPreviewCard,
GenerationProgressCopy,
IMAGE_UPLOAD_ACCEPT,
IMAGE_UPLOAD_HINT,
KineticCopyCycle,
productPopoverClass,
productPopoverMotionClass,
useImageDropTarget,
useProductPopoverMotion,
type KineticCopyMessage,
} from '@/shared/ui'
Expand Down Expand Up @@ -1196,15 +1199,24 @@ function QuickStartInput({
function selectTemplateFile(event: ChangeEvent<HTMLInputElement>) {
if (entryBusy) return
const selected = event.target.files?.[0] ?? null
setTemplateFile(selected)
setError(null)
event.target.value = ''
if (selected) templateDropTarget.selectFile(selected)
}

function removeTemplateFile() {
setTemplateFile(null)
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)
Expand Down Expand Up @@ -1525,13 +1537,21 @@ function QuickStartInput({
<form
onSubmit={(event) => 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 ? (
<div className="pointer-events-none absolute inset-0 z-30 grid place-items-center rounded-app-surface border-2 border-dashed border-app-accent bg-app-surface-raised/95 text-sm font-semibold text-app-accent shadow-app-panel backdrop-blur-sm">
松开以添加角色母版
</div>
) : null}
<label
className={`relative block min-h-[52px] min-w-0 overflow-hidden ${
hasConversation
Expand Down Expand Up @@ -1605,7 +1625,7 @@ function QuickStartInput({
<input
ref={fileInput}
type="file"
accept="image/*"
accept={IMAGE_UPLOAD_ACCEPT}
aria-label="上传角色母版"
disabled={entryBusy || hasConversation}
className="sr-only"
Expand Down Expand Up @@ -1968,6 +1988,11 @@ function QuickStartInput({
</div>
</div>
</div>
{!hasConversation ? (
<p className="order-first mb-1 px-2 text-[11px] leading-4 text-app-faint">
拖入角色母版,或点击添加 · {IMAGE_UPLOAD_HINT}
</p>
) : null}
</form>

{unavailableReason ? (
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/pages/workflow-editor/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,48 @@ describe('WorkflowEditorPage real runtime boundary', () => {
).toBe(false)
})

it('支持在角色设定卡的大区域拖入参考图并提示格式限制', async () => {
const uploadReferenceImage = vi.fn().mockResolvedValue('opaque-reference-1' as MediaReference)
const session = createSession(workflowFixture(), { uploadReferenceImage })
defaultSessionLoader.mockResolvedValue(session)
renderEditor('/workflow-editor/42')
const dropzone = await screen.findByRole('group', { name: '角色参考图上传区' })
const file = new File(['pixels'], 'reference.gif', { type: 'image/gif' })

expect(dropzone.textContent).toContain('拖入图片,或点击选择')
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'] },
})
await waitFor(() =>
expect(uploadReferenceImage).toHaveBeenCalledWith(file, expect.any(AbortSignal)),
)
})

it('拖入超过 10 MB 的参考图时在本地拒绝且不发起上传', async () => {
const uploadReferenceImage = vi.fn()
const session = createSession(workflowFixture(), { uploadReferenceImage })
defaultSessionLoader.mockResolvedValue(session)
renderEditor('/workflow-editor/42')
const dropzone = await screen.findByRole('group', { name: '角色参考图上传区' })
const file = new File(['pixels'], 'oversized.png', { type: 'image/png' })
Object.defineProperty(file, 'size', { value: 10 * 1024 * 1024 + 1 })

fireEvent.drop(dropzone, {
dataTransfer: { files: [file], types: ['Files'] },
})

expect(screen.getByRole('alert').textContent).toContain('图片不能超过 10 MB')
expect(uploadReferenceImage).not.toHaveBeenCalled()
})

it('上传失败时提示错误且不写入 WorkflowRun', async () => {
const uploadReferenceImage = vi.fn().mockRejectedValue(new Error('对象存储暂不可用'))
const session = createSession(workflowFixture(), { uploadReferenceImage })
Expand Down
72 changes: 59 additions & 13 deletions frontend/src/pages/workflow-editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import '@xyflow/react/dist/style.css'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useLocation, useParams } from 'react-router'
import { UploadSimple } from '@phosphor-icons/react'

import {
characterApis,
Expand Down Expand Up @@ -43,7 +44,14 @@ import {
ExportButton,
type ExportPackageModel,
} from '@/features/export-package'
import { FrameAnimationPlayer, GenerationPreviewCard, GenerationProgressCopy } from '@/shared/ui'
import {
FrameAnimationPlayer,
GenerationPreviewCard,
GenerationProgressCopy,
IMAGE_UPLOAD_ACCEPT,
IMAGE_UPLOAD_HINT,
useImageDropTarget,
} from '@/shared/ui'
import { Render3DAssetPanel } from './render3d-panel'
import { loadDefaultActionPresets, type WorkflowEditorSession } from './runtime'
import { useWorkflowEditorSession } from './use-workflow-editor-session'
Expand Down Expand Up @@ -641,6 +649,7 @@ function CharacterSetupContent({
const [uploadingReference, setUploadingReference] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const uploadAbortRef = useRef<AbortController | null>(null)
const referenceInputRef = useRef<HTMLInputElement | null>(null)

useEffect(() => () => uploadAbortRef.current?.abort(), [])

Expand Down Expand Up @@ -669,6 +678,12 @@ function CharacterSetupContent({
})
}

const referenceDropTarget = useImageDropTarget({
disabled: branchBusy || uploadingReference,
onFile: uploadReferenceImage,
onError: setUploadError,
})

if (node.status === 'failed') return <StatusText node={node} input={input} />
if (node.status === 'passed') return <p className={CARD_SUMMARY}>角色描述已确认</p>
return (
Expand All @@ -694,18 +709,49 @@ function CharacterSetupContent({
</label>
<div className="grid gap-[7px]">
<span className="text-[9px] font-[750] text-app-muted">角色参考图(选填)</span>
<input
type="file"
accept="image/*"
aria-label="角色参考图"
className="block w-full rounded-lg border border-[var(--color-app-line)] bg-app-surface text-[10px] text-[var(--color-app-muted)] file:mr-3 file:border-0 file:border-r file:border-[var(--color-app-line)] file:bg-transparent file:px-3 file:py-2 file:text-[10px] file:font-[700] file:text-[var(--color-app-ink)]"
disabled={branchBusy || uploadingReference}
onChange={(event) => {
const file = event.currentTarget.files?.[0]
event.currentTarget.value = ''
if (file) uploadReferenceImage(file)
}}
/>
<div
role="group"
aria-label="角色参考图上传区"
data-drag-active={referenceDropTarget.isDragging}
{...referenceDropTarget.dropTargetProps}
className={`rounded-xl border border-dashed transition-[border-color,background-color,box-shadow] duration-150 ${
referenceDropTarget.isDragging
? 'border-app-accent bg-app-accent-soft shadow-[0_0_0_2px_var(--color-app-accent-soft)]'
: 'border-app-line-strong bg-app-surface hover:border-app-accent hover:bg-app-surface-raised'
}`}
>
<input
ref={referenceInputRef}
type="file"
accept={IMAGE_UPLOAD_ACCEPT}
aria-label="角色参考图"
className="sr-only"
disabled={branchBusy || uploadingReference}
onChange={(event) => {
const file = event.currentTarget.files?.[0]
event.currentTarget.value = ''
if (file) referenceDropTarget.selectFile(file)
}}
/>
<button
type="button"
disabled={branchBusy || uploadingReference}
onClick={() => referenceInputRef.current?.click()}
className="flex min-h-24 w-full items-center gap-3 rounded-xl px-3.5 py-3 text-left transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-app-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-app-accent-muted text-app-accent">
<UploadSimple aria-hidden="true" size={18} weight="bold" />
</span>
<span className="grid min-w-0 gap-1">
<strong className="text-[10px] font-[750] text-app-ink-soft">
{referenceDropTarget.isDragging ? '松开以上传参考图' : '拖入图片,或点击选择'}
</strong>
<small className="text-[9px] leading-[1.45] text-app-faint">
{IMAGE_UPLOAD_HINT}
</small>
</span>
</button>
</div>
{uploadingReference ? (
<small role="status" className="text-[9px] font-[750] text-app-muted">
正在上传参考图…
Expand Down
89 changes: 89 additions & 0 deletions frontend/src/shared/ui/image-drop-target.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>) {
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<HTMLElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
if (disabled) return
dragDepth.current += 1
setIsDragging(true)
},
[disabled],
)

const onDragOver = useCallback((event: DragEvent<HTMLElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
}, [])

const onDragLeave = useCallback((event: DragEvent<HTMLElement>) => {
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<HTMLElement>) => {
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
}
7 changes: 7 additions & 0 deletions frontend/src/shared/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,12 @@ 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'
export { ProductSelect } from './product-select'
export type { ProductSelectOption, ProductSelectProps } from './product-select'
Loading