diff --git a/alembic/versions/030_add_tags_and_asset_tags.py b/alembic/versions/030_add_tags_and_asset_tags.py new file mode 100644 index 000000000..338750833 --- /dev/null +++ b/alembic/versions/030_add_tags_and_asset_tags.py @@ -0,0 +1,68 @@ +"""Add tags and asset_tags tables + +Revision ID: 030 +Revises: 029 +Create Date: 2026-07-07 + +新增标签表和素材-标签关联表,支持规范化多对多标签管理。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "030" +down_revision = "029" +branch_labels = None +depends_on = None + + +def _table_exists(table: str) -> bool: + ctx = op.get_context() + if ctx.as_sql: + return False + conn = op.get_bind() + result = conn.execute( + sa.text("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = :table"), + {"table": table}, + ) + return (result.scalar() or 0) > 0 + + +def upgrade() -> None: + if not _table_exists("tags"): + op.create_table( + "tags", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(36), nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.UniqueConstraint("user_id", "name", name="uq_tags_user_name"), + ) + op.create_index("ix_tags_user_id", "tags", ["user_id"]) + + if not _table_exists("asset_tags"): + op.create_table( + "asset_tags", + sa.Column("asset_id", sa.String(36), primary_key=True), + sa.Column("tag_id", sa.String(36), primary_key=True), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + ) + op.create_index("ix_asset_tags_tag_id", "asset_tags", ["tag_id"]) + + +def downgrade() -> None: + op.drop_index("ix_asset_tags_tag_id", table_name="asset_tags") + op.drop_table("asset_tags") + op.drop_index("ix_tags_user_id", table_name="tags") + op.drop_table("tags") diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 714181055..875833cda 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -16,6 +16,7 @@ from app.api.routes.jobs import router as jobs_router from app.api.routes.projects import router as projects_router from app.api.routes.recipes import router as recipes_router from app.api.routes.subscription import router as subscription_router +from app.api.routes.tags import router as tags_router from app.api.routes.task_center import router as task_center_router from app.api.routes.templates import router as templates_router from app.api.routes.titles import router as titles_router @@ -38,6 +39,11 @@ api_router.include_router( prefix="/projects", tags=["Project"], ) +api_router.include_router( + tags_router, + prefix="/tags", + tags=["Tag"], +) api_router.include_router( task_center_router, tags=["TaskCenter"], diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index b7a4c2aef..f351699f7 100644 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -7,14 +7,18 @@ from app.dependencies import ( get_asset_library_repository, get_asset_repository, get_project_repository, + get_tag_repository, ) from app.schemas.asset import ( AssetResponse, + BatchDeleteRequest, + BatchDeleteResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetRequest, UpdateAssetReviewRequest, ) +from app.schemas.tag import TagAssetsRequest from fastapi import APIRouter, Depends, HTTPException, Query from packages.application import ( @@ -64,6 +68,7 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse: classification_status=item.classification_status.value, quality_score=item.quality_score, uploaded_by_user_id=item.uploaded_by_user_id, + tag_ids=getattr(item, "tag_ids", []), ) @@ -84,6 +89,7 @@ def list_assets( keyword: Optional[str] = Query(None, description="按名称模糊匹配"), gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"), style: Optional[str] = Query(None, description="按 metadata.style 筛选"), + tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -96,12 +102,19 @@ def list_assets( # kind → file_type 映射(voice 对应 audio) kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"} - def _apply_filters(items): - """依次应用 kind / keyword / gender / style 过滤。""" + # 解析 tag_ids 参数(逗号分隔) + filter_tag_ids: list[str] | None = None + if tag_ids: + filter_tag_ids = [t.strip() for t in tag_ids.split(",") if t.strip()] + if not filter_tag_ids: + filter_tag_ids = None + + # 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤) + needs_memory_filter = bool(keyword or gender or style or filter_tag_ids) + + def _apply_memory_filters(items): + """应用 keyword / gender / style / tag_ids 内存过滤。""" result = items - if kind: - ft = kind_to_file_type.get(kind) - result = [i for i in result if i.mime_type and i.mime_type.startswith(ft or "")] if keyword: kw = keyword.lower() result = [i for i in result if kw in (i.name or "").lower()] @@ -109,9 +122,90 @@ def list_assets( result = [i for i in result if (i.metadata or {}).get("gender") == gender] if style: result = [i for i in result if (i.metadata or {}).get("style") == style] + if filter_tag_ids: + tag_set = set(filter_tag_ids) + result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))] return result - # 模式1:指定 library_id → 返回该库的素材 + # ── 优化路径:无内存过滤时,使用 DB 级分页 ── + if not needs_memory_filter: + ft = kind_to_file_type.get(kind) if kind else None + + # 模式1:指定 library_id + if library_id: + library = asset_library_repository.get(library_id) + if library is None: + raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") + _check_project_access(library.project_id, user_id, project_repository) + if ft: + items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit) + total = asset_repository.count_by_project(library.project_id) if not kind else len(items) + else: + items = asset_repository.find_by_library(library_id, skip=skip, limit=limit) + total = asset_repository.count_by_project(library.project_id) + return ListAssetsResponse( + items=[_to_asset_response(item) for item in items], + total=total, + skip=skip, + limit=limit, + ) + + # 模式2:指定 project_id + if project_id: + _check_project_access(project_id, user_id, project_repository) + if ft: + # 无直接方法,加载后按 file_type 过滤(仍比全量加载好) + all_items = asset_repository.find_by_project(project_id) + items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)] + total = len(items) + paged = items[skip : skip + limit] + else: + items = asset_repository.find_by_project(project_id, skip=skip, limit=limit) + total = asset_repository.count_by_project(project_id) + paged = items + return ListAssetsResponse( + items=[_to_asset_response(item) for item in paged], + total=total, + skip=skip, + limit=limit, + ) + + # 模式3:跨项目(无 library_id/project_id) + try: + projects = project_repository.find_accessible_projects(user_id) + except Exception: + logger.exception("查询用户可访问项目失败: user_id=%s", user_id) + return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit) + + project_ids = [p.id for p in projects] + if not project_ids: + return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit) + + total = asset_repository.count_by_project_ids(project_ids) + # 跨项目分页:逐项目累积直到凑够一页 + paged_items: list = [] + offset = skip + remaining = limit + for pid in project_ids: + proj_total = asset_repository.count_by_project(pid) + if offset >= proj_total: + offset -= proj_total + continue + proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining) + paged_items.extend(proj_items) + remaining -= len(proj_items) + offset = 0 + if remaining <= 0: + break + + return ListAssetsResponse( + items=[_to_asset_response(item) for item in paged_items], + total=total, + skip=skip, + limit=limit, + ) + + # ── 内存过滤路径:有 keyword/gender/style 时,加载全量后内存过滤 ── if library_id: library = asset_library_repository.get(library_id) if library is None: @@ -121,41 +215,24 @@ def list_assets( all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind]) else: all_items = asset_repository.find_by_library(library_id) - filtered = _apply_filters(all_items) - total = len(filtered) - paged = filtered[skip : skip + limit] - return ListAssetsResponse( - items=[_to_asset_response(item) for item in paged], - total=total, - skip=skip, - limit=limit, - ) - - # 模式2:指定 project_id → 返回该项目所有素材 - if project_id: + elif project_id: _check_project_access(project_id, user_id, project_repository) all_items = asset_repository.find_by_project(project_id) - filtered = _apply_filters(all_items) - total = len(filtered) - paged = filtered[skip : skip + limit] - return ListAssetsResponse( - items=[_to_asset_response(item) for item in paged], - total=total, - skip=skip, - limit=limit, - ) + else: + try: + projects = project_repository.find_accessible_projects(user_id) + except Exception: + logger.exception("查询用户可访问项目失败: user_id=%s", user_id) + return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit) + all_items = [] + for proj in projects: + all_items.extend(asset_repository.find_by_project(proj.id)) - # 模式3:都不传 → 返回用户可访问的所有项目的所有素材 - try: - projects = project_repository.find_accessible_projects(user_id) - except Exception: - logger.exception("查询用户可访问项目失败: user_id=%s", user_id) - return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit) - - all_items = [] - for proj in projects: - all_items.extend(asset_repository.find_by_project(proj.id)) - filtered = _apply_filters(all_items) + # 应用 kind 过滤(如果有)+ keyword/gender/style + if kind: + ft = kind_to_file_type.get(kind) + all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")] + filtered = _apply_memory_filters(all_items) total = len(filtered) paged = filtered[skip : skip + limit] return ListAssetsResponse( @@ -191,6 +268,35 @@ def update_asset_review_status( return _to_asset_response(updated) +@router.post("/batch-delete", response_model=BatchDeleteResponse) +def batch_delete_assets( + request: BatchDeleteRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + asset_repository: Any = Depends(get_asset_repository), + project_repository: Any = Depends(get_project_repository), +) -> BatchDeleteResponse: + """批量删除素材(配音素材等),需逐项校验项目权限。""" + user_id = authenticated_user.user.id + deleted_ids: list[str] = [] + failed_ids: list[str] = [] + + for asset_id in request.ids: + item = asset_repository.find_by_id(asset_id) + if item is None: + failed_ids.append(asset_id) + continue + try: + _check_project_access(item.project_id, user_id, project_repository) + deleted_ids.append(asset_id) + except HTTPException: + failed_ids.append(asset_id) + + if deleted_ids: + asset_repository.batch_delete(deleted_ids) + + return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids) + + @router.get("/{asset_id}", response_model=AssetResponse) def get_asset( asset_id: str, @@ -244,6 +350,48 @@ def delete_asset( asset_repository.delete(asset_id) +@router.post("/{asset_id}/tags", response_model=AssetResponse) +def tag_asset( + asset_id: str, + request: TagAssetsRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + asset_repository: Any = Depends(get_asset_repository), + project_repository: Any = Depends(get_project_repository), + tag_repository: Any = Depends(get_tag_repository), +) -> AssetResponse: + """给素材打标签。""" + item = asset_repository.find_by_id(asset_id) + if item is None: + raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found") + _check_project_access(item.project_id, authenticated_user.user.id, project_repository) + for tag_id in request.tag_ids: + tag = tag_repository.get(tag_id) + if tag is None: + raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found") + if tag.user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail=f"无权使用标签 {tag_id}") + item.add_tag(tag_id) + updated = asset_repository.update(item) + return _to_asset_response(updated) + + +@router.delete("/{asset_id}/tags/{tag_id}", status_code=204) +def untag_asset( + asset_id: str, + tag_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + asset_repository: Any = Depends(get_asset_repository), + project_repository: Any = Depends(get_project_repository), +) -> None: + """取消素材的标签。""" + item = asset_repository.find_by_id(asset_id) + if item is None: + raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found") + _check_project_access(item.project_id, authenticated_user.user.id, project_repository) + item.remove_tag(tag_id) + asset_repository.update(item) + + @router.post("", response_model=AssetResponse) def create_asset( request: CreateAssetRequest, diff --git a/apps/api/app/api/routes/tags.py b/apps/api/app/api/routes/tags.py new file mode 100644 index 000000000..7de3e278b --- /dev/null +++ b/apps/api/app/api/routes/tags.py @@ -0,0 +1,67 @@ +"""标签 CRUD 路由。""" + +import logging +from typing import Any + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_tag_repository +from app.schemas.tag import ( + CreateTagRequest, + ListTagsResponse, + TagResponse, +) +from fastapi import APIRouter, Depends, HTTPException + +from packages.domain import Tag + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("", response_model=ListTagsResponse) +def list_tags( + skip: int = 0, + limit: int = 100, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + tag_repository: Any = Depends(get_tag_repository), +) -> ListTagsResponse: + """列出当前用户的标签。""" + user_id = authenticated_user.user.id + items = tag_repository.list_by_user(user_id, skip=skip, limit=limit) + total = tag_repository.count_by_user(user_id) + return ListTagsResponse( + items=[TagResponse(id=t.id, name=t.name, created_at=t.created_at) for t in items], + total=total, + ) + + +@router.post("", response_model=TagResponse, status_code=201) +def create_tag( + request: CreateTagRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + tag_repository: Any = Depends(get_tag_repository), +) -> TagResponse: + """创建标签(同用户同名去重,返回 409)。""" + user_id = authenticated_user.user.id + existing = tag_repository.find_by_name(user_id, request.name) + if existing: + raise HTTPException(status_code=409, detail="标签名称已存在") + tag = Tag.create(user_id=user_id, name=request.name) + created = tag_repository.create(tag) + return TagResponse(id=created.id, name=created.name, created_at=created.created_at) + + +@router.delete("/{tag_id}", status_code=204) +def delete_tag( + tag_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + tag_repository: Any = Depends(get_tag_repository), +) -> None: + """删除标签(同时清理素材关联)。""" + tag = tag_repository.get(tag_id) + if tag is None: + raise HTTPException(status_code=404, detail="标签不存在") + if tag.user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail="无权删除该标签") + tag_repository.delete(tag_id) diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 38ba5ccd1..7bb55d80b 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -9,22 +9,28 @@ from app.auth import AuthenticatedUser, get_current_user from app.dependencies import ( get_cosyvoice_service, get_db_session, + get_user_repository, get_voice_clone_profile_repository, + get_voice_library_repository, ) from app.schemas.tts import ( ListTTSJobResponse, + SaveToLibraryRequest, + SaveToLibraryResponse, TTSJobResponse, TTSStatusResponse, TTSSynthesizeRequest, TTSSynthesizeResponse, ) -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.tts_job_repository import ( SQLAlchemyTTSJobRepository, ) +from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository from packages.application.cosyvoice_service import CosyVoiceService +from packages.application.tts_job.streaming_service import TTSStreamingService from packages.application.tts_job.use_cases import ( CreateTTSJobUseCase, DeleteTTSJobUseCase, @@ -34,6 +40,12 @@ from packages.application.tts_job.use_cases import ( TTSJobNotFoundError, ) from packages.application.tts_job.workflow import TTSWorkflowService +from packages.application.voice_library.commands import CreateVoiceLibraryCommand +from packages.application.voice_library.use_cases import ( + CreateVoiceLibraryUseCase, + QuotaExceededError, +) +from packages.ports.user_repository import UserRepository logger = logging.getLogger(__name__) @@ -130,18 +142,25 @@ def synthesize( # 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询 if job.status.value == "processing": - task_id = (job.metadata or {}).get("cosyvoice_task_id", "") - if task_id: - try: + # 分段合成任务 vs 普通单段任务 + segment_task_ids = (job.metadata or {}).get("segment_task_ids", []) + is_segment = len(segment_task_ids) > 0 + + try: + if is_segment: + from worker_app.tasks import process_tts_segment_synthesis + + process_tts_segment_synthesis.delay(job.id) + else: from worker_app.tasks import process_tts_synthesis process_tts_synthesis.delay(job.id) - except Exception as e: - # Celery 调度失败,标记 job 为 failed - try: - workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}") - except Exception as inner_e: - logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}") + except Exception as e: + # Celery 调度失败,标记 job 为 failed + try: + workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}") + except Exception as inner_e: + logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}") return TTSSynthesizeResponse( job_id=job.id, @@ -225,3 +244,127 @@ def delete_tts_job( if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") return Response(status_code=204) + + +@router.post( + "/jobs/{job_id}/save-to-library", + response_model=SaveToLibraryResponse, + status_code=status.HTTP_201_CREATED, +) +def save_tts_job_to_library( + job_id: str, + request: SaveToLibraryRequest = SaveToLibraryRequest(), + authenticated_user: AuthenticatedUser = Depends(get_current_user), + tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), + voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository), + user_repository: UserRepository = Depends(get_user_repository), +) -> SaveToLibraryResponse: + """将已完成的 TTS 合成结果保存到配音库。 + + 自动携带音色名、时长、语速等元信息。 + """ + user_id = authenticated_user.user.id + + # 获取 TTS job + get_use_case = GetTTSJobUseCase(tts_repository) + try: + job = get_use_case.execute(job_id, user_id) + except TTSJobNotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") + + # 校验已完成 + if not job.is_completed: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="TTS job is not completed yet", + ) + + # 构建配音素材名称 + name = request.name or f"TTS-{job.id[:8]}" + + # 构建元信息 + metadata_ = { + "source": "tts_job", + "tts_job_id": job.id, + "format": job.format, + "sample_rate": job.sample_rate, + } + if job.metadata: + # 保留原始 job 的有用元信息 + for key in ("speed", "language"): + if key in job.metadata: + metadata_[key] = job.metadata[key] + + # 获取用户套餐(用于配额检查) + user = user_repository.find_by_id(user_id) + plan_name = getattr(user, "subscription_plan", "free") if user else "free" + + # 构建命令并执行 + command = CreateVoiceLibraryCommand( + user_id=user_id, + name=name, + text=job.input_text, + voice_provider="cosyvoice", + voice_id=job.voice_id, + voice_name=job.voice_model or "", + audio_url=job.output_audio_url, + duration=job.duration, + file_size=job.file_size, + status="completed", + project_id=job.project_id or "", + tags=[], + metadata_=metadata_, + ) + + use_case = CreateVoiceLibraryUseCase(voice_library_repository) + try: + item = use_case.execute(command, plan_name=plan_name or "free") + except QuotaExceededError as exc: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", + ) + + return SaveToLibraryResponse( + id=item.id, + name=item.name, + audio_url=item.audio_url, + duration=item.duration, + voice_id=item.voice_id, + voice_name=item.voice_name, + status=item.status, + ) + + +@router.websocket("/ws/tts/stream") +async def tts_websocket_stream( + websocket: WebSocket, + cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service), +) -> None: + """WebSocket 流式 TTS 合成。 + + 协议: + 1. 客户端发送 JSON 文本帧: {"text": "...", "voice_id": "...", ...} + 2. 服务端发送 JSON 状态帧 + 二进制音频帧 + 3. 完成时发送 JSON 结束帧 + """ + await websocket.accept() + try: + message = await websocket.receive_json() + params = { + "text": message.get("text", ""), + "voice_id": message.get("voice_id", ""), + "sample_rate": message.get("sample_rate", 0), + "format": message.get("format", "mp3"), + "speed": message.get("speed", 1.0), + } + streaming_service = TTSStreamingService(cosyvoice_service) + await streaming_service.synthesize_and_stream(websocket, params) + except WebSocketDisconnect: + logger.info("WebSocket 客户端断开连接") + except Exception as e: + logger.error(f"WebSocket 流式合成异常: {e}", exc_info=True) + try: + await websocket.send_json({"type": "error", "message": f"服务异常: {e}"}) + except Exception: + pass diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py index 5d38872f7..9c42a59b3 100755 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -39,6 +39,7 @@ from packages.adapters.sqlalchemy_impl.project_repository import ( SQLAlchemyProjectRepository, ) from packages.adapters.sqlalchemy_impl.session import build_session_factory +from packages.adapters.sqlalchemy_impl.tag_repository import SQLAlchemyTagRepository from packages.adapters.sqlalchemy_impl.title_library_repository import ( SQLAlchemyTitleLibraryRepository, ) @@ -58,6 +59,7 @@ from packages.ports.generation_task_repository import GenerationTaskRepository from packages.ports.ingest_job_repository import IngestJobRepository from packages.ports.job_repository import JobRepository from packages.ports.project_repository import ProjectRepository +from packages.ports.tag_repository import TagRepository from packages.ports.title_library_repository import TitleLibraryRepository from packages.ports.user_repository import UserRepository from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository @@ -138,6 +140,13 @@ def get_project_repository( return SQLAlchemyProjectRepository(session) +def get_tag_repository( + session: Session = Depends(get_db_session), +) -> TagRepository: + """Provide the SQLAlchemy tag repository implementation.""" + return SQLAlchemyTagRepository(session) + + def get_user_repository( session: Session = Depends(get_db_session), ) -> UserRepository: diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py index 8b783b539..1c686772f 100644 --- a/apps/api/app/schemas/asset.py +++ b/apps/api/app/schemas/asset.py @@ -51,6 +51,20 @@ class AssetResponse(BaseModel): classification_status: str quality_score: float | None = None uploaded_by_user_id: str + tag_ids: list[str] = Field(default_factory=list) + + +class BatchDeleteRequest(BaseModel): + """批量删除请求。""" + + ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表") + + +class BatchDeleteResponse(BaseModel): + """批量删除响应。""" + + deleted_count: int = Field(..., ge=0, description="实际删除数量") + failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表") class ListAssetsResponse(BaseModel): diff --git a/apps/api/app/schemas/tag.py b/apps/api/app/schemas/tag.py new file mode 100644 index 000000000..70c8e2eb5 --- /dev/null +++ b/apps/api/app/schemas/tag.py @@ -0,0 +1,24 @@ +"""标签相关 Schema。""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class CreateTagRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + + +class TagResponse(BaseModel): + id: str + name: str + created_at: datetime + + +class ListTagsResponse(BaseModel): + items: list[TagResponse] + total: int = Field(default=0, ge=0) + + +class TagAssetsRequest(BaseModel): + tag_ids: list[str] = Field(..., min_length=1, max_length=50) diff --git a/apps/api/app/schemas/tts.py b/apps/api/app/schemas/tts.py index bfad312e1..d2d098be7 100644 --- a/apps/api/app/schemas/tts.py +++ b/apps/api/app/schemas/tts.py @@ -83,3 +83,21 @@ class ListTTSJobResponse(BaseModel): total: int page: int page_size: int + + +class SaveToLibraryRequest(BaseModel): + """保存到配音库请求。""" + + name: Optional[str] = Field(None, description="配音素材名称,留空则自动生成") + + +class SaveToLibraryResponse(BaseModel): + """保存到配音库响应。""" + + id: str + name: str + audio_url: str + duration: float + voice_id: str + voice_name: str + status: str diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index 964f82bd7..856652f85 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -19,6 +19,7 @@ export interface AssetItem { status?: string; classification_status?: string | null; quality_score?: number | null; + tag_ids?: string[]; created_at?: string; } @@ -144,12 +145,18 @@ export const getAssets = async (libraryId: string): Promise => { /** 按类型获取素材(如 voice/video/image),支持可选筛选 */ export const getAssetsByKind = async ( kind: string, - filters?: { keyword?: string; gender?: string; style?: string }, + filters?: { + keyword?: string; + gender?: string; + style?: string; + tag_ids?: string[]; + }, ): Promise => { const params: Record = { kind }; if (filters?.keyword) params.keyword = filters.keyword; if (filters?.gender) params.gender = filters.gender; if (filters?.style) params.style = filters.style; + if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(","); const response = await apiClient.get("/assets", { params }); return response.data.items || []; }; @@ -233,10 +240,11 @@ export const completeDirectUpload = async (data: { return response.data; }; -/** 直传上传(大文件推荐) */ +/** 直传上传(大文件推荐),支持可选进度回调 */ export const uploadAssetDirect = async (data: { file: File; library_id: string; + onProgress?: (percent: number) => void; }): Promise<{ storage_key: string; ingest_job_id: string }> => { // 后端要求 project_id,前端自动获取默认项目 const project = await getOrCreateDefaultProject(); @@ -255,13 +263,25 @@ export const uploadAssetDirect = async (data: { ); directForm.append("file", data.file); - const uploadResponse = await fetch(prepared.upload_url, { - method: prepared.method, - body: directForm, + // 使用 XMLHttpRequest 以获取上传进度(fetch 不支持) + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(prepared.method, prepared.upload_url); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && data.onProgress) { + data.onProgress(Math.round((e.loaded / e.total) * 100)); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`OSS direct upload failed: ${xhr.status}`)); + } + }; + xhr.onerror = () => reject(new Error("OSS direct upload failed")); + xhr.send(directForm); }); - if (!uploadResponse.ok) { - throw new Error(`OSS direct upload failed: ${uploadResponse.status}`); - } return completeDirectUpload({ project_id: project.id, diff --git a/apps/web/src/api/tags.ts b/apps/web/src/api/tags.ts new file mode 100644 index 000000000..7a825ef12 --- /dev/null +++ b/apps/web/src/api/tags.ts @@ -0,0 +1,46 @@ +/** + * 标签 CRUD API + * P3 标签体系:对接后端标签表 + */ +import apiClient from "./client"; + +export interface TagItem { + id: string; + name: string; + created_at?: string; + usage_count?: number; +} + +/** 获取当前用户所有标签 */ +export const getTags = async (): Promise => { + const response = await apiClient.get("/tags"); + return response.data.items || []; +}; + +/** 创建标签(同名返回 409) */ +export const createTag = async (name: string): Promise => { + const response = await apiClient.post("/tags", { name }); + return response.data; +}; + +/** 删除标签(同时清理素材关联) */ +export const deleteTag = async (tagId: string): Promise => { + await apiClient.delete(`/tags/${tagId}`); +}; + +/** 为素材添加标签(最多 50 个) */ +export const tagAsset = async ( + assetId: string, + tagIds: string[], +): Promise => { + if (tagIds.length === 0) return; + await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds }); +}; + +/** 移除素材的某个标签 */ +export const untagAsset = async ( + assetId: string, + tagId: string, +): Promise => { + await apiClient.delete(`/assets/${assetId}/tags/${tagId}`); +}; diff --git a/apps/web/src/api/tts.ts b/apps/web/src/api/tts.ts index d060e115e..05b028def 100644 --- a/apps/web/src/api/tts.ts +++ b/apps/web/src/api/tts.ts @@ -122,6 +122,20 @@ export const getTTSJobs = async ( return response.data; }; +/** 存为素材请求参数 */ +export interface SaveTtsToLibraryRequest { + name?: string; + tag_ids?: string[]; +} + +/** 将 TTS 合成结果保存到配音素材库 */ +export const saveTtsToLibrary = async ( + jobId: string, + data?: SaveTtsToLibraryRequest, +): Promise => { + await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {}); +}; + /** 删除 TTS 任务 */ export const deleteTTSJob = async (jobId: string): Promise => { await apiClient.delete(`/tts/jobs/${jobId}`); diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index 2df388f17..0dbb3ee97 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -1968,7 +1968,9 @@ border: 1px dashed var(--primary, #6366f1); border-radius: 6px; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } .ep-voice-upload-btn:hover { @@ -1996,7 +1998,9 @@ border: none; border-radius: 4px; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } .ep-voice-refresh-btn:hover { @@ -2035,7 +2039,9 @@ border: 1px solid var(--border-color, #334155); border-radius: 6px; cursor: pointer; - transition: background 0.15s, border-color 0.15s; + transition: + background 0.15s, + border-color 0.15s; } .ep-voice-preview-btn:hover { diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 90c06365a..6e9dc0342 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -15,6 +15,9 @@ import { PauseCircleOutlined, DownloadOutlined, ShareAltOutlined, + SaveOutlined, + PlusOutlined, + CloseOutlined, } from "@ant-design/icons"; import type { AssetItem } from "@/api/assets"; import { getAssets, getAssetLibraries } from "@/api/assets"; @@ -28,9 +31,10 @@ import type { PresetVoiceItem } from "@/api/voices"; import { formatDuration } from "@/api/voiceClone"; import type { VoiceClone } from "@/api/voiceClone"; import CloneModal from "@/components/voice/CloneModal"; -import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts"; +import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"; +import { getTags, createTag } from "@/api/tags"; import { useCloneProgress } from "@/hooks/useCloneProgress"; -import { useSearchParams } from "react-router-dom"; +import { useSearchParams, useNavigate } from "react-router-dom"; import { getEditPlan } from "@/api/editPlans"; import "./generate.css"; @@ -97,6 +101,8 @@ const STEPS = [ ================================================================ */ const GeneratePage: React.FC = () => { + const navigate = useNavigate(); + /* ── 步骤状态 ── */ const [currentStep, setCurrentStep] = useState(1); @@ -225,6 +231,23 @@ const GeneratePage: React.FC = () => { const [customAudioUrl, setCustomAudioUrl] = useState(null); const [ttsError, setTtsError] = useState(null); const [ttsJobId, setTtsJobId] = useState(null); + /** 合成完成后保留的 job ID,用于"存为素材" */ + const [completedTtsJobId, setCompletedTtsJobId] = useState( + null, + ); + + /* ── 存为素材弹窗状态 ── */ + const [saveModalOpen, setSaveModalOpen] = useState(false); + const [saveName, setSaveName] = useState(""); + const [saveTagIds, setSaveTagIds] = useState([]); + const [saveNewTag, setSaveNewTag] = useState(""); + + /* ── 标签列表(用于存为素材弹窗) ── */ + const { data: allTags = [] } = useQuery({ + queryKey: ["generate-save-tags"], + queryFn: getTags, + staleTime: 30_000, + }); /* ── 素材数据 API ── */ const { data: libraries = [] } = useQuery({ @@ -310,6 +333,7 @@ const GeneratePage: React.FC = () => { if (cancelled) return; if (status.status === "completed") { setCustomAudioUrl(status.output_audio_url); + setCompletedTtsJobId(ttsJobId); setTtsJobId(null); setTtsError(null); message.success("语音合成完成!"); @@ -351,6 +375,86 @@ const GeneratePage: React.FC = () => { }); }, [customVoiceText, selectedVoice, synthesizeMutation]); + /* ── 存为素材 mutation ── */ + const saveToLibraryMutation = useMutation({ + mutationFn: (params: { name?: string; tag_ids?: string[] }) => + saveTtsToLibrary(completedTtsJobId!, params), + onSuccess: () => { + message.success({ + content: ( + + 已保存到配音素材库!{" "} + + 去素材库查看 + + + ), + duration: 5, + }); + setSaveModalOpen(false); + setSaveName(""); + setSaveTagIds([]); + setSaveNewTag(""); + setCompletedTtsJobId(null); + setCustomAudioUrl(null); + }, + onError: (err: Error) => { + message.error(`保存失败:${err.message || "请重试"}`); + }, + }); + + /** 打开存为素材弹窗 */ + const handleOpenSaveModal = useCallback(() => { + setSaveName(""); + setSaveTagIds([]); + setSaveNewTag(""); + setSaveModalOpen(true); + }, []); + + /** 确认保存 */ + const handleConfirmSave = useCallback(() => { + if (!completedTtsJobId) return; + saveToLibraryMutation.mutate({ + name: saveName.trim() || undefined, + tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined, + }); + }, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]); + + /** 在弹窗中新增标签(先创建再选中) */ + const handleAddTagInModal = useCallback( + async (tagName: string) => { + const trimmed = tagName.trim(); + if (!trimmed) return; + /* 已在选中列表则跳过 */ + const existing = allTags.find((t) => t.name === trimmed); + if (existing) { + if (!saveTagIds.includes(existing.id)) { + setSaveTagIds((prev) => [...prev, existing.id]); + } + return; + } + try { + const created = await createTag(trimmed); + setSaveTagIds((prev) => [...prev, created.id]); + setSaveNewTag(""); + } catch { + message.error(`创建标签"${trimmed}"失败`); + } + }, + [allTags, saveTagIds], + ); + + /** 保存成功后跳转到素材库 */ + const handleGoToLibrary = useCallback(() => { + navigate("/app/voice-materials"); + }, [navigate]); + const handleGenerate = useCallback(async () => { if (!title.trim()) { message.warning("请先选择或输入标题"); @@ -910,16 +1014,136 @@ const GeneratePage: React.FC = () => { {ttsError} )} - {customAudioUrl && ( - - ✓ 语音合成完成 - + + ✓ 语音合成完成 + + + + )} + + {/* ── 存为素材弹窗 ── */} + {saveModalOpen && ( +
setSaveModalOpen(false)} + > +
e.stopPropagation()} + > +
+ 保存到配音素材库 + +
+
+ + setSaveName(e.target.value)} + maxLength={50} + /> + +
+ {saveTagIds.map((id) => { + const tag = allTags.find((t) => t.id === id); + return tag ? ( + + {tag.name} + + setSaveTagIds((prev) => + prev.filter((x) => x !== id), + ) + } + /> + + ) : null; + })} + setSaveNewTag(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddTagInModal(saveNewTag); + } + }} + /> +
+ {allTags.length > 0 && ( +
+ {allTags + .filter((t) => !saveTagIds.includes(t.id)) + .slice(0, 12) + .map((t) => ( + + ))} +
+ )} +
+
+ + +
+
+
)} )} diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 76331e052..82052f16b 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -883,3 +883,194 @@ flex-direction: column; } } + +/* ── 存为素材弹窗 ── */ +.xx-save-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: xxFadeIn 0.15s ease; +} + +.xx-save-modal { + background: var(--bg-card, #fff); + border-radius: var(--radius-lg, 16px); + width: 420px; + max-width: 90vw; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); + animation: xxSlideUp 0.2s ease; +} + +.xx-save-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-light, #f1f5f9); + font-weight: 600; + font-size: 15px; + color: var(--text-primary, #0f172a); +} + +.xx-save-modal-close { + background: none; + border: none; + cursor: pointer; + color: var(--text-tertiary, #94a3b8); + font-size: 14px; + padding: 4px; + border-radius: var(--radius-sm, 6px); + transition: all 0.15s; +} + +.xx-save-modal-close:hover { + background: var(--bg-hover, #f8fafc); + color: var(--text-primary, #0f172a); +} + +.xx-save-modal-body { + padding: 20px; +} + +.xx-save-modal-label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary, #475569); + margin-bottom: 6px; + margin-top: 14px; +} + +.xx-save-modal-label:first-child { + margin-top: 0; +} + +.xx-save-modal-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color, #e2e8f0); + border-radius: var(--radius-sm, 10px); + font-size: 14px; + outline: none; + background: var(--bg-surface, #fff); + color: var(--text-primary, #0f172a); + transition: border-color 0.15s; +} + +.xx-save-modal-input:focus { + border-color: var(--primary-500, #6366f1); + box-shadow: 0 0 0 2px var(--primary-100, #e0e7ff); +} + +.xx-save-modal-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px; + border: 1px solid var(--border-color, #e2e8f0); + border-radius: var(--radius-sm, 10px); + min-height: 40px; + align-items: center; + cursor: text; + transition: border-color 0.15s; +} + +.xx-save-modal-tags:focus-within { + border-color: var(--primary-500, #6366f1); + box-shadow: 0 0 0 2px var(--primary-100, #e0e7ff); +} + +.xx-save-modal-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + font-size: 12px; + border-radius: 12px; + background: var(--primary-50, #eef2ff); + color: var(--primary-600, #4f46e5); + border: 1px solid var(--primary-200, #c7d2fe); +} + +.xx-save-modal-tag-remove { + font-size: 10px; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s; +} + +.xx-save-modal-tag-remove:hover { + opacity: 1; +} + +.xx-save-modal-tag-input { + border: none; + outline: none; + flex: 1; + min-width: 100px; + font-size: 13px; + background: transparent; + color: var(--text-primary, #0f172a); +} + +.xx-save-modal-tag-input::placeholder { + color: var(--text-tertiary, #94a3b8); +} + +.xx-save-modal-tag-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-light, #f1f5f9); +} + +.xx-save-modal-tag-preset { + padding: 3px 10px; + font-size: 12px; + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 12px; + background: var(--bg-surface, #fff); + color: var(--text-secondary, #475569); + cursor: pointer; + transition: all 0.15s; +} + +.xx-save-modal-tag-preset:hover { + border-color: var(--primary-300, #a5b4fc); + color: var(--primary-600, #4f46e5); + background: var(--primary-50, #eef2ff); +} + +.xx-save-modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 20px; + border-top: 1px solid var(--border-light, #f1f5f9); +} + +@keyframes xxFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes xxSlideUp { + from { + transform: translateY(12px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx index e232889e5..92ab18f08 100644 --- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -32,8 +32,12 @@ import { UserOutlined, ManOutlined, WomanOutlined, + CheckOutlined, + TagsOutlined, + MutedOutlined, } from "@ant-design/icons"; import { Button, Input, Select, Modal, Tag } from "@/components/ui"; +import { message, Popover, Popconfirm, Tooltip } from "antd"; import PageHead from "@/components/layout/PageHead"; import { getAssetsByKind, @@ -45,6 +49,13 @@ import { createAssetLibrary, type AssetItem, } from "@/api/assets"; +import { + type TagItem, + getTags, + createTag, + tagAsset, + untagAsset, +} from "@/api/tags"; import "./voice-materials.css"; /* ============================================================ @@ -54,19 +65,16 @@ import "./voice-materials.css"; type VoiceGender = "male" | "female" | "child" | "neutral"; type ViewMode = "card" | "list"; -/** 风格标签预设 */ -const STYLE_TAGS = [ - "活力", - "沉稳", - "温柔", - "专业", - "甜美", - "磁性", - "清新", - "激昂", - "幽默", - "庄重", -]; +/** 标签溢出限制 */ +const MAX_CARD_TAGS = 3; +const MAX_ROW_TAGS = 2; +const TAG_VARIANTS = [ + "info", + "primary", + "success", + "warning", + "error", +] as const; /** 性别选项 */ const GENDER_OPTIONS: { @@ -86,7 +94,7 @@ interface VoiceMaterial { name: string; description: string; gender: VoiceGender; - tags: string[]; + tagIds: string[]; fileName: string; fileSize: number; duration: number; @@ -102,18 +110,13 @@ interface VoiceMaterial { /** 后端 AssetItem → 前端 VoiceMaterial */ const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => { const meta = asset.metadata || {}; - const tags = Array.isArray(meta.style) - ? (meta.style as string[]) - : typeof meta.style === "string" && meta.style - ? (meta.style as string).split(",").map((s) => s.trim()) - : []; return { id: asset.id, name: asset.name, description: (meta.description as string) || "", gender: (meta.gender as VoiceGender) || "neutral", - tags, + tagIds: Array.isArray(asset.tag_ids) ? asset.tag_ids : [], fileName: asset.storage_key?.split("/").pop() || asset.name, fileSize: asset.file_size || 0, duration: (meta.duration as number) || 0, @@ -123,16 +126,14 @@ const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => { }; }; -/** 前端表单数据 → 后端 metadata */ +/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style) */ const buildMetadata = (data: { gender: VoiceGender; description: string; - tags: string[]; duration?: number; }): Record => ({ gender: data.gender, description: data.description, - style: data.tags, duration: data.duration || 0, }); @@ -184,6 +185,180 @@ const getAudioDuration = (file: File): Promise => { }); }; +/* ============================================================ + * 标签选择器组件(支持自定义新增 + 移除) + * ============================================================ */ + +interface TagSelectorProps { + /** 已选标签 ID 列表 */ + value: string[]; + onChange: (tagIds: string[]) => void; + /** 所有可用标签(来自 API) */ + tags: TagItem[]; + /** 标签 ID → TagItem 映射 */ + tagMap: Map; + /** 创建新标签,返回带 ID 的 TagItem */ + onCreateTag: (name: string) => Promise; + placeholder?: string; +} + +const TagSelector: React.FC = ({ + value, + onChange, + tags, + tagMap, + onCreateTag, + placeholder = "输入标签后回车添加", +}) => { + const [inputVal, setInputVal] = useState(""); + const [showSuggestions, setShowSuggestions] = useState(false); + const inputRef = useRef(null); + + /** 按名称查找已有标签(大小写不敏感) */ + const findTagByName = useCallback( + (name: string) => + tags.find((t) => t.name.toLowerCase() === name.toLowerCase()), + [tags], + ); + + /** 去重添加标签(按 ID) */ + const addTagId = useCallback( + (tagId: string) => { + if (value.includes(tagId)) return; + onChange([...value, tagId]); + setInputVal(""); + setShowSuggestions(false); + }, + [value, onChange], + ); + + /** 输入自定义标签名:若已存在则直接选,否则创建新标签 */ + const addTagByName = useCallback( + async (name: string) => { + const trimmed = name.trim(); + if (!trimmed) return; + const existing = findTagByName(trimmed); + if (existing) { + addTagId(existing.id); + } else { + try { + const created = await onCreateTag(trimmed); + addTagId(created.id); + } catch { + /* 创建失败静默忽略 */ + } + } + }, + [findTagByName, addTagId, onCreateTag], + ); + + const removeTagId = useCallback( + (tagId: string) => { + onChange(value.filter((t) => t !== tagId)); + }, + [value, onChange], + ); + + /** 输入补全建议(排除已选) */ + const suggestions = useMemo(() => { + if (!inputVal.trim()) return []; + const lower = inputVal.toLowerCase(); + return tags.filter( + (t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id), + ); + }, [inputVal, tags, value]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (suggestions.length > 0) { + addTagId(suggestions[0].id); + } else { + addTagByName(inputVal); + } + } else if (e.key === "Backspace" && !inputVal && value.length > 0) { + removeTagId(value[value.length - 1]); + } + }; + + return ( +
+
inputRef.current?.focus()} + > + {value.map((tagId) => ( + removeTagId(tagId)} + > + {tagMap.get(tagId)?.name ?? tagId} + + ))} + { + setInputVal(e.target.value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} + onKeyDown={handleKeyDown} + placeholder={value.length === 0 ? placeholder : ""} + /> +
+ + {/* 自动补全下拉 */} + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.slice(0, 6).map((tag) => ( + + ))} +
+ )} + + {/* 已有标签快捷选择 */} + {tags.length > 0 && ( +
+ {tags.map((tag) => { + const isSelected = value.includes(tag.id); + return ( + + ); + })} +
+ )} +
+ ); +}; + /* ============================================================ * 上传 / 编辑 表单 * ============================================================ */ @@ -195,6 +370,10 @@ interface MaterialFormProps { ) => void; onCancel: () => void; loading?: boolean; + uploadProgress?: number | null; + tags?: TagItem[]; + tagMap?: Map; + onCreateTag?: (name: string) => Promise; } const MaterialForm: React.FC = ({ @@ -202,24 +381,22 @@ const MaterialForm: React.FC = ({ onSubmit, onCancel, loading, + uploadProgress, + tags = [], + tagMap = new Map(), + onCreateTag, }) => { const [name, setName] = useState(initial?.name ?? ""); const [description, setDescription] = useState(initial?.description ?? ""); const [gender, setGender] = useState( initial?.gender ?? "female", ); - const [selectedTags, setSelectedTags] = useState( - initial?.tags ?? [], + const [selectedTagIds, setSelectedTagIds] = useState( + initial?.tagIds ?? [], ); const [file, setFile] = useState(undefined); const fileInputRef = useRef(null); - const handleTagToggle = (tag: string) => { - setSelectedTags((prev) => - prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], - ); - }; - const handleSubmit = () => { if (!name.trim()) return; if (!initial && !file) return; @@ -227,7 +404,7 @@ const MaterialForm: React.FC = ({ name: name.trim(), description: description.trim(), gender, - tags: selectedTags, + tagIds: selectedTagIds, fileName: file?.name ?? initial?.fileName ?? "", fileSize: file?.size ?? initial?.fileSize ?? 0, duration: initial?.duration ?? 0, @@ -288,6 +465,18 @@ const MaterialForm: React.FC = ({ )} + {/* 上传进度条 */} + {uploadProgress !== null && uploadProgress !== undefined && ( +
+
+ + {uploadProgress}% + +
+ )}
)} @@ -335,18 +524,13 @@ const MaterialForm: React.FC = ({ {/* 风格标签 */}
-
- {STYLE_TAGS.map((tag) => ( - - ))} -
+ ({ id: "", name: "" }))} + />
{/* 操作按钮 */} @@ -376,39 +560,88 @@ interface VoiceCardProps { material: VoiceMaterial; isPlaying: boolean; currentTime: number; + isSelected: boolean; + batchMode: boolean; + volume: number; + tagMap: Map; onPlay: () => void; onPause: () => void; onSeek: (time: number) => void; onEdit: () => void; onDelete: () => void; + onToggleSelect: (id: string) => void; + onVolumeChange: (e: React.ChangeEvent) => void; + onToggleMute: () => void; } const VoiceMaterialCard: React.FC = ({ material, isPlaying, currentTime, + isSelected, + batchMode, + volume, + tagMap, onPlay, onPause, onSeek, onEdit, onDelete, + onToggleSelect, + onVolumeChange, + onToggleMute, }) => { const progressRef = useRef(null); - const handleProgressClick = (e: React.MouseEvent) => { + const handleProgressMouseDown = (e: React.MouseEvent) => { if (!progressRef.current) return; - const rect = progressRef.current.getBoundingClientRect(); - const percent = (e.clientX - rect.left) / rect.width; - onSeek(Math.max(0, Math.min(1, percent)) * material.duration); + e.preventDefault(); + const doSeek = (ev: MouseEvent) => { + if (!progressRef.current) return; + const rect = progressRef.current.getBoundingClientRect(); + const percent = Math.max( + 0, + Math.min(1, (ev.clientX - rect.left) / rect.width), + ); + onSeek(percent * material.duration); + }; + doSeek(e.nativeEvent); + const handleMove = (ev: MouseEvent) => doSeek(ev); + const handleUp = () => { + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleUp); + }; + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleUp); }; const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0; + const handleCardClick = () => { + if (batchMode) { + onToggleSelect(material.id); + } + }; + return (
+ {/* 批量选择 checkbox */} + {(batchMode || isSelected) && ( +
{ + e.stopPropagation(); + onToggleSelect(material.id); + }} + > + {isSelected && } +
+ )} + {/* 操作按钮 */}
+ { + e.stopPropagation(); + onVolumeChange(e); + }} + onClick={(e) => e.stopPropagation()} + /> +
); @@ -513,30 +805,53 @@ interface VoiceRowProps { material: VoiceMaterial; isPlaying: boolean; currentTime: number; + isSelected: boolean; + batchMode: boolean; + tagMap: Map; onPlay: () => void; onPause: () => void; onSeek: (time: number) => void; onEdit: () => void; onDelete: () => void; + onToggleSelect: (id: string) => void; } const VoiceMaterialRow: React.FC = ({ material, isPlaying, currentTime, + isSelected, + batchMode, + tagMap, onPlay, onPause, onSeek, onEdit, onDelete, + onToggleSelect, }) => { const progressRef = useRef(null); - const handleProgressClick = (e: React.MouseEvent) => { + const handleProgressMouseDown = (e: React.MouseEvent) => { if (!progressRef.current) return; - const rect = progressRef.current.getBoundingClientRect(); - const percent = (e.clientX - rect.left) / rect.width; - onSeek(Math.max(0, Math.min(1, percent)) * material.duration); + e.preventDefault(); + const doSeek = (ev: MouseEvent) => { + if (!progressRef.current) return; + const rect = progressRef.current.getBoundingClientRect(); + const percent = Math.max( + 0, + Math.min(1, (ev.clientX - rect.left) / rect.width), + ); + onSeek(percent * material.duration); + }; + doSeek(e.nativeEvent); + const handleMove = (ev: MouseEvent) => doSeek(ev); + const handleUp = () => { + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleUp); + }; + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleUp); }; const progress = @@ -544,8 +859,21 @@ const VoiceMaterialRow: React.FC = ({ return (
+ {/* 批量选择 checkbox */} + {(batchMode || isSelected) && ( +
{ + e.stopPropagation(); + onToggleSelect(material.id); + }} + > + {isSelected && } +
+ )} + {/* 播放按钮 */} + ))} +
+ } + title="批量打标签" + trigger="click" + > + + + + + + + + )} + {/* 内容区 */} {!isLoading && filtered.length > 0 && viewMode === "card" && (
@@ -1053,22 +1700,30 @@ const VoiceMaterialLibrary: React.FC = () => { handlePlay(m)} - onPause={handlePause} + onPause={() => handlePause(m)} onSeek={(t) => handleSeek(m, t)} onEdit={() => setEditingMaterial(m)} onDelete={() => handleDelete(m.id)} + onToggleSelect={handleToggleSelect} + onVolumeChange={handleVolumeChange} + onToggleMute={toggleMute} /> ))}
)} {!isLoading && filtered.length > 0 && viewMode === "list" && ( -
+
{/* 列表头 */}
+ {batchMode && } 名称 性别 @@ -1082,13 +1737,17 @@ const VoiceMaterialLibrary: React.FC = () => { handlePlay(m)} - onPause={handlePause} + onPause={() => handlePause(m)} onSeek={(t) => handleSeek(m, t)} onEdit={() => setEditingMaterial(m)} onDelete={() => handleDelete(m.id)} + onToggleSelect={handleToggleSelect} /> ))}
@@ -1101,11 +1760,11 @@ const VoiceMaterialLibrary: React.FC = () => {

暂无配音素材

- {searchText || filterGender !== "all" || filterTag !== "all" + {searchText || filterGender !== "all" || filterTagId !== "all" ? "未找到匹配的素材,试试调整筛选条件" : "上传音频文件,开始管理配音素材"}

- {!searchText && filterGender === "all" && filterTag === "all" && ( + {!searchText && filterGender === "all" && filterTagId === "all" && (