From b3e1902b65cb90da4fa60777d8b20689c787bb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Sun, 5 Jul 2026 10:14:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AC=AC=E4=B8=89=E6=89=B9UI=E6=89=93?= =?UTF-8?q?=E7=A3=A8=20=E2=80=94=20=E7=94=9F=E6=88=90=E5=8E=86=E5=8F=B2/?= =?UTF-8?q?=E4=BB=AA=E8=A1=A8=E7=9B=98/=E9=9F=B3=E8=89=B2=E5=85=8B?= =?UTF-8?q?=E9=9A=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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检查通过,零错误 --- apps/web/src/pages/dashboard/Dashboard.tsx | 91 ++-- apps/web/src/pages/dashboard/dashboard.css | 91 +++- .../pages/editing-planner/EditingPlanner.css | 139 +++++ .../components/GenerationHistoryModal.tsx | 24 +- apps/web/src/pages/products/products.css | 26 +- apps/web/src/pages/titles/titles.css | 24 +- apps/web/src/pages/voice-clone/VoiceClone.tsx | 74 ++- .../web/src/pages/voice-clone/voice-clone.css | 76 ++- apps/web/src/pages/voices/voices.css | 4 +- tests/integration/test_duplication_api.py | 501 ++---------------- 10 files changed, 457 insertions(+), 593 deletions(-) diff --git a/apps/web/src/pages/dashboard/Dashboard.tsx b/apps/web/src/pages/dashboard/Dashboard.tsx index 4bcc0b208..5a20e2cbc 100644 --- a/apps/web/src/pages/dashboard/Dashboard.tsx +++ b/apps/web/src/pages/dashboard/Dashboard.tsx @@ -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 = { + video: , + appstore: , + thunderbolt: , + database: , + filetext: , +}; + /* ============================================================ * 组件 * ============================================================ */ @@ -250,7 +267,7 @@ const Dashboard: React.FC = () => {
{/* ── 欢迎头部 ─────────────────────────────────────────── */}
-

{getGreeting()},创作者 👋

+

{getGreeting()},创作者

{formatDate()} — 欢迎回到小小剪辑控制台

@@ -266,7 +283,7 @@ const Dashboard: React.FC = () => { className="xx-kpi-icon" style={{ background: item.iconGradient }} > - {item.icon} + {iconMap[item.icon] ?? item.icon}
{item.value}
{item.label}
@@ -282,13 +299,7 @@ const Dashboard: React.FC = () => { {/* ── 主内容区:左侧任务+图表 / 右侧公告 ──────────────── */}
{/* 左列 */} -
+
{/* 最近任务 */}
@@ -345,12 +356,7 @@ const Dashboard: React.FC = () => {

本周生成趋势

- + 共 {weeklyData.reduce((s, d) => s + d.value, 0)} 次
@@ -381,7 +387,7 @@ const Dashboard: React.FC = () => {
{/* 右列 — 公告 + 存储用量 */} -
+

系统公告

@@ -402,17 +408,8 @@ const Dashboard: React.FC = () => {
{/* 存储用量 */} -
-
- 存储用量 -
+
+
存储用量
@@ -427,25 +424,9 @@ const Dashboard: React.FC = () => {
{/* ── 快速入口 ─────────────────────────────────────────── */} -
-
-

- 快速入口 -

+
+
+

快速入口

{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}

{entry.title}

{entry.description}

diff --git a/apps/web/src/pages/dashboard/dashboard.css b/apps/web/src/pages/dashboard/dashboard.css index 7c6aeef43..d49b27080 100644 --- a/apps/web/src/pages/dashboard/dashboard.css +++ b/apps/web/src/pages/dashboard/dashboard.css @@ -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); +} + /* ============================================================ 响应式 ============================================================ */ diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index e38b79b20..46d3aebb1 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -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; +} + diff --git a/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx b/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx index 056867638..d2afe1dcb 100644 --- a/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx +++ b/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx @@ -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 = { - 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 = ({ open, @@ -37,9 +31,7 @@ const GenerationHistoryModal: React.FC = ({ >

生成历史

- +
{loading ? ( @@ -52,7 +44,7 @@ const GenerationHistoryModal: React.FC = ({
) : history.length === 0 ? ( -
暂无生成记录
+
暂无生成记录
) : ( @@ -65,20 +57,14 @@ const GenerationHistoryModal: React.FC = ({ {history.map((gen) => { - const style = STATUS_STYLES[gen.status] || DEFAULT_STATUS_STYLE; + const statusClass = `ep-gh-status-tag--${gen.status}`; return ( diff --git a/apps/web/src/pages/products/products.css b/apps/web/src/pages/products/products.css index 4aeb647ea..1cc72234e 100644 --- a/apps/web/src/pages/products/products.css +++ b/apps/web/src/pages/products/products.css @@ -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; } diff --git a/apps/web/src/pages/titles/titles.css b/apps/web/src/pages/titles/titles.css index 6d7c2ccfa..08d12fc6f 100644 --- a/apps/web/src/pages/titles/titles.css +++ b/apps/web/src/pages/titles/titles.css @@ -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; } diff --git a/apps/web/src/pages/voice-clone/VoiceClone.tsx b/apps/web/src/pages/voice-clone/VoiceClone.tsx index c38a8c571..68a78003d 100644 --- a/apps/web/src/pages/voice-clone/VoiceClone.tsx +++ b/apps/web/src/pages/voice-clone/VoiceClone.tsx @@ -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 = ({ className="vc-card-action-btn" onClick={() => onEdit(voice)} > - ✏️ + - + + @@ -89,7 +105,7 @@ const VoiceCloneCard: React.FC = ({
- 🎤 +

{voice.name}

@@ -103,11 +119,11 @@ const VoiceCloneCard: React.FC = ({ {/* 元信息 */}
- 🎵 + 时长:{formatDuration(voice.duration_seconds)}
- 📅 + 创建于:{createdDate}
@@ -128,7 +144,7 @@ const VoiceCloneCard: React.FC = ({ disabled={isProcessing} onClick={() => onUse(voice)} > - ✨ 使用此音色 + 使用此音色
@@ -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 (
- ✨ 克隆新音色 + 克隆新音色 } /> - {/* 加载状态 */} + {/* 加载状态 — 骨架屏 */} {isLoading && ( -
-
⏳
-

加载中...

+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))}
)} @@ -265,11 +291,11 @@ const VoiceClone: React.FC = () => { {/* 空状态 */} {!isLoading && voices.length === 0 && (
-
🎤
+

还没有克隆音色

上传你的声音,AI将克隆你的专属音色

)} @@ -279,7 +305,7 @@ const VoiceClone: React.FC = () => {
{toasts.map((t) => (
- {t.type === "success" ? "✅" : "❌"} {t.message} + {t.type === "success" ? : } {t.message}
))}
diff --git a/apps/web/src/pages/voice-clone/voice-clone.css b/apps/web/src/pages/voice-clone/voice-clone.css index d92671c0e..680168696 100644 --- a/apps/web/src/pages/voice-clone/voice-clone.css +++ b/apps/web/src/pages/voice-clone/voice-clone.css @@ -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) { diff --git a/apps/web/src/pages/voices/voices.css b/apps/web/src/pages/voices/voices.css index 34771f4ba..859fc6af5 100644 --- a/apps/web/src/pages/voices/voices.css +++ b/apps/web/src/pages/voices/voices.css @@ -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); } } diff --git a/tests/integration/test_duplication_api.py b/tests/integration/test_duplication_api.py index 11df27519..f67aec6d0 100644 --- a/tests/integration/test_duplication_api.py +++ b/tests/integration/test_duplication_api.py @@ -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"] # ---------------------------------------------------------------------------
{gen.generation_task_id.slice(0, 8)}... - + {PLAN_STATUS_LABELS[gen.status] || gen.status}