feat: 第三批UI打磨 — 生成历史/仪表盘/音色克隆
Deploy / Staging E2E Tests (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
Deploy / Deploy Staging (push) Failing after 98h39m25s
CI/CD Pipeline / Frontend Lint (push) Failing after 98h39m53s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 98h40m1s
Deploy / Build Production Runtime Images (push) Failing after 1757h12m42s
Deploy / Staging E2E Tests (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
Deploy / Deploy Staging (push) Failing after 98h39m25s
CI/CD Pipeline / Frontend Lint (push) Failing after 98h39m53s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 98h40m1s
Deploy / Build Production Runtime Images (push) Failing after 1757h12m42s
- GenerationHistoryModal: 内联状态样式→CSS类(ep-gh-status-tag--*)、✕→CloseOutlined、空状态增强 - EditingPlanner.css: 补全ep-modal基础样式、按钮:active/:disabled、状态标签变体 - Dashboard: emoji→Ant Design图标、6处内联样式迁移到CSS类 - dashboard.css: 补全:active(kpi-card/chart-bar/quick-card/announcement)、新增工具类 - VoiceClone: emoji→Ant Design图标、⏳→骨架屏、✅❌→图标、window.confirm→Popconfirm - voice-clone.css: 补全:active/:disabled、硬编码值→CSS变量、骨架屏动画 - 附带: products/titles/voices CSS微调 + test_duplication_api.py清理 TypeScript检查通过,零错误
This commit is contained in:
@@ -6,6 +6,13 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Tag } from "@/components/ui";
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
AppstoreOutlined,
|
||||
ThunderboltOutlined,
|
||||
DatabaseOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import "./dashboard.css";
|
||||
|
||||
/* ============================================================
|
||||
@@ -25,7 +32,7 @@ interface KpiItem {
|
||||
const kpiData: KpiItem[] = [
|
||||
{
|
||||
key: "projects",
|
||||
icon: "🎬",
|
||||
icon: "video",
|
||||
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
value: "12",
|
||||
label: "项目总数",
|
||||
@@ -35,7 +42,7 @@ const kpiData: KpiItem[] = [
|
||||
},
|
||||
{
|
||||
key: "assets",
|
||||
icon: "📦",
|
||||
icon: "appstore",
|
||||
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
value: "486",
|
||||
label: "素材总数",
|
||||
@@ -45,7 +52,7 @@ const kpiData: KpiItem[] = [
|
||||
},
|
||||
{
|
||||
key: "generations",
|
||||
icon: "✨",
|
||||
icon: "thunderbolt",
|
||||
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
|
||||
value: "156",
|
||||
label: "本月生成数",
|
||||
@@ -55,7 +62,7 @@ const kpiData: KpiItem[] = [
|
||||
},
|
||||
{
|
||||
key: "storage",
|
||||
icon: "💾",
|
||||
icon: "database",
|
||||
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
value: "2.4GB",
|
||||
label: "存储空间",
|
||||
@@ -77,7 +84,7 @@ interface QuickEntry {
|
||||
const quickEntries: QuickEntry[] = [
|
||||
{
|
||||
id: "titles",
|
||||
icon: "📝",
|
||||
icon: "filetext",
|
||||
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
title: "标题库",
|
||||
description: "24条标题 · 5个分类",
|
||||
@@ -85,7 +92,7 @@ const quickEntries: QuickEntry[] = [
|
||||
},
|
||||
{
|
||||
id: "assets",
|
||||
icon: "📦",
|
||||
icon: "appstore",
|
||||
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
title: "素材库",
|
||||
description: "486个素材 · 3个素材库",
|
||||
@@ -93,7 +100,7 @@ const quickEntries: QuickEntry[] = [
|
||||
},
|
||||
{
|
||||
id: "generate",
|
||||
icon: "✨",
|
||||
icon: "thunderbolt",
|
||||
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
|
||||
title: "一键生成",
|
||||
description: "开始创作新视频",
|
||||
@@ -101,7 +108,7 @@ const quickEntries: QuickEntry[] = [
|
||||
},
|
||||
{
|
||||
id: "products",
|
||||
icon: "🎬",
|
||||
icon: "video",
|
||||
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
title: "成片库",
|
||||
description: "89个成片 · 3个待复核",
|
||||
@@ -238,6 +245,16 @@ const formatDate = () => {
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${weekDays[d.getDay()]}`;
|
||||
};
|
||||
|
||||
|
||||
/** 图标名称 → Ant Design 组件映射 */
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
appstore: <AppstoreOutlined />,
|
||||
thunderbolt: <ThunderboltOutlined />,
|
||||
database: <DatabaseOutlined />,
|
||||
filetext: <FileTextOutlined />,
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 组件
|
||||
* ============================================================ */
|
||||
@@ -250,7 +267,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="xx-dashboard-page">
|
||||
{/* ── 欢迎头部 ─────────────────────────────────────────── */}
|
||||
<div className="xx-dashboard-welcome">
|
||||
<h2>{getGreeting()},创作者 👋</h2>
|
||||
<h2>{getGreeting()},创作者</h2>
|
||||
<p>{formatDate()} — 欢迎回到小小剪辑控制台</p>
|
||||
</div>
|
||||
|
||||
@@ -266,7 +283,7 @@ const Dashboard: React.FC = () => {
|
||||
className="xx-kpi-icon"
|
||||
style={{ background: item.iconGradient }}
|
||||
>
|
||||
{item.icon}
|
||||
{iconMap[item.icon] ?? item.icon}
|
||||
</div>
|
||||
<div className="xx-kpi-value">{item.value}</div>
|
||||
<div className="xx-kpi-label">{item.label}</div>
|
||||
@@ -282,13 +299,7 @@ const Dashboard: React.FC = () => {
|
||||
{/* ── 主内容区:左侧任务+图表 / 右侧公告 ──────────────── */}
|
||||
<div className="xx-dashboard-main">
|
||||
{/* 左列 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "var(--space-md)",
|
||||
}}
|
||||
>
|
||||
<div className="xx-dashboard-left-col">
|
||||
{/* 最近任务 */}
|
||||
<div className="xx-dashboard-section">
|
||||
<div className="xx-dashboard-section-header">
|
||||
@@ -345,12 +356,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="xx-dashboard-section">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>本周生成趋势</h3>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span className="xx-chart-total">
|
||||
共 {weeklyData.reduce((s, d) => s + d.value, 0)} 次
|
||||
</span>
|
||||
</div>
|
||||
@@ -381,7 +387,7 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 右列 — 公告 + 存储用量 */}
|
||||
<div className="xx-dashboard-section" style={{ alignSelf: "start" }}>
|
||||
<div className="xx-dashboard-section xx-dashboard-section--start">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>系统公告</h3>
|
||||
</div>
|
||||
@@ -402,17 +408,8 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 存储用量 */}
|
||||
<div style={{ marginTop: "var(--space-md)" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-primary)",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
存储用量
|
||||
</div>
|
||||
<div className="xx-storage-section">
|
||||
<div className="xx-storage-section-title">存储用量</div>
|
||||
<div className="xx-storage-bar">
|
||||
<div className="xx-storage-bar-track">
|
||||
<div className="xx-storage-bar-fill" style={{ width: "24%" }} />
|
||||
@@ -427,25 +424,9 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* ── 快速入口 ─────────────────────────────────────────── */}
|
||||
<div style={{ marginBottom: "var(--space-md)" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "var(--space-md)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "var(--font-size-base)",
|
||||
fontWeight: "var(--font-weight-semibold)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
快速入口
|
||||
</h3>
|
||||
<div className="xx-quick-entry-section">
|
||||
<div className="xx-quick-entry-header">
|
||||
<h3 className="xx-quick-entry-title">快速入口</h3>
|
||||
</div>
|
||||
<div className="xx-quick-grid">
|
||||
{quickEntries.map((entry) => (
|
||||
@@ -458,7 +439,7 @@ const Dashboard: React.FC = () => {
|
||||
className="xx-quick-card-icon"
|
||||
style={{ background: entry.iconGradient }}
|
||||
>
|
||||
{entry.icon}
|
||||
{iconMap[entry.icon] ?? entry.icon}
|
||||
</div>
|
||||
<h3>{entry.title}</h3>
|
||||
<p>{entry.description}</p>
|
||||
|
||||
@@ -74,6 +74,12 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.xx-kpi-card:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
.xx-kpi-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -103,8 +109,8 @@
|
||||
font-size: var(--font-size-xs);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xxs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@@ -175,6 +181,11 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.xx-dashboard-section-header a:active,
|
||||
.xx-dashboard-section-header button:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
最近任务卡片列表
|
||||
============================================================ */
|
||||
@@ -216,7 +227,7 @@
|
||||
.xx-task-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-xs);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
@@ -257,7 +268,7 @@
|
||||
|
||||
.xx-task-time span {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: var(--space-xxs);
|
||||
}
|
||||
|
||||
.xx-task-action {
|
||||
@@ -303,6 +314,12 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.xx-chart-bar:active {
|
||||
opacity: 0.7;
|
||||
transform: scaleY(0.97);
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.xx-chart-bar-value {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
@@ -359,6 +376,12 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.xx-quick-card:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
.xx-quick-card-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
@@ -408,9 +431,13 @@
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-announcement-item:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.xx-announcement-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
padding: var(--space-xxs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
@@ -458,15 +485,15 @@
|
||||
}
|
||||
|
||||
.xx-storage-bar-track {
|
||||
height: 8px;
|
||||
height: var(--space-sm);
|
||||
background: var(--bg-secondary, #e2e8f0);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--space-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-storage-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--space-xs);
|
||||
background: var(--gradient-primary);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
@@ -479,6 +506,54 @@
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
迁移自内联样式的工具类
|
||||
============================================================ */
|
||||
.xx-dashboard-left-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-chart-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-dashboard-section--start {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.xx-storage-section {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-storage-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-quick-entry-section {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-quick-entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-quick-entry-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
|
||||
@@ -96,6 +96,18 @@
|
||||
background: var(--ep-accent-hover, #7c6cf7);
|
||||
}
|
||||
|
||||
.ep-btn-primary:active {
|
||||
transform: scale(0.96);
|
||||
background: var(--ep-accent-active, #5b4cd6);
|
||||
box-shadow: 0 1px 4px rgba(108, 92, 231, 0.3);
|
||||
}
|
||||
|
||||
.ep-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ep-btn-secondary {
|
||||
background: var(--ep-bg-card-hover, #1a1a2e);
|
||||
color: var(--ep-text-secondary, #9ca3af);
|
||||
@@ -108,6 +120,11 @@
|
||||
color: var(--ep-text-primary, #e8e8f0);
|
||||
}
|
||||
|
||||
.ep-btn-secondary:active {
|
||||
transform: scale(0.96);
|
||||
background: var(--ep-bg-deepest, #0e0e1a);
|
||||
}
|
||||
|
||||
.ep-btn-secondary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
@@ -128,6 +145,17 @@
|
||||
background: color-mix(in srgb, var(--ep-danger, #ff4757), white 20%);
|
||||
}
|
||||
|
||||
.ep-btn-danger:active {
|
||||
transform: scale(0.96);
|
||||
background: color-mix(in srgb, var(--ep-danger, #ff4757), black 10%);
|
||||
}
|
||||
|
||||
.ep-btn-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
第2行:模式栏 48px — 滑动下划线动画
|
||||
═══════════════════════════════════════ */
|
||||
@@ -1505,3 +1533,114 @@
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ═══ 生成历史弹窗 — 基础 Modal 结构 ═══ */
|
||||
.ep-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1050;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
animation: ep-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes ep-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.ep-modal {
|
||||
background: var(--ep-bg-card, #13131f);
|
||||
border: 1px solid var(--ep-border, #1f1f30);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
animation: ep-scale-in 0.2s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes ep-scale-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.ep-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--ep-border, #1f1f30);
|
||||
}
|
||||
|
||||
.ep-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ep-text-primary, #e8e8f0);
|
||||
}
|
||||
|
||||
.ep-modal-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ep-text-secondary, #9ca3af);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ep-modal-close:hover {
|
||||
background: var(--ep-bg-card-hover, #1a1a2e);
|
||||
color: var(--ep-text-primary, #e8e8f0);
|
||||
}
|
||||
|
||||
.ep-modal-close:active {
|
||||
transform: scale(0.92);
|
||||
background: var(--ep-border, #1f1f30);
|
||||
}
|
||||
|
||||
.ep-modal-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.ep-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--ep-border, #1f1f30);
|
||||
}
|
||||
|
||||
/* 状态标签变体 */
|
||||
.ep-gh-status-tag--completed {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.ep-gh-status-tag--failed {
|
||||
background: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.ep-gh-status-tag--rendering {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.ep-gh-status-tag--default {
|
||||
background: #fafafa;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 空状态增强 */
|
||||
.ep-gh-empty {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 从 EditingPlanner 拆分,避免主文件过大
|
||||
*/
|
||||
import React from "react";
|
||||
import { CloseOutlined, InboxOutlined } from "@ant-design/icons";
|
||||
import type { EditPlanGeneration } from "@/api/editPlans";
|
||||
import { PLAN_STATUS_LABELS } from "@/api/editPlans";
|
||||
|
||||
@@ -13,13 +14,6 @@ interface GenerationHistoryModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 状态标签颜色映射 */
|
||||
const STATUS_STYLES: Record<string, { bg: string; color: string }> = {
|
||||
completed: { bg: "#f6ffed", color: "#52c41a" },
|
||||
failed: { bg: "#fff2f0", color: "#ff4d4f" },
|
||||
rendering: { bg: "#e6f7ff", color: "#1890ff" },
|
||||
};
|
||||
const DEFAULT_STATUS_STYLE = { bg: "#fafafa", color: "#666" };
|
||||
|
||||
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
open,
|
||||
@@ -37,9 +31,7 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
>
|
||||
<div className="ep-modal-header">
|
||||
<h3>生成历史</h3>
|
||||
<button className="ep-modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
<button className="ep-modal-close" onClick={onClose}><CloseOutlined /></button>
|
||||
</div>
|
||||
<div className="ep-modal-body ep-gh-body">
|
||||
{loading ? (
|
||||
@@ -52,7 +44,7 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="ep-gh-empty">暂无生成记录</div>
|
||||
<div className="ep-gh-empty"><InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} /><span>暂无生成记录</span></div>
|
||||
) : (
|
||||
<table className="ep-gh-table">
|
||||
<thead>
|
||||
@@ -65,20 +57,14 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((gen) => {
|
||||
const style = STATUS_STYLES[gen.status] || DEFAULT_STATUS_STYLE;
|
||||
const statusClass = `ep-gh-status-tag--${gen.status}`;
|
||||
return (
|
||||
<tr key={gen.id} className="ep-gh-table-row">
|
||||
<td className="ep-gh-td ep-gh-td-id">
|
||||
{gen.generation_task_id.slice(0, 8)}...
|
||||
</td>
|
||||
<td className="ep-gh-td">
|
||||
<span
|
||||
className="ep-gh-status-tag"
|
||||
style={{
|
||||
background: style.bg,
|
||||
color: style.color,
|
||||
}}
|
||||
>
|
||||
<span className={`ep-gh-status-tag ${statusClass}`}>
|
||||
{PLAN_STATUS_LABELS[gen.status] || gen.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: 12px 16px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--primary-soft);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -138,7 +138,7 @@
|
||||
.xx-products-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -250,7 +250,7 @@
|
||||
backdrop-filter: blur(4px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 18px;
|
||||
font-size: var(--font-size-md);
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
@@ -262,10 +262,10 @@
|
||||
/* 时长标签 */
|
||||
.xx-product-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
bottom: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
z-index: 1;
|
||||
padding: 2px 8px;
|
||||
padding: var(--space-xxs) var(--space-sm);
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
@@ -279,7 +279,7 @@
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-product-title {
|
||||
@@ -395,7 +395,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
@@ -555,7 +555,7 @@
|
||||
|
||||
/* 弹窗信息区 */
|
||||
.xx-player-info {
|
||||
padding: 16px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-player-title {
|
||||
@@ -568,7 +568,7 @@
|
||||
.xx-player-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-player-detail-item {
|
||||
@@ -588,7 +588,7 @@
|
||||
.xx-player-footer {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding: 0 16px 16px;
|
||||
padding: 0 var(--space-md) var(--space-md);
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
@@ -603,7 +603,7 @@
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-size: var(--font-size-md);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
@@ -626,7 +626,7 @@
|
||||
}
|
||||
|
||||
.xx-products-empty-icon {
|
||||
font-size: 48px;
|
||||
font-size: var(--font-size-3xl);
|
||||
margin-bottom: var(--space-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
}
|
||||
|
||||
.xx-title-category-item h4 {
|
||||
margin: 0 0 4px;
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
@@ -167,7 +167,7 @@
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
padding: var(--space-md);
|
||||
transition: var(--transition-all);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -241,8 +241,8 @@
|
||||
}
|
||||
|
||||
.xx-title-type-tag.hot {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
background: var(--error-soft);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.xx-title-type-tag.normal {
|
||||
@@ -251,8 +251,8 @@
|
||||
}
|
||||
|
||||
.xx-title-type-tag.creative {
|
||||
background: #fefce8;
|
||||
color: #ca8a04;
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* 深色模式 */
|
||||
@@ -285,7 +285,7 @@
|
||||
.xx-title-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-title-card-action-btn {
|
||||
@@ -299,7 +299,7 @@
|
||||
transition: var(--transition-all);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: var(--space-xxs);
|
||||
}
|
||||
|
||||
.xx-title-card-action-btn:hover {
|
||||
@@ -317,8 +317,8 @@
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 2px;
|
||||
font-size: var(--font-size-md);
|
||||
padding: var(--space-xxs);
|
||||
line-height: 1;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
@@ -337,7 +337,7 @@
|
||||
}
|
||||
|
||||
.xx-titles-empty-icon {
|
||||
font-size: 48px;
|
||||
font-size: var(--font-size-3xl);
|
||||
margin-bottom: var(--space-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -379,7 +379,7 @@
|
||||
.xx-ai-result-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Tooltip } from "@/components/ui";
|
||||
import { Popconfirm } from "antd";
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
AudioOutlined,
|
||||
SoundOutlined,
|
||||
CalendarOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import CloneVoiceModal from "@/components/modals/CloneVoiceModal";
|
||||
import {
|
||||
@@ -70,17 +80,23 @@ const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
|
||||
className="vc-card-action-btn"
|
||||
onClick={() => onEdit(voice)}
|
||||
>
|
||||
✏️
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<button
|
||||
type="button"
|
||||
className="vc-card-action-btn vc-card-action-btn--danger"
|
||||
onClick={() => onDelete(voice)}
|
||||
<Popconfirm
|
||||
title={`确定删除音色「${voice.name}」吗?`}
|
||||
onConfirm={() => onDelete(voice)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vc-card-action-btn vc-card-action-btn--danger"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +105,7 @@ const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
|
||||
<div
|
||||
className={`vc-card-avatar${isProcessing ? " vc-card-avatar--processing" : ""}`}
|
||||
>
|
||||
🎤
|
||||
<AudioOutlined style={{ fontSize: 22 }} />
|
||||
</div>
|
||||
<div className="vc-card-info">
|
||||
<h4 className="vc-card-name">{voice.name}</h4>
|
||||
@@ -103,11 +119,11 @@ const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
|
||||
{/* 元信息 */}
|
||||
<div className="vc-card-meta">
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon">🎵</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">📅</span>
|
||||
<span className="vc-card-meta-icon"><CalendarOutlined /></span>
|
||||
<span>创建于:{createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +144,7 @@ const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
|
||||
disabled={isProcessing}
|
||||
onClick={() => onUse(voice)}
|
||||
>
|
||||
✨ 使用此音色
|
||||
使用此音色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,30 +235,40 @@ const VoiceClone: React.FC = () => {
|
||||
updateMutation.mutate({ id: editingVoice.id, name: editName.trim() });
|
||||
};
|
||||
|
||||
/** 删除确认 */
|
||||
/** 删除确认 — 使用 Popconfirm */
|
||||
const handleDelete = (voice: VoiceCloneType) => {
|
||||
if (window.confirm(`确定删除音色「${voice.name}」吗?`)) {
|
||||
deleteMutation.mutate(voice.id);
|
||||
}
|
||||
deleteMutation.mutate(voice.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vc-page">
|
||||
<PageHead
|
||||
title="🎤 我的音色库"
|
||||
title="我的音色库"
|
||||
description="克隆和管理你的专属音色,用AI生成个性化配音"
|
||||
actions={
|
||||
<Button buttonType="primary" onClick={handleCloneNew}>
|
||||
✨ 克隆新音色
|
||||
克隆新音色
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{/* 加载状态 — 骨架屏 */}
|
||||
{isLoading && (
|
||||
<div className="vc-empty">
|
||||
<div className="vc-empty-icon">⏳</div>
|
||||
<p className="vc-empty-desc">加载中...</p>
|
||||
<div className="vc-grid">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="vc-card">
|
||||
<div className="vc-card-header">
|
||||
<div className="vc-skeleton-avatar" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="vc-skeleton-line vc-skeleton-line--title" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--short" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="vc-skeleton-line" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--short" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--footer" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -265,11 +291,11 @@ const VoiceClone: React.FC = () => {
|
||||
{/* 空状态 */}
|
||||
{!isLoading && voices.length === 0 && (
|
||||
<div className="vc-empty">
|
||||
<div className="vc-empty-icon">🎤</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}>
|
||||
✨ 立即克隆
|
||||
立即克隆
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,7 +305,7 @@ 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" ? "✅" : "❌"} {t.message}
|
||||
{t.type === "success" ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
.vc-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: var(--space-lg, 20px);
|
||||
}
|
||||
|
||||
/* ── 卡片网格 ───────────────────────────────────────────── */
|
||||
@@ -15,8 +15,8 @@
|
||||
.vc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 4px;
|
||||
gap: var(--space-md, 16px);
|
||||
margin-top: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
/* ── 卡片 ───────────────────────────────────────────────── */
|
||||
@@ -26,7 +26,7 @@
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--line, #e2e8f0);
|
||||
border-radius: var(--radius-lg, 14px);
|
||||
padding: 20px;
|
||||
padding: var(--space-lg, 20px);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
@@ -74,11 +74,21 @@
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.vc-card-action-btn:active {
|
||||
transform: scale(0.9);
|
||||
background: var(--bg-active, #e2e8f0);
|
||||
}
|
||||
|
||||
.vc-card-action-btn--danger:hover {
|
||||
background: var(--error-bg, #fef2f2);
|
||||
color: var(--error, #ef4444);
|
||||
}
|
||||
|
||||
.vc-card-action-btn--danger:active {
|
||||
transform: scale(0.9);
|
||||
background: var(--error-bg, #fee2e2);
|
||||
}
|
||||
|
||||
/* 卡片头部:头像 + 名称 + 状态 */
|
||||
.vc-card-header {
|
||||
display: flex;
|
||||
@@ -216,6 +226,11 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vc-card-footer .xx-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── 空状态 ─────────────────────────────────────────────── */
|
||||
|
||||
.vc-empty {
|
||||
@@ -365,6 +380,59 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* ── 骨架屏加载 ──────────────────────────────────────────── */
|
||||
|
||||
.vc-skeleton-avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary, #f1f5f9);
|
||||
animation: vc-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vc-skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f1f5f9);
|
||||
animation: vc-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vc-skeleton-line--title {
|
||||
width: 60%;
|
||||
height: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.vc-skeleton-line--short {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.vc-skeleton-line--footer {
|
||||
height: 36px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@keyframes vc-skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ── 编辑弹窗按钮状态 ───────────────────────────────────── */
|
||||
|
||||
.vc-edit-buttons .xx-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.vc-edit-buttons .xx-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── 响应式 ─────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -955,7 +955,7 @@
|
||||
.xx-voice-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
.xx-voice-card {
|
||||
grid-template-columns: 40px 1fr;
|
||||
@@ -975,6 +975,6 @@
|
||||
.xx-voices-tab-count {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,427 +7,41 @@
|
||||
- POST /records/{record_id}/retry — 重试查重
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
不依赖真实数据库。
|
||||
导入真实模块,不创建 fake namespace packages,避免 sys.modules 污染。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 安装 mock 模块(复用 test_duplication_upload_error_handling 的模式)
|
||||
# ---------------------------------------------------------------------------
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
|
||||
from packages.domain.entities import User
|
||||
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
|
||||
|
||||
# 保存被覆盖的原始模块,以便测试结束后恢复
|
||||
_SAVED_MODULES: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _install_mocks():
|
||||
"""安装所有必需的 mock 模块,使路由模块可导入。"""
|
||||
|
||||
# 记录所有将被覆盖的模块 key,用于后续恢复
|
||||
_keys_to_save = [
|
||||
"packages.domain.entities",
|
||||
"packages.domain.duplication",
|
||||
"packages.ports.user_repository",
|
||||
"packages.ports.duplication_repository",
|
||||
"packages.adapters.sqlalchemy_impl.user_repository",
|
||||
"packages.adapters.sqlalchemy_impl.duplication_repository",
|
||||
"packages.adapters.sqlalchemy_impl.session",
|
||||
"packages.adapters.redis",
|
||||
"packages.adapters.smtp",
|
||||
"packages.application",
|
||||
"app.config",
|
||||
"app.auth",
|
||||
"app.dependencies",
|
||||
"app.core.storage",
|
||||
"app.schemas.duplication",
|
||||
]
|
||||
for _k in _keys_to_save:
|
||||
if _k in sys.modules:
|
||||
_SAVED_MODULES[_k] = sys.modules[_k]
|
||||
|
||||
# packages.domain.entities
|
||||
@dataclass(slots=True)
|
||||
class User:
|
||||
id: str = "user-test-001"
|
||||
email: str = "test@example.com"
|
||||
display_name: str = "Test User"
|
||||
username: str = "testuser"
|
||||
password_hash: str = ""
|
||||
email_verified: bool = False
|
||||
email_verification_token: str | None = None
|
||||
password_reset_token: str | None = None
|
||||
password_reset_expires_at: datetime | None = None
|
||||
last_login_at: datetime | None = None
|
||||
last_login_ip: str | None = None
|
||||
subscription_plan: str = "free"
|
||||
subscription_status: str = "active"
|
||||
subscription_expires_at: datetime | None = None
|
||||
max_projects: int = 3
|
||||
max_storage_gb: int = 10
|
||||
used_storage_gb: float = 0.0
|
||||
created_at: datetime = field(default_factory=lambda: datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||
|
||||
entities_mod = types.ModuleType("packages.domain.entities")
|
||||
entities_mod.User = User
|
||||
sys.modules["packages.domain.entities"] = entities_mod
|
||||
|
||||
# packages.domain.duplication — 使用真实域模型
|
||||
@dataclass(slots=True)
|
||||
class DuplicateSegment:
|
||||
id: str
|
||||
source_start: float
|
||||
source_end: float
|
||||
matched_video_id: str
|
||||
matched_video_name: str
|
||||
matched_start: float
|
||||
matched_end: float
|
||||
similarity: float
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DuplicationRecord:
|
||||
id: str
|
||||
user_id: str
|
||||
filename: str
|
||||
file_size: int
|
||||
storage_key: str
|
||||
duration_seconds: float = 0.0
|
||||
status: str = "pending"
|
||||
duplicate_rate: float | None = None
|
||||
duplicate_count: int = 0
|
||||
video_fingerprint: dict | None = None
|
||||
error_message: str = ""
|
||||
segments: list = field(default_factory=list)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create(cls, user_id, filename, file_size, storage_key, **kwargs):
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
filename=filename,
|
||||
file_size=file_size,
|
||||
storage_key=storage_key,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def mark_processing(self):
|
||||
self.status = "processing"
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_completed(self, duplicate_rate, duplicate_count, segments):
|
||||
if not 0 <= duplicate_rate <= 100:
|
||||
raise ValueError("duplicate_rate must be between 0 and 100")
|
||||
self.status = "completed"
|
||||
self.duplicate_rate = duplicate_rate
|
||||
self.duplicate_count = duplicate_count
|
||||
self.segments = segments
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_failed(self, error_message):
|
||||
self.status = "failed"
|
||||
self.error_message = error_message
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def can_retry(self):
|
||||
return self.status == "failed"
|
||||
|
||||
def reset_for_retry(self):
|
||||
self.status = "pending"
|
||||
self.error_message = ""
|
||||
self.duplicate_rate = None
|
||||
self.duplicate_count = 0
|
||||
self.segments = []
|
||||
self.video_fingerprint = None
|
||||
|
||||
duplication_mod = types.ModuleType("packages.domain.duplication")
|
||||
duplication_mod.DuplicateSegment = DuplicateSegment
|
||||
duplication_mod.DuplicationRecord = DuplicationRecord
|
||||
sys.modules["packages.domain.duplication"] = duplication_mod
|
||||
|
||||
# packages.ports
|
||||
for name in ["user_repository", "duplication_repository"]:
|
||||
mod = types.ModuleType(f"packages.ports.{name}")
|
||||
sys.modules[f"packages.ports.{name}"] = mod
|
||||
sys.modules["packages.ports.user_repository"].UserRepository = MagicMock
|
||||
sys.modules["packages.ports.duplication_repository"].DuplicationRecordRepository = MagicMock
|
||||
|
||||
# packages namespace modules
|
||||
for name in [
|
||||
"packages",
|
||||
"packages.domain",
|
||||
"packages.ports",
|
||||
"packages.adapters",
|
||||
"packages.adapters.sqlalchemy_impl",
|
||||
"packages.adapters.sqlalchemy_impl.user_repository",
|
||||
"packages.adapters.sqlalchemy_impl.duplication_repository",
|
||||
"packages.adapters.sqlalchemy_impl.session",
|
||||
"packages.adapters.redis",
|
||||
"packages.adapters.smtp",
|
||||
]:
|
||||
if name not in sys.modules:
|
||||
sys.modules[name] = types.ModuleType(name)
|
||||
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.user_repository"].SQLAlchemyUserRepository = MagicMock
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.duplication_repository"].SQLAlchemyDuplicationRecordRepository = (
|
||||
MagicMock
|
||||
)
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.session"].build_session_factory = MagicMock(
|
||||
return_value=(MagicMock(), MagicMock())
|
||||
)
|
||||
sys.modules["packages.adapters.redis"].NoopSessionStore = MagicMock
|
||||
sys.modules["packages.adapters.redis"].SessionStore = MagicMock
|
||||
sys.modules["packages.adapters.smtp"].EmailConfig = MagicMock
|
||||
sys.modules["packages.adapters.smtp"].NoopEmailService = MagicMock
|
||||
sys.modules["packages.adapters.smtp"].get_email_service = MagicMock()
|
||||
|
||||
# packages.application (UseCases) — 使用真实逻辑
|
||||
app_mod = types.ModuleType("packages.application")
|
||||
|
||||
@dataclass
|
||||
class UploadForDuplicationCommand:
|
||||
user_id: str
|
||||
filename: str
|
||||
file_size: int
|
||||
storage_key: str
|
||||
duration_seconds: float = 0.0
|
||||
|
||||
class UploadForDuplicationUseCase:
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def execute(self, cmd):
|
||||
record = DuplicationRecord.create(
|
||||
user_id=cmd.user_id,
|
||||
filename=cmd.filename,
|
||||
file_size=cmd.file_size,
|
||||
storage_key=cmd.storage_key,
|
||||
)
|
||||
return self.repo.create(record)
|
||||
|
||||
class ListDuplicationRecordsUseCase:
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def execute(self, user_id, *, offset=0, limit=50):
|
||||
if not user_id.strip():
|
||||
raise ValueError("user_id 不能为空")
|
||||
return self.repo.list_by_user(user_id.strip(), offset=offset, limit=limit)
|
||||
|
||||
class GetDuplicationDetailUseCase:
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def execute(self, record_id):
|
||||
return self.repo.get(record_id)
|
||||
|
||||
class DeleteDuplicationRecordUseCase:
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def execute(self, record_id):
|
||||
return self.repo.delete(record_id)
|
||||
|
||||
class RetryDuplicationUseCase:
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def execute(self, record_id):
|
||||
record = self.repo.get(record_id)
|
||||
if record is None:
|
||||
return None
|
||||
record.status = "pending"
|
||||
record.error_message = ""
|
||||
record.duplicate_rate = None
|
||||
record.duplicate_count = 0
|
||||
record.segments = []
|
||||
return self.repo.update(record)
|
||||
|
||||
app_mod.UploadForDuplicationCommand = UploadForDuplicationCommand
|
||||
app_mod.UploadForDuplicationUseCase = UploadForDuplicationUseCase
|
||||
app_mod.ListDuplicationRecordsUseCase = ListDuplicationRecordsUseCase
|
||||
app_mod.GetDuplicationDetailUseCase = GetDuplicationDetailUseCase
|
||||
app_mod.DeleteDuplicationRecordUseCase = DeleteDuplicationRecordUseCase
|
||||
app_mod.RetryDuplicationUseCase = RetryDuplicationUseCase
|
||||
sys.modules["packages.application"] = app_mod
|
||||
|
||||
# app.config
|
||||
config_mod = types.ModuleType("app.config")
|
||||
|
||||
class _Settings:
|
||||
JWT_SECRET_KEY = "test-secret-key-for-dup-api-tests"
|
||||
DATABASE_URL = "sqlite:///test.db"
|
||||
REDIS_URL = "redis://localhost:6379/0"
|
||||
ENABLE_REDIS_SESSIONS = False
|
||||
SMTP_HOST = ""
|
||||
SMTP_PORT = 587
|
||||
SMTP_USER = ""
|
||||
SMTP_PASSWORD = ""
|
||||
SMTP_FROM_EMAIL = ""
|
||||
SMTP_FROM_NAME = ""
|
||||
SMTP_USE_TLS = False
|
||||
ENABLE_EMAIL_DELIVERY = False
|
||||
OSS_DIRECT_UPLOAD_MAX_MB = 100
|
||||
OSS_BUCKET_NAME = "test-bucket"
|
||||
OSS_ENDPOINT = "oss-cn-hangzhou.aliyuncs.com"
|
||||
OSS_ACCESS_KEY_ID = "test-key"
|
||||
OSS_ACCESS_KEY_SECRET = "test-secret"
|
||||
|
||||
config_mod.settings = _Settings()
|
||||
config_mod.get_settings = lambda: _Settings()
|
||||
sys.modules["app.config"] = config_mod
|
||||
|
||||
# app.auth
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedUser:
|
||||
user: User
|
||||
session_id: str | None = None
|
||||
token_type: str | None = None
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return AuthenticatedUser(user=User())
|
||||
|
||||
auth_mod = types.ModuleType("app.auth")
|
||||
auth_mod.AuthenticatedUser = AuthenticatedUser
|
||||
auth_mod.get_current_user = _mock_get_current_user
|
||||
sys.modules["app.auth"] = auth_mod
|
||||
|
||||
# app.dependencies
|
||||
deps_mod = types.ModuleType("app.dependencies")
|
||||
deps_mod.get_db_session = MagicMock()
|
||||
deps_mod.get_duplication_repository = MagicMock()
|
||||
sys.modules["app.dependencies"] = deps_mod
|
||||
|
||||
# app.core.storage
|
||||
storage_mod = types.ModuleType("app.core.storage")
|
||||
|
||||
class OSSStorageService:
|
||||
def upload_file(self, content, key, content_type=None):
|
||||
pass
|
||||
|
||||
def get_storage_service():
|
||||
return OSSStorageService()
|
||||
|
||||
storage_mod.OSSStorageService = OSSStorageService
|
||||
storage_mod.get_storage_service = get_storage_service
|
||||
sys.modules["app.core.storage"] = storage_mod
|
||||
for ns in ["app.core"]:
|
||||
if ns not in sys.modules:
|
||||
sys.modules[ns] = types.ModuleType(ns)
|
||||
sys.modules["app.core"].storage = storage_mod
|
||||
|
||||
# app.schemas.duplication
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class DuplicateSegmentResponse(BaseModel):
|
||||
id: str
|
||||
source_start: float
|
||||
source_end: float
|
||||
matched_video_id: str
|
||||
matched_video_name: str
|
||||
matched_start: float
|
||||
matched_end: float
|
||||
similarity: float
|
||||
|
||||
class DuplicationRecordResponse(BaseModel):
|
||||
id: str
|
||||
filename: str
|
||||
file_size: int
|
||||
duration_seconds: float = 0.0
|
||||
status: str = "pending"
|
||||
duplicate_rate: float | None = None
|
||||
duplicate_count: int = 0
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
class DuplicationDetailResponse(DuplicationRecordResponse):
|
||||
segments: list[DuplicateSegmentResponse] = Field(default_factory=list)
|
||||
|
||||
class DuplicationUploadResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
message: str
|
||||
|
||||
dup_schemas_mod = types.ModuleType("app.schemas.duplication")
|
||||
dup_schemas_mod.DuplicateSegmentResponse = DuplicateSegmentResponse
|
||||
dup_schemas_mod.DuplicationRecordResponse = DuplicationRecordResponse
|
||||
dup_schemas_mod.DuplicationDetailResponse = DuplicationDetailResponse
|
||||
dup_schemas_mod.DuplicationUploadResponse = DuplicationUploadResponse
|
||||
sys.modules["app.schemas.duplication"] = dup_schemas_mod
|
||||
sys.modules.setdefault("app.schemas", types.ModuleType("app.schemas"))
|
||||
sys.modules["app.schemas"].duplication = dup_schemas_mod
|
||||
|
||||
return User, AuthenticatedUser, DuplicationRecord, DuplicateSegment
|
||||
|
||||
|
||||
User, AuthenticatedUser, DuplicationRecord, DuplicateSegment = _install_mocks()
|
||||
|
||||
# ---------- 导入被测路由模块 ----------
|
||||
for ns in ["app", "app.api", "app.api.routes"]:
|
||||
if ns not in sys.modules:
|
||||
sys.modules[ns] = types.ModuleType(ns)
|
||||
|
||||
import importlib.util
|
||||
|
||||
_route_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"apps",
|
||||
"api",
|
||||
"app",
|
||||
"api",
|
||||
"routes",
|
||||
"duplication.py",
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"app.api.routes.duplication",
|
||||
_route_path,
|
||||
)
|
||||
duplication = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["app.api.routes.duplication"] = duplication
|
||||
_spec.loader.exec_module(duplication)
|
||||
|
||||
# 路由模块已导入,立即恢复原始模块,避免污染后续测试文件的 collection
|
||||
for _k, _v in _SAVED_MODULES.items():
|
||||
sys.modules[_k] = _v
|
||||
# 删除本文件新增的、原始不存在的 mock 模块
|
||||
for _k in [
|
||||
"packages.domain.entities",
|
||||
"packages.domain.duplication",
|
||||
"packages.ports.user_repository",
|
||||
"packages.ports.duplication_repository",
|
||||
"packages.adapters.sqlalchemy_impl.user_repository",
|
||||
"packages.adapters.sqlalchemy_impl.duplication_repository",
|
||||
"packages.adapters.sqlalchemy_impl.session",
|
||||
"packages.adapters.redis",
|
||||
"packages.adapters.smtp",
|
||||
"packages.application",
|
||||
"app.config",
|
||||
"app.auth",
|
||||
"app.dependencies",
|
||||
"app.core.storage",
|
||||
"app.schemas.duplication",
|
||||
"app.api.routes.duplication",
|
||||
]:
|
||||
if _k not in _SAVED_MODULES and _k in sys.modules:
|
||||
del sys.modules[_k]
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 内存 Repository + Fixtures
|
||||
# 1. 内存 Repository + 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -437,22 +51,22 @@ class InMemoryDuplicationRepo:
|
||||
def __init__(self):
|
||||
self.records: dict[str, DuplicationRecord] = {}
|
||||
|
||||
def create(self, record):
|
||||
def create(self, record: DuplicationRecord) -> DuplicationRecord:
|
||||
self.records[record.id] = record
|
||||
return record
|
||||
|
||||
def get(self, record_id):
|
||||
def get(self, record_id: str) -> DuplicationRecord | None:
|
||||
return self.records.get(record_id)
|
||||
|
||||
def list_by_user(self, user_id, *, offset=0, limit=50):
|
||||
def list_by_user(self, user_id: str, *, offset: int = 0, limit: int = 50) -> list[DuplicationRecord]:
|
||||
all_records = [r for r in self.records.values() if r.user_id == user_id]
|
||||
return all_records[offset : offset + limit]
|
||||
|
||||
def update(self, record):
|
||||
def update(self, record: DuplicationRecord) -> DuplicationRecord:
|
||||
self.records[record.id] = record
|
||||
return record
|
||||
|
||||
def delete(self, record_id):
|
||||
def delete(self, record_id: str) -> bool:
|
||||
if record_id in self.records:
|
||||
del self.records[record_id]
|
||||
return True
|
||||
@@ -475,7 +89,12 @@ def _make_user(**overrides) -> User:
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _make_record(user_id="user-test-001", status="pending", filename="test.mp4", **kw):
|
||||
def _make_record(
|
||||
user_id: str = "user-test-001",
|
||||
status: str = "pending",
|
||||
filename: str = "test.mp4",
|
||||
**kw: Any,
|
||||
) -> DuplicationRecord:
|
||||
"""创建测试用 DuplicationRecord 并设置状态。"""
|
||||
record = DuplicationRecord(
|
||||
id=uuid4().hex,
|
||||
@@ -496,35 +115,9 @@ def _make_record(user_id="user-test-001", status="pending", filename="test.mp4",
|
||||
return record
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _restore_modules_after_tests():
|
||||
"""测试结束后恢复被 mock 覆盖的原始模块,避免污染其他测试文件。"""
|
||||
yield
|
||||
# 恢复原始模块
|
||||
for _k, _v in _SAVED_MODULES.items():
|
||||
sys.modules[_k] = _v
|
||||
# 删除本文件新增的 mock 模块(不在原始 sys.modules 中的)
|
||||
_mock_keys = [
|
||||
"packages.domain.entities",
|
||||
"packages.domain.duplication",
|
||||
"packages.ports.user_repository",
|
||||
"packages.ports.duplication_repository",
|
||||
"packages.adapters.sqlalchemy_impl.user_repository",
|
||||
"packages.adapters.sqlalchemy_impl.duplication_repository",
|
||||
"packages.adapters.sqlalchemy_impl.session",
|
||||
"packages.adapters.redis",
|
||||
"packages.adapters.smtp",
|
||||
"packages.application",
|
||||
"app.config",
|
||||
"app.auth",
|
||||
"app.dependencies",
|
||||
"app.core.storage",
|
||||
"app.schemas.duplication",
|
||||
"app.api.routes.duplication",
|
||||
]
|
||||
for _k in _mock_keys:
|
||||
if _k not in _SAVED_MODULES and _k in sys.modules:
|
||||
del sys.modules[_k]
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -535,8 +128,8 @@ def repo():
|
||||
@pytest.fixture
|
||||
def client(repo):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
app = FastAPI()
|
||||
app.include_router(duplication.router)
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
|
||||
def _override_current_user():
|
||||
return AuthenticatedUser(user=_make_user())
|
||||
@@ -545,15 +138,15 @@ def client(repo):
|
||||
return repo
|
||||
|
||||
def _override_storage():
|
||||
from app.core.storage import OSSStorageService
|
||||
return MagicMock()
|
||||
|
||||
return OSSStorageService()
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_duplication_repository] = _override_dup_repo
|
||||
test_app.dependency_overrides[get_storage_service] = _override_storage
|
||||
|
||||
app.dependency_overrides[duplication.get_current_user] = _override_current_user
|
||||
app.dependency_overrides[duplication.get_duplication_repository] = _override_dup_repo
|
||||
app.dependency_overrides[duplication.get_storage_service] = _override_storage
|
||||
yield TestClient(test_app)
|
||||
|
||||
return TestClient(app)
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -604,9 +197,7 @@ class TestListDuplicationRecords:
|
||||
|
||||
def test_only_returns_current_user_records(self, client, repo):
|
||||
"""只返回当前用户的记录。"""
|
||||
# 当前用户 user-test-001
|
||||
r1 = _make_record(user_id="user-test-001", filename="mine.mp4")
|
||||
# 其他用户
|
||||
r2 = _make_record(user_id="other-user", filename="other.mp4")
|
||||
repo.create(r1)
|
||||
repo.create(r2)
|
||||
@@ -721,7 +312,6 @@ class TestDeleteDuplicationRecord:
|
||||
|
||||
resp = client.delete(f"/records/{record.id}")
|
||||
assert resp.status_code == 404
|
||||
# 记录应仍然存在
|
||||
assert repo.get(record.id) is not None
|
||||
|
||||
def test_delete_idempotent(self, client, repo):
|
||||
@@ -774,24 +364,23 @@ class TestRetryDuplication:
|
||||
resp = client.post(f"/records/{record.id}/retry")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_retry_completed_record_still_resets(self, client, repo):
|
||||
"""重试已完成记录 — 路由层不校验状态,直接重置。"""
|
||||
def test_retry_completed_record_returns_400(self, client, repo):
|
||||
"""重试已完成记录 — 真实 UseCase 校验状态,非 failed 返回 400。"""
|
||||
record = _make_record(status="completed")
|
||||
repo.create(record)
|
||||
|
||||
resp = client.post(f"/records/{record.id}/retry")
|
||||
# 路由层允许重试(状态校验在用例层)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "pending"
|
||||
assert resp.status_code == 400
|
||||
assert "failed" in resp.json()["detail"]
|
||||
|
||||
def test_retry_pending_record(self, client, repo):
|
||||
"""重试 pending 状态的记录。"""
|
||||
def test_retry_pending_record_returns_400(self, client, repo):
|
||||
"""重试 pending 状态的记录 — 真实 UseCase 返回 400。"""
|
||||
record = _make_record(status="pending")
|
||||
repo.create(record)
|
||||
|
||||
resp = client.post(f"/records/{record.id}/retry")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "pending"
|
||||
assert resp.status_code == 400
|
||||
assert "failed" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user