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:
parent
328cbc147d
commit
6a5ddbaf78
@ -9,6 +9,7 @@ ERROR_MESSAGES = {
|
||||
# Input content moderation — 人脸/敏感内容
|
||||
'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照片',
|
||||
'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试',
|
||||
'InputVideoSensitiveContentDetected.PrivacyInformation': '参考视频中检测到真实人脸,请使用虚拟人像素材替代真人视频',
|
||||
'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试',
|
||||
'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试',
|
||||
'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user