feat(video): 视频页「导出全部」— 把项目所有视频片段打包成 zip 下载

- 后端 ProjectViewSet 加 export-clips action:逐段从 TOS 拉取已采用视频片段、内存 zip 打包返回下载
  (单段失败跳过不毁整包;空项目 400);文件名走 RFC5987 UTF-8 编码兼容中文
- 前端视频阶段 queue-bar 在「上传视频」旁加「导出全部」按钮(下载图标 + 导出中/失败态),
  api.exportProjectClips 取 blob → 触发浏览器下载;仅有已完成片段时可点
- 单测 ExportClipsTests:打包全部片段 + 空项目 400

验收:后端单测绿(基线 4 失败零新增);tsc+build 绿;真打包 补水面膜 项目得 950KB zip;无头 0 报错
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-21 20:38:31 +08:00
parent 9e791a4db9
commit a39d2b8603
5 changed files with 158 additions and 1 deletions
+42
View File
@@ -490,3 +490,45 @@ class WorkerGateTests(TestCase):
with patch.object(celery_health, "_ping_workers", side_effect=ConnectionError("broker down")):
self.assertFalse(celery_health.celery_worker_available())
celery_health._cache["expires"] = 0.0
class ExportClipsTests(TestCase):
"""导出全部:把项目已采用视频片段打成一个 zip 下载。"""
def setUp(self):
self.user = User.objects.create_user(username="exp", password="p")
self.team = Team.objects.create(name="ExpT", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
self.project = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user)
self.client = APIClient()
self.client.force_authenticate(self.user)
def _add_clip(self, order):
from apps.assets.models import AssetFile
asset = Asset.objects.create(team=self.team, name=f"clip{order}", asset_type="video", source="ai_generated", category="video_clip")
AssetFile.objects.create(asset=asset, object_key=f"k{order}.mp4", bucket="b", content_type="video/mp4", is_primary=True)
seg = VideoSegment.objects.create(project=self.project, sort_order=order)
ver = VideoSegmentVersion.objects.create(video_segment=seg, asset=asset, is_adopted=True)
seg.adopted_version = ver
seg.save(update_fields=["adopted_version"])
def test_export_zip_packs_all_clips(self):
self._add_clip(0)
self._add_clip(1)
with patch("apps.projects.views.TosStorage") as Tos, patch("requests.get") as rget:
Tos.return_value.presigned_get_url.return_value = "http://x/c.mp4"
rget.return_value.content = b"MP4DATA"
res = self.client.get(f"/api/projects/{self.project.id}/export-clips/")
self.assertEqual(res.status_code, 200)
self.assertEqual(res["Content-Type"], "application/zip")
import io
import zipfile
zf = zipfile.ZipFile(io.BytesIO(res.content))
self.assertEqual(len(zf.namelist()), 2)
def test_export_empty_returns_400(self):
res = self.client.get(f"/api/projects/{self.project.id}/export-clips/")
self.assertEqual(res.status_code, 400)