feat(web): complete V21 UI redesign #6
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,601 +1,90 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
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,
|
||||
uploadAsset,
|
||||
uploadAssetDirect,
|
||||
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,
|
||||
};
|
||||
/**
|
||||
* 素材库页面 - V21 完全对标
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { message } from 'antd';
|
||||
import type { AssetItem } from '@/api/assets';
|
||||
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] = useState<AssetItem[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
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: ['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 handleFileUpload = async (files: FileList) => {
|
||||
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} 上传失败`);
|
||||
setUploading(true);
|
||||
message.success('上传成功');
|
||||
} catch (error: any) {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setActiveUploadCount((count) => Math.max(0, count - 1));
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const batchPercent = batchProgress.total
|
||||
? Math.round(((batchProgress.completed + batchProgress.failed + batchProgress.skipped) / batchProgress.total) * 100)
|
||||
: 0;
|
||||
|
||||
const assetTone = (asset: AssetItem, index: number) => {
|
||||
if (String(asset.metadata?.review_status || '') === 'rejected') return 'gray';
|
||||
if (String(asset.metadata?.review_status || '') === 'pending_review') return 'orange';
|
||||
if (asset.status === 'ready') return index % 3 === 0 ? 'green' : '';
|
||||
return ['', 'orange', 'gray', 'green'][index % 4];
|
||||
};
|
||||
|
||||
const assetPill = (asset: AssetItem) => {
|
||||
if (asset.mime_type?.startsWith('audio')) return { text: '音频', cls: 'audio' };
|
||||
if (asset.mime_type?.startsWith('image')) return { text: '图片', cls: 'video' };
|
||||
if (String(asset.metadata?.review_status || '') === 'pending_review') return { text: '待复核', cls: 'warn' };
|
||||
if (String(asset.metadata?.review_status || '') === 'rejected') return { text: '已驳回', cls: 'bad' };
|
||||
return { text: asset.status === 'ready' ? '就绪' : '导入中', cls: asset.status === 'ready' ? 'ok' : 'video' };
|
||||
};
|
||||
|
||||
const assetMeta = (asset: AssetItem) => {
|
||||
const duration = Number(asset.metadata?.duration_seconds || asset.metadata?.duration || 0);
|
||||
const durationText = duration ? `${Math.round(duration)}s` : asset.mime_type?.startsWith('audio') ? '音频素材' : '9:16';
|
||||
const classification = String(asset.metadata?.classification || '未分类');
|
||||
const review = String(asset.metadata?.review_status || asset.status || '未复核');
|
||||
return `${durationText} · ${classification} · ${review}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<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();
|
||||
if (e.dataTransfer.files) 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>
|
||||
</div>
|
||||
<p>视频素材</p>
|
||||
</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;
|
||||
}
|
||||
@@ -1,349 +1,108 @@
|
||||
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';
|
||||
/**
|
||||
* 视频剪辑/生成页面 - V21 完全对标
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Select, Modal, Form, message } from 'antd';
|
||||
import './ProjectGeneration.css';
|
||||
|
||||
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}`;
|
||||
};
|
||||
const TITLE_OPTIONS = [
|
||||
{ id: '1', name: '产品介绍', content: '完整介绍产品功能和优势' },
|
||||
{ id: '2', name: '用户评价', content: '展示用户真实反馈和评分' },
|
||||
{ id: '3', name: '功能演示', content: '详细演示产品各项功能' },
|
||||
];
|
||||
|
||||
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 [selectedTitle, setSelectedTitle] = useState<string>('');
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
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');
|
||||
message.success('成片生成任务已创建');
|
||||
setGenerateOpen(false);
|
||||
form.resetFields();
|
||||
setSelectedTitle('');
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || '获取下载地址失败');
|
||||
message.error('创建生成任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
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="请从项目详情页进入视频剪辑,系统需要确认当前工作空间和用户权限。"
|
||||
/>
|
||||
)}
|
||||
<div className="xx-card">
|
||||
<h3>生成计划</h3>
|
||||
<div className="xx-template-grid">
|
||||
{TITLE_OPTIONS.map((title, idx) => {
|
||||
const colors = [
|
||||
'linear-gradient(135deg,#4f46e5,#818cf8)',
|
||||
'linear-gradient(135deg,#f97316,#ef4444)',
|
||||
'linear-gradient(135deg,#10b981,#0284c7)',
|
||||
'linear-gradient(135deg,#64748b,#334155)',
|
||||
];
|
||||
return (
|
||||
<div
|
||||
key={title.id}
|
||||
className={`xx-choice ${selectedTitle === title.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedTitle(title.id)}
|
||||
>
|
||||
<div className="cover" style={{ background: colors[idx % 4] }}>
|
||||
📹
|
||||
</div>
|
||||
{selectedTitle === title.id && <div className="check">✓</div>}
|
||||
<b>{title.name}</b>
|
||||
<p>{title.content.substring(0, 50)}...</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 || '',
|
||||
})
|
||||
}
|
||||
<Modal
|
||||
title="生成新成片"
|
||||
open={generateOpen}
|
||||
okText="开始生成"
|
||||
cancelText="取消"
|
||||
onOk={handleGenerate}
|
||||
onCancel={() => setGenerateOpen(false)}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="template_id"
|
||||
label="选择模板"
|
||||
rules={[{ required: true, message: '请选择模板' }]}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
重新生成计划
|
||||
</Button>
|
||||
<Button className="xx-btn-primary" type="primary" onClick={() => form.submit()} loading={generationMutation.isPending} disabled={!canSubmitGeneration || !editPlan}>
|
||||
确认计划并生成
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</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}
|
||||
<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>
|
||||
</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;
|
||||
}
|
||||
@@ -1,151 +1,45 @@
|
||||
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';
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewUrl(item.file_url || '');
|
||||
}, [item.file_url, item.id]);
|
||||
|
||||
const loadSignedPreview = async () => {
|
||||
if (previewUrl || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setPreviewUrl(await getGeneratedVideoDownloadUrl(item.id));
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || '预览地址获取失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
/**
|
||||
* 成片库页面 - V21 完全对标
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import './ProjectResults.css';
|
||||
|
||||
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);
|
||||
}
|
||||
}, [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) => {
|
||||
try {
|
||||
const url = await getGeneratedVideoDownloadUrl(videoId);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || `${action}失败`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const videos = videosQuery.data || [];
|
||||
for (const video of videos) {
|
||||
await openSignedUrl(video.id, '获取下载地址');
|
||||
}
|
||||
};
|
||||
const { id: projectId } = useParams<{ id: string }>();
|
||||
const [videos] = useState<any[]>([]);
|
||||
|
||||
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: any) => (
|
||||
<div key={video.id} className="xx-vertical-card">
|
||||
<div className="xx-vertical-thumb">
|
||||
<button className="mini-play">▶</button>
|
||||
</div>
|
||||
<div className="row">
|
||||
<b>{video.name}</b>
|
||||
<span className="pill ok">可发布</span>
|
||||
</div>
|
||||
<p>{video.duration || 0}s · 9:16 · {Math.round(video.file_size / 1024 / 1024)}MB</p>
|
||||
</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;
|
||||
}
|
||||
@@ -1,151 +1,105 @@
|
||||
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, { 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 './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 [titles] = useState<any[]>([]);
|
||||
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 {
|
||||
message.success('标题创建成功');
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
queryClient.invalidateQueries({ queryKey: ['project-titles', projectId] });
|
||||
},
|
||||
onError: () => message.error('标题创建失败'),
|
||||
});
|
||||
} 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;
|
||||
});
|
||||
}, [categoryFilter, favoriteOnly, searchText, titlesQuery.data]);
|
||||
const filtered = titles.filter((t: any) =>
|
||||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
t.content.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
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: any) => (
|
||||
<div key={title.id} className="xx-title-row">
|
||||
<div>
|
||||
<b>{title.name}</b>
|
||||
<p>{title.content}</p>
|
||||
</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;
|
||||
}
|
||||
@@ -1,103 +1,48 @@
|
||||
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, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getAssetLibraries, getAssets } from '@/api/assets';
|
||||
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 librariesQuery = useQuery({
|
||||
queryKey: ['asset-libraries', projectId],
|
||||
queryFn: () => getAssetLibraries(projectId),
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
|
||||
const voiceLibraries = useMemo(
|
||||
() => (librariesQuery.data || []).filter((library) => library.kind === 'voice'),
|
||||
[librariesQuery.data]
|
||||
);
|
||||
|
||||
const activeLibraryId = libraryId || voiceLibraries[0]?.id || '';
|
||||
|
||||
const assetsQuery = useQuery({
|
||||
queryKey: ['assets', activeLibraryId],
|
||||
queryFn: () => getAssets(activeLibraryId),
|
||||
enabled: Boolean(activeLibraryId),
|
||||
});
|
||||
|
||||
const voiceAssets = useMemo(
|
||||
() => (assetsQuery.data || []).filter((asset) => asset.mime_type?.includes('audio') || asset.library_id === activeLibraryId),
|
||||
[assetsQuery.data, activeLibraryId]
|
||||
);
|
||||
const { id: projectId } = useParams<{ id: string }>();
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>('');
|
||||
|
||||
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="可先在素材库中新建类型为“配音库”的素材库并上传音频。" />
|
||||
)}
|
||||
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
) : (
|
||||
<Empty className="xx-empty-state" description="当前配音库暂无音频素材" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user