- 全局样式对标Air Spark设计系统(背景、glass card、配色、圆角) - 视频详情弹窗(VideoDetailModal)全屏预览+信息面板 - GenerationCard重构:fixed定位tooltip、9:16视频适配、blob下载 - 个人中心:总额度/今日/本月三卡片布局 - Dashboard图表配色统一为#6c63ff主色调 - Sidebar、InputBar、Toolbar等组件样式优化 - 新增AmbientBackground、AssetsPage组件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
178 lines
6.5 KiB
TypeScript
178 lines
6.5 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { useInputBarStore } from '../store/inputBar';
|
|
import { showToast } from './Toast';
|
|
import styles from './UniversalUpload.module.css';
|
|
|
|
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
|
|
const MAX_VIDEO_SIZE = 50 * 1024 * 1024; // 50MB per API doc
|
|
const MAX_AUDIO_SIZE = 15 * 1024 * 1024; // 15MB per API doc
|
|
|
|
const THUMB_H = 80; // matches --thumbnail-size
|
|
const THUMB_W = THUMB_H * 3 / 4; // 60px (aspect-ratio 3:4)
|
|
const PEEK = 12; // visible width per stacked card beyond the first
|
|
|
|
const AudioIcon = () => (
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
<path d="M9 18V5l12-2v13" />
|
|
<circle cx="6" cy="18" r="3" />
|
|
<circle cx="18" cy="16" r="3" />
|
|
</svg>
|
|
);
|
|
|
|
export function UniversalUpload() {
|
|
const references = useInputBarStore((s) => s.references);
|
|
const addReferences = useInputBarStore((s) => s.addReferences);
|
|
const removeReference = useInputBarStore((s) => s.removeReference);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [badgeHover, setBadgeHover] = useState(false);
|
|
|
|
const handleTrigger = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (!files.length) return;
|
|
|
|
const valid: File[] = [];
|
|
for (const f of files) {
|
|
let limit: number;
|
|
let limitLabel: string;
|
|
if (f.type.startsWith('video/')) {
|
|
limit = MAX_VIDEO_SIZE;
|
|
limitLabel = '视频文件不能超过50MB';
|
|
} else if (f.type.startsWith('audio/')) {
|
|
limit = MAX_AUDIO_SIZE;
|
|
limitLabel = '音频文件不能超过15MB';
|
|
} else {
|
|
limit = MAX_IMAGE_SIZE;
|
|
limitLabel = '图片文件不能超过30MB';
|
|
}
|
|
if (f.size > limit) {
|
|
showToast(limitLabel);
|
|
} else {
|
|
valid.push(f);
|
|
}
|
|
}
|
|
if (!valid.length) { e.target.value = ''; return; }
|
|
|
|
addReferences(valid);
|
|
e.target.value = '';
|
|
};
|
|
|
|
const hasFiles = references.length > 0;
|
|
const count = references.length;
|
|
|
|
// Check if all type slots are full
|
|
const counts = { image: 0, video: 0, audio: 0 };
|
|
for (const ref of references) counts[ref.type]++;
|
|
const allFull = counts.image >= 9 && counts.video >= 3 && counts.audio >= 3;
|
|
|
|
// Collapsed stack visual width
|
|
const stackWidth = THUMB_W + Math.max(0, count - 1) * PEEK;
|
|
|
|
return (
|
|
<div
|
|
className={`${styles.wrapper} ${hasFiles ? styles.wrapperActive : ''}`}
|
|
style={hasFiles ? { width: stackWidth, height: THUMB_H } : undefined}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*,video/*,audio/*"
|
|
multiple
|
|
className={styles.hiddenInput}
|
|
onChange={handleFileChange}
|
|
/>
|
|
|
|
{/* Empty state */}
|
|
{!hasFiles && (
|
|
<div className={styles.trigger} onClick={handleTrigger}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#5a5a6a" strokeWidth="1.5" strokeLinecap="round">
|
|
<line x1="12" y1="5" x2="12" y2="19" />
|
|
<line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
<span className={styles.triggerText}>参考内容</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Thumbnails — thumbRow always absolute, hover to expand */}
|
|
{hasFiles && (
|
|
<>
|
|
<div
|
|
className={`${styles.thumbRow} ${expanded ? styles.thumbRowExpanded : ''}`}
|
|
onMouseEnter={() => setExpanded(true)}
|
|
onMouseLeave={() => setExpanded(false)}
|
|
>
|
|
{references.map((ref, i) => (
|
|
<div
|
|
key={ref.id}
|
|
className={`${styles.thumbItem} ${expanded ? styles.itemExpanded : ''}`}
|
|
style={{
|
|
marginLeft: i === 0 ? 0 : (expanded ? 8 : -48),
|
|
zIndex: expanded ? 1 : count - i,
|
|
}}
|
|
>
|
|
<div className={styles.thumbInner}>
|
|
{ref.type === 'video' ? (
|
|
<video src={ref.previewUrl} className={styles.thumbMedia} muted />
|
|
) : ref.type === 'audio' ? (
|
|
<div className={styles.audioPlaceholder}>
|
|
<AudioIcon />
|
|
</div>
|
|
) : (
|
|
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} />
|
|
)}
|
|
<div
|
|
className={styles.thumbClose}
|
|
onClick={(e) => { e.stopPropagation(); removeReference(ref.id); }}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round">
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</div>
|
|
<div className={styles.thumbLabel}>{ref.label}</div>
|
|
</div>
|
|
<div className={styles.thumbTooltip}>{ref.label}</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Add more button (expanded state only) */}
|
|
{expanded && !allFull && (
|
|
<div
|
|
className={`${styles.addMore} ${styles.addMoreVisible}`}
|
|
style={{ marginLeft: 8 }}
|
|
onClick={(e) => { e.stopPropagation(); handleTrigger(); }}
|
|
>
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#5a5a6a" strokeWidth="1.5" strokeLinecap="round">
|
|
<line x1="12" y1="5" x2="12" y2="19" />
|
|
<line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
<div className={styles.addMoreTooltip}>上传参考内容</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* "+" badge — outside thumbRow, position based on stack width */}
|
|
{!expanded && !allFull && (
|
|
<div
|
|
className={styles.countBadge}
|
|
style={{ left: stackWidth - 14 }}
|
|
onClick={(e) => { e.stopPropagation(); handleTrigger(); }}
|
|
onMouseEnter={() => setBadgeHover(true)}
|
|
onMouseLeave={() => setBadgeHover(false)}
|
|
>
|
|
+
|
|
{badgeHover && (
|
|
<div className={styles.badgeTooltip}>上传参考内容</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|