Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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({
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 @@ -68,9 +68,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 @@ -1034,15 +1037,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 @@ -1355,13 +1367,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 @@ -1435,7 +1455,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 @@ -1694,6 +1714,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 @@ -543,6 +543,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 { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useLocation, useParams } from 'react-router'
import { UploadSimple } from '@phosphor-icons/react'

import {
projectApis,
Expand Down Expand Up @@ -39,7 +40,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 { loadDefaultActionPresets, type WorkflowEditorSession } from './runtime'
import { useWorkflowEditorSession } from './use-workflow-editor-session'
import { WorkflowEditorView, type WorkflowCardNode } from './workflow-editor-view'
Expand Down Expand Up @@ -505,6 +513,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 @@ -533,6 +542,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 @@ -558,18 +573,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,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'
Loading