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

This commit is contained in:
DevOps Bot
2026-07-06 09:12:54 +08:00
parent 2d05f37651
commit b86a9c2af2
35 changed files with 467 additions and 209 deletions
@@ -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
+4 -7
View File
@@ -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"}
+2 -1
View File
@@ -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,
+3 -1
View File
@@ -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 });
+5 -1
View File
@@ -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);
};
+3 -1
View File
@@ -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);
};
+33 -13
View File
@@ -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>
+48 -13
View File
@@ -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 {
+12 -2
View File
@@ -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: "我的音色",
+26 -6
View File
@@ -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>
+9 -4
View File
@@ -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);
}
/* ============================================================
内联样式迁移类
============================================================ */
+4 -2
View File
@@ -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);
}
/* ── 忘记密码链接 ───────────────────────────────────────── */
+15 -3
View File
@@ -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>
+4 -2
View File
@@ -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 />,
+1 -6
View File
@@ -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) => {
+115 -39
View File
@@ -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>
+36 -11
View File
@@ -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;
}
/* ============================================================
内联样式迁移类
============================================================ */
+15 -4
View File
@@ -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;
}
}
/* ── 编辑弹窗按钮状态 ───────────────────────────────────── */
-1
View File
@@ -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
# ---------------------------------------------------------------------------
+3 -9
View File
@@ -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)
+9 -9
View File
@@ -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: