// creation-tool.jsx — Image generation (参考生图 + 文生图)
// Layout matches video-tool.jsx: AppShell + tabs inside aside + results panel
const IMG_ACCENT = '#22d3ee';
// ── OSS thumbnail helpers (shared with video-tool pattern) ──────────────────
let _ctCdnDomain = null;
const _ctFetchCdn = () => {
if (_ctCdnDomain !== null) return Promise.resolve(_ctCdnDomain);
const p = window.YiteAPI?.getCdnDomain?.() || Promise.resolve('');
return p.then(d => { _ctCdnDomain = d; return d; });
};
const _ctThumbUrl = (url, tag, cdnDomain) => {
if (!url || !cdnDomain || !url.startsWith(cdnDomain) || url.includes('?')) return url;
if (tag === 'video') return url + '?x-oss-process=video/snapshot,t_1000,f_jpg,w_200,h_200,m_fast';
if (tag === 'image') return url + '?x-oss-process=image/resize,w_200,h_200,m_fill/format,webp';
return url;
};
const _isVideoUrl2 = url => /\.(mp4|mov|webm|avi|mkv)(\?|$)/i.test(url);
const ResultMedia2 = ({ url, style: s }) => _isVideoUrl2(url)
?
:
;
const IMG_TABS = [
{ id: 'ref', labelKey: 'creation.tab.ref', icon: 'image' },
{ id: 'text', labelKey: 'creation.tab.text', icon: 'pen' },
{ id: 'template', labelKey: 'nav.template', icon: 'layers', hot: true },
];
// ─── Reuse API helpers from video-tool ───────────────────────────────────────
const _imgToken = () => window.YiteAPI?.getToken?.() || localStorage.getItem('token') || '';
const _imgAssetAPI = {
list: (parentID = '', tag = '') => {
let url = '/api/v1/assets?parent_id=' + encodeURIComponent(parentID);
if (tag) url += '&tag=' + encodeURIComponent(tag);
return fetch(url, { headers: { Authorization: 'Bearer ' + _imgToken() } }).then(r => r.json());
},
upload: (file, parentID = '') => {
const fd = new FormData(); fd.append('file', file); fd.append('parent_id', parentID);
return fetch('/api/v1/assets/upload', { method: 'POST', headers: { Authorization: 'Bearer ' + _imgToken() }, body: fd }).then(r => r.json());
},
};
const _imgTaskAPI = {
list: (type = '') => {
let url = '/api/v1/create-tasks?media_type=image';
if (type) url += '&type=' + encodeURIComponent(type);
return fetch(url, { headers: { Authorization: 'Bearer ' + _imgToken() } }).then(r => r.json());
},
publishTemplate: body => fetch('/api/v1/templates', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + _imgToken() },
body: JSON.stringify(body),
}).then(r => r.json()),
unpublishTemplate: templateId => fetch('/api/v1/templates/' + templateId + '/unpublish', {
method: 'POST', headers: { Authorization: 'Bearer ' + _imgToken() },
}).then(r => r.json()),
create: body => fetch('/api/v1/create-tasks', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + _imgToken() },
body: JSON.stringify({ ...body, media_type: 'image' }),
}).then(r => r.json()),
remove: id => fetch('/api/v1/create-tasks/' + id, {
method: 'DELETE', headers: { Authorization: 'Bearer ' + _imgToken() },
}).then(r => r.json()),
listTemplates: (page = 1) =>
fetch('/api/v1/templates?media_type=image&category=template&page_size=50&page=' + page).then(r => r.json()),
getTemplate: id =>
fetch('/api/v1/templates/' + id).then(r => r.json()),
};
// ─── Asset picker modal (self-contained for image page) ──────────────────────
const _IMG_ASSET_TYPE_INFO = {
image: { label: '图片', color: '#22d3ee', bg: 'rgba(34,211,238,0.15)' },
video: { label: '视频', color: '#a78bfa', bg: 'rgba(167,139,250,0.15)' },
audio: { label: '音频', color: '#34d399', bg: 'rgba(52,211,153,0.15)' },
};
const _imgAssetTypeInfo = tp => _IMG_ASSET_TYPE_INFO[tp] || { label: tp || '?', color: 'var(--fg-3)', bg: 'rgba(255,255,255,0.08)' };
const ImgAssetPickerModal = ({ max = 6, existing = 0, onSelect, onClose }) => {
useModalLock(true, onClose);
const { t } = useI18n();
const [folders, setFolders] = React.useState([]);
const [allFiles, setAllFiles] = React.useState([]);
const [path, setPath] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [selected, setSelected] = React.useState(new Set());
const [typeFilter, setTypeFilter] = React.useState('image');
const [viewMode, setViewMode] = React.useState('list');
const [cdnDomain, setCdnDomain] = React.useState(() => _ctCdnDomain || window.YiteAPI?.cdnDomain || '');
const pendingSelectRef = React.useRef(null);
const uploadRef = React.useRef(null);
const parentID = path.length > 0 ? path[path.length - 1].id : '';
const remaining = max - existing;
const files = typeFilter === 'all' ? allFiles : allFiles.filter(f => f.tag === typeFilter);
const fetchItems = async () => {
setLoading(true);
const res = await _imgAssetAPI.list(parentID, '');
const items = res.items || [];
setFolders(items.filter(i => i.is_folder));
const newFiles = items.filter(i => !i.is_folder);
setAllFiles(newFiles);
if (pendingSelectRef.current) {
const id = pendingSelectRef.current;
pendingSelectRef.current = null;
if (newFiles.some(f => f.id === id)) {
setSelected(prev => {
const next = new Set(prev);
if (next.size < remaining) next.add(id);
return next;
});
}
}
setLoading(false);
};
React.useEffect(() => {
fetchItems();
if (!_ctCdnDomain) _ctFetchCdn().then(d => setCdnDomain(d));
}, [parentID]);
const toggleSelect = id => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (next.size < remaining) next.add(id);
return next;
});
};
const handleUpload = async e => {
const f = e.target.files?.[0]; e.target.value = '';
if (!f) return;
setLoading(true);
const res = await _imgAssetAPI.upload(f, parentID);
if (!res.error && res.item?.id) pendingSelectRef.current = res.item.id;
fetchItems();
};
const handleConfirm = () => {
const picked = files.filter(f => selected.has(f.id)).map(f => ({ id: f.id, url: f.url, name: f.name }));
onSelect(picked);
onClose();
};
const uploadAccept = typeFilter === 'audio' ? 'audio/*' : typeFilter === 'video' ? 'video/*' : 'image/*';
const TYPE_FILTERS = [
{ key: 'all', label: '全部' }, { key: 'image', label: '图片' },
{ key: 'video', label: '视频' }, { key: 'audio', label: '音频' },
];
const ImgFileThumb = ({ f, size = 40 }) => {
const ti = _imgAssetTypeInfo(f.tag);
if (f.tag === 'audio') return (
);
if (!f.url) return (
);
const thumbSrc = _ctThumbUrl(f.url, f.tag, cdnDomain);
return
;
};
return (
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()}>
{/* Header */}
{t('tpl.selectImages')}
{t('tpl.canSelect')} {remaining - selected.size} {t('tpl.items')}
{[
{ mode: 'list', icon: },
{ mode: 'grid', icon: },
].map(({ mode, icon }) => (
))}
{/* Breadcrumb + type filter */}
{path.map((p, i) => (
))}
{TYPE_FILTERS.map(tf => (
))}
{/* Content */}
{loading ? (
) : viewMode === 'list' ? (
/* ── List view ── */
{folders.map(f => {
const hue = (f.name.charCodeAt(0) * 37) % 360;
return (
setPath([...path, { id: f.id, name: f.name }])} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '7px 8px',
borderRadius: 8, cursor: 'pointer', transition: 'background .1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{f.name}
);
})}
{files.map(f => {
const isSel = selected.has(f.id);
const ti = _imgAssetTypeInfo(f.tag);
return (
toggleSelect(f.id)} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '6px 8px',
borderRadius: 8, border: `1px solid ${isSel ? IMG_ACCENT + '60' : 'transparent'}`,
background: isSel ? 'rgba(34,211,238,0.05)' : 'transparent',
cursor: 'pointer', transition: 'all .1s',
}}
onMouseEnter={e => { if (!isSel) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { if (!isSel) e.currentTarget.style.background = 'transparent'; }}>
{f.name}
{ti.label}
);
})}
{files.length === 0 && folders.length === 0 && (
暂无文件
)}
) : (
/* ── Grid / Preview view ── */
{folders.map(f => {
const hue = (f.name.charCodeAt(0) * 37) % 360;
return (
setPath([...path, { id: f.id, name: f.name }])} style={{ aspectRatio: '1', borderRadius: 8, border: '1px solid var(--line-2)', background: `hsl(${hue} 50% 12%)`, cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6, transition: 'all .12s' }}
onMouseEnter={e => e.currentTarget.style.borderColor = `hsl(${hue} 60% 40%)`}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--line-2)'}>
{f.name}
);
})}
{files.map(f => {
const isSel = selected.has(f.id);
const ti = _imgAssetTypeInfo(f.tag);
return (
toggleSelect(f.id)} style={{ aspectRatio: '1', borderRadius: 8, overflow: 'hidden', position: 'relative', border: `2px solid ${isSel ? IMG_ACCENT : 'var(--line-2)'}`, cursor: 'pointer', transition: 'border-color .12s' }}>
{f.tag === 'audio' ? (
) : f.url ? (

) : (
)}
{ti.label}
{isSel &&
}
);
})}
)}
);
};
// ─── Ref image strip (with labels 图1, 图2 ...) ──────────────────────────────
const RefImageStrip = ({ images, onAdd, onRemove, size = 88 }) => {
const { t } = useI18n();
const [pickerOpen, setPickerOpen] = React.useState(false);
const handlePicked = picked => { picked.forEach(p => onAdd({ id: p.id, url: p.url, name: p.name })); };
return (
{images.map((img, idx) => (
{t('creation.image')}{idx + 1}
))}
{/* + 图片 button */}
{images.length < 6 && (
)}
{pickerOpen && ReactDOM.createPortal(
setPickerOpen(false)} />,
document.body
)}
);
};
// ─── Prompt with @图N inline highlights ──────────────────────────────────────
const ImgPromptTextarea = ({ value, onChange, images, placeholder, minHeight = 120 }) => {
const [showAt, setShowAt] = React.useState(false);
const [atPos, setAtPos] = React.useState({ top: 0, left: 0 });
const [caretIdx, setCaretIdx] = React.useState(0);
const [atFilter, setAtFilter] = React.useState('');
const [hlIdx, setHlIdx] = React.useState(0);
const taRef = React.useRef(null);
const getCaretCoords = (ta, pos) => {
const div = document.createElement('div');
const style = getComputedStyle(ta);
['font','fontSize','fontFamily','fontWeight','lineHeight','letterSpacing','wordSpacing',
'padding','border','boxSizing','whiteSpace','wordWrap','overflowWrap','width'].forEach(p => div.style[p] = style[p]);
div.style.position = 'absolute'; div.style.visibility = 'hidden'; div.style.height = 'auto'; div.style.overflow = 'hidden';
div.textContent = ta.value.substring(0, pos);
const span = document.createElement('span');
span.textContent = ta.value.substring(pos) || '.';
div.appendChild(span);
document.body.appendChild(div);
const taRect = ta.getBoundingClientRect();
const top = taRect.top + (span.offsetTop - ta.scrollTop) + span.getBoundingClientRect().height + 4;
document.body.removeChild(div);
return { top: Math.min(top, taRect.bottom + 4), left: taRect.left };
};
const filteredImgs = images.filter((img, i) => !atFilter || ('图' + (i + 1)).includes(atFilter) || img.name.toLowerCase().includes(atFilter));
const handleInput = e => {
const v = e.target.value;
const pos = e.target.selectionStart;
onChange(v);
const before = v.slice(0, pos);
const atMatch = before.match(/@([^\s@]*)$/);
if (atMatch && images.length > 0) {
setAtFilter(atMatch[1].toLowerCase());
setCaretIdx(pos);
setHlIdx(0);
const ta = taRef.current;
if (ta) setAtPos(getCaretCoords(ta, pos));
setShowAt(true);
} else { setShowAt(false); }
};
const insertMention = (img, idx) => {
const before = value.slice(0, caretIdx);
const atStart = before.lastIndexOf('@');
const after = value.slice(caretIdx);
const tag = '图' + (idx + 1);
const newVal = before.slice(0, atStart) + '@' + tag + ' ' + after;
onChange(newVal);
setShowAt(false);
};
const handleKeyDown = e => {
if (!showAt || filteredImgs.length === 0) return;
if (e.key === 'ArrowDown') { e.preventDefault(); setHlIdx(i => (i + 1) % filteredImgs.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHlIdx(i => (i - 1 + filteredImgs.length) % filteredImgs.length); }
else if (e.key === 'Enter') { e.preventDefault(); const img = filteredImgs[hlIdx]; insertMention(img, images.indexOf(img)); }
else if (e.key === 'Escape') { e.preventDefault(); setShowAt(false); }
};
return (
{showAt && filteredImgs.length > 0 && ReactDOM.createPortal(
引用图片 ↑↓ 选择 ↵ 确认
{filteredImgs.map((img, i) => {
const origIdx = images.indexOf(img);
const isHl = i === hlIdx;
return (
);
})}
,
document.body
)}
);
};
// ─── Build final prompt ──────────────────────────────────────────────────────
const buildImgPrompt = (rawPrompt, imageList) => {
let result = rawPrompt;
imageList.forEach((img, idx) => {
result = result.replace(new RegExp('@图' + (idx + 1), 'g'), 'image ' + (idx + 1));
});
return result;
};
// ─── Model selector modal (same as video page) ──────────────────────────────
const _imgIsNew = m => {
if (!m.created_at) return false;
return (Date.now() - new Date(m.created_at).getTime()) < 30 * 86400000;
};
const ImgModelPickerModal = ({ models, value, onChange, onClose }) => {
useModalLock(true, onClose);
const { t } = useI18n();
const [search, setSearch] = React.useState('');
const [platform, setPlatform] = React.useState('');
const platforms = [...new Set(models.map(m => m.platform).filter(Boolean))];
const filtered = models.filter(m =>
(!search || m.name.toLowerCase().includes(search.toLowerCase())) &&
(!platform || m.platform === platform)
);
return ReactDOM.createPortal(
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()}>
{t('creation.selectModel')}
{platforms.length > 1 && (
)}
setSearch(e.target.value)} placeholder={t('creation.search')} style={{ height: 34, paddingLeft: 28, paddingRight: 10, width: 150, borderRadius: 8, border: '1px solid var(--line-2)', background: 'rgba(255,255,255,0.03)', color: 'var(--fg-0)', fontSize: 14, outline: 'none' }} />
{filtered.map(m => {
const isSel = value === m.id;
const isNew = _imgIsNew(m);
return (
);
})}
{filtered.length === 0 &&
{t('creation.noModels')}
}
,
document.body
);
};
const ImgModelSelect = ({ value, onChange, models }) => {
const { t } = useI18n();
const active = models.find(m => m.id === value) || models[0];
const [open, setOpen] = React.useState(false);
return (
{open &&
setOpen(false)} />}
);
};
// ─── Aspect ratio select ─────────────────────────────────────────────────────
const IMG_ASPECTS = [
{ value: '1:1', w: 1, h: 1 },
{ value: '16:9', w: 16, h: 9 },
{ value: '9:16', w: 9, h: 16 },
{ value: '4:3', w: 4, h: 3 },
{ value: '3:4', w: 3, h: 4 },
];
const ImgAspectIcon = ({ w, h, size = 18, color = 'currentColor' }) => {
if (w === 0 && h === 0) {
return (
);
}
const maxDim = size - 4;
const scale = maxDim / Math.max(w, h);
const rw = Math.round(w * scale);
const rh = Math.round(h * scale);
const x = Math.round((size - rw) / 2);
const y = Math.round((size - rh) / 2);
return (
);
};
const ImgAspectSelect = ({ value, onChange }) => (
{IMG_ASPECTS.map(r => {
const isActive = r.value === value;
return (
);
})}
);
// ─── Param row ───────────────────────────────────────────────────────────────
const ImgParamRow = ({ label, children, info }) => (
{label}
{info && ⓘ}
{children}
);
// ─── Task card ───────────────────────────────────────────────────────────────
const _downloadFile = async (url, filename) => {
const token = window.YiteAPI?.getToken?.() || localStorage.getItem('token') || '';
try {
const res = await fetch('/api/v1/download?url=' + encodeURIComponent(url) + '&filename=' + encodeURIComponent(filename), {
headers: { Authorization: 'Bearer ' + token },
});
if (!res.ok) throw new Error('proxy ' + res.status);
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
} catch (e) {
console.error('download failed', e);
}
};
// ─── Media preview modal (image/video fullscreen) ────────────────────────────
const MediaPreviewModal = ({ url, type, onClose }) => ReactDOM.createPortal(
{type === 'video' ? (
,
document.body
);
// ─── Task card — feed-style with large result preview ────────────────────────
const ImgTaskCard = ({ task, onDelete, onPublish, onUnpublish, onRepeat, onEdit }) => {
const { t } = useI18n();
const [preview, setPreview] = React.useState(null);
const [publishing, setPublishing] = React.useState(false);
const isRunning = task.status === 'running' || task.status === 'pending';
const isDone = task.status === 'done';
const isPublished = task.published;
const statusLabel = task.status === 'pending' ? t('status.queued') : task.status === 'running' ? t('status.generating') : isDone ? t('status.completed') : t('status.failed');
const statusColor = task.status === 'error' ? '#f87171' : isRunning ? IMG_ACCENT : '#4ade80';
const results = task.results || [];
const mp = task.model_param || {};
const metaChips = [mp.aspect_ratio, mp.resolution].filter(Boolean);
return (
e.currentTarget.style.borderColor = 'rgba(34,211,238,0.25)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--line)'}>
{/* Header */}
{task.type}
{relativeTime(task.created_at)}
{metaChips.length > 0 && (
{metaChips.join(' · ')}
)}
{statusLabel}
{/* Prompt */}
{task.prompt && (
{task.prompt}
)}
{/* Result images — grid for multiple, single large for one */}
{results.length > 0 && (
{results.map((url, i) => (
setPreview(url)} style={{
borderRadius: 10, overflow: 'hidden', cursor: 'pointer', background: '#06080f',
flex: results.length === 1 ? '1 1 100%' : '1 1 calc(50% - 3px)',
maxWidth: results.length === 1 ? '100%' : 'calc(50% - 3px)',
}}>
))}
)}
{/* Running placeholder */}
{isRunning && results.length === 0 && (
)}
{/* Error */}
{task.error_msg && (
{task.error_msg}
)}
{/* Actions */}
{/* Repeat */}
{/* Edit */}
{/* Download */}
{isDone && results.length > 0 && (
)}
{/* Publish */}
{isDone && (
)}
{/* Preview modal */}
{preview &&
setPreview(null)} />}
);
};
// ─── Folder picker (same as video-tool, but with IMG_ACCENT) ─────────────────
const ImgFolderPickerModal = ({ onSelect, onClose }) => {
useModalLock(true, onClose);
const { t } = useI18n();
const [folders, setFolders] = React.useState([]);
const [path, setPath] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const parentID = path.length > 0 ? path[path.length - 1].id : '';
const fetchFolders = async () => { setLoading(true); const res = await _imgAssetAPI.list(parentID, ''); setFolders((res.items || []).filter(i => i.is_folder)); setLoading(false); };
React.useEffect(() => { fetchFolders(); }, [parentID]);
return ReactDOM.createPortal(
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()}>
选择保存文件夹
{path.map((p, i) => (
))}
{loading ? (
) : folders.length > 0 ? (
{folders.map(f => {
const hue = (f.name.charCodeAt(0) * 37) % 360;
return (
{ e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'var(--line-2)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'transparent'; }}>
{ e.stopPropagation(); setPath([...path, { id: f.id, name: f.name }]); }} style={{ flex: 1, fontSize: 14, fontWeight: 500, color: 'var(--fg-0)', cursor: 'pointer' }}>{f.name}
);
})}
) : (
当前目录没有文件夹
)}
,
document.body
);
};
// ─── Template picker modal (grid of image templates) ────────────────────────
const ImgTemplatePickerModal = ({ onSelect, onClose }) => {
useModalLock(true, onClose);
const { t } = useI18n();
const [templates, setTemplates] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
_imgTaskAPI.listTemplates().then(d => { setTemplates(d.items || []); setLoading(false); }).catch(() => setLoading(false));
}, []);
return ReactDOM.createPortal(
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()}>
{t('nav.template')}
{loading ? (
) : templates.length === 0 ? (
{t('tpl.noTemplates')}
) : (
{templates.map(tpl => (
{ onSelect(tpl); onClose(); }} style={{
borderRadius: 10, overflow: 'hidden', cursor: 'pointer',
border: '1px solid var(--line-2)', transition: 'border-color .15s, transform .15s',
background: 'rgba(255,255,255,0.02)',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(34,211,238,0.4)'; e.currentTarget.style.transform = 'translateY(-2px)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--line-2)'; e.currentTarget.style.transform = 'none'; }}>
{(tpl.cover_url || tpl.result_url) &&

}
{tpl.title || t('tpl.untitled')}
{tpl.use_count || 0} {t('tpl.usageCount')}
))}
)}
,
document.body
);
};
// ─── Template image slots (for template tab) ────────────────────────────────
const TplImageSlots = ({ slots, onPickSlot, size = 88 }) => {
const { t } = useI18n();
return (
{slots.map((slot, idx) => (
onPickSlot(idx)} style={{
position: 'relative', width: size, height: size, borderRadius: 10, overflow: 'hidden',
border: slot.user_url ? '1.5px solid rgba(34,211,238,0.4)' : '1.5px dashed var(--line-2)',
cursor: 'pointer', background: '#0a0e1a',
}}>
{slot.user_url ? (

) : slot.original_url ? (

) : (
)}
{!slot.user_url && (
Replace
)}
{slot.label}
))}
);
};
// ═══════ MAIN SCREEN ═══════
const CreationToolScreen = ({ initialTab = 'ref' }) => {
const { t } = useI18n();
const { isMobile, isTablet, w } = useBreakpoint();
React.useEffect(() => {
if (!window.YiteAPI?.isLoggedIn?.()) {
window.showLoginModal?.().then(ok => {
if (ok) window.location.reload();
else window.location.href = '/';
});
}
}, []);
const isCompact = isMobile || isTablet;
const sidebarW = w >= 3840 ? '600px' : w >= 2560 ? '540px' : '480px';
const thumbSize = w >= 2560 ? 110 : w >= 1440 ? 96 : 88;
const promptH = w >= 2560 ? 200 : w >= 1440 ? 160 : 120;
const [mobileView, setMobileView] = React.useState('config');
const [tab, setTabState] = React.useState(initialTab);
const setTab = id => {
setTabState(id);
const url = new URL(window.location.href);
url.searchParams.set('tab', id);
window.history.pushState({}, '', url.toString());
};
// ── Template tab state ──
const [selectedTpl, setSelectedTpl] = React.useState(null);
const [tplSlots, setTplSlots] = React.useState([]);
const [tplPickerOpen, setTplPickerOpen] = React.useState(false);
const [tplSlotPicking, setTplSlotPicking] = React.useState(null); // index of slot being picked
const handleSelectTemplate = tpl => {
// Fetch full template data with task details
_imgTaskAPI.getTemplate(tpl.id).then(data => {
if (!data || data.error) return;
setSelectedTpl(data);
const task = data.task;
const mp = task?.model_param || data.model_param || {};
// Prefill prompt (editable)
setPrompt(task?.prompt || data.prompt || '');
// Prefill model
const m = task?.model || data.model_id;
if (m) { tplModelRef.current = m; setModel(m); }
// Prefill params
if (mp.resolution) setResolution(String(mp.resolution));
if (mp.aspect_ratio) setAspect(String(mp.aspect_ratio));
// Setup image slots
const imgList = data.image_list || task?.image_list || [];
if (imgList.length > 0) {
setTplSlots(imgList.map((img, i) => ({
role: img.role || 'reference_image',
original_url: img.image_url,
user_url: null,
label: img.role === 'first_frame' ? 'Start' : img.role === 'last_frame' ? 'End' : (i + 1).toString(),
})));
} else {
const cnt = data.image_count || 1;
setTplSlots(Array.from({ length: cnt }, (_, i) => ({
role: 'reference_image', original_url: null, user_url: null, label: (i + 1).toString(),
})));
}
});
};
const handleTplSlotPicked = picked => {
if (tplSlotPicking == null || picked.length === 0) return;
setTplSlots(prev => prev.map((s, i) => i === tplSlotPicking ? { ...s, user_url: picked[0].url } : s));
setTplSlotPicking(null);
};
// Fetch models from API
const [imgModels, setImgModels] = React.useState([]);
const [model, setModel] = usePersistentState('yt_img_model', '');
const tplModelRef = React.useRef('');
React.useEffect(() => {
fetch('/api/v1/ai-models?media_type=image').then(r => r.json()).then(d => {
if (d.items && d.items.length > 0) {
setImgModels(d.items);
const prefill = tplModelRef.current;
if (prefill && d.items.some(m => m.id === prefill)) {
setModel(prefill);
tplModelRef.current = '';
} else if (!model && d.items.length > 0) {
setModel(d.items[0].id);
}
}
});
}, []);
const [refImages, setRefImages] = usePersistentState('yt_img_refs', []);
const [prompt, setPrompt] = usePersistentState('yt_img_prompt', '');
const [resolution, setResolution] = usePersistentState('yt_img_resolution', '1080p');
const [aspect, setAspect] = usePersistentState('yt_img_aspect', '16:9');
const [count, setCount] = usePersistentState('yt_img_count', 1);
const [generating, setGenerating] = React.useState(false);
const [genError, setGenError] = React.useState('');
const [tasks, setTasks] = React.useState([]);
const [taskFilter, setTaskFilter] = React.useState('all');
const [taskFilterOpen, setTaskFilterOpen] = React.useState(false);
const [folderPickerOpen, setFolderPickerOpen] = React.useState(false);
// Task filter options: [filterKey, labelKey]
const IMG_TASK_FILTERS = [
['all', 'creation.filter.all'],
['ref', 'creation.filter.ref'],
['text', 'creation.filter.text'],
];
// Map filter key to API type value
const filterToType = key => {
if (key === 'ref') return '参考生图';
if (key === 'text') return '文生图';
return '';
};
const addRefImage = img => setRefImages(prev => [...prev, img]);
const removeRefImage = id => setRefImages(prev => prev.filter(i => i.id !== id));
// Load template params if from_template in URL
React.useEffect(() => {
const p = new URLSearchParams(window.location.search);
const tplId = p.get('from_template');
if (!tplId) return;
console.log('[from_template] loading template:', tplId);
fetch('/api/v1/templates/' + tplId).then(r => r.json()).then(data => {
if (!data || data.error) return;
const task = data.task;
const prompt_ = task?.prompt || data.prompt;
const model_ = task?.model || data.model_id;
const mp = task?.model_param || data.model_param || {};
const imgList = task?.image_list || [];
console.log('[from_template] task:', task, 'model_param:', mp);
if (prompt_) setPrompt(prompt_);
if (model_) { tplModelRef.current = model_; setModel(model_); }
if (mp.resolution) setResolution(String(mp.resolution));
if (mp.aspect_ratio) setAspect(String(mp.aspect_ratio));
// Prefill reference images
if (imgList.length > 0) {
setRefImages(imgList.map((img, i) => ({ id: 'tpl_img_' + i, url: img.image_url, name: img.role || ('图' + (i + 1)) })));
}
const url = new URL(window.location.href);
url.searchParams.delete('from_template');
window.history.replaceState({}, '', url.toString());
}).catch(e => console.error('[from_template] error:', e));
}, []);
// Fetch tasks
const fetchTasks = React.useCallback(() => {
const typeParam = filterToType(taskFilter);
_imgTaskAPI.list(typeParam).then(res => { if (res.items) setTasks(res.items); });
}, [taskFilter]);
React.useEffect(() => { fetchTasks(); }, [fetchTasks]);
React.useEffect(() => {
const hasRunning = tasks.some(t => t.status === 'pending' || t.status === 'running');
if (!hasRunning) return;
const id = setInterval(fetchTasks, 5000);
return () => clearInterval(id);
}, [tasks, fetchTasks]);
const activeModel = imgModels.find(m => m.id === model) || imgModels[0] || { credits: 4 };
const credits = activeModel.credits * count;
const handleGenerateClick = () => {
if (!prompt.trim()) { setGenError(t('creation.enterPrompt')); return; }
if (tab === 'ref' && refImages.length === 0) { setGenError(t('creation.addRefImage')); return; }
if (tab === 'template' && tplSlots.some(s => !s.user_url)) { setGenError(t('tpl.missingImages') + tplSlots.filter(s => !s.user_url).length + ')'); return; }
if (!window.YiteAPI?.isLoggedIn()) { showLoginModal(); return; }
setGenError('');
setFolderPickerOpen(true);
};
const handleFolderSelected = async folder => {
setFolderPickerOpen(false);
setGenerating(true); setGenError('');
if (repeatImgTask) {
try {
const res = await _imgTaskAPI.create({ ...repeatImgTask, id: undefined, folder_id: folder.id });
if (res.error) { setGenError(res.error); } else { fetchTasks(); if (isCompact) setMobileView('results'); }
} catch (e) { setGenError(e.message || t('video.createFailed')); }
finally { setGenerating(false); setRepeatImgTask(null); }
return;
}
let typeLabel, imageList, finalPrompt;
if (tab === 'template') {
typeLabel = selectedTpl?.task_type || '参考生图';
imageList = tplSlots.map(s => ({ image_url: s.user_url, role: s.role }));
finalPrompt = prompt.trim();
} else {
typeLabel = tab === 'ref' ? '参考生图' : '文生图';
imageList = refImages.filter(img => img.url).map(img => ({ image_url: img.url, role: 'reference_image' }));
finalPrompt = buildImgPrompt(prompt.trim(), refImages);
}
try {
const body = {
type: typeLabel, prompt: finalPrompt, model: model || activeModel?.id || '',
model_param: { resolution, aspect_ratio: aspect, count },
image_list: imageList, folder_id: folder.id,
};
if (tab === 'template' && selectedTpl) body.template_id = selectedTpl.id;
const res = await _imgTaskAPI.create(body);
if (res.error) { setGenError(res.error); } else { fetchTasks(); if (isCompact) setMobileView('results'); }
} catch (e) { setGenError(e.message || t('video.createFailed')); }
finally { setGenerating(false); }
};
const handleDeleteTask = async id => {
await _imgTaskAPI.remove(id);
fetchTasks();
};
const [repeatImgTask, setRepeatImgTask] = React.useState(null);
const handleRepeatTask = task => {
setRepeatImgTask(task);
setFolderPickerOpen(true);
};
const handleEditTask = task => {
const mp = task.model_param || {};
const imgList = task.image_list || [];
if (task.prompt) setPrompt(task.prompt);
if (task.model) setModel(task.model);
if (mp.aspect_ratio) setAspect(String(mp.aspect_ratio));
if (mp.resolution) setResolution(String(mp.resolution));
if (mp.count) setCount(Number(mp.count));
const refs = imgList.filter(img => img.role === 'reference_image' || !img.role);
if (refs.length > 0) {
setTab('ref');
setRefImages(refs.map((img, i) => ({ id: 'edit_' + i, url: img.image_url, name: t('creation.image') + (i + 1) })));
} else {
setTab('text');
}
if (isCompact) setMobileView('config');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const [publishingTask, setPublishingTask] = React.useState(null);
const handlePublishTask = task => {
setPublishingTask(task);
};
const handlePublishConfirm = async (title, isTemplate, customCoverFile) => {
const task = publishingTask;
setPublishingTask(null);
if (!task) return;
let coverOssKey = '';
if (customCoverFile) {
const fd = new FormData(); fd.append('file', customCoverFile);
const coverRes = await fetch('/api/v1/templates/cover', { method: 'POST', headers: { Authorization: 'Bearer ' + _imgToken() }, body: fd }).then(r => r.json());
if (coverRes.oss_key) coverOssKey = coverRes.oss_key;
}
const res = await _imgTaskAPI.publishTemplate({ task_id: task.id, title, is_public: true, is_template: !!isTemplate, cover_oss_key: coverOssKey });
if (res.error) { ytAlert(res.error); return; }
fetchTasks();
};
const handleUnpublishTask = async task => {
if (!task.template_id) return;
const res = await _imgTaskAPI.unpublishTemplate(task.template_id);
if (res.error) { ytAlert(res.error); return; }
fetchTasks();
};
// Current filter label for display
const currentFilterEntry = IMG_TASK_FILTERS.find(([k]) => k === taskFilter) || IMG_TASK_FILTERS[0];
return (
{/* ── LEFT (mobile: top): tabs + params ── */}
{/* ── RIGHT (mobile: 紧接参数下方) ── */}
= 2560 ? 960 : 720), margin: '0 auto', display: 'flex', flexDirection: 'column' }}>
{/* Mobile section header */}
{isCompact && (
执行历史
{tasks.length > 0 && {tasks.length} 项}
)}
{/* Filter bar */}
{taskFilterOpen && (
{IMG_TASK_FILTERS.map(([key, labelKey]) => (
))}
)}
{/* Task list */}
{tasks.length > 0 ? (
) : (
{t('creation.emptyFirst')}
)}
{/* end maxWidth wrapper */}
{folderPickerOpen && setFolderPickerOpen(false)} />}
{publishingTask && setPublishingTask(null)} />}
{tplPickerOpen && setTplPickerOpen(false)} />}
{tplSlotPicking != null && setTplSlotPicking(null)} />}
);
};
Object.assign(window, { CreationToolScreen, CREATION_MODES: IMG_TABS });