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

- 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:
灵应
2026-07-05 10:14:27 +08:00
parent b0e9266571
commit b3e1902b65
10 changed files with 457 additions and 593 deletions
+36 -55
View File
@@ -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>
+83 -8
View File
@@ -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>
+13 -13
View File
@@ -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;
}
+12 -12
View File
@@ -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;
}
+50 -24
View File
@@ -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>
+72 -4
View File
@@ -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) {
+2 -2
View File
@@ -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);
}
}
+45 -456
View File
@@ -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"]
# ---------------------------------------------------------------------------