diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 8386181f3..3e54aac11 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -1,29 +1,40 @@ -/** - * 顶部导航栏 - */ -import React from 'react'; -import { Layout, Button, Dropdown, Avatar, Space } from 'antd'; -import { - MenuFoldOutlined, - MenuUnfoldOutlined, - UserOutlined, - LogoutOutlined, - SettingOutlined, -} from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; -import { useUIStore } from '@/store/uiStore'; +import React, { useMemo } from 'react'; +import { Avatar, Dropdown, Space } from 'antd'; +import { LogoutOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '@/store/authStore'; import { useLogout } from '@/hooks/useAuth'; import type { MenuProps } from 'antd'; -const { Header: AntHeader } = Layout; +const extractProjectId = (pathname: string) => { + const match = pathname.match(/^\/projects\/([^/]+)/); + return match?.[1] || ''; +}; const Header: React.FC = () => { const navigate = useNavigate(); - const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed); - const toggleSidebar = useUIStore((state) => state.toggleSidebar); + const location = useLocation(); const user = useAuthStore((state) => state.user); const logoutMutation = useLogout(); + const projectId = extractProjectId(location.pathname); + + const navItems = useMemo(() => { + const projectNav = projectId + ? [ + { key: 'assets', label: '素材库', path: `/projects/${projectId}/assets` }, + { key: 'titles', label: '标题库', path: `/projects/${projectId}/titles` }, + { key: 'voices', label: '配音库', path: `/projects/${projectId}/voices` }, + { key: 'generation', label: '视频剪辑', path: `/projects/${projectId}/generation` }, + { key: 'results', label: '成片库', path: `/projects/${projectId}/results` }, + ] + : []; + + return [ + { key: 'home', label: '首页', path: '/workspaces' }, + { key: 'subscription', label: '订阅', path: '/subscription' }, + ...projectNav, + ]; + }, [projectId]); const menuItems: MenuProps['items'] = [ { @@ -38,9 +49,7 @@ const Header: React.FC = () => { label: '账号设置', onClick: () => navigate('/profile/settings'), }, - { - type: 'divider', - }, + { type: 'divider' }, { key: 'logout', icon: , @@ -50,30 +59,39 @@ const Header: React.FC = () => { ]; return ( - - - - - } /> - {user?.display_name || user?.username} - - - + + + + + } /> + {user?.display_name || user?.username || '小虾用户'} + + + + ); }; diff --git a/apps/web/src/components/layout/MainLayout.css b/apps/web/src/components/layout/MainLayout.css index 15e9a31f6..7b0081ea4 100644 --- a/apps/web/src/components/layout/MainLayout.css +++ b/apps/web/src/components/layout/MainLayout.css @@ -1,10 +1,860 @@ -.main-layout { - min-height: 100vh; +: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); } -.main-layout-content { - margin: 24px 16px; - padding: 24px; +.xx-app-shell { + 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); +} + +.xx-app-content { + width: min(1440px, calc(100% - 48px)); + 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; - min-height: 280px; + 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; +} + +.xx-result-video { + background: #0f172a; +} + +.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; } } diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 2941f4da8..840572918 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -1,29 +1,18 @@ -/** - * 主布局组件 - * 包含 Header、Sidebar、Content 区域 - */ import React from 'react'; import { Layout } from 'antd'; import { Outlet } from 'react-router-dom'; import Header from './Header'; -import Sidebar from './Sidebar'; -import { useUIStore } from '@/store/uiStore'; import './MainLayout.css'; const { Content } = Layout; const MainLayout: React.FC = () => { - const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed); - return ( - - - -
- - - - + +
+ + + ); }; diff --git a/apps/web/src/pages/subscription/Plans.tsx b/apps/web/src/pages/subscription/Plans.tsx index da6958672..f2525d99a 100644 --- a/apps/web/src/pages/subscription/Plans.tsx +++ b/apps/web/src/pages/subscription/Plans.tsx @@ -1,81 +1,69 @@ -/** - * 订阅计划页面 - */ import React from 'react'; -import { Card, Row, Col, Button, List } from 'antd'; -import { CheckOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; -const Plans: React.FC = () => { - const plans = [ - { - name: 'Free', - price: '¥0', - period: '/月', - features: ['3 个项目', '10GB 存储', '3 个成员', '邮件支持'], - }, - { - name: 'Pro', - price: '¥99', - period: '/月', - features: ['无限项目', '100GB 存储', '20 个成员', '优先支持'], - recommended: true, - }, - { - name: 'Enterprise', - price: '¥999', - period: '/月', - features: ['无限项目', '1TB 存储', '无限成员', '专属支持', '定制开发'], - }, - ]; +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: '联系销售', + }, +]; - return ( -
-

选择适合你的套餐

- - {plans.map((plan) => ( - - - {plan.recommended && ( -
推荐
- )} -

{plan.name}

-
- {plan.price} - - {plan.period} - -
- ( - - - {item} - - )} - /> - -
- - ))} -
+const Plans: React.FC = () => ( +
+
+
+ 订阅套餐 +

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

+

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

+
- ); -}; + +
+ {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.tsx b/apps/web/src/pages/workspace/ProjectAssets.tsx index fd24cedbb..0ba9dcb4f 100644 --- a/apps/web/src/pages/workspace/ProjectAssets.tsx +++ b/apps/web/src/pages/workspace/ProjectAssets.tsx @@ -3,17 +3,12 @@ import { useLocation, useParams } from 'react-router-dom'; import { Alert, Button, - Card, - Col, - Empty, Form, Input, Modal, Progress, - Row, Select, Space, - Table, Tag, Upload, message, @@ -424,62 +419,6 @@ const ProjectAssets: React.FC = () => { ); }; - const renderReviewStatus = (asset: AssetItem) => { - const status = String(asset.metadata?.review_status || 'unreviewed'); - const labels: Record = { - pending_review: '待复核', - approved: '已通过', - rejected: '已拒绝', - unreviewed: '未复核', - }; - const colors: Record = { - pending_review: 'orange', - approved: 'green', - rejected: 'red', - unreviewed: 'default', - }; - return {labels[status] || status}; - }; - - const columns = [ - { title: '名称', dataIndex: 'name', key: 'name' }, - { title: '类型', dataIndex: 'mime_type', key: 'mime_type', render: (value: string) => {value} }, - { title: '分类结果', key: 'classification', render: (_: unknown, record: AssetItem) => renderClassification(record) }, - { title: '复核状态', key: 'review_status', render: (_: unknown, record: AssetItem) => renderReviewStatus(record) }, - { title: '使用次数', key: 'generation_use_count', render: (_: unknown, record: AssetItem) => Number(record.metadata?.generation_use_count || 0) }, - { title: '存储键', dataIndex: 'storage_key', key: 'storage_key', ellipsis: true }, - { - title: '操作', - key: 'actions', - render: (_: unknown, record: AssetItem) => { - const hasClassification = !!record.metadata?.classification; - const isCurrentClassifying = classifyingAssetId === record.id; - const isAutoClassifying = record.metadata?.auto_classification === 'queued'; - return ( - - - - - - ); - }, - }, - ]; - const customUpload = async (options: any) => { const { file, onSuccess, onError } = options; const uploadFile = file as File; @@ -529,213 +468,132 @@ const ProjectAssets: React.FC = () => { ? Math.round(((batchProgress.completed + batchProgress.failed + batchProgress.skipped) / batchProgress.total) * 100) : 0; + 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]; + }; + + 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 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}`; + }; + return ( -
- - - - - setBatchMode(value)} /> - + + + + +
- {assetsQuery.isError && ( - - )} + {!workspaceId && } + {librariesQuery.isError && } + {assetsQuery.isError && } + {ingestJobQuery.isError && } + {classificationJobQuery.isError && } + {ingestJobQuery.data && } + {classificationJobQuery.data && } - {ingestJobQuery.isError && ( - - )} + {batchProgress.total > 0 && ( +
+

??????

+ 0 ? 'exception' : 'success'} /> + ???{batchProgress.total}????{batchProgress.submitted}????{batchProgress.completed}???{batchProgress.failed}???{batchProgress.skipped} +
+ )} - {classificationJobQuery.isError && ( - - )} + {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})} + +
+ )} - {ingestJobQuery.data && ( - - )} +
+ +

+

????????????????

+

?????????????????????? {uploading ? `(${activeUploadCount} ??????)` : '????????'}

+
+
- {classificationJobQuery.data && ( - - )} + {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()}> + + + +
+
+ ); + })} +
+ ) :
+
???????????????? 9:16 ???
} - {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.unused_assets} - 已使用 {diagnosisQuery.data.used_assets} - 待复核 {diagnosisQuery.data.pending_review_assets} - -
- - {diagnosisQuery.data.smart_views.map((item) => ( - setSmartViewFilter(item.key)} - > - {item.label}:{item.count} - - ))} - {smartViewFilter !== 'all' && ( - - )} - -
- -
- {diagnosisQuery.data.gaps.length ? ( - - {diagnosisQuery.data.gaps.map((gap) => ( - - ))} - - ) : ( - - )} -
- )} - - -

-

点击或拖拽文件到这里批量上传素材

-

- 支持一次选择多个视频、音频、图片文件;上传中 {uploading ? `(${activeUploadCount} 个文件处理中)` : '自动进入导入任务'} -

-
- - {filteredAssets.length ? ( - - ) : ( - - )} - - - - - 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, - }); - }} - > - - - + - - - - - - - - + /> + )} + + {diagnosisQuery.isError && ( + + )} + +
+
+ + generationMutation.mutate({ + workspace_id: workspaceId, + project_id: projectId, + asset_library_id: values.asset_library_id, + voice_library_id: values.voice_library_id || '', + strategy_id: values.title_id || values.strategy_id || '', + edit_plan_id: editPlan?.id || '', + created_by_user_id: user?.id || '', + }) + } + > +
+

????

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

??????

+ + {resultsQuery.data.map((item) => ( + + {item.name} + + + ))} - - - {editPlan && ( - - {editPlan.summary} - ( - - {clip.sequence}. {clip.asset_name} / {Math.round(clip.duration)}秒 / {clip.reason} - - )} - /> - - )} - - {taskQuery.data && ( - - )} - - {resultsQuery.data?.length ? ( - - - {resultsQuery.data.map((item) => ( - - {item.name} - - - ))} - - - ) : null} - +
+ ) : null} ); }; diff --git a/apps/web/src/pages/workspace/ProjectResults.tsx b/apps/web/src/pages/workspace/ProjectResults.tsx index f0676bb65..23f2f5464 100644 --- a/apps/web/src/pages/workspace/ProjectResults.tsx +++ b/apps/web/src/pages/workspace/ProjectResults.tsx @@ -1,13 +1,61 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; -import { Alert, Button, Card, Empty, List, Space, Spin, Tag, Typography, message } from 'antd'; +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 } from '@/api/generation'; +import { getGeneratedVideoDownloadUrl, getGeneratedVideos, updateGeneratedVideoReviewStatus, type GeneratedVideoItem } from '@/api/generation'; -const reviewLabels: Record = { - pending_review: { label: '待复核', color: 'gold' }, - approved: { label: '可发布', color: 'green' }, - rejected: { label: '需返工', color: 'red' }, +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); + + useEffect(() => { + setPreviewUrl(item.file_url || ''); + }, [item.file_url, item.id]); + + const loadSignedPreview = async () => { + if (previewUrl || loading) return; + setLoading(true); + try { + setPreviewUrl(await getGeneratedVideoDownloadUrl(item.id)); + } catch (error: any) { + message.error(error.response?.data?.detail || '预览地址获取失败'); + } finally { + setLoading(false); + } + }; + + return ( +
+ {previewUrl ? ( +
+ ); }; const ProjectResults: React.FC = () => { @@ -40,102 +88,66 @@ const ProjectResults: React.FC = () => { onError: () => message.error('成片复核状态更新失败'), }); - const handlePreview = async (videoId: string) => { + const openSignedUrl = async (videoId: string, action: string) => { try { const url = await getGeneratedVideoDownloadUrl(videoId); window.open(url, '_blank', 'noopener,noreferrer'); } catch (error: any) { - message.error(error.response?.data?.detail || '获取预览地址失败'); - } - }; - - const handleDownload = async (videoId: string) => { - try { - const url = await getGeneratedVideoDownloadUrl(videoId); - window.open(url, '_blank', 'noopener,noreferrer'); - } catch (error: any) { - message.error(error.response?.data?.detail || '获取下载地址失败'); + message.error(error.response?.data?.detail || `${action}失败`); } }; const handleBatchDownload = async () => { const videos = videosQuery.data || []; for (const video of videos) { - await handleDownload(video.id); + await openSignedUrl(video.id, '获取下载地址'); } }; return ( -
- - 批量获取下载地址 - - } - > - {videosQuery.isError && ( - - )} +
+
+
+

成片库

+

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

+
+ +
- {videosQuery.isLoading ? ( - - ) : videosQuery.data?.length ? ( - { - const review = reviewLabels[item.review_status || 'pending_review'] || reviewLabels.pending_review; - return ( - handlePreview(item.id)}> - 预览成片 - , - , - , - , - ]} - > - - {item.name} - {item.status || 'completed'} - {review.label} - {item.width}x{item.height} - {item.duration}s - {Math.round(item.file_size / 1024)} KB - - } - description={ - - - {item.name} - - - 预览:{item.thumbnail_url ? '封面已生成' : '暂无封面,使用成片文件预览'} - - - 生成参数:素材库 {String(item.generation_params?.asset_library_id || '未记录')},标题 {String(item.generation_params?.title_id || '自动/未选择')} - - - } - /> - - ); - }} - /> - ) : ( - - )} - + {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)}

+
+ + + +
+
+ ); + })} +
+ ) : ( +
+
+ 暂无成片 + 先去视频剪辑页确认剪辑计划并生成第一条 9:16 成片。 +
+ )}
); }; diff --git a/apps/web/src/pages/workspace/ProjectTitles.tsx b/apps/web/src/pages/workspace/ProjectTitles.tsx index 36d6e5cc9..9a02b5350 100644 --- a/apps/web/src/pages/workspace/ProjectTitles.tsx +++ b/apps/web/src/pages/workspace/ProjectTitles.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Button, Card, Form, Input, List, Select, Space, Switch, Tag, message } from 'antd'; +import { Button, Form, Input, Select, Space, Switch, Tag, message } from 'antd'; +import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { useParams } from 'react-router-dom'; import { createProjectTitle, getProjectTitles, updateProjectTitle } from '@/api/projectTitles'; import { getProject } from '@/api/projects'; @@ -79,76 +80,70 @@ const ProjectTitles: React.FC = () => { }, [categoryFilter, favoriteOnly, searchText, titlesQuery.data]); return ( - - titlesQuery.refetch()}>刷新}> -
createMutation.mutate(values)} - > - - - - - - +
+ createMutation.mutate(values)}> + + + + + + + + + + {filteredTitles.length ? ( + + {filteredTitles.map((item, index) => ( +
+
{index + 1}
+
+ {item.text} + 使用次数:{item.usage_count} +
+ + {item.favorite && 常用} + {item.is_active ? '启用' : '停用'} + {categoryLabels[item.category] || item.category} + toggleMutation.mutate({ titleId: item.id, favorite: checked })} /> + toggleMutation.mutate({ titleId: item.id, isActive: checked })} /> + +
+ ))}
- ( - toggleMutation.mutate({ titleId: item.id, favorite: checked })} - />, - toggleMutation.mutate({ titleId: item.id, isActive: checked })} - />, - ]} - > - - {item.text} - {item.favorite && 常用} - {item.is_active ? '启用' : '停用'} - {categoryLabels[item.category] || item.category} - - } - description={`使用次数:${item.usage_count}`} - /> - - )} - /> - - + ) : ( +
+
T
+ 暂无标题 + 先新增一个常用标题,后续视频剪辑时可以直接选择。 +
+ )} +
); }; diff --git a/apps/web/src/pages/workspace/ProjectVoices.tsx b/apps/web/src/pages/workspace/ProjectVoices.tsx new file mode 100644 index 000000000..8728c2ad9 --- /dev/null +++ b/apps/web/src/pages/workspace/ProjectVoices.tsx @@ -0,0 +1,106 @@ +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'; +import { useParams } from 'react-router-dom'; +import { getAssetLibraries, getAssets } from '@/api/assets'; + +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 ProjectVoices: React.FC = () => { + const { id: projectId = '' } = useParams(); + const [libraryId, setLibraryId] = useState(''); + const [playingId, setPlayingId] = useState(''); + + const librariesQuery = useQuery({ + queryKey: ['asset-libraries', projectId], + queryFn: () => getAssetLibraries(projectId), + enabled: Boolean(projectId), + }); + + const voiceLibraries = useMemo( + () => (librariesQuery.data || []).filter((library) => library.kind === 'voice'), + [librariesQuery.data] + ); + + 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] + ); + + return ( +
+
+
+

配音库

+

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

+
+
+