diff --git a/alembic/versions/031_add_file_hash_to_assets_and_ing_jobs.py b/alembic/versions/031_add_file_hash_to_assets_and_ing_jobs.py
new file mode 100644
index 000000000..3af61f178
--- /dev/null
+++ b/alembic/versions/031_add_file_hash_to_assets_and_ing_jobs.py
@@ -0,0 +1,33 @@
+"""Add file_hash to assets and ingest_jobs
+
+Revision ID: 031
+Revises: 030
+Create Date: 2026-07-07
+
+为素材去重检测功能添加 file_hash 字段。
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "031"
+down_revision = "030"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("assets", sa.Column("file_hash", sa.String(64), nullable=True))
+ op.create_index(op.f("ix_assets_file_hash"), "assets", ["file_hash"])
+
+ op.add_column("ingest_jobs", sa.Column("file_hash", sa.String(64), nullable=True))
+ op.create_index(op.f("ix_ingest_jobs_file_hash"), "ingest_jobs", ["file_hash"])
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_ingest_jobs_file_hash"), table_name="ingest_jobs")
+ op.drop_column("ingest_jobs", "file_hash")
+
+ op.drop_index(op.f("ix_assets_file_hash"), table_name="assets")
+ op.drop_column("assets", "file_hash")
diff --git a/alembic/versions/032_add_asset_select_mode_to_generation_tasks.py b/alembic/versions/032_add_asset_select_mode_to_generation_tasks.py
new file mode 100644
index 000000000..842863817
--- /dev/null
+++ b/alembic/versions/032_add_asset_select_mode_to_generation_tasks.py
@@ -0,0 +1,28 @@
+"""Add asset_select_mode to generation_tasks
+
+Revision ID: 032
+Revises: 031
+Create Date: 2026-07-07
+
+素材库自动匹配功能:为 generation_tasks 表添加 asset_select_mode 字段。
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "032"
+down_revision = "031"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "generation_tasks",
+ sa.Column("asset_select_mode", sa.String(20), nullable=False, server_default=""),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("generation_tasks", "asset_select_mode")
diff --git a/alembic/versions/033_add_batch_id_to_generation_tasks.py b/alembic/versions/033_add_batch_id_to_generation_tasks.py
new file mode 100644
index 000000000..843dfab63
--- /dev/null
+++ b/alembic/versions/033_add_batch_id_to_generation_tasks.py
@@ -0,0 +1,31 @@
+"""Add batch_id to generation_tasks
+
+Revision ID: 033
+Revises: 032
+Create Date: 2026-07-07
+
+视频查重功能:为 generation_tasks 表添加 batch_id 字段,
+用于关联同一次批量生成请求中的多个任务。
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "033"
+down_revision = "032"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "generation_tasks",
+ sa.Column("batch_id", sa.String(32), nullable=False, server_default=""),
+ )
+ op.create_index(op.f("ix_generation_tasks_batch_id"), "generation_tasks", ["batch_id"])
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_generation_tasks_batch_id"), table_name="generation_tasks")
+ op.drop_column("generation_tasks", "batch_id")
diff --git a/apps/api/app/api/routes/chunked_upload.py b/apps/api/app/api/routes/chunked_upload.py
index 058b1a884..cf8aaae4c 100644
--- a/apps/api/app/api/routes/chunked_upload.py
+++ b/apps/api/app/api/routes/chunked_upload.py
@@ -19,6 +19,7 @@ from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
+ get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
@@ -360,6 +361,7 @@ async def complete_chunked_upload(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
+ asset_repository: Any = Depends(get_asset_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ChunkedUploadCompleteResponse:
@@ -423,6 +425,29 @@ async def complete_chunked_upload(
content_type=meta["content_type"],
)
+ # ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
+ if request.file_hash:
+ existing = asset_repository.find_by_library_and_file_hash(
+ library_id=request.library_id,
+ file_hash=request.file_hash,
+ )
+ if existing is not None:
+ logger.info(
+ "素材去重命中(chunked): library=%s hash=%s existing_asset=%s",
+ request.library_id,
+ request.file_hash,
+ existing.id,
+ )
+ meta["status"] = "completed"
+ _save_upload_meta(upload_id, meta)
+ return ChunkedUploadCompleteResponse(
+ storage_key=storage_key,
+ ingest_job_id="",
+ url=file_url,
+ duplicated=True,
+ asset_id=existing.id,
+ )
+
# Create ingest job
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
@@ -430,6 +455,7 @@ async def complete_chunked_upload(
project_id=meta["project_id"],
library_id=meta["library_id"],
storage_key=storage_key,
+ file_hash=request.file_hash,
)
)
celery_app.send_task("worker.ingest_asset", args=[job.id])
diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py
index cfd47edbd..b4ece7f3e 100644
--- a/apps/api/app/api/routes/generation_tasks.py
+++ b/apps/api/app/api/routes/generation_tasks.py
@@ -1,3 +1,5 @@
+import random
+import uuid
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
@@ -14,6 +16,7 @@ from app.schemas.generated_video import (
ListGeneratedVideosResponse,
)
from app.schemas.generation_task import (
+ BatchGenerationTaskResponse,
CreateGenerationTaskRequest,
GenerationTaskResponse,
ListGenerationTasksResponse,
@@ -51,6 +54,8 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
title_ids=task.title_ids,
voice_ids=task.voice_ids,
source_edit_plan_id=task.source_edit_plan_id or "",
+ asset_select_mode=getattr(task, "asset_select_mode", ""),
+ batch_id=getattr(task, "batch_id", ""),
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -85,6 +90,49 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
)
+def _select_assets_from_library(
+ assets: list,
+ mode: str,
+ count: int,
+) -> list[str]:
+ """根据选取模式从素材库中选取 ready 状态的视频素材 ID。
+
+ Args:
+ assets: 素材库中所有素材(Asset 实体列表)
+ mode: 选取模式 — all=全部, random=随机, smart=按质量评分
+ count: 选取数量,0 表示全部(仅 random/smart 模式有效)
+
+ Returns:
+ 选中的素材 ID 列表
+ """
+ ready_video_assets = [a for a in assets if a.status.value == "ready" and a.mime_type.startswith("video")]
+
+ if not ready_video_assets:
+ return []
+
+ if mode == "random":
+ selected = (
+ ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
+ )
+ return [a.id for a in selected]
+
+ if mode == "smart":
+ # 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
+ sorted_assets = sorted(
+ ready_video_assets,
+ key=lambda a: (
+ a.quality_score if a.quality_score is not None else 0.0,
+ a.duration if a.duration is not None else 0.0,
+ ),
+ reverse=True,
+ )
+ selected = sorted_assets if count <= 0 else sorted_assets[:count]
+ return [a.id for a in selected]
+
+ # 默认 all 模式:返回全部 ready 视频素材
+ return [a.id for a in ready_video_assets]
+
+
def _resolve_project_and_library(
request: CreateGenerationTaskRequest,
project_repository: Any,
@@ -122,7 +170,7 @@ def _resolve_project_and_library(
return project_id, asset_library_id
-@router.post("/tasks", response_model=GenerationTaskResponse)
+@router.post("/tasks", response_model=BatchGenerationTaskResponse)
def create_generation_task(
request: CreateGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -130,12 +178,13 @@ def create_generation_task(
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
-) -> GenerationTaskResponse:
+) -> BatchGenerationTaskResponse:
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
+ resolved_asset_ids: list[str] = list(request.asset_ids)
if asset_library_id:
library = asset_library_repository.get(asset_library_id)
if library is None or (project_id and library.project_id != project_id):
@@ -144,23 +193,42 @@ def create_generation_task(
assets = asset_repository.find_by_library(asset_library_id)
_ensure_library_has_ready_video_assets(assets)
+ # 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
+ if not resolved_asset_ids:
+ resolved_asset_ids = _select_assets_from_library(
+ assets,
+ mode=request.asset_select_mode,
+ count=request.asset_select_count,
+ )
+
use_case = CreateGenerationTaskUseCase(generation_task_repository)
- task = use_case.execute(
- CreateGenerationTaskCommand(
- project_id=project_id,
- asset_library_id=asset_library_id,
- strategy_id=request.strategy_id,
- voice_library_id=request.voice_library_id,
- template_id=request.template_id,
- asset_ids=request.asset_ids,
- title_ids=request.title_ids,
- voice_ids=request.voice_ids,
- created_by_user_id=authenticated_user.user.id,
- source_edit_plan_id=request.source_edit_plan_id,
+ count = request.count
+ created_tasks = []
+ # 同批次任务共享 batch_id,用于视频查重时批次内比对
+ batch_id = uuid.uuid4().hex if count > 1 else ""
+
+ for _ in range(count):
+ task = use_case.execute(
+ CreateGenerationTaskCommand(
+ project_id=project_id,
+ asset_library_id=asset_library_id,
+ strategy_id=request.strategy_id,
+ voice_library_id=request.voice_library_id,
+ template_id=request.template_id,
+ asset_ids=resolved_asset_ids,
+ title_ids=request.title_ids,
+ voice_ids=request.voice_ids,
+ created_by_user_id=authenticated_user.user.id,
+ source_edit_plan_id=request.source_edit_plan_id,
+ asset_select_mode=request.asset_select_mode,
+ batch_id=batch_id,
+ )
)
- )
- celery_app.send_task("worker.generate_video", args=[task.id])
- return _to_generation_task_response(task)
+ celery_app.send_task("worker.generate_video", args=[task.id])
+ created_tasks.append(task)
+
+ items = [_to_generation_task_response(t) for t in created_tasks]
+ return BatchGenerationTaskResponse(items=items, total=len(items))
@router.get("/tasks", response_model=ListGenerationTasksResponse)
@@ -237,6 +305,7 @@ def retry_generation_task(
voice_ids=task.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=task.source_edit_plan_id or "",
+ asset_select_mode=getattr(task, "asset_select_mode", ""),
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py
index 304afefe9..7679630c3 100644
--- a/apps/api/app/api/routes/upload.py
+++ b/apps/api/app/api/routes/upload.py
@@ -8,6 +8,7 @@ from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
+ get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
@@ -99,6 +100,7 @@ def _submit_ingest_job(
library_id: str,
storage_key: str,
ingest_job_repository: Any,
+ file_hash: str = "",
) -> Any:
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
@@ -106,6 +108,7 @@ def _submit_ingest_job(
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
+ file_hash=file_hash,
)
)
celery_app.send_task("worker.ingest_asset", args=[job.id])
@@ -176,6 +179,7 @@ async def complete_direct_upload(
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
+ asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadCompleteResponse:
"""确认浏览器直传完成并创建导入任务。"""
@@ -199,11 +203,32 @@ async def complete_direct_upload(
if not file_exists:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
+ # ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
+ if request.file_hash:
+ existing = asset_repository.find_by_library_and_file_hash(
+ library_id=request.library_id,
+ file_hash=request.file_hash,
+ )
+ if existing is not None:
+ logger.info(
+ "素材去重命中: library=%s hash=%s existing_asset=%s",
+ request.library_id,
+ request.file_hash,
+ existing.id,
+ )
+ return DirectUploadCompleteResponse(
+ storage_key=normalized_key,
+ ingest_job_id="",
+ duplicated=True,
+ asset_id=existing.id,
+ )
+
job = _submit_ingest_job(
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
ingest_job_repository=ingest_job_repository,
+ file_hash=request.file_hash,
)
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
@@ -218,15 +243,38 @@ async def upload_asset(
project_id: str = Form(..., min_length=1, description="项目 ID"),
library_id: str = Form(..., min_length=1, description="素材库 ID"),
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
+ file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
+ asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> UploadAssetResponse:
"""上传素材文件并触发导入流水线。"""
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
+ # ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
+ if file_hash:
+ existing = asset_repository.find_by_library_and_file_hash(
+ library_id=library_id,
+ file_hash=file_hash,
+ )
+ if existing is not None:
+ logger.info(
+ "素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
+ library_id,
+ file_hash,
+ existing.id,
+ )
+ return UploadAssetResponse(
+ storage_key=existing.storage_key,
+ ingest_job_id="",
+ url="",
+ duplicated=True,
+ asset_id=existing.id,
+ )
+
# P2-5: 服务端验证 MIME 类型
validated_content_type = _validate_mime_type(file.content_type)
@@ -255,6 +303,7 @@ async def upload_asset(
library_id=library_id,
storage_key=storage_key,
ingest_job_repository=ingest_job_repository,
+ file_hash=file_hash,
)
return UploadAssetResponse(
diff --git a/apps/api/app/schemas/chunked_upload.py b/apps/api/app/schemas/chunked_upload.py
index 8c5cbef78..884b1f481 100644
--- a/apps/api/app/schemas/chunked_upload.py
+++ b/apps/api/app/schemas/chunked_upload.py
@@ -36,9 +36,12 @@ class ChunkedUploadStatusResponse(BaseModel):
class ChunkedUploadCompleteRequest(BaseModel):
project_id: str = Field(..., min_length=1, description="Project ID")
library_id: str = Field(..., min_length=1, description="Asset library ID")
+ file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class ChunkedUploadCompleteResponse(BaseModel):
storage_key: str = Field(..., description="Storage key")
ingest_job_id: str = Field(..., description="Ingest job ID")
url: str = Field(..., description="File URL")
+ duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
+ asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py
index 8e1fc7a74..e75479954 100644
--- a/apps/api/app/schemas/generation_task.py
+++ b/apps/api/app/schemas/generation_task.py
@@ -21,6 +21,16 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ──
source_edit_plan_id: str = ""
+ # ── 批量生成 ──
+ count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
+ # ── 素材库自动匹配 ──
+ asset_select_mode: str = Field(
+ default="all",
+ description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
+ )
+ asset_select_count: int = Field(
+ default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
+ )
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -46,12 +56,21 @@ class GenerationTaskResponse(BaseModel):
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
source_edit_plan_id: str = ""
+ asset_select_mode: str = ""
+ batch_id: str = ""
status: str
progress: float
result_count: int
error_message: str
+class BatchGenerationTaskResponse(BaseModel):
+ """批量生成任务响应。"""
+
+ items: list[GenerationTaskResponse]
+ total: int
+
+
class ListGenerationTasksResponse(BaseModel):
"""用户级生成任务列表响应(跨 project)。"""
diff --git a/apps/api/app/schemas/upload.py b/apps/api/app/schemas/upload.py
index 90a7a026d..b9763606c 100644
--- a/apps/api/app/schemas/upload.py
+++ b/apps/api/app/schemas/upload.py
@@ -6,12 +6,7 @@ class UploadAssetRequest(BaseModel):
project_id: str = Field(..., min_length=1, description="项目 ID")
library_id: str = Field(..., min_length=1, description="素材库 ID")
-
-
-class UploadAssetResponse(BaseModel):
- storage_key: str
- ingest_job_id: str
- url: str = Field(..., description="Public URL of uploaded file")
+ file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadPrepareRequest(BaseModel):
@@ -20,6 +15,7 @@ class DirectUploadPrepareRequest(BaseModel):
filename: str = Field(..., min_length=1, max_length=255)
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
file_size: int = Field(..., gt=0)
+ file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadPrepareResponse(BaseModel):
@@ -35,8 +31,19 @@ class DirectUploadCompleteRequest(BaseModel):
project_id: str = Field(..., min_length=1)
library_id: str = Field(..., min_length=1)
storage_key: str = Field(..., min_length=1, max_length=255)
+ file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadCompleteResponse(BaseModel):
storage_key: str
ingest_job_id: str
+ duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
+ asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
+
+
+class UploadAssetResponse(BaseModel):
+ storage_key: str
+ ingest_job_id: str
+ url: str = Field(..., description="Public URL of uploaded file")
+ duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
+ asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx
index e7129fff7..80aef0597 100644
--- a/apps/web/src/pages/assets/AssetLibrary.tsx
+++ b/apps/web/src/pages/assets/AssetLibrary.tsx
@@ -110,14 +110,16 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
item.classification_status ?? undefined,
);
const metadata = item.metadata || {};
+ const kind = inferKind(item.mime_type || "");
return {
id: item.id,
name: item.name,
- kind: inferKind(item.mime_type || ""),
+ kind,
+ // 视频类型不能用 file_url 做缩略图(是视频文件,
无法渲染)
thumbUrl:
(item.thumbnail_url as string | undefined) ||
- (item.file_url as string | undefined) ||
- (metadata.thumbnail_url as string | undefined),
+ (metadata.thumbnail_url as string | undefined) ||
+ (kind !== "video" ? (item.file_url as string | undefined) : undefined),
fileUrl:
(item.file_url as string | undefined) ||
(metadata.file_url as string | undefined),
@@ -361,6 +363,7 @@ const AssetLibrary: React.FC = () => {
/* 上传 */
const [uploading, setUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
/* 新建素材库 */
const [createModalOpen, setCreateModalOpen] = useState(false);
@@ -434,11 +437,16 @@ const AssetLibrary: React.FC = () => {
}
setUploading(true);
+ setUploadProgress(0);
try {
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`);
}
- await uploadAssetDirect({ file, library_id: effectiveLibId });
+ await uploadAssetDirect({
+ file,
+ library_id: effectiveLibId,
+ onProgress: (pct) => setUploadProgress(pct),
+ });
message.success(`"${file.name}" 上传成功`);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
@@ -446,6 +454,7 @@ const AssetLibrary: React.FC = () => {
message.error(`"${file.name}" 上传失败`);
} finally {
setUploading(false);
+ setUploadProgress(0);
}
return false;
};
@@ -532,6 +541,54 @@ const AssetLibrary: React.FC = () => {
return (
+ {/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
+
+
+
+
+ {uploadProgress}%
+ 上传中…
+
+
+
+
{/* 两栏布局 */}
{/* ─── 左侧:素材库列表 ─── */}
diff --git a/apps/web/src/pages/assets/assets.css b/apps/web/src/pages/assets/assets.css
index 3333f1306..63e46e653 100644
--- a/apps/web/src/pages/assets/assets.css
+++ b/apps/web/src/pages/assets/assets.css
@@ -587,3 +587,40 @@
grid-template-columns: 1fr;
}
}
+
+/* ─── 上传进度弹窗 ─── */
+.xx-upload-progress-modal .ant-modal-content {
+ padding: 24px 16px 20px;
+ border-radius: 16px;
+}
+
+.xx-upload-progress-body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ padding: 8px 0;
+}
+
+.xx-upload-progress-ring {
+ display: block;
+}
+
+.xx-upload-progress-text {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+}
+
+.xx-upload-progress-pct {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--primary-color, #6366f1);
+ line-height: 1;
+}
+
+.xx-upload-progress-label {
+ font-size: 13px;
+ color: var(--text-secondary, #6b7280);
+}
diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx
index 6e9dc0342..1c6ad79ea 100644
--- a/apps/web/src/pages/generate/GeneratePage.tsx
+++ b/apps/web/src/pages/generate/GeneratePage.tsx
@@ -17,13 +17,13 @@ import {
ShareAltOutlined,
SaveOutlined,
PlusOutlined,
+ MinusOutlined,
CloseOutlined,
} from "@ant-design/icons";
import type { AssetItem } from "@/api/assets";
import { getAssets, getAssetLibraries } from "@/api/assets";
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
import { getEditingTemplates } from "@/api/editingPlanner";
-import { MODE_LABELS, type TemplateMode } from "@/api/editingPlanner";
import { getTitles } from "@/api/titles";
import apiClient from "@/api/client";
import { fetchPresetVoices } from "@/api/voices";
@@ -54,13 +54,6 @@ const MODE_GRADIENTS: Record = {
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
};
-const MODE_ABBRS: Record = {
- pip: "PIP",
- one_take: "ONE",
- voice_over: "VOI",
- voice_pip: "VP",
-};
-
/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */
const VOICE_GENDER_ICON: Record = {
female: "🎀",
@@ -122,6 +115,8 @@ const GeneratePage: React.FC = () => {
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState([]);
+ /* 素材选择模式:手动选择 / 自动匹配 */
+ const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual");
/* ── 标题 ── */
const [title, setTitle] = useState("");
@@ -146,6 +141,9 @@ const GeneratePage: React.FC = () => {
);
const [customVoiceText, setCustomVoiceText] = useState("");
+ /* ── 生成数量 ── */
+ const [generateCount, setGenerateCount] = useState(1);
+
/* ── 克隆声音 ── */
const [selectedClonedVoice, setSelectedClonedVoice] = useState("");
const [cloneModalOpen, setCloneModalOpen] = useState(false);
@@ -456,11 +454,17 @@ const GeneratePage: React.FC = () => {
}, [navigate]);
const handleGenerate = useCallback(async () => {
+ console.log("[handleGenerate] 开始生成, 参数:", {
+ title,
+ selectedTemplate,
+ selectedMaterials,
+ voiceMode,
+ });
if (!title.trim()) {
message.warning("请先选择或输入标题");
return;
}
- if (selectedMaterials.length === 0) {
+ if (materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材");
return;
}
@@ -498,6 +502,8 @@ const GeneratePage: React.FC = () => {
duration,
auto_subtitles: autoSubtitles,
bgm,
+ generate_count: generateCount,
+ material_mode: materialMode,
},
total_duration: duration,
source_edit_plan_id: editPlanId || undefined,
@@ -560,20 +566,34 @@ const GeneratePage: React.FC = () => {
typeof setInterval
>;
} catch (err: unknown) {
- console.error("生成失败:", err);
+ console.error("[handleGenerate] 生成失败:", err);
setGenerating(false);
// 提取 axios 响应中的后端错误信息
const axiosErr = err as {
response?: {
- data?: { message?: string; error?: string; detail?: string };
+ data?: {
+ message?: string;
+ error?: string;
+ detail?: string;
+ msg?: string;
+ };
};
+ message?: string;
};
const backendMsg =
axiosErr.response?.data?.message ||
axiosErr.response?.data?.error ||
axiosErr.response?.data?.detail ||
+ axiosErr.response?.data?.msg ||
+ axiosErr.message ||
"";
- message.error(backendMsg || "生成失败,请重试");
+ console.error(
+ "[handleGenerate] 错误信息:",
+ backendMsg,
+ "完整错误:",
+ axiosErr,
+ );
+ message.error(backendMsg || "生成失败,请检查网络后重试或联系管理员");
}
}, [
title,
@@ -590,6 +610,8 @@ const GeneratePage: React.FC = () => {
bgm,
editPlanId,
selectedTemplate,
+ generateCount,
+ materialMode,
]);
/* ── 步骤导航 ── */
@@ -598,7 +620,11 @@ const GeneratePage: React.FC = () => {
message.warning("请先选择一个模板");
return;
}
- if (currentStep === 2 && selectedMaterials.length === 0) {
+ if (
+ currentStep === 2 &&
+ materialMode === "manual" &&
+ selectedMaterials.length === 0
+ ) {
message.warning("请至少选择一个素材");
return;
}
@@ -609,7 +635,13 @@ const GeneratePage: React.FC = () => {
if (currentStep < 5) {
setCurrentStep((s) => s + 1);
}
- }, [currentStep, selectedTemplate, selectedMaterials.length, title]);
+ }, [
+ currentStep,
+ selectedTemplate,
+ selectedMaterials.length,
+ title,
+ materialMode,
+ ]);
const goPrev = useCallback(() => {
if (currentStep > 1) {
@@ -669,11 +701,10 @@ const GeneratePage: React.FC = () => {
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
}}
>
- {MODE_ABBRS[tpl.mode] || "TPL"}
+ 🎬
{tpl.name}
- {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "}
{tpl.estimated_duration}s · {tpl.segments.length}片段
{tpl.tags.length > 0 && (
@@ -708,11 +739,31 @@ const GeneratePage: React.FC = () => {
);
- /** 步骤 2:选择素材 */
+ /** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */
const renderStep2 = () => (
📦 选择素材
-
+
+ {/* ── 模式切换 Tab ── */}
+
+
+
+
+
+ {/* ── 素材库选择(两种模式共用) ── */}
+
-
-
- 已选 {selectedMaterials.length} 个素材
-
-
- 系统将自动选择最合适的素材
-
-
- {/* 素材列表 */}
-
- {materialsLoading ? (
-
- 加载素材中…
-
- ) : materials.length === 0 ? (
-
- 暂无素材,请先在素材库中上传
-
- ) : (
-
- {materials.map((m) => {
- const checked = selectedMaterials.includes(m.id);
- return (
-
- );
- })}
+ {/* ── 手动选择模式 ── */}
+ {materialMode === "manual" && (
+ <>
+
+
+ 已选 {selectedMaterials.length} 个素材
+
- )}
-
+
+ {/* 素材列表 */}
+
+ {materialsLoading ? (
+
+ 加载素材中…
+
+ ) : materials.length === 0 ? (
+
+ 暂无素材,请先在素材库中上传
+
+ ) : (
+
+ {materials.map((m) => {
+ const checked = selectedMaterials.includes(m.id);
+ return (
+
+ );
+ })}
+
+ )}
+
+ >
+ )}
+
+ {/* ── 自动匹配模式 ── */}
+ {materialMode === "auto" && (
+
+
🤖
+
+
智能素材匹配
+
+ 系统将根据所选模板和标题,从素材库中自动分析并匹配最合适的素材进行视频生成。
+ 无需手动挑选,AI
+ 会综合素材质量、时长、内容相关性等维度进行智能筛选。
+
+
+ 📊 质量评分筛选
+ 🎯 内容相关性匹配
+ ⏱️ 时长智能分配
+
+
+ {materialsLoading ? (
+
+ 扫描素材库中…
+
+ ) : (
+
+ 当前素材库共 {materials.length} 个素材可供匹配
+
+ )}
+
+ )}
);
@@ -1270,7 +1369,9 @@ const GeneratePage: React.FC = () => {
素材
- {selectedMaterials.length} 个素材
+ {materialMode === "auto"
+ ? "自动匹配"
+ : `${selectedMaterials.length} 个素材`}
@@ -1281,6 +1382,29 @@ const GeneratePage: React.FC = () => {
配音
{getVoiceName()}
+
+
生成数量
+
+
+
+
{generateCount}
+
+
条视频
+
+
+
{/* 生成进度 */}
diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css
index 82052f16b..8f915d49e 100644
--- a/apps/web/src/pages/generate/generate.css
+++ b/apps/web/src/pages/generate/generate.css
@@ -1074,3 +1074,141 @@
opacity: 1;
}
}
+
+/* ── 生成数量步进器 ── */
+.xx-count-stepper {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.xx-count-stepper-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: 1px solid var(--border-primary, #e2e8f0);
+ border-radius: 8px;
+ background: var(--bg-surface, #fff);
+ color: var(--text-secondary, #64748b);
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.xx-count-stepper-btn:hover:not(:disabled) {
+ border-color: var(--primary-400, #818cf8);
+ color: var(--primary-600, #4f46e5);
+ background: var(--primary-50, #eef2ff);
+}
+
+.xx-count-stepper-btn:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+.xx-count-stepper-value {
+ min-width: 24px;
+ text-align: center;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary, #1e293b);
+}
+
+.xx-count-stepper-hint {
+ font-size: 12px;
+ color: var(--text-tertiary, #94a3b8);
+ margin-left: 2px;
+}
+
+/* ── 素材选择模式切换 Tab ── */
+.xx-material-mode-tabs {
+ display: flex;
+ gap: 0;
+ border: 1px solid var(--border-primary, #e2e8f0);
+ border-radius: 10px;
+ overflow: hidden;
+ margin-bottom: 4px;
+}
+
+.xx-material-mode-tab {
+ flex: 1;
+ padding: 10px 16px;
+ font-size: 13px;
+ font-weight: 500;
+ border: none;
+ background: var(--bg-surface, #fff);
+ color: var(--text-secondary, #64748b);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-align: center;
+}
+
+.xx-material-mode-tab:first-child {
+ border-right: 1px solid var(--border-primary, #e2e8f0);
+}
+
+.xx-material-mode-tab:hover {
+ background: var(--primary-50, #eef2ff);
+ color: var(--primary-600, #4f46e5);
+}
+
+.xx-material-mode-tab.active {
+ background: var(--primary-500, #6366f1);
+ color: #fff;
+ font-weight: 600;
+}
+
+/* ── 自动匹配卡片 ── */
+.xx-auto-match-card {
+ margin-top: 14px;
+ padding: 20px;
+ background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
+ border: 1px solid var(--border-primary, #e2e8f0);
+ border-radius: 14px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+}
+
+.xx-auto-match-icon {
+ font-size: 36px;
+ margin-bottom: 10px;
+}
+
+.xx-auto-match-body {
+ width: 100%;
+}
+
+.xx-auto-match-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary, #1e293b);
+ margin: 0 0 8px;
+}
+
+.xx-auto-match-desc {
+ font-size: 13px;
+ color: var(--text-secondary, #64748b);
+ line-height: 1.6;
+ margin: 0 0 14px;
+}
+
+.xx-auto-match-features {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 8px;
+}
+
+.xx-auto-match-feature {
+ display: inline-block;
+ padding: 4px 12px;
+ font-size: 12px;
+ color: var(--primary-600, #4f46e5);
+ background: rgba(255, 255, 255, 0.8);
+ border: 1px solid var(--border-light, #f1f5f9);
+ border-radius: 20px;
+}
diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx
index d0aa34e58..b992c853f 100644
--- a/apps/web/src/pages/products/ProductLibrary.tsx
+++ b/apps/web/src/pages/products/ProductLibrary.tsx
@@ -737,11 +737,19 @@ const ProductLibrary: React.FC = () => {
// ── Error 状态 ──
if (isError) {
+ console.error("[ProductLibrary] 加载失败:", error);
+ const errorMsg = error?.message || "加载失败";
+ // 区分 404 和其他错误
+ const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found");
return (
-
❌
-
{error?.message || "加载失败"}
+
{is404 ? "🔍" : "❌"}
+
+ {is404
+ ? "成片库功能正在建设中,敬请期待"
+ : errorMsg || "加载失败,请稍后重试"}
+