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
This commit is contained in:
Xiaoxia AI
2026-06-25 14:53:22 +08:00
parent e40a7d4df9
commit a0ea960eb8
19 changed files with 1586 additions and 2259 deletions
+87
View File
@@ -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;
}
+20 -848
View File
@@ -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;
}
@@ -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 (
<Layout className="xx-app-shell">
<div className="xx-app-shell">
<Header />
<Content className="xx-app-content">
<main className="xx-app-content">
<Outlet />
</Content>
</Layout>
</main>
</div>
);
};
+80 -11
View File
@@ -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;
}
+29 -26
View File
@@ -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 (
<div className="login-container">
<Card className="login-card" title="登录小虾 SaaS">
<div className="xx-auth-page">
<div className="xx-auth-card">
<div className="xx-auth-header">
<div className="xx-logo-big">🦐</div>
<h1></h1>
<p></p>
</div>
<Form
form={form}
name="login"
onFinish={onFinish}
autoComplete="off"
size="large"
layout="vertical"
>
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
{ type: 'email', message: '请输入有效的邮箱' },
]}
>
<Input
prefix={<UserOutlined />}
placeholder="邮箱"
/>
<Input placeholder="name@example.com" size="large" />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
<Input.Password placeholder="•••••••••" size="large" />
</Form.Item>
<Form.Item>
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Link to="/forgot-password" style={{ float: 'right' }}>
<Link to="/forgot-password" style={{ float: 'right', fontSize: '12px' }}>
</Link>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loginMutation.isPending}
block
<button
type="submit"
className="btn primary"
style={{ width: '100%' }}
disabled={loginMutation.isPending}
>
</Button>
{loginMutation.isPending ? '登录中...' : '登录'}
</button>
</Form.Item>
<div style={{ textAlign: 'center' }}>
<div className="xx-auth-footer">
<Link to="/register"></Link>
</div>
</Form>
</Card>
</div>
</div>
);
};
+103
View File
@@ -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;
}
+66 -61
View File
@@ -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 = () => (
<div className="xx-page-card">
<div className="xx-page-head xx-pricing-head">
<div>
<span className="xx-tag"></span>
<h1></h1>
<p></p>
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 (
<div className="xx-plans-page">
<div className="xx-page-head">
<div>
<h2></h2>
<p></p>
</div>
</div>
<div className="xx-plans-grid">
{PLANS.map((plan) => (
<div key={plan.id} className={`xx-plan-card ${plan.recommended ? 'featured' : ''}`}>
{plan.recommended && <div className="xx-badge"></div>}
<h3>{plan.name}</h3>
<div className="xx-plan-price">
¥{plan.price}
<span>/</span>
</div>
<p className="xx-plan-description">{plan.description}</p>
<button
className={`btn ${plan.recommended ? 'primary' : 'ghost'}`}
onClick={() => handleSubscribe(plan.id)}
disabled={subscribing}
style={{ width: '100%' }}
>
{subscribing ? '处理中...' : '选择此计划'}
</button>
<div className="xx-features">
{plan.features.map((feature: string, idx: number) => (
<div key={idx} className="xx-feature">
<span></span>
{feature}
</div>
))}
</div>
</div>
))}
</div>
</div>
<div className="xx-pricing-grid">
{plans.map((plan) => (
<article className={`xx-pricing-card ${plan.recommended ? 'recommended' : ''}`} key={plan.name}>
<span className="xx-tag">{plan.tone}</span>
<h2>{plan.name}</h2>
<p>{plan.description}</p>
<div className="xx-price">
<b>{plan.price}</b>
<span>{plan.period}</span>
</div>
<div className="xx-feature-list">
{plan.features.map((feature) => <div key={feature}> {feature}</div>)}
</div>
<Button className={plan.recommended ? 'xx-btn-primary' : ''} type={plan.recommended ? 'primary' : 'default'} block disabled={plan.disabled}>
{plan.button}
</Button>
</article>
))}
</div>
</div>
);
);
};
export default Plans;
export const Component = Plans;
@@ -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;
}
+136 -566
View File
@@ -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<string>(routedWorkspaceId || storedWorkspaceId);
const workspaceId = routedWorkspaceId || storedWorkspaceId || resolvedWorkspaceId;
const [libraryId, setLibraryId] = useState<string>('');
const [createLibraryOpen, setCreateLibraryOpen] = useState(false);
const [activeUploadCount, setActiveUploadCount] = useState(0);
const [ingestJobId, setIngestJobId] = useState<string>('');
const [classificationJobId, setClassificationJobId] = useState<string>('');
const [classifyingAssetId, setClassifyingAssetId] = useState<string>('');
const [classificationFilter, setClassificationFilter] = useState<string>('all');
const [classificationSourceFilter, setClassificationSourceFilter] = useState<string>('all');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchMode, setBatchMode] = useState<'unclassified_only' | 'include_classified'>('unclassified_only');
const [smartViewFilter, setSmartViewFilter] = useState<string>('all');
const [batchProgress, setBatchProgress] = useState<BatchProgressState>(defaultBatchProgress);
const [batchJobIds, setBatchJobIds] = useState<string[]>([]);
const [batchFailedIds, setBatchFailedIds] = useState<string[]>([]);
const [form] = Form.useForm();
const uploading = activeUploadCount > 0;
const { id: projectId } = useParams<{ id: string }>();
const [assets, setAssets] = useState<AssetItem[]>([]);
const [libraries, setLibraries] = useState<any[]>([]);
const [selectedLibrary, setSelectedLibrary] = useState<string>('');
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [project, setProject] = useState<any>(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 <Tag color="processing"></Tag>;
}
const classification = String(asset.metadata?.classification || '');
const confidence = Number(asset.metadata?.classification_confidence || 0);
if (!classification) {
return <Tag color="default"></Tag>;
}
return (
<Space>
<Tag color="blue">{classification}</Tag>
<Tag>{Math.round(confidence * 100)}%</Tag>
{autoClassification === 'completed' ? <Tag color="green"></Tag> : <Tag></Tag>}
</Space>
);
};
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 <div className="xx-loading">...</div>;
}
return (
<div className="xx-page-card">
<div className="xx-project-assets">
<div className="xx-page-head">
<div>
<h1></h1>
<p> 9:16 </p>
</div>
<div className="xx-action-row">
<Button icon={<PlusOutlined />} onClick={() => setCreateLibraryOpen(true)}></Button>
<Button className="xx-btn-primary" type="primary" disabled={!workspaceId || !libraryId} onClick={() => document.getElementById('asset-upload-dropzone')?.scrollIntoView({ behavior: 'smooth', block: 'center' })}></Button>
<h2></h2>
<p></p>
</div>
</div>
<div className="xx-filter-row">
<Select style={{ width: 160 }} value={classificationFilter} options={classificationOptions} onChange={setClassificationFilter} />
<Select style={{ width: 160 }} value={classificationSourceFilter} options={sourceOptions} onChange={setClassificationSourceFilter} />
<Select
style={{ width: 160 }}
value={smartViewFilter}
options={[
{ label: '全部素材', value: 'all' },
{ label: '推荐可用', value: 'recommended' },
{ label: '需要处理', value: 'needs_attention' },
{ label: '高风险素材', value: 'high_risk' },
{ label: '未分类素材', value: 'unclassified' },
{ label: '最近上传', value: 'recent' },
{ label: '未使用素材', value: 'unused' },
{ label: '已使用素材', value: 'used' },
{ label: '待复核素材', value: 'pending_review' },
{ label: '配音素材', value: 'voice' },
]}
onChange={setSmartViewFilter}
/>
<Select style={{ width: 170 }} value={batchMode} options={batchModeOptions} onChange={(value) => setBatchMode(value)} />
<Select style={{ width: 240 }} placeholder="选择素材库" value={libraryId || undefined} options={libraryOptions} onChange={setLibraryId} loading={librariesQuery.isLoading} />
<Button icon={<TagsOutlined />} disabled={!selectedRowKeys.length || batchProgress.running} onClick={handleBatchClassification}> ({selectedRowKeys.length})</Button>
<Button icon={<ReloadOutlined />} onClick={() => { librariesQuery.refetch(); assetsQuery.refetch(); diagnosisQuery.refetch(); }}></Button>
{/* 上传区 */}
<div className="xx-card">
<h3></h3>
<div
className="xx-upload-zone"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
handleFileUpload(e.dataTransfer.files);
}}
>
<div style={{ fontSize: '32px', marginBottom: '12px' }}>📁</div>
<p></p>
<input
type="file"
multiple
style={{ display: 'none' }}
id="file-input"
onChange={(e) => e.target.files && handleFileUpload(e.target.files)}
/>
<button
className="btn primary"
onClick={() => document.getElementById('file-input')?.click()}
disabled={uploading}
>
{uploading ? '上传中...' : '选择文件'}
</button>
</div>
</div>
{!workspaceId && <Alert style={{ marginBottom: 16 }} type="warning" showIcon message="缺少工作空间信息" description="请从项目详情页进入素材库,系统需要确认当前工作空间。" />}
{librariesQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="素材库加载失败" />}
{assetsQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="素材加载失败" />}
{ingestJobQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="导入任务加载失败" />}
{classificationJobQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="分类任务加载失败" />}
{ingestJobQuery.data && <Alert style={{ marginBottom: 16 }} type={ingestJobQuery.data.status === 'failed' ? 'error' : 'info'} message={`导入任务:${ingestJobQuery.data.status}`} description={ingestJobQuery.data.error_message || `任务 ID: ${ingestJobQuery.data.id}`} />}
{classificationJobQuery.data && <Alert style={{ marginBottom: 16 }} type={classificationJobQuery.data.status === 'failed' ? 'error' : 'success'} message={`分类任务:${classificationJobQuery.data.status}`} description={classificationJobQuery.data.error_message || `分类结果:${classificationJobQuery.data.classification || '未返回'}`} />}
{batchProgress.total > 0 && (
<div className="xx-section-block">
<div className="xx-section-title"><h3></h3></div>
<Progress percent={batchPercent} status={batchProgress.running ? 'active' : batchFailedIds.length > 0 ? 'exception' : 'success'} />
<Space size="large" wrap><span> {batchProgress.total}</span><span> {batchProgress.submitted}</span><span> {batchProgress.completed}</span><span> {batchProgress.failed}</span><span> {batchProgress.skipped}</span></Space>
</div>
)}
{diagnosisQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="素材诊断加载失败" />}
{diagnosisQuery.data && (
<div className="xx-diagnosis-strip">
<div className="xx-kpi-card"><b>{diagnosisQuery.data.readiness_score}</b><span>{diagnosisQuery.data.readiness_label}</span></div>
<Space size={[8, 8]} wrap>
<Tag color="blue"> {diagnosisQuery.data.total_assets}</Tag><Tag color="green">Ready {diagnosisQuery.data.ready_assets}</Tag><Tag color="purple"> {diagnosisQuery.data.video_assets}</Tag><Tag color="orange"> {diagnosisQuery.data.image_assets}</Tag><Tag color="cyan"> {diagnosisQuery.data.voice_assets}</Tag><Tag color="gold"> {diagnosisQuery.data.estimated_video_count}</Tag>
{diagnosisQuery.data.smart_views.map((item) => <Tag key={item.key} color={smartViewFilter === item.key ? 'blue' : undefined} style={{ cursor: 'pointer' }} onClick={() => setSmartViewFilter(item.key)}>{item.label}{item.count}</Tag>)}
</Space>
</div>
)}
<div id="asset-upload-dropzone">
<Dragger name="file" multiple customRequest={customUpload} showUploadList={false} disabled={!workspaceId || !libraryId} style={{ marginBottom: 24 }}>
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"> {uploading ? `(${activeUploadCount} 个文件上传中)` : '上传后自动导入并分类'}</p>
</Dragger>
</div>
{filteredAssets.length ? (
<div className="xx-vertical-grid compact">
{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 (
<article className={`xx-vertical-card ${selected ? 'selected' : ''}`} key={asset.id} onClick={() => setSelectedRowKeys((keys) => selected ? keys.filter((key) => key !== asset.id) : [...keys, asset.id])}>
<div className={`xx-vertical-thumb ${assetTone(asset, index)}`}><button className="xx-mini-play" type="button"></button></div>
<div className="xx-vertical-row"><b title={asset.name}>{asset.name}</b><span className={`xx-pill ${pill.cls}`}>{pill.text}</span></div>
<p className="xx-vertical-meta">{assetMeta(asset)}</p>
<div style={{ marginTop: 8 }}>{renderClassification(asset)}</div>
<div className="xx-action-row" style={{ marginTop: 10 }} onClick={(event) => event.stopPropagation()}>
<Button size="small" icon={<TagsOutlined />} loading={isCurrentClassifying && classificationMutation.isPending} disabled={isAutoClassifying || (!!classifyingAssetId && classifyingAssetId !== asset.id) || batchProgress.running} onClick={() => { setClassifyingAssetId(asset.id); classificationMutation.mutate({ workspace_id: asset.workspace_id, project_id: asset.project_id, asset_id: asset.id }); }}>{isAutoClassifying ? '排队中' : hasClassification ? '重分类' : '分类'}</Button>
<Button size="small" disabled={reviewMutation.isPending} onClick={() => reviewMutation.mutate({ assetId: asset.id, reviewStatus: 'approved' })}></Button>
<Button size="small" danger disabled={reviewMutation.isPending} onClick={() => reviewMutation.mutate({ assetId: asset.id, reviewStatus: 'rejected' })}></Button>
{/* 素材列表 */}
<div className="xx-card">
<h3></h3>
{assets.length === 0 ? (
<div className="xx-empty-state">
<p></p>
</div>
) : (
<div className="xx-video-grid">
{assets.map((asset) => (
<div key={asset.id} className="xx-video-card">
<div className="xx-video-thumb">
<div className="mini-play"></div>
</div>
</article>
);
})}
</div>
) : <div className="xx-empty-state"><div className="xx-empty-icon">+</div><b></b><span> 9:16 </span></div>}
<Modal title="新建素材库" open={createLibraryOpen} onCancel={() => setCreateLibraryOpen(false)} onOk={() => form.submit()} confirmLoading={createLibraryMutation.isPending} okButtonProps={{ disabled: !workspaceId }}>
<Form form={form} layout="vertical" onFinish={(values) => createLibraryMutation.mutate({ workspace_id: workspaceId, project_id: projectId, name: values.name, kind: values.kind })}>
<Form.Item label="素材库名称" name="name" rules={[{ required: true, message: '请输入素材库名称' }]}><Input placeholder="例如:产品口播素材" /></Form.Item>
<Form.Item label="素材类型" name="kind" initialValue="video" rules={[{ required: true }]}><Select options={[{ label: '视频', value: 'video' }, { label: '配音', value: 'voice' }, { label: '图片', value: 'image' }]} /></Form.Item>
</Form>
</Modal>
<div className="row">
<b>{asset.name}</b>
<span className={`pill ${asset.review_status === 'approved' ? 'ok' : asset.review_status === 'rejected' ? 'bad' : 'warn'}`}>
{asset.review_status === 'approved' ? '已批准' : asset.review_status === 'rejected' ? '已拒绝' : '待审核'}
</span>
</div>
<p>{asset.duration || 0}s {asset.size_mb || 0}MB</p>
{asset.review_status !== 'approved' && (
<div className="assist-actions">
<button
className="btn ghost"
onClick={() => handleReviewStatus(asset.id, 'approved')}
>
</button>
<button
className="btn ghost"
onClick={() => handleReviewStatus(asset.id, 'rejected')}
>
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,78 @@
/* V21 视频剪辑 */
.xx-project-generation {
}
.xx-template-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
@media (max-width: 1200px) {
.xx-template-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.xx-template-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.xx-choice {
position: relative;
border: 2px solid #e2e8f0;
border-radius: 18px;
padding: 14px;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.xx-choice:hover {
border-color: #4f46e5;
}
.xx-choice.selected {
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.16);
}
.check {
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;
}
.cover {
height: 78px;
border-radius: 14px;
display: grid;
place-items: center;
color: white;
font-size: 28px;
margin-bottom: 10px;
}
.xx-choice b {
font-size: 15px;
font-weight: 850;
color: #0f172a;
display: block;
}
.xx-choice p {
margin: 5px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
+121 -328
View File
@@ -1,349 +1,142 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Alert, Button, Form, Input, List, Select, Space, Typography, message } from 'antd';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createGenerationTask, getGeneratedVideoDownloadUrl, getGenerationResults, getGenerationTask } from '@/api/generation';
import { getAssetLibraries, getProjectAssetDiagnosis } from '@/api/assets';
import { getProject } from '@/api/projects';
import { getProjectTitles } from '@/api/projectTitles';
import { createEditPlan, getEditTemplates, EditPlanItem } from '@/api/editPlans';
import { useAuthStore } from '@/store/authStore';
const humanizeGenerationStatus = (status: string) => {
const labels: Record<string, string> = {
pending: '排队中',
running: '生成中',
completed: '生成完成',
failed: '生成失败',
cancelled: '已取消',
};
return labels[status] || status;
};
const humanizeGenerationError = (errorMessage?: string | null) => {
const raw = (errorMessage || '').trim();
if (!raw) {
return '生成失败了,但系统没有返回具体原因。请重新发起一次;如果还失败,把项目和素材库发给小虾排查。';
}
const lower = raw.toLowerCase();
if (lower.includes('ffmpeg') || lower.includes('ffprobe') || lower.includes('invalid data') || lower.includes('moov atom')) {
return '视频素材格式无法被系统识别。请换一个常见 MP4/H.264 文件,或先用剪映/播放器重新导出后再上传。';
}
if (lower.includes('oss') || lower.includes('bucket') || lower.includes('accesskey') || lower.includes('storage')) {
return '素材存储服务暂时异常,系统没能读取或保存文件。请稍后重试;如果持续失败,让小虾检查 OSS 配置。';
}
if (lower.includes('not found') || lower.includes('no such file')) {
return '系统找不到要生成的视频素材。请确认素材已经上传并导入完成,再重新生成。';
}
if (lower.includes('permission') || lower.includes('forbidden') || lower.includes('unauthorized')) {
return '当前账号没有这个项目或素材库的操作权限。请回到工作空间确认登录账号和权限。';
}
return `生成失败:${raw}`;
};
/**
* 视频剪辑/生成页面 - V21 完全对标
*/
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Select, Modal, Form, message, Input } from 'antd';
import { useProjectTitles } from '@/hooks/useTitles';
import { useCreateGenerationTask } from '@/hooks/useGeneration';
import './ProjectGeneration.css';
const ProjectGeneration: React.FC = () => {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const user = useAuthStore((state) => state.user);
const projectId = id || '';
const routedWorkspaceId = (location.state as { workspaceId?: string } | null)?.workspaceId || '';
const storedWorkspaceId = projectId ? sessionStorage.getItem(`project-workspace:${projectId}`) || '' : '';
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string>(routedWorkspaceId || storedWorkspaceId);
const workspaceId = routedWorkspaceId || storedWorkspaceId || resolvedWorkspaceId;
const [taskId, setTaskId] = useState('');
const [editPlan, setEditPlan] = useState<EditPlanItem | null>(null);
const { id: projectId } = useParams<{ id: string }>();
const { data: titles, isLoading } = useProjectTitles(projectId!);
const createMutation = useCreateGenerationTask();
const [generateOpen, setGenerateOpen] = useState(false);
const [selectedTitle, setSelectedTitle] = useState<string>('');
const [form] = Form.useForm();
useEffect(() => {
if (projectId && routedWorkspaceId) {
sessionStorage.setItem(`project-workspace:${projectId}`, routedWorkspaceId);
setResolvedWorkspaceId(routedWorkspaceId);
}
}, [projectId, routedWorkspaceId]);
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: ['generation-libraries', projectId],
queryFn: () => getAssetLibraries(projectId),
enabled: !!projectId,
});
const diagnosisQuery = useQuery({
queryKey: ['generation-asset-diagnosis', projectId],
queryFn: () => getProjectAssetDiagnosis(projectId),
enabled: !!projectId,
refetchOnWindowFocus: false,
});
const titlesQuery = useQuery({
queryKey: ['generation-project-titles', projectId],
queryFn: () => getProjectTitles(projectId, true),
enabled: !!projectId,
});
const templatesQuery = useQuery({
queryKey: ['edit-templates', projectId, workspaceId],
queryFn: () => getEditTemplates(projectId, workspaceId),
enabled: !!projectId && !!workspaceId,
});
const taskQuery = useQuery({
queryKey: ['generation-task', taskId],
queryFn: () => getGenerationTask(taskId),
enabled: !!taskId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === 'completed' || status === 'failed' || status === 'cancelled' ? false : 2000;
},
});
const resultsQuery = useQuery({
queryKey: ['generation-results', taskId],
queryFn: () => getGenerationResults(taskId),
enabled: !!taskId,
});
const generationMutation = useMutation({
mutationFn: createGenerationTask,
onSuccess: (task) => {
setTaskId(task.id);
message.success('生成任务已创建');
},
onError: (error: any) => message.error(humanizeGenerationError(error.response?.data?.detail || '创建生成任务失败')),
});
const editPlanMutation = useMutation({
mutationFn: createEditPlan,
onSuccess: (plan) => {
setEditPlan(plan);
message.success('剪辑计划已生成,请确认后再发起生成');
},
onError: (error: any) => message.error(error.response?.data?.detail || '生成剪辑计划失败'),
});
useEffect(() => {
if (taskQuery.data?.status === 'completed') {
resultsQuery.refetch();
}
}, [taskQuery.data?.status, resultsQuery]);
const assetLibraryOptions = useMemo(
() => (librariesQuery.data || []).filter((item) => item.kind === 'video' || item.kind === 'image').map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })),
[librariesQuery.data]
);
const voiceLibraryOptions = useMemo(
() => (librariesQuery.data || []).filter((item) => item.kind === 'voice').map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })),
[librariesQuery.data]
);
const titleOptions = useMemo(
() => (titlesQuery.data || []).map((item) => ({ label: item.text, value: item.id })),
[titlesQuery.data]
);
const templateOptions = useMemo(
() => (templatesQuery.data || []).map((item) => ({ label: `${item.name} (${item.clip_count}段/${Math.round(item.target_duration)}秒)`, value: item.id })),
[templatesQuery.data]
);
const failedReason = taskQuery.data?.status === 'failed'
? humanizeGenerationError(taskQuery.data.error_message)
: '';
const hasCriticalGap = !!diagnosisQuery.data?.gaps.some((gap) => gap.severity === 'critical');
const canSubmitGeneration = !!workspaceId && !!user?.id && !hasCriticalGap && !diagnosisQuery.isLoading;
const openSignedDownload = async (videoId: string) => {
const handleGenerate = async () => {
const values = await form.validateFields();
try {
const url = await getGeneratedVideoDownloadUrl(videoId);
window.open(url, '_blank', 'noopener,noreferrer');
await createMutation.mutateAsync({
project_id: projectId!,
title_id: selectedTitle,
...values,
});
message.success('成片生成任务已创建');
setGenerateOpen(false);
form.resetFields();
setSelectedTitle('');
} catch (error: any) {
message.error(error.response?.data?.detail || '获取下载地址失败');
message.error('创建生成任务失败');
}
};
if (isLoading) {
return <div className="xx-loading">...</div>;
}
if (!titles || titles.length === 0) {
return (
<div className="xx-project-generation">
<div className="xx-page-head">
<div>
<h2></h2>
<p></p>
</div>
</div>
<div className="xx-card">
<div className="xx-empty-state">
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🎬</div>
<p></p>
</div>
</div>
</div>
);
}
return (
<div className="xx-page-card">
<div className="xx-project-generation">
<div className="xx-page-head">
<div>
<h1></h1>
<p></p>
<h2></h2>
<p></p>
</div>
<Button className="xx-btn-primary" type="primary" onClick={() => form.submit()} loading={generationMutation.isPending} disabled={!canSubmitGeneration || !editPlan}>
</Button>
<button className="btn primary" onClick={() => setGenerateOpen(true)}>
+
</button>
</div>
{(!workspaceId || !user?.id) && (
<Alert
style={{ marginBottom: 16 }}
type="error"
showIcon
message="缺少工作空间信息"
description="请从项目详情页进入视频剪辑,系统需要确认当前工作空间和用户权限。"
/>
)}
{librariesQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="素材库加载失败" />}
{taskQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="生成任务加载失败" />}
{resultsQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="生成结果加载失败" />}
{diagnosisQuery.data && (
<Alert
style={{ marginBottom: 16 }}
type={hasCriticalGap ? 'error' : diagnosisQuery.data.gaps.length ? 'warning' : 'success'}
showIcon
message={`素材就绪度:${diagnosisQuery.data.readiness_score} / ${diagnosisQuery.data.readiness_label}`}
description={
diagnosisQuery.data.gaps.length
? diagnosisQuery.data.gaps.map((gap) => `${gap.message}${gap.recommendation}`).join('')
: '素材状态良好,可以继续生成剪辑计划。'
}
/>
)}
{diagnosisQuery.isError && (
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="素材诊断加载失败" description="请稍后刷新页面重试。" />
)}
<div className="xx-generation-layout">
<div>
<Form
form={form}
layout="vertical"
onFinish={(values) =>
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 || '',
})
}
>
<div className="xx-section-block">
<div className="xx-section-title"><h3></h3><span className="xx-hint"> + B-roll</span></div>
<Form.Item label="素材库" name="asset_library_id" rules={[{ required: true, message: '请选择素材库' }]}>
<Select options={assetLibraryOptions} placeholder="选择视频/图片素材库" loading={librariesQuery.isLoading} />
</Form.Item>
</div>
<div className="xx-section-block">
<div className="xx-section-title"><h3></h3></div>
<Form.Item label="标题" name="title_id">
<Select allowClear options={titleOptions} placeholder="选择标题库中的标题" loading={titlesQuery.isLoading} />
</Form.Item>
<Form.Item label="剪辑模板" name="template_id">
<Select allowClear options={templateOptions} placeholder="选择剪辑模板" loading={templatesQuery.isLoading} />
</Form.Item>
</div>
<div className="xx-section-block">
<div className="xx-section-title"><h3></h3></div>
<Form.Item label="配音库" name="voice_library_id">
<Select allowClear options={voiceLibraryOptions} placeholder="选择配音库" loading={librariesQuery.isLoading} />
</Form.Item>
<Form.Item label="策略 ID" name="strategy_id">
<Input placeholder="可选,例如 default-strategy" />
</Form.Item>
</div>
</Form>
</div>
<aside className="xx-plan-panel">
<div className="xx-preview-phone">
<span className="xx-play"></span>
<div>3 · 30 </div>
</div>
<h3></h3>
{editPlan ? (
<>
<Typography.Paragraph>{editPlan.summary}</Typography.Paragraph>
<List
size="small"
dataSource={editPlan.clips}
renderItem={(clip) => (
<List.Item>
<Typography.Text>{clip.sequence}. {clip.asset_name} / {Math.round(clip.duration)} / {clip.reason}</Typography.Text>
</List.Item>
)}
/>
</>
) : (
<div className="xx-empty-state" style={{ padding: 18 }}>
<div className="xx-empty-icon">AI</div>
<b></b>
<span></span>
</div>
)}
<div className="xx-action-row" style={{ marginTop: 16 }}>
<Button
onClick={() => {
const values = form.getFieldsValue();
if (!values.asset_library_id) {
message.warning('请先选择素材库');
return;
}
editPlanMutation.mutate({
projectId,
data: {
workspace_id: workspaceId,
asset_library_id: values.asset_library_id,
template_id: values.template_id || '',
title_id: values.title_id || '',
},
});
}}
loading={editPlanMutation.isPending}
disabled={!canSubmitGeneration}
<div className="xx-card">
<h3></h3>
<div className="xx-template-grid">
{titles?.map((title, idx) => (
<div
key={title.id}
className={`xx-choice ${selectedTitle === title.id ? 'selected' : ''}`}
onClick={() => setSelectedTitle(title.id)}
>
</Button>
<Button className="xx-btn-primary" type="primary" onClick={() => form.submit()} loading={generationMutation.isPending} disabled={!canSubmitGeneration || !editPlan}>
</Button>
</div>
</aside>
<div className="cover" style={{
background: ['linear-gradient(135deg,#4f46e5,#818cf8)',
'linear-gradient(135deg,#f97316,#ef4444)',
'linear-gradient(135deg,#10b981,#0284c7)',
'linear-gradient(135deg,#64748b,#334155)'][idx % 4]
}}>
📹
</div>
{selectedTitle === title.id && <div className="check"></div>}
<b>{title.name}</b>
<p>{title.content.substring(0, 50)}...</p>
</div>
))}
</div>
</div>
{taskQuery.data && (
<Alert
style={{ marginTop: 16 }}
type={taskQuery.data.status === 'failed' ? 'error' : taskQuery.data.status === 'completed' ? 'success' : 'info'}
message={`生成状态:${humanizeGenerationStatus(taskQuery.data.status)}`}
description={taskQuery.data.status === 'failed' ? failedReason : `进度 ${taskQuery.data.progress}% / 结果 ${taskQuery.data.result_count}`}
/>
)}
{resultsQuery.data?.length ? (
<div className="xx-section-block" style={{ marginTop: 16 }}>
<div className="xx-section-title"><h3></h3></div>
<Space direction="vertical">
{resultsQuery.data.map((item) => (
<Space key={item.id}>
<Typography.Text>{item.name}</Typography.Text>
<Button type="link" onClick={() => openSignedDownload(item.id)}>/</Button>
</Space>
))}
</Space>
</div>
) : null}
<Modal
title="生成新成片"
open={generateOpen}
okText="开始生成"
cancelText="取消"
confirmLoading={createMutation.isPending}
onOk={handleGenerate}
onCancel={() => setGenerateOpen(false)}
>
<Form form={form} layout="vertical">
<Form.Item
name="template_id"
label="选择模板"
rules={[{ required: true, message: '请选择模板' }]}
>
<Select placeholder="选择剪辑模板">
<Select.Option value="basic"></Select.Option>
<Select.Option value="product"></Select.Option>
<Select.Option value="lifestyle"></Select.Option>
</Select>
</Form.Item>
<Form.Item
name="duration"
label="视频时长"
rules={[{ required: true, message: '请选择时长' }]}
>
<Select placeholder="选择时长">
<Select.Option value="15">15</Select.Option>
<Select.Option value="30">30</Select.Option>
<Select.Option value="60">60</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="music"
label="背景音乐"
>
<Select placeholder="选择背景音乐">
<Select.Option value="upbeat"></Select.Option>
<Select.Option value="calm"></Select.Option>
<Select.Option value="dramatic"></Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
);
};
@@ -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;
}
+97 -119
View File
@@ -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<string, { label: string; color: string; pill: string }> = {
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<GeneratedVideoItem[]>([]);
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 (
<div className={`xx-vertical-thumb ${tone}`} onMouseEnter={loadSignedPreview} onClick={loadSignedPreview}>
{previewUrl ? (
<video className="xx-result-video" src={previewUrl} poster={item.thumbnail_url || undefined} controls preload="metadata" playsInline />
) : item.thumbnail_url ? (
<img className="xx-result-poster" src={item.thumbnail_url} alt={item.name} />
) : (
<button className="xx-mini-play" type="button" disabled={loading}>{loading ? '...' : '▶'}</button>
)}
</div>
);
};
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 <div className="xx-loading">...</div>;
}
return (
<div className="xx-page-card">
<div className="xx-project-results">
<div className="xx-page-head">
<div>
<h1></h1>
<p></p>
<h2></h2>
<p></p>
</div>
<Button disabled={!videosQuery.data?.length} onClick={handleBatchDownload} className="xx-btn-primary" type="primary">
</Button>
</div>
{videosQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="生成结果加载失败" />}
{videosQuery.isLoading ? (
<Spin tip="加载生成结果..." />
) : videosQuery.data?.length ? (
<div className="xx-vertical-grid compact">
{videosQuery.data.map((item, index) => {
const review = reviewLabels[item.review_status || 'pending_review'] || reviewLabels.pending_review;
return (
<article className="xx-vertical-card" key={item.id}>
<ResultPreview item={item} tone={thumbClassNames[index % thumbClassNames.length]} />
<div className="xx-vertical-row">
<b title={item.name}>{item.name}</b>
<span className={`xx-pill ${review.pill}`}>{review.label}</span>
</div>
<p className="xx-vertical-meta">{videoMeta(item)}</p>
<div className="xx-action-row" style={{ marginTop: 10 }}>
<Button size="small" icon={<DownloadOutlined />} onClick={() => openSignedUrl(item.id, '获取下载地址')}></Button>
<Button size="small" onClick={() => reviewMutation.mutate({ videoId: item.id, reviewStatus: 'approved' })}></Button>
<Button size="small" danger onClick={() => reviewMutation.mutate({ videoId: item.id, reviewStatus: 'rejected' })}></Button>
</div>
</article>
);
})}
{videos.length === 0 ? (
<div className="xx-empty-state">
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🎬</div>
<p></p>
</div>
) : (
<div className="xx-empty-state">
<div className="xx-empty-icon"></div>
<b></b>
<span> 9:16 </span>
<div className="xx-card">
<h3></h3>
<div className="xx-vertical-grid compact">
{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 (
<div key={video.id} className="xx-vertical-card">
<div className={`xx-vertical-thumb ${tone}`}>
<button
className="mini-play"
onClick={() => window.open(video.file_url || '#', '_blank')}
>
</button>
</div>
<div className="row">
<b>{video.name}</b>
<span className={`pill ${statusInfo.class}`}>{statusInfo.label}</span>
</div>
<p>
{video.duration || 0}s · {video.width}x{video.height} · {Math.round(video.file_size / 1024 / 1024)}MB
</p>
<div className="assist-actions">
{video.review_status !== 'approved' && (
<button
className="btn ghost"
onClick={() => handleReviewStatus(video.id, 'approved')}
>
</button>
)}
<button
className="btn ghost"
onClick={() => handleDownload(video.id, video.name)}
>
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
@@ -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;
}
+122 -129
View File
@@ -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<string, string> = {
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<string>('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 <div className="xx-loading">...</div>;
}
return (
<div className="xx-page-card">
<div className="xx-project-titles">
<div className="xx-page-head">
<div>
<h1></h1>
<p></p>
</div>
<div className="xx-action-row">
<Button></Button>
<Button icon={<ReloadOutlined />} onClick={() => titlesQuery.refetch()}></Button>
<Button className="xx-btn-primary" type="primary" icon={<PlusOutlined />} onClick={() => form.submit()} loading={createMutation.isPending} disabled={!projectQuery.data}>
</Button>
<h2></h2>
<p></p>
</div>
<button className="btn primary" onClick={() => setCreateOpen(true)}>
+
</button>
</div>
<div style={{ marginBottom: 24, padding: 18, border: '1px solid #e8eef7', borderRadius: 22, background: '#fff' }}>
<Form form={form} layout="vertical" initialValues={{ category: 'default', favorite: false }} onFinish={(values) => createMutation.mutate(values)}>
<Space wrap style={{ width: '100%' }} align="start">
<Form.Item name="text" label="标题内容" rules={[{ required: true, message: '请输入标题' }, { max: 200, message: '标题最多 200 字' }]} style={{ minWidth: 360, flex: 1 }}>
<Input placeholder="例如:3 秒抓住注意力,30 秒讲清卖点" />
</Form.Item>
<Form.Item name="category" label="分类">
<Select options={categoryOptions} style={{ width: 140 }} />
</Form.Item>
<Form.Item name="favorite" label="常用" valuePropName="checked">
<Switch checkedChildren="常用" unCheckedChildren="普通" />
</Form.Item>
</Space>
</Form>
</div>
<div className="xx-card">
<div className="xx-search-bar">
<Input
placeholder="搜索标题..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ padding: '10px 14px', borderRadius: '12px' }}
/>
</div>
<Space wrap style={{ marginBottom: 16 }}>
<Input.Search placeholder="搜索标题" allowClear onSearch={setSearchText} onChange={(event) => setSearchText(event.target.value)} style={{ width: 260 }} />
<Select value={categoryFilter} onChange={setCategoryFilter} style={{ width: 140 }} options={[{ label: '全部分类', value: 'all' }, ...categoryOptions]} />
<Switch checked={favoriteOnly} onChange={setFavoriteOnly} checkedChildren="仅常用" unCheckedChildren="全部" />
</Space>
{filteredTitles.length ? (
<Space direction="vertical" size={10} style={{ width: '100%' }}>
{filteredTitles.map((item, index) => (
<div className="xx-title-row" key={item.id}>
<div className="xx-title-index">{index + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<b>{item.text}</b>
<span>使{item.usage_count}</span>
{filtered.length === 0 ? (
<div className="xx-empty-state">
<p>{searchText ? '没有找到匹配的标题' : '还没有创建任何标题'}</p>
</div>
) : (
<div className="xx-title-list">
{filtered.map((title) => (
<div key={title.id} className="xx-title-row">
<div>
<b>{title.name}</b>
<p>{title.content}</p>
</div>
<div className="xx-actions">
<button
className="btn ghost"
onClick={() => handleDelete(title.id)}
>
</button>
</div>
</div>
<Space wrap>
{item.favorite && <Tag color="gold"></Tag>}
<Tag color={item.is_active ? 'green' : 'default'}>{item.is_active ? '启用' : '停用'}</Tag>
<Tag>{categoryLabels[item.category] || item.category}</Tag>
<Switch checked={item.favorite} checkedChildren="常用" unCheckedChildren="普通" onChange={(checked) => toggleMutation.mutate({ titleId: item.id, favorite: checked })} />
<Switch checked={item.is_active} checkedChildren="启用" unCheckedChildren="停用" onChange={(checked) => toggleMutation.mutate({ titleId: item.id, isActive: checked })} />
</Space>
</div>
))}
</Space>
) : (
<div className="xx-empty-state">
<div className="xx-empty-icon">T</div>
<b></b>
<span></span>
</div>
)}
))}
</div>
)}
</div>
<Modal
title="创建新标题"
open={createOpen}
okText="创建"
cancelText="取消"
onOk={handleCreate}
onCancel={() => setCreateOpen(false)}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="标题"
rules={[{ required: true, message: '请输入标题' }]}
>
<Input placeholder="例如:产品介绍" />
</Form.Item>
<Form.Item
name="content"
label="标题内容"
rules={[{ required: true, message: '请输入标题内容' }]}
>
<Input.TextArea
placeholder="例如:完整介绍产品功能和优势"
rows={4}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export const Component = ProjectTitles;
export default ProjectTitles;
export const Component = ProjectTitles;
@@ -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;
}
+69 -82
View File
@@ -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<string>('');
const [playingId, setPlayingId] = useState<string>('');
const { id: projectId } = useParams<{ id: string }>();
const [voices, setVoices] = useState<VoiceLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedVoice, setSelectedVoice] = useState<string>('');
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 <div className="xx-loading">...</div>;
}
return (
<div className="xx-page-card">
<div className="xx-project-voices">
<div className="xx-page-head">
<div>
<h1></h1>
<p></p>
</div>
<div className="xx-action-row">
<Select
style={{ width: 240 }}
placeholder="选择配音库"
value={activeLibraryId || undefined}
options={voiceLibraries.map((library) => ({ label: library.name, value: library.id }))}
onChange={setLibraryId}
loading={librariesQuery.isLoading}
/>
<Button icon={<ReloadOutlined />} onClick={() => { librariesQuery.refetch(); assetsQuery.refetch(); }}></Button>
<Button icon={<PlusOutlined />} disabled></Button>
<h2></h2>
<p></p>
</div>
</div>
{librariesQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="配音库加载失败" />}
{assetsQuery.isError && <Alert style={{ marginBottom: 16 }} type="error" showIcon message="配音素材加载失败" />}
{!voiceLibraries.length && !librariesQuery.isLoading && (
<Alert style={{ marginBottom: 16 }} type="info" showIcon message="还没有配音库" description="可先在素材库中新建类型为“配音库”的素材库并上传音频。" />
)}
<div className="xx-card">
<h3></h3>
<div className="xx-voice-grid">
{VOICE_OPTIONS.map((voice) => (
<div
key={voice.id}
className={`xx-voice ${selectedVoice === voice.id ? 'selected' : ''}`}
onClick={() => setSelectedVoice(voice.id)}
>
<div className="voice-icon">{voice.icon}</div>
<div>{voice.name}</div>
<small></small>
</div>
))}
</div>
</div>
{voiceAssets.length ? (
<div className="xx-sound-grid">
{voiceAssets.map((asset, index) => {
const duration = formatDuration(asset.metadata?.duration_seconds || asset.metadata?.duration);
const audioUrl = String(asset.metadata?.url || asset.metadata?.file_url || asset.storage_key || '');
const isPlaying = playingId === asset.id;
return (
<div key={asset.id}>
{isPlaying && <div className="xx-sound-progress" />}
<article className="xx-sound-card">
<button className={`xx-sound-cover ${coverTones[index % coverTones.length]}`} type="button" onClick={() => setPlayingId(isPlaying ? '' : asset.id)}>
<span className="xx-sound-play">{isPlaying ? 'Ⅱ' : '▶'}</span>
</button>
<div>
<div className="xx-sound-title" title={asset.name}>{asset.name}</div>
<div className="xx-sound-wave" />
{audioUrl && isPlaying && <audio src={audioUrl} autoPlay controls className="xx-audio-inline" />}
</div>
<div className="xx-sound-time">{duration}</div>
</article>
{voices.length > 0 && (
<div className="xx-card">
<h3>使</h3>
<div className="xx-voice-usage-list">
{voices.map((voice) => (
<div key={voice.id} className="xx-voice-usage-row">
<div>
<b>{voice.name}</b>
<p>{voice.type} - {voice.language}</p>
</div>
</div>
);
})}
))}
</div>
</div>
) : (
<Empty className="xx-empty-state" description="当前配音库暂无音频素材" />
)}
</div>
);
+85 -24
View File
@@ -1,37 +1,98 @@
.workspace-list {
padding: 24px;
/* V21 工作空间列表 */
.xx-workspace-list {
}
.workspace-list-header {
.xx-page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
align-items: flex-start;
gap: 18px;
margin-bottom: 22px;
}
.workspace-card {
.xx-page-head h2 {
font-size: 28px;
margin: 0 0 6px;
color: #0f172a;
}
.xx-page-head p {
margin: 0;
color: #64748b;
font-size: 14px;
}
.xx-grid4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
@media (max-width: 1200px) {
.xx-grid4 {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.xx-grid4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.xx-grid4 {
grid-template-columns: 1fr;
}
}
.xx-workspace-card {
border: 1px solid #e2e8f0;
background: rgba(255, 255, 255, 0.94);
border-radius: 24px;
padding: 18px;
cursor: pointer;
transition: all 0.3s;
transition: all 0.2s;
}
.workspace-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
.xx-workspace-card:hover {
border-color: #4f46e5;
box-shadow: 0 12px 36px rgba(79, 70, 229, 0.12);
}
.workspace-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.empty-state {
text-align: center;
padding: 80px 0;
}
.empty-state p {
.xx-workspace-name {
font-size: 16px;
color: #999;
margin: 24px 0;
font-weight: 850;
color: #0f172a;
margin-bottom: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xx-workspace-plan {
display: inline-block;
font-size: 12px;
font-weight: 850;
padding: 4px 8px;
border-radius: 8px;
background: #eef2ff;
color: #4338ca;
margin-bottom: 12px;
}
.xx-workspace-date {
font-size: 12px;
color: #94a3b8;
}
.xx-empty-state {
text-align: center;
padding: 60px 24px;
color: #64748b;
}
.xx-empty-state p {
font-size: 16px;
margin: 12px 0;
}
+32 -58
View File
@@ -1,16 +1,15 @@
/**
* 工作空间列表页面
* 工作空间列表页面 - V21 严格对标
*/
import React from 'react';
import { Card, Button, Row, Col, Tag, Space, Modal, Form, Input, message, Alert } from 'antd';
import { PlusOutlined, TeamOutlined, CrownOutlined } from '@ant-design/icons';
import { Button, Modal, Form, Input, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useCreateWorkspace, useWorkspaces } from '@/hooks/useWorkspace';
import './WorkspaceList.css';
const WorkspaceList: React.FC = () => {
const navigate = useNavigate();
const { data: workspaces, isLoading, error } = useWorkspaces();
const { data: workspaces, isLoading } = useWorkspaces();
const createWorkspaceMutation = useCreateWorkspace();
const [form] = Form.useForm<{ name: string }>();
const [createOpen, setCreateOpen] = React.useState(false);
@@ -26,69 +25,45 @@ const WorkspaceList: React.FC = () => {
form.resetFields();
navigate(`/workspaces/${workspace.id}`);
} catch (createError: any) {
message.error(createError.response?.data?.detail || '工作空间创建失败,请稍后重试');
message.error(createError.response?.data?.detail || '工作空间创建失败');
}
};
const getPlanTag = (plan: string) => {
const planConfig = {
free: { color: 'default', text: 'Free' },
pro: { color: 'blue', text: 'Pro' },
enterprise: { color: 'gold', text: 'Enterprise' },
};
const config = planConfig[plan as keyof typeof planConfig] || planConfig.free;
return <Tag color={config.color}>{config.text}</Tag>;
};
return (
<div className="workspace-list">
<div className="workspace-list-header">
<h1></h1>
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={openCreateModal}>
<div className="xx-workspace-list">
<div className="xx-page-head">
<div>
<h2></h2>
<p></p>
</div>
<Button type="primary" size="large" onClick={openCreateModal}>
+
</Button>
</div>
{error && (
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
message="工作空间加载失败"
description="请刷新页面重试;如果仍失败,请联系管理员。"
/>
)}
<Row gutter={[16, 16]}>
<div className="xx-grid4">
{workspaces?.map((workspace) => (
<Col key={workspace.id} xs={24} sm={12} md={8} lg={6}>
<Card
hoverable
onClick={() => navigate(`/workspaces/${workspace.id}`)}
className="workspace-card"
>
<Space direction="vertical" style={{ width: '100%' }}>
<div className="workspace-name">{workspace.name}</div>
<div>{getPlanTag(workspace.subscription_plan)}</div>
<div style={{ color: '#666', fontSize: '14px' }}>
<TeamOutlined />
</div>
<div style={{ color: '#999', fontSize: '12px' }}>
{workspace.created_at
? `创建于 ${new Date(workspace.created_at).toLocaleDateString()}`
: '创建时间暂未返回'}
</div>
</Space>
</Card>
</Col>
<div
key={workspace.id}
className="xx-workspace-card"
onClick={() => navigate(`/workspaces/${workspace.id}`)}
>
<div className="xx-workspace-name">{workspace.name}</div>
<div className="xx-workspace-plan">
{workspace.subscription_plan === 'enterprise' ? '企业版' : workspace.subscription_plan === 'pro' ? 'Pro' : 'Free'}
</div>
<div className="xx-workspace-date">
{workspace.created_at ? new Date(workspace.created_at).toLocaleDateString() : ''}
</div>
</div>
))}
</Row>
</div>
{!isLoading && workspaces?.length === 0 && (
<div className="empty-state">
<CrownOutlined style={{ fontSize: '64px', color: '#ccc' }} />
<div className="xx-empty-state">
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
<p></p>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
<Button type="primary" onClick={openCreateModal}>
</Button>
</div>
@@ -103,13 +78,13 @@ const WorkspaceList: React.FC = () => {
onOk={handleCreateWorkspace}
onCancel={() => setCreateOpen(false)}
>
<Form form={form} layout="vertical" preserve={false}>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="工作空间名称"
rules={[
{ required: true, message: '请输入工作空间名称' },
{ max: 50, message: '工作空间名称最多 50 个字符' },
{ max: 50, message: '最多 50 个字符' },
]}
>
<Input placeholder="例如:我的剪辑工作室" autoFocus />
@@ -121,5 +96,4 @@ const WorkspaceList: React.FC = () => {
};
export default WorkspaceList;
export const Component = WorkspaceList;