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}