diff --git a/apps/api/app/api/routes/asset_diagnosis.py b/apps/api/app/api/routes/asset_diagnosis.py index 26a8c6456..4fa210232 100644 --- a/apps/api/app/api/routes/asset_diagnosis.py +++ b/apps/api/app/api/routes/asset_diagnosis.py @@ -44,7 +44,12 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> voice_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VOICE] problem_assets = [asset for asset in assets if asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING}] unclassified_assets = [asset for asset in ready_assets if asset.classification_status.value in {"pending", "failed"}] - risky_assets = [asset for asset in ready_assets if asset.quality_score is not None and asset.quality_score < 60] + risky_assets = [ + asset for asset in ready_assets + if (asset.quality_score is not None and asset.quality_score < 60) + or asset.metadata.get("review_status") == "rejected" + or asset.status == AssetStatus.ERROR + ] used_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) > 0] unused_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) == 0] pending_review_assets = [asset for asset in ready_assets if asset.metadata.get("review_status") == "pending_review"] @@ -125,6 +130,7 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> smart_views = [ AssetSmartViewItem(key="recommended", label="推荐素材", count=len(video_assets), description="已导入完成、可参与生成的视频素材"), AssetSmartViewItem(key="needs_attention", label="慎用素材", count=len(problem_assets) + len(risky_assets), description="导入未完成、失败或质量分偏低的素材"), + AssetSmartViewItem(key="high_risk", label="高风险素材", count=len(risky_assets), description="质量分偏低或复核拒绝的素材"), AssetSmartViewItem(key="unclassified", label="未分类素材", count=len(unclassified_assets), description="尚未完成分类或分类失败的 ready 素材"), AssetSmartViewItem(key="recent", label="最近上传", count=min(len(assets), 10), description="最近进入素材库的素材,可用于快速复核"), AssetSmartViewItem(key="unused", label="未使用素材", count=len(unused_assets), description="尚未参与生成的 ready 素材"), diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index 4bbe5ae07..5f5e4c833 100644 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -8,7 +8,7 @@ from app.dependencies import ( get_project_repository, get_workspace_member_repository, ) -from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse +from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest from fastapi import APIRouter, Depends, HTTPException from packages.application import ( @@ -63,6 +63,31 @@ def list_assets( return ListAssetsResponse(items=[_to_asset_response(item) for item in items]) +def _apply_asset_review_status(item, review_status: str): + item.metadata = { + **item.metadata, + "review_status": review_status, + } + return item + + +@router.patch("/{asset_id}/review", response_model=AssetResponse) +def update_asset_review_status( + asset_id: str, + request: UpdateAssetReviewRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + asset_repository: Any = Depends(get_asset_repository), + workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), +) -> AssetResponse: + item = asset_repository.get(asset_id) + if item is None: + raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found") + require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository) + _apply_asset_review_status(item, request.review_status) + updated = asset_repository.update(item) + return _to_asset_response(updated) + + @router.post("", response_model=AssetResponse) def create_asset( request: CreateAssetRequest, diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py index 9a709ee13..21127f98a 100644 --- a/apps/api/app/schemas/asset.py +++ b/apps/api/app/schemas/asset.py @@ -22,6 +22,10 @@ class CreateAssetRequest(BaseModel): uploaded_by_user_id: str = Field(default="", max_length=100) +class UpdateAssetReviewRequest(BaseModel): + review_status: str = Field(..., pattern="^(pending_review|approved|rejected)$") + + class AssetResponse(BaseModel): id: str workspace_id: str diff --git a/apps/web/e2e/core-upload.spec.ts b/apps/web/e2e/core-upload.spec.ts index 1a1ce73f5..312d653ae 100644 --- a/apps/web/e2e/core-upload.spec.ts +++ b/apps/web/e2e/core-upload.spec.ts @@ -112,6 +112,9 @@ test.describe('Core media upload flow', () => { await expect(page.getByText(/推荐素材:1/)).toBeVisible({ timeout: 20_000 }); await expect(page.getByText(/视频素材数量偏少|素材准备度良好/)).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole('cell', { name: 'e2e-sample.MOV', exact: true })).toBeVisible({ timeout: 20_000 }); + await page.getByRole('row', { name: /e2e-sample\.MOV/ }).getByRole('button', { name: '通过' }).click(); + await expect(page.getByText('复核状态已更新')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText('已通过')).toBeVisible({ timeout: 20_000 }); await expect(page.getByText(/素材列表加载失败|上传失败/)).toHaveCount(0); }); }); diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index e8e8961fc..c278caf79 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -98,6 +98,16 @@ export const getAssets = async (libraryId: string): Promise => { return response.data.items; }; +export const updateAssetReviewStatus = async ( + assetId: string, + reviewStatus: 'pending_review' | 'approved' | 'rejected' +): Promise => { + const response = await apiClient.patch(`/assets/${assetId}/review`, { + review_status: reviewStatus, + }); + return response.data; +}; + export const uploadAsset = async ( formData: FormData ): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => { diff --git a/apps/web/src/pages/workspace/ProjectAssets.tsx b/apps/web/src/pages/workspace/ProjectAssets.tsx index 28a63ec20..fd24cedbb 100644 --- a/apps/web/src/pages/workspace/ProjectAssets.tsx +++ b/apps/web/src/pages/workspace/ProjectAssets.tsx @@ -28,6 +28,7 @@ import { getClassificationJob, getIngestJob, submitClassificationJob, + updateAssetReviewStatus, uploadAsset, uploadAssetDirect, type AssetItem, @@ -274,6 +275,17 @@ const ProjectAssets: React.FC = () => { }, }); + const reviewMutation = useMutation({ + mutationFn: ({ assetId, reviewStatus }: { assetId: string; reviewStatus: 'pending_review' | 'approved' | 'rejected' }) => + updateAssetReviewStatus(assetId, reviewStatus), + onSuccess: () => { + message.success('复核状态已更新'); + assetsQuery.refetch(); + diagnosisQuery.refetch(); + }, + onError: (error: any) => message.error(error.response?.data?.detail || '复核状态更新失败'), + }); + const libraryOptions = useMemo( () => (librariesQuery.data || []).map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })), [librariesQuery.data] @@ -286,6 +298,8 @@ const ProjectAssets: React.FC = () => { items = items.filter((item) => item.status === 'ready' && item.mime_type?.startsWith('video')); } else if (smartViewFilter === 'needs_attention') { items = items.filter((item) => item.status !== 'ready' || (typeof item.quality_score === 'number' && item.quality_score < 60)); + } else if (smartViewFilter === 'high_risk') { + items = items.filter((item) => (typeof item.quality_score === 'number' && item.quality_score < 60) || item.metadata?.review_status === 'rejected' || item.status === 'error'); } else if (smartViewFilter === 'unclassified') { items = items.filter((item) => !item.metadata?.classification || item.classification_status === 'failed'); } else if (smartViewFilter === 'voice') { @@ -410,10 +424,29 @@ const ProjectAssets: React.FC = () => { ); }; + const renderReviewStatus = (asset: AssetItem) => { + const status = String(asset.metadata?.review_status || 'unreviewed'); + const labels: Record = { + pending_review: '待复核', + approved: '已通过', + rejected: '已拒绝', + unreviewed: '未复核', + }; + const colors: Record = { + pending_review: 'orange', + approved: 'green', + rejected: 'red', + unreviewed: 'default', + }; + return {labels[status] || status}; + }; + const columns = [ { title: '名称', dataIndex: 'name', key: 'name' }, { title: '类型', dataIndex: 'mime_type', key: 'mime_type', render: (value: string) => {value} }, { title: '分类结果', key: 'classification', render: (_: unknown, record: AssetItem) => renderClassification(record) }, + { title: '复核状态', key: 'review_status', render: (_: unknown, record: AssetItem) => renderReviewStatus(record) }, + { title: '使用次数', key: 'generation_use_count', render: (_: unknown, record: AssetItem) => Number(record.metadata?.generation_use_count || 0) }, { title: '存储键', dataIndex: 'storage_key', key: 'storage_key', ellipsis: true }, { title: '操作', @@ -423,21 +456,25 @@ const ProjectAssets: React.FC = () => { const isCurrentClassifying = classifyingAssetId === record.id; const isAutoClassifying = record.metadata?.auto_classification === 'queued'; return ( - + + + + + ); }, }, @@ -509,6 +546,7 @@ const ProjectAssets: React.FC = () => { { label: '全部素材', value: 'all' }, { label: '推荐素材', value: 'recommended' }, { label: '慎用素材', value: 'needs_attention' }, + { label: '高风险素材', value: 'high_risk' }, { label: '未分类素材', value: 'unclassified' }, { label: '最近上传', value: 'recent' }, { label: '未使用素材', value: 'unused' }, diff --git a/tests/unit/test_asset_diagnosis.py b/tests/unit/test_asset_diagnosis.py index 07633de97..b62a402bc 100644 --- a/tests/unit/test_asset_diagnosis.py +++ b/tests/unit/test_asset_diagnosis.py @@ -75,4 +75,6 @@ def test_asset_diagnosis_flags_unready_and_low_quality_assets(): gap_keys = {gap.key for gap in diagnosis.gaps} assert "not_ready_assets" in gap_keys assert "low_quality_assets" in gap_keys - assert {item.key: item.count for item in diagnosis.smart_views}["needs_attention"] == 2 + smart_view_counts = {item.key: item.count for item in diagnosis.smart_views} + assert smart_view_counts["needs_attention"] == 2 + assert smart_view_counts["high_risk"] == 1 diff --git a/tests/unit/test_asset_review.py b/tests/unit/test_asset_review.py new file mode 100644 index 000000000..80eb7c99d --- /dev/null +++ b/tests/unit/test_asset_review.py @@ -0,0 +1,26 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from app.api.routes.assets import _apply_asset_review_status +from packages.domain import Asset, AssetStatus + + +def test_apply_asset_review_status_preserves_existing_metadata(): + asset = Asset.create( + workspace_id="workspace-1", + project_id="project-1", + library_id="library-1", + name="video.mp4", + storage_key="uploads/video.mp4", + mime_type="video/mp4", + file_size=1024, + status=AssetStatus.READY, + metadata={"generation_use_count": 2}, + ) + + _apply_asset_review_status(asset, "approved") + + assert asset.metadata["generation_use_count"] == 2 + assert asset.metadata["review_status"] == "approved"