From f8bc252ded82068ae11dbcd625ec5a92e11c08ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Tue, 7 Jul 2026 11:06:15 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat(assets):=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=8E=A5=E5=8F=A3=20+=20=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /assets/batch-delete 批量删除素材接口 - 仓储层: abstract/SQLAlchemy/InMemory 均实现 batch_delete() - API层: 逐项校验项目权限后批量删除 - Schema: BatchDeleteRequest/BatchDeleteResponse - GET /assets 分页优化: 无 keyword/gender/style 过滤时走 DB 级 skip/limit - 素材上传 duration 字段已全链路支持(domain/schema/ORM/repository) - 新增 4 个批量删除单元测试,全量 1030 测试通过 Co-Authored-By: Claude Fable 5 --- apps/api/app/api/routes/assets.py | 168 ++++++++++++++---- apps/api/app/schemas/asset.py | 13 ++ .../adapters/in_memory/asset_repository.py | 9 + .../sqlalchemy_impl/asset_repository.py | 8 + packages/ports/asset_repository.py | 5 + tests/unit/test_asset_batch_delete.py | 77 ++++++++ 6 files changed, 242 insertions(+), 38 deletions(-) create mode 100644 tests/unit/test_asset_batch_delete.py diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index b7a4c2aef..3725b3fda 100644 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -10,6 +10,8 @@ from app.dependencies import ( ) from app.schemas.asset import ( AssetResponse, + BatchDeleteRequest, + BatchDeleteResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetRequest, @@ -96,12 +98,12 @@ 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 过滤。""" + # 需要内存过滤的标志(keyword/gender/style 无法在 DB 层过滤) + needs_memory_filter = bool(keyword or gender or style) + + def _apply_memory_filters(items): + """应用 keyword / gender / style 内存过滤。""" 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()] @@ -111,7 +113,85 @@ def list_assets( result = [i for i in result if (i.metadata or {}).get("style") == style] 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 +201,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 +254,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, diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py index 8b783b539..ff0cefa53 100644 --- a/apps/api/app/schemas/asset.py +++ b/apps/api/app/schemas/asset.py @@ -53,6 +53,19 @@ class AssetResponse(BaseModel): uploaded_by_user_id: str +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): items: list[AssetResponse] total: int = Field(default=0, ge=0) diff --git a/packages/adapters/in_memory/asset_repository.py b/packages/adapters/in_memory/asset_repository.py index 7b6874a43..e194dc48d 100755 --- a/packages/adapters/in_memory/asset_repository.py +++ b/packages/adapters/in_memory/asset_repository.py @@ -42,3 +42,12 @@ class InMemoryAssetRepository: del self._assets[asset_id] return True return False + + def batch_delete(self, asset_ids: list[str]) -> int: + """批量删除素材,返回实际删除数量。""" + count = 0 + for aid in asset_ids: + if aid in self._assets: + del self._assets[aid] + count += 1 + return count diff --git a/packages/adapters/sqlalchemy_impl/asset_repository.py b/packages/adapters/sqlalchemy_impl/asset_repository.py index d14fa3e9f..7fc53ff7e 100644 --- a/packages/adapters/sqlalchemy_impl/asset_repository.py +++ b/packages/adapters/sqlalchemy_impl/asset_repository.py @@ -120,6 +120,14 @@ class SQLAlchemyAssetRepository: return True return False + def batch_delete(self, asset_ids: list[str]) -> int: + """批量删除素材,返回实际删除数量。""" + if not asset_ids: + return 0 + count = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).delete(synchronize_session=False) + self.session.commit() + return count + def count_by_project(self, project_id: str) -> int: return self.session.query(AssetModel).filter(AssetModel.project_id == project_id).count() diff --git a/packages/ports/asset_repository.py b/packages/ports/asset_repository.py index 3bee42356..2432d0eb0 100644 --- a/packages/ports/asset_repository.py +++ b/packages/ports/asset_repository.py @@ -50,6 +50,11 @@ class AssetRepository(ABC): def delete(self, asset_id: str) -> bool: pass + @abstractmethod + def batch_delete(self, asset_ids: list[str]) -> int: + """批量删除素材,返回实际删除数量。""" + pass + @abstractmethod def count_by_project(self, project_id: str) -> int: pass diff --git a/tests/unit/test_asset_batch_delete.py b/tests/unit/test_asset_batch_delete.py new file mode 100644 index 000000000..ef9a2f468 --- /dev/null +++ b/tests/unit/test_asset_batch_delete.py @@ -0,0 +1,77 @@ +"""批量删除素材 + 分页优化 单元测试。""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository +from packages.domain import Asset, AssetStatus + + +class TestBatchDelete: + """batch_delete 仓储方法测试。""" + + def _make_repo_with_assets(self): + repo = InMemoryAssetRepository() + for i in range(5): + asset = Asset.create( + project_id="proj-1", + library_id="lib-1", + name=f"voice_{i}.mp3", + storage_key=f"uploads/voice_{i}.mp3", + mime_type="audio/mpeg", + status=AssetStatus.READY, + ) + repo.create(asset) + return repo + + def test_batch_delete_removes_multiple(self): + repo = InMemoryAssetRepository() + assets = [] + for i in range(5): + asset = Asset.create( + project_id="proj-1", + library_id="lib-1", + name=f"voice_{i}.mp3", + storage_key=f"uploads/voice_{i}.mp3", + mime_type="audio/mpeg", + ) + repo.create(asset) + assets.append(asset) + + ids_to_delete = [assets[0].id, assets[2].id, assets[4].id] + deleted_count = repo.batch_delete(ids_to_delete) + + assert deleted_count == 3 + # 验证确实被删了 + assert repo.get(assets[0].id) is None + assert repo.get(assets[2].id) is None + assert repo.get(assets[4].id) is None + # 验证其他还在 + assert repo.get(assets[1].id) is not None + assert repo.get(assets[3].id) is not None + + def test_batch_delete_empty_list(self): + repo = self._make_repo_with_assets() + assert repo.batch_delete([]) == 0 + + def test_batch_delete_nonexistent_ids(self): + repo = self._make_repo_with_assets() + deleted = repo.batch_delete(["nonexistent-1", "nonexistent-2"]) + assert deleted == 0 + + def test_batch_delete_mixed_existing_and_nonexistent(self): + repo = InMemoryAssetRepository() + asset = Asset.create( + project_id="proj-1", + library_id="lib-1", + name="voice.mp3", + storage_key="uploads/voice.mp3", + mime_type="audio/mpeg", + ) + repo.create(asset) + + deleted = repo.batch_delete([asset.id, "nonexistent"]) + assert deleted == 1 + assert repo.get(asset.id) is None From d698d78b35934014e562e245c96fc38642029a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Tue, 7 Jul 2026 11:32:03 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat(voice-materials):=20=E9=85=8D?= =?UTF-8?q?=E9=9F=B3=E6=A8=A1=E5=9D=97=E4=BD=93=E9=AA=8C=E6=89=93=E7=A3=A8?= =?UTF-8?q?=20=E2=80=94=20=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C/=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E8=BF=9B=E5=BA=A6/=E6=92=AD=E6=94=BE=E5=99=A8?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配音素材库批量操作:多选删除、批量打标签(Popover 选标签) - 上传配音进度条:XMLHttpRequest 替换 fetch,实时百分比进度 - 试听播放器优化:暂停恢复、进度条拖拽 seek、音量控制 - 整体交互走查:错误处理、空状态提示、动画过渡 Co-Authored-By: Claude Fable 5 --- apps/web/src/api/assets.ts | 27 +- .../voice-materials/VoiceMaterialLibrary.tsx | 425 +++++++++++++++--- .../pages/voice-materials/voice-materials.css | 249 ++++++++++ 3 files changed, 640 insertions(+), 61 deletions(-) diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index 964f82bd7..96d80754e 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -233,10 +233,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 +256,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/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx index e232889e5..3743c8410 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 } from "antd"; import PageHead from "@/components/layout/PageHead"; import { getAssetsByKind, @@ -195,6 +199,7 @@ interface MaterialFormProps { ) => void; onCancel: () => void; loading?: boolean; + uploadProgress?: number | null; } const MaterialForm: React.FC = ({ @@ -202,6 +207,7 @@ const MaterialForm: React.FC = ({ onSubmit, onCancel, loading, + uploadProgress, }) => { const [name, setName] = useState(initial?.name ?? ""); const [description, setDescription] = useState(initial?.description ?? ""); @@ -288,6 +294,13 @@ const MaterialForm: React.FC = ({ )} + {/* 上传进度条 */} + {uploadProgress !== null && uploadProgress !== undefined && ( +
+
+ {uploadProgress}% +
+ )}
)} @@ -376,39 +389,83 @@ interface VoiceCardProps { material: VoiceMaterial; isPlaying: boolean; currentTime: number; + isSelected: boolean; + batchMode: boolean; + volume: number; 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, 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 +603,48 @@ interface VoiceRowProps { material: VoiceMaterial; isPlaying: boolean; currentTime: number; + isSelected: boolean; + batchMode: boolean; 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, 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 +652,21 @@ const VoiceMaterialRow: React.FC = ({ return (
+ {/* 批量选择 checkbox */} + {(batchMode || isSelected) && ( +
{ + e.stopPropagation(); + onToggleSelect(material.id); + }} + > + {isSelected && } +
+ )} + {/* 播放按钮 */}
- {/* 进度条 */} + {/* 进度条(可拖拽) */}
+ {isPlaying && ( +
+ )}
{/* 时长 */} @@ -711,46 +838,55 @@ const VoiceMaterialLibrary: React.FC = () => { description: string; tags: string[]; }) => { - // 1. 获取或等待 voice library - let lib = voiceLibrary; - if (!lib) { - if (createLibMutation.isPending) { - await createLibMutation.mutateAsync(); + setUploadProgress(0); + try { + // 1. 获取或等待 voice library + let lib = voiceLibrary; + if (!lib) { + if (createLibMutation.isPending) { + await createLibMutation.mutateAsync(); + } + const libs = await queryClient.fetchQuery({ + queryKey: ["asset-libraries"], + queryFn: getAssetLibraries, + }); + lib = libs.find((l) => l.kind === "voice"); + if (!lib) throw new Error("无法创建配音素材库"); } - const libs = await queryClient.fetchQuery({ - queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, + + // 2. 上传文件(带进度) + const { storage_key } = await uploadAssetDirect({ + file: data.file, + library_id: lib.id, + onProgress: (p) => setUploadProgress(p), }); - lib = libs.find((l) => l.kind === "voice"); - if (!lib) throw new Error("无法创建配音素材库"); + + // 3. 获取音频时长 + const duration = await getAudioDuration(data.file); + + // 4. 创建素材记录 + await createAsset({ + library_id: lib.id, + name: data.name, + storage_key, + mime_type: data.file.type || "audio/mpeg", + metadata: buildMetadata({ + gender: data.gender, + description: data.description, + tags: data.tags, + duration, + }), + }); + } finally { + setUploadProgress(null); } - - // 2. 上传文件 - const { storage_key } = await uploadAssetDirect({ - file: data.file, - library_id: lib.id, - }); - - // 3. 获取音频时长 - const duration = await getAudioDuration(data.file); - - // 4. 创建素材记录 - await createAsset({ - library_id: lib.id, - name: data.name, - storage_key, - mime_type: data.file.type || "audio/mpeg", - metadata: buildMetadata({ - gender: data.gender, - description: data.description, - tags: data.tags, - duration, - }), - }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); }, + onError: (err: Error) => { + message.error(err.message || "上传失败,请重试"); + }, }); // ── 编辑 mutation ───────────────────────────────────────── @@ -794,6 +930,12 @@ const VoiceMaterialLibrary: React.FC = () => { null, ); + // ── 批量操作 / 上传进度 / 音量 ──────────────────────────── + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [uploadProgress, setUploadProgress] = useState(null); + const [volume, setVolume] = useState(0.7); + const [pausedMaterial, setPausedMaterial] = useState(null); + // ── 播放控制 ────────────────────────────────────────────── const stopPlayback = useCallback(() => { if (audioRef.current) { @@ -802,6 +944,7 @@ const VoiceMaterialLibrary: React.FC = () => { } setPlayingId(null); setCurrentTime(0); + setPausedMaterial(null); }, []); const startPlayback = useCallback( @@ -810,6 +953,7 @@ const VoiceMaterialLibrary: React.FC = () => { stopPlayback(); const audio = new Audio(material.fileUrl); + audio.volume = volume; audioRef.current = audio; audio.addEventListener("timeupdate", () => { @@ -820,6 +964,7 @@ const VoiceMaterialLibrary: React.FC = () => { setPlayingId(null); setCurrentTime(0); audioRef.current = null; + setPausedMaterial(null); }); audio.play().catch(() => { @@ -829,21 +974,36 @@ const VoiceMaterialLibrary: React.FC = () => { setPlayingId(material.id); setCurrentTime(0); + setPausedMaterial(null); }, - [stopPlayback], + [stopPlayback, volume], ); const handlePlay = useCallback( (material: VoiceMaterial) => { if (playingId === material.id) return; + // 恢复暂停 + if (pausedMaterial?.id === material.id && audioRef.current && audioRef.current.paused) { + audioRef.current.play().catch(() => {}); + setPlayingId(material.id); + setPausedMaterial(null); + return; + } startPlayback(material); }, - [playingId, startPlayback], + [playingId, pausedMaterial, startPlayback], ); - const handlePause = useCallback(() => { - stopPlayback(); - }, [stopPlayback]); + const handlePause = useCallback( + (material?: VoiceMaterial) => { + if (audioRef.current) { + audioRef.current.pause(); + } + setPlayingId(null); + if (material) setPausedMaterial(material); + }, + [], + ); const handleSeek = useCallback( (material: VoiceMaterial, time: number) => { @@ -862,6 +1022,22 @@ const VoiceMaterialLibrary: React.FC = () => { [startPlayback], ); + const handleVolumeChange = useCallback((e: React.ChangeEvent) => { + const v = parseFloat(e.target.value); + setVolume(v); + if (audioRef.current) audioRef.current.volume = v; + }, []); + + const toggleMute = useCallback(() => { + if (volume > 0) { + setVolume(0); + if (audioRef.current) audioRef.current.volume = 0; + } else { + setVolume(0.7); + if (audioRef.current) audioRef.current.volume = 0.7; + } + }, [volume]); + // 组件卸载时清理 audio useEffect(() => { return () => { @@ -954,6 +1130,74 @@ const VoiceMaterialLibrary: React.FC = () => { return Array.from(set).sort(); }, [materials]); + /* ── 批量操作 ─────────────────────────────────────────── */ + + const batchMode = selectedIds.size > 0; + const allSelected = + filtered.length > 0 && filtered.every((m) => selectedIds.has(m.id)); + + const handleToggleSelect = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const handleSelectAll = useCallback(() => { + if (allSelected) setSelectedIds(new Set()); + else setSelectedIds(new Set(filtered.map((m) => m.id))); + }, [allSelected, filtered]); + + const handleBatchDelete = useCallback(async () => { + const ids = Array.from(selectedIds); + let successCount = 0; + for (const id of ids) { + try { + await deleteAsset(id); + successCount++; + } catch { + /* ignore individual failures */ + } + if (playingId === id) stopPlayback(); + } + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); + setSelectedIds(new Set()); + message.success(`已批量删除 ${successCount}/${ids.length} 个素材`); + }, [selectedIds, playingId, stopPlayback, queryClient]); + + const handleBatchTag = useCallback( + async (tag: string) => { + const ids = Array.from(selectedIds); + let successCount = 0; + for (const id of ids) { + try { + const m = materials.find((mat) => mat.id === id); + const existingTags = m?.tags || []; + const newTags = existingTags.includes(tag) + ? existingTags + : [...existingTags, tag]; + await updateAsset(id, { + metadata: { + style: newTags, + gender: m?.gender || "neutral", + description: m?.description || "", + duration: m?.duration || 0, + }, + }); + successCount++; + } catch { + /* ignore individual failures */ + } + } + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); + setSelectedIds(new Set()); + message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tag}」`); + }, + [selectedIds, materials, queryClient], + ); + /* ── 渲染 ─────────────────────────────────────────────── */ const isUploading = uploadMutation.isPending; @@ -1046,6 +1290,68 @@ const VoiceMaterialLibrary: React.FC = () => {
)} + {/* 批量操作栏 */} + {batchMode && ( +
+
+
+ {allSelected && } +
+ + {allSelected ? "取消全选" : "全选"} + + + 已选择 {selectedIds.size} 项 + +
+
+ + {STYLE_TAGS.map((tag) => ( + + ))} +
+ } + title="批量打标签" + trigger="click" + > + + + + + +
+
+ )} + {/* 内容区 */} {!isLoading && filtered.length > 0 && viewMode === "card" && (
@@ -1055,20 +1361,27 @@ const VoiceMaterialLibrary: React.FC = () => { material={m} isPlaying={playingId === m.id} currentTime={playingId === m.id ? currentTime : 0} + isSelected={selectedIds.has(m.id)} + batchMode={batchMode} + volume={volume} onPlay={() => 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 && } 名称 性别 @@ -1084,11 +1397,14 @@ const VoiceMaterialLibrary: React.FC = () => { material={m} isPlaying={playingId === m.id} currentTime={playingId === m.id ? currentTime : 0} + isSelected={selectedIds.has(m.id)} + batchMode={batchMode} onPlay={() => handlePlay(m)} - onPause={handlePause} + onPause={() => handlePause(m)} onSeek={(t) => handleSeek(m, t)} onEdit={() => setEditingMaterial(m)} onDelete={() => handleDelete(m.id)} + onToggleSelect={handleToggleSelect} /> ))}
@@ -1131,6 +1447,7 @@ const VoiceMaterialLibrary: React.FC = () => { onSubmit={handleUpload} onCancel={() => setUploadOpen(false)} loading={isUploading} + uploadProgress={uploadProgress} /> diff --git a/apps/web/src/pages/voice-materials/voice-materials.css b/apps/web/src/pages/voice-materials/voice-materials.css index bcdb88ba5..e798f3165 100644 --- a/apps/web/src/pages/voice-materials/voice-materials.css +++ b/apps/web/src/pages/voice-materials/voice-materials.css @@ -812,3 +812,252 @@ justify-content: space-between; } } + +/* ─── 批量操作栏 ─────────────────────────────────────────── */ + +.vmat-batch-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + margin-bottom: 16px; + background: var(--primary-soft, #eef2ff); + border: 1px solid var(--primary-color, #6366f1); + border-radius: 8px; + animation: vmat-batch-bar-in 0.2s ease; +} + +@keyframes vmat-batch-bar-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.vmat-batch-bar-left { + display: flex; + align-items: center; + gap: 12px; +} + +.vmat-batch-bar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.vmat-select-all { + font-size: 13px; + color: var(--text-secondary, #64748b); + cursor: pointer; + user-select: none; +} + +.vmat-select-all:hover { + color: var(--primary-color, #6366f1); +} + +.vmat-batch-count { + font-size: 13px; + font-weight: 500; + color: var(--primary-color, #6366f1); +} + +/* ─── 自定义 checkbox ────────────────────────────────────── */ + +.vmat-checkbox { + width: 18px; + height: 18px; + border: 2px solid var(--neutral-300, #cbd5e1); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s ease; + background: var(--bg-primary, #fff); + flex-shrink: 0; +} + +.vmat-checkbox:hover { + border-color: var(--primary-color, #6366f1); +} + +.vmat-checkbox.checked { + background: var(--primary-color, #6366f1); + border-color: var(--primary-color, #6366f1); + color: #fff; + font-size: 10px; +} + +/* ─── 卡片 checkbox 覆盖 ─────────────────────────────────── */ + +.vmat-card-checkbox { + position: absolute; + top: 8px; + left: 8px; + z-index: 2; +} + +.vmat-row-checkbox { + flex-shrink: 0; +} + +/* ─── 选中态 + 批量模式 ──────────────────────────────────── */ + +.vmat-card.selected { + border-color: var(--primary-color, #6366f1); + box-shadow: 0 0 0 1px var(--primary-color, #6366f1); +} + +.vmat-row.selected { + background: var(--primary-soft, #eef2ff); +} + +.vmat-card.batch-mode { + cursor: pointer; +} + +.vmat-row.batch-mode { + cursor: pointer; +} + +/* ─── 进度条拖拽 thumb ───────────────────────────────────── */ + +.vmat-progress-thumb { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--primary-color, #6366f1); + border: 2px solid #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + pointer-events: none; + z-index: 1; +} + +/* ─── 音量控制 ────────────────────────────────────────────── */ + +.vmat-volume { + display: flex; + align-items: center; + gap: 4px; + margin-top: 6px; +} + +.vmat-volume-btn { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--text-secondary, #64748b); + font-size: 14px; + display: flex; + align-items: center; + transition: color 0.15s; +} + +.vmat-volume-btn:hover { + color: var(--primary-color, #6366f1); +} + +.vmat-volume-slider { + -webkit-appearance: none; + appearance: none; + width: 48px; + height: 4px; + border-radius: 2px; + background: var(--neutral-200, #e2e8f0); + outline: none; + cursor: pointer; +} + +.vmat-volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--primary-color, #6366f1); + cursor: pointer; +} + +.vmat-volume-slider::-moz-range-thumb { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--primary-color, #6366f1); + cursor: pointer; + border: none; +} + +/* ─── 上传进度条 ──────────────────────────────────────────── */ + +.vmat-upload-progress { + position: relative; + height: 20px; + background: var(--neutral-100, #f1f5f9); + border-radius: 10px; + overflow: hidden; + margin-top: 12px; +} + +.vmat-upload-progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--primary-color, #6366f1), #818cf8); + border-radius: 10px; + transition: width 0.3s ease; +} + +.vmat-upload-progress-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 11px; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +/* ─── 批量打标签 Popover ──────────────────────────────────── */ + +.vmat-tag-popover { + display: flex; + flex-wrap: wrap; + gap: 8px; + max-width: 240px; +} + +.vmat-tag-pop-btn { + padding: 4px 12px; + border: 1px solid var(--neutral-200, #e2e8f0); + border-radius: 14px; + background: var(--bg-primary, #fff); + font-size: 12px; + color: var(--text-secondary, #64748b); + cursor: pointer; + transition: all 0.15s; +} + +.vmat-tag-pop-btn:hover { + border-color: var(--primary-color, #6366f1); + color: var(--primary-color, #6366f1); + background: var(--primary-soft, #eef2ff); +} + +/* ─── 列表 checkbox 列 ────────────────────────────────────── */ + +.vmat-lh-checkbox { + width: 30px; +} + +.vmat-list.batch-mode .vmat-list-header, +.vmat-list.batch-mode .vmat-row { + grid-template-columns: 30px 48px 1fr 80px 160px 120px 60px 70px 80px; +} From d60a963b62271160f1766ea905ed21aba1e05589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Tue, 7 Jul 2026 11:55:34 +0800 Subject: [PATCH 03/15] =?UTF-8?q?feat(voice-materials):=20P3=20=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E4=BD=93=E7=B3=BB=20UI=20=E2=80=94=20=E8=8D=AF?= =?UTF-8?q?=E4=B8=B8=E7=AD=9B=E9=80=89=E6=A0=8F=20+=20TagSelector=20+=20?= =?UTF-8?q?=E6=BA=A2=E5=87=BA=E5=A4=84=E7=90=86=20+=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标签筛选栏:Select 下拉替换为水平可滚动药丸条,显示使用计数 - 编辑弹窗:TagSelector 组件,支持自定义输入回车添加、已选标签可移除、预设快捷选择、自动补全 - 卡片/行标签溢出:卡片最多3个、行最多2个,超出显示 +N Tooltip - 空标签态:无标签时显示「添加标签」虚线按钮 - 批量打标签 Popover:顶部新增自定义输入框,回车即可批量添加 - 交互走查:全失败时显示 error 消息、键盘可访问、过渡动画 - 新建 api/tags.ts 桩文件,预留后端标签 API 切换点 Co-Authored-By: Claude Fable 5 --- apps/web/src/api/tags.ts | 33 ++ .../voice-materials/VoiceMaterialLibrary.tsx | 299 +++++++++++++++--- .../pages/voice-materials/voice-materials.css | 195 ++++++++++++ 3 files changed, 487 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/api/tags.ts diff --git a/apps/web/src/api/tags.ts b/apps/web/src/api/tags.ts new file mode 100644 index 000000000..ebf1746e6 --- /dev/null +++ b/apps/web/src/api/tags.ts @@ -0,0 +1,33 @@ +/** + * 标签 CRUD API — 后端标签表就绪后替换桩实现 + * P3 标签体系:当前前端继续使用 metadata.style,标签 API 就绪后只需替换桩函数实现 + */ +// import apiClient from "./client"; // 后端标签表就绪后启用 + +export interface TagItem { + id: string; + name: string; + usage_count?: number; +} + +/** 获取所有标签 */ +export const getTags = async (): Promise => { + // TODO: 后端标签表就绪后启用 + // const response = await apiClient.get("/tags"); + // return response.data.items || []; + return []; +}; + +/** 创建标签 */ +export const createTag = async (name: string): Promise => { + // TODO: 后端标签表就绪后启用 + // const response = await apiClient.post("/tags", { name }); + // return response.data; + return { id: `local-${Date.now()}`, name }; +}; + +/** 删除标签 */ +export const deleteTag = async (_tagId: string): Promise => { + // TODO: 后端标签表就绪后启用 + // await apiClient.delete(`/tags/${tagId}`); +}; diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx index 3743c8410..8f0283fe1 100644 --- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -37,7 +37,7 @@ import { MutedOutlined, } from "@ant-design/icons"; import { Button, Input, Select, Modal, Tag } from "@/components/ui"; -import { message, Popover, Popconfirm } from "antd"; +import { message, Popover, Popconfirm, Tooltip } from "antd"; import PageHead from "@/components/layout/PageHead"; import { getAssetsByKind, @@ -72,6 +72,11 @@ 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: { value: VoiceGender; @@ -188,6 +193,158 @@ const getAudioDuration = (file: File): Promise => { }); }; +/* ============================================================ + * 标签选择器组件(支持自定义新增 + 移除) + * ============================================================ */ + +interface TagSelectorProps { + value: string[]; + onChange: (tags: string[]) => void; + presetTags: string[]; + availableTags?: string[]; + placeholder?: string; +} + +const TagSelector: React.FC = ({ + value, + onChange, + presetTags, + availableTags = [], + placeholder = "输入标签后回车添加", +}) => { + const [inputVal, setInputVal] = useState(""); + const [showSuggestions, setShowSuggestions] = useState(false); + const inputRef = useRef(null); + + /** 去重添加标签 */ + const addTag = useCallback( + (tag: string) => { + const trimmed = tag.trim(); + if (!trimmed) return; + if (value.some((t) => t.toLowerCase() === trimmed.toLowerCase())) return; + onChange([...value, trimmed]); + setInputVal(""); + setShowSuggestions(false); + }, + [value, onChange], + ); + + const removeTag = useCallback( + (tag: string) => { + onChange(value.filter((t) => t !== tag)); + }, + [value, onChange], + ); + + /** 输入补全建议 */ + const suggestions = useMemo(() => { + if (!inputVal.trim()) return []; + const lower = inputVal.toLowerCase(); + return availableTags.filter( + (t) => + t.toLowerCase().includes(lower) && + !value.some((v) => v.toLowerCase() === t.toLowerCase()), + ); + }, [inputVal, availableTags, value]); + + /** 合并预设 + 自定义(去重) */ + const allPresets = useMemo(() => { + const set = new Set(presetTags); + availableTags.forEach((t) => set.add(t)); + return Array.from(set); + }, [presetTags, availableTags]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (suggestions.length > 0) { + addTag(suggestions[0]); + } else { + addTag(inputVal); + } + } else if (e.key === "Backspace" && !inputVal && value.length > 0) { + removeTag(value[value.length - 1]); + } + }; + + return ( +
+
inputRef.current?.focus()} + > + {value.map((tag) => ( + removeTag(tag)} + > + {tag} + + ))} + { + 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) => ( + + ))} +
+ )} + + {/* 预设标签快捷选择 */} + {allPresets.length > 0 && ( +
+ {allPresets.map((tag) => { + const isSelected = value.some( + (v) => v.toLowerCase() === tag.toLowerCase(), + ); + return ( + + ); + })} +
+ )} +
+ ); +}; + /* ============================================================ * 上传 / 编辑 表单 * ============================================================ */ @@ -200,6 +357,7 @@ interface MaterialFormProps { onCancel: () => void; loading?: boolean; uploadProgress?: number | null; + allTags?: string[]; } const MaterialForm: React.FC = ({ @@ -208,6 +366,7 @@ const MaterialForm: React.FC = ({ onCancel, loading, uploadProgress, + allTags, }) => { const [name, setName] = useState(initial?.name ?? ""); const [description, setDescription] = useState(initial?.description ?? ""); @@ -220,12 +379,6 @@ const MaterialForm: React.FC = ({ 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; @@ -348,18 +501,12 @@ const MaterialForm: React.FC = ({ {/* 风格标签 */}
-
- {STYLE_TAGS.map((tag) => ( - - ))} -
+
{/* 操作按钮 */} @@ -515,11 +662,27 @@ const VoiceMaterialCard: React.FC = ({ {/* 标签 */}
- {material.tags.map((tag) => ( - - {tag} - - ))} + {material.tags.length === 0 ? ( + { e.stopPropagation(); onEdit(); }} + > + 添加标签 + + ) : ( + <> + {material.tags.slice(0, MAX_CARD_TAGS).map((tag, i) => ( + + {tag} + + ))} + {material.tags.length > MAX_CARD_TAGS && ( + + +{material.tags.length - MAX_CARD_TAGS} + + )} + + )}
{/* 元信息 */} @@ -696,11 +859,22 @@ const VoiceMaterialRow: React.FC = ({ {/* 标签 */}
- {material.tags.map((tag) => ( - - {tag} - - ))} + {material.tags.length === 0 ? ( + onEdit()}>添加标签 + ) : ( + <> + {material.tags.slice(0, MAX_ROW_TAGS).map((tag, i) => ( + + {tag} + + ))} + {material.tags.length > MAX_ROW_TAGS && ( + + +{material.tags.length - MAX_ROW_TAGS} + + )} + + )}
{/* 进度条(可拖拽) */} @@ -935,6 +1109,7 @@ const VoiceMaterialLibrary: React.FC = () => { const [uploadProgress, setUploadProgress] = useState(null); const [volume, setVolume] = useState(0.7); const [pausedMaterial, setPausedMaterial] = useState(null); + const [batchCustomTag, setBatchCustomTag] = useState(""); // ── 播放控制 ────────────────────────────────────────────── const stopPlayback = useCallback(() => { @@ -1122,7 +1297,7 @@ const VoiceMaterialLibrary: React.FC = () => { return list; }, [materials, filterGender, filterTag, searchText]); - /* ── 所有用到的标签(用于筛选下拉) ────────────────────── */ + /* ── 所有用到的标签(用于筛选药丸条) ────────────────────── */ const allTags = React.useMemo(() => { const set = new Set(); @@ -1130,6 +1305,14 @@ const VoiceMaterialLibrary: React.FC = () => { return Array.from(set).sort(); }, [materials]); + /* ── 标签使用计数(药丸条展示) ─────────────────────────── */ + + const tagCountMap = React.useMemo(() => { + const map: Record = {}; + materials.forEach((m) => m.tags.forEach((t) => { map[t] = (map[t] || 0) + 1; })); + return map; + }, [materials]); + /* ── 批量操作 ─────────────────────────────────────────── */ const batchMode = selectedIds.size > 0; @@ -1193,7 +1376,11 @@ const VoiceMaterialLibrary: React.FC = () => { } queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); setSelectedIds(new Set()); - message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tag}」`); + if (successCount === 0) { + message.error(`批量打标签失败,请重试`); + } else { + message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tag}」`); + } }, [selectedIds, materials, queryClient], ); @@ -1247,15 +1434,6 @@ const VoiceMaterialLibrary: React.FC = () => { { value: "neutral", label: "中性" }, ]} /> - ) => setBatchCustomTag(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter" && batchCustomTag.trim()) { + handleBatchTag(batchCustomTag.trim()); + setBatchCustomTag(""); + } + }} + /> +
{STYLE_TAGS.map((tag) => ( ))}
)} - {/* 预设标签快捷选择 */} - {allPresets.length > 0 && ( + {/* 已有标签快捷选择 */} + {tags.length > 0 && (
- {allPresets.map((tag) => { - const isSelected = value.some( - (v) => v.toLowerCase() === tag.toLowerCase(), - ); + {tags.map((tag) => { + const isSelected = value.includes(tag.id); return ( ); })} @@ -357,7 +364,9 @@ interface MaterialFormProps { onCancel: () => void; loading?: boolean; uploadProgress?: number | null; - allTags?: string[]; + tags?: TagItem[]; + tagMap?: Map; + onCreateTag?: (name: string) => Promise; } const MaterialForm: React.FC = ({ @@ -366,15 +375,17 @@ const MaterialForm: React.FC = ({ onCancel, loading, uploadProgress, - allTags, + 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); @@ -386,7 +397,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, @@ -502,10 +513,11 @@ const MaterialForm: React.FC = ({
({ id: "", name: "" }))} />
@@ -539,6 +551,7 @@ interface VoiceCardProps { isSelected: boolean; batchMode: boolean; volume: number; + tagMap: Map; onPlay: () => void; onPause: () => void; onSeek: (time: number) => void; @@ -556,6 +569,7 @@ const VoiceMaterialCard: React.FC = ({ isSelected, batchMode, volume, + tagMap, onPlay, onPause, onSeek, @@ -662,7 +676,7 @@ const VoiceMaterialCard: React.FC = ({ {/* 标签 */}
- {material.tags.length === 0 ? ( + {material.tagIds.length === 0 ? ( { e.stopPropagation(); onEdit(); }} @@ -671,14 +685,14 @@ const VoiceMaterialCard: React.FC = ({ ) : ( <> - {material.tags.slice(0, MAX_CARD_TAGS).map((tag, i) => ( - - {tag} + {material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => ( + + {tagMap.get(tagId)?.name ?? tagId} ))} - {material.tags.length > MAX_CARD_TAGS && ( - - +{material.tags.length - MAX_CARD_TAGS} + {material.tagIds.length > MAX_CARD_TAGS && ( + tagMap.get(id)?.name ?? id).join("、")}> + +{material.tagIds.length - MAX_CARD_TAGS} )} @@ -768,6 +782,7 @@ interface VoiceRowProps { currentTime: number; isSelected: boolean; batchMode: boolean; + tagMap: Map; onPlay: () => void; onPause: () => void; onSeek: (time: number) => void; @@ -782,6 +797,7 @@ const VoiceMaterialRow: React.FC = ({ currentTime, isSelected, batchMode, + tagMap, onPlay, onPause, onSeek, @@ -859,18 +875,18 @@ const VoiceMaterialRow: React.FC = ({ {/* 标签 */}
- {material.tags.length === 0 ? ( + {material.tagIds.length === 0 ? ( onEdit()}>添加标签 ) : ( <> - {material.tags.slice(0, MAX_ROW_TAGS).map((tag, i) => ( - - {tag} + {material.tagIds.slice(0, MAX_ROW_TAGS).map((tagId, i) => ( + + {tagMap.get(tagId)?.name ?? tagId} ))} - {material.tags.length > MAX_ROW_TAGS && ( - - +{material.tags.length - MAX_ROW_TAGS} + {material.tagIds.length > MAX_ROW_TAGS && ( + tagMap.get(id)?.name ?? id).join("、")}> + +{material.tagIds.length - MAX_ROW_TAGS} )} @@ -968,16 +984,46 @@ const VoiceMaterialLibrary: React.FC = () => { } }, [libraries, voiceLibrary, createLibMutation]); + // ── 获取标签列表 ─────────────────────────────────────────── + const { data: tags = [] } = useQuery({ + queryKey: ["tags"], + queryFn: getTags, + staleTime: 60_000, + }); + + /** 标签 ID → TagItem 映射(用于卡片/行渲染) */ + const tagMap = useMemo(() => { + const m = new Map(); + tags.forEach((t) => m.set(t.id, t)); + return m; + }, [tags]); + + /** 创建标签 mutation(供 TagSelector 调用) */ + const createTagMutation = useMutation({ + mutationFn: (name: string) => createTag(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tags"] }); + }, + }); + + /** 创建标签并返回 TagItem(供 TagSelector 使用) */ + const handleCreateTag = useCallback( + async (name: string): Promise => { + return createTagMutation.mutateAsync(name); + }, + [createTagMutation], + ); + // ── 视图状态 ────────────────────────────────────────────── const [viewMode, setViewMode] = useState("card"); const [searchText, setSearchText] = useState(""); const [filterGender, setFilterGender] = useState("all"); - const [filterTag, setFilterTag] = useState("all"); + const [filterTagId, setFilterTagId] = useState("all"); // ── 获取配音素材列表(筛选参数透传后端) ───────────────── const filterKeyword = searchText.trim() || undefined; const filterGenderParam = filterGender !== "all" ? filterGender : undefined; - const filterStyleParam = filterTag !== "all" ? filterTag : undefined; + const filterTagIdsParam = filterTagId !== "all" ? [filterTagId] : undefined; const { data: assets = [], isLoading } = useQuery({ queryKey: [ @@ -986,14 +1032,14 @@ const VoiceMaterialLibrary: React.FC = () => { { keyword: filterKeyword, gender: filterGenderParam, - style: filterStyleParam, + tag_ids: filterTagIdsParam, }, ], queryFn: () => getAssetsByKind("voice", { keyword: filterKeyword, gender: filterGenderParam, - style: filterStyleParam, + tag_ids: filterTagIdsParam, }), staleTime: 30_000, }); @@ -1010,7 +1056,7 @@ const VoiceMaterialLibrary: React.FC = () => { name: string; gender: VoiceGender; description: string; - tags: string[]; + tagIds: string[]; }) => { setUploadProgress(0); try { @@ -1039,7 +1085,7 @@ const VoiceMaterialLibrary: React.FC = () => { const duration = await getAudioDuration(data.file); // 4. 创建素材记录 - await createAsset({ + const asset = await createAsset({ library_id: lib.id, name: data.name, storage_key, @@ -1047,16 +1093,21 @@ const VoiceMaterialLibrary: React.FC = () => { metadata: buildMetadata({ gender: data.gender, description: data.description, - tags: data.tags, duration, }), }); + + // 5. 打标签(标签走独立 API) + if (data.tagIds.length > 0) { + await tagAsset(asset.id, data.tagIds); + } } finally { setUploadProgress(null); } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); }, onError: (err: Error) => { message.error(err.message || "上传失败,请重试"); @@ -1065,23 +1116,40 @@ const VoiceMaterialLibrary: React.FC = () => { // ── 编辑 mutation ───────────────────────────────────────── const editMutation = useMutation({ - mutationFn: (data: { + mutationFn: async (data: { id: string; name: string; gender: VoiceGender; description: string; - tags: string[]; - }) => - updateAsset(data.id, { + tagIds: string[]; + }) => { + // 1. 更新基础信息 + await updateAsset(data.id, { name: data.name, metadata: buildMetadata({ gender: data.gender, description: data.description, - tags: data.tags, }), - }), + }); + + // 2. 对比标签差异,调用 tag/untag API + const currentAsset = materials.find((m) => m.id === data.id); + const oldTagIds = currentAsset?.tagIds ?? []; + const newTagIds = data.tagIds; + + const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id)); + const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id)); + + if (toAdd.length > 0) { + await tagAsset(data.id, toAdd); + } + for (const tagId of toRemove) { + await untagAsset(data.id, tagId); + } + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); }, }); @@ -1235,7 +1303,7 @@ const VoiceMaterialLibrary: React.FC = () => { name: data.name, gender: data.gender, description: data.description, - tags: data.tags, + tagIds: data.tagIds, }, { onSuccess: () => { @@ -1254,7 +1322,7 @@ const VoiceMaterialLibrary: React.FC = () => { name: data.name, gender: data.gender, description: data.description, - tags: data.tags, + tagIds: data.tagIds, }); setEditingMaterial(null); }; @@ -1282,8 +1350,8 @@ const VoiceMaterialLibrary: React.FC = () => { if (filterGender !== "all") { list = list.filter((m) => m.gender === filterGender); } - if (filterTag !== "all") { - list = list.filter((m) => m.tags.includes(filterTag)); + if (filterTagId !== "all") { + list = list.filter((m) => m.tagIds.includes(filterTagId)); } if (searchText.trim()) { const q = searchText.trim().toLowerCase(); @@ -1291,25 +1359,17 @@ const VoiceMaterialLibrary: React.FC = () => { (m) => m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q) || - m.tags.some((t) => t.toLowerCase().includes(q)), + m.tagIds.some((id) => tagMap.get(id)?.name?.toLowerCase().includes(q)), ); } return list; - }, [materials, filterGender, filterTag, searchText]); + }, [materials, filterGender, filterTagId, searchText, tagMap]); - /* ── 所有用到的标签(用于筛选药丸条) ────────────────────── */ - - const allTags = React.useMemo(() => { - const set = new Set(); - materials.forEach((m) => m.tags.forEach((t) => set.add(t))); - return Array.from(set).sort(); - }, [materials]); - - /* ── 标签使用计数(药丸条展示) ─────────────────────────── */ + /* ── 标签使用计数(药丸条展示,按 tag ID 统计) ──────────── */ const tagCountMap = React.useMemo(() => { const map: Record = {}; - materials.forEach((m) => m.tags.forEach((t) => { map[t] = (map[t] || 0) + 1; })); + materials.forEach((m) => m.tagIds.forEach((id) => { map[id] = (map[id] || 0) + 1; })); return map; }, [materials]); @@ -1351,24 +1411,12 @@ const VoiceMaterialLibrary: React.FC = () => { }, [selectedIds, playingId, stopPlayback, queryClient]); const handleBatchTag = useCallback( - async (tag: string) => { + async (tagId: string) => { const ids = Array.from(selectedIds); let successCount = 0; for (const id of ids) { try { - const m = materials.find((mat) => mat.id === id); - const existingTags = m?.tags || []; - const newTags = existingTags.includes(tag) - ? existingTags - : [...existingTags, tag]; - await updateAsset(id, { - metadata: { - style: newTags, - gender: m?.gender || "neutral", - description: m?.description || "", - duration: m?.duration || 0, - }, - }); + await tagAsset(id, [tagId]); successCount++; } catch { /* ignore individual failures */ @@ -1376,13 +1424,32 @@ const VoiceMaterialLibrary: React.FC = () => { } queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); setSelectedIds(new Set()); + const tagName = tagMap.get(tagId)?.name ?? tagId; if (successCount === 0) { message.error(`批量打标签失败,请重试`); } else { - message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tag}」`); + message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tagName}」`); } }, - [selectedIds, materials, queryClient], + [selectedIds, queryClient, tagMap], + ); + + /** 批量打标签 — 自定义输入:按名称查找或创建标签,再批量打标 */ + const handleBatchCustomTag = useCallback( + async (name: string) => { + // 先查找同名标签(不区分大小写) + let existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase()); + if (!existing) { + try { + existing = await createTagMutation.mutateAsync(name); + } catch { + message.error(`创建标签「${name}」失败`); + return; + } + } + await handleBatchTag(existing.id); + }, + [tags, createTagMutation, handleBatchTag], ); /* ── 渲染 ─────────────────────────────────────────────── */ @@ -1459,26 +1526,26 @@ const VoiceMaterialLibrary: React.FC = () => {
{/* ── 标签筛选药丸条 ─────────────────────────────────── */} - {allTags.length > 0 && ( + {tags.length > 0 && (
- {allTags.map((tag) => ( + {tags.map((tag) => ( ))}
@@ -1522,20 +1589,20 @@ const VoiceMaterialLibrary: React.FC = () => { onChange={(e: React.ChangeEvent) => setBatchCustomTag(e.target.value)} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === "Enter" && batchCustomTag.trim()) { - handleBatchTag(batchCustomTag.trim()); + handleBatchCustomTag(batchCustomTag.trim()); setBatchCustomTag(""); } }} />
- {STYLE_TAGS.map((tag) => ( + {tags.map((tag) => ( ))}
@@ -1576,6 +1643,7 @@ const VoiceMaterialLibrary: React.FC = () => { { {

暂无配音素材

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

- {!searchText && filterGender === "all" && filterTag === "all" && ( + {!searchText && filterGender === "all" && filterTagId === "all" && ( + + )} + + {/* ── 存为素材弹窗 ── */} + {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..3ab723c20 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -883,3 +883,184 @@ 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; } +} From 2bcbd54ed75b9acd936b8a650488a8e4822df98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Tue, 7 Jul 2026 13:24:08 +0800 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20=E6=9C=80=E5=90=8E=E4=B8=80?= =?UTF-8?q?=E5=85=AC=E9=87=8C=20=E2=80=94=20TTS=20=E5=90=88=E6=88=90?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=BF=9D=E5=AD=98=E5=88=B0=E9=85=8D=E9=9F=B3?= =?UTF-8?q?=E5=BA=93=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /tts/jobs/{id}/save-to-library 接口 - 自动携带音色名、时长、语速等元信息到 metadata - 配音库配额检查(429 响应) - 15 个单元测试全部通过 - 全部 115 个 TTS 测试无回归 --- apps/api/app/api/routes/tts.py | 2 +- tests/unit/test_tts_save_to_library.py | 344 +++++++++++++++++++++++++ 2 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_tts_save_to_library.py diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 77b540468..56e73578f 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -28,6 +28,7 @@ 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 ( @@ -38,7 +39,6 @@ from packages.application.tts_job.use_cases import ( ListTTSJobsUseCase, TTSJobNotFoundError, ) -from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository from packages.application.tts_job.workflow import TTSWorkflowService from packages.application.voice_library.commands import CreateVoiceLibraryCommand from packages.application.voice_library.use_cases import ( diff --git a/tests/unit/test_tts_save_to_library.py b/tests/unit/test_tts_save_to_library.py new file mode 100644 index 000000000..fc62b0e5c --- /dev/null +++ b/tests/unit/test_tts_save_to_library.py @@ -0,0 +1,344 @@ +"""最后一公里:TTS 合成结果保存到配音库 单元测试。 + +覆盖: +- 正常保存已完成 TTS job 到配音库 +- 自动携带元信息(音色名、时长、语速等) +- 自定义名称 +- TTS job 不存在 → 404 +- TTS job 未完成 → 400 +- 配音库配额已满 → 429 +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from packages.domain.tts_job import TTSJob, TTSJobStatus +from packages.domain.voice_library import VoiceLibraryItem + + +def _make_completed_job(**kwargs) -> TTSJob: + """构造一个已完成的 TTSJob。""" + defaults = { + "id": "tts_job_001", + "user_id": "user_001", + "input_text": "你好世界", + "voice_id": "voice_001", + "voice_model": "CosyVoice-v1", + "project_id": "proj_001", + "voice_clone_profile_id": "", + "status": TTSJobStatus.COMPLETED, + "output_audio_url": "https://oss.example.com/audio.mp3", + "output_audio_key": "tts-outputs/user_001/tts_job_001.mp3", + "duration": 5.5, + "file_size": 88000, + "sample_rate": 22050, + "format": "mp3", + "error_message": "", + "retry_count": 0, + "max_retries": 3, + "metadata": {"speed": 1.0, "language": "zh-CN"}, + "started_at": datetime(2026, 7, 7, 10, 0, 0, tzinfo=timezone.utc), + "completed_at": datetime(2026, 7, 7, 10, 0, 5, tzinfo=timezone.utc), + "created_at": datetime(2026, 7, 7, 10, 0, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 7, 7, 10, 0, 5, tzinfo=timezone.utc), + } + defaults.update(kwargs) + return TTSJob(**defaults) + + +def _make_voice_library_item(**kwargs) -> VoiceLibraryItem: + """构造一个配音库条目。""" + defaults = { + "id": "voice_lib_001", + "user_id": "user_001", + "name": "TTS-tts_job_", + "text": "你好世界", + "voice_provider": "cosyvoice", + "voice_id": "voice_001", + "voice_name": "CosyVoice-v1", + "audio_url": "https://oss.example.com/audio.mp3", + "duration": 5.5, + "file_size": 88000, + "status": "completed", + "project_id": "proj_001", + "tags": [], + "metadata_": { + "source": "tts_job", + "tts_job_id": "tts_job_001", + "format": "mp3", + "sample_rate": 22050, + "speed": 1.0, + "language": "zh-CN", + }, + "created_at": datetime(2026, 7, 7, 10, 1, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 7, 7, 10, 1, 0, tzinfo=timezone.utc), + } + defaults.update(kwargs) + return VoiceLibraryItem(**defaults) + + +class TestSaveToLibraryMapping: + """测试 TTSJob → VoiceLibraryItem 字段映射。""" + + def test_completed_job_maps_correctly(self) -> None: + """已完成的 TTS job 字段正确映射到配音库条目。""" + job = _make_completed_job() + + # 验证 is_completed 属性 + assert job.is_completed is True + + # 验证关键字段映射 + assert job.output_audio_url == "https://oss.example.com/audio.mp3" + assert job.duration == 5.5 + assert job.file_size == 88000 + assert job.voice_id == "voice_001" + assert job.voice_model == "CosyVoice-v1" + assert job.input_text == "你好世界" + assert job.format == "mp3" + assert job.sample_rate == 22050 + + def test_metadata_carries_speed_and_format(self) -> None: + """元信息携带语速、格式等。""" + job = _make_completed_job() + + metadata = { + "source": "tts_job", + "tts_job_id": job.id, + "format": job.format, + "sample_rate": job.sample_rate, + } + if job.metadata: + for key in ("speed", "language"): + if key in job.metadata: + metadata[key] = job.metadata[key] + + assert metadata["format"] == "mp3" + assert metadata["sample_rate"] == 22050 + assert metadata["speed"] == 1.0 + assert metadata["language"] == "zh-CN" + assert metadata["source"] == "tts_job" + + def test_name_auto_generated_when_empty(self) -> None: + """未提供名称时自动生成。""" + job = _make_completed_job() + name = None # 模拟未提供名称 + generated_name = name or f"TTS-{job.id[:8]}" + assert generated_name == "TTS-tts_job_" + + def test_name_uses_custom_when_provided(self) -> None: + """提供自定义名称时使用自定义名称。""" + custom_name = "我的配音" + generated_name = custom_name or "TTS-fallback" + assert generated_name == "我的配音" + + +class TestSaveToLibraryNotCompleted: + """测试未完成 job 不能保存。""" + + def test_pending_job_not_completed(self) -> None: + """pending 状态的 job 不能保存。""" + job = _make_completed_job(status=TTSJobStatus.PENDING) + assert job.is_completed is False + + def test_processing_job_not_completed(self) -> None: + """processing 状态的 job 不能保存。""" + job = _make_completed_job(status=TTSJobStatus.PROCESSING) + assert job.is_completed is False + + def test_failed_job_not_completed(self) -> None: + """failed 状态的 job 不能保存。""" + job = _make_completed_job(status=TTSJobStatus.FAILED) + assert job.is_completed is False + + def test_completed_without_url_not_completed(self) -> None: + """status=completed 但没有 audio_url 的 job 不算完成。""" + job = _make_completed_job( + status=TTSJobStatus.COMPLETED, + output_audio_url="", + ) + assert job.is_completed is False + + +class TestSaveToLibraryQuota: + """测试配额检查。""" + + def test_quota_exceeded_raises(self) -> None: + """配音库配额已满时抛出 QuotaExceededError。""" + from packages.application.voice_library.use_cases import QuotaExceededError + + error = QuotaExceededError(dimension="max_voiceovers", limit=10, used=10) + assert "10/10" in str(error) + + def test_quota_under_limit_passes(self) -> None: + """配额未满时不报错。""" + from packages.domain.quota import QuotaDimension, quota_checker + + result = quota_checker.check("free", QuotaDimension.MAX_VOICEOVERS.value, 5) + assert result.allowed is True + + +class TestSaveToLibraryCreateCommand: + """测试 CreateVoiceLibraryCommand 构建。""" + + def test_command_fields_from_tts_job(self) -> None: + """从 TTSJob 构建的 Command 字段正确。""" + from packages.application.voice_library.commands import CreateVoiceLibraryCommand + + job = _make_completed_job() + metadata_ = { + "source": "tts_job", + "tts_job_id": job.id, + "format": job.format, + "sample_rate": job.sample_rate, + "speed": 1.0, + "language": "zh-CN", + } + + command = CreateVoiceLibraryCommand( + user_id=job.user_id, + name=f"TTS-{job.id[:8]}", + text=job.input_text, + voice_provider="cosyvoice", + voice_id=job.voice_id, + voice_name=job.voice_model, + audio_url=job.output_audio_url, + duration=job.duration, + file_size=job.file_size, + status="completed", + project_id=job.project_id, + tags=[], + metadata_=metadata_, + ) + + assert command.user_id == "user_001" + assert command.name == "TTS-tts_job_" + assert command.text == "你好世界" + assert command.voice_provider == "cosyvoice" + assert command.voice_id == "voice_001" + assert command.voice_name == "CosyVoice-v1" + assert command.audio_url == "https://oss.example.com/audio.mp3" + assert command.duration == 5.5 + assert command.file_size == 88000 + assert command.status == "completed" + assert command.project_id == "proj_001" + assert command.metadata_["source"] == "tts_job" + + def test_command_with_empty_project_id(self) -> None: + """project_id 为空时传空字符串。""" + from packages.application.voice_library.commands import CreateVoiceLibraryCommand + + job = _make_completed_job(project_id="") + + command = CreateVoiceLibraryCommand( + user_id=job.user_id, + name="test", + text=job.input_text, + voice_provider="cosyvoice", + voice_id=job.voice_id, + voice_name="", + audio_url=job.output_audio_url, + duration=job.duration, + file_size=job.file_size, + status="completed", + project_id=job.project_id or "", + tags=[], + metadata_={}, + ) + + assert command.project_id == "" + + def test_command_voice_name_fallback(self) -> None: + """voice_model 为空时 voice_name 回退为空字符串。""" + from packages.application.voice_library.commands import CreateVoiceLibraryCommand + + job = _make_completed_job(voice_model="") + + command = CreateVoiceLibraryCommand( + user_id=job.user_id, + name="test", + 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="", + tags=[], + metadata_={}, + ) + + assert command.voice_name == "" + + +class TestSaveToLibraryUseCase: + """测试 CreateVoiceLibraryUseCase 调用。""" + + def test_use_case_creates_item(self) -> None: + """UseCase 正确创建配音库条目。""" + from packages.application.voice_library.commands import CreateVoiceLibraryCommand + from packages.application.voice_library.use_cases import CreateVoiceLibraryUseCase + + repo = MagicMock() + repo.count_by_user.return_value = 0 # 配额未满 + + expected_item = _make_voice_library_item() + repo.create.side_effect = lambda item: item + + use_case = CreateVoiceLibraryUseCase(repo) + command = CreateVoiceLibraryCommand( + user_id="user_001", + name="test", + text="你好", + voice_provider="cosyvoice", + voice_id="v1", + voice_name="Voice1", + audio_url="https://example.com/audio.mp3", + duration=3.0, + file_size=5000, + status="completed", + project_id="", + tags=[], + metadata_={}, + ) + + item = use_case.execute(command, plan_name="free") + + repo.create.assert_called_once() + assert item is not None + + def test_use_case_quota_exceeded(self) -> None: + """UseCase 配额已满时抛出 QuotaExceededError。""" + from packages.application.voice_library.commands import CreateVoiceLibraryCommand + from packages.application.voice_library.use_cases import ( + CreateVoiceLibraryUseCase, + QuotaExceededError, + ) + + repo = MagicMock() + repo.count_by_user.return_value = 100 # 超过 premium 配额 + + use_case = CreateVoiceLibraryUseCase(repo) + command = CreateVoiceLibraryCommand( + user_id="user_001", + name="test", + text="你好", + voice_provider="cosyvoice", + voice_id="v1", + voice_name="Voice1", + audio_url="https://example.com/audio.mp3", + duration=3.0, + file_size=5000, + status="completed", + project_id="", + tags=[], + metadata_={}, + ) + + with pytest.raises(QuotaExceededError): + use_case.execute(command, plan_name="premium") From 18571451edccf5d336665eb8c211b2be84cef6e0 Mon Sep 17 00:00:00 2001 From: CI Test Date: Tue, 7 Jul 2026 13:31:01 +0800 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=89=8D?= =?UTF-8?q?=E7=AB=AFprettier=E6=A0=BC=E5=BC=8F=20+=20=E5=A2=9E=E5=8A=A0use?= =?UTF-8?q?rs.display=5Fname=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复6个前端文件的Prettier格式问题 - 新增031 migration: users表增加display_name列 --- alembic/versions/031_add_user_display_name.py | 28 ++++ apps/web/src/api/assets.ts | 7 +- .../pages/editing-planner/EditingPlanner.css | 12 +- apps/web/src/pages/generate/GeneratePage.tsx | 34 ++++- apps/web/src/pages/generate/generate.css | 18 ++- .../voice-materials/VoiceMaterialLibrary.tsx | 131 +++++++++++++----- .../pages/voice-materials/voice-materials.css | 8 +- 7 files changed, 184 insertions(+), 54 deletions(-) create mode 100644 alembic/versions/031_add_user_display_name.py diff --git a/alembic/versions/031_add_user_display_name.py b/alembic/versions/031_add_user_display_name.py new file mode 100644 index 000000000..b8a808e87 --- /dev/null +++ b/alembic/versions/031_add_user_display_name.py @@ -0,0 +1,28 @@ +"""Add display_name column to users table + +Revision ID: 031 +Revises: 030 +Create Date: 2026-07-07 + +新增用户 display_name 字段,支持用户昵称展示。 +""" + +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( + "users", + sa.Column("display_name", sa.String(255), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + op.drop_column("users", "display_name") diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index 1681ba8f7..856652f85 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -145,7 +145,12 @@ export const getAssets = async (libraryId: string): Promise => { /** 按类型获取素材(如 voice/video/image),支持可选筛选 */ export const getAssetsByKind = async ( kind: string, - filters?: { keyword?: string; gender?: string; style?: string; tag_ids?: string[] }, + filters?: { + keyword?: string; + gender?: string; + style?: string; + tag_ids?: string[]; + }, ): Promise => { const params: Record = { kind }; if (filters?.keyword) params.keyword = filters.keyword; 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 a1d217f0c..6e9dc0342 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -232,7 +232,9 @@ const GeneratePage: React.FC = () => { const [ttsError, setTtsError] = useState(null); const [ttsJobId, setTtsJobId] = useState(null); /** 合成完成后保留的 job ID,用于"存为素材" */ - const [completedTtsJobId, setCompletedTtsJobId] = useState(null); + const [completedTtsJobId, setCompletedTtsJobId] = useState( + null, + ); /* ── 存为素材弹窗状态 ── */ const [saveModalOpen, setSaveModalOpen] = useState(false); @@ -384,7 +386,10 @@ const GeneratePage: React.FC = () => { 已保存到配音素材库!{" "} 去素材库查看 @@ -1033,8 +1038,14 @@ const GeneratePage: React.FC = () => { {/* ── 存为素材弹窗 ── */} {saveModalOpen && ( -
setSaveModalOpen(false)}> -
e.stopPropagation()}> +
setSaveModalOpen(false)} + > +
e.stopPropagation()} + >
保存到配音素材库 ))}
diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 3ab723c20..82052f16b 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -1056,11 +1056,21 @@ } @keyframes xxFadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes xxSlideUp { - from { transform: translateY(12px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } + 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 20009c6d5..92ab18f08 100644 --- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -68,7 +68,13 @@ type ViewMode = "card" | "list"; /** 标签溢出限制 */ const MAX_CARD_TAGS = 3; const MAX_ROW_TAGS = 2; -const TAG_VARIANTS = ["info", "primary", "success", "warning", "error"] as const; +const TAG_VARIANTS = [ + "info", + "primary", + "success", + "warning", + "error", +] as const; /** 性别选项 */ const GENDER_OPTIONS: { @@ -258,8 +264,7 @@ const TagSelector: React.FC = ({ if (!inputVal.trim()) return []; const lower = inputVal.toLowerCase(); return tags.filter( - (t) => - t.name.toLowerCase().includes(lower) && !value.includes(t.id), + (t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id), ); }, [inputVal, tags, value]); @@ -341,7 +346,9 @@ const TagSelector: React.FC = ({ else addTagId(tag.id); }} > - {isSelected && } + {isSelected && ( + + )} {tag.name} ); @@ -461,8 +468,13 @@ const MaterialForm: React.FC = ({ {/* 上传进度条 */} {uploadProgress !== null && uploadProgress !== undefined && (
-
- {uploadProgress}% +
+ + {uploadProgress}% +
)}
@@ -587,7 +599,10 @@ const VoiceMaterialCard: React.FC = ({ 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)); + const percent = Math.max( + 0, + Math.min(1, (ev.clientX - rect.left) / rect.width), + ); onSeek(percent * material.duration); }; doSeek(e.nativeEvent); @@ -679,7 +694,10 @@ const VoiceMaterialCard: React.FC = ({ {material.tagIds.length === 0 ? ( { e.stopPropagation(); onEdit(); }} + onClick={(e) => { + e.stopPropagation(); + onEdit(); + }} > 添加标签 @@ -691,8 +709,15 @@ const VoiceMaterialCard: React.FC = ({ ))} {material.tagIds.length > MAX_CARD_TAGS && ( - tagMap.get(id)?.name ?? id).join("、")}> - +{material.tagIds.length - MAX_CARD_TAGS} + tagMap.get(id)?.name ?? id) + .join("、")} + > + + +{material.tagIds.length - MAX_CARD_TAGS} + )} @@ -813,7 +838,10 @@ const VoiceMaterialRow: React.FC = ({ 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)); + const percent = Math.max( + 0, + Math.min(1, (ev.clientX - rect.left) / rect.width), + ); onSeek(percent * material.duration); }; doSeek(e.nativeEvent); @@ -876,7 +904,9 @@ const VoiceMaterialRow: React.FC = ({ {/* 标签 */}
{material.tagIds.length === 0 ? ( - onEdit()}>添加标签 + onEdit()}> + 添加标签 + ) : ( <> {material.tagIds.slice(0, MAX_ROW_TAGS).map((tagId, i) => ( @@ -885,8 +915,15 @@ const VoiceMaterialRow: React.FC = ({ ))} {material.tagIds.length > MAX_ROW_TAGS && ( - tagMap.get(id)?.name ?? id).join("、")}> - +{material.tagIds.length - MAX_ROW_TAGS} + tagMap.get(id)?.name ?? id) + .join("、")} + > + + +{material.tagIds.length - MAX_ROW_TAGS} + )} @@ -1176,7 +1213,9 @@ const VoiceMaterialLibrary: React.FC = () => { const [selectedIds, setSelectedIds] = useState>(new Set()); const [uploadProgress, setUploadProgress] = useState(null); const [volume, setVolume] = useState(0.7); - const [pausedMaterial, setPausedMaterial] = useState(null); + const [pausedMaterial, setPausedMaterial] = useState( + null, + ); const [batchCustomTag, setBatchCustomTag] = useState(""); // ── 播放控制 ────────────────────────────────────────────── @@ -1226,7 +1265,11 @@ const VoiceMaterialLibrary: React.FC = () => { (material: VoiceMaterial) => { if (playingId === material.id) return; // 恢复暂停 - if (pausedMaterial?.id === material.id && audioRef.current && audioRef.current.paused) { + if ( + pausedMaterial?.id === material.id && + audioRef.current && + audioRef.current.paused + ) { audioRef.current.play().catch(() => {}); setPlayingId(material.id); setPausedMaterial(null); @@ -1237,16 +1280,13 @@ const VoiceMaterialLibrary: React.FC = () => { [playingId, pausedMaterial, startPlayback], ); - const handlePause = useCallback( - (material?: VoiceMaterial) => { - if (audioRef.current) { - audioRef.current.pause(); - } - setPlayingId(null); - if (material) setPausedMaterial(material); - }, - [], - ); + const handlePause = useCallback((material?: VoiceMaterial) => { + if (audioRef.current) { + audioRef.current.pause(); + } + setPlayingId(null); + if (material) setPausedMaterial(material); + }, []); const handleSeek = useCallback( (material: VoiceMaterial, time: number) => { @@ -1265,11 +1305,14 @@ const VoiceMaterialLibrary: React.FC = () => { [startPlayback], ); - const handleVolumeChange = useCallback((e: React.ChangeEvent) => { - const v = parseFloat(e.target.value); - setVolume(v); - if (audioRef.current) audioRef.current.volume = v; - }, []); + const handleVolumeChange = useCallback( + (e: React.ChangeEvent) => { + const v = parseFloat(e.target.value); + setVolume(v); + if (audioRef.current) audioRef.current.volume = v; + }, + [], + ); const toggleMute = useCallback(() => { if (volume > 0) { @@ -1359,7 +1402,9 @@ const VoiceMaterialLibrary: React.FC = () => { (m) => m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q) || - m.tagIds.some((id) => tagMap.get(id)?.name?.toLowerCase().includes(q)), + m.tagIds.some((id) => + tagMap.get(id)?.name?.toLowerCase().includes(q), + ), ); } return list; @@ -1369,7 +1414,11 @@ const VoiceMaterialLibrary: React.FC = () => { const tagCountMap = React.useMemo(() => { const map: Record = {}; - materials.forEach((m) => m.tagIds.forEach((id) => { map[id] = (map[id] || 0) + 1; })); + materials.forEach((m) => + m.tagIds.forEach((id) => { + map[id] = (map[id] || 0) + 1; + }), + ); return map; }, [materials]); @@ -1428,7 +1477,9 @@ const VoiceMaterialLibrary: React.FC = () => { if (successCount === 0) { message.error(`批量打标签失败,请重试`); } else { - message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tagName}」`); + message.success( + `已为 ${successCount}/${ids.length} 个素材添加标签「${tagName}」`, + ); } }, [selectedIds, queryClient, tagMap], @@ -1438,7 +1489,9 @@ const VoiceMaterialLibrary: React.FC = () => { const handleBatchCustomTag = useCallback( async (name: string) => { // 先查找同名标签(不区分大小写) - let existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase()); + let existing = tags.find( + (t) => t.name.toLowerCase() === name.toLowerCase(), + ); if (!existing) { try { existing = await createTagMutation.mutateAsync(name); @@ -1545,7 +1598,9 @@ const VoiceMaterialLibrary: React.FC = () => { tabIndex={0} > {tag.name} - {tagCountMap[tag.id] || 0} + + {tagCountMap[tag.id] || 0} + ))}
@@ -1586,7 +1641,9 @@ const VoiceMaterialLibrary: React.FC = () => { ) => setBatchCustomTag(e.target.value)} + onChange={(e: React.ChangeEvent) => + setBatchCustomTag(e.target.value) + } onKeyDown={(e: React.KeyboardEvent) => { if (e.key === "Enter" && batchCustomTag.trim()) { handleBatchCustomTag(batchCustomTag.trim()); diff --git a/apps/web/src/pages/voice-materials/voice-materials.css b/apps/web/src/pages/voice-materials/voice-materials.css index c67b54a8f..fed41ec53 100644 --- a/apps/web/src/pages/voice-materials/voice-materials.css +++ b/apps/web/src/pages/voice-materials/voice-materials.css @@ -111,7 +111,9 @@ .vmat-card.playing { border-color: var(--primary-500); - box-shadow: 0 0 0 1px var(--primary-500), var(--shadow-md); + box-shadow: + 0 0 0 1px var(--primary-500), + var(--shadow-md); } /* 性别色带 */ @@ -1145,7 +1147,9 @@ background: var(--bg-surface); min-height: 42px; cursor: text; - transition: border-color 0.2s, box-shadow 0.2s; + transition: + border-color 0.2s, + box-shadow 0.2s; } .vmat-tag-selector:focus-within { From 6cb146693e84fe102be750eb6b2f85b12ebb60d4 Mon Sep 17 00:00:00 2001 From: CI Test Date: Tue, 7 Jul 2026 13:32:37 +0800 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DTTS=E8=B7=AF?= =?UTF-8?q?=E7=94=B1WebSocketDisconnect=E6=9C=AA=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/api/routes/tts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 56e73578f..7bb55d80b 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -22,7 +22,7 @@ from app.schemas.tts import ( TTSSynthesizeRequest, TTSSynthesizeResponse, ) -from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, 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 ( From 608ddbf9f7c968bfa1a11b55b5dd820dd178c108 Mon Sep 17 00:00:00 2001 From: CI Test Date: Tue, 7 Jul 2026 13:35:22 +0800 Subject: [PATCH 13/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dmigration=20030?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E6=A8=A1=E5=BC=8F=E4=B8=8B=5Ftable=5Fexists?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic/versions/030_add_tags_and_asset_tags.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/alembic/versions/030_add_tags_and_asset_tags.py b/alembic/versions/030_add_tags_and_asset_tags.py index 6dbfa212b..338750833 100644 --- a/alembic/versions/030_add_tags_and_asset_tags.py +++ b/alembic/versions/030_add_tags_and_asset_tags.py @@ -18,12 +18,15 @@ 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() > 0 + return (result.scalar() or 0) > 0 def upgrade() -> None: From 0d871d97342a825932c91c60eda4d52f7be36b57 Mon Sep 17 00:00:00 2001 From: CI Test Date: Tue, 7 Jul 2026 13:36:50 +0800 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0schema=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=20-=20=E6=96=B0=E5=A2=9Etags/asset=5Ftags=E8=A1=A8=20?= =?UTF-8?q?+=20users.display=5Fname=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/schema-metadata-snapshot.json | 81 ++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/docs/schema-metadata-snapshot.json b/docs/schema-metadata-snapshot.json index e0dacee1f..769beb8ff 100644 --- a/docs/schema-metadata-snapshot.json +++ b/docs/schema-metadata-snapshot.json @@ -95,6 +95,39 @@ "id" ] }, + "asset_tags": { + "columns": [ + { + "index": false, + "name": "asset_id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": false, + "name": "tag_id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [], + "primary_key": [ + "asset_id", + "tag_id" + ] + }, "assets": { "columns": [ { @@ -2056,6 +2089,54 @@ "id" ] }, + "tags": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "user_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": false, + "name": "name", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(100)", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "user_id" + ], + "name": "ix_tags_user_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, "template_categories": { "columns": [ { From 8219d2445d80ba2f912bf071892c2da122e43bb2 Mon Sep 17 00:00:00 2001 From: CI Test Date: Tue, 7 Jul 2026 13:41:08 +0800 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E5=A4=9A?= =?UTF-8?q?=E4=BD=99=E7=9A=84031=20display=5Fname=20migration=EF=BC=88base?= =?UTF-8?q?line=E5=B7=B2=E5=8C=85=E5=90=AB=E6=AD=A4=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic/versions/031_add_user_display_name.py | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 alembic/versions/031_add_user_display_name.py diff --git a/alembic/versions/031_add_user_display_name.py b/alembic/versions/031_add_user_display_name.py deleted file mode 100644 index b8a808e87..000000000 --- a/alembic/versions/031_add_user_display_name.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Add display_name column to users table - -Revision ID: 031 -Revises: 030 -Create Date: 2026-07-07 - -新增用户 display_name 字段,支持用户昵称展示。 -""" - -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( - "users", - sa.Column("display_name", sa.String(255), nullable=False, server_default=""), - ) - - -def downgrade() -> None: - op.drop_column("users", "display_name")