feat(assets): add review actions and high risk view

This commit is contained in:
Xiaoxia AI
2026-06-23 22:57:56 +08:00
parent bf452e3510
commit 9cc48aeee9
8 changed files with 132 additions and 18 deletions
+7 -1
View File
@@ -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 素材"),
+26 -1
View File
@@ -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,
+4
View File
@@ -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
+3
View File
@@ -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);
});
});
+10
View File
@@ -98,6 +98,16 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
return response.data.items;
};
export const updateAssetReviewStatus = async (
assetId: string,
reviewStatus: 'pending_review' | 'approved' | 'rejected'
): Promise<AssetItem> => {
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 }> => {
+53 -15
View File
@@ -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<string, string> = {
pending_review: '待复核',
approved: '已通过',
rejected: '已拒绝',
unreviewed: '未复核',
};
const colors: Record<string, string> = {
pending_review: 'orange',
approved: 'green',
rejected: 'red',
unreviewed: 'default',
};
return <Tag color={colors[status] || 'default'}>{labels[status] || status}</Tag>;
};
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '类型', dataIndex: 'mime_type', key: 'mime_type', render: (value: string) => <Tag>{value}</Tag> },
{ 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 (
<Button
icon={<TagsOutlined />}
loading={isCurrentClassifying && classificationMutation.isPending}
disabled={isAutoClassifying || (!!classifyingAssetId && classifyingAssetId !== record.id) || batchProgress.running}
onClick={() => {
setClassifyingAssetId(record.id);
classificationMutation.mutate({
workspace_id: record.workspace_id,
project_id: record.project_id,
asset_id: record.id,
});
}}
>
{isAutoClassifying ? '自动分类中...' : isCurrentClassifying ? '分类中...' : hasClassification ? '重新分类' : '发起分类'}
</Button>
<Space wrap>
<Button
icon={<TagsOutlined />}
loading={isCurrentClassifying && classificationMutation.isPending}
disabled={isAutoClassifying || (!!classifyingAssetId && classifyingAssetId !== record.id) || batchProgress.running}
onClick={() => {
setClassifyingAssetId(record.id);
classificationMutation.mutate({
workspace_id: record.workspace_id,
project_id: record.project_id,
asset_id: record.id,
});
}}
>
{isAutoClassifying ? '自动分类中...' : isCurrentClassifying ? '分类中...' : hasClassification ? '重新分类' : '发起分类'}
</Button>
<Button size="small" disabled={reviewMutation.isPending} onClick={() => reviewMutation.mutate({ assetId: record.id, reviewStatus: 'approved' })}>通过</Button>
<Button size="small" danger disabled={reviewMutation.isPending} onClick={() => reviewMutation.mutate({ assetId: record.id, reviewStatus: 'rejected' })}>拒绝</Button>
</Space>
);
},
},
@@ -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' },
+3 -1
View File
@@ -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
+26
View File
@@ -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"