feat: v0.11.2 图片缩略图优化 + 素材库修复 + UI 细节

图片缩略图优化:
- 新增 tosThumb() 工具函数,TOS 图片按显示尺寸 2x 加载缩略图
- 所有小图(任务卡片、mention 标签、hover 预览、素材库、输入栏参考图)全部走缩略图
- 原图仅在 ImageLightbox 大图预览和提交生成时使用
- tosThumb 只匹配 airdrama-media 桶,不影响火山内部桶 URL

素材库修复:
- 旧数据图片从火山桶同步到我们 TOS 桶(一次性脚本)
- 素材详情页图片支持点击看大图(ImageLightbox)
- 弹窗高度固定 85vh,三个视图高度一致
- 列表页点击图片进素材组,不触发预览
- 视频敏感内容错误码映射补充

UI 细节:
- 任务卡片参考图 hover 预览(上方弹出)
- 详细信息弹窗延迟关闭(鼠标可移到弹窗上)
- 删除@后 mention 弹窗自动关闭
- 导航箭头禁用时不触发关闭弹窗

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-22 16:13:04 +08:00
parent 328cbc147d
commit 6a5ddbaf78
8 changed files with 71 additions and 17 deletions

View File

@ -9,6 +9,7 @@ ERROR_MESSAGES = {
# Input content moderation — 人脸/敏感内容
'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照片',
'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试',
'InputVideoSensitiveContentDetected.PrivacyInformation': '参考视频中检测到真实人脸,请使用虚拟人像素材替代真人视频',
'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试',
'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试',
'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试',

View File

@ -11,12 +11,11 @@
.modal {
width: 90vw;
max-width: 1400px;
min-height: 85vh;
max-height: 92vh;
height: 85vh;
background: #16161e;
border: 1px solid var(--color-border-card);
border-radius: 12px;
overflow-y: auto;
overflow: hidden;
display: flex;
flex-direction: column;
}

View File

@ -1,7 +1,8 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useAssetLibraryStore } from '../store/assetLibrary';
import { assetsApi } from '../lib/api';
import { assetsApi, tosThumb } from '../lib/api';
import { showToast } from './Toast';
import { ImageLightbox } from './ImageLightbox';
import type { AssetGroup, AssetItem } from '../types';
import styles from './AssetLibraryModal.module.css';
@ -20,6 +21,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadPreview, setUploadPreview] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const addFileInputRef = useRef<HTMLInputElement>(null);
@ -209,7 +211,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
<div className={styles.grid}>
{groups.map((group) => (
<div key={group.id} className={styles.card} onClick={() => handleGroupClick(group)}>
<img src={group.thumbnail_url} alt={group.name} className={styles.cardThumb} />
<img src={tosThumb(group.thumbnail_url, 300)} alt={group.name} className={styles.cardThumb} />
<div className={styles.cardInfo}>
{editingName && editingName.id === group.id ? (
<div className={styles.inlineEditWrap} onClick={(e) => e.stopPropagation()}>
@ -342,7 +344,13 @@ export function AssetLibraryModal({ open, onClose }: Props) {
<div className={styles.assetGrid}>
{groupAssets.map((asset) => (
<div key={asset.id} className={styles.assetCard}>
<img src={asset.url} alt={asset.name} className={styles.assetThumb} />
<img
src={tosThumb(asset.url, 300)}
alt={asset.name}
className={styles.assetThumb}
style={{ cursor: 'zoom-in' }}
onClick={() => setLightboxSrc(asset.url)}
/>
<div className={styles.assetInfo}>
<div className={styles.assetName}>{asset.name}</div>
<span className={`${styles.statusBadge} ${
@ -423,6 +431,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
)}
</div>
</div>
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
</div>
);
}

View File

@ -4,6 +4,7 @@ import type { GenerationTask } from '../types';
import { useGenerationStore } from '../store/generation';
import { showToast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import { tosThumb } from '../lib/api';
import styles from './GenerationCard.module.css';
const EditIcon = () => (
@ -57,7 +58,7 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
>
{thumbUrl && (
<img
src={thumbUrl}
src={tosThumb(thumbUrl, 28)}
alt=""
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
/>
@ -66,7 +67,7 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
</span>
{hover && thumbUrl && createPortal(
<div className={styles.mentionPreview} style={{ top: pos.top, left: pos.left }}>
<img src={thumbUrl} alt={label} className={styles.mentionPreviewImg} />
<img src={tosThumb(thumbUrl, 200)} alt={label} className={styles.mentionPreviewImg} />
<div className={styles.mentionPreviewLabel}>{label}</div>
</div>,
document.body
@ -137,6 +138,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
const detailLinkRef = useRef<HTMLSpanElement>(null);
const detailLeaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [refPreview, setRefPreview] = useState<{ url: string; label: string; type: string; top: number; left: number } | null>(null);
const startDetailLeave = useCallback(() => {
if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current);
@ -272,7 +274,16 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{task.references.length > 0 && (
<div ref={refColumnRef} className={styles.refColumn}>
{task.references.map((ref) => (
<div key={ref.id} className={styles.refThumb}>
<div
key={ref.id}
className={styles.refThumb}
onMouseEnter={(e) => {
if (ref.type === 'audio') return;
const rect = e.currentTarget.getBoundingClientRect();
setRefPreview({ url: ref.previewUrl, label: ref.label, type: ref.type, top: rect.top - 8, left: rect.left + rect.width / 2 });
}}
onMouseLeave={() => setRefPreview(null)}
>
{ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.refMedia} muted />
) : ref.type === 'audio' ? (
@ -284,7 +295,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
</svg>
</div>
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.refMedia} />
<img src={tosThumb(ref.previewUrl, 112)} alt={ref.label} className={styles.refMedia} />
)}
</div>
))}
@ -387,6 +398,19 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
)}
</div>
{/* Reference thumbnail hover preview */}
{refPreview && createPortal(
<div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}>
{refPreview.type === 'video' ? (
<video src={refPreview.url} className={styles.mentionPreviewImg} autoPlay loop muted playsInline />
) : (
<img src={tosThumb(refPreview.url, 300)} alt={refPreview.label} className={styles.mentionPreviewImg} />
)}
<div className={styles.mentionPreviewLabel}>{refPreview.label}</div>
</div>,
document.body
)}
{/* Video / result area */}
<div className={styles.content}>
{isGenerating ? (

View File

@ -1,7 +1,7 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar';
import { assetsApi } from '../lib/api';
import { assetsApi, tosThumb } from '../lib/api';
import type { UploadedFile, AssetGroup } from '../types';
import styles from './PromptInput.module.css';
@ -77,7 +77,7 @@ export function PromptInput() {
if (opts.thumbUrl) {
const img = document.createElement('img');
img.src = opts.thumbUrl;
img.src = tosThumb(opts.thumbUrl, 32);
img.className = styles.mentionImg;
// 显式设置尺寸,防止 CSS class 未生效时图片为 0x0
img.setAttribute('width', '16');
@ -253,6 +253,12 @@ export function PromptInput() {
const textBeforeCursor = text.substring(0, offset);
const lastAtIdx = textBeforeCursor.lastIndexOf('@');
if (lastAtIdx < 0) {
// No @ before cursor, close popup
setShowMentionPopup(false);
return;
}
if (lastAtIdx >= 0) {
const textAfterAt = textBeforeCursor.substring(lastAtIdx + 1);
@ -604,7 +610,7 @@ export function PromptInput() {
{ref.type === 'video' ? (
<video src={ref.previewUrl} muted className={styles.thumbMedia} />
) : (
<img src={ref.previewUrl} alt="" className={styles.thumbMedia} />
<img src={tosThumb(ref.previewUrl, 72)} alt="" className={styles.thumbMedia} />
)}
</div>
<span className={styles.mentionLabel}>{ref.label}</span>
@ -628,7 +634,7 @@ export function PromptInput() {
}}
>
<div className={styles.mentionThumb}>
<img src={group.thumbnail_url} alt="" className={styles.thumbMedia} />
<img src={tosThumb(group.thumbnail_url, 72)} alt="" className={styles.thumbMedia} />
</div>
<span className={styles.mentionLabel}>{group.name}</span>
<span className={styles.mentionType}></span>
@ -656,7 +662,7 @@ export function PromptInput() {
/>
) : (
<img
src={hoverRef.previewUrl}
src={tosThumb(hoverRef.previewUrl, 200)}
alt={hoverRef.label}
className={styles.previewMedia}
/>

View File

@ -2,6 +2,7 @@ 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 MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
@ -124,7 +125,7 @@ export function UniversalUpload() {
<AudioIcon />
</div>
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
<img src={tosThumb(ref.previewUrl, 200)} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
)}
<div
className={styles.thumbClose}

View File

@ -6,6 +6,7 @@ import { ConfirmModal } from './ConfirmModal';
import { ImageLightbox } from './ImageLightbox';
import { useInputBarStore } from '../store/inputBar';
import { renderPromptWithMentions } from './GenerationCard';
import { tosThumb } from '../lib/api';
import styles from './VideoDetailModal.module.css';
interface Props {
@ -488,7 +489,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</svg>
</div>
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} />
<img src={tosThumb(ref.previewUrl, 300)} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} />
)}
<span className={styles.refLabel}>{ref.label}</span>
</div>

View File

@ -387,4 +387,17 @@ export const assetsApi = {
api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`),
};
/**
* Append TOS image resize parameter to reduce loading size.
* Only applies to TOS image URLs (volces.com with image extensions).
*/
export function tosThumb(url: string | undefined, height: number): string {
if (!url) return '';
// 只对我们自己的 TOS 桶生效airdrama-media不处理火山内部桶ark-media-asset 等)
if (!url.includes('airdrama-media')) return url;
if (!/\.(png|jpg|jpeg|webp|gif)/i.test(url)) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}x-tos-process=image/resize,h_${height}`;
}
export default api;