Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoxia 65a80f2b01 fix(lint): Prettier format VideoPlayer
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 47s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m48s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m58s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m3s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 32s
AI Code Review / AI Code Review (pull_request) Successful in 2m52s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m12s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Successful in 23s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 47s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1168h33m21s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1168h38m43s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1168h44m45s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1168h44m46s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1168h44m46s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1168h44m47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 1168h55m0s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1168h55m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1168h57m31s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1168h57m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1168h57m33s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1168h57m34s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1168h57m35s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 1169h28m27s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1169h30m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1169h30m36s
2026-07-29 22:30:12 +08:00
xiaoxia effd1dfdd9 test(products): 补充 useVideoPlayer smoke test
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 39s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m47s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m22s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m29s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 50s
AI Code Review / AI Code Review (pull_request) Failing after 5m30s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m15s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Failing after 30s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1169h8m40s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1169h8m52s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1169h9m12s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1169h9m13s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1169h9m14s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1169h9m17s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1169h9m18s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1169h9m19s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1169h9m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1169h11m54s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 1169h11m58s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1169h12m22s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1169h16m45s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1169h16m49s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 1169h44m57s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1169h49m48s
2026-07-29 22:05:04 +08:00
xiaoxia 1cc091e8d8 refactor(products): VideoPlayer 改用 useVideoPlayer Hook 消除重复代码 2026-07-29 22:04:43 +08:00
3 changed files with 17 additions and 331 deletions
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback } from "react"
import React, { useEffect } from "react"
import {
VideoCameraOutlined,
PlayCircleOutlined,
@@ -11,6 +11,7 @@ import {
import { Button } from "@/components/ui"
import type { ProductItem } from "../types"
import { formatTime, formatSize } from "../utils"
import { useVideoPlayer } from "../hooks/useVideoPlayer"
interface VideoPlayerProps {
product: ProductItem
@@ -27,52 +28,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onShare,
onViewDetail,
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
const progressRef = useRef<HTMLDivElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(product.duration)
const {
videoRef,
progressRef,
isPlaying,
currentTime,
duration,
progress,
togglePlay,
handleSeek,
} = useVideoPlayer()
const hasVideo = !!product.videoUrl
/** 播放/暂停 */
const handlePlayPause = useCallback(() => {
const video = videoRef.current
if (!video) return
if (isPlaying) {
video.pause()
} else {
video.play().catch(() => {})
}
setIsPlaying(!isPlaying)
}, [isPlaying])
/** 视频事件监听 */
useEffect(() => {
const video = videoRef.current
if (!video) return
const onTime = () => setCurrentTime(video.currentTime)
const onDur = () => setDuration(video.duration || product.duration)
const onEnd = () => setIsPlaying(false)
video.addEventListener("timeupdate", onTime)
video.addEventListener("loadedmetadata", onDur)
video.addEventListener("ended", onEnd)
return () => {
video.removeEventListener("timeupdate", onTime)
video.removeEventListener("loadedmetadata", onDur)
video.removeEventListener("ended", onEnd)
}
}, [product.duration])
/** 进度条点击 */
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const newTime = percent * duration
setCurrentTime(newTime)
if (videoRef.current) videoRef.current.currentTime = newTime
}
const displayDuration = duration || product.duration
/** ESC 关闭 */
useEffect(() => {
@@ -83,8 +51,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return () => window.removeEventListener("keydown", handleKey)
}, [onClose])
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="xx-player-overlay" onClick={onClose}>
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
@@ -114,7 +80,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
{/* 播放/暂停按钮 */}
<button className="xx-player-play-btn" onClick={handlePlayPause}>
<button className="xx-player-play-btn" onClick={togglePlay}>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
@@ -125,12 +91,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{/* 进度条 */}
<div className="xx-player-progress-wrap">
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
<div ref={progressRef} className="xx-player-progress" onClick={handleSeek}>
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="xx-player-time">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
<span>{formatTime(displayDuration)}</span>
</div>
</div>
</div>
@@ -22,6 +22,7 @@ import "@/pages/products/components/VideoPlayer"
// Hooks
import "@/pages/products/hooks/useProductList"
import "@/pages/products/hooks/useProductActions"
import "@/pages/products/hooks/useVideoPlayer"
describe("ProductLibrary module smoke test", () => {
it("should load all product modules", () => {
-281
View File
@@ -1,281 +0,0 @@
"""edit_template 剪辑模板实体单测."""
from datetime import datetime, timezone
import pytest
from domain.edit_template import EditTemplate, EditTemplateStatus
from domain.editing_mode import EditingMode
# ── EditTemplateStatus 枚举 ──────────────────────────────────────────────────
class TestEditTemplateStatus:
"""EditTemplateStatus 枚举"""
def test_enum_values(self):
assert EditTemplateStatus.ACTIVE.value == "active"
assert EditTemplateStatus.INACTIVE.value == "inactive"
def test_is_str_enum(self):
assert isinstance(EditTemplateStatus.ACTIVE, str)
assert EditTemplateStatus.ACTIVE == "active"
def test_from_string(self):
assert EditTemplateStatus("active") == EditTemplateStatus.ACTIVE
assert EditTemplateStatus("inactive") == EditTemplateStatus.INACTIVE
def test_invalid_raises(self):
with pytest.raises(ValueError):
EditTemplateStatus("deleted")
# ── EditTemplate.create 工厂方法 ────────────────────────────────────────────
class TestEditTemplateCreate:
"""EditTemplate.create 工厂方法"""
def test_minimal_create(self):
t = EditTemplate.create("测试模板")
assert t.id is not None
assert len(t.id) == 32 # uuid4 hex
assert t.name == "测试模板"
assert t.description == ""
assert t.template_type == "default"
assert t.editing_mode == "one_take"
assert t.config == {}
assert t.preview_url == ""
assert t.sort_weight == 0
assert t.status == EditTemplateStatus.ACTIVE
assert t.version == 1
def test_unique_ids(self):
t1 = EditTemplate.create("模板A")
t2 = EditTemplate.create("模板B")
assert t1.id != t2.id
def test_custom_fields(self):
t = EditTemplate.create(
"自定义模板",
description="这是一个自定义模板",
template_type="story",
editing_mode="one_take",
config={"key": "value"},
preview_url="https://example.com/preview.mp4",
sort_weight=100,
status=EditTemplateStatus.INACTIVE,
version=2,
)
assert t.name == "自定义模板"
assert t.description == "这是一个自定义模板"
assert t.template_type == "story"
assert t.editing_mode == "one_take"
assert t.config == {"key": "value"}
assert t.preview_url == "https://example.com/preview.mp4"
assert t.sort_weight == 100
assert t.status == EditTemplateStatus.INACTIVE
assert t.version == 2
def test_name_stripped(self):
t = EditTemplate.create(" 带空格的模板 ")
assert t.name == "带空格的模板"
def test_empty_name_raises(self):
with pytest.raises(ValueError, match="名称"):
EditTemplate.create("")
def test_whitespace_only_name_raises(self):
with pytest.raises(ValueError):
EditTemplate.create(" ")
def test_invalid_editing_mode_raises(self):
with pytest.raises(ValueError, match="editing_mode"):
EditTemplate.create("测试", editing_mode="invalid_mode")
def test_empty_editing_mode_falls_back_to_default(self):
t = EditTemplate.create("测试", editing_mode="")
assert t.editing_mode == "one_take"
def test_whitespace_editing_mode_falls_back(self):
t = EditTemplate.create("测试", editing_mode=" ")
assert t.editing_mode == "one_take"
def test_editing_mode_stripped(self):
t = EditTemplate.create("测试", editing_mode=" one_take ")
assert t.editing_mode == "one_take"
def test_description_stripped(self):
t = EditTemplate.create("测试", description=" 描述 ")
assert t.description == "描述"
def test_template_type_stripped(self):
t = EditTemplate.create("测试", template_type=" vlog ")
assert t.template_type == "vlog"
def test_empty_template_type_falls_back(self):
t = EditTemplate.create("测试", template_type="")
assert t.template_type == "default"
def test_none_config_becomes_empty_dict(self):
t = EditTemplate.create("测试", config=None)
assert t.config == {}
assert isinstance(t.config, dict)
def test_preview_url_stripped(self):
t = EditTemplate.create("测试", preview_url=" https://x.com/a.mp4 ")
assert t.preview_url == "https://x.com/a.mp4"
def test_timestamps_are_utc(self):
t = EditTemplate.create("测试")
assert t.created_at.tzinfo is not None
assert t.updated_at.tzinfo is not None
def test_created_at_equals_updated_at_on_create(self):
t = EditTemplate.create("测试")
# 创建时两个时间应该非常接近
diff = abs((t.updated_at - t.created_at).total_seconds())
assert diff < 1.0
# ── 状态操作 ─────────────────────────────────────────────────────────────────
class TestEditTemplateStatusOperations:
"""EditTemplate 状态操作"""
def test_activate_sets_active(self):
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
t.activate()
assert t.status == EditTemplateStatus.ACTIVE
assert t.is_active is True
def test_deactivate_sets_inactive(self):
t = EditTemplate.create("测试")
t.deactivate()
assert t.status == EditTemplateStatus.INACTIVE
assert t.is_active is False
def test_is_active_true(self):
t = EditTemplate.create("测试")
assert t.is_active is True
def test_is_active_false(self):
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
assert t.is_active is False
def test_activate_updates_updated_at(self):
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
old_updated = t.updated_at
t.activate()
assert t.updated_at >= old_updated
def test_deactivate_updates_updated_at(self):
t = EditTemplate.create("测试")
old_updated = t.updated_at
t.deactivate()
assert t.updated_at >= old_updated
# ── 版本操作 ─────────────────────────────────────────────────────────────────
class TestEditTemplateVersion:
"""EditTemplate 版本操作"""
def test_bump_version_increments(self):
t = EditTemplate.create("测试")
assert t.version == 1
t.bump_version()
assert t.version == 2
def test_bump_version_multiple(self):
t = EditTemplate.create("测试", version=5)
t.bump_version()
t.bump_version()
t.bump_version()
assert t.version == 8
def test_bump_version_updates_updated_at(self):
t = EditTemplate.create("测试")
old_updated = t.updated_at
t.bump_version()
assert t.updated_at >= old_updated
# ── dataclass 基础特性 ───────────────────────────────────────────────────────
class TestEditTemplateBasics:
"""EditTemplate 基础特性"""
def test_slots_no_extra_attrs(self):
t = EditTemplate.create("测试")
with pytest.raises(AttributeError):
t.nonexistent_field = "value"
def test_direct_construction_minimal(self):
# 最小构造:仅必填字段 + 状态,其余走默认值
t = EditTemplate(
id="custom_id",
name="直接构造",
status=EditTemplateStatus.ACTIVE,
)
assert t.id == "custom_id"
assert t.name == "直接构造"
assert t.status == EditTemplateStatus.ACTIVE
# 默认值检查
assert t.description == ""
assert t.config == {}
assert t.version == 1
assert t.editing_mode == EditingMode.ONE_TAKE.value
assert isinstance(t.created_at, datetime)
assert isinstance(t.updated_at, datetime)
def test_direct_construction_full(self):
# 完整构造:所有字段都传
now = datetime(2025, 1, 1, tzinfo=timezone.utc)
t = EditTemplate(
id="full_id",
name="完整构造",
description="测试描述",
template_type="custom",
editing_mode=EditingMode.PIP.value,
config={"key": "value"},
preview_url="https://example.com/preview.jpg",
sort_weight=100,
status=EditTemplateStatus.INACTIVE,
version=3,
created_at=now,
updated_at=now,
)
assert t.id == "full_id"
assert t.name == "完整构造"
assert t.description == "测试描述"
assert t.template_type == "custom"
assert t.editing_mode == EditingMode.PIP.value
assert t.config == {"key": "value"}
assert t.preview_url == "https://example.com/preview.jpg"
assert t.sort_weight == 100
assert t.status == EditTemplateStatus.INACTIVE
assert t.version == 3
assert t.created_at == now
assert t.updated_at == now
def test_config_is_independent(self):
# 不同实例的 config 应该是独立的 dict
t1 = EditTemplate.create("模板1")
t2 = EditTemplate.create("模板2")
t1.config["key"] = "value"
assert "key" not in t2.config
def test_equality(self):
# 两个不同实例即使内容相同也不等(id不同)
t1 = EditTemplate.create("同名模板")
t2 = EditTemplate.create("同名模板")
assert t1 != t2
def test_same_id_equal(self):
now = datetime.now(timezone.utc)
t1 = EditTemplate(id="same", name="同名", created_at=now, updated_at=now)
t2 = EditTemplate(id="same", name="同名", created_at=now, updated_at=now)
assert t1 == t2