chore: 统一代码格式 - black + prettier 全量格式化
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 75h41m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 75h41m16s
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 75h41m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 75h41m16s
This commit is contained in:
@@ -23,10 +23,7 @@ def _column_exists(table: str, column: str) -> bool:
|
||||
"""检查 PostgreSQL 表中某列是否已存在。"""
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
sa.text("SELECT 1 FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
@@ -198,13 +198,10 @@ def _build_single_asset_diagnosis(project_id: str, asset: Asset) -> ProjectAsset
|
||||
kind = _asset_kind(asset)
|
||||
is_ready = asset.status == AssetStatus.READY
|
||||
is_problem = asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING}
|
||||
is_risky = (
|
||||
is_ready
|
||||
and (
|
||||
(asset.quality_score is not None and asset.quality_score < 60)
|
||||
or asset.metadata.get("review_status") == "rejected"
|
||||
or asset.status == AssetStatus.ERROR
|
||||
)
|
||||
is_risky = is_ready and (
|
||||
(asset.quality_score is not None and asset.quality_score < 60)
|
||||
or asset.metadata.get("review_status") == "rejected"
|
||||
or asset.status == AssetStatus.ERROR
|
||||
)
|
||||
is_unclassified = is_ready and asset.classification_status.value in {"pending", "failed"}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ from packages.application.generation_tasks import (
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -662,6 +661,7 @@ def ai_recommend_clips(
|
||||
|
||||
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||||
|
||||
result = run_ai_recommend(
|
||||
plan_id=plan_id,
|
||||
template_id=plan.template_id,
|
||||
@@ -783,6 +783,7 @@ def generate_cover(
|
||||
|
||||
# 调用 AI 封面生成服务
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
|
||||
@@ -83,7 +83,9 @@ export interface AssetDiagnosis {
|
||||
// ─── 素材诊断 ──────────────────────────────────────────────
|
||||
|
||||
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
||||
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
||||
export const getAssetDiagnosis = async (
|
||||
assetId?: string,
|
||||
): Promise<AssetDiagnosis> => {
|
||||
const params: Record<string, string> = {};
|
||||
if (assetId) params.asset_id = assetId;
|
||||
const response = await apiClient.get("/asset-diagnosis", { params });
|
||||
|
||||
@@ -51,7 +51,11 @@ const Header: React.FC = () => {
|
||||
const isActive = (path: string) => {
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return location.pathname === "/" || location.pathname === "/app" || location.pathname === "/app/dashboard";
|
||||
return (
|
||||
location.pathname === "/" ||
|
||||
location.pathname === "/app" ||
|
||||
location.pathname === "/app/dashboard"
|
||||
);
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,9 @@ import "./Sidebar.css";
|
||||
const isMenuItemActive = (pathname: string, path: string): boolean => {
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return pathname === "/" || pathname === "/app" || pathname === "/app/dashboard";
|
||||
return (
|
||||
pathname === "/" || pathname === "/app" || pathname === "/app/dashboard"
|
||||
);
|
||||
}
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
|
||||
@@ -86,7 +86,10 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
};
|
||||
@@ -108,7 +111,10 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
@@ -207,13 +213,18 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
} else {
|
||||
// 开始录制
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
@@ -250,7 +261,10 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setErrorMessage("已达最长录制时长(5分钟),已自动停止");
|
||||
@@ -311,9 +325,13 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
fileToUpload = selectedFile;
|
||||
} else {
|
||||
// 将录音 Blob 转为 File
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
});
|
||||
fileToUpload = new File(
|
||||
[recordedBlob!],
|
||||
`recorded-${Date.now()}.webm`,
|
||||
{
|
||||
type: "audio/webm",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -345,9 +363,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
/* ── 计算属性 ──────────────────────────────────────── */
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= 2 &&
|
||||
voiceName.trim().length <= 20 &&
|
||||
hasAudio;
|
||||
voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio;
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning";
|
||||
|
||||
@@ -564,7 +580,9 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
<div className="xx-clonemodal-step-icon">
|
||||
{isDone ? "✓" : step.icon}
|
||||
</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
<span className="xx-clonemodal-step-label">
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -585,7 +603,9 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-clonemodal-progress-text">
|
||||
AI 正在克隆你的声音…
|
||||
</p>
|
||||
<p className="xx-clonemodal-progress-sub">
|
||||
正在分析声音特征,生成专属音色模型
|
||||
</p>
|
||||
|
||||
@@ -345,15 +345,34 @@
|
||||
animation: xx-clonemodal-wave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-wave-bar:nth-child(1) { height: 40%; animation-delay: 0s; }
|
||||
.xx-clonemodal-record-wave-bar:nth-child(2) { height: 70%; animation-delay: 0.15s; }
|
||||
.xx-clonemodal-record-wave-bar:nth-child(3) { height: 100%; animation-delay: 0.3s; }
|
||||
.xx-clonemodal-record-wave-bar:nth-child(4) { height: 60%; animation-delay: 0.45s; }
|
||||
.xx-clonemodal-record-wave-bar:nth-child(5) { height: 30%; animation-delay: 0.6s; }
|
||||
.xx-clonemodal-record-wave-bar:nth-child(1) {
|
||||
height: 40%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.xx-clonemodal-record-wave-bar:nth-child(2) {
|
||||
height: 70%;
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.xx-clonemodal-record-wave-bar:nth-child(3) {
|
||||
height: 100%;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.xx-clonemodal-record-wave-bar:nth-child(4) {
|
||||
height: 60%;
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
.xx-clonemodal-record-wave-bar:nth-child(5) {
|
||||
height: 30%;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes xx-clonemodal-wave {
|
||||
from { transform: scaleY(0.4); }
|
||||
to { transform: scaleY(1); }
|
||||
from {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 录制按钮 */
|
||||
@@ -366,7 +385,10 @@
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -390,8 +412,13 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clonemodal-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────────────── */
|
||||
@@ -522,9 +549,17 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clonemodal-bounce {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(1); }
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-title {
|
||||
|
||||
@@ -43,14 +43,24 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/dashboard",
|
||||
icon: <DashboardOutlined />,
|
||||
},
|
||||
{ key: "assets", label: "素材库", path: "/app/assets", icon: <FileOutlined /> },
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/app/assets",
|
||||
icon: <FileOutlined />,
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/app/titles",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
{ key: "voices", label: "配音库", path: "/app/voices", icon: <AudioOutlined /> },
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/app/voices",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-clone",
|
||||
label: "我的音色",
|
||||
|
||||
@@ -194,9 +194,23 @@ const SkeletonCard: React.FC = () => (
|
||||
<div className="xx-asset-card xx-asset-skeleton">
|
||||
<div className="xx-asset-thumb xx-skeleton-pulse" />
|
||||
<div className="xx-asset-info">
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "100%", height: 28, marginTop: 8, borderRadius: "var(--radius-xs)" }} />
|
||||
<div
|
||||
className="xx-skeleton-line xx-skeleton-pulse"
|
||||
style={{ width: "70%" }}
|
||||
/>
|
||||
<div
|
||||
className="xx-skeleton-line xx-skeleton-pulse"
|
||||
style={{ width: "40%", marginTop: 8 }}
|
||||
/>
|
||||
<div
|
||||
className="xx-skeleton-line xx-skeleton-pulse"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 28,
|
||||
marginTop: 8,
|
||||
borderRadius: "var(--radius-xs)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -668,8 +682,12 @@ const AssetLibrary: React.FC = () => {
|
||||
</div>
|
||||
) : assetsError ? (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon"><ExclamationCircleOutlined /></div>
|
||||
<p className="xx-assets-empty-title">{assetsErrorObj?.message || "加载失败"}</p>
|
||||
<div className="xx-assets-empty-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">
|
||||
{assetsErrorObj?.message || "加载失败"}
|
||||
</p>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
@@ -697,7 +715,9 @@ const AssetLibrary: React.FC = () => {
|
||||
<div className="xx-assets-empty-icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">暂无素材,请上传或切换素材库</p>
|
||||
<p className="xx-assets-empty-title">
|
||||
暂无素材,请上传或切换素材库
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -401,9 +401,15 @@
|
||||
骨架屏(素材列表加载占位)
|
||||
============================================================ */
|
||||
@keyframes xx-skeleton-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
100% { opacity: 1; }
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-asset-skeleton {
|
||||
@@ -464,7 +470,6 @@
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
内联样式迁移类
|
||||
============================================================ */
|
||||
|
||||
@@ -33,13 +33,15 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-auth-card .xx-btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
box-shadow: 0 6px 20px
|
||||
color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
}
|
||||
|
||||
/* ── 忘记密码链接 ───────────────────────────────────────── */
|
||||
|
||||
@@ -71,11 +71,19 @@ const Login: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||
<Checkbox>记住我</Checkbox>
|
||||
</Form.Item>
|
||||
<Link className="xx-auth-forgot" to="/forgot-password">忘记密码?</Link>
|
||||
<Link className="xx-auth-forgot" to="/forgot-password">
|
||||
忘记密码?
|
||||
</Link>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
@@ -95,7 +103,11 @@ const Login: React.FC = () => {
|
||||
<div className="xx-auth-divider">或</div>
|
||||
|
||||
<div className="xx-auth-third-party">
|
||||
<button type="button" className="xx-btn-wechat" onClick={() => message.info("微信登录功能开发中")}>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn-wechat"
|
||||
onClick={() => message.info("微信登录功能开发中")}
|
||||
>
|
||||
<span className="xx-wechat-icon">💬</span>
|
||||
微信登录
|
||||
</button>
|
||||
|
||||
@@ -33,11 +33,13 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-auth-card .xx-btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
box-shadow: 0 6px 20px
|
||||
color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
}
|
||||
|
||||
@@ -245,7 +245,6 @@ const formatDate = () => {
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${weekDays[d.getDay()]}`;
|
||||
};
|
||||
|
||||
|
||||
/** 图标名称 → Ant Design 组件映射 */
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
|
||||
@@ -44,11 +44,7 @@
|
||||
}
|
||||
|
||||
.xx-kpi-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--bg-primary),
|
||||
var(--bg-secondary)
|
||||
);
|
||||
background: linear-gradient(180deg, var(--bg-primary), var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
@@ -484,7 +480,6 @@
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
迁移自内联样式的工具类
|
||||
============================================================ */
|
||||
|
||||
@@ -504,7 +504,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--ep-bg-card-hover, #1a1a2e), var(--ep-bg-deepest, #0a0a14));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--ep-bg-card-hover, #1a1a2e),
|
||||
var(--ep-bg-deepest, #0a0a14)
|
||||
);
|
||||
height: calc(100% - 20px);
|
||||
position: relative;
|
||||
}
|
||||
@@ -572,7 +576,11 @@
|
||||
.ep-cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--ep-bg-card-hover, #1a1a2e), var(--ep-bg-card, #13131f));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--ep-bg-card-hover, #1a1a2e),
|
||||
var(--ep-bg-card, #13131f)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -805,7 +813,9 @@
|
||||
|
||||
.ep-clip-card.selected {
|
||||
border-color: var(--primary, #6366f1);
|
||||
box-shadow: 0 0 0 2px var(--primary-soft, #eef2ff), 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--primary-soft, #eef2ff),
|
||||
0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.ep-clip-card.drag-over {
|
||||
@@ -819,7 +829,11 @@
|
||||
|
||||
.ep-clip-thumbnail {
|
||||
height: 52px;
|
||||
background: linear-gradient(135deg, var(--bg-secondary, #f8fafc), var(--border-light, #f1f5f9));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--bg-secondary, #f8fafc),
|
||||
var(--border-light, #f1f5f9)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1124,7 +1138,10 @@
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--gradient-primary, linear-gradient(135deg, #6366f1, #4f46e5));
|
||||
background: var(
|
||||
--gradient-primary,
|
||||
linear-gradient(135deg, #6366f1, #4f46e5)
|
||||
);
|
||||
border: none;
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
color: #fff;
|
||||
@@ -1333,7 +1350,9 @@
|
||||
stroke: var(--primary, #6366f1);
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.5s ease, stroke 0.3s;
|
||||
transition:
|
||||
stroke-dashoffset 0.5s ease,
|
||||
stroke 0.3s;
|
||||
}
|
||||
|
||||
.ep-gen-progress-pct {
|
||||
@@ -1475,7 +1494,9 @@
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
@@ -1490,11 +1511,15 @@
|
||||
color: #5a4bd6;
|
||||
}
|
||||
|
||||
|
||||
/* Skeleton loading */
|
||||
@keyframes ep-skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.ep-skeleton {
|
||||
@@ -1654,8 +1679,12 @@
|
||||
}
|
||||
|
||||
@keyframes ep-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ep-modal {
|
||||
@@ -1668,8 +1697,14 @@
|
||||
}
|
||||
|
||||
@keyframes ep-scale-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.ep-modal-header {
|
||||
@@ -1751,4 +1786,3 @@
|
||||
text-align: center;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
generateFromTemplate,
|
||||
MODE_LABELS,
|
||||
} from "@/api/editingPlanner";
|
||||
import type { EditPlanClip, EditPlanGeneration, MediaAsset } from "@/api/editPlans";
|
||||
import type {
|
||||
EditPlanClip,
|
||||
EditPlanGeneration,
|
||||
MediaAsset,
|
||||
} from "@/api/editPlans";
|
||||
import {
|
||||
getMediaAssets,
|
||||
getEditPlanGenerations,
|
||||
@@ -387,7 +391,9 @@ const EditingPlanner: React.FC = () => {
|
||||
asset_ids: assetIds,
|
||||
cover_type: coverType,
|
||||
});
|
||||
setCurrentCoverScheme(coverType === "ai_frame" ? "ai_frame" : "ai_reselect");
|
||||
setCurrentCoverScheme(
|
||||
coverType === "ai_frame" ? "ai_frame" : "ai_reselect",
|
||||
);
|
||||
msg.success("AI 封面生成成功");
|
||||
} catch {
|
||||
msg.error("AI 封面生成失败");
|
||||
@@ -750,10 +756,7 @@ const EditingPlanner: React.FC = () => {
|
||||
<span className="ep-status-sep">|</span>
|
||||
<span>📐 模板片段: {currentTemplate?.segments.length || 0}</span>
|
||||
<span className="ep-status-sep">|</span>
|
||||
<button
|
||||
className="ep-status-link"
|
||||
onClick={handleViewGenHistory}
|
||||
>
|
||||
<button className="ep-status-link" onClick={handleViewGenHistory}>
|
||||
📋 生成历史
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -287,7 +287,9 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
onSubtitleSettingsChange({ size: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="ep-slider-value">{subtitleSettings.size}px</span>
|
||||
<span className="ep-slider-value">
|
||||
{subtitleSettings.size}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -323,9 +325,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={bgmSettings.music}
|
||||
onChange={(e) =>
|
||||
onBgmSettingsChange({ music: e.target.value })
|
||||
}
|
||||
onChange={(e) => onBgmSettingsChange({ music: e.target.value })}
|
||||
>
|
||||
{BGM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -375,7 +375,9 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">关联素材</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
<span className="ep-clip-detail-linked">{selectedClip.assetName}</span>
|
||||
<span className="ep-clip-detail-linked">
|
||||
{selectedClip.assetName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,6 @@ interface GenerationHistoryModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
||||
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
open,
|
||||
loading,
|
||||
@@ -31,7 +30,9 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
>
|
||||
<div className="ep-modal-header">
|
||||
<h3>生成历史</h3>
|
||||
<button className="ep-modal-close" onClick={onClose}><CloseOutlined /></button>
|
||||
<button className="ep-modal-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ep-modal-body ep-gh-body">
|
||||
{loading ? (
|
||||
@@ -44,7 +45,10 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="ep-gh-empty"><InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} /><span>暂无生成记录</span></div>
|
||||
<div className="ep-gh-empty">
|
||||
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
|
||||
<span>暂无生成记录</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="ep-gh-table">
|
||||
<thead>
|
||||
@@ -69,10 +73,14 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
</span>
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.created_at ? new Date(gen.created_at).toLocaleString("zh-CN") : "—"}
|
||||
{gen.created_at
|
||||
? new Date(gen.created_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.updated_at ? new Date(gen.updated_at).toLocaleString("zh-CN") : "—"}
|
||||
{gen.updated_at
|
||||
? new Date(gen.updated_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -73,9 +73,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
style={{ width: isPlaying ? "45%" : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="ep-phone-clip-label">
|
||||
{displayClip.name}
|
||||
</div>
|
||||
<div className="ep-phone-clip-label">{displayClip.name}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="ep-phone-empty-hint">暂无片段</span>
|
||||
|
||||
@@ -133,7 +133,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
<div className="ep-timeline-header">
|
||||
<div className="ep-timeline-title">
|
||||
<span>🎬 时间线</span>
|
||||
<span className="ep-timeline-duration">总时长: {formatTime(totalDuration)}</span>
|
||||
<span className="ep-timeline-duration">
|
||||
总时长: {formatTime(totalDuration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-timeline-actions">
|
||||
<button
|
||||
@@ -148,10 +150,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
<button
|
||||
className="ep-timeline-action-btn"
|
||||
title="重做"
|
||||
>
|
||||
<button className="ep-timeline-action-btn" title="重做">
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
@@ -194,9 +193,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onDragOver={handleEmptyDragOver}
|
||||
>
|
||||
{clips.length === 0 ? (
|
||||
<div className="ep-track-empty">
|
||||
🎬 拖入素材或从模板加载片段
|
||||
</div>
|
||||
<div className="ep-track-empty">🎬 拖入素材或从模板加载片段</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => (
|
||||
<div
|
||||
|
||||
@@ -13,19 +13,17 @@ export function useUndoRedo<T>(initialState: T) {
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
|
||||
const set = useCallback(
|
||||
(next: T | ((prev: T) => T)) => {
|
||||
setPresent((curr) => {
|
||||
const resolved = typeof next === "function" ? (next as (p: T) => T)(curr) : next;
|
||||
pastRef.current = [...pastRef.current.slice(-(MAX_HISTORY - 1)), curr];
|
||||
futureRef.current = [];
|
||||
setCanUndo(true);
|
||||
setCanRedo(false);
|
||||
return resolved;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const set = useCallback((next: T | ((prev: T) => T)) => {
|
||||
setPresent((curr) => {
|
||||
const resolved =
|
||||
typeof next === "function" ? (next as (p: T) => T)(curr) : next;
|
||||
pastRef.current = [...pastRef.current.slice(-(MAX_HISTORY - 1)), curr];
|
||||
futureRef.current = [];
|
||||
setCanUndo(true);
|
||||
setCanRedo(false);
|
||||
return resolved;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
setPresent((curr) => {
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import { Typography, message } from "antd";
|
||||
import {
|
||||
AudioOutlined,
|
||||
ThunderboltOutlined,
|
||||
@@ -110,7 +107,6 @@ const MOCK_TIMELINE: TimelineScene[] = [
|
||||
{ scene: "用户反馈 · 结尾", time: "20-30s", duration: 10 },
|
||||
];
|
||||
|
||||
|
||||
/* ── 步骤定义 ── */
|
||||
const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
@@ -149,7 +145,9 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 配音 ── */
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>("");
|
||||
const [playingVoice, setPlayingVoice] = useState<string | null>(null);
|
||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset");
|
||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">(
|
||||
"preset",
|
||||
);
|
||||
const [customVoiceText, setCustomVoiceText] = useState("");
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
@@ -215,7 +213,9 @@ const GeneratePage: React.FC = () => {
|
||||
if (plan.name) setTitle(plan.name);
|
||||
const cfg = plan.config as Record<string, unknown>;
|
||||
if (cfg && Array.isArray(cfg.asset_ids)) {
|
||||
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"));
|
||||
setSelectedMaterials(
|
||||
cfg.asset_ids.filter((v): v is string => typeof v === "string"),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("加载剪辑计划配置失败:", err);
|
||||
@@ -250,7 +250,9 @@ const GeneratePage: React.FC = () => {
|
||||
}
|
||||
}, [libraries, selectedLibraryId]);
|
||||
|
||||
const { data: materials = [], isLoading: materialsLoading } = useQuery<AssetItem[]>({
|
||||
const { data: materials = [], isLoading: materialsLoading } = useQuery<
|
||||
AssetItem[]
|
||||
>({
|
||||
queryKey: ["generate-assets", selectedLibraryId],
|
||||
queryFn: () => getAssets(selectedLibraryId),
|
||||
enabled: !!selectedLibraryId,
|
||||
@@ -387,7 +389,8 @@ const GeneratePage: React.FC = () => {
|
||||
} else if (voiceMode === "custom") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined;
|
||||
if (customAudioUrl) voiceConfig.custom_audio_url = customAudioUrl;
|
||||
if (customVoiceText.trim()) voiceConfig.custom_text = customVoiceText.trim();
|
||||
if (customVoiceText.trim())
|
||||
voiceConfig.custom_text = customVoiceText.trim();
|
||||
}
|
||||
|
||||
const plan = await createEditPlan({
|
||||
@@ -527,7 +530,10 @@ const GeneratePage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<span className="xx-choice-check">✓</span>
|
||||
<div className="xx-choice-thumb" style={{ background: tpl.gradient }}>
|
||||
<div
|
||||
className="xx-choice-thumb"
|
||||
style={{ background: tpl.gradient }}
|
||||
>
|
||||
{tpl.abbr}
|
||||
</div>
|
||||
<h4>{tpl.label}</h4>
|
||||
@@ -555,7 +561,14 @@ const GeneratePage: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginTop: 14, display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span className="xx-pill xx-pill-ok">
|
||||
已选 {selectedMaterials.length} 个素材
|
||||
</span>
|
||||
@@ -586,7 +599,9 @@ const GeneratePage: React.FC = () => {
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
|
||||
background: checked
|
||||
? "var(--primary-soft, #eef2ff)"
|
||||
: "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
@@ -607,10 +622,21 @@ const GeneratePage: React.FC = () => {
|
||||
}}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: "var(--text-primary)", flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary, #94a3b8)" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
{m.mime_type.split("/")[1].toUpperCase()}
|
||||
</span>
|
||||
</label>
|
||||
@@ -628,10 +654,7 @@ const GeneratePage: React.FC = () => {
|
||||
<h3>📝 选择标题</h3>
|
||||
<div className="xx-form-field">
|
||||
<label>选择标题</label>
|
||||
<select
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
>
|
||||
<select value={title} onChange={(e) => setTitle(e.target.value)}>
|
||||
<option value="">请选择标题…</option>
|
||||
{TITLE_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
@@ -737,7 +760,14 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
{/* 试听按钮 */}
|
||||
{presetVoices.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{presetVoices.slice(0, 4).map((v) => (
|
||||
<button
|
||||
key={v.voice_id}
|
||||
@@ -746,9 +776,13 @@ const GeneratePage: React.FC = () => {
|
||||
onClick={() => toggleVoicePlay(v.voice_id, v.preview_url)}
|
||||
>
|
||||
{playingVoice === v.voice_id ? (
|
||||
<><PauseCircleOutlined /> 停止</>
|
||||
<>
|
||||
<PauseCircleOutlined /> 停止
|
||||
</>
|
||||
) : (
|
||||
<><PlayCircleOutlined /> {v.name}</>
|
||||
<>
|
||||
<PlayCircleOutlined /> {v.name}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
@@ -782,16 +816,29 @@ const GeneratePage: React.FC = () => {
|
||||
disabled={!customVoiceText.trim() || synthesizeMutation.isPending}
|
||||
onClick={handleSynthesizeVoice}
|
||||
>
|
||||
<AudioOutlined /> {synthesizeMutation.isPending ? "合成中…" : "合成语音"}
|
||||
<AudioOutlined />{" "}
|
||||
{synthesizeMutation.isPending ? "合成中…" : "合成语音"}
|
||||
</button>
|
||||
</div>
|
||||
{ttsError && (
|
||||
<Text style={{ color: "var(--error, #ef4444)", marginTop: 8, display: "block" }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--error, #ef4444)",
|
||||
marginTop: 8,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</Text>
|
||||
)}
|
||||
{customAudioUrl && (
|
||||
<Text style={{ color: "var(--success, #10b981)", marginTop: 8, display: "block" }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--success, #10b981)",
|
||||
marginTop: 8,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
✓ 语音合成完成
|
||||
</Text>
|
||||
)}
|
||||
@@ -802,7 +849,9 @@ const GeneratePage: React.FC = () => {
|
||||
{voiceMode === "clone" && (
|
||||
<div className="xx-clone-section">
|
||||
<p className="xx-clone-section-title">克隆我的声音</p>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}>
|
||||
<Text
|
||||
style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}
|
||||
>
|
||||
上传一段您的语音样本,AI 将克隆您的声音用于视频配音
|
||||
</Text>
|
||||
|
||||
@@ -856,14 +905,21 @@ const GeneratePage: React.FC = () => {
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
{isReady && (
|
||||
<span style={{ color: "var(--text-secondary)", marginLeft: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{formatDuration(cv.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selected && isReady && (
|
||||
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
|
||||
<CheckCircleFilled
|
||||
style={{ color: "var(--primary-color, #4f46e5)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -872,12 +928,26 @@ const GeneratePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{clonedVoices.length === 0 && (
|
||||
<Text style={{ color: "var(--text-secondary)", display: "block", textAlign: "center", padding: "16px 0" }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
padding: "16px 0",
|
||||
}}
|
||||
>
|
||||
暂无克隆音色,点击「克隆新声音」开始
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)", marginTop: 10, display: "block" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
marginTop: 10,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
💡 提示:录音环境越安静,克隆效果越好。
|
||||
</Text>
|
||||
</div>
|
||||
@@ -896,7 +966,9 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">{selectedMaterials.length} 个素材</span>
|
||||
<span className="xx-summary-value">
|
||||
{selectedMaterials.length} 个素材
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
@@ -928,12 +1000,18 @@ const GeneratePage: React.FC = () => {
|
||||
/** 渲染当前步骤 */
|
||||
const renderCurrentStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1: return renderStep1();
|
||||
case 2: return renderStep2();
|
||||
case 3: return renderStep3();
|
||||
case 4: return renderStep4();
|
||||
case 5: return renderStep5();
|
||||
default: return null;
|
||||
case 1:
|
||||
return renderStep1();
|
||||
case 2:
|
||||
return renderStep2();
|
||||
case 3:
|
||||
return renderStep3();
|
||||
case 4:
|
||||
return renderStep4();
|
||||
case 5:
|
||||
return renderStep5();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -992,9 +1070,7 @@ const GeneratePage: React.FC = () => {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="xx-step-num">
|
||||
{isDone ? "✓" : step.key}
|
||||
</div>
|
||||
<div className="xx-step-num">{isDone ? "✓" : step.key}</div>
|
||||
<span className="xx-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
|
||||
@@ -424,8 +424,13 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clone-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-info {
|
||||
@@ -474,8 +479,10 @@
|
||||
width: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
#f59e0b 0%, #f59e0b 25%,
|
||||
#10b981 25%, #10b981 50%,
|
||||
#f59e0b 0%,
|
||||
#f59e0b 25%,
|
||||
#10b981 25%,
|
||||
#10b981 50%,
|
||||
#f59e0b 50%
|
||||
);
|
||||
background-size: 60px 100%;
|
||||
@@ -483,8 +490,12 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clone-progress-flow {
|
||||
from { background-position: 0 0; }
|
||||
to { background-position: 60px 0; }
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: 60px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-progress-text {
|
||||
@@ -518,13 +529,23 @@
|
||||
}
|
||||
|
||||
@keyframes xx-fade {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes xx-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -654,7 +675,11 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%);
|
||||
background: radial-gradient(
|
||||
circle at 72% 28%,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent 40%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,10 @@ const MyTemplates: React.FC = () => {
|
||||
description="还没有模板,点击右上角「新建模板」开始创建"
|
||||
style={{ padding: 80 }}
|
||||
>
|
||||
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate("/app/editing-planner")}
|
||||
>
|
||||
新建模板
|
||||
</Button>
|
||||
</Empty>
|
||||
|
||||
@@ -231,9 +231,7 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ??
|
||||
"📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">
|
||||
{template.name}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -522,7 +520,9 @@ const TemplateLibrary: React.FC = () => {
|
||||
return (
|
||||
<div className="xx-templates-page">
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon"><LoadingOutlined /></div>
|
||||
<div className="xx-templates-empty-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<h3>加载模板中...</h3>
|
||||
</div>
|
||||
</div>
|
||||
@@ -534,7 +534,9 @@ const TemplateLibrary: React.FC = () => {
|
||||
return (
|
||||
<div className="xx-templates-page">
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon"><ExclamationCircleOutlined /></div>
|
||||
<div className="xx-templates-empty-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "网络异常,请稍后重试"}</p>
|
||||
</div>
|
||||
@@ -562,7 +564,9 @@ const TemplateLibrary: React.FC = () => {
|
||||
{/* ── 工具栏:搜索 + 类型按钮组 ─────────────────────────── */}
|
||||
<div className="xx-templates-toolbar">
|
||||
<div className="xx-templates-search">
|
||||
<span className="xx-templates-search-icon"><SearchOutlined /></span>
|
||||
<span className="xx-templates-search-icon">
|
||||
<SearchOutlined />
|
||||
</span>
|
||||
<input
|
||||
className="xx-templates-search-input"
|
||||
type="text"
|
||||
@@ -588,7 +592,9 @@ const TemplateLibrary: React.FC = () => {
|
||||
{/* ── 模板展示区 ────────────────────────────────────────── */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon"><InboxOutlined /></div>
|
||||
<div className="xx-templates-empty-icon">
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<h3>
|
||||
{searchText || activeType !== "全部"
|
||||
? "未找到匹配的模板"
|
||||
|
||||
@@ -675,7 +675,6 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
内联样式迁移类
|
||||
============================================================ */
|
||||
|
||||
@@ -119,11 +119,15 @@ const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
|
||||
{/* 元信息 */}
|
||||
<div className="vc-card-meta">
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon"><SoundOutlined /></span>
|
||||
<span className="vc-card-meta-icon">
|
||||
<SoundOutlined />
|
||||
</span>
|
||||
<span>时长:{formatDuration(voice.duration_seconds)}</span>
|
||||
</div>
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon"><CalendarOutlined /></span>
|
||||
<span className="vc-card-meta-icon">
|
||||
<CalendarOutlined />
|
||||
</span>
|
||||
<span>创建于:{createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,7 +295,9 @@ const VoiceClone: React.FC = () => {
|
||||
{/* 空状态 */}
|
||||
{!isLoading && voices.length === 0 && (
|
||||
<div className="vc-empty">
|
||||
<div className="vc-empty-icon"><AudioOutlined style={{ fontSize: 48 }} /></div>
|
||||
<div className="vc-empty-icon">
|
||||
<AudioOutlined style={{ fontSize: 48 }} />
|
||||
</div>
|
||||
<h3 className="vc-empty-title">还没有克隆音色</h3>
|
||||
<p className="vc-empty-desc">上传你的声音,AI将克隆你的专属音色</p>
|
||||
<Button buttonType="primary" onClick={handleCloneNew}>
|
||||
@@ -305,7 +311,12 @@ const VoiceClone: React.FC = () => {
|
||||
<div className="vc-toast-container">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
|
||||
{t.type === "success" ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {t.message}
|
||||
{t.type === "success" ? (
|
||||
<CheckCircleOutlined />
|
||||
) : (
|
||||
<CloseCircleOutlined />
|
||||
)}{" "}
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -382,7 +382,6 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* ── 骨架屏加载 ──────────────────────────────────────────── */
|
||||
|
||||
.vc-skeleton-avatar {
|
||||
@@ -419,8 +418,13 @@
|
||||
}
|
||||
|
||||
@keyframes vc-skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 编辑弹窗按钮状态 ───────────────────────────────────── */
|
||||
|
||||
@@ -13,7 +13,6 @@ from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── 枚举类型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ from app.dependencies import get_db_session, get_duplication_repository
|
||||
from app.core.storage import get_storage_service
|
||||
from app.api.routes.duplication import router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 内存 Repository + 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -39,7 +39,6 @@ from app.dependencies import get_duplication_repository
|
||||
from app.core.storage import get_storage_service, OSSStorageService
|
||||
from app.api.routes.duplication import router, _validate_video_mime_type
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Fixtures & Mocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -38,12 +38,8 @@ from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository
|
||||
|
||||
# ── 导入被测路由模块(从 fixtures 加载简化版路由) ─────────────────────────────
|
||||
_fixture_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "fixtures", "subscription_routes.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"app.api.routes.subscription", _fixture_path
|
||||
)
|
||||
_fixture_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "subscription_routes.py")
|
||||
_spec = importlib.util.spec_from_file_location("app.api.routes.subscription", _fixture_path)
|
||||
subscription = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["app.api.routes.subscription"] = subscription
|
||||
_spec.loader.exec_module(subscription)
|
||||
@@ -376,9 +372,7 @@ class TestCancelSubscription:
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(subscription.router)
|
||||
app.dependency_overrides[subscription.get_current_user] = lambda: AuthenticatedUser(
|
||||
user=original_user
|
||||
)
|
||||
app.dependency_overrides[subscription.get_current_user] = lambda: AuthenticatedUser(user=original_user)
|
||||
app.dependency_overrides[subscription.get_user_repository] = lambda: mock_user_repo
|
||||
|
||||
tc = TestClient(app)
|
||||
|
||||
@@ -105,9 +105,9 @@ class TestTTSJobStatusDeserialization:
|
||||
model = _make_tts_model(status=status_str)
|
||||
entity = SQLAlchemyTTSJobRepository._model_to_entity(model)
|
||||
|
||||
assert isinstance(entity.status, TTSJobStatus), (
|
||||
f"status 应该是 TTSJobStatus 枚举,实际是 {type(entity.status).__name__}"
|
||||
)
|
||||
assert isinstance(
|
||||
entity.status, TTSJobStatus
|
||||
), f"status 应该是 TTSJobStatus 枚举,实际是 {type(entity.status).__name__}"
|
||||
assert entity.status.value == status_str
|
||||
|
||||
def test_status_value_attribute_works(self) -> None:
|
||||
@@ -136,9 +136,9 @@ class TestVoiceCloneStatusDeserialization:
|
||||
model = _make_voice_clone_model(status=status_str)
|
||||
entity = SQLAlchemyVoiceCloneProfileRepository._model_to_entity(model)
|
||||
|
||||
assert isinstance(entity.status, VoiceCloneStatus), (
|
||||
f"status 应该是 VoiceCloneStatus 枚举,实际是 {type(entity.status).__name__}"
|
||||
)
|
||||
assert isinstance(
|
||||
entity.status, VoiceCloneStatus
|
||||
), f"status 应该是 VoiceCloneStatus 枚举,实际是 {type(entity.status).__name__}"
|
||||
assert entity.status.value == status_str
|
||||
|
||||
def test_status_value_attribute_works(self) -> None:
|
||||
@@ -162,9 +162,9 @@ class TestGenerationTaskStatusDeserialization:
|
||||
|
||||
entity = _to_domain(model)
|
||||
|
||||
assert isinstance(entity.status, GenerationTaskStatus), (
|
||||
f"status 应该是 GenerationTaskStatus 枚举,实际是 {type(entity.status).__name__}"
|
||||
)
|
||||
assert isinstance(
|
||||
entity.status, GenerationTaskStatus
|
||||
), f"status 应该是 GenerationTaskStatus 枚举,实际是 {type(entity.status).__name__}"
|
||||
assert entity.status.value == status_str
|
||||
|
||||
def test_status_value_attribute_works(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user