feat: add props system and refactor asset library architecture

This commit is contained in:
saturn
2026-03-19 15:37:47 +08:00
parent 9aff44e37a
commit f364bbc9e4
139 changed files with 9112 additions and 2827 deletions

View File

@@ -13,7 +13,7 @@ interface GlobalAssetPickerProps {
isOpen: boolean
onClose: () => void
onSelect: (globalAssetId: string) => void
type: 'character' | 'location' | 'voice'
type: 'character' | 'location' | 'prop' | 'voice'
loading?: boolean
}
@@ -47,6 +47,8 @@ interface GlobalLocation {
images: GlobalLocationImage[]
}
type GlobalProp = GlobalLocation
interface GlobalVoice {
id: string
name: string
@@ -117,41 +119,54 @@ export default function GlobalAssetPicker({
const charactersQuery = useQuery({
queryKey: ['global-assets', 'characters'],
queryFn: async () => {
const res = await apiFetch('/api/asset-hub/characters')
const res = await apiFetch('/api/assets?scope=global&kind=character')
if (!res.ok) throw new Error('Failed to fetch characters')
const data = await res.json()
return data.characters as GlobalCharacter[]
return data.assets as GlobalCharacter[]
},
enabled: type === 'character',
})
const locationsQuery = useQuery({
queryKey: ['global-assets', 'locations'],
queryFn: async () => {
const res = await apiFetch('/api/asset-hub/locations')
const res = await apiFetch('/api/assets?scope=global&kind=location')
if (!res.ok) throw new Error('Failed to fetch locations')
const data = await res.json()
return data.locations as GlobalLocation[]
return data.assets as GlobalLocation[]
},
enabled: type === 'location',
})
const propsQuery = useQuery({
queryKey: ['global-assets', 'props'],
queryFn: async () => {
const res = await apiFetch('/api/assets?scope=global&kind=prop')
if (!res.ok) throw new Error('Failed to fetch props')
const data = await res.json()
return data.assets as GlobalProp[]
},
enabled: type === 'prop',
})
const voicesQuery = useQuery({
queryKey: ['global-assets', 'voices'],
queryFn: async () => {
const res = await apiFetch('/api/asset-hub/voices')
const res = await apiFetch('/api/assets?scope=global&kind=voice')
if (!res.ok) throw new Error('Failed to fetch voices')
const data = await res.json()
return data.voices as GlobalVoice[]
return data.assets as GlobalVoice[]
},
enabled: type === 'voice',
})
const characters = (charactersQuery.data || []) as GlobalCharacter[]
const locations = (locationsQuery.data || []) as GlobalLocation[]
const props = (propsQuery.data || []) as GlobalProp[]
const voices = (voicesQuery.data || []) as GlobalVoice[]
const isLoading = type === 'character'
? charactersQuery.isFetching
: type === 'location'
? locationsQuery.isFetching
: type === 'prop'
? propsQuery.isFetching
: voicesQuery.isFetching
const loadingState = isLoading
? resolveTaskPresentationState({
@@ -179,6 +194,7 @@ export default function GlobalAssetPicker({
// 提取稳定的 refetch 引用,避免 useEffect 无限循环
const refetchCharacters = charactersQuery.refetch
const refetchLocations = locationsQuery.refetch
const refetchProps = propsQuery.refetch
const refetchVoices = voicesQuery.refetch
// 停止音频播放的辅助函数
@@ -200,6 +216,8 @@ export default function GlobalAssetPicker({
refetchCharacters()
} else if (type === 'location') {
refetchLocations()
} else if (type === 'prop') {
refetchProps()
} else {
refetchVoices()
}
@@ -225,6 +243,10 @@ export default function GlobalAssetPicker({
l.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredProps = props.filter(l =>
l.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredVoices = voices.filter(v =>
v.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(v.description && v.description.toLowerCase().includes(searchQuery.toLowerCase()))
@@ -263,8 +285,20 @@ export default function GlobalAssetPicker({
if (!isOpen) return null
const items = type === 'character' ? filteredCharacters : type === 'location' ? filteredLocations : filteredVoices
const hasNoAssets = type === 'character' ? characters.length === 0 : type === 'location' ? locations.length === 0 : voices.length === 0
const items = type === 'character'
? filteredCharacters
: type === 'location'
? filteredLocations
: type === 'prop'
? filteredProps
: filteredVoices
const hasNoAssets = type === 'character'
? characters.length === 0
: type === 'location'
? locations.length === 0
: type === 'prop'
? props.length === 0
: voices.length === 0
return (
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50">
@@ -272,7 +306,7 @@ export default function GlobalAssetPicker({
{/* 头部 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--glass-stroke-base)]">
<h2 className="text-lg font-semibold text-[var(--glass-text-primary)]">
{type === 'character' ? t('selectCharacter') : type === 'location' ? t('selectLocation') : t('selectVoice')}
{type === 'character' ? t('selectCharacter') : type === 'location' ? t('selectLocation') : type === 'prop' ? t('selectProp') : t('selectVoice')}
</h2>
<button onClick={onClose} className="glass-btn-base glass-btn-soft text-[var(--glass-text-tertiary)]">
<XMarkIcon className="w-5 h-5" />
@@ -303,7 +337,7 @@ export default function GlobalAssetPicker({
<div className="flex flex-col items-center justify-center h-40 text-[var(--glass-text-tertiary)]">
{type === 'character' ? (
<UserIcon className="w-12 h-12 mb-2" />
) : type === 'location' ? (
) : type === 'location' || type === 'prop' ? (
<PhotoIcon className="w-12 h-12 mb-2" />
) : (
<MicrophoneIcon className="w-12 h-12 mb-2" />
@@ -412,6 +446,48 @@ export default function GlobalAssetPicker({
</div>
)
})
) : type === 'prop' ? (
filteredProps.map((prop) => {
const propPreview = getLocationPreview(prop)
return (
<div
key={prop.id}
onClick={() => setSelectedId(prop.id)}
className={`relative cursor-pointer rounded-xl border-2 p-2 transition-all hover:shadow-md ${selectedId === prop.id
? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]'
: 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'
}`}
>
{selectedId === prop.id && (
<CheckCircleIcon className="absolute -top-2 -right-2 w-6 h-6 text-[var(--glass-tone-info-fg)] bg-[var(--glass-bg-surface)] rounded-full" />
)}
<div className="aspect-video rounded-lg overflow-hidden bg-[var(--glass-bg-muted)] mb-2 relative">
{propPreview ? (
<MediaImageWithLoading
src={propPreview}
alt={prop.name}
containerClassName="w-full h-full"
className="w-full h-full object-cover cursor-zoom-in"
onClick={(e) => {
e.stopPropagation()
setPreviewImage(propPreview)
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-[var(--glass-text-tertiary)]">
<PhotoIcon className="w-12 h-12" />
</div>
)}
</div>
<div className="text-center">
<p className="font-medium text-sm text-[var(--glass-text-primary)] truncate">{prop.name}</p>
<p className="text-xs text-[var(--glass-text-secondary)] mt-1">
{prop.images?.length || 0} {t('images')}
</p>
</div>
</div>
)
})
) : (
// 音色列表渲染 - 与资产中心 VoiceCard 风格统一
filteredVoices.map((voice) => {

View File

@@ -0,0 +1,165 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { AppIcon } from '@/components/ui/icons'
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { useAssetActions } from '@/lib/query/hooks'
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
export interface PropCreationModalProps {
mode: 'asset-hub' | 'project'
folderId?: string | null
projectId?: string
onClose: () => void
onSuccess: () => void
}
export function PropCreationModal({
mode,
folderId,
projectId,
onClose,
onSuccess,
}: PropCreationModalProps) {
const t = useTranslations('assetModal')
const actions = useAssetActions({
scope: mode === 'asset-hub' ? 'global' : 'project',
projectId,
kind: 'prop',
})
const { count, setCount } = useImageGenerationCount('location')
const [name, setName] = useState('')
const [summary, setSummary] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [isSubmitting, setIsSubmitting] = useState(false)
const submittingState = isSubmitting
? resolveTaskPresentationState({
phase: 'processing',
intent: 'generate',
resource: 'image',
hasOutput: false,
})
: null
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isSubmitting) {
onClose()
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [isSubmitting, onClose])
const handleSubmit = async (generateAfterCreate: boolean) => {
if (!name.trim() || !summary.trim()) return
try {
setIsSubmitting(true)
const result = await actions.create({
name: name.trim(),
summary: summary.trim(),
folderId,
artStyle,
}) as { assetId?: string }
if (generateAfterCreate) {
if (!result.assetId) {
throw new Error('Missing assetId from create response')
}
await actions.generate({
id: result.assetId,
artStyle,
count,
})
}
onSuccess()
onClose()
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
<div className="glass-surface-modal max-w-2xl w-full max-h-[85vh] flex flex-col">
<div className="p-6 overflow-y-auto flex-1">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
{t('prop.title')}
</h3>
<button
onClick={onClose}
className="glass-btn-base glass-btn-soft w-8 h-8 rounded-full flex items-center justify-center text-[var(--glass-text-tertiary)]"
>
<AppIcon name="close" className="w-5 h-5" />
</button>
</div>
<div className="space-y-5">
<div className="space-y-2">
<label className="glass-field-label block">
{t('prop.name')} <span className="text-[var(--glass-tone-danger-fg)]">*</span>
</label>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('prop.namePlaceholder')}
className="glass-input-base w-full px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label className="glass-field-label block">
{t('prop.summary')} <span className="text-[var(--glass-tone-danger-fg)]">*</span>
</label>
<textarea
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder={t('prop.summaryPlaceholder')}
className="glass-textarea-base w-full h-36 px-3 py-2 text-sm resize-none"
/>
</div>
</div>
</div>
<div className="flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-xl flex-shrink-0">
<button
onClick={onClose}
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
onClick={() => void handleSubmit(false)}
disabled={isSubmitting || !name.trim() || !summary.trim()}
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm flex items-center gap-2"
>
{isSubmitting ? (
<TaskStatusInline state={submittingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
) : (
<span>{mode === 'asset-hub' ? t('common.addOnlyToAssetHubProp') : t('common.addOnlyProp')}</span>
)}
</button>
<ImageGenerationInlineCountButton
prefix={<span>{t('common.addAndGeneratePrefix')}</span>}
suffix={<span>{t('common.generateCountSuffix')}</span>}
value={count}
options={getImageGenerationCountOptions('location')}
onValueChange={setCount}
onClick={() => void handleSubmit(true)}
actionDisabled={!name.trim() || !summary.trim()}
selectDisabled={isSubmitting}
ariaLabel={t('common.selectGenerateCount')}
className="glass-btn-base glass-btn-primary flex items-center justify-center gap-1 rounded-lg px-4 py-2 text-sm disabled:opacity-40 disabled:cursor-not-allowed"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-sm font-semibold text-current outline-none cursor-pointer leading-none transition-colors"
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,157 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { AppIcon } from '@/components/ui/icons'
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { useAssetActions } from '@/lib/query/hooks'
export interface PropEditModalProps {
mode: 'asset-hub' | 'project'
propId: string
propName: string
summary: string
variantId?: string
projectId?: string
onClose: () => void
onRefresh?: () => void
}
export function PropEditModal({
mode,
propId,
propName,
summary,
variantId,
projectId,
onClose,
onRefresh,
}: PropEditModalProps) {
const t = useTranslations('assets')
const actions = useAssetActions({
scope: mode === 'asset-hub' ? 'global' : 'project',
projectId,
kind: 'prop',
})
const [editingName, setEditingName] = useState(propName)
const [editingSummary, setEditingSummary] = useState(summary)
const [isSaving, setIsSaving] = useState(false)
const savingState = isSaving
? resolveTaskPresentationState({
phase: 'processing',
intent: 'process',
resource: 'text',
hasOutput: false,
})
: null
const persist = async () => {
await actions.update(propId, {
name: editingName.trim(),
summary: editingSummary.trim(),
})
if (variantId) {
await actions.updateVariant(propId, variantId, {
description: editingSummary.trim(),
})
}
onRefresh?.()
}
const handleSaveOnly = async () => {
if (!editingName.trim() || !editingSummary.trim()) return
try {
setIsSaving(true)
await persist()
onClose()
} finally {
setIsSaving(false)
}
}
const handleSaveAndGenerate = async () => {
if (!editingName.trim() || !editingSummary.trim()) return
try {
setIsSaving(true)
await persist()
await actions.generate({ id: propId })
onClose()
} finally {
setIsSaving(false)
}
}
return (
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
<div className="glass-surface-modal max-w-2xl w-full max-h-[80vh] flex flex-col">
<div className="p-6 space-y-4 overflow-y-auto flex-1">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
{t('modal.editProp')} - {propName}
</h3>
<button
onClick={onClose}
className="glass-btn-base glass-btn-soft w-9 h-9 rounded-full text-[var(--glass-text-tertiary)]"
>
<AppIcon name="close" className="w-6 h-6" />
</button>
</div>
<div className="space-y-2">
<label className="glass-field-label block">
{t('prop.name')}
</label>
<input
type="text"
value={editingName}
onChange={(event) => setEditingName(event.target.value)}
className="glass-input-base w-full px-3 py-2"
placeholder={t('modal.namePlaceholder')}
/>
</div>
<div className="space-y-2">
<label className="glass-field-label block">
{t('prop.summary')}
</label>
<textarea
value={editingSummary}
onChange={(event) => setEditingSummary(event.target.value)}
className="glass-textarea-base w-full h-48 px-3 py-2 resize-none"
placeholder={t('prop.summaryPlaceholder')}
/>
</div>
</div>
<div className="flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-lg flex-shrink-0">
<button
onClick={onClose}
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg"
disabled={isSaving}
>
{t('common.cancel')}
</button>
<button
onClick={() => void handleSaveOnly()}
disabled={isSaving || !editingName.trim() || !editingSummary.trim()}
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSaving ? (
<TaskStatusInline state={savingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
) : (
t('modal.saveOnly')
)}
</button>
<button
onClick={() => void handleSaveAndGenerate()}
disabled={isSaving || !editingName.trim() || !editingSummary.trim()}
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('modal.saveAndGenerate')}
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,9 +1,12 @@
export { CharacterCreationModal } from './CharacterCreationModal'
export { LocationCreationModal } from './LocationCreationModal'
export { PropCreationModal } from './PropCreationModal'
export { CharacterEditModal } from './CharacterEditModal'
export { LocationEditModal } from './LocationEditModal'
export { PropEditModal } from './PropEditModal'
export type { CharacterCreationModalProps } from './CharacterCreationModal'
export type { LocationCreationModalProps } from './LocationCreationModal'
export type { PropCreationModalProps } from './PropCreationModal'
export type { CharacterEditModalProps } from './CharacterEditModal'
export type { LocationEditModalProps } from './LocationEditModal'
export type { PropEditModalProps } from './PropEditModal'

View File

@@ -1,6 +1,6 @@
'use client'
import type { ReactNode } from 'react'
import { useRef, useState, useEffect, type ReactNode } from 'react'
// ─── Types ────────────────────────────────────────────
@@ -20,11 +20,12 @@ interface SegmentedControlProps<T extends string = string> {
// ─── Component ────────────────────────────────────────
/**
* Unified iOS-style segmented control.
* Unified iOS-style segmented control with sliding pill indicator.
*
* Single source of truth for all tab/segment UIs across the app.
* Uses per-button selected styling (not a sliding indicator) for
* pixel-perfect equal padding on all four sides.
* Indicator lives inside the grid container to share the same
* positioning context as buttons — guaranteeing equal padding
* on all four sides (Apple-style).
*/
export function SegmentedControl<T extends string = string>({
options,
@@ -32,20 +33,39 @@ export function SegmentedControl<T extends string = string>({
onChange,
className = '',
}: SegmentedControlProps<T>) {
const gridRef = useRef<HTMLDivElement>(null)
const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 })
useEffect(() => {
if (!gridRef.current) return
const activeIndex = options.findIndex((opt) => opt.value === value)
const buttons = gridRef.current.querySelectorAll<HTMLButtonElement>('button')
const activeButton = buttons[activeIndex]
if (activeButton) {
setIndicator({ left: activeButton.offsetLeft, width: activeButton.offsetWidth })
}
}, [value, options])
return (
<div className={`rounded-lg p-[3px] bg-[#f2f2f7] dark:bg-[#1c1c1e] shadow-inner ${className}`}>
<div className={`rounded-xl p-[3px] bg-[#e8e8ed] dark:bg-[#1c1c1e] ${className}`}>
<div
className="grid"
ref={gridRef}
className="relative grid"
style={{ gridTemplateColumns: `repeat(${Math.max(1, options.length)}, minmax(0, 1fr))` }}
>
{/* Sliding pill indicator */}
<div
className="absolute top-0 bottom-0 rounded-[10px] bg-white dark:bg-[#3a3a3c] shadow-[0_1px_3px_rgba(0,0,0,0.08),0_4px_12px_rgba(0,0,0,0.05)] transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
style={{ left: indicator.left, width: indicator.width }}
/>
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={`flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition-all cursor-pointer ${value === opt.value
? 'bg-white text-[var(--glass-text-primary)] dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold'
: 'text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]'
className={`relative z-10 flex items-center justify-center gap-1.5 rounded-[10px] px-3 py-1.5 text-[13px] font-semibold transition-colors duration-200 cursor-pointer ${value === opt.value
? 'text-[#1d1d1f] dark:text-white'
: 'text-[#86868b] hover:text-[#6e6e73]'
}`}
>
{opt.label}