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 — 人脸/敏感内容
|
# Input content moderation — 人脸/敏感内容
|
||||||
'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照片',
|
'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照片',
|
||||||
'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试',
|
'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试',
|
||||||
|
'InputVideoSensitiveContentDetected.PrivacyInformation': '参考视频中检测到真实人脸,请使用虚拟人像素材替代真人视频',
|
||||||
'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试',
|
'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试',
|
||||||
'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试',
|
'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试',
|
||||||
'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试',
|
'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试',
|
||||||
|
|||||||
@ -11,12 +11,11 @@
|
|||||||
.modal {
|
.modal {
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
min-height: 85vh;
|
height: 85vh;
|
||||||
max-height: 92vh;
|
|
||||||
background: #16161e;
|
background: #16161e;
|
||||||
border: 1px solid var(--color-border-card);
|
border: 1px solid var(--color-border-card);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useAssetLibraryStore } from '../store/assetLibrary';
|
import { useAssetLibraryStore } from '../store/assetLibrary';
|
||||||
import { assetsApi } from '../lib/api';
|
import { assetsApi, tosThumb } from '../lib/api';
|
||||||
import { showToast } from './Toast';
|
import { showToast } from './Toast';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
import type { AssetGroup, AssetItem } from '../types';
|
import type { AssetGroup, AssetItem } from '../types';
|
||||||
import styles from './AssetLibraryModal.module.css';
|
import styles from './AssetLibraryModal.module.css';
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
const [uploadPreview, setUploadPreview] = useState<string | null>(null);
|
const [uploadPreview, setUploadPreview] = useState<string | null>(null);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const addFileInputRef = useRef<HTMLInputElement>(null);
|
const addFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@ -209,7 +211,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<div key={group.id} className={styles.card} onClick={() => handleGroupClick(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}>
|
<div className={styles.cardInfo}>
|
||||||
{editingName && editingName.id === group.id ? (
|
{editingName && editingName.id === group.id ? (
|
||||||
<div className={styles.inlineEditWrap} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.inlineEditWrap} onClick={(e) => e.stopPropagation()}>
|
||||||
@ -342,7 +344,13 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
<div className={styles.assetGrid}>
|
<div className={styles.assetGrid}>
|
||||||
{groupAssets.map((asset) => (
|
{groupAssets.map((asset) => (
|
||||||
<div key={asset.id} className={styles.assetCard}>
|
<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.assetInfo}>
|
||||||
<div className={styles.assetName}>{asset.name}</div>
|
<div className={styles.assetName}>{asset.name}</div>
|
||||||
<span className={`${styles.statusBadge} ${
|
<span className={`${styles.statusBadge} ${
|
||||||
@ -423,6 +431,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { GenerationTask } from '../types';
|
|||||||
import { useGenerationStore } from '../store/generation';
|
import { useGenerationStore } from '../store/generation';
|
||||||
import { showToast } from './Toast';
|
import { showToast } from './Toast';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
|
import { tosThumb } from '../lib/api';
|
||||||
import styles from './GenerationCard.module.css';
|
import styles from './GenerationCard.module.css';
|
||||||
|
|
||||||
const EditIcon = () => (
|
const EditIcon = () => (
|
||||||
@ -57,7 +58,7 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
|
|||||||
>
|
>
|
||||||
{thumbUrl && (
|
{thumbUrl && (
|
||||||
<img
|
<img
|
||||||
src={thumbUrl}
|
src={tosThumb(thumbUrl, 28)}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
|
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>
|
</span>
|
||||||
{hover && thumbUrl && createPortal(
|
{hover && thumbUrl && createPortal(
|
||||||
<div className={styles.mentionPreview} style={{ top: pos.top, left: pos.left }}>
|
<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 className={styles.mentionPreviewLabel}>{label}</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
@ -137,6 +138,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
|
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
|
||||||
const detailLinkRef = useRef<HTMLSpanElement>(null);
|
const detailLinkRef = useRef<HTMLSpanElement>(null);
|
||||||
const detailLeaveTimer = useRef<ReturnType<typeof setTimeout> | null>(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(() => {
|
const startDetailLeave = useCallback(() => {
|
||||||
if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current);
|
if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current);
|
||||||
@ -272,7 +274,16 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
{task.references.length > 0 && (
|
{task.references.length > 0 && (
|
||||||
<div ref={refColumnRef} className={styles.refColumn}>
|
<div ref={refColumnRef} className={styles.refColumn}>
|
||||||
{task.references.map((ref) => (
|
{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' ? (
|
{ref.type === 'video' ? (
|
||||||
<video src={ref.previewUrl} className={styles.refMedia} muted />
|
<video src={ref.previewUrl} className={styles.refMedia} muted />
|
||||||
) : ref.type === 'audio' ? (
|
) : ref.type === 'audio' ? (
|
||||||
@ -284,7 +295,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img src={ref.previewUrl} alt={ref.label} className={styles.refMedia} />
|
<img src={tosThumb(ref.previewUrl, 112)} alt={ref.label} className={styles.refMedia} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -387,6 +398,19 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Video / result area */}
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { useInputBarStore } from '../store/inputBar';
|
import { useInputBarStore } from '../store/inputBar';
|
||||||
import { assetsApi } from '../lib/api';
|
import { assetsApi, tosThumb } from '../lib/api';
|
||||||
import type { UploadedFile, AssetGroup } from '../types';
|
import type { UploadedFile, AssetGroup } from '../types';
|
||||||
import styles from './PromptInput.module.css';
|
import styles from './PromptInput.module.css';
|
||||||
|
|
||||||
@ -77,7 +77,7 @@ export function PromptInput() {
|
|||||||
|
|
||||||
if (opts.thumbUrl) {
|
if (opts.thumbUrl) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = opts.thumbUrl;
|
img.src = tosThumb(opts.thumbUrl, 32);
|
||||||
img.className = styles.mentionImg;
|
img.className = styles.mentionImg;
|
||||||
// 显式设置尺寸,防止 CSS class 未生效时图片为 0x0
|
// 显式设置尺寸,防止 CSS class 未生效时图片为 0x0
|
||||||
img.setAttribute('width', '16');
|
img.setAttribute('width', '16');
|
||||||
@ -253,6 +253,12 @@ export function PromptInput() {
|
|||||||
const textBeforeCursor = text.substring(0, offset);
|
const textBeforeCursor = text.substring(0, offset);
|
||||||
const lastAtIdx = textBeforeCursor.lastIndexOf('@');
|
const lastAtIdx = textBeforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
|
if (lastAtIdx < 0) {
|
||||||
|
// No @ before cursor, close popup
|
||||||
|
setShowMentionPopup(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (lastAtIdx >= 0) {
|
if (lastAtIdx >= 0) {
|
||||||
const textAfterAt = textBeforeCursor.substring(lastAtIdx + 1);
|
const textAfterAt = textBeforeCursor.substring(lastAtIdx + 1);
|
||||||
|
|
||||||
@ -604,7 +610,7 @@ export function PromptInput() {
|
|||||||
{ref.type === 'video' ? (
|
{ref.type === 'video' ? (
|
||||||
<video src={ref.previewUrl} muted className={styles.thumbMedia} />
|
<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>
|
</div>
|
||||||
<span className={styles.mentionLabel}>{ref.label}</span>
|
<span className={styles.mentionLabel}>{ref.label}</span>
|
||||||
@ -628,7 +634,7 @@ export function PromptInput() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles.mentionThumb}>
|
<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>
|
</div>
|
||||||
<span className={styles.mentionLabel}>{group.name}</span>
|
<span className={styles.mentionLabel}>{group.name}</span>
|
||||||
<span className={styles.mentionType}>人像</span>
|
<span className={styles.mentionType}>人像</span>
|
||||||
@ -656,7 +662,7 @@ export function PromptInput() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={hoverRef.previewUrl}
|
src={tosThumb(hoverRef.previewUrl, 200)}
|
||||||
alt={hoverRef.label}
|
alt={hoverRef.label}
|
||||||
className={styles.previewMedia}
|
className={styles.previewMedia}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
|
|||||||
import { useInputBarStore } from '../store/inputBar';
|
import { useInputBarStore } from '../store/inputBar';
|
||||||
import { showToast } from './Toast';
|
import { showToast } from './Toast';
|
||||||
import { ImageLightbox } from './ImageLightbox';
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
import { tosThumb } from '../lib/api';
|
||||||
import styles from './UniversalUpload.module.css';
|
import styles from './UniversalUpload.module.css';
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
|
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
|
||||||
@ -124,7 +125,7 @@ export function UniversalUpload() {
|
|||||||
<AudioIcon />
|
<AudioIcon />
|
||||||
</div>
|
</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
|
<div
|
||||||
className={styles.thumbClose}
|
className={styles.thumbClose}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { ConfirmModal } from './ConfirmModal';
|
|||||||
import { ImageLightbox } from './ImageLightbox';
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
import { useInputBarStore } from '../store/inputBar';
|
import { useInputBarStore } from '../store/inputBar';
|
||||||
import { renderPromptWithMentions } from './GenerationCard';
|
import { renderPromptWithMentions } from './GenerationCard';
|
||||||
|
import { tosThumb } from '../lib/api';
|
||||||
import styles from './VideoDetailModal.module.css';
|
import styles from './VideoDetailModal.module.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -488,7 +489,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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>
|
<span className={styles.refLabel}>{ref.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -387,4 +387,17 @@ export const assetsApi = {
|
|||||||
api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`),
|
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;
|
export default api;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user