Merge pull request 'feat(task-2.15): 素材选择器优化 — 批量选择+拖拽排序+预览缩略图+质量分筛选' (#154) from feature/task-2.15-asset-selector into develop
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 185h34m32s
CI/CD Pipeline / Frontend Lint (push) Failing after 185h34m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 185h35m4s
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 185h34m32s
CI/CD Pipeline / Frontend Lint (push) Failing after 185h34m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 185h35m4s
This commit was merged in pull request #154.
This commit is contained in:
@@ -77,6 +77,10 @@ export interface MediaAsset {
|
||||
/** 标签 */
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
/** 质量分 0-100 */
|
||||
quality_score?: number;
|
||||
/** 分类状态 */
|
||||
classification_status?: "pending" | "processing" | "completed" | "failed";
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -88,19 +92,25 @@ const MOCK_ASSETS: MediaAsset[] = [
|
||||
id: "asset-001",
|
||||
name: "产品展示-正面.mp4",
|
||||
type: "video",
|
||||
thumbnail_url: "https://picsum.photos/seed/asset001/320/180",
|
||||
duration: 15,
|
||||
size: 12_500_000,
|
||||
tags: ["产品", "展示"],
|
||||
created_at: "2026-06-20T10:00:00Z",
|
||||
quality_score: 92,
|
||||
classification_status: "completed",
|
||||
},
|
||||
{
|
||||
id: "asset-002",
|
||||
name: "使用教程-片段A.mp4",
|
||||
type: "video",
|
||||
thumbnail_url: "https://picsum.photos/seed/asset002/320/180",
|
||||
duration: 20,
|
||||
size: 18_000_000,
|
||||
tags: ["教程", "使用"],
|
||||
created_at: "2026-06-21T14:00:00Z",
|
||||
quality_score: 85,
|
||||
classification_status: "completed",
|
||||
},
|
||||
{
|
||||
id: "asset-003",
|
||||
@@ -110,32 +120,43 @@ const MOCK_ASSETS: MediaAsset[] = [
|
||||
size: 3_200_000,
|
||||
tags: ["BGM", "轻快"],
|
||||
created_at: "2026-06-18T09:00:00Z",
|
||||
quality_score: 78,
|
||||
classification_status: "completed",
|
||||
},
|
||||
{
|
||||
id: "asset-004",
|
||||
name: "封面图-主图.jpg",
|
||||
type: "image",
|
||||
thumbnail_url: "https://picsum.photos/seed/asset004/320/180",
|
||||
size: 850_000,
|
||||
tags: ["封面", "主图"],
|
||||
created_at: "2026-06-22T11:00:00Z",
|
||||
quality_score: 88,
|
||||
classification_status: "completed",
|
||||
},
|
||||
{
|
||||
id: "asset-005",
|
||||
name: "细节特写-侧面.mp4",
|
||||
type: "video",
|
||||
thumbnail_url: "https://picsum.photos/seed/asset005/320/180",
|
||||
duration: 10,
|
||||
size: 8_500_000,
|
||||
tags: ["产品", "细节"],
|
||||
created_at: "2026-06-23T16:00:00Z",
|
||||
quality_score: 72,
|
||||
classification_status: "completed",
|
||||
},
|
||||
{
|
||||
id: "asset-006",
|
||||
name: "开箱视频-片段.mp4",
|
||||
type: "video",
|
||||
thumbnail_url: "https://picsum.photos/seed/asset006/320/180",
|
||||
duration: 25,
|
||||
size: 22_000_000,
|
||||
tags: ["开箱", "展示"],
|
||||
created_at: "2026-06-19T08:00:00Z",
|
||||
quality_score: 65,
|
||||
classification_status: "processing",
|
||||
},
|
||||
{
|
||||
id: "asset-007",
|
||||
@@ -145,14 +166,19 @@ const MOCK_ASSETS: MediaAsset[] = [
|
||||
size: 5_600_000,
|
||||
tags: ["配音", "产品"],
|
||||
created_at: "2026-06-24T10:00:00Z",
|
||||
quality_score: 95,
|
||||
classification_status: "completed",
|
||||
},
|
||||
{
|
||||
id: "asset-008",
|
||||
name: "场景图-生活场景.jpg",
|
||||
type: "image",
|
||||
thumbnail_url: "https://picsum.photos/seed/asset008/320/180",
|
||||
size: 1_200_000,
|
||||
tags: ["场景", "生活"],
|
||||
created_at: "2026-06-25T13:00:00Z",
|
||||
quality_score: 58,
|
||||
classification_status: "pending",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -334,3 +360,11 @@ export const PLAN_STATUS_LABELS: Record<EditPlan["status"], string> = {
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
/** 质量分筛选选项 */
|
||||
export const QUALITY_OPTIONS: { value: string; label: string; min?: number; max?: number }[] = [
|
||||
{ value: "all", label: "全部质量" },
|
||||
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
|
||||
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
|
||||
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* 素材选择器 - V21 设计系统样式
|
||||
* 支持批量选择、拖拽排序、预览缩略图、质量分筛选
|
||||
* 前缀: as- (AssetSelector)
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
|
||||
/* ============================================================
|
||||
容器
|
||||
============================================================ */
|
||||
.as-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 工具栏 ─────────────────────────────────────────────────── */
|
||||
.as-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.as-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.as-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.as-search {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* 视图切换按钮 */
|
||||
.as-view-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.as-view-btn:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.as-view-btn.active {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ── 批量操作栏 ─────────────────────────────────────────────── */
|
||||
.as-batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 6px var(--space-md);
|
||||
background: var(--primary-soft);
|
||||
border-bottom: 1px solid var(--color-primary-200);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--primary-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.as-batch-bar-count {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.as-batch-bar-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── 素材列表区域 ───────────────────────────────────────────── */
|
||||
.as-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.as-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl) var(--space-md);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.as-empty-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: var(--space-sm);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.as-empty p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ── 网格视图 ───────────────────────────────────────────────── */
|
||||
.as-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.as-grid.compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── 列表视图 ───────────────────────────────────────────────── */
|
||||
.as-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
素材卡片 — 网格模式
|
||||
============================================================ */
|
||||
.as-card {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
background: var(--bg-primary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.as-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.as-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-soft);
|
||||
}
|
||||
|
||||
.as-card.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.as-card.drag-over {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-color);
|
||||
}
|
||||
|
||||
/* 缩略图区域 */
|
||||
.as-card-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 9 / 16;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.as-card-thumb-icon {
|
||||
font-size: 28px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.as-card-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 类型角标 */
|
||||
.as-card-type-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 时长角标 */
|
||||
.as-card-duration {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 质量分角标 */
|
||||
.as-card-quality {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.as-card-quality.excellent {
|
||||
background: var(--success-color, #10b981);
|
||||
}
|
||||
|
||||
.as-card-quality.good {
|
||||
background: var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
.as-card-quality.fair {
|
||||
background: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.as-card-quality.poor {
|
||||
background: var(--error-color, #ef4444);
|
||||
}
|
||||
|
||||
/* 选择 checkbox */
|
||||
.as-card-checkbox {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 2;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px solid rgba(255, 255, 255, 0.7);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition-all);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.as-card-checkbox.checked {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.as-card-checkbox.checked::after {
|
||||
content: "✓";
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 有 checkbox 时,类型角标右移 */
|
||||
.as-card.has-checkbox .as-card-type-badge {
|
||||
left: 26px;
|
||||
}
|
||||
|
||||
/* 卡片信息 */
|
||||
.as-card-info {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.as-card-name {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.as-card-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
素材卡片 — 列表模式
|
||||
============================================================ */
|
||||
.as-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.as-list-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.as-list-item.selected {
|
||||
background: var(--primary-soft);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.as-list-item.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.as-list-item.drag-over {
|
||||
border-top: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.as-list-item-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
border: 2px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
.as-list-item-checkbox.checked {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.as-list-item-checkbox.checked::after {
|
||||
content: "✓";
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.as-list-item-drag {
|
||||
cursor: grab;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
opacity: 0.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.as-list-item-drag:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.as-list-item-drag:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.as-list-item-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.as-list-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.as-list-item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.as-list-item-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.as-list-item-quality {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
悬浮预览
|
||||
============================================================ */
|
||||
.as-preview-overlay {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 8px;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
animation: as-preview-in 0.15s ease;
|
||||
}
|
||||
|
||||
.as-preview-overlay-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 200px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.as-preview-overlay-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.as-preview-overlay-thumb-icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.as-preview-overlay-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.as-preview-overlay-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@keyframes as-preview-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 768px) {
|
||||
.as-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
}
|
||||
|
||||
.as-toolbar {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.as-search {
|
||||
max-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.as-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.as-card-info {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.as-card-name {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* 素材选择器 — V21 设计系统
|
||||
* 支持批量选择、拖拽排序、预览缩略图、质量分筛选、视图切换
|
||||
*
|
||||
* 功能:
|
||||
* - 批量选择:checkbox 多选 + 全选/反选 + Shift 连选
|
||||
* - 拖拽排序:HTML5 DnD,已选素材可拖拽调整顺序
|
||||
* - 预览缩略图:悬浮放大预览(视频显示时长角标)
|
||||
* - 筛选增强:类型筛选 + 质量分筛选
|
||||
* - 视图切换:网格视图 / 列表视图
|
||||
*/
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import "./AssetSelector.css";
|
||||
import { Input, Select, Button } from "@/components/ui";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
import {
|
||||
MATERIAL_TYPE_LABELS,
|
||||
MATERIAL_TYPE_ICONS,
|
||||
QUALITY_OPTIONS,
|
||||
} from "@/api/editPlans";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export interface AssetSelectorProps {
|
||||
assets: MediaAsset[];
|
||||
selectedIds?: string[];
|
||||
onSelectionChange?: (ids: string[]) => void;
|
||||
onAssetDragStart?: (asset: MediaAsset) => void;
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void;
|
||||
showQualityFilter?: boolean;
|
||||
showBatchSelect?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
type ViewMode = "grid" | "list";
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return "";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`;
|
||||
};
|
||||
|
||||
/** 获取质量分等级 */
|
||||
const getQualityLevel = (score?: number): string => {
|
||||
if (score == null) return "none";
|
||||
if (score >= 90) return "excellent";
|
||||
if (score >= 70) return "good";
|
||||
if (score >= 50) return "fair";
|
||||
return "poor";
|
||||
};
|
||||
|
||||
/** 质量分颜色 */
|
||||
const getQualityColor = (score?: number): string => {
|
||||
if (score == null) return "var(--text-secondary)";
|
||||
if (score >= 90) return "var(--success-color, #10b981)";
|
||||
if (score >= 70) return "var(--primary-color, #6366f1)";
|
||||
if (score >= 50) return "var(--warning-color, #f59e0b)";
|
||||
return "var(--error-color, #ef4444)";
|
||||
};
|
||||
|
||||
/* ──────────── 类型筛选选项 ──────────── */
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: "video", label: "🎬 视频" },
|
||||
{ value: "image", label: "🖼️ 图片" },
|
||||
{ value: "audio", label: "🎵 音频" },
|
||||
];
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
assets,
|
||||
selectedIds = [],
|
||||
onSelectionChange,
|
||||
onAssetDragStart,
|
||||
onReorder,
|
||||
showQualityFilter = true,
|
||||
showBatchSelect = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
/* ── 搜索 & 筛选 ── */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterType, setFilterType] = useState("");
|
||||
const [filterQuality, setFilterQuality] = useState("");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||
|
||||
/* ── 拖拽状态 ── */
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
/* ── 悬浮预览 ── */
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null);
|
||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 });
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/* ── Shift 连选 ── */
|
||||
const lastClickedIdx = useRef<number | null>(null);
|
||||
|
||||
/* ── 过滤后的素材列表 ── */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets;
|
||||
if (searchText) {
|
||||
const q = searchText.toLowerCase();
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
if (filterType) {
|
||||
list = list.filter((a) => a.type === filterType);
|
||||
}
|
||||
if (filterQuality) {
|
||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality);
|
||||
if (opt?.min != null && opt?.max != null) {
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.quality_score != null &&
|
||||
a.quality_score >= opt.min! &&
|
||||
a.quality_score <= opt.max!,
|
||||
);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [assets, searchText, filterType, filterQuality]);
|
||||
|
||||
/* ── 选中状态 ── */
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const allFilteredSelected =
|
||||
filteredAssets.length > 0 &&
|
||||
filteredAssets.every((a) => selectedSet.has(a.id));
|
||||
|
||||
/* ── 选择操作 ── */
|
||||
const toggleSelect = useCallback(
|
||||
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
if (shiftKey && lastClickedIdx.current !== null) {
|
||||
// Shift 连选
|
||||
const start = Math.min(lastClickedIdx.current, idx);
|
||||
const end = Math.max(lastClickedIdx.current, idx);
|
||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id);
|
||||
const newSet = new Set(selectedIds);
|
||||
rangeIds.forEach((id) => newSet.add(id));
|
||||
onSelectionChange(Array.from(newSet));
|
||||
} else {
|
||||
const newSet = new Set(selectedIds);
|
||||
if (newSet.has(asset.id)) {
|
||||
newSet.delete(asset.id);
|
||||
} else {
|
||||
newSet.add(asset.id);
|
||||
}
|
||||
onSelectionChange(Array.from(newSet));
|
||||
}
|
||||
lastClickedIdx.current = idx;
|
||||
},
|
||||
[onSelectionChange, selectedIds, filteredAssets],
|
||||
);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (!onSelectionChange) return;
|
||||
if (allFilteredSelected) {
|
||||
// 反选:取消当前过滤结果中的所有选中
|
||||
const filteredIds = new Set(filteredAssets.map((a) => a.id));
|
||||
onSelectionChange(selectedIds.filter((id) => !filteredIds.has(id)));
|
||||
} else {
|
||||
// 全选:合并当前过滤结果到选中列表
|
||||
const newSet = new Set(selectedIds);
|
||||
filteredAssets.forEach((a) => newSet.add(a.id));
|
||||
onSelectionChange(Array.from(newSet));
|
||||
}
|
||||
}, [onSelectionChange, selectedIds, filteredAssets, allFilteredSelected]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
onSelectionChange?.([]);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
/* ── 拖拽排序 ── */
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
setDragIdx(idx);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(idx));
|
||||
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-asset",
|
||||
JSON.stringify(filteredAssets[idx]),
|
||||
);
|
||||
// 批量拖拽:如果有多个选中素材,一起携带
|
||||
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
||||
const batchAssets = filteredAssets.filter((a) =>
|
||||
selectedIds.includes(a.id),
|
||||
);
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-assets",
|
||||
JSON.stringify(batchAssets),
|
||||
);
|
||||
}
|
||||
// 通知父组件素材拖拽开始
|
||||
if (onAssetDragStart) {
|
||||
onAssetDragStart(filteredAssets[idx]);
|
||||
}
|
||||
},
|
||||
[filteredAssets, selectedIds, onAssetDragStart],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragIdx === null || dragIdx === idx) return;
|
||||
setDragOverIdx(idx);
|
||||
},
|
||||
[dragIdx],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault();
|
||||
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
||||
onReorder(dragIdx, toIdx);
|
||||
}
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
},
|
||||
[dragIdx, onReorder],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
/* ── 悬浮预览 ── */
|
||||
const handleMouseEnter = useCallback(
|
||||
(asset: MediaAsset, e: React.MouseEvent) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
previewTimer.current = setTimeout(() => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setPreviewAsset(asset);
|
||||
setPreviewPos({
|
||||
x: rect.right + 12,
|
||||
y: Math.max(8, rect.top - 20),
|
||||
});
|
||||
}, 400);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (previewTimer.current) {
|
||||
clearTimeout(previewTimer.current);
|
||||
previewTimer.current = null;
|
||||
}
|
||||
setPreviewAsset(null);
|
||||
}, []);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── 点击卡片 ── */
|
||||
const handleCardClick = useCallback(
|
||||
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
|
||||
// 如果点击的是 checkbox 区域,不触发卡片点击
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-checkbox]")) return;
|
||||
toggleSelect(asset, idx, e.shiftKey);
|
||||
},
|
||||
[toggleSelect],
|
||||
);
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
const hasSelection = selectedIds.length > 0;
|
||||
|
||||
return (
|
||||
<div className="as-container">
|
||||
{/* ═══ 工具栏 ═══ */}
|
||||
<div className="as-toolbar">
|
||||
<div className="as-toolbar-left">
|
||||
<div className="as-search">
|
||||
<Input
|
||||
placeholder="搜索素材..."
|
||||
value={searchText}
|
||||
onChange={(v: string) => setSearchText(v)}
|
||||
prefix="🔍"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={(v: string) => setFilterType(v)}
|
||||
options={TYPE_OPTIONS}
|
||||
/>
|
||||
{showQualityFilter && (
|
||||
<Select
|
||||
value={filterQuality}
|
||||
onChange={(v: string) => setFilterQuality(v)}
|
||||
options={QUALITY_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="as-toolbar-right">
|
||||
<button
|
||||
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
|
||||
onClick={() => setViewMode("grid")}
|
||||
title="网格视图"
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
<button
|
||||
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => setViewMode("list")}
|
||||
title="列表视图"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 批量操作栏 ═══ */}
|
||||
{showBatchSelect && hasSelection && (
|
||||
<div className="as-batch-bar">
|
||||
<span className="as-batch-bar-count">
|
||||
已选 {selectedIds.length} 项
|
||||
</span>
|
||||
<div className="as-batch-bar-actions">
|
||||
<Button buttonType="ghost" buttonSize="xs" onClick={clearSelection}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ 素材列表 ═══ */}
|
||||
<div className="as-body">
|
||||
{filteredAssets.length === 0 ? (
|
||||
<div className="as-empty">
|
||||
<div className="as-empty-icon">📂</div>
|
||||
<p>暂无素材</p>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
/* ── 网格视图 ── */
|
||||
<div className={`as-grid${compact ? " compact" : ""}`}>
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id);
|
||||
const isDragging = dragIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
const qualityLevel = getQualityLevel(asset.quality_score);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={[
|
||||
"as-card",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
showBatchSelect ? "has-checkbox" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => handleCardClick(asset, idx, e)}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="as-card-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img
|
||||
src={asset.thumbnail_url}
|
||||
alt={asset.name}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="as-card-thumb-icon">
|
||||
{MATERIAL_TYPE_ICONS[asset.type]}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
{showBatchSelect && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(asset, idx, e.shiftKey);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型角标 */}
|
||||
<span className="as-card-type-badge">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
</span>
|
||||
|
||||
{/* 时长角标 */}
|
||||
{asset.duration != null && (
|
||||
<span className="as-card-duration">
|
||||
{formatDuration(asset.duration)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 质量分角标 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className={`as-card-quality ${qualityLevel}`}
|
||||
title={`质量分: ${asset.quality_score}`}
|
||||
>
|
||||
{asset.quality_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-card-info">
|
||||
<p className="as-card-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="as-card-meta">
|
||||
{formatSize(asset.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* ── 列表视图 ── */
|
||||
<div className="as-list">
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id);
|
||||
const isDragging = dragIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={[
|
||||
"as-list-item",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => handleCardClick(asset, idx, e)}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<span className="as-list-item-drag" title="拖拽排序">
|
||||
⠿
|
||||
</span>
|
||||
|
||||
{/* Checkbox */}
|
||||
{showBatchSelect && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(asset, idx, e.shiftKey);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="as-list-item-icon">
|
||||
{MATERIAL_TYPE_ICONS[asset.type]}
|
||||
</span>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-list-item-info">
|
||||
<div className="as-list-item-name">{asset.name}</div>
|
||||
<div className="as-list-item-meta">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 质量分 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className="as-list-item-quality"
|
||||
style={{ color: getQualityColor(asset.quality_score) }}
|
||||
>
|
||||
{asset.quality_score}分
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 悬浮预览 ═══ */}
|
||||
{previewAsset && (
|
||||
<div
|
||||
className="as-preview-overlay"
|
||||
style={{ left: previewPos.x, top: previewPos.y }}
|
||||
>
|
||||
<div className="as-preview-overlay-thumb">
|
||||
{previewAsset.thumbnail_url ? (
|
||||
<img
|
||||
src={previewAsset.thumbnail_url}
|
||||
alt={previewAsset.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="as-preview-overlay-thumb-icon">
|
||||
{MATERIAL_TYPE_ICONS[previewAsset.type]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="as-preview-overlay-name">{previewAsset.name}</p>
|
||||
<div className="as-preview-overlay-meta">
|
||||
<span>类型: {MATERIAL_TYPE_LABELS[previewAsset.type]}</span>
|
||||
{previewAsset.duration != null && (
|
||||
<span>时长: {formatDuration(previewAsset.duration)}</span>
|
||||
)}
|
||||
{previewAsset.size != null && (
|
||||
<span>大小: {formatSize(previewAsset.size)}</span>
|
||||
)}
|
||||
{previewAsset.quality_score != null && (
|
||||
<span>
|
||||
质量分: {previewAsset.quality_score}
|
||||
</span>
|
||||
)}
|
||||
{previewAsset.tags.length > 0 && (
|
||||
<span>标签: {previewAsset.tags.join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssetSelector;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as AssetSelector } from "./AssetSelector";
|
||||
export type { AssetSelectorProps } from "./AssetSelector";
|
||||
@@ -353,6 +353,19 @@
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
/* 素材 Tab — AssetSelector 容器 */
|
||||
.ep-left-media {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ep-left-media-footer {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 中间时间线 ──────────────────────────────────────────────── */
|
||||
.ep-center {
|
||||
flex: 1;
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
type SaveTemplatePayload,
|
||||
} from "@/api/editingPlanner";
|
||||
import {
|
||||
getMediaAssets,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
type EditPlanClip,
|
||||
@@ -87,12 +86,6 @@ const EditingPlanner: React.FC = () => {
|
||||
queryFn: getTemplateCategories,
|
||||
});
|
||||
|
||||
/* ── 数据查询:素材库 ── */
|
||||
const { data: assets = [], isLoading: assetsLoading } = useQuery({
|
||||
queryKey: ["media-assets"],
|
||||
queryFn: getMediaAssets,
|
||||
});
|
||||
|
||||
/* ── 编辑器状态 ── */
|
||||
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip");
|
||||
const [clips, setClips] = useState<EditPlanClip[]>([
|
||||
@@ -297,6 +290,33 @@ const EditingPlanner: React.FC = () => {
|
||||
// 素材拖拽开始时的回调(可用于高亮时间线等)
|
||||
}, []);
|
||||
|
||||
/* ── 批量添加素材到时间线 ── */
|
||||
const handleBatchAddAssets = useCallback(
|
||||
(assets: MediaAsset[], insertIdx?: number) => {
|
||||
const at = insertIdx ?? clips.length;
|
||||
const newClips: EditPlanClip[] = assets.map((asset, i) => ({
|
||||
id: newClipId(),
|
||||
template_segment_id: newSegId(),
|
||||
media_asset_id: asset.id,
|
||||
material_type: asset.type as EditPlanClip["material_type"],
|
||||
script_text: "",
|
||||
duration: asset.duration || 10,
|
||||
transition: { type: "none", duration: 0 },
|
||||
order: at + i,
|
||||
}));
|
||||
setClips((prev) => {
|
||||
const next = [...prev];
|
||||
next.splice(at, 0, ...newClips);
|
||||
return next.map((c, i) => ({ ...c, order: i }));
|
||||
});
|
||||
if (newClips.length > 0) {
|
||||
setSelectedClipId(newClips[0].id);
|
||||
}
|
||||
showToast(`已批量添加 ${assets.length} 个素材`, "success");
|
||||
},
|
||||
[clips.length, showToast],
|
||||
);
|
||||
|
||||
/* ──────────── 模式切换 ──────────── */
|
||||
|
||||
const handleModeChange = (mode: TemplateMode) => {
|
||||
@@ -511,9 +531,8 @@ const EditingPlanner: React.FC = () => {
|
||||
loadedTemplateId={loadedTemplateId}
|
||||
onTemplateSelect={loadTemplate}
|
||||
onNewTemplate={resetEditor}
|
||||
assets={assets}
|
||||
isLoadingAssets={assetsLoading}
|
||||
onAssetDragStart={handleAssetDragStart}
|
||||
onBatchAddAssets={handleBatchAddAssets}
|
||||
/>
|
||||
|
||||
{/* 中间:时间线 */}
|
||||
@@ -524,6 +543,7 @@ const EditingPlanner: React.FC = () => {
|
||||
onRemoveClip={handleRemoveClip}
|
||||
onReorderClips={handleReorderClips}
|
||||
onAssetDrop={handleAssetDrop}
|
||||
onBatchAssetDrop={handleBatchAddAssets}
|
||||
onAddClip={handleAddClip}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 左侧素材面板 — V21 设计系统
|
||||
* Tab 切换:模板列表 / 素材库
|
||||
* 素材支持拖拽到时间线
|
||||
* 素材 Tab 集成 AssetSelector 组件
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Input, Select, Tag } from "@/components/ui";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Input, Select, Tag, Button } from "@/components/ui";
|
||||
import {
|
||||
MODE_LABELS,
|
||||
MODE_COLORS,
|
||||
@@ -12,8 +13,11 @@ import {
|
||||
type TemplateCategory,
|
||||
type TemplateMode,
|
||||
} from "@/api/editingPlanner";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
import { MATERIAL_TYPE_ICONS } from "@/api/editPlans";
|
||||
import {
|
||||
getMediaAssets,
|
||||
type MediaAsset,
|
||||
} from "@/api/editPlans";
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector";
|
||||
|
||||
/** antd Tag color → V21 Tag variant */
|
||||
const modeVariantMap: Record<
|
||||
@@ -37,9 +41,8 @@ interface MediaPanelProps {
|
||||
onTemplateSelect: (tpl: EditingTemplate) => void;
|
||||
onNewTemplate: () => void;
|
||||
/* 素材相关 */
|
||||
assets: MediaAsset[];
|
||||
isLoadingAssets: boolean;
|
||||
onAssetDragStart: (asset: MediaAsset) => void;
|
||||
onAssetDragStart?: (asset: MediaAsset) => void;
|
||||
onBatchAddAssets?: (assets: MediaAsset[]) => void;
|
||||
}
|
||||
|
||||
const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
@@ -49,14 +52,19 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
loadedTemplateId,
|
||||
onTemplateSelect,
|
||||
onNewTemplate,
|
||||
assets,
|
||||
isLoadingAssets,
|
||||
onAssetDragStart,
|
||||
onBatchAddAssets,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<LeftTab>("templates");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterCategory, setFilterCategory] = useState("");
|
||||
const [assetFilter, setAssetFilter] = useState<string>("");
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
|
||||
/* 素材数据查询 */
|
||||
const { data: assets = [], isLoading: isLoadingAssets } = useQuery({
|
||||
queryKey: ["media-assets"],
|
||||
queryFn: getMediaAssets,
|
||||
});
|
||||
|
||||
/* 过滤模板 */
|
||||
const filteredTemplates = templates.filter((tpl) => {
|
||||
@@ -66,19 +74,16 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
return true;
|
||||
});
|
||||
|
||||
/* 过滤素材 */
|
||||
const filteredAssets = assets.filter((a) => {
|
||||
if (searchText && !a.name.toLowerCase().includes(searchText.toLowerCase()))
|
||||
return false;
|
||||
if (assetFilter && a.type !== assetFilter) return false;
|
||||
return true;
|
||||
});
|
||||
/* 获取选中的素材 */
|
||||
const selectedAssets = assets.filter((a) => selectedAssetIds.includes(a.id));
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, asset: MediaAsset) => {
|
||||
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(asset));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
onAssetDragStart(asset);
|
||||
};
|
||||
/* 批量添加到时间线 */
|
||||
const handleBatchAdd = useCallback(() => {
|
||||
if (selectedAssets.length > 0 && onBatchAddAssets) {
|
||||
onBatchAddAssets(selectedAssets);
|
||||
setSelectedAssetIds([]);
|
||||
}
|
||||
}, [selectedAssets, onBatchAddAssets]);
|
||||
|
||||
return (
|
||||
<div className="ep-left">
|
||||
@@ -98,137 +103,110 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="ep-left-header">
|
||||
<Input.Search
|
||||
placeholder={
|
||||
activeTab === "templates" ? "搜索模板..." : "搜索素材..."
|
||||
}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
{activeTab === "templates" && (
|
||||
<Select
|
||||
placeholder="按分类筛选"
|
||||
value={filterCategory || undefined}
|
||||
onChange={(v: string) => setFilterCategory(v || "")}
|
||||
allowClear
|
||||
options={categories.map((c) => ({ value: c.name, label: c.name }))}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "assets" && (
|
||||
<Select
|
||||
placeholder="素材类型"
|
||||
value={assetFilter || undefined}
|
||||
onChange={(v: string) => setAssetFilter(v || "")}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: "video", label: "🎬 视频" },
|
||||
{ value: "image", label: "🖼️ 图片" },
|
||||
{ value: "audio", label: "🎵 音频" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="ep-left-list">
|
||||
{activeTab === "templates" ? (
|
||||
/* ── 模板列表 ── */
|
||||
isLoadingTemplates ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">📭</div>
|
||||
<p>暂无模板</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((tpl) => {
|
||||
const modeColor =
|
||||
MODE_COLORS[tpl.mode as TemplateMode] || "blue";
|
||||
const variant = modeVariantMap[modeColor] || "primary";
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card${loadedTemplateId === tpl.id ? " selected" : ""}`}
|
||||
onClick={() => onTemplateSelect(tpl)}
|
||||
>
|
||||
<div className="ep-template-card-name">{tpl.name}</div>
|
||||
<div className="ep-template-card-tags">
|
||||
<Tag variant={variant}>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
||||
</Tag>
|
||||
{tpl.tags.slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} variant="info">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
{tpl.segments.length} 片段 · ~{tpl.estimated_duration}s
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : /* ── 素材列表(可拖拽) ── */
|
||||
isLoadingAssets ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">⏳</div>
|
||||
<p>加载素材中...</p>
|
||||
{activeTab === "templates" ? (
|
||||
<>
|
||||
{/* 搜索栏 */}
|
||||
<div className="ep-left-header">
|
||||
<Input.Search
|
||||
placeholder="搜索模板..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="按分类筛选"
|
||||
value={filterCategory || undefined}
|
||||
onChange={(v: string) => setFilterCategory(v || "")}
|
||||
allowClear
|
||||
options={categories.map((c) => ({ value: c.name, label: c.name }))}
|
||||
/>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">📭</div>
|
||||
<p>暂无素材</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredAssets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="ep-asset-card"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, asset)}
|
||||
>
|
||||
<div className="ep-asset-card-icon">
|
||||
{MATERIAL_TYPE_ICONS[asset.type] || "📄"}
|
||||
</div>
|
||||
<div className="ep-asset-card-info">
|
||||
<div className="ep-asset-card-name">{asset.name}</div>
|
||||
<div className="ep-asset-card-meta">
|
||||
{asset.duration ? `${asset.duration}s · ` : ""}
|
||||
{asset.type}
|
||||
{asset.tags.length > 0 && (
|
||||
<span className="ep-asset-card-tags">
|
||||
{asset.tags.map((t) => (
|
||||
<span key={t} className="ep-asset-card-tag">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ep-asset-card-drag-hint">⠿</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
{activeTab === "templates" && loadedTemplateId && (
|
||||
<div className="ep-left-footer">
|
||||
<button
|
||||
className="ep-new-template-btn"
|
||||
onClick={onNewTemplate}
|
||||
>
|
||||
✨ 新建空白模板
|
||||
</button>
|
||||
</div>
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-left-list">
|
||||
{isLoadingTemplates ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">📭</div>
|
||||
<p>暂无模板</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((tpl) => {
|
||||
const modeColor =
|
||||
MODE_COLORS[tpl.mode as TemplateMode] || "blue";
|
||||
const variant = modeVariantMap[modeColor] || "primary";
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card${loadedTemplateId === tpl.id ? " selected" : ""}`}
|
||||
onClick={() => onTemplateSelect(tpl)}
|
||||
>
|
||||
<div className="ep-template-card-name">{tpl.name}</div>
|
||||
<div className="ep-template-card-tags">
|
||||
<Tag variant={variant}>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
||||
</Tag>
|
||||
{tpl.tags.slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} variant="info">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
{tpl.segments.length} 片段 · ~{tpl.estimated_duration}s
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
{loadedTemplateId && (
|
||||
<div className="ep-left-footer">
|
||||
<button
|
||||
className="ep-new-template-btn"
|
||||
onClick={onNewTemplate}
|
||||
>
|
||||
✨ 新建空白模板
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 素材 Tab — 使用 AssetSelector */}
|
||||
<div className="ep-left-media">
|
||||
<AssetSelector
|
||||
assets={assets}
|
||||
selectedIds={selectedAssetIds}
|
||||
onSelectionChange={setSelectedAssetIds}
|
||||
onAssetDragStart={onAssetDragStart}
|
||||
showQualityFilter
|
||||
showBatchSelect
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 批量添加按钮 */}
|
||||
{selectedAssetIds.length > 0 && onBatchAddAssets && (
|
||||
<div className="ep-left-media-footer">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={handleBatchAdd}
|
||||
block
|
||||
>
|
||||
📦 添加选中素材到时间线 ({selectedAssetIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ interface TimelinePanelProps {
|
||||
onRemoveClip: (clipId: string) => void;
|
||||
onReorderClips: (fromIdx: number, toIdx: number) => void;
|
||||
onAssetDrop: (asset: MediaAsset, insertIdx: number) => void;
|
||||
onBatchAssetDrop?: (assets: MediaAsset[], insertIdx: number) => void;
|
||||
onAddClip: () => void;
|
||||
totalDuration: number;
|
||||
}
|
||||
@@ -25,6 +26,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onRemoveClip,
|
||||
onReorderClips,
|
||||
onAssetDrop,
|
||||
onBatchAssetDrop,
|
||||
onAddClip,
|
||||
totalDuration,
|
||||
}) => {
|
||||
@@ -51,8 +53,12 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
};
|
||||
|
||||
/* ── 外部素材拖入 ── */
|
||||
const isAssetDrag = (e: React.DragEvent) =>
|
||||
e.dataTransfer.types.includes("application/x-media-asset") ||
|
||||
e.dataTransfer.types.includes("application/x-media-assets");
|
||||
|
||||
const handleAssetDragOver = (e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes("application/x-media-asset")) {
|
||||
if (isAssetDrag(e)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
@@ -73,7 +79,23 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 外部素材拖入
|
||||
// 批量素材拖入
|
||||
const assetsJson = e.dataTransfer.getData("application/x-media-assets");
|
||||
if (assetsJson) {
|
||||
try {
|
||||
const assets: MediaAsset[] = JSON.parse(assetsJson);
|
||||
if (onBatchAssetDrop) {
|
||||
onBatchAssetDrop(assets, insertIdx);
|
||||
} else {
|
||||
assets.forEach((asset, i) => onAssetDrop(asset, insertIdx + i));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 单个素材拖入
|
||||
const assetJson = e.dataTransfer.getData("application/x-media-asset");
|
||||
if (assetJson) {
|
||||
try {
|
||||
@@ -88,6 +110,24 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
const handleDropOnEmpty = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOverEmpty(false);
|
||||
|
||||
// 批量素材拖入
|
||||
const assetsJson = e.dataTransfer.getData("application/x-media-assets");
|
||||
if (assetsJson) {
|
||||
try {
|
||||
const assets: MediaAsset[] = JSON.parse(assetsJson);
|
||||
if (onBatchAssetDrop) {
|
||||
onBatchAssetDrop(assets, clips.length);
|
||||
} else {
|
||||
assets.forEach((asset, i) => onAssetDrop(asset, clips.length + i));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 单个素材拖入
|
||||
const assetJson = e.dataTransfer.getData("application/x-media-asset");
|
||||
if (assetJson) {
|
||||
try {
|
||||
@@ -100,7 +140,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
};
|
||||
|
||||
const handleEmptyDragOver = (e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes("application/x-media-asset")) {
|
||||
if (isAssetDrag(e)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOverEmpty(true);
|
||||
|
||||
Reference in New Issue
Block a user