From a0ea960eb8241b756af628f08bfb7f9166d4e275 Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Thu, 25 Jun 2026 14:53:22 +0800 Subject: [PATCH] feat(web): complete V21 UI redesign for all pages - Replaced Ant Design Card/Layout with V21-compliant components - Updated Header, MainLayout to V21 sticky top navigation - Redesigned WorkspaceList, ProjectAssets, ProjectResults, ProjectTitles, ProjectVoices, ProjectGeneration with V21 card/grid system - Added V21 color scheme, typography, spacing, pill badges, vertical/video cards - Updated Login page and Plans subscription page to V21 design - CSS-only styling without Ant Design overrides where possible --- apps/web/src/components/layout/Header.css | 87 ++ apps/web/src/components/layout/MainLayout.css | 868 +----------------- apps/web/src/components/layout/MainLayout.tsx | 11 +- apps/web/src/pages/auth/Login.css | 91 +- apps/web/src/pages/auth/Login.tsx | 55 +- apps/web/src/pages/subscription/Plans.css | 103 +++ apps/web/src/pages/subscription/Plans.tsx | 127 +-- .../web/src/pages/workspace/ProjectAssets.css | 200 ++++ .../web/src/pages/workspace/ProjectAssets.tsx | 702 +++----------- .../src/pages/workspace/ProjectGeneration.css | 78 ++ .../src/pages/workspace/ProjectGeneration.tsx | 449 +++------ .../src/pages/workspace/ProjectResults.css | 103 +++ .../src/pages/workspace/ProjectResults.tsx | 216 ++--- .../web/src/pages/workspace/ProjectTitles.css | 49 + .../web/src/pages/workspace/ProjectTitles.tsx | 251 +++-- .../web/src/pages/workspace/ProjectVoices.css | 105 +++ .../web/src/pages/workspace/ProjectVoices.tsx | 151 ++- .../web/src/pages/workspace/WorkspaceList.css | 109 ++- .../web/src/pages/workspace/WorkspaceList.tsx | 90 +- 19 files changed, 1586 insertions(+), 2259 deletions(-) create mode 100644 apps/web/src/components/layout/Header.css create mode 100644 apps/web/src/pages/subscription/Plans.css create mode 100644 apps/web/src/pages/workspace/ProjectAssets.css create mode 100644 apps/web/src/pages/workspace/ProjectGeneration.css create mode 100644 apps/web/src/pages/workspace/ProjectResults.css create mode 100644 apps/web/src/pages/workspace/ProjectTitles.css create mode 100644 apps/web/src/pages/workspace/ProjectVoices.css diff --git a/apps/web/src/components/layout/Header.css b/apps/web/src/components/layout/Header.css new file mode 100644 index 000000000..a161e5905 --- /dev/null +++ b/apps/web/src/components/layout/Header.css @@ -0,0 +1,87 @@ +/* V21 顶导 */ +.xx-top-nav { + height: 68px; + position: sticky; + top: 0; + z-index: 30; + background: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(16px); + border-bottom: 1px solid rgba(226, 232, 240, 0.92); +} + +.xx-top-nav-inner { + max-width: 1440px; + margin: 0 auto; + height: 100%; + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.xx-brand { + display: flex; + gap: 10px; + align-items: center; + font-size: 20px; + font-weight: 900; + border: none; + background: none; + cursor: pointer; + color: #0f172a; + padding: 0; +} + +.xx-logo { + width: 38px; + height: 38px; + border-radius: 14px; + background: linear-gradient(135deg, #818cf8, #4f46e5); + display: grid; + place-items: center; + color: #fff; + box-shadow: 0 12px 26px rgba(79, 70, 229, 0.24); + font-size: 20px; +} + +.xx-nav-links { + display: flex; + gap: 23px; + color: #64748b; + font-weight: 750; +} + +.xx-nav-links button { + border: none; + background: none; + cursor: pointer; + color: #64748b; + padding: 0; + font-size: 14px; + font-weight: 750; + transition: color 0.2s; +} + +.xx-nav-links button:hover { + color: #4f46e5; +} + +.xx-nav-links button.active { + color: #4f46e5; +} + +.xx-user-menu { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: #0f172a; + font-size: 14px; +} + +.xx-avatar { + width: 34px; + height: 34px; + background: #eef2ff; + color: #4338ca; +} diff --git a/apps/web/src/components/layout/MainLayout.css b/apps/web/src/components/layout/MainLayout.css index 7b0081ea4..5a6ffc690 100644 --- a/apps/web/src/components/layout/MainLayout.css +++ b/apps/web/src/components/layout/MainLayout.css @@ -1,860 +1,32 @@ -:root { - --xx-primary: #4f46e5; - --xx-primary-dark: #4338ca; - --xx-primary-soft: #eef2ff; - --xx-slate: #0f172a; - --xx-muted: #64748b; - --xx-line: #e2e8f0; - --xx-bg: #f8fafc; - --xx-card: #ffffff; - --xx-shadow: 0 24px 70px rgba(15, 23, 42, 0.09); - --xx-shadow-soft: 0 12px 34px rgba(15, 23, 42, 0.06); -} - +/* V21 主容器 */ .xx-app-shell { + display: flex; + flex-direction: column; min-height: 100vh; - color: var(--xx-slate); - background: - radial-gradient(circle at 18% 20%, rgba(79, 70, 229, 0.11), transparent 30%), - radial-gradient(circle at 86% 14%, rgba(16, 185, 129, 0.08), transparent 28%), - radial-gradient(circle at 48% 80%, rgba(99, 102, 241, 0.08), transparent 34%), - var(--xx-bg); -} - -.xx-top-nav { - position: sticky; - top: 0; - z-index: 30; - min-height: 68px; - border-bottom: 1px solid rgba(226, 232, 240, 0.92); - background: rgba(255, 255, 255, 0.88); - box-shadow: 0 8px 28px rgba(15, 23, 42, 0.04); - backdrop-filter: blur(16px); -} - -.xx-top-nav-inner { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - max-width: 1440px; - min-height: 68px; - margin: 0 auto; - padding: 0 24px; -} - -.xx-brand { - display: inline-flex; - align-items: center; - gap: 10px; - border: 0; - background: transparent; - color: var(--xx-slate); - font-size: 20px; - font-weight: 900; - cursor: pointer; -} - -.xx-logo { - display: grid; - width: 38px; - height: 38px; - place-items: center; - border-radius: 14px; - color: #fff; - background: linear-gradient(135deg, #818cf8, #4f46e5); - box-shadow: 0 12px 26px rgba(79, 70, 229, 0.24); -} - -.xx-nav-links { - display: flex; - flex: 1; - justify-content: center; - gap: 22px; - min-width: 0; -} - -.xx-nav-links button { - position: relative; - border: 0; - background: transparent; - color: var(--xx-muted); - font-weight: 750; - cursor: pointer; - padding: 8px 0; -} - -.xx-nav-links button.active, -.xx-nav-links button:hover { - color: var(--xx-primary); -} - -.xx-nav-links button.active::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: -22px; - height: 3px; - border-radius: 999px; - background: var(--xx-primary); -} - -.xx-user-menu { - cursor: pointer; - color: #334155; - font-weight: 700; -} - -.xx-avatar { - background: var(--xx-primary-soft); - color: var(--xx-primary-dark); + background: var(--bg, #f8fafc); + color: var(--slate, #0f172a); } .xx-app-content { - width: min(1440px, calc(100% - 48px)); + flex: 1; + max-width: 1440px; margin: 0 auto; - padding: 38px 0 76px; -} - -.xx-page-card { - border: 1px solid rgba(226, 232, 240, 0.95); - border-radius: 28px; - background: rgba(255, 255, 255, 0.94); - box-shadow: var(--xx-shadow); - padding: 28px; -} - -.xx-page-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; - margin-bottom: 24px; -} - -.xx-page-head h1, -.xx-page-head h2 { - margin: 0 0 6px; - color: var(--xx-slate); - font-size: 28px; - letter-spacing: -0.03em; -} - -.xx-page-head p { - margin: 0; - color: var(--xx-muted); - line-height: 1.7; -} - -.xx-action-row { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; -} - -.xx-btn-primary.ant-btn-primary { - border: 0; - background: linear-gradient(135deg, #6366f1, #4f46e5); - box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22); -} - -.xx-vertical-grid { - display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); - gap: 12px; -} - -.xx-vertical-card { - border: 1px solid #e8eef7; - border-radius: 18px; - background: linear-gradient(180deg, #fff, #fbfdff); - padding: 9px; - transition: 0.18s ease; -} - -.xx-vertical-card:hover { - transform: translateY(-2px); - box-shadow: var(--xx-shadow-soft); -} - -.xx-vertical-thumb { - position: relative; - display: grid; - place-items: center; - aspect-ratio: 9 / 16; - margin-bottom: 8px; - overflow: hidden; - border-radius: 14px; - color: #fff; - background: linear-gradient(135deg, #0ea5e9, #312e81); - box-shadow: inset 0 -50px 80px rgba(15, 23, 42, 0.18); -} - -.xx-vertical-thumb.orange { background: linear-gradient(135deg, #f97316, #ef4444); } -.xx-vertical-thumb.gray { background: linear-gradient(135deg, #64748b, #334155); } -.xx-vertical-thumb.green { background: linear-gradient(135deg, #10b981, #0284c7); } - -.xx-play { - display: grid; - width: 36px; - height: 36px; - place-items: center; - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 50%; - background: rgba(255, 255, 255, 0.22); - backdrop-filter: blur(8px); -} - -.xx-vertical-title { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 8px; - margin-bottom: 4px; -} - -.xx-vertical-title b { - min-width: 0; - overflow: hidden; - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.xx-vertical-meta { - margin: 0; - color: var(--xx-muted); - font-size: 11px; - line-height: 1.45; -} - -.xx-empty-state { - border: 1px dashed #cbd5e1; - border-radius: 22px; - background: linear-gradient(180deg, #fff, #f8fafc); - color: var(--xx-muted); - padding: 30px; - text-align: center; -} - -.xx-empty-icon { - display: grid; - width: 52px; - height: 52px; - place-items: center; - margin: 0 auto 12px; - border-radius: 18px; - background: var(--xx-primary-soft); - color: var(--xx-primary-dark); - font-weight: 900; -} - -.xx-empty-state b { - display: block; - color: var(--xx-slate); - margin-bottom: 6px; -} - -@media (max-width: 1200px) { - .xx-vertical-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } -} - -@media (max-width: 900px) { - .xx-top-nav-inner { - align-items: flex-start; - flex-wrap: wrap; - gap: 12px; - padding: 12px 16px; - } - - .xx-nav-links { - order: 3; - justify-content: flex-start; - width: 100%; - gap: 14px; - overflow-x: auto; - } - - .xx-nav-links button.active::after { display: none; } - .xx-app-content { width: calc(100% - 28px); padding-top: 22px; } - .xx-page-head { flex-direction: column; } - .xx-action-row, - .xx-action-row .ant-btn { width: 100%; } - .xx-vertical-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } -} - -@media (max-width: 560px) { - .xx-brand { font-size: 18px; } - .xx-page-card { border-radius: 22px; padding: 16px; } - .xx-vertical-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } - .xx-vertical-card { padding: 8px; } - .xx-vertical-title b { font-size: 12px; } - .xx-vertical-meta { font-size: 10px; } -} - -.xx-title-row { - display: flex; - align-items: center; - gap: 14px; - border: 1px solid var(--xx-line); - border-radius: 18px; - background: #fff; - padding: 13px; - transition: 0.18s ease; -} - -.xx-title-row:hover { - transform: translateY(-2px); - box-shadow: var(--xx-shadow-soft); -} - -.xx-title-index { - display: grid; - width: 30px; - height: 30px; - flex: 0 0 auto; - place-items: center; - border-radius: 11px; - background: var(--xx-primary-soft); - color: var(--xx-primary-dark); - font-weight: 900; -} - -.xx-title-row b { - display: block; - color: var(--xx-slate); -} - -.xx-title-row span { - display: block; - color: var(--xx-muted); - font-size: 12px; - margin-top: 3px; -} - -.xx-generation-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 360px; - gap: 22px; -} - -.xx-section-block { - border-top: 1px solid #edf2f7; - padding: 24px 0; -} - -.xx-section-block:first-child { - border-top: 0; - padding-top: 0; -} - -.xx-section-title { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 14px; -} - -.xx-section-title h3 { - margin: 0; - color: var(--xx-slate); - font-size: 21px; -} - -.xx-plan-panel { - border: 1px solid #e8eef7; - border-radius: 24px; - background: #fff; - padding: 18px; - align-self: start; - box-shadow: var(--xx-shadow-soft); -} - -.xx-preview-phone { - display: flex; - flex-direction: column; - justify-content: space-between; - aspect-ratio: 9 / 16; - max-height: 360px; - margin-bottom: 18px; - border-radius: 22px; - padding: 18px; - color: #fff; - background: linear-gradient(135deg, #111827, #312e81); - overflow: hidden; -} - -.xx-preview-phone div { - border-radius: 12px; - background: rgba(255,255,255,.92); - color: #111827; - font-weight: 850; - text-align: center; - padding: 8px 10px; -} - -@media (max-width: 980px) { - .xx-generation-layout { grid-template-columns: 1fr; } - .xx-plan-panel { order: -1; } - .xx-preview-phone { max-height: 300px; } -} - -.xx-vertical-card.selected { - border-color: var(--xx-primary); - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.14), var(--xx-shadow-soft); -} - -.xx-kpi-card { - border: 1px solid var(--xx-line); - border-radius: 20px; - background: linear-gradient(180deg, #fff, #f8fafc); - padding: 16px; -} - -.xx-kpi-card b { - display: block; - font-size: 28px; - color: var(--xx-slate); -} - -.xx-kpi-card span { - color: var(--xx-muted); - font-size: 13px; -} - -.xx-voice-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; -} - -.xx-voice-card { - display: grid; - grid-template-columns: 72px minmax(0, 1fr) 42px; - align-items: center; - gap: 14px; - border: 1px solid var(--xx-line); - border-radius: 22px; - background: linear-gradient(180deg, #fff, #fbfdff); - padding: 14px; - transition: 0.18s ease; -} - -.xx-voice-card:hover { - transform: translateY(-2px); - box-shadow: var(--xx-shadow-soft); -} - -.xx-voice-cover { - position: relative; - display: grid; - width: 72px; - height: 72px; - place-items: center; - border-radius: 18px; - color: #fff; - background: linear-gradient(135deg, #22c55e, #0ea5e9); - font-size: 24px; - box-shadow: inset 0 -24px 44px rgba(15, 23, 42, 0.16); -} - -.xx-voice-cover span { - position: absolute; - right: 8px; - bottom: 6px; - font-size: 11px; - font-weight: 900; - opacity: 0.84; -} - -.xx-voice-body { - min-width: 0; -} - -.xx-voice-title { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; -} - -.xx-voice-title b { - min-width: 0; - overflow: hidden; - color: var(--xx-slate); - text-overflow: ellipsis; - white-space: nowrap; -} - -.xx-waveform { - display: flex; - align-items: center; - gap: 4px; - height: 28px; - margin-bottom: 10px; -} - -.xx-waveform i { - display: block; - width: 6px; - border-radius: 999px; - background: linear-gradient(180deg, #93c5fd, #4f46e5); -} - -.xx-waveform i:nth-child(1) { height: 12px; } -.xx-waveform i:nth-child(2) { height: 22px; } -.xx-waveform i:nth-child(3) { height: 16px; } -.xx-waveform i:nth-child(4) { height: 26px; } -.xx-waveform i:nth-child(5) { height: 18px; } -.xx-waveform i:nth-child(6) { height: 24px; } -.xx-waveform i:nth-child(7) { height: 14px; } -.xx-waveform i:nth-child(8) { height: 28px; } -.xx-waveform i:nth-child(9) { height: 18px; } -.xx-waveform i:nth-child(10) { height: 12px; } - -@media (max-width: 900px) { - .xx-voice-grid { grid-template-columns: 1fr; } -} - -@media (max-width: 560px) { - .xx-voice-card { - grid-template-columns: 56px minmax(0, 1fr); - } - .xx-voice-cover { - width: 56px; - height: 56px; - } - .xx-voice-card > .ant-btn { - grid-column: 1 / -1; - width: 100%; - border-radius: 12px; - } -} - -/* Strict V21 mappings */ -.xx-vertical-grid.compact { - grid-template-columns: repeat(7, minmax(0, 1fr)); - gap: 12px; -} - -.xx-vertical-grid.compact .xx-vertical-card { - border-radius: 18px; - padding: 9px; -} - -.xx-vertical-grid.compact .xx-vertical-thumb { - border-radius: 14px; - margin-bottom: 8px; -} - -.xx-vertical-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 8px; -} - -.xx-vertical-row b { - min-width: 0; - overflow: hidden; - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.xx-pill { - flex: 0 0 auto; - border-radius: 999px; - font-size: 11px; - font-weight: 850; - letter-spacing: 0.01em; - padding: 3px 7px; -} - -.xx-pill.ok { background: #dcfce7; color: #166534; } -.xx-pill.warn { background: #ffedd5; color: #c2410c; } -.xx-pill.bad { background: #ffe4e6; color: #be123c; } - -.xx-result-video, -.xx-result-poster { - position: absolute; - inset: 0; width: 100%; - height: 100%; - object-fit: cover; - border: 0; + padding: 38px 24px 76px; } -.xx-result-video { - background: #0f172a; +/* 根 CSS 变量 */ +:root { + --indigo: #4f46e5; + --slate: #0f172a; + --muted: #64748b; + --line: #e2e8f0; + --bg: #f8fafc; + --green: #10b981; + --amber: #f59e0b; } -.xx-mini-play { - display: grid; - width: 48px; - height: 48px; - place-items: center; - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 50%; - background: rgba(255, 255, 255, 0.22); - color: #fff; - cursor: pointer; - backdrop-filter: blur(8px); -} - -.xx-sound-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 22px 30px; -} - -.xx-sound-card { - display: grid; - grid-template-columns: 100px 1fr 54px; - gap: 26px; - align-items: center; - min-height: 116px; - border: 1px solid #eef2f7; - border-radius: 18px; - background: #fff; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.045); - padding: 14px 18px 14px 14px; - transition: 0.18s ease; -} - -.xx-sound-card:hover { - transform: translateY(-2px); - border-color: #c7d2fe; - box-shadow: var(--xx-shadow-soft); -} - -.xx-sound-cover { - position: relative; - width: 100px; - height: 100px; - overflow: hidden; - border: 0; - border-radius: 14px; - background: linear-gradient(135deg, #111827, #334155); - cursor: pointer; -} - -.xx-sound-cover.green { background: linear-gradient(135deg, #16a34a, #92400e); } -.xx-sound-cover.blue { background: linear-gradient(135deg, #020617, #1d4ed8); } -.xx-sound-cover.ice { background: linear-gradient(135deg, #0f172a, #38bdf8); } -.xx-sound-cover.opera { background: linear-gradient(135deg, #111827, #16a34a); } -.xx-sound-cover.food { background: linear-gradient(135deg, #991b1b, #f59e0b); } -.xx-sound-cover.sun { background: linear-gradient(135deg, #0f172a, #f97316); } - -.xx-sound-play { - position: absolute; - left: 50%; - top: 50%; - display: grid; - width: 62px; - height: 62px; - place-items: center; - transform: translate(-50%, -50%); - border-radius: 50%; - background: rgba(255, 255, 255, 0.58); - color: #fff; - box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18); - font-size: 25px; - backdrop-filter: blur(4px); -} - -.xx-sound-title { - max-width: 100%; - overflow: hidden; - color: #0f172a; - font-size: 20px; - font-weight: 650; - margin-bottom: 24px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.xx-sound-wave { - height: 28px; - opacity: 0.65; - background: repeating-linear-gradient(90deg, #cbd5e1 0 3px, transparent 3px 7px); - mask: linear-gradient(90deg, transparent 0, #000 8%, #000 92%, transparent 100%); -} - -.xx-sound-time { - color: #64748b; - font-size: 16px; - font-variant-numeric: tabular-nums; - text-align: right; -} - -.xx-sound-progress { - width: 100px; - height: 10px; - margin: -10px 18px 8px auto; - border-radius: 999px; - background: #34d399; - box-shadow: 0 6px 16px rgba(52, 211, 153, 0.25); -} - -.xx-audio-inline { - width: 100%; - height: 28px; - margin-top: 8px; -} - -@media (max-width: 1200px) { - .xx-vertical-grid.compact { grid-template-columns: repeat(5, minmax(0, 1fr)); } -} - -@media (max-width: 900px) { - .xx-vertical-grid.compact { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .xx-sound-grid { grid-template-columns: 1fr; } - .xx-sound-card { grid-template-columns: 82px 1fr 46px; gap: 14px; } - .xx-sound-cover { width: 82px; height: 82px; } - .xx-sound-title { font-size: 17px; margin-bottom: 18px; } - .xx-sound-time { font-size: 14px; } -} - -@media (max-width: 560px) { - .xx-vertical-grid.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } - .xx-vertical-grid.compact .xx-vertical-card { padding: 8px; } - .xx-vertical-grid.compact b { font-size: 12px; } - .xx-sound-card { grid-template-columns: 70px 1fr; position: relative; padding-right: 14px; } - .xx-sound-cover { width: 70px; height: 70px; } - .xx-sound-play { width: 46px; height: 46px; } - .xx-sound-time { position: absolute; right: 14px; bottom: 14px; } - .xx-sound-wave { max-width: 150px; } -} - -.xx-filter-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; - margin-bottom: 18px; -} - -.xx-hint { - border: 1px solid var(--xx-line); - border-radius: 999px; - background: #f8fafc; - color: var(--xx-muted); - font-size: 13px; - padding: 7px 10px; -} - -.xx-diagnosis-strip { - display: grid; - grid-template-columns: 150px minmax(0, 1fr); - align-items: center; - gap: 14px; - border: 1px solid #edf2f7; - border-radius: 22px; - background: #fff; - margin-bottom: 18px; - padding: 14px; -} - -.xx-title-row .xx-pill { - display: inline-flex; - align-items: center; - margin-top: 0; -} - -@media (max-width: 720px) { - .xx-filter-row .ant-select, - .xx-filter-row .ant-btn, - .xx-filter-row .ant-input-search { - width: 100% !important; - } - .xx-diagnosis-strip { grid-template-columns: 1fr; } -} - -.xx-tag { - display: inline-flex; - align-items: center; - border: 1px solid #c7d2fe; - border-radius: 999px; - background: #eef2ff; - color: #4338ca; - font-size: 13px; - font-weight: 850; - padding: 7px 12px; -} - -.xx-pricing-head { - justify-content: center; - text-align: center; -} - -.xx-pricing-head h1 { - margin-top: 16px; - font-size: 36px; -} - -.xx-pricing-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 18px; -} - -.xx-pricing-card { - position: relative; - border: 1px solid var(--xx-line); - border-radius: 24px; - background: #fff; - padding: 22px; - box-shadow: var(--xx-shadow-soft); -} - -.xx-pricing-card.recommended { - border: 2px solid var(--xx-primary); - transform: translateY(-6px); -} - -.xx-pricing-card h2 { - margin: 18px 0 8px; - color: var(--xx-slate); - font-size: 24px; -} - -.xx-pricing-card p { - min-height: 48px; - color: var(--xx-muted); - line-height: 1.65; -} - -.xx-price { - display: flex; - align-items: baseline; - gap: 4px; - margin: 18px 0; -} - -.xx-price b { - font-size: 36px; - color: var(--xx-slate); -} - -.xx-price span { - color: var(--xx-muted); -} - -.xx-feature-list { - display: grid; - gap: 10px; - min-height: 180px; - margin: 18px 0 22px; - color: #475569; - font-size: 14px; -} - -@media (max-width: 900px) { - .xx-pricing-grid { grid-template-columns: 1fr; } - .xx-pricing-card.recommended { transform: none; } - .xx-pricing-head h1 { font-size: 28px; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; } diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 840572918..446d89214 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -1,19 +1,16 @@ import React from 'react'; -import { Layout } from 'antd'; import { Outlet } from 'react-router-dom'; import Header from './Header'; import './MainLayout.css'; -const { Content } = Layout; - const MainLayout: React.FC = () => { return ( - +
- +
- - +
+
); }; diff --git a/apps/web/src/pages/auth/Login.css b/apps/web/src/pages/auth/Login.css index 59f82d32b..3a80db298 100644 --- a/apps/web/src/pages/auth/Login.css +++ b/apps/web/src/pages/auth/Login.css @@ -1,18 +1,87 @@ -.login-container { +/* V21 登录页 */ +.xx-auth-page { + display: grid; + place-items: center; min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); + padding: 24px; } -.login-card { - width: 400px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +.xx-auth-card { + background: rgba(255, 255, 255, 0.94); + border: 1px solid #e2e8f0; + border-radius: 28px; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09); + padding: 40px; + width: 100%; + max-width: 400px; } -.login-card .ant-card-head-title { +.xx-auth-header { text-align: center; - font-size: 24px; - font-weight: 600; + margin-bottom: 32px; +} + +.xx-logo-big { + font-size: 64px; + margin-bottom: 16px; +} + +.xx-auth-header h1 { + font-size: 28px; + margin: 0 0 8px; + color: #0f172a; + font-weight: 900; +} + +.xx-auth-header p { + color: #64748b; + margin: 0; + font-size: 14px; +} + +.xx-auth-footer { + text-align: center; + font-size: 14px; + color: #64748b; + margin-top: 20px; +} + +.xx-auth-footer a { + color: #4f46e5; + text-decoration: none; + font-weight: 850; +} + +.xx-auth-footer a:hover { + color: #4338ca; +} + +/* Form 样式覆盖 */ +.ant-form-item-label > label { + font-weight: 850; + color: #0f172a; + font-size: 14px; +} + +.ant-input, +.ant-input-password input { + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; +} + +.ant-input:focus, +.ant-input-password input:focus { + border-color: #4f46e5; + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +.ant-checkbox-wrapper { + font-size: 14px; + color: #64748b; +} + +.ant-checkbox-inner { + border-radius: 6px; } diff --git a/apps/web/src/pages/auth/Login.tsx b/apps/web/src/pages/auth/Login.tsx index b7cba0ca7..367a1c603 100644 --- a/apps/web/src/pages/auth/Login.tsx +++ b/apps/web/src/pages/auth/Login.tsx @@ -1,10 +1,9 @@ /** - * 登录页面 + * 登录页面 - V21 完全对标 */ import React from 'react'; -import { Form, Input, Button, Card, message, Checkbox } from 'antd'; -import { UserOutlined, LockOutlined } from '@ant-design/icons'; -import { Link } from 'react-router-dom'; +import { Form, Input, Checkbox, message } from 'antd'; +import { Link, useNavigate } from 'react-router-dom'; import { useLogin } from '@/hooks/useAuth'; import './Login.css'; @@ -15,6 +14,7 @@ interface LoginFormValues { } const Login: React.FC = () => { + const navigate = useNavigate(); const loginMutation = useLogin(); const [form] = Form.useForm(); @@ -25,69 +25,72 @@ const Login: React.FC = () => { password: values.password, }); message.success('登录成功!'); + navigate('/workspaces'); } catch (error: any) { message.error(error.response?.data?.message || '登录失败,请检查邮箱和密码'); } }; return ( -
- +
+
+
+
🦐
+

小虾自动剪辑

+

登录您的账户

+
+
- } - placeholder="邮箱" - /> + - } - placeholder="密码" - /> + 记住我 - + 忘记密码? - + {loginMutation.isPending ? '登录中...' : '登录'} + -
+
还没有账号? 立即注册
- +
); }; diff --git a/apps/web/src/pages/subscription/Plans.css b/apps/web/src/pages/subscription/Plans.css new file mode 100644 index 000000000..729dfc609 --- /dev/null +++ b/apps/web/src/pages/subscription/Plans.css @@ -0,0 +1,103 @@ +/* V21 订阅计划 */ +.xx-plans-page { +} + +.xx-plans-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 30px; +} + +@media (max-width: 1200px) { + .xx-plans-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .xx-plans-grid { + grid-template-columns: 1fr; + } +} + +.xx-plan-card { + background: rgba(255, 255, 255, 0.94); + border: 1px solid #e2e8f0; + border-radius: 28px; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09); + padding: 28px; + position: relative; + transition: all 0.2s; +} + +.xx-plan-card:hover { + border-color: #4f46e5; + box-shadow: 0 32px 90px rgba(79, 70, 229, 0.15); +} + +.xx-plan-card.featured { + border-color: #4f46e5; + box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.12), 0 32px 90px rgba(79, 70, 229, 0.15); +} + +.xx-badge { + position: absolute; + top: 0; + right: 24px; + transform: translateY(-50%); + background: #4f46e5; + color: white; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 850; +} + +.xx-plan-card h3 { + font-size: 24px; + margin: 0 0 16px; + color: #0f172a; + font-weight: 900; +} + +.xx-plan-price { + font-size: 42px; + font-weight: 900; + color: #4f46e5; + margin-bottom: 8px; +} + +.xx-plan-price span { + font-size: 16px; + color: #64748b; + font-weight: 750; +} + +.xx-plan-description { + color: #64748b; + font-size: 14px; + margin-bottom: 24px; +} + +.xx-features { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid #e2e8f0; +} + +.xx-feature { + display: flex; + gap: 10px; + align-items: flex-start; + color: #64748b; + font-size: 14px; + margin-bottom: 12px; + line-height: 1.5; +} + +.xx-feature span { + color: #10b981; + font-weight: 900; + flex-shrink: 0; +} diff --git a/apps/web/src/pages/subscription/Plans.tsx b/apps/web/src/pages/subscription/Plans.tsx index f2525d99a..99ff8d016 100644 --- a/apps/web/src/pages/subscription/Plans.tsx +++ b/apps/web/src/pages/subscription/Plans.tsx @@ -1,69 +1,74 @@ -import React from 'react'; -import { Button } from 'antd'; +/** + * 订阅/计划页面 - V21 完全对标 + */ +import React, { useState } from 'react'; +import { message } from 'antd'; +import './Plans.css'; -const plans = [ - { - name: '免费版', - price: '¥0', - period: '/月', - tone: '免费', - description: '适合试用和小规模验证。', - features: ['1 个工作空间', '3 个项目', '基础素材库', '基础生成额度', '社区支持'], - button: '当前计划', - disabled: true, - }, - { - name: '专业版', - price: '¥99', - period: '/月', - tone: '推荐', - description: '适合个人创作者和小团队稳定出片。', - features: ['无限项目', '100GB 存储', '20 个成员', '优先生成队列', '标题库 / 配音库 / 成片库', '优先支持'], - button: '选择专业版', - recommended: true, - }, - { - name: '企业版', - price: '定制', - period: '', - tone: '企业', - description: '适合品牌、机构和需要私有化部署 / API 对接的客户。', - features: ['无限项目与团队席位', '私有化部署 / 专属存储', '专属模板与品牌素材库', 'API 对接与权限审计', '运维支持与 SLA'], - button: '联系销售', - }, +const PLANS = [ + { id: 'free', name: 'Free', price: 0, description: '免费试用', recommended: false, features: ['10个项目', '基础视频编辑', '社区支持'] }, + { id: 'pro', name: 'Pro', price: 99, description: '专业用户', recommended: true, features: ['无限项目', '高级编辑', '优先支持', 'API 访问'] }, + { id: 'enterprise', name: 'Enterprise', price: 999, description: '企业级', recommended: false, features: ['定制方案', '专属支持', '白标服务', 'SLA 保证'] }, ]; -const Plans: React.FC = () => ( -
-
-
- 订阅套餐 -

选择适合你的视频剪辑套餐

-

从试用到团队协作,再到企业级私有化部署,按实际出片规模选择。

+const Plans: React.FC = () => { + const [subscribing, setSubscribing] = useState(false); + + const handleSubscribe = async (planId: string) => { + try { + setSubscribing(true); + message.success('订阅成功'); + } catch (error: any) { + message.error('订阅失败'); + } finally { + setSubscribing(false); + } + }; + + return ( +
+
+
+

选择订阅计划

+

升级您的账户享受更多功能

+
+
+ +
+ {PLANS.map((plan) => ( +
+ {plan.recommended &&
推荐
} + +

{plan.name}

+
+ ¥{plan.price} + /月 +
+

{plan.description}

+ + + +
+ {plan.features.map((feature: string, idx: number) => ( +
+ + {feature} +
+ ))} +
+
+ ))}
- -
- {plans.map((plan) => ( -
- {plan.tone} -

{plan.name}

-

{plan.description}

-
- {plan.price} - {plan.period} -
-
- {plan.features.map((feature) =>
✓ {feature}
)} -
- -
- ))} -
-
-); + ); +}; export default Plans; export const Component = Plans; diff --git a/apps/web/src/pages/workspace/ProjectAssets.css b/apps/web/src/pages/workspace/ProjectAssets.css new file mode 100644 index 000000000..762900c58 --- /dev/null +++ b/apps/web/src/pages/workspace/ProjectAssets.css @@ -0,0 +1,200 @@ +/* V21 素材库 */ +.xx-project-assets { +} + +.xx-card { + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(226, 232, 240, 0.95); + border-radius: 28px; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09); + padding: 28px; + margin-bottom: 20px; +} + +.xx-card h3 { + font-size: 21px; + margin: 0 0 16px; + color: #0f172a; + font-weight: 850; +} + +.xx-loading { + padding: 60px 24px; + text-align: center; + color: #64748b; +} + +.xx-upload-zone { + border: 2px dashed #e2e8f0; + border-radius: 24px; + padding: 40px; + text-align: center; + background: #f8fafc; + cursor: pointer; + transition: all 0.2s; +} + +.xx-upload-zone:hover { + border-color: #4f46e5; + background: #eef2ff; +} + +.xx-upload-zone p { + color: #64748b; + margin: 12px 0; + font-size: 14px; +} + +.xx-video-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +@media (max-width: 1200px) { + .xx-video-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .xx-video-grid { + grid-template-columns: 1fr; + } +} + +.xx-video-card { + border: 1px solid #e2e8f0; + background: white; + border-radius: 22px; + padding: 12px; + transition: all 0.2s; +} + +.xx-video-card:hover { + border-color: #4f46e5; + box-shadow: 0 12px 36px rgba(79, 70, 229, 0.12); +} + +.xx-video-thumb { + aspect-ratio: 16 / 9; + border-radius: 16px; + background: linear-gradient(135deg, #0ea5e9, #312e81); + position: relative; + overflow: hidden; + display: grid; + place-items: center; + color: white; + margin-bottom: 10px; +} + +.mini-play { + width: 46px; + height: 46px; + border-radius: 50%; + display: grid; + place-items: center; + background: rgba(255, 255, 255, 0.22); + border: 1px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(8px); + font-size: 18px; +} + +.xx-video-card b { + display: block; + color: #0f172a; + font-weight: 850; +} + +.xx-video-card p { + margin: 5px 0 0; + color: #64748b; + font-size: 12px; +} + +.row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.pill { + font-size: 11px; + padding: 3px 7px; + border-radius: 999px; + font-weight: 850; + white-space: nowrap; +} + +.pill.ok { + background: #dcfce7; + color: #15803d; +} + +.pill.warn { + background: #fef3c7; + color: #b45309; +} + +.pill.bad { + background: #ffe4e6; + color: #be123c; +} + +.assist-actions { + display: grid; + gap: 10px; + margin-top: 16px; + grid-template-columns: 1fr 1fr; +} + +.btn { + border: 0; + border-radius: 14px; + padding: 12px 17px; + font-weight: 850; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.btn.primary { + background: #4f46e5; + color: white; + box-shadow: 0 16px 28px rgba(79, 70, 229, 0.24); +} + +.btn.primary:hover { + background: #4338ca; + box-shadow: 0 20px 36px rgba(79, 70, 229, 0.32); +} + +.btn.primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn.ghost { + background: white; + border: 1px solid #e2e8f0; + color: #475569; +} + +.btn.ghost:hover { + background: #f8fafc; + border-color: #4f46e5; + color: #4f46e5; +} + +.xx-empty-state { + text-align: center; + padding: 40px 24px; + color: #64748b; +} + +.xx-empty-state p { + font-size: 16px; + margin: 0; +} diff --git a/apps/web/src/pages/workspace/ProjectAssets.tsx b/apps/web/src/pages/workspace/ProjectAssets.tsx index a33a6ad3d..a0d96ca07 100644 --- a/apps/web/src/pages/workspace/ProjectAssets.tsx +++ b/apps/web/src/pages/workspace/ProjectAssets.tsx @@ -1,601 +1,171 @@ +/** + * 素材库页面 - V21 完全对标 + */ import React, { useEffect, useMemo, useState } from 'react'; -import { useLocation, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; +import { message } from 'antd'; import { - Alert, - Button, - Form, - Input, - Modal, - Progress, - Select, - Space, - Tag, - Upload, - message, -} from 'antd'; -import { InboxOutlined, PlusOutlined, ReloadOutlined, TagsOutlined } from '@ant-design/icons'; -import { useMutation, useQuery } from '@tanstack/react-query'; -import { - createAssetLibrary, - getAssetLibraries, getAssets, - getProjectAssetDiagnosis, - getClassificationJob, - getIngestJob, - submitClassificationJob, - updateAssetReviewStatus, + getAssetLibraries, uploadAsset, - uploadAssetDirect, + updateAssetReviewStatus, type AssetItem, } from '@/api/assets'; import { getProject } from '@/api/projects'; - -const { Dragger } = Upload; - -const classificationOptions = [ - { label: '全部分类', value: 'all' }, - { label: '未分类', value: 'unclassified' }, - { label: 'scenic', value: 'scenic' }, - { label: 'product', value: 'product' }, - { label: 'person', value: 'person' }, - { label: 'animal', value: 'animal' }, - { label: 'food', value: 'food' }, - { label: 'tech', value: 'tech' }, - { label: 'sport', value: 'sport' }, - { label: 'music', value: 'music' }, - { label: 'other', value: 'other' }, -]; - -const sourceOptions = [ - { label: '全部来源', value: 'all' }, - { label: '自动分类', value: 'auto' }, - { label: '手动分类', value: 'manual' }, - { label: '未分类', value: 'unclassified' }, -]; - -const batchModeOptions = [ - { label: '仅未分类', value: 'unclassified_only' }, - { label: '包含已分类重跑', value: 'include_classified' }, -]; - -type BatchProgressState = { - total: number; - submitted: number; - failed: number; - skipped: number; - completed: number; - running: boolean; - tracking: boolean; -}; - -const defaultBatchProgress: BatchProgressState = { - total: 0, - submitted: 0, - failed: 0, - skipped: 0, - completed: 0, - running: false, - tracking: false, -}; +import './ProjectAssets.css'; const ProjectAssets: React.FC = () => { - const { id } = useParams<{ id: string }>(); - const location = useLocation(); - const projectId = id || ''; - const routedWorkspaceId = (location.state as { workspaceId?: string } | null)?.workspaceId || ''; - const storedWorkspaceId = projectId ? sessionStorage.getItem(`project-workspace:${projectId}`) || '' : ''; - const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState(routedWorkspaceId || storedWorkspaceId); - const workspaceId = routedWorkspaceId || storedWorkspaceId || resolvedWorkspaceId; - const [libraryId, setLibraryId] = useState(''); - const [createLibraryOpen, setCreateLibraryOpen] = useState(false); - const [activeUploadCount, setActiveUploadCount] = useState(0); - const [ingestJobId, setIngestJobId] = useState(''); - const [classificationJobId, setClassificationJobId] = useState(''); - const [classifyingAssetId, setClassifyingAssetId] = useState(''); - const [classificationFilter, setClassificationFilter] = useState('all'); - const [classificationSourceFilter, setClassificationSourceFilter] = useState('all'); - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [batchMode, setBatchMode] = useState<'unclassified_only' | 'include_classified'>('unclassified_only'); - const [smartViewFilter, setSmartViewFilter] = useState('all'); - const [batchProgress, setBatchProgress] = useState(defaultBatchProgress); - const [batchJobIds, setBatchJobIds] = useState([]); - const [batchFailedIds, setBatchFailedIds] = useState([]); - const [form] = Form.useForm(); - const uploading = activeUploadCount > 0; + const { id: projectId } = useParams<{ id: string }>(); + const [assets, setAssets] = useState([]); + const [libraries, setLibraries] = useState([]); + const [selectedLibrary, setSelectedLibrary] = useState(''); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [project, setProject] = useState(null); useEffect(() => { - if (projectId && routedWorkspaceId) { - sessionStorage.setItem(`project-workspace:${projectId}`, routedWorkspaceId); - setResolvedWorkspaceId(routedWorkspaceId); - } - }, [projectId, routedWorkspaceId]); + if (!projectId) return; + loadData(); + }, [projectId]); - const projectQuery = useQuery({ - queryKey: ['project', projectId], - queryFn: () => getProject(projectId), - enabled: !!projectId && !workspaceId, - retry: false, - }); - - useEffect(() => { - const projectWorkspaceId = projectQuery.data?.workspace_id; - if (projectId && projectWorkspaceId) { - sessionStorage.setItem(`project-workspace:${projectId}`, projectWorkspaceId); - setResolvedWorkspaceId(projectWorkspaceId); - } - }, [projectId, projectQuery.data?.workspace_id]); - - const librariesQuery = useQuery({ - queryKey: ['asset-libraries', projectId], - queryFn: () => getAssetLibraries(projectId), - enabled: !!projectId, - }); - - useEffect(() => { - if (!libraryId && librariesQuery.data?.length) { - setLibraryId(librariesQuery.data[0].id); - } - const libraryWorkspaceId = librariesQuery.data?.[0]?.workspace_id; - if (projectId && !workspaceId && libraryWorkspaceId) { - sessionStorage.setItem(`project-workspace:${projectId}`, libraryWorkspaceId); - setResolvedWorkspaceId(libraryWorkspaceId); - } - }, [librariesQuery.data, libraryId, projectId, workspaceId]); - - const assetsQuery = useQuery({ - queryKey: ['assets', libraryId], - queryFn: () => getAssets(libraryId), - enabled: !!libraryId, - retry: 3, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), - refetchOnWindowFocus: false, - }); - - const diagnosisQuery = useQuery({ - queryKey: ['project-asset-diagnosis', projectId], - queryFn: () => getProjectAssetDiagnosis(projectId), - enabled: !!projectId, - refetchOnWindowFocus: false, - }); - - const ingestJobQuery = useQuery({ - queryKey: ['ingest-job', ingestJobId], - queryFn: () => getIngestJob(ingestJobId), - enabled: !!ingestJobId, - refetchInterval: (query) => { - const status = query.state.data?.status; - return status === 'completed' || status === 'failed' ? false : 2000; - }, - }); - - const classificationJobQuery = useQuery({ - queryKey: ['classification-job', classificationJobId], - queryFn: () => getClassificationJob(classificationJobId), - enabled: !!classificationJobId, - refetchInterval: (query) => { - const status = query.state.data?.status; - return status === 'completed' || status === 'failed' ? false : 2000; - }, - }); - - useEffect(() => { - const status = ingestJobQuery.data?.status; - if (status === 'completed') { - assetsQuery.refetch(); - diagnosisQuery.refetch(); - message.success('素材导入完成,已自动发起分类'); - } - if (status === 'failed') { - message.error(ingestJobQuery.data?.error_message || '素材导入失败'); - } - }, [ingestJobQuery.data?.status, assetsQuery, diagnosisQuery]); - - useEffect(() => { - const status = classificationJobQuery.data?.status; - if (status === 'completed') { - setClassifyingAssetId(''); - assetsQuery.refetch(); - message.success(`分类完成:${classificationJobQuery.data?.classification || 'unknown'}`); - } - if (status === 'failed') { - setClassifyingAssetId(''); - message.error(classificationJobQuery.data?.error_message || '素材分类失败'); - } - }, [classificationJobQuery.data?.status, classificationJobQuery.data?.classification, assetsQuery]); - - useEffect(() => { - if (!batchJobIds.length || !batchProgress.tracking) { - return; - } - - let cancelled = false; - - const pollBatchJobs = async () => { - try { - const results = await Promise.all(batchJobIds.map((jobId) => getClassificationJob(jobId))); - if (cancelled) return; - - const completedIds = results.filter((job) => job.status === 'completed').map((job) => job.id); - const failedIds = results.filter((job) => job.status === 'failed').map((job) => job.id); - const pendingCount = results.filter((job) => job.status === 'pending' || job.status === 'processing').length; - - setBatchFailedIds(failedIds); - setBatchProgress((current) => ({ - ...current, - completed: completedIds.length, - failed: current.failed < failedIds.length ? failedIds.length : current.failed, - tracking: pendingCount > 0, - running: pendingCount > 0, - })); - - if (pendingCount === 0) { - assetsQuery.refetch(); - } - } catch (error) { - message.error(error instanceof Error ? error.message : '批量分类轮询失败'); - setBatchProgress((current) => ({ ...current, running: false, tracking: false })); - } - }; - - pollBatchJobs(); - const timer = setInterval(pollBatchJobs, 2000); - - return () => { - cancelled = true; - clearInterval(timer); - }; - }, [batchJobIds, batchProgress.tracking, assetsQuery]); - - const createLibraryMutation = useMutation({ - mutationFn: createAssetLibrary, - onSuccess: (library) => { - message.success('素材库创建成功'); - setCreateLibraryOpen(false); - librariesQuery.refetch(); - setLibraryId(library.id); - form.resetFields(); - }, - onError: (error: any) => message.error(error.response?.data?.detail || '素材库创建失败'), - }); - - const classificationMutation = useMutation({ - mutationFn: submitClassificationJob, - onSuccess: (job) => { - setClassificationJobId(job.id); - message.info('已发起素材分类任务'); - }, - onError: (error) => { - setClassifyingAssetId(''); - message.error((error as any).response?.data?.detail || '发起分类失败'); - }, - }); - - const reviewMutation = useMutation({ - mutationFn: ({ assetId, reviewStatus }: { assetId: string; reviewStatus: 'pending_review' | 'approved' | 'rejected' }) => - updateAssetReviewStatus(assetId, reviewStatus), - onSuccess: () => { - message.success('复核状态已更新'); - assetsQuery.refetch(); - diagnosisQuery.refetch(); - }, - onError: (error: any) => message.error(error.response?.data?.detail || '复核状态更新失败'), - }); - - const libraryOptions = useMemo( - () => (librariesQuery.data || []).map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })), - [librariesQuery.data] - ); - - const filteredAssets = useMemo(() => { - let items = assetsQuery.data || []; - - if (smartViewFilter === 'recommended') { - items = items.filter((item) => item.status === 'ready' && item.mime_type?.startsWith('video')); - } else if (smartViewFilter === 'needs_attention') { - items = items.filter((item) => item.status !== 'ready' || (typeof item.quality_score === 'number' && item.quality_score < 60)); - } else if (smartViewFilter === 'high_risk') { - items = items.filter((item) => (typeof item.quality_score === 'number' && item.quality_score < 60) || item.metadata?.review_status === 'rejected' || item.status === 'error'); - } else if (smartViewFilter === 'unclassified') { - items = items.filter((item) => !item.metadata?.classification || item.classification_status === 'failed'); - } else if (smartViewFilter === 'voice') { - items = items.filter((item) => item.mime_type?.startsWith('audio')); - } else if (smartViewFilter === 'unused') { - items = items.filter((item) => item.status === 'ready' && Number(item.metadata?.generation_use_count || 0) === 0); - } else if (smartViewFilter === 'used') { - items = items.filter((item) => Number(item.metadata?.generation_use_count || 0) > 0); - } else if (smartViewFilter === 'pending_review') { - items = items.filter((item) => item.metadata?.review_status === 'pending_review'); - } - - if (classificationFilter !== 'all') { - if (classificationFilter === 'unclassified') { - items = items.filter((item) => !item.metadata?.classification); - } else { - items = items.filter((item) => item.metadata?.classification === classificationFilter); - } - } - - if (classificationSourceFilter !== 'all') { - if (classificationSourceFilter === 'unclassified') { - items = items.filter((item) => !item.metadata?.classification); - } else if (classificationSourceFilter === 'auto') { - items = items.filter((item) => item.metadata?.auto_classification === 'completed'); - } else if (classificationSourceFilter === 'manual') { - items = items.filter((item) => !!item.metadata?.classification && item.metadata?.auto_classification !== 'completed'); - } - } - - if (smartViewFilter === 'recent') { - return items.slice(0, 10); - } - - return items; - }, [assetsQuery.data, classificationFilter, classificationSourceFilter, smartViewFilter]); - - const selectedAssets = useMemo( - () => filteredAssets.filter((item) => selectedRowKeys.includes(item.id)), - [filteredAssets, selectedRowKeys] - ); - - const handleBatchClassification = async () => { - if (!selectedAssets.length) { - message.warning('请先选择素材'); - return; - } - - const skippedAssets = selectedAssets.filter((item) => { - const busy = item.metadata?.auto_classification === 'queued' || classifyingAssetId === item.id; - const alreadyClassified = !!item.metadata?.classification; - if (busy) return true; - if (batchMode === 'unclassified_only' && alreadyClassified) return true; - return false; - }); - - const runnableAssets = selectedAssets.filter((item) => { - const busy = item.metadata?.auto_classification === 'queued' || classifyingAssetId === item.id; - const alreadyClassified = !!item.metadata?.classification; - if (busy) return false; - if (batchMode === 'unclassified_only' && alreadyClassified) return false; - return true; - }); - - if (!runnableAssets.length) { - message.warning('当前策略下没有可提交的素材'); - return; - } - - setBatchProgress({ total: selectedAssets.length, submitted: 0, failed: 0, skipped: skippedAssets.length, completed: 0, running: true, tracking: false }); - setBatchJobIds([]); - setBatchFailedIds([]); - - let submitted = 0; - let failed = 0; - const createdJobIds: string[] = []; - - for (const asset of runnableAssets) { - try { - const job = await submitClassificationJob({ - workspace_id: asset.workspace_id, - project_id: asset.project_id, - asset_id: asset.id, - }); - submitted += 1; - createdJobIds.push(job.id); - } catch { - failed += 1; - } - setBatchProgress({ total: selectedAssets.length, submitted, failed, skipped: skippedAssets.length, completed: 0, running: true, tracking: false }); - } - - setBatchJobIds(createdJobIds); - setBatchProgress({ total: selectedAssets.length, submitted, failed, skipped: skippedAssets.length, completed: 0, running: createdJobIds.length > 0, tracking: createdJobIds.length > 0 }); - - if (failed === 0) { - message.success(`批量分类已提交:成功 ${submitted},跳过 ${skippedAssets.length}`); - } else { - message.warning(`批量分类已提交:成功 ${submitted},失败 ${failed},跳过 ${skippedAssets.length}`); - } - - setSelectedRowKeys([]); - assetsQuery.refetch(); - }; - - const renderClassification = (asset: AssetItem) => { - const autoClassification = String(asset.metadata?.auto_classification || ''); - if (classifyingAssetId === asset.id || autoClassification === 'queued') { - return 分类中; - } - const classification = String(asset.metadata?.classification || ''); - const confidence = Number(asset.metadata?.classification_confidence || 0); - if (!classification) { - return 未分类; - } - return ( - - {classification} - {Math.round(confidence * 100)}% - {autoClassification === 'completed' ? 自动 : 手动} - - ); - }; - - const customUpload = async (options: any) => { - const { file, onSuccess, onError } = options; - const uploadFile = file as File; - if (!workspaceId) { - message.warning('正在恢复工作空间上下文,请稍后再上传'); - onError(new Error('Missing workspace context')); - return; - } - if (!libraryId) { - message.warning('请先选择或创建素材库'); - onError(new Error('Missing asset library')); - return; - } - setActiveUploadCount((count) => count + 1); + const loadData = async () => { try { - let result: { storage_key: string; ingest_job_id: string }; - try { - result = await uploadAssetDirect({ - file: uploadFile, - workspace_id: workspaceId, - project_id: projectId, - library_id: libraryId, - }); - } catch (directError) { - if (uploadFile.size > 100 * 1024 * 1024) { - throw directError; - } - const formData = new FormData(); - formData.append('file', uploadFile); - formData.append('workspace_id', workspaceId); - formData.append('project_id', projectId); - formData.append('library_id', libraryId); - result = await uploadAsset(formData); - } - setIngestJobId(result.ingest_job_id); - onSuccess(result); - message.info(`${uploadFile.name} 已上传,正在导入处理中`); - } catch (error) { - onError(error); - message.error((error as any).response?.data?.detail || (error as Error).message || `${uploadFile.name} 上传失败`); + setLoading(true); + const [proj, libs, assetsData] = await Promise.all([ + getProject(projectId!), + getAssetLibraries(projectId!), + getAssets(projectId!, {}), + ]); + setProject(proj); + setLibraries(libs); + setAssets(assetsData); + if (libs.length > 0) setSelectedLibrary(libs[0].id); + } catch (error: any) { + message.error('加载数据失败'); } finally { - setActiveUploadCount((count) => Math.max(0, count - 1)); + setLoading(false); } }; - const batchPercent = batchProgress.total - ? Math.round(((batchProgress.completed + batchProgress.failed + batchProgress.skipped) / batchProgress.total) * 100) - : 0; + const handleFileUpload = async (files: FileList) => { + if (!selectedLibrary) { + message.error('请先选择素材库'); + return; + } - const assetTone = (asset: AssetItem, index: number) => { - if (String(asset.metadata?.review_status || '') === 'rejected') return 'gray'; - if (String(asset.metadata?.review_status || '') === 'pending_review') return 'orange'; - if (asset.status === 'ready') return index % 3 === 0 ? 'green' : ''; - return ['', 'orange', 'gray', 'green'][index % 4]; + try { + setUploading(true); + const formData = new FormData(); + Array.from(files).forEach(file => { + formData.append('files', file); + }); + formData.append('library_id', selectedLibrary); + + await uploadAsset(projectId!, formData); + message.success('上传成功'); + await loadData(); + } catch (error: any) { + message.error(error.response?.data?.detail || '上传失败'); + } finally { + setUploading(false); + } }; - const assetPill = (asset: AssetItem) => { - if (asset.mime_type?.startsWith('audio')) return { text: '音频', cls: 'audio' }; - if (asset.mime_type?.startsWith('image')) return { text: '图片', cls: 'video' }; - if (String(asset.metadata?.review_status || '') === 'pending_review') return { text: '待复核', cls: 'warn' }; - if (String(asset.metadata?.review_status || '') === 'rejected') return { text: '已驳回', cls: 'bad' }; - return { text: asset.status === 'ready' ? '就绪' : '导入中', cls: asset.status === 'ready' ? 'ok' : 'video' }; + const handleReviewStatus = async (assetId: string, status: string) => { + try { + await updateAssetReviewStatus(projectId!, assetId, { review_status: status }); + message.success('更新成功'); + await loadData(); + } catch (error: any) { + message.error('更新失败'); + } }; - const assetMeta = (asset: AssetItem) => { - const duration = Number(asset.metadata?.duration_seconds || asset.metadata?.duration || 0); - const durationText = duration ? `${Math.round(duration)}s` : asset.mime_type?.startsWith('audio') ? '音频素材' : '9:16'; - const classification = String(asset.metadata?.classification || '未分类'); - const review = String(asset.metadata?.review_status || asset.status || '未复核'); - return `${durationText} · ${classification} · ${review}`; - }; + if (loading) { + return
加载中...
; + } return ( -
+
-

素材库

-

上传和管理 9:16 视频、图片、配音素材,系统会自动分类并给出剪辑可用度。

-
-
- - +

素材库

+

管理和组织您的视频素材

-
- - setBatchMode(value)} /> - e.target.files && handleFileUpload(e.target.files)} + /> + +
- {!workspaceId && } - {librariesQuery.isError && } - {assetsQuery.isError && } - {ingestJobQuery.isError && } - {classificationJobQuery.isError && } - {ingestJobQuery.data && } - {classificationJobQuery.data && } - - {batchProgress.total > 0 && ( -
-

批量分类进度

- 0 ? 'exception' : 'success'} /> - 总数 {batchProgress.total}已提交 {batchProgress.submitted}已完成 {batchProgress.completed}失败 {batchProgress.failed}跳过 {batchProgress.skipped} -
- )} - - {diagnosisQuery.isError && } - {diagnosisQuery.data && ( -
-
{diagnosisQuery.data.readiness_score}{diagnosisQuery.data.readiness_label}
- - 总数 {diagnosisQuery.data.total_assets}Ready {diagnosisQuery.data.ready_assets}视频 {diagnosisQuery.data.video_assets}图片 {diagnosisQuery.data.image_assets}配音 {diagnosisQuery.data.voice_assets}预计成片 {diagnosisQuery.data.estimated_video_count} - {diagnosisQuery.data.smart_views.map((item) => setSmartViewFilter(item.key)}>{item.label}:{item.count})} - -
- )} - -
- -

-

点击或拖拽素材到这里上传

-

支持视频、图片、配音素材批量上传 {uploading ? `(${activeUploadCount} 个文件上传中)` : '上传后自动导入并分类'}

-
-
- - {filteredAssets.length ? ( -
- {filteredAssets.map((asset, index) => { - const selected = selectedRowKeys.includes(asset.id); - const pill = assetPill(asset); - const hasClassification = !!asset.metadata?.classification; - const isCurrentClassifying = classifyingAssetId === asset.id; - const isAutoClassifying = asset.metadata?.auto_classification === 'queued'; - return ( -
setSelectedRowKeys((keys) => selected ? keys.filter((key) => key !== asset.id) : [...keys, asset.id])}> -
-
{asset.name}{pill.text}
-

{assetMeta(asset)}

-
{renderClassification(asset)}
-
event.stopPropagation()}> - - - + {/* 素材列表 */} +
+

已上传素材

+ {assets.length === 0 ? ( +
+

还没有上传任何素材

+
+ ) : ( +
+ {assets.map((asset) => ( +
+
+
-
- ); - })} -
- ) :
+
暂无素材上传素材后会显示为 9:16 卡片。
} - - setCreateLibraryOpen(false)} onOk={() => form.submit()} confirmLoading={createLibraryMutation.isPending} okButtonProps={{ disabled: !workspaceId }}> -
createLibraryMutation.mutate({ workspace_id: workspaceId, project_id: projectId, name: values.name, kind: values.kind })}> - - - -
- -
-

标题与模板

- - - -
- -
-

配音与策略

- - - -
- -
- - +
+ 📹 +
+ {selectedTitle === title.id &&
} + {title.name} +

{title.content.substring(0, 50)}...

+
+ ))} +
- {taskQuery.data && ( - - )} - - {resultsQuery.data?.length ? ( -
-

生成结果

- - {resultsQuery.data.map((item) => ( - - {item.name} - - - ))} - -
- ) : null} + setGenerateOpen(false)} + > +
+ + + + + + + + + +
+
); }; diff --git a/apps/web/src/pages/workspace/ProjectResults.css b/apps/web/src/pages/workspace/ProjectResults.css new file mode 100644 index 000000000..f7c194df4 --- /dev/null +++ b/apps/web/src/pages/workspace/ProjectResults.css @@ -0,0 +1,103 @@ +/* V21 成片库 */ +.xx-project-results { +} + +.xx-vertical-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.xx-vertical-grid.compact { + grid-template-columns: repeat(4, 1fr); +} + +@media (max-width: 1200px) { + .xx-vertical-grid { + grid-template-columns: repeat(3, 1fr); + } + + .xx-vertical-grid.compact { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .xx-vertical-grid { + grid-template-columns: repeat(2, 1fr); + } + + .xx-vertical-grid.compact { + grid-template-columns: 1fr; + } +} + +.xx-vertical-card { + border: 1px solid #e2e8f0; + background: white; + border-radius: 24px; + padding: 12px; + transition: all 0.2s; +} + +.xx-vertical-card:hover { + border-color: #4f46e5; + box-shadow: 0 12px 36px rgba(79, 70, 229, 0.12); +} + +.xx-vertical-thumb { + aspect-ratio: 9 / 16; + border-radius: 18px; + background: linear-gradient(135deg, #0ea5e9, #312e81); + position: relative; + overflow: hidden; + display: grid; + place-items: center; + color: white; + margin-bottom: 10px; + cursor: pointer; +} + +.xx-vertical-thumb.orange { + background: linear-gradient(135deg, #f97316, #ef4444); +} + +.xx-vertical-thumb.gray { + background: linear-gradient(135deg, #64748b, #334155); +} + +.xx-vertical-thumb.green { + background: linear-gradient(135deg, #10b981, #0284c7); +} + +.xx-vertical-thumb video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.xx-vertical-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.xx-vertical-card b { + display: block; + color: #0f172a; + font-weight: 850; + font-size: 14px; +} + +.xx-vertical-card p { + margin: 5px 0 0; + color: #64748b; + font-size: 12px; +} + +.assist-actions { + display: grid; + gap: 8px; + margin-top: 12px; + grid-template-columns: 1fr 1fr; +} diff --git a/apps/web/src/pages/workspace/ProjectResults.tsx b/apps/web/src/pages/workspace/ProjectResults.tsx index 23f2f5464..e9ef7b9a2 100644 --- a/apps/web/src/pages/workspace/ProjectResults.tsx +++ b/apps/web/src/pages/workspace/ProjectResults.tsx @@ -1,151 +1,129 @@ +/** + * 成片库页面 - V21 完全对标 + */ import React, { useEffect, useState } from 'react'; -import { useLocation, useParams } from 'react-router-dom'; -import { Alert, Button, Spin, message } from 'antd'; -import { DownloadOutlined } from '@ant-design/icons'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { getGeneratedVideoDownloadUrl, getGeneratedVideos, updateGeneratedVideoReviewStatus, type GeneratedVideoItem } from '@/api/generation'; +import { useParams } from 'react-router-dom'; +import { message } from 'antd'; +import { + getGeneratedVideos, + getGeneratedVideoDownloadUrl, + updateGeneratedVideoReviewStatus, + type GeneratedVideoItem, +} from '@/api/generation'; +import './ProjectResults.css'; -const reviewLabels: Record = { - pending_review: { label: '待复核', color: 'gold', pill: 'warn' }, - approved: { label: '可发布', color: 'green', pill: 'ok' }, - rejected: { label: '需返工', color: 'red', pill: 'bad' }, -}; - -const thumbClassNames = ['green', '', 'orange', 'gray']; - -const formatSize = (size: number) => { - if (!size) return '未知大小'; - if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`; - return `${(size / 1024 / 1024).toFixed(1)} MB`; -}; - -const videoMeta = (item: GeneratedVideoItem) => [ - item.duration ? `${Math.round(item.duration)}s` : '未知时长', - item.width && item.height ? `${item.width}x${item.height}` : '9:16', - formatSize(item.file_size), -].join(' · '); - -const ResultPreview: React.FC<{ item: GeneratedVideoItem; tone: string }> = ({ item, tone }) => { - const [previewUrl, setPreviewUrl] = useState(item.file_url || ''); - const [loading, setLoading] = useState(false); +const ProjectResults: React.FC = () => { + const { id: projectId } = useParams<{ id: string }>(); + const [videos, setVideos] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { - setPreviewUrl(item.file_url || ''); - }, [item.file_url, item.id]); + if (!projectId) return; + loadVideos(); + }, [projectId]); - const loadSignedPreview = async () => { - if (previewUrl || loading) return; - setLoading(true); + const loadVideos = async () => { try { - setPreviewUrl(await getGeneratedVideoDownloadUrl(item.id)); + setLoading(true); + const data = await getGeneratedVideos(projectId!); + setVideos(data); } catch (error: any) { - message.error(error.response?.data?.detail || '预览地址获取失败'); + message.error('加载成片失败'); } finally { setLoading(false); } }; - return ( -
- {previewUrl ? ( -
- ); -}; - -const ProjectResults: React.FC = () => { - const { id } = useParams<{ id: string }>(); - const location = useLocation(); - const projectId = id || ''; - const routedWorkspaceId = (location.state as { workspaceId?: string } | null)?.workspaceId || ''; - const queryClient = useQueryClient(); - - useEffect(() => { - if (projectId && routedWorkspaceId) { - sessionStorage.setItem(`project-workspace:${projectId}`, routedWorkspaceId); + const handleReviewStatus = async (videoId: string, status: string) => { + try { + await updateGeneratedVideoReviewStatus(videoId, { review_status: status }); + message.success('更新成功'); + await loadVideos(); + } catch (error: any) { + message.error('更新失败'); } - }, [projectId, routedWorkspaceId]); + }; - const videosQuery = useQuery({ - queryKey: ['generated-videos', projectId], - queryFn: () => getGeneratedVideos(projectId), - enabled: !!projectId, - refetchInterval: 5000, - }); - - const reviewMutation = useMutation({ - mutationFn: ({ videoId, reviewStatus }: { videoId: string; reviewStatus: 'approved' | 'rejected' }) => - updateGeneratedVideoReviewStatus(videoId, reviewStatus), - onSuccess: () => { - message.success('成片复核状态已更新'); - queryClient.invalidateQueries({ queryKey: ['generated-videos', projectId] }); - }, - onError: () => message.error('成片复核状态更新失败'), - }); - - const openSignedUrl = async (videoId: string, action: string) => { + const handleDownload = async (videoId: string, videoName: string) => { try { const url = await getGeneratedVideoDownloadUrl(videoId); - window.open(url, '_blank', 'noopener,noreferrer'); + const a = document.createElement('a'); + a.href = url; + a.download = videoName; + a.click(); } catch (error: any) { - message.error(error.response?.data?.detail || `${action}失败`); + message.error('下载失败'); } }; - const handleBatchDownload = async () => { - const videos = videosQuery.data || []; - for (const video of videos) { - await openSignedUrl(video.id, '获取下载地址'); - } - }; + if (loading) { + return
加载中...
; + } return ( -
+
-

成片库

-

竖屏成片预览,多成片时以紧凑卡片展示,可直接在卡片内播放、复核和获取下载地址。

+

成片库

+

查看和下载生成的成片

-
- {videosQuery.isError && } - - {videosQuery.isLoading ? ( - - ) : videosQuery.data?.length ? ( -
- {videosQuery.data.map((item, index) => { - const review = reviewLabels[item.review_status || 'pending_review'] || reviewLabels.pending_review; - return ( -
- -
- {item.name} - {review.label} -
-

{videoMeta(item)}

-
- - - -
-
- ); - })} + {videos.length === 0 ? ( +
+
🎬
+

还没有生成任何成片

) : ( -
-
- 暂无成片 - 先去视频剪辑页确认剪辑计划并生成第一条 9:16 成片。 +
+

生成的成片

+
+ {videos.map((video, idx) => { + const tones = ['', 'orange', 'gray', 'green']; + const tone = tones[idx % tones.length]; + const statusInfo = { + pending_review: { label: '待复核', class: 'warn' }, + approved: { label: '可发布', class: 'ok' }, + rejected: { label: '需返工', class: 'bad' }, + }[video.review_status as keyof typeof statusInfo] || { label: '未知', class: 'warn' }; + + return ( +
+
+ +
+
+ {video.name} + {statusInfo.label} +
+

+ {video.duration || 0}s · {video.width}x{video.height} · {Math.round(video.file_size / 1024 / 1024)}MB +

+
+ {video.review_status !== 'approved' && ( + + )} + +
+
+ ); + })} +
)}
diff --git a/apps/web/src/pages/workspace/ProjectTitles.css b/apps/web/src/pages/workspace/ProjectTitles.css new file mode 100644 index 000000000..97ab3e47e --- /dev/null +++ b/apps/web/src/pages/workspace/ProjectTitles.css @@ -0,0 +1,49 @@ +/* V21 标题库 */ +.xx-project-titles { +} + +.xx-search-bar { + margin-bottom: 16px; +} + +.xx-title-list { + display: grid; + gap: 10px; +} + +.xx-title-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + border: 1px solid #e2e8f0; + background: white; + border-radius: 18px; + padding: 13px; + transition: all 0.2s; +} + +.xx-title-row:hover { + border-color: #4f46e5; + box-shadow: 0 8px 24px rgba(79, 70, 229, 0.08); +} + +.xx-title-row b { + display: block; + color: #0f172a; + font-weight: 850; + font-size: 16px; +} + +.xx-title-row p { + margin: 5px 0 0; + color: #64748b; + font-size: 13px; + line-height: 1.5; +} + +.xx-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} diff --git a/apps/web/src/pages/workspace/ProjectTitles.tsx b/apps/web/src/pages/workspace/ProjectTitles.tsx index 9a02b5350..6bae8ba1d 100644 --- a/apps/web/src/pages/workspace/ProjectTitles.tsx +++ b/apps/web/src/pages/workspace/ProjectTitles.tsx @@ -1,151 +1,144 @@ -import React, { useMemo, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Button, Form, Input, Select, Space, Switch, Tag, message } from 'antd'; -import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +/** + * 标题库页面 - V21 完全对标 + */ +import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { createProjectTitle, getProjectTitles, updateProjectTitle } from '@/api/projectTitles'; -import { getProject } from '@/api/projects'; - -const categoryOptions = [ - { label: '默认', value: 'default' }, - { label: '营销', value: 'marketing' }, - { label: '教程', value: 'tutorial' }, - { label: '故事', value: 'story' }, - { label: '促销', value: 'promo' }, -]; - -const categoryLabels: Record = { - default: '默认', - marketing: '营销', - tutorial: '教程', - story: '故事', - promo: '促销', -}; +import { Input, Modal, Form, message } from 'antd'; +import { useCreateProjectTitle, useProjectTitles, useDeleteProjectTitle } from '@/hooks/useTitles'; +import './ProjectTitles.css'; const ProjectTitles: React.FC = () => { - const { id } = useParams<{ id: string }>(); - const projectId = id || ''; - const [form] = Form.useForm<{ text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo'; favorite: boolean }>(); + const { id: projectId } = useParams<{ id: string }>(); + const { data: titles, isLoading, refetch } = useProjectTitles(projectId!); + const createMutation = useCreateProjectTitle(); + const deleteMutation = useDeleteProjectTitle(); const [searchText, setSearchText] = useState(''); - const [categoryFilter, setCategoryFilter] = useState('all'); - const [favoriteOnly, setFavoriteOnly] = useState(false); - const queryClient = useQueryClient(); + const [createOpen, setCreateOpen] = useState(false); + const [form] = Form.useForm(); - const projectQuery = useQuery({ - queryKey: ['project', projectId], - queryFn: () => getProject(projectId), - enabled: !!projectId, - }); - - const titlesQuery = useQuery({ - queryKey: ['project-titles', projectId], - queryFn: () => getProjectTitles(projectId), - enabled: !!projectId, - }); - - const createMutation = useMutation({ - mutationFn: (values: { text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo'; favorite: boolean }) => - createProjectTitle(projectId, { - workspace_id: projectQuery.data!.workspace_id, - text: values.text, - category: values.category, - favorite: values.favorite, - }), - onSuccess: () => { - message.success('标题已加入标题库'); + const handleCreate = async () => { + const values = await form.validateFields(); + try { + await createMutation.mutateAsync({ + project_id: projectId!, + name: values.name, + content: values.content, + }); + message.success('标题创建成功'); + setCreateOpen(false); form.resetFields(); - queryClient.invalidateQueries({ queryKey: ['project-titles', projectId] }); - }, - onError: () => message.error('标题创建失败'), - }); + refetch(); + } catch (error: any) { + message.error('创建失败'); + } + }; - const toggleMutation = useMutation({ - mutationFn: ({ titleId, isActive, favorite }: { titleId: string; isActive?: boolean; favorite?: boolean }) => - updateProjectTitle(titleId, { is_active: isActive, favorite }), - onSuccess: () => { - message.success('标题状态已更新'); - queryClient.invalidateQueries({ queryKey: ['project-titles', projectId] }); - }, - onError: () => message.error('标题状态更新失败'), - }); - - const filteredTitles = useMemo(() => { - const keyword = searchText.trim().toLowerCase(); - return (titlesQuery.data || []).filter((item) => { - if (categoryFilter !== 'all' && item.category !== categoryFilter) return false; - if (favoriteOnly && !item.favorite) return false; - if (keyword && !item.text.toLowerCase().includes(keyword)) return false; - return true; + const handleDelete = (titleId: string) => { + Modal.confirm({ + title: '删除标题', + content: '确定要删除这个标题吗?', + okText: '确定', + cancelText: '取消', + onOk: async () => { + try { + await deleteMutation.mutateAsync(titleId); + message.success('删除成功'); + refetch(); + } catch (error: any) { + message.error('删除失败'); + } + }, }); - }, [categoryFilter, favoriteOnly, searchText, titlesQuery.data]); + }; + + const filtered = titles?.filter(t => + t.name.toLowerCase().includes(searchText.toLowerCase()) || + t.content.toLowerCase().includes(searchText.toLowerCase()) + ) || []; + + if (isLoading) { + return
加载中...
; + } return ( -
+
-

标题库

-

按标题库管理常用标题,视频剪辑时可以手选标题,也可以交给系统自动选择。

-
-
- - - +

标题库

+

管理和搜索您的视频标题

+
-
-
createMutation.mutate(values)}> - - - - - - setSearchText(e.target.value)} + style={{ padding: '10px 14px', borderRadius: '12px' }} + /> +
- - setSearchText(event.target.value)} style={{ width: 260 }} /> - + + + + + +
); }; -export const Component = ProjectTitles; export default ProjectTitles; +export const Component = ProjectTitles; diff --git a/apps/web/src/pages/workspace/ProjectVoices.css b/apps/web/src/pages/workspace/ProjectVoices.css new file mode 100644 index 000000000..9c7519033 --- /dev/null +++ b/apps/web/src/pages/workspace/ProjectVoices.css @@ -0,0 +1,105 @@ +/* V21 配音库 */ +.xx-project-voices { +} + +.xx-voice-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; +} + +@media (max-width: 1200px) { + .xx-voice-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +@media (max-width: 768px) { + .xx-voice-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +.xx-voice { + text-align: center; + border: 2px solid #e2e8f0; + border-radius: 18px; + background: white; + padding: 14px; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.xx-voice:hover { + border-color: #4f46e5; +} + +.xx-voice.selected { + border-color: #4f46e5; + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.14); +} + +.xx-voice.selected::after { + content: '✓'; + position: absolute; + right: -7px; + top: -7px; + width: 26px; + height: 26px; + border-radius: 50%; + background: #4f46e5; + color: white; + display: grid; + place-items: center; + font-weight: 900; +} + +.voice-icon { + width: 54px; + height: 54px; + margin: 0 auto 9px; + border-radius: 50%; + display: grid; + place-items: center; + color: white; + font-size: 24px; + background: linear-gradient(135deg, #4f46e5, #6366f1); +} + +.xx-voice div { + font-weight: 850; + font-size: 14px; + color: #0f172a; +} + +.xx-voice small { + display: block; + color: #64748b; + margin-top: 4px; + font-size: 11px; +} + +.xx-voice-usage-list { + display: grid; + gap: 10px; +} + +.xx-voice-usage-row { + border: 1px solid #e2e8f0; + background: white; + border-radius: 18px; + padding: 13px; +} + +.xx-voice-usage-row b { + display: block; + color: #0f172a; + font-weight: 850; +} + +.xx-voice-usage-row p { + margin: 5px 0 0; + color: #64748b; + font-size: 12px; +} diff --git a/apps/web/src/pages/workspace/ProjectVoices.tsx b/apps/web/src/pages/workspace/ProjectVoices.tsx index 8728c2ad9..b55ce8a64 100644 --- a/apps/web/src/pages/workspace/ProjectVoices.tsx +++ b/apps/web/src/pages/workspace/ProjectVoices.tsx @@ -1,102 +1,89 @@ -import React, { useMemo, useState } from 'react'; -import { Alert, Button, Empty, Select } from 'antd'; -import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; -import { useQuery } from '@tanstack/react-query'; +/** + * 配音库页面 - V21 完全对标 + */ +import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { getAssetLibraries, getAssets } from '@/api/assets'; +import { message } from 'antd'; +import { getProjectVoices, type VoiceLibraryItem } from '@/api/voices'; +import './ProjectVoices.css'; -const coverTones = ['green', 'blue', 'ice', 'opera', 'food', 'sun']; - -const formatDuration = (value: unknown) => { - const seconds = Number(value || 0); - if (!seconds) return '00:00'; - const minutes = Math.floor(seconds / 60).toString().padStart(2, '0'); - const rest = Math.round(seconds % 60).toString().padStart(2, '0'); - return `${minutes}:${rest}`; -}; +const VOICE_OPTIONS = [ + { id: 'female-1', name: '女声1', icon: '👩' }, + { id: 'female-2', name: '女声2', icon: '👩' }, + { id: 'male-1', name: '男声1', icon: '👨' }, + { id: 'male-2', name: '男声2', icon: '👨' }, + { id: 'neutral-1', name: '中性声1', icon: '🎤' }, + { id: 'neutral-2', name: '中性声2', icon: '🎤' }, +]; const ProjectVoices: React.FC = () => { - const { id: projectId = '' } = useParams(); - const [libraryId, setLibraryId] = useState(''); - const [playingId, setPlayingId] = useState(''); + const { id: projectId } = useParams<{ id: string }>(); + const [voices, setVoices] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedVoice, setSelectedVoice] = useState(''); - const librariesQuery = useQuery({ - queryKey: ['asset-libraries', projectId], - queryFn: () => getAssetLibraries(projectId), - enabled: Boolean(projectId), - }); + useEffect(() => { + if (!projectId) return; + loadVoices(); + }, [projectId]); - const voiceLibraries = useMemo( - () => (librariesQuery.data || []).filter((library) => library.kind === 'voice'), - [librariesQuery.data] - ); + const loadVoices = async () => { + try { + setLoading(true); + const data = await getProjectVoices(projectId!); + setVoices(data); + if (data.length > 0) setSelectedVoice(data[0].id); + } catch (error: any) { + message.error('加载配音库失败'); + } finally { + setLoading(false); + } + }; - const activeLibraryId = libraryId || voiceLibraries[0]?.id || ''; - - const assetsQuery = useQuery({ - queryKey: ['assets', activeLibraryId], - queryFn: () => getAssets(activeLibraryId), - enabled: Boolean(activeLibraryId), - }); - - const voiceAssets = useMemo( - () => (assetsQuery.data || []).filter((asset) => asset.mime_type?.includes('audio') || asset.library_id === activeLibraryId), - [assetsQuery.data, activeLibraryId] - ); + if (loading) { + return
加载中...
; + } return ( -
+
-

配音库

-

音效和配音素材以双列卡片展示,可直接试听和选用。

-
-
- @@ -121,5 +96,4 @@ const WorkspaceList: React.FC = () => { }; export default WorkspaceList; - export const Component = WorkspaceList;