video-shuoshan/web/src/components/UniversalUpload.tsx
seaislee1209 34e56ddf86 feat: v0.16.0 即时上传 + 音频视频前端校验 + 资产页修复 + Toast UI
- 即时上传:拖入文件后立刻上传 TOS,spinner/红色重试/禁用提交
- 音频校验:格式(MP3/WAV) + 时长[2,15.4]s + 总时长≤15.4s
- 视频校验:格式(MP4/MOV) + 时长[2,15.4]s + 总时长≤15.4s
- 后端 blob: URL 兜底拦截 + 音频错误文案优化
- 资产页:nginx 403 修复 + 倒序排列 + 加载更多按钮
- Toast:glass-card 毛玻璃风格 + 橙色感叹号图标

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:12:06 +08:00

221 lines
8.5 KiB
TypeScript

import { useRef, useState } from 'react';
import { useInputBarStore } from '../store/inputBar';
import { showToast } from './Toast';
import { ImageLightbox } from './ImageLightbox';
import { tosThumb } from '../lib/api';
import styles from './UniversalUpload.module.css';
const Spinner = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" className={styles.spinner}>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
);
const ErrorIcon = () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(239,68,68,0.85)" />
<text x="12" y="16" textAnchor="middle" fill="#fff" fontSize="14" fontWeight="bold">!</text>
</svg>
);
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 retryUpload = useInputBarStore((s) => s.retryUpload);
const fileInputRef = useRef<HTMLInputElement>(null);
const [expanded, setExpanded] = useState(false);
const [badgeHover, setBadgeHover] = useState(false);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
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) {
// Format validation
if (f.type.startsWith('video/') && f.type !== 'video/mp4' && f.type !== 'video/quicktime') {
showToast('仅支持 MP4 和 MOV 格式的视频');
continue;
}
if (f.type.startsWith('audio/') && f.type !== 'audio/mpeg' && f.type !== 'audio/wav') {
showToast('仅支持 MP3 和 WAV 格式的音频');
continue;
}
// Size validation
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/mp4,video/quicktime,audio/mpeg,audio/wav"
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={tosThumb(ref.previewUrl, 200)} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
)}
{/* Upload status overlay */}
{ref.uploading && (
<div className={styles.uploadOverlay}>
<Spinner />
</div>
)}
{ref.uploadError && (
<div
className={`${styles.uploadOverlay} ${styles.uploadError}`}
onClick={(e) => { e.stopPropagation(); retryUpload(ref.id); }}
title="点击重试"
>
<ErrorIcon />
</div>
)}
<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>
)}
</>
)}
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
</div>
);
}