Compare commits

...

7 Commits

Author SHA1 Message Date
xiaoxia 078569cf6c refactor(duplication-detail): 三阶段重构 DuplicationDetail(323→110行, -66%)
CI/CD Pipeline / CI Gate (pull_request) CI运行中 [frontend-only]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 9s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m17s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m21s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m2s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 45s
AI Code Review / AI Code Review (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m46s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- Phase 1: 抽 constants(RISK_DESC/RISK_LABEL/ToastState)+ utils(formatTime/formatSize/formatDuration/getRiskLevel)
- Phase 2: 抽 4 个 UI 组件(RiskCard/InfoCard/SegmentsSection/SegmentCard)
- Phase 3: 抽 useDuplicationDetail 业务 Hook
- 主文件 323→110 行,职责清晰
2026-07-27 16:07:48 +08:00
xiaoxia d9f2436bd3 refactor(home-page): 拆分 HomePage 落地页(300→44行, -85%) (#971)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-27 15:53:27 +08:00
xiaoxia 4d9131f2e4 refactor: 拆分 DuplicationResults 页面(275→67行,-76%) (#1022) 2026-07-27 15:53:27 +08:00
xiaoxia b4bde72b44 refactor(editing-planner): 拆分 ClipDetailSection,按职责拆为 3 个子组件 (#1018)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-27 15:28:27 +08:00
xiaoxia 1d4c7d6aea refactor(editing-planner): 拆分 LayerConfig 组件,按职责拆为 3 个子面板 (#1015) 2026-07-27 15:28:27 +08:00
xiaoxia 20b593dcf8 refactor(editing-planner): 拆分 CoverSelector 组件,抽离 Hook + 模式面板 (#1010) 2026-07-27 15:28:27 +08:00
xiaoxia bc37ce2ee7 fix(test): 修复test_agent_docs.py相对路径问题,Unit Tests CI从apps/api运行也能通过 (#989) 2026-07-27 15:28:17 +08:00
54 changed files with 2094 additions and 1641 deletions
-1
View File
@@ -12,7 +12,6 @@
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional
+23 -239
View File
@@ -3,137 +3,29 @@
* 风险评估 + 基本信息 + 检测项列表 + 匹配片段
* 零 antd 依赖
*/
import React, { useState, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Button, Tag, Tooltip } from "@/components/ui"
import { useParams, useNavigate } from "react-router-dom"
import { getDuplicationDetail, retryDuplication, type DuplicateSegment } from "@/api/duplication"
import "./duplication.css"
import React from "react"
import { Button, Tag } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
/** 格式化时间(秒 → mm:ss */
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
}
/** 格式化文件大小 */
const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
/** 格式化时长 */
const formatDuration = (seconds?: number) => {
if (!seconds) return "-"
const totalSec = Math.round(seconds)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return m > 0 ? `${m}${s}` : `${s}`
}
/** 根据查重率获取风险等级 */
const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
if (rate === undefined) return "low"
if (rate <= 10) return "low"
if (rate <= 30) return "medium"
return "high"
}
/** 风险等级描述 */
const RISK_DESC: Record<string, string> = {
low: "查重率较低,内容原创度高",
medium: "存在一定重复,建议修改部分片段",
high: "重复率较高,建议大幅修改或替换",
}
/** 风险等级标签变体 */
const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
low: "success",
medium: "warning",
high: "error",
}
/** 风险等级文字 */
const RISK_LABEL: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
}
/** 简易 toast */
interface ToastState {
message: string
type: "success" | "error" | "warning"
}
/** 单个重复片段卡片 */
const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
segment,
index,
}) => {
const sourceDuration = segment.source_end - segment.source_start
const matchedDuration = segment.matched_end - segment.matched_start
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
return (
<div className="dup-check-item">
<div className="dup-check-icon">🎬</div>
<div className="dup-check-body">
<h4>
{index + 1}{segment.matched_video_name}
</h4>
<p>
{formatTime(segment.source_start)} - {formatTime(segment.source_end)}
{sourceDuration.toFixed(0)}s {formatTime(segment.matched_start)} -{" "}
{formatTime(segment.matched_end)}{matchedDuration.toFixed(0)}s
</p>
</div>
<div className={`dup-check-bar`}>
<div
className={`dup-check-bar-fill ${riskLevel}`}
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
/>
</div>
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
</div>
)
}
import { RiskCard } from "./components/RiskCard"
import { InfoCard } from "./components/InfoCard"
import { SegmentsSection } from "./components/SegmentsSection"
import { useDuplicationDetail } from "./hooks/useDuplicationDetail"
import { RISK_TAG_VARIANT, RISK_LABEL } from "./constants"
import { formatSize, formatDuration } from "./utils"
import "./duplication.css"
const DuplicationDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [toast, setToast] = useState<ToastState | null>(null)
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
const {
data: detail,
detail,
isLoading,
isError,
} = useQuery({
queryKey: ["duplication-detail", id],
queryFn: () => getDuplicationDetail(id!),
enabled: !!id,
})
// 重新查重
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
showToast("已重新提交查重", "success")
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] })
},
onError: () => {
showToast("重新查重失败", "error")
},
})
toast,
riskLevel,
similarityPercent,
handleRetry,
handleDownloadReport,
handleBack,
} = useDuplicationDetail()
if (isLoading) {
return (
@@ -155,7 +47,7 @@ const DuplicationDetail: React.FC = () => {
<Button
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/app/duplication/results")}
onClick={handleBack}
style={{ marginTop: 16 }}
>
@@ -165,10 +57,6 @@ const DuplicationDetail: React.FC = () => {
)
}
const riskLevel = getRiskLevel(detail.duplicate_rate)
const similarityPercent =
detail.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—"
return (
<div className="dup-page">
{/* Toast */}
@@ -194,21 +82,11 @@ const DuplicationDetail: React.FC = () => {
}
actions={
<div className="dup-detail-actions" style={{ display: "flex", gap: 8 }}>
<Button
buttonType="secondary"
buttonSize="md"
onClick={() => {
showToast("报告下载功能开发中", "warning")
}}
>
<Button buttonType="secondary" buttonSize="md" onClick={handleDownloadReport}>
📥
</Button>
{detail.status === "failed" && (
<Button
buttonType="primary"
buttonSize="md"
onClick={() => retryMutation.mutate(detail.id)}
>
<Button buttonType="primary" buttonSize="md" onClick={handleRetry}>
🔄
</Button>
)}
@@ -218,103 +96,9 @@ const DuplicationDetail: React.FC = () => {
{/* 内容网格 */}
<div className="dup-detail-grid">
{/* 风险评估卡片 */}
<div className="dup-risk-card">
<h3>📊 </h3>
<div className={`dup-risk-circle ${riskLevel}`}>
<span className="dup-risk-value">{similarityPercent}%</span>
<span className="dup-risk-label"></span>
</div>
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
</div>
{/* 基本信息卡片 */}
<div className="dup-info-detail-card">
<h3>📋 </h3>
<div className="dup-info-rows">
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<Tooltip title={detail.filename}>
<span
className="dup-info-row-value"
style={{
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{detail.filename}
</span>
</Tooltip>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
<Tag
variant={
detail.status === "completed"
? "success"
: detail.status === "failed"
? "error"
: detail.status === "processing"
? "warning"
: "info"
}
>
{detail.status === "completed"
? "✅ 已完成"
: detail.status === "failed"
? "❌ 失败"
: detail.status === "processing"
? "🔄 查重中"
: "⏳ 等待中"}
</Tag>
</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} </span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
{new Date(detail.created_at).toLocaleString("zh-CN")}
</span>
</div>
</div>
</div>
{/* 检测项列表 */}
<div className="dup-checks-section">
<h3>
🔍
<Tag variant="primary" style={{ marginLeft: 8 }}>
{detail.segments?.length ?? 0}
</Tag>
</h3>
{detail.segments && detail.segments.length > 0 ? (
<div className="dup-checks-list">
{detail.segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</div>
) : (
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
<p></p>
</div>
)}
</div>
<RiskCard riskLevel={riskLevel} similarityPercent={similarityPercent} />
<InfoCard detail={detail} />
<SegmentsSection segments={detail.segments} />
</div>
</div>
)
@@ -2,130 +2,27 @@
* 查重结果列表页面 — V21 设计系统
* 胶囊筛选 + 卡片列表,零 antd 依赖
*/
import React, { useState, useMemo } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Button, Tag, Tooltip } from "@/components/ui"
import { useNavigate } from "react-router-dom"
import {
getDuplicationRecords,
deleteDuplicationRecord,
retryDuplication,
type DuplicationStatus,
} from "@/api/duplication"
import "./duplication.css"
import React from "react"
import { Button } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
/** 风险等级分类 */
type RiskFilter = "all" | "high" | "medium" | "low"
/** 状态配置 */
const STATUS_CONFIG: Record<
DuplicationStatus,
{
variant: "primary" | "warning" | "success" | "error"
text: string
icon: string
}
> = {
pending: { variant: "primary", text: "等待中", icon: "⏳" },
processing: { variant: "warning", text: "查重中", icon: "🔄" },
completed: { variant: "success", text: "已完成", icon: "✅" },
failed: { variant: "error", text: "失败", icon: "❌" },
}
/** 根据查重率获取风险等级 */
const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
if (rate === undefined) return "low"
if (rate <= 10) return "low"
if (rate <= 30) return "medium"
return "high"
}
/** 风险等级标签 */
const RISK_LABELS: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
}
/** 格式化文件大小 */
const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
/** 格式化时长 */
const formatDuration = (seconds?: number) => {
if (!seconds) return "-"
const totalSec = Math.round(seconds)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return m > 0 ? `${m}${s}` : `${s}`
}
/** 简易 toast */
interface ToastState {
message: string
type: "success" | "error" | "warning"
}
const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "low", label: "低风险" },
{ key: "medium", label: "中风险" },
{ key: "high", label: "高风险" },
]
import "./duplication.css"
import useDuplicationResults from "./hooks/useDuplicationResults"
import FilterBar from "./components/FilterBar"
import EmptyState from "./components/EmptyState"
import ResultCard from "./components/ResultCard"
const DuplicationResults: React.FC = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [riskFilter, setRiskFilter] = useState<RiskFilter>("all")
const [toast, setToast] = useState<ToastState | null>(null)
const showToast = (message: string, type: "success" | "error" | "warning") => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}
// 获取查重记录
const { data: records = [], isLoading } = useQuery({
queryKey: ["duplication-records"],
queryFn: getDuplicationRecords,
})
// 删除
const deleteMutation = useMutation({
mutationFn: deleteDuplicationRecord,
onSuccess: () => {
showToast("已删除", "success")
queryClient.invalidateQueries({ queryKey: ["duplication-records"] })
},
onError: () => {
showToast("删除失败", "error")
},
})
// 重新查重
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
showToast("已重新提交查重", "success")
queryClient.invalidateQueries({ queryKey: ["duplication-records"] })
},
onError: () => {
showToast("重新查重失败", "error")
},
})
/** 按风险等级筛选 */
const filteredRecords = useMemo(() => {
if (riskFilter === "all") return records
return records.filter((r) => {
if (r.status !== "completed") return riskFilter === "low"
return getRiskLevel(r.duplicate_rate) === riskFilter
})
}, [records, riskFilter])
const {
isLoading,
filteredRecords,
riskFilter,
setRiskFilter,
toast,
handleDelete,
handleRetry,
handleView,
handleUpload,
} = useDuplicationResults()
return (
<div className="dup-page">
@@ -136,136 +33,31 @@ const DuplicationResults: React.FC = () => {
title="查重记录"
actions={
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{/* 筛选胶囊 */}
<div className="dup-filter">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.key}
className={`dup-filter-btn ${riskFilter === opt.key ? "active" : ""}`}
onClick={() => setRiskFilter(opt.key)}
>
{opt.label}
</button>
))}
</div>
<Button
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/app/duplication")}
>
<FilterBar value={riskFilter} onChange={setRiskFilter} />
<Button buttonType="primary" buttonSize="md" onClick={handleUpload}>
📤
</Button>
</div>
}
/>
{/* 加载中 */}
{isLoading && (
<div className="dup-results-empty">
<div className="dup-results-empty-icon"></div>
<p>...</p>
</div>
)}
{/* 空状态 */}
{!isLoading && filteredRecords.length === 0 && (
<div className="dup-results-empty">
<div className="dup-results-empty-icon">📭</div>
<p>
{riskFilter === "all"
? "暂无查重记录,上传视频开始查重吧"
: `没有${RISK_LABELS[riskFilter]}的记录`}
</p>
</div>
{/* 加载中 / 空状态 */}
{(isLoading || filteredRecords.length === 0) && (
<EmptyState isLoading={isLoading} riskFilter={riskFilter} />
)}
{/* 结果卡片列表 */}
{!isLoading && filteredRecords.length > 0 && (
<div className="dup-results-list">
{filteredRecords.map((record) => {
const statusCfg = STATUS_CONFIG[record.status]
const riskLevel = getRiskLevel(record.duplicate_rate)
const rateValue = record.duplicate_rate
return (
<div
key={record.id}
className="dup-result-card"
onClick={() => {
if (record.status === "completed") {
navigate(`/duplication/${record.id}`)
}
}}
>
{/* 缩略图 */}
<div className="dup-result-card-thumb">🎬</div>
{/* 信息区 */}
<div className="dup-result-card-body">
<h4>{record.filename}</h4>
<div className="dup-result-card-meta">
<Tag variant={statusCfg.variant}>
{statusCfg.icon} {statusCfg.text}
</Tag>
<span>{formatSize(record.file_size)}</span>
<span>{formatDuration(record.duration_seconds)}</span>
<span>{new Date(record.created_at).toLocaleDateString("zh-CN")}</span>
{record.status === "completed" && record.duplicate_count !== undefined && (
<span>{record.duplicate_count} </span>
)}
</div>
</div>
{/* 查重率 */}
<div className="dup-result-card-score">
{record.status === "completed" && rateValue !== undefined ? (
<>
<div className="dup-score-bar">
<div
className={`dup-score-bar-fill ${riskLevel}`}
style={{ width: `${Math.min(rateValue, 100)}%` }}
/>
</div>
<span className={`dup-score-value ${riskLevel}`}>
{rateValue.toFixed(1)}%
</span>
</>
) : record.status === "failed" ? (
<Tooltip title="重新查重">
<Button
buttonType="ghost"
buttonSize="sm"
onClick={(e) => {
e.stopPropagation()
retryMutation.mutate(record.id)
}}
>
🔄
</Button>
</Tooltip>
) : (
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
{record.status === "processing" ? "分析中..." : "—"}
</span>
)}
</div>
{/* 删除按钮 */}
<Button
buttonType="ghost"
buttonSize="sm"
onClick={(e) => {
e.stopPropagation()
if (window.confirm("确定删除此记录?")) {
deleteMutation.mutate(record.id)
}
}}
>
🗑
</Button>
</div>
)
})}
{filteredRecords.map((record) => (
<ResultCard
key={record.id}
record={record}
onView={handleView}
onDelete={handleDelete}
onRetry={handleRetry}
/>
))}
</div>
)}
</div>
@@ -0,0 +1,32 @@
import React from "react"
import { RISK_LABELS } from "../constants"
import type { RiskFilter } from "../types"
interface EmptyStateProps {
isLoading: boolean
riskFilter: RiskFilter
}
const EmptyState: React.FC<EmptyStateProps> = ({ isLoading, riskFilter }) => {
if (isLoading) {
return (
<div className="dup-results-empty">
<div className="dup-results-empty-icon"></div>
<p>...</p>
</div>
)
}
return (
<div className="dup-results-empty">
<div className="dup-results-empty-icon">📭</div>
<p>
{riskFilter === "all"
? "暂无查重记录,上传视频开始查重吧"
: `没有${RISK_LABELS[riskFilter]}的记录`}
</p>
</div>
)
}
export default EmptyState
@@ -0,0 +1,26 @@
import React from "react"
import type { RiskFilter } from "../types"
import { FILTER_OPTIONS } from "../constants"
interface FilterBarProps {
value: RiskFilter
onChange: (value: RiskFilter) => void
}
const FilterBar: React.FC<FilterBarProps> = ({ value, onChange }) => {
return (
<div className="dup-filter">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.key}
className={`dup-filter-btn ${value === opt.key ? "active" : ""}`}
onClick={() => onChange(opt.key)}
>
{opt.label}
</button>
))}
</div>
)
}
export default FilterBar
@@ -0,0 +1,72 @@
import React from "react"
import { Tag, Tooltip } from "@/components/ui"
import { formatSize, formatDuration } from "../utils"
import type { DuplicationDetail } from "@/api/duplication"
interface InfoCardProps {
detail: DuplicationDetail
}
/**
* 基本信息卡片
*/
export const InfoCard: React.FC<InfoCardProps> = ({ detail }) => {
const statusMap: Record<string, { text: string; variant: string }> = {
completed: { text: "✅ 已完成", variant: "success" },
failed: { text: "❌ 失败", variant: "error" },
processing: { text: "🔄 查重中", variant: "warning" },
pending: { text: "⏳ 等待中", variant: "info" },
}
const status = statusMap[detail.status] || {
text: detail.status,
variant: "info",
}
return (
<div className="dup-info-detail-card">
<h3>📋 </h3>
<div className="dup-info-rows">
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<Tooltip title={detail.filename}>
<span
className="dup-info-row-value"
style={{
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{detail.filename}
</span>
</Tooltip>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
<Tag variant={status.variant as "success"}>{status.text}</Tag>
</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} </span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
{new Date(detail.created_at).toLocaleString("zh-CN")}
</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,95 @@
import React from "react"
import { Button, Tag, Tooltip } from "@/components/ui"
import type { DuplicationRecord } from "@/api/duplication"
import { STATUS_CONFIG } from "../constants"
import { getRiskLevel, formatSize, formatDuration } from "../utils"
interface ResultCardProps {
record: DuplicationRecord
onView: (id: string) => void
onDelete: (id: string) => void
onRetry: (id: string) => void
}
const ResultCard: React.FC<ResultCardProps> = ({ record, onView, onDelete, onRetry }) => {
const statusCfg = STATUS_CONFIG[record.status]
const riskLevel = getRiskLevel(record.duplicate_rate)
const rateValue = record.duplicate_rate
const handleClick = () => {
if (record.status === "completed") {
onView(record.id)
}
}
return (
<div key={record.id} className="dup-result-card" onClick={handleClick}>
{/* 缩略图 */}
<div className="dup-result-card-thumb">🎬</div>
{/* 信息区 */}
<div className="dup-result-card-body">
<h4>{record.filename}</h4>
<div className="dup-result-card-meta">
<Tag variant={statusCfg.variant}>
{statusCfg.icon} {statusCfg.text}
</Tag>
<span>{formatSize(record.file_size)}</span>
<span>{formatDuration(record.duration_seconds)}</span>
<span>{new Date(record.created_at).toLocaleDateString("zh-CN")}</span>
{record.status === "completed" && record.duplicate_count !== undefined && (
<span>{record.duplicate_count} </span>
)}
</div>
</div>
{/* 查重率 */}
<div className="dup-result-card-score">
{record.status === "completed" && rateValue !== undefined ? (
<>
<div className="dup-score-bar">
<div
className={`dup-score-bar-fill ${riskLevel}`}
style={{ width: `${Math.min(rateValue, 100)}%` }}
/>
</div>
<span className={`dup-score-value ${riskLevel}`}>{rateValue.toFixed(1)}%</span>
</>
) : record.status === "failed" ? (
<Tooltip title="重新查重">
<Button
buttonType="ghost"
buttonSize="sm"
onClick={(e) => {
e.stopPropagation()
onRetry(record.id)
}}
>
🔄
</Button>
</Tooltip>
) : (
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
{record.status === "processing" ? "分析中..." : "—"}
</span>
)}
</div>
{/* 删除按钮 */}
<Button
buttonType="ghost"
buttonSize="sm"
onClick={(e) => {
e.stopPropagation()
if (window.confirm("确定删除此记录?")) {
onDelete(record.id)
}
}}
>
🗑
</Button>
</div>
)
}
export default ResultCard
@@ -0,0 +1,21 @@
import React from "react"
import { RISK_DESC } from "../constants"
interface RiskCardProps {
riskLevel: string
similarityPercent: string
}
/**
* 风险评估卡片
*/
export const RiskCard: React.FC<RiskCardProps> = ({ riskLevel, similarityPercent }) => (
<div className="dup-risk-card">
<h3>📊 </h3>
<div className={`dup-risk-circle ${riskLevel}`}>
<span className="dup-risk-value">{similarityPercent}%</span>
<span className="dup-risk-label"></span>
</div>
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
</div>
)
@@ -0,0 +1,40 @@
import React from "react"
import type { DuplicateSegment } from "@/api/duplication"
import { formatTime } from "../utils"
interface SegmentCardProps {
segment: DuplicateSegment
index: number
}
/**
* 单个重复片段卡片
*/
export const SegmentCard: React.FC<SegmentCardProps> = ({ segment, index }) => {
const sourceDuration = segment.source_end - segment.source_start
const matchedDuration = segment.matched_end - segment.matched_start
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
return (
<div className="dup-check-item">
<div className="dup-check-icon">🎬</div>
<div className="dup-check-body">
<h4>
{index + 1}{segment.matched_video_name}
</h4>
<p>
{formatTime(segment.source_start)} - {formatTime(segment.source_end)}
{sourceDuration.toFixed(0)}s {formatTime(segment.matched_start)} -{" "}
{formatTime(segment.matched_end)}{matchedDuration.toFixed(0)}s
</p>
</div>
<div className={`dup-check-bar`}>
<div
className={`dup-check-bar-fill ${riskLevel}`}
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
/>
</div>
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
</div>
)
}
@@ -0,0 +1,35 @@
import React from "react"
import { Tag } from "@/components/ui"
import type { DuplicateSegment } from "@/api/duplication"
import { SegmentCard } from "./SegmentCard"
interface SegmentsSectionProps {
segments?: DuplicateSegment[]
}
/**
* 重复片段列表区域
*/
export const SegmentsSection: React.FC<SegmentsSectionProps> = ({ segments = [] }) => (
<div className="dup-checks-section">
<h3>
🔍
<Tag variant="primary" style={{ marginLeft: 8 }}>
{segments.length}
</Tag>
</h3>
{segments.length > 0 ? (
<div className="dup-checks-list">
{segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</div>
) : (
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
<p></p>
</div>
)}
</div>
)
@@ -0,0 +1,58 @@
import type { DuplicationStatus } from "@/api/duplication"
import type { RiskFilter } from "./types"
/** 状态配置 */
export const STATUS_CONFIG: Record<
DuplicationStatus,
{
variant: "primary" | "warning" | "success" | "error"
text: string
icon: string
}
> = {
pending: { variant: "primary", text: "等待中", icon: "⏳" },
processing: { variant: "warning", text: "查重中", icon: "🔄" },
completed: { variant: "success", text: "已完成", icon: "✅" },
failed: { variant: "error", text: "失败", icon: "❌" },
}
/** 风险等级描述 */
export const RISK_DESC: Record<string, string> = {
low: "查重率较低,内容原创度高",
medium: "存在一定重复,建议修改部分片段",
high: "重复率较高,建议大幅修改或替换",
}
/** 风险等级标签变体 */
export const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
low: "success",
medium: "warning",
high: "error",
}
/** 风险等级文字(详情页用) */
export const RISK_LABEL: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
}
/** 风险等级标签(列表页用) */
export const RISK_LABELS: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
}
export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "low", label: "低风险" },
{ key: "medium", label: "中风险" },
{ key: "high", label: "高风险" },
]
/** Toast 类型 */
export interface ToastState {
message: string
type: "success" | "error" | "warning"
}
@@ -0,0 +1,77 @@
import { useState, useCallback } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getDuplicationDetail, retryDuplication } from "@/api/duplication"
import type { ToastState } from "../constants"
import { getRiskLevel } from "../utils"
/**
* 查重详情业务 Hook
*/
export const useDuplicationDetail = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [toast, setToast] = useState<ToastState | null>(null)
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
const {
data: detail,
isLoading,
isError,
} = useQuery({
queryKey: ["duplication-detail", id],
queryFn: () => getDuplicationDetail(id!),
enabled: !!id,
})
// 重新查重
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
showToast("已重新提交查重", "success")
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] })
},
onError: () => {
showToast("重新查重失败", "error")
},
})
const handleRetry = useCallback(() => {
if (!detail) return
retryMutation.mutate(detail.id)
}, [detail, retryMutation])
const handleDownloadReport = useCallback(() => {
showToast("报告下载功能开发中", "warning")
}, [showToast])
const handleBack = useCallback(() => {
navigate("/app/duplication/results")
}, [navigate])
const riskLevel = detail ? getRiskLevel(detail.duplicate_rate) : "low"
const similarityPercent =
detail?.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—"
return {
// 数据
detail,
isLoading,
isError,
// 状态
toast,
riskLevel,
similarityPercent,
retryLoading: retryMutation.isPending,
// 操作
showToast,
handleRetry,
handleDownloadReport,
handleBack,
}
}
@@ -0,0 +1,106 @@
import { useState, useMemo } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom"
import {
getDuplicationRecords,
deleteDuplicationRecord,
retryDuplication,
type DuplicationRecord,
} from "@/api/duplication"
import type { RiskFilter, ToastState } from "../types"
import { getRiskLevel } from "../utils"
interface UseDuplicationResultsReturn {
records: DuplicationRecord[]
isLoading: boolean
filteredRecords: DuplicationRecord[]
riskFilter: RiskFilter
setRiskFilter: (filter: RiskFilter) => void
toast: ToastState | null
handleDelete: (id: string) => void
handleRetry: (id: string) => void
handleView: (id: string) => void
handleUpload: () => void
}
const useDuplicationResults = (): UseDuplicationResultsReturn => {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [riskFilter, setRiskFilter] = useState<RiskFilter>("all")
const [toast, setToast] = useState<ToastState | null>(null)
const showToast = (message: string, type: "success" | "error" | "warning") => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}
// 获取查重记录
const { data: records = [], isLoading } = useQuery({
queryKey: ["duplication-records"],
queryFn: getDuplicationRecords,
})
// 删除
const deleteMutation = useMutation({
mutationFn: deleteDuplicationRecord,
onSuccess: () => {
showToast("已删除", "success")
queryClient.invalidateQueries({ queryKey: ["duplication-records"] })
},
onError: () => {
showToast("删除失败", "error")
},
})
// 重新查重
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
showToast("已重新提交查重", "success")
queryClient.invalidateQueries({ queryKey: ["duplication-records"] })
},
onError: () => {
showToast("重新查重失败", "error")
},
})
/** 按风险等级筛选 */
const filteredRecords = useMemo(() => {
if (riskFilter === "all") return records
return records.filter((r) => {
if (r.status !== "completed") return riskFilter === "low"
return getRiskLevel(r.duplicate_rate) === riskFilter
})
}, [records, riskFilter])
const handleDelete = (id: string) => {
deleteMutation.mutate(id)
}
const handleRetry = (id: string) => {
retryMutation.mutate(id)
}
const handleView = (id: string) => {
navigate(`/duplication/${id}`)
}
const handleUpload = () => {
navigate("/app/duplication")
}
return {
records,
isLoading,
filteredRecords,
riskFilter,
setRiskFilter,
toast,
handleDelete,
handleRetry,
handleView,
handleUpload,
}
}
export default useDuplicationResults
+8
View File
@@ -0,0 +1,8 @@
/** 风险等级分类 */
export type RiskFilter = "all" | "high" | "medium" | "low"
/** 简易 toast */
export interface ToastState {
message: string
type: "success" | "error" | "warning"
}
+30
View File
@@ -0,0 +1,30 @@
/** 根据查重率获取风险等级 */
export const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
if (rate === undefined) return "low"
if (rate <= 10) return "low"
if (rate <= 30) return "medium"
return "high"
}
/** 格式化时间(秒 → mm:ss */
export const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
}
/** 格式化文件大小 */
export const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
/** 格式化时长 */
export const formatDuration = (seconds?: number) => {
if (!seconds) return "-"
const totalSec = Math.round(seconds)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return m > 0 ? `${m}${s}` : `${s}`
}
@@ -1,286 +1,5 @@
/**
* 封面选择器
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
* 封面选择器入口(向后兼容)
* 实际实现位于 ./cover-selector/ 目录
*/
import React, { useCallback, useRef, useState } from "react"
import { Drawer } from "antd"
import type { CoverConfig, CoverMode } from "../types"
import { DEFAULT_COVER_CONFIG } from "../types"
interface CoverSelectorProps {
open: boolean
onClose: () => void
config: CoverConfig
onChange: (config: CoverConfig) => void
totalDuration: number
}
/** 封面模式标签 */
const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
}
/** 封面模式图标 */
const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
}
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial })
},
[config, onChange],
)
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
/** 切换模式 */
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode })
},
[update],
)
/** 处理文件上传 */
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
const url = e.target?.result as string
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
}
reader.readAsDataURL(file)
},
[update],
)
/** 拖拽上传 */
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFileUpload(file)
},
[handleFileUpload],
)
/** 使用 AI 推荐时间 */
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" })
}
}, [config.ai_suggested_time, update])
/** 格式化时间 */
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{/* 智能封面 */}
{config.mode === "auto" && (
<div className="cover-auto-section">
<div className="cover-auto-desc">
AI
</div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">
{formatTime(config.ai_suggested_time)}
</div>
<button className="cover-auto-use-btn" onClick={handleUseAiSuggestion}>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)}
{/* 抽帧选封面 */}
{config.mode === "frame" && (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => update({ frame_time: Number(e.target.value) })}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
{/* 快捷时间点 */}
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button
key={ratio}
className="cover-quick-btn"
onClick={() => update({ frame_time: t })}
>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)}
{/* 上传封面 */}
{config.mode === "upload" && (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text"></span>
<span className="cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFileUpload(file)
}}
/>
</div>
</div>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
)
}
export default CoverSelector
export { default } from "./cover-selector"
@@ -1,266 +1,5 @@
/**
* 片段详情区块
* ClipDetailSection 入口(向后兼容)
* 实际实现位于 ./clip-detail-section/ 目录
*/
import React from "react"
import { useNavigate } from "react-router-dom"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import type { ClipData, ClipType } from "@/pages/editing-planner/types"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties"
import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties"
import type { AssetItem } from "@/api/assets"
import type { TemplateMode } from "@/api/editing-planner"
interface ClipDetailSectionProps {
clip: ClipData
currentMode: TemplateMode
voiceMaterials?: AssetItem[]
voiceMaterialsLoading?: boolean
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
onRefreshVoiceMaterials?: () => void
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
onOpenTransitionDrawer?: (clipId: string) => void
onOpenSpeedDrawer?: (clipId: string) => void
onOpenTtsDrawer?: (clipId: string) => void
previewingId: string | null
onPreviewVoice: (asset: AssetItem) => void
onStopPreview: () => void
}
const ClipDetailSection: React.FC<ClipDetailSectionProps> = ({
clip,
currentMode,
voiceMaterials = [],
voiceMaterialsLoading = false,
onClipUpdate,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
previewingId,
onPreviewVoice,
onStopPreview,
}) => {
const navigate = useNavigate()
const isTypeDisabled = (t: ClipType) => {
if (currentMode === "pip") return t !== "pip"
if (currentMode === "voice_over") return t !== "voice"
return false
}
const transitionLabel = (() => {
const t = clip.transition
if (!t || t.type === "none") return "无转场"
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
})()
const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x"
const ttsLabel = (() => {
const tts = clip.tts_config
if (!tts || tts.mode === "none") return "无配音"
if (tts.mode === "upload") return "上传配音"
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
})()
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled = isTypeDisabled(t)
return (
<button
key={t}
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={clip.duration}
onChange={(e) =>
onClipUpdate(clip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{transitionLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{speedLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">{ttsLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={clip.startOffset}
onChange={(e) =>
onClipUpdate(clip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
{/* 配音素材选择 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={clip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(clip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(clip.id, asset)
}
onStopPreview()
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{clip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
if (asset) onPreviewVoice(asset)
}}
>
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
)
}
export default ClipDetailSection
export { default } from "./clip-detail-section"
@@ -0,0 +1,85 @@
import React from "react"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import type { ClipData } from "@/pages/editing-planner/types"
interface AdvancedEntriesProps {
clip: ClipData
onOpenTransitionDrawer?: (clipId: string) => void
onOpenSpeedDrawer?: (clipId: string) => void
onOpenTtsDrawer?: (clipId: string) => void
}
/**
* 高级功能入口按钮(转场/调速/TTS)
*/
export const AdvancedEntries: React.FC<AdvancedEntriesProps> = ({
clip,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
}) => {
const transitionLabel = (() => {
const t = clip.transition
if (!t || t.type === "none") return "无转场"
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
})()
const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x"
const ttsLabel = (() => {
const tts = clip.tts_config
if (!tts || tts.mode === "none") return "无配音"
if (tts.mode === "upload") return "上传配音"
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
})()
return (
<>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{transitionLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{speedLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">{ttsLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
</>
)
}
@@ -0,0 +1,69 @@
import React from "react"
import type { ClipData, ClipType } from "@/pages/editing-planner/types"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties"
import type { TemplateMode } from "@/api/editing-planner"
interface ClipTypeAndDurationProps {
clip: ClipData
currentMode: TemplateMode
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
}
/**
* 片段类型选择 + 时长设置
*/
export const ClipTypeAndDuration: React.FC<ClipTypeAndDurationProps> = ({
clip,
currentMode,
onClipUpdate,
}) => {
const isTypeDisabled = (t: ClipType) => {
if (currentMode === "pip") return t !== "pip"
if (currentMode === "voice_over") return t !== "voice"
return false
}
return (
<>
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled = isTypeDisabled(t)
return (
<button
key={t}
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={clip.duration}
onChange={(e) =>
onClipUpdate(clip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
</>
)
}
@@ -0,0 +1,132 @@
import React from "react"
import { useNavigate } from "react-router-dom"
import type { ClipData } from "@/pages/editing-planner/types"
import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties"
import type { AssetItem } from "@/api/assets"
interface VoiceMaterialSectionProps {
clip: ClipData
voiceMaterials: AssetItem[]
voiceMaterialsLoading: boolean
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
onRefreshVoiceMaterials?: () => void
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
previewingId: string | null
onPreviewVoice: (asset: AssetItem) => void
onStopPreview: () => void
}
/**
* 配音素材选择区(仅 voice 类型显示)
*/
export const VoiceMaterialSection: React.FC<VoiceMaterialSectionProps> = ({
clip,
voiceMaterials,
voiceMaterialsLoading,
onClipUpdate,
onRefreshVoiceMaterials,
onClipVoiceSelect,
previewingId,
onPreviewVoice,
onStopPreview,
}) => {
const navigate = useNavigate()
return (
<>
{/* 素材起始时间 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={clip.startOffset}
onChange={(e) =>
onClipUpdate(clip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 配音素材选择 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={clip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(clip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(clip.id, asset)
}
onStopPreview()
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{clip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
if (asset) onPreviewVoice(asset)
}}
>
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button className="ep-voice-upload-btn" onClick={() => navigate("/app/voice-materials")}>
+
</button>
</div>
</>
)
}
@@ -0,0 +1,78 @@
/**
* 片段详情区块
*/
import React from "react"
import type { ClipData } from "@/pages/editing-planner/types"
import type { AssetItem } from "@/api/assets"
import type { TemplateMode } from "@/api/editing-planner"
import { ClipTypeAndDuration } from "./ClipTypeAndDuration"
import { AdvancedEntries } from "./AdvancedEntries"
import { VoiceMaterialSection } from "./VoiceMaterialSection"
interface ClipDetailSectionProps {
clip: ClipData
currentMode: TemplateMode
voiceMaterials?: AssetItem[]
voiceMaterialsLoading?: boolean
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
onRefreshVoiceMaterials?: () => void
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
onOpenTransitionDrawer?: (clipId: string) => void
onOpenSpeedDrawer?: (clipId: string) => void
onOpenTtsDrawer?: (clipId: string) => void
previewingId: string | null
onPreviewVoice: (asset: AssetItem) => void
onStopPreview: () => void
}
const ClipDetailSection: React.FC<ClipDetailSectionProps> = ({
clip,
currentMode,
voiceMaterials = [],
voiceMaterialsLoading = false,
onClipUpdate,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
previewingId,
onPreviewVoice,
onStopPreview,
}) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
<ClipTypeAndDuration clip={clip} currentMode={currentMode} onClipUpdate={onClipUpdate} />
<AdvancedEntries
clip={clip}
onOpenTransitionDrawer={onOpenTransitionDrawer}
onOpenSpeedDrawer={onOpenSpeedDrawer}
onOpenTtsDrawer={onOpenTtsDrawer}
/>
{clip.type === "voice" && (
<VoiceMaterialSection
clip={clip}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onClipUpdate={onClipUpdate}
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
onClipVoiceSelect={onClipVoiceSelect}
previewingId={previewingId}
onPreviewVoice={onPreviewVoice}
onStopPreview={onStopPreview}
/>
)}
</div>
</div>
)
}
export default ClipDetailSection
@@ -0,0 +1,143 @@
import React from "react"
import type { CoverConfig } from "../../types"
interface CoverAutoModeProps {
config: CoverConfig
formatTime: (s: number) => string
onUseAiSuggestion: () => void
}
/** 智能封面模式面板 */
export const CoverAutoMode: React.FC<CoverAutoModeProps> = ({
config,
formatTime,
onUseAiSuggestion,
}) => (
<div className="cover-auto-section">
<div className="cover-auto-desc">AI </div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">{formatTime(config.ai_suggested_time)}</div>
<button className="cover-auto-use-btn" onClick={onUseAiSuggestion}>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)
interface CoverFrameModeProps {
config: CoverConfig
totalDuration: number
formatTime: (s: number) => string
onFrameTimeChange: (time: number) => void
}
/** 抽帧选封面模式面板 */
export const CoverFrameMode: React.FC<CoverFrameModeProps> = ({
config,
totalDuration,
formatTime,
onFrameTimeChange,
}) => (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button key={ratio} className="cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)
interface CoverUploadModeProps {
config: CoverConfig
isDragging: boolean
fileInputRef: React.RefObject<HTMLInputElement>
onDragOver: (e: React.DragEvent) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent) => void
onAreaClick: () => void
onFileChange: (file: File) => void
}
/** 上传封面模式面板 */
export const CoverUploadMode: React.FC<CoverUploadModeProps> = ({
config,
isDragging,
fileInputRef,
onDragOver,
onDragLeave,
onDrop,
onAreaClick,
onFileChange,
}) => (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={onAreaClick}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text"></span>
<span className="cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) onFileChange(file)
}}
/>
</div>
</div>
)
@@ -0,0 +1,146 @@
/**
* 封面选择器
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
*/
import React from "react"
import { Drawer } from "antd"
import type { CoverConfig, CoverMode } from "../../types"
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
interface CoverSelectorProps {
open: boolean
onClose: () => void
config: CoverConfig
onChange: (config: CoverConfig) => void
totalDuration: number
}
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const {
fileInputRef,
isDragging,
setIsDragging,
update,
handleReset,
handleModeChange,
handleFileUpload,
handleDrop,
handleUseAiSuggestion,
formatTime,
} = useCoverSelector({ config, onChange })
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{config.mode === "auto" && (
<CoverAutoMode
config={config}
formatTime={formatTime}
onUseAiSuggestion={handleUseAiSuggestion}
/>
)}
{config.mode === "frame" && (
<CoverFrameMode
config={config}
totalDuration={totalDuration}
formatTime={formatTime}
onFrameTimeChange={(t) => update({ frame_time: t })}
/>
)}
{config.mode === "upload" && (
<CoverUploadMode
config={config}
isDragging={isDragging}
fileInputRef={fileInputRef}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onAreaClick={() => fileInputRef.current?.click()}
onFileChange={handleFileUpload}
/>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
)
}
export default CoverSelector
@@ -0,0 +1,98 @@
import { useCallback, useRef, useState } from "react"
import type { CoverConfig, CoverMode } from "../../types"
import { DEFAULT_COVER_CONFIG } from "../../types"
/** 封面模式标签 */
export const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
}
/** 封面模式图标 */
export const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
}
interface UseCoverSelectorOptions {
config: CoverConfig
onChange: (config: CoverConfig) => void
}
/**
* 封面选择器 Hook
* 封装状态管理、文件上传、模式切换等逻辑
*/
export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial })
},
[config, onChange],
)
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode })
},
[update],
)
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
const url = e.target?.result as string
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
}
reader.readAsDataURL(file)
},
[update],
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFileUpload(file)
},
[handleFileUpload],
)
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" })
}
}, [config.ai_suggested_time, update])
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}
return {
fileInputRef,
isDragging,
setIsDragging,
update,
handleReset,
handleModeChange,
handleFileUpload,
handleDrop,
handleUseAiSuggestion,
formatTime,
}
}
+3 -284
View File
@@ -1,286 +1,5 @@
/**
* 混剪单图层配置区
* LayerConfig 入口(向后兼容)
* 实际实现位于 ./layer-config/ 目录
*/
import React from "react"
import type {
PipLayer,
PipAnimType,
PipSlideDirection,
PipGridPosition,
} from "@/pages/editing-planner/types"
import {
GRID_POSITIONS,
ANIM_OPTIONS,
SLIDE_DIR_OPTIONS,
LAYER_COLORS,
} from "@/pages/editing-planner/constants/pipConfig"
interface LayerConfigProps {
layer: PipLayer | null
layers: PipLayer[]
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${layer.id === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{layer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
layer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={layer.material_url}
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
onClick={() => onGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.x}
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.y}
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.height}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
>
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={layer.border_radius}
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.border_radius}%</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={layer.opacity}
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.opacity}%</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={layer.start_time}
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.animation}
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{layer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.slide_direction}
onChange={(e) =>
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</div>
)
}
export default LayerConfig
export { default } from "./layer-config"
@@ -0,0 +1,172 @@
import React from "react"
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
import { GRID_POSITIONS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerPositionSizeProps {
layer: PipLayer
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
/**
* 图层位置与尺寸配置面板
*/
export const LayerPositionSize: React.FC<LayerPositionSizeProps> = ({
layer,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => (
<>
{/* 素材类型 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* 素材 URL */}
<div className="pip-field">
<label className="pip-field-label">
{layer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
layer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={layer.material_url}
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
/>
</div>
{/* 位置:九宫格 + 坐标 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
onClick={() => onGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.x}
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.y}
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
/>
</div>
</div>
</div>
</div>
{/* 尺寸 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.height}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
>
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* 圆角 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={layer.border_radius}
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.border_radius}%</span>
</div>
</div>
{/* 透明度 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={layer.opacity}
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.opacity}%</span>
</div>
</div>
</>
)
@@ -0,0 +1,87 @@
import React from "react"
import type { PipLayer, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
import { ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerTimingAnimationProps {
layer: PipLayer
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
}
/**
* 图层时间与动画配置面板
*/
export const LayerTimingAnimation: React.FC<LayerTimingAnimationProps> = ({
layer,
totalDuration,
onUpdate,
}) => (
<>
{/* 时间 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={layer.start_time}
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* 入场动画 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.animation}
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{layer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.slide_direction}
onChange={(e) =>
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</>
)
@@ -0,0 +1,33 @@
import React from "react"
import type { PipLayer } from "@/pages/editing-planner/types"
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
interface PipPreviewProps {
layers: PipLayer[]
selectedId: string
}
/**
* PIP 图层迷你预览组件
*/
export const PipPreview: React.FC<PipPreviewProps> = ({ layers, selectedId }) => (
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${selectedId === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
)
@@ -0,0 +1,57 @@
/**
* 混剪单图层配置区
*/
import React from "react"
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
import { PipPreview } from "./PipPreview"
import { LayerPositionSize } from "./LayerPositionSize"
import { LayerTimingAnimation } from "./LayerTimingAnimation"
interface LayerConfigProps {
layer: PipLayer | null
layers: PipLayer[]
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* 迷你预览 */}
<PipPreview layers={layers} selectedId={layer.id} />
{/* 位置与尺寸 */}
<LayerPositionSize
layer={layer}
onUpdate={onUpdate}
onGridClick={onGridClick}
onWidthChange={onWidthChange}
onHeightChange={onHeightChange}
/>
{/* 时间与动画 */}
<LayerTimingAnimation layer={layer} totalDuration={totalDuration} onUpdate={onUpdate} />
</div>
)
}
export default LayerConfig
+8 -264
View File
@@ -8,249 +8,15 @@
*/
import React from "react"
import { useNavigate } from "react-router-dom"
import { Button } from "@/components/ui"
import { useAuthStore } from "@/store/authStore"
import { NavBar, Footer } from "./sections/Layout"
import { HeroSection } from "./sections/HeroSection"
import { FeatureSection } from "./sections/FeatureSection"
import { WorkflowSection } from "./sections/WorkflowSection"
import { PricingSection } from "./sections/PricingSection"
import { CTASection } from "./sections/CTASection"
import "./home-page.css"
/* ── HeroSection ─────────────────────────────────────────── */
const HeroSection: React.FC = () => {
const navigate = useNavigate()
return (
<section className="hp-hero">
<div className="hp-hero-inner">
{/* 左侧文案 */}
<div className="hp-hero-content">
<span className="hp-hero-tag">🦐 · AI智能视频创作平台</span>
<h1 className="hp-hero-title">
AI自动剪辑
<br />
</h1>
<p className="hp-hero-desc">
AI 30
</p>
<div className="hp-hero-actions">
<Button buttonType="primary" buttonSize="lg" onClick={() => navigate("/register")}>
</Button>
<Button buttonType="secondary" buttonSize="lg" onClick={() => navigate("/pricing")}>
</Button>
</div>
</div>
{/* 右侧视觉 */}
<div className="hp-hero-visual">
<div className="hp-hero-video">
<div className="hp-hero-video-inner">
<span className="hp-hero-video-placeholder">🎬</span>
</div>
<button className="hp-hero-play" type="button" aria-label="播放演示视频">
</button>
</div>
<div className="hp-hero-info">
<div className="hp-hero-badges">
<span className="hp-hero-badge"> AI智能剪辑</span>
<span className="hp-hero-badge"> 30</span>
</div>
<span className="hp-hero-pill"></span>
</div>
</div>
</div>
</section>
)
}
/* ── FeatureSection ──────────────────────────────────────── */
const FEATURES = [
{
icon: "🤖",
title: "AI 智能剪辑",
desc: "自动识别视频高光片段,智能去除冗余内容,智能剪辑精彩短视频。",
},
{
icon: "🎙️",
title: "AI 配音克隆",
desc: "克隆您的声音,支持多种音色风格,自动生成自然流畅的配音。",
},
{
icon: "📝",
title: "智能字幕标题",
desc: "自动语音识别生成精准字幕,AI 创作吸睛标题,提升内容传播力。",
},
{
icon: "📱",
title: "多平台一键发布",
desc: "支持抖音、快手、小红书、微信视频号等主流平台,一键同步发布。",
},
]
const FeatureSection: React.FC = () => {
return (
<section className="hp-features">
<div className="hp-section-inner">
<h2 className="hp-section-title"></h2>
<p className="hp-section-desc"> AI </p>
<div className="hp-feature-grid">
{FEATURES.map((f) => (
<div key={f.title} className="hp-feature-card">
<div className="hp-feature-icon">{f.icon}</div>
<h3 className="hp-feature-title">{f.title}</h3>
<p className="hp-feature-desc">{f.desc}</p>
</div>
))}
</div>
</div>
</section>
)
}
/* ── WorkflowSection ─────────────────────────────────────── */
const STEPS = [
{ icon: "📤", title: "上传素材", desc: "拖拽或选择视频素材,支持批量上传" },
{ icon: "🧠", title: "AI 处理", desc: "AI 自动分析、剪辑、配音、加字幕" },
{ icon: "👀", title: "预览调整", desc: "在线预览生成结果,支持微调编辑" },
{ icon: "🚀", title: "一键发布", desc: "多平台同步发布,追踪数据表现" },
]
const WorkflowSection: React.FC = () => {
return (
<section className="hp-workflow">
<div className="hp-section-inner">
<h2 className="hp-section-title"></h2>
<p className="hp-section-desc"> 30 </p>
<div className="hp-step-grid">
{STEPS.map((step, idx) => (
<div key={step.title} className="hp-step-card">
<div className="hp-step-number">{idx + 1}</div>
<div className="hp-step-icon">{step.icon}</div>
<h3 className="hp-step-title">{step.title}</h3>
<p className="hp-step-desc">{step.desc}</p>
{idx < STEPS.length - 1 && <div className="hp-step-arrow"></div>}
</div>
))}
</div>
</div>
</section>
)
}
/* ── PricingSection ──────────────────────────────────────── */
const PLANS = [
{
name: "基础版",
price: "免费",
period: "",
desc: "适合个人体验,快速上手",
features: ["每月 5 次 AI 生成", "720p 视频导出", "基础模板库", "1 个平台账号绑定"],
highlighted: false,
cta: "免费开始",
},
{
name: "专业版",
price: "¥99",
period: "/月",
desc: "适合内容创作者,高效产出",
features: [
"每月 100 次 AI 生成",
"1080p 视频导出",
"全部模板库",
"4 个平台账号绑定",
"AI 配音克隆",
"优先客服支持",
],
highlighted: true,
cta: "立即订阅",
},
{
name: "企业版",
price: "¥399",
period: "/月",
desc: "适合团队与企业,规模化运营",
features: [
"无限次 AI 生成",
"4K 视频导出",
"全部模板 + 定制模板",
"无限平台账号绑定",
"团队协作管理",
"API 接入支持",
"专属客户经理",
],
highlighted: false,
cta: "联系销售",
},
]
const PricingSection: React.FC = () => {
const navigate = useNavigate()
return (
<section className="hp-pricing">
<div className="hp-section-inner">
<h2 className="hp-section-title"></h2>
<p className="hp-section-desc"></p>
<div className="hp-pricing-grid">
{PLANS.map((plan) => (
<div
key={plan.name}
className={`hp-pricing-card${plan.highlighted ? " hp-pricing-card--highlight" : ""}`}
>
{plan.highlighted && <div className="hp-pricing-badge"></div>}
<h3 className="hp-pricing-name">{plan.name}</h3>
<div className="hp-pricing-price">
{plan.price}
{plan.period && <span className="hp-pricing-period">{plan.period}</span>}
</div>
<p className="hp-pricing-desc">{plan.desc}</p>
<ul className="hp-pricing-features">
{plan.features.map((f) => (
<li key={f}>
<span className="hp-pricing-check"></span>
{f}
</li>
))}
</ul>
<Button
buttonType={plan.highlighted ? "primary" : "secondary"}
onClick={() => navigate("/register")}
>
{plan.cta}
</Button>
</div>
))}
</div>
</div>
</section>
)
}
/* ── CTASection ──────────────────────────────────────────── */
const CTASection: React.FC = () => {
const navigate = useNavigate()
return (
<section className="hp-cta">
<div className="hp-cta-inner">
<h2 className="hp-cta-title"> AI </h2>
<p className="hp-cta-desc"> AI </p>
<Button buttonType="primary" buttonSize="lg" onClick={() => navigate("/register")}>
</Button>
</div>
</section>
)
}
/* ── 主页面 ─────────────────────────────────────────────── */
const HomePage: React.FC = () => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const navigate = useNavigate()
@@ -264,35 +30,13 @@ const HomePage: React.FC = () => {
return (
<div className="hp-page">
{/* 顶部导航栏 */}
<header className="hp-nav">
<div className="hp-nav-inner">
<button className="hp-nav-brand" type="button" onClick={() => navigate("/")}>
<span className="hp-nav-logo">🦐</span>
<span className="hp-nav-brand-text"></span>
</button>
<div className="hp-nav-actions">
<Button buttonType="ghost" buttonSize="sm" onClick={() => navigate("/login")}>
</Button>
<Button buttonType="primary" buttonSize="sm" onClick={() => navigate("/register")}>
</Button>
</div>
</div>
</header>
{/* 5 个区域 */}
<NavBar />
<HeroSection />
<FeatureSection />
<WorkflowSection />
<PricingSection />
<CTASection />
{/* 底部 */}
<footer className="hp-footer">
<p>© 2026 · AI智能视频创作平台</p>
</footer>
<Footer />
</div>
)
}
@@ -0,0 +1,20 @@
import React from "react"
import { useNavigate } from "react-router-dom"
import { Button } from "@/components/ui"
/** CTA 行动号召区域 */
export const CTASection: React.FC = () => {
const navigate = useNavigate()
return (
<section className="hp-cta">
<div className="hp-cta-inner">
<h2 className="hp-cta-title"> AI </h2>
<p className="hp-cta-desc"> AI </p>
<Button buttonType="primary" buttonSize="lg" onClick={() => navigate("/register")}>
</Button>
</div>
</section>
)
}
@@ -0,0 +1,45 @@
import React from "react"
const FEATURES = [
{
icon: "🤖",
title: "AI 智能剪辑",
desc: "自动识别视频高光片段,智能去除冗余内容,智能剪辑精彩短视频。",
},
{
icon: "🎙️",
title: "AI 配音克隆",
desc: "克隆您的声音,支持多种音色风格,自动生成自然流畅的配音。",
},
{
icon: "📝",
title: "智能字幕标题",
desc: "自动语音识别生成精准字幕,AI 创作吸睛标题,提升内容传播力。",
},
{
icon: "📱",
title: "多平台一键发布",
desc: "支持抖音、快手、小红书、微信视频号等主流平台,一键同步发布。",
},
]
/** 核心功能区域 */
export const FeatureSection: React.FC = () => {
return (
<section className="hp-features">
<div className="hp-section-inner">
<h2 className="hp-section-title"></h2>
<p className="hp-section-desc"> AI </p>
<div className="hp-feature-grid">
{FEATURES.map((f) => (
<div key={f.title} className="hp-feature-card">
<div className="hp-feature-icon">{f.icon}</div>
<h3 className="hp-feature-title">{f.title}</h3>
<p className="hp-feature-desc">{f.desc}</p>
</div>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,55 @@
import React from "react"
import { useNavigate } from "react-router-dom"
import { Button } from "@/components/ui"
/** Hero 首屏区域 */
export const HeroSection: React.FC = () => {
const navigate = useNavigate()
return (
<section className="hp-hero">
<div className="hp-hero-inner">
{/* 左侧文案 */}
<div className="hp-hero-content">
<span className="hp-hero-tag">🦐 · AI智能视频创作平台</span>
<h1 className="hp-hero-title">
AI自动剪辑
<br />
</h1>
<p className="hp-hero-desc">
AI 30
</p>
<div className="hp-hero-actions">
<Button buttonType="primary" buttonSize="lg" onClick={() => navigate("/register")}>
</Button>
<Button buttonType="secondary" buttonSize="lg" onClick={() => navigate("/pricing")}>
</Button>
</div>
</div>
{/* 右侧视觉 */}
<div className="hp-hero-visual">
<div className="hp-hero-video">
<div className="hp-hero-video-inner">
<span className="hp-hero-video-placeholder">🎬</span>
</div>
<button className="hp-hero-play" type="button" aria-label="播放演示视频">
</button>
</div>
<div className="hp-hero-info">
<div className="hp-hero-badges">
<span className="hp-hero-badge"> AI智能剪辑</span>
<span className="hp-hero-badge"> 30</span>
</div>
<span className="hp-hero-pill"></span>
</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,34 @@
import React from "react"
import { useNavigate } from "react-router-dom"
import { Button } from "@/components/ui"
/** 顶部导航栏 */
export const NavBar: React.FC = () => {
const navigate = useNavigate()
return (
<header className="hp-nav">
<div className="hp-nav-inner">
<button className="hp-nav-brand" type="button" onClick={() => navigate("/")}>
<span className="hp-nav-logo">🦐</span>
<span className="hp-nav-brand-text"></span>
</button>
<div className="hp-nav-actions">
<Button buttonType="ghost" buttonSize="sm" onClick={() => navigate("/login")}>
</Button>
<Button buttonType="primary" buttonSize="sm" onClick={() => navigate("/register")}>
</Button>
</div>
</div>
</header>
)
}
/** 页脚 */
export const Footer: React.FC = () => (
<footer className="hp-footer">
<p>© 2026 · AI智能视频创作平台</p>
</footer>
)
@@ -0,0 +1,102 @@
import React from "react"
import { useNavigate } from "react-router-dom"
import { Button } from "@/components/ui"
interface Plan {
name: string
price: string
period: string
desc: string
features: string[]
highlighted: boolean
cta: string
}
const PLANS: Plan[] = [
{
name: "基础版",
price: "免费",
period: "",
desc: "适合个人体验,快速上手",
features: ["每月 5 次 AI 生成", "720p 视频导出", "基础模板库", "1 个平台账号绑定"],
highlighted: false,
cta: "免费开始",
},
{
name: "专业版",
price: "¥99",
period: "/月",
desc: "适合内容创作者,高效产出",
features: [
"每月 100 次 AI 生成",
"1080p 视频导出",
"全部模板库",
"4 个平台账号绑定",
"AI 配音克隆",
"优先客服支持",
],
highlighted: true,
cta: "立即订阅",
},
{
name: "企业版",
price: "¥399",
period: "/月",
desc: "适合团队与企业,规模化运营",
features: [
"无限次 AI 生成",
"4K 视频导出",
"全部模板 + 定制模板",
"无限平台账号绑定",
"团队协作管理",
"API 接入支持",
"专属客户经理",
],
highlighted: false,
cta: "联系销售",
},
]
/** 定价方案区域 */
export const PricingSection: React.FC = () => {
const navigate = useNavigate()
return (
<section className="hp-pricing">
<div className="hp-section-inner">
<h2 className="hp-section-title"></h2>
<p className="hp-section-desc"></p>
<div className="hp-pricing-grid">
{PLANS.map((plan) => (
<div
key={plan.name}
className={`hp-pricing-card${plan.highlighted ? " hp-pricing-card--highlight" : ""}`}
>
{plan.highlighted && <div className="hp-pricing-badge"></div>}
<h3 className="hp-pricing-name">{plan.name}</h3>
<div className="hp-pricing-price">
{plan.price}
{plan.period && <span className="hp-pricing-period">{plan.period}</span>}
</div>
<p className="hp-pricing-desc">{plan.desc}</p>
<ul className="hp-pricing-features">
{plan.features.map((f) => (
<li key={f}>
<span className="hp-pricing-check"></span>
{f}
</li>
))}
</ul>
<Button
buttonType={plan.highlighted ? "primary" : "secondary"}
onClick={() => navigate("/register")}
>
{plan.cta}
</Button>
</div>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,31 @@
import React from "react"
const STEPS = [
{ icon: "📤", title: "上传素材", desc: "拖拽或选择视频素材,支持批量上传" },
{ icon: "🧠", title: "AI 处理", desc: "AI 自动分析、剪辑、配音、加字幕" },
{ icon: "👀", title: "预览调整", desc: "在线预览生成结果,支持微调编辑" },
{ icon: "🚀", title: "一键发布", desc: "多平台同步发布,追踪数据表现" },
]
/** 工作流程区域 */
export const WorkflowSection: React.FC = () => {
return (
<section className="hp-workflow">
<div className="hp-section-inner">
<h2 className="hp-section-title"></h2>
<p className="hp-section-desc"> 30 </p>
<div className="hp-step-grid">
{STEPS.map((step, idx) => (
<div key={step.title} className="hp-step-card">
<div className="hp-step-number">{idx + 1}</div>
<div className="hp-step-icon">{step.icon}</div>
<h3 className="hp-step-title">{step.title}</h3>
<p className="hp-step-desc">{step.desc}</p>
{idx < STEPS.length - 1 && <div className="hp-step-arrow"></div>}
</div>
))}
</div>
</div>
</section>
)
}
@@ -9,12 +9,11 @@
from __future__ import annotations
import logging
from typing import Optional
from packages.domain.chroma_key_config import CHROMA_KEY_PRESETS # noqa: F401
from packages.domain.chroma_key_config import apply_chroma_key_if_needed # noqa: F401
from packages.domain.chroma_key_config import (
CHROMA_KEY_PRESETS,
ChromaKeyConfig,
apply_chroma_key_if_needed,
)
from packages.domain.chroma_key_config import ( # noqa: F401 — 向后兼容
build_chromakey_filter as _build_chromakey_filter_base,
@@ -195,17 +195,3 @@ class ColorGradeEngine:
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def get_preset_names() -> list[tuple[str, str]]:
"""获取所有预设名称列表.
Returns:
[(preset_key, display_name), ...]
"""
return [(key, PRESET_DISPLAY_NAMES.get(key, key)) for key in PRESET_PARAMS.keys()]
def get_preset_params(preset: str) -> dict[str, float] | None:
"""获取指定预设的参数."""
return PRESET_PARAMS.get(preset)
+3 -5
View File
@@ -22,11 +22,9 @@ from shared.ffmpeg_utils import ( # noqa: F401
# xfade 转场纯逻辑已抽离到 domain 层,这里 re-export 保持向后兼容
from packages.domain.xfade_builder import DEFAULT_TRANSITION_DURATION as _default_transition_duration_base # noqa: F401
from packages.domain.xfade_builder import (
SUPPORTED_TRANSITIONS,
XFADE_TRANSITION_MAP,
XFade_TRANSITION_NAMES,
)
from packages.domain.xfade_builder import SUPPORTED_TRANSITIONS # noqa: F401
from packages.domain.xfade_builder import XFADE_TRANSITION_MAP # noqa: F401
from packages.domain.xfade_builder import XFade_TRANSITION_NAMES # noqa: F401
from packages.domain.xfade_builder import build_xfade_filter_chain as _build_xfade_filter_chain_base
from packages.domain.xfade_builder import chain_filters as _chain_filters_base
from packages.domain.xfade_builder import resolve_xfade_transition as _resolve_xfade_transition_base
@@ -9,16 +9,21 @@ from __future__ import annotations
import logging
# isort: off
from packages.domain.noise_reduction_config import (
NoiseReductionConfig,
NoiseReductionLevel,
NoiseReductionLevel, # noqa: F401
)
from packages.domain.noise_reduction_config import (
apply_noise_reduction_if_needed as _apply_noise_reduction_if_needed_base,
)
from packages.domain.noise_reduction_config import build_afftdn_filter as _build_afftdn_filter_base # noqa: F401 — 向后兼容
from packages.domain.noise_reduction_config import (
build_afftdn_filter as _build_afftdn_filter_base,
) # noqa: F401 — 向后兼容
from packages.domain.noise_reduction_config import build_arnndn_filter as _build_arnndn_filter_base
# isort: on
logger = logging.getLogger(__name__)
+1 -10
View File
@@ -18,22 +18,13 @@ from pathlib import Path
# 向后兼容:POSITION_BOTTOM_CENTER 也从 pip_config 再导出
from packages.domain.pip_config import POSITION_BOTTOM_CENTER # noqa: E402, F401
from packages.domain.pip_config import PiPConfig # noqa: F401
from packages.domain.pip_config import (
ANIMATION_FADE,
ANIMATION_SCALE,
ANIMATION_SLIDE_BOTTOM,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
POSITION_BOTTOM_LEFT,
POSITION_BOTTOM_RIGHT,
POSITION_CENTER,
POSITION_CENTER_LEFT,
POSITION_CENTER_RIGHT,
POSITION_TOP_CENTER,
POSITION_TOP_LEFT,
POSITION_TOP_RIGHT,
PiPConfig,
PiPLayerConfig,
)
from packages.domain.pip_config import ( # noqa: F401 — 向后兼容:保留模块级导出
@@ -13,9 +13,6 @@ from pathlib import Path
from typing import Any
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_BOTTOM,
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
build_ass_content,
)
from packages.domain.ass_subtitle_builder import build_ass_style as _build_ass_style_base # noqa: F401 — 向后兼容
@@ -14,16 +14,20 @@ import logging
from pathlib import Path
from typing import Any
# isort: off
from packages.domain.sticker_config import (
POSITION_PRESETS,
STICKER_CATEGORIES,
POSITION_PRESETS, # noqa: F401
STICKER_CATEGORIES, # noqa: F401
ImageStickerConfig,
StickerOverlayResult,
TextStickerConfig,
)
from packages.domain.sticker_config import get_sticker_categories as _get_sticker_categories_base # noqa: F401 向后兼容导出
from packages.domain.sticker_config import (
get_sticker_categories as _get_sticker_categories_base,
) # noqa: F401 向后兼容导出
from packages.domain.sticker_config import parse_stickers_from_config as _parse_stickers_base
from packages.domain.sticker_config import (
# isort: on
resolve_sticker_position,
)
@@ -25,28 +25,22 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
from packages.domain.subtitle_style import (
ALLOWED_SUBTITLE_EXTENSIONS,
DEFAULT_COLOR,
DEFAULT_FONT,
DEFAULT_FONT_SIZE,
DEFAULT_MAX_CHARS_PER_LINE,
DEFAULT_POSITION,
DEFAULT_STROKE_COLOR,
DEFAULT_STROKE_WIDTH,
POSITION_ALIASES,
POSITION_ALIGNMENT,
SubtitleSegment,
SubtitleStyle,
)
from packages.domain.subtitle_style import escape_ass_text as _escape_ass_text # noqa: F401 向后兼容导出
from packages.domain.subtitle_style import format_ass_time as _format_ass_time
from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr
from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color
from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha
from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr # noqa: F401
from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color # noqa: F401
from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha # noqa: F401
from packages.domain.subtitle_style import wrap_text as _wrap_text
logger = logging.getLogger(__name__)
+2 -4
View File
@@ -15,16 +15,14 @@ from __future__ import annotations
import logging
from typing import Any
from packages.domain.trim_config import MIN_TRIM_DURATION # noqa: F401
from packages.domain.trim_config import extract_trim_from_clip_config # noqa: F401
from packages.domain.trim_config import (
MIN_TRIM_DURATION,
TrimConfig,
TrimSegment,
)
from packages.domain.trim_config import build_audio_trim_filter as _build_audio_trim_filter # noqa: F401 — 向后兼容
from packages.domain.trim_config import build_video_trim_filter as _build_video_trim_filter
from packages.domain.trim_config import (
extract_trim_from_clip_config,
)
from packages.domain.trim_config import parse_segments_from_config as _parse_segments_from_config
from packages.domain.trim_config import resolve_segments as _resolve_segments
@@ -53,7 +53,6 @@ from video_processing.tts_engine import TtsEngine
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
from packages.domain.render_layer_utils import LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX
from packages.domain.render_layer_utils import can_pass_through as _can_pass_through_pure
from packages.domain.render_layer_utils import clip_adjusted_duration as _clip_adjusted_duration_pure
from packages.domain.render_layer_utils import clip_effective_duration as _clip_effective_duration_pure
from packages.domain.render_layer_utils import clip_playback_speed as _clip_playback_speed_pure
@@ -14,10 +14,9 @@
from __future__ import annotations
import logging
from typing import Any
from packages.domain.watermark_config import WATERMARK_POSITIONS # noqa: F401
from packages.domain.watermark_config import (
WATERMARK_POSITIONS,
WatermarkConfig,
)
from packages.domain.watermark_config import ( # noqa: F401 — 向后兼容
+1 -1
View File
@@ -186,7 +186,7 @@ class PiPConfig:
"""最大 z_index."""
if not self.layers:
return 0
return max(l.z_index for l in self.layers)
return max(layer.z_index for layer in self.layers)
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
+1 -1
View File
@@ -6,7 +6,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any
# ── 常量 ──────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -11,7 +11,7 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -10,7 +10,7 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
+2 -2
View File
@@ -39,7 +39,7 @@ class TestConstants:
def test_preset_params_complete(self):
assert set(PRESET_PARAMS.keys()) == VALID_PRESETS
for preset, params in PRESET_PARAMS.items():
for _preset, params in PRESET_PARAMS.items():
assert set(params.keys()) == set(ALL_PARAM_KEYS)
def test_default_params_keys(self):
@@ -54,7 +54,7 @@ class TestConstants:
assert min_val <= DEFAULT_PARAMS[key] <= max_val
def test_all_presets_within_ranges(self):
for preset, params in PRESET_PARAMS.items():
for _preset, params in PRESET_PARAMS.items():
for key in ALL_PARAM_KEYS:
min_val, max_val = PARAM_RANGES[key]
assert min_val <= params[key] <= max_val, f"{preset}.{key}={params[key]} out of range"
+1 -1
View File
@@ -208,7 +208,7 @@ class TestPiPConfigFromDict:
}
)
assert cfg.layer_count == 3
assert [l.source for l in cfg.layers] == ["bottom", "mid", "top"]
assert [layer.source for layer in cfg.layers] == ["bottom", "mid", "top"]
def test_invalid_layer_skipped(self):
cfg = PiPConfig.from_dict(
+1 -1
View File
@@ -23,7 +23,7 @@ class TestConstants:
assert len(POSITION_PRESETS) == 9
def test_position_presets_normalized(self):
for name, (x, y) in POSITION_PRESETS.items():
for _name, (x, y) in POSITION_PRESETS.items():
assert 0.0 <= x <= 1.0
assert 0.0 <= y <= 1.0