Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 078569cf6c | |||
| d9f2436bd3 | |||
| 4d9131f2e4 | |||
| b4bde72b44 | |||
| 1d4c7d6aea | |||
| 20b593dcf8 | |||
| bc37ce2ee7 |
@@ -12,7 +12,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
Regular → Executable
+23
-239
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
/** 风险等级分类 */
|
||||
export type RiskFilter = "all" | "high" | "medium" | "low"
|
||||
|
||||
/** 简易 toast */
|
||||
export interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+3
-264
@@ -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"
|
||||
|
||||
+85
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+69
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+132
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+78
@@ -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,
|
||||
}
|
||||
}
|
||||
Executable → Regular
+3
-284
@@ -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"
|
||||
|
||||
+172
@@ -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>
|
||||
</>
|
||||
)
|
||||
+87
@@ -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
|
||||
Regular → Executable
+8
-264
@@ -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)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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 — 向后兼容
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user