refactor(cover): AI数字人封面复用智能剪辑共享组件 (#2034)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m40s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m43s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m18s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m36s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m44s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 3m54s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m3s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 27s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 30s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m40s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m43s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m18s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m36s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m44s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 3m54s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m3s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 27s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 30s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
- 抽取共享 hook useSharedCover 统一封装模板CRUD/自动生成/编辑器/上传逻辑 - AI数字人 ModalCoverSelect 改为复用 CoverSettingsModal+CoverEditorModal,支持模板选择、7面板自定义编辑器、自动生成封面、本地上传 - generateRenderSmartCover 新增 templateId 参数(默认default),支持模板样式渲染;模板模式超时延长到 180s - Step6CoverSettings 改为基于 useSharedCover,删除重复的模板/编辑器状态逻辑 - 从 generate.css 抽取封面编辑器样式到 components/cover/cover.css,两个业务模块按需引入 - PanelCoverAndGenerate 精简为 setup 变体(分辨率/配置摘要/生成按钮),封面相关UI统一迁入 ModalCoverSelect - tsc/eslint/prettier 全过
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* 共享封面编辑器样式(智能剪辑 generate + AI数字人 ai-avatar 共用)
|
||||
* #2033:从 generate.css 抽取 xx-ce-* / xx-cover-template-* / xx-cover-modal-* 规则
|
||||
*/
|
||||
|
||||
.xx-cover-modal-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.xx-cover-template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-template-card {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.xx-cover-template-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-cover-template-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.xx-cover-template-thumb {
|
||||
aspect-ratio: 9/16;
|
||||
background: linear-gradient(135deg, #f0f0f0, #e0e0e0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: #ccc;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-cover-template-info {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.xx-cover-template-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.xx-cover-template-badge {
|
||||
font-size: 11px;
|
||||
color: #7c3aed;
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-cover-template-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.xx-cover-template-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-ce-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-ce-name-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xx-ce-name-input:focus {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.xx-ce-header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-ce-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.xx-ce-left {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.xx-ce-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.xx-ce-section {
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-ce-section-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f0f4ff;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-ce-section-header:hover {
|
||||
background: #e8edf8;
|
||||
}
|
||||
|
||||
.xx-ce-section-body {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.xx-ce-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-ce-status-text {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.xx-ce-row {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.xx-ce-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-ce-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.xx-ce-sub-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.xx-ce-switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.xx-ce-switch-item {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.xx-ce-switch-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-ce-color-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-ce-color-picker input[type="color"] {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.xx-ce-color-hex {
|
||||
width: 70px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.xx-ce-position {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-ce-position .ant-input-number {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xx-ce-radio-group {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn {
|
||||
padding: 4px 14px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn + .xx-ce-radio-btn {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn.active {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
|
||||
border-left: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.xx-ce-font-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.xx-ce-font-dot--preset {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.xx-ce-font-dot--system {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.xx-ce-shadow-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.xx-ce-add-shadow-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-ce-add-shadow-btn:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
.xx-ce-preset-shadow-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-ce-text-bg-section {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.xx-ce-readonly-text {
|
||||
padding: 6px 10px;
|
||||
background: #eff6ff;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-ce-file-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-ce-file-name {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.xx-ce-file-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-ce-file-btn:hover {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.xx-ce-canvas-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-ce-canvas {
|
||||
width: 225px;
|
||||
height: 400px;
|
||||
background: #ddd;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-ce-anchor-dot {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xx-ce-el-portrait {
|
||||
position: absolute;
|
||||
background: #a8d4f0;
|
||||
border: 2px solid #333;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.xx-ce-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #3b82f6;
|
||||
border: 1px solid #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.xx-ce-handle--0 {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--1 {
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--2 {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--3 {
|
||||
top: 50%;
|
||||
right: -4px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--4 {
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--5 {
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--6 {
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--7 {
|
||||
top: 50%;
|
||||
left: -4px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-el-bg {
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xx-ce-el-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xx-ce-text-bg {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.xx-cover-template-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
.xx-cover-template-thumb {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-ce-preview-tip {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.xx-ce-canvas {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.xx-ce-section-body .ant-slider {
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
.xx-ce-section-body .ant-slider-rail {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.xx-ce-section-body .ant-slider-track {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.xx-ce-section-body .ant-slider-handle::after {
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
}
|
||||
|
||||
.xx-ce-section-body .ant-slider-mark-text {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.xx-ce-font-select-dropdown .ant-select-item-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-ce-canvas > div {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Text panel wrapper */
|
||||
.xx-ce-text-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Canvas base gradient layer (behind all elements) */
|
||||
.xx-ce-canvas-base {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useSharedCover } from "./useSharedCover"
|
||||
export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover"
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 共享封面选择 Hook(供智能剪辑 generate 与 AI 数字人 ai-avatar 共同使用)
|
||||
*
|
||||
* 能力:
|
||||
* - 封面模板列表加载 / 选择 / 创建 / 编辑 / 删除(调用 /cover-templates 接口)
|
||||
* - 自动生成封面按钮点击 → 调用调用方传入的 generateFn
|
||||
* - 封面编辑器弹窗状态
|
||||
* - 本地封面上传文件选择
|
||||
*/
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverTemplate } from "@/pages/generate/types/cover"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
updateCoverTemplate,
|
||||
deleteCoverTemplate,
|
||||
} from "@/api/cover-templates"
|
||||
|
||||
export interface UseSharedCoverOptions {
|
||||
canGenerate: boolean
|
||||
disabledHint?: string
|
||||
generateFn: (templateId: string) => Promise<string | null | undefined>
|
||||
initialTemplateId?: string
|
||||
}
|
||||
|
||||
export interface UseSharedCoverReturn {
|
||||
templates: CoverTemplate[]
|
||||
templatesLoading: boolean
|
||||
templatesError: string | null
|
||||
selectedTemplateId: string
|
||||
selectedTemplateName: string
|
||||
handleSelectTemplate: (id: string) => void
|
||||
reloadTemplates: () => void
|
||||
showCoverSettings: boolean
|
||||
setShowCoverSettings: (v: boolean) => void
|
||||
showCoverEditor: boolean
|
||||
setShowCoverEditor: (v: boolean) => void
|
||||
editingTemplate: CoverTemplate | null
|
||||
handleEditTemplate: (tpl: CoverTemplate) => void
|
||||
handleCreateTemplate: () => void
|
||||
handleSaveTemplate: (tpl: CoverTemplate) => Promise<void>
|
||||
handleDeleteTemplate: (id: string) => Promise<void>
|
||||
generating: boolean
|
||||
generateAutoCover: () => Promise<void>
|
||||
uploadInputRef: React.RefObject<HTMLInputElement>
|
||||
handleUploadClick: () => void
|
||||
handleFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
setOnUploadFile: (fn: (file: File) => Promise<string | null> | string | null) => void
|
||||
}
|
||||
|
||||
export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverReturn {
|
||||
const { canGenerate, disabledHint, generateFn, initialTemplateId = "default" } = opts
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [showCoverSettings, setShowCoverSettings] = useState(false)
|
||||
const [showCoverEditor, setShowCoverEditor] = useState(false)
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(initialTemplateId)
|
||||
const [editingTemplate, setEditingTemplate] = useState<CoverTemplate | null>(null)
|
||||
const [templates, setTemplates] = useState<CoverTemplate[]>([])
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null)
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const onUploadFileRef = useRef<
|
||||
((file: File) => Promise<string | null> | string | null) | undefined
|
||||
>(undefined)
|
||||
|
||||
const setOnUploadFile = useCallback(
|
||||
(fn: (file: File) => Promise<string | null> | string | null) => {
|
||||
onUploadFileRef.current = fn
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const reloadTemplates = useCallback(async () => {
|
||||
setTemplatesLoading(true)
|
||||
setTemplatesError(null)
|
||||
try {
|
||||
const res = await fetchCoverTemplates()
|
||||
setTemplates(res.items || [])
|
||||
} catch (err) {
|
||||
console.error("[SharedCover] 加载封面模板失败:", err)
|
||||
setTemplatesError("加载模板失败,请稍后重试")
|
||||
} finally {
|
||||
setTemplatesLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (showCoverSettings) {
|
||||
void reloadTemplates()
|
||||
}
|
||||
}, [showCoverSettings, reloadTemplates])
|
||||
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
setSelectedTemplateId(id)
|
||||
}, [])
|
||||
|
||||
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
|
||||
setEditingTemplate(tpl)
|
||||
setShowCoverEditor(true)
|
||||
}, [])
|
||||
|
||||
const handleCreateTemplate = useCallback(() => {
|
||||
setEditingTemplate(null)
|
||||
setShowCoverEditor(true)
|
||||
}, [])
|
||||
|
||||
const handleSaveTemplate = useCallback(
|
||||
async (tpl: CoverTemplate) => {
|
||||
try {
|
||||
if (tpl.id && templates.some((t) => t.id === tpl.id)) {
|
||||
const updated = await updateCoverTemplate(tpl.id, { name: tpl.name, config: tpl.config })
|
||||
setTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t)))
|
||||
} else {
|
||||
const created = await createCoverTemplate({ name: tpl.name, config: tpl.config })
|
||||
setTemplates((prev) => [...prev, created])
|
||||
setSelectedTemplateId(created.id || tpl.id)
|
||||
}
|
||||
setShowCoverEditor(false)
|
||||
} catch (err) {
|
||||
console.error("[SharedCover] 保存模板失败:", err)
|
||||
message.error("保存模板失败,请稍后重试")
|
||||
}
|
||||
},
|
||||
[templates],
|
||||
)
|
||||
|
||||
const handleDeleteTemplate = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await deleteCoverTemplate(id)
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
if (selectedTemplateId === id) {
|
||||
setSelectedTemplateId("default")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SharedCover] 删除模板失败:", err)
|
||||
message.error("删除模板失败,请稍后重试")
|
||||
}
|
||||
},
|
||||
[selectedTemplateId],
|
||||
)
|
||||
|
||||
const generateAutoCover = useCallback(async () => {
|
||||
if (generating) {
|
||||
message.warning("封面正在生成中,请稍候…")
|
||||
return
|
||||
}
|
||||
if (!canGenerate) {
|
||||
if (disabledHint) message.warning(disabledHint)
|
||||
return
|
||||
}
|
||||
setGenerating(true)
|
||||
try {
|
||||
const url = await generateFn(selectedTemplateId || "default")
|
||||
if (!url) {
|
||||
message.warning("封面生成未返回图片,请重试")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SharedCover] 自动生成封面失败:", err)
|
||||
const anyErr = err as { __msgShown?: boolean; message?: string }
|
||||
if (!anyErr?.__msgShown) {
|
||||
message.error(anyErr?.message || "封面生成失败")
|
||||
}
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [generating, canGenerate, disabledHint, generateFn, selectedTemplateId])
|
||||
|
||||
const handleUploadClick = useCallback(() => {
|
||||
uploadInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (!file) return
|
||||
if (onUploadFileRef.current) {
|
||||
const ret = onUploadFileRef.current(file)
|
||||
if (ret instanceof Promise) {
|
||||
ret.catch((err) => {
|
||||
console.error("[SharedCover] 上传封面失败:", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectedTemplateName =
|
||||
templates.find((t) => t.id === selectedTemplateId)?.name ||
|
||||
(selectedTemplateId === "default" ? "默认模板" : "自定义")
|
||||
|
||||
return {
|
||||
templates,
|
||||
templatesLoading,
|
||||
templatesError,
|
||||
selectedTemplateId,
|
||||
selectedTemplateName,
|
||||
handleSelectTemplate,
|
||||
reloadTemplates,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
editingTemplate,
|
||||
handleEditTemplate,
|
||||
handleCreateTemplate,
|
||||
handleSaveTemplate,
|
||||
handleDeleteTemplate,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
uploadInputRef,
|
||||
handleUploadClick,
|
||||
handleFileInputChange,
|
||||
setOnUploadFile,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSharedCover
|
||||
@@ -726,17 +726,11 @@ const AiAvatarPage: React.FC = () => {
|
||||
<div className="aa-panel__body">
|
||||
{currentRenderJob?.status !== "completed" ? (
|
||||
<PanelCoverAndGenerate
|
||||
variant="setup"
|
||||
coverConfig={state.coverConfig}
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
renderJob={currentRenderJob}
|
||||
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
renderJob={currentRenderJob}
|
||||
summary={summary}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -99,15 +99,18 @@ export const cancelRenderJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
|
||||
}
|
||||
|
||||
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */
|
||||
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/render/{job_id}/smart-cover) ──
|
||||
* #2033 共享封面组件:支持传 template_id(模板ID,传 default 走默认智能抽帧)
|
||||
*/
|
||||
export const generateRenderSmartCover = async (
|
||||
jobId: string,
|
||||
templateId: string = "default",
|
||||
): Promise<{ cover_url: string; status: string; message: string }> => {
|
||||
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
|
||||
`/ai-avatar/render/${jobId}/smart-cover`,
|
||||
{},
|
||||
// 抽帧+评分+转存 OSS 链路较长,120s 超时
|
||||
{ timeout: 120000 },
|
||||
templateId && templateId !== "default" ? { template_id: templateId } : {},
|
||||
// 抽帧+评分+转存 OSS 链路较长,120s 超时;使用模板时叠加文字渲染再加 60s
|
||||
{ timeout: templateId && templateId !== "default" ? 180000 : 120000 },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
/**
|
||||
* AI数字人 — 封面选择弹窗
|
||||
* 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-cover 变体)提供
|
||||
* 智能抽帧 + 自定义上传 + 预览 + 确定按钮。
|
||||
* AI数字人 — 封面选择弹窗(#2033 共享封面组件重构)
|
||||
*
|
||||
* 复用智能剪辑的 CoverSettingsModal(模板选择)+ CoverEditorModal(7 面板自定义编辑器)
|
||||
* + 智能生成 / 本地上传 / 封面预览,与智能剪辑侧 UI 一致。
|
||||
*
|
||||
* 父组件仍维持 AiAvatarCoverConfig { mode, smart_cover_url, upload_url, thumbnail_url } 结构:
|
||||
* - 智能生成封面:mode="auto_frame",thumbnail_url/smart_cover_url 指向后端返回的 cover_url
|
||||
* - 本地上传封面:mode="upload",upload_url/thumbnail_url 指向 blob 预览 URL
|
||||
*
|
||||
* 模板 CRUD 通过 @/api/cover-templates 统一接口(智能剪辑与 AI数字人共享同一套模板库)。
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useCallback, useEffect, useMemo } from "react"
|
||||
import { Modal as AntModal, Spin, message } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "@/pages/generate/components/cover-settings/CoverSettingsModal"
|
||||
import CoverEditorModal from "@/pages/generate/components/cover-settings/CoverEditorModal"
|
||||
import { useSharedCover } from "@/components/cover/useSharedCover"
|
||||
import { generateRenderSmartCover as apiGenerateSmartCover } from "../api/aiAvatar"
|
||||
import type { AiAvatarCoverConfig, RenderJob } from "../types"
|
||||
import PanelCoverAndGenerate from "./PanelCoverAndGenerate"
|
||||
|
||||
interface ModalCoverSelectProps {
|
||||
open: boolean
|
||||
@@ -13,7 +27,13 @@ interface ModalCoverSelectProps {
|
||||
renderJob: RenderJob | null
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
|
||||
/**
|
||||
* 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入
|
||||
* 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。
|
||||
*/
|
||||
onGenerateRenderSmartCover?: (
|
||||
renderId: string,
|
||||
) => Promise<{ cover_url: string; message?: string }>
|
||||
onUploadCover?: (file: File) => void
|
||||
onCoverSelected: (coverUrl: string) => void
|
||||
}
|
||||
@@ -28,31 +48,302 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
|
||||
onUploadCover,
|
||||
onCoverSelected,
|
||||
}) => {
|
||||
const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id
|
||||
|
||||
const generateFn = useCallback(
|
||||
async (templateId: string): Promise<string | null> => {
|
||||
if (!renderJob || !isRenderCompleted) return null
|
||||
try {
|
||||
let coverUrl = ""
|
||||
if (onGenerateRenderSmartCover) {
|
||||
const res = await onGenerateRenderSmartCover(renderJob.id)
|
||||
coverUrl = res.cover_url
|
||||
} else {
|
||||
const res = await apiGenerateSmartCover(renderJob.id, templateId)
|
||||
coverUrl = res.cover_url
|
||||
if (!coverUrl && res.message) {
|
||||
const err = new Error(res.message) as Error & { __msgShown?: boolean }
|
||||
err.__msgShown = true
|
||||
message.error(res.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
if (coverUrl) {
|
||||
onCoverConfigChange({
|
||||
mode: "auto_frame",
|
||||
thumbnail_url: coverUrl,
|
||||
smart_cover_url: coverUrl,
|
||||
})
|
||||
onCoverSelected(coverUrl)
|
||||
message.success("智能封面已生成")
|
||||
}
|
||||
return coverUrl || null
|
||||
} catch (err) {
|
||||
const anyErr = err as { __msgShown?: boolean; message?: string }
|
||||
if (!anyErr?.__msgShown) {
|
||||
message.error(anyErr?.message || "智能封面生成失败")
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[
|
||||
renderJob,
|
||||
isRenderCompleted,
|
||||
onGenerateRenderSmartCover,
|
||||
onCoverConfigChange,
|
||||
onCoverSelected,
|
||||
],
|
||||
)
|
||||
|
||||
const shared = useSharedCover({
|
||||
canGenerate: isRenderCompleted,
|
||||
disabledHint: "请先完成视频生成再选择封面",
|
||||
initialTemplateId: "default",
|
||||
generateFn,
|
||||
})
|
||||
|
||||
// 父层 onUploadCover 走 onUploadFile 回调(兼容老父组件)
|
||||
useEffect(() => {
|
||||
shared.setOnUploadFile((file: File) => {
|
||||
if (onUploadCover) {
|
||||
onUploadCover(file)
|
||||
} else {
|
||||
const url = URL.createObjectURL(file)
|
||||
onCoverConfigChange({
|
||||
mode: "upload",
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
})
|
||||
onCoverSelected(url)
|
||||
}
|
||||
return null
|
||||
})
|
||||
}, [shared, onUploadCover, onCoverConfigChange, onCoverSelected])
|
||||
|
||||
// 打开时同步刷新模板列表
|
||||
useEffect(() => {
|
||||
if (open) void shared.reloadTemplates()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/** 当前预览 URL:智能封面 > 自定义上传 */
|
||||
const previewUrl = useMemo(
|
||||
() => coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url || "",
|
||||
[coverConfig.smart_cover_url, coverConfig.thumbnail_url, coverConfig.upload_url],
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 480 }}>
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择封面</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="选择封面"
|
||||
width={560}
|
||||
footer={
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" onClick={onClose}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
<div className="aa-modal__body" style={{ padding: 20 }}>
|
||||
<PanelCoverAndGenerate
|
||||
variant="select-cover"
|
||||
coverConfig={coverConfig}
|
||||
onCoverConfigChange={onCoverConfigChange}
|
||||
renderJob={renderJob}
|
||||
onGenerateRenderSmartCover={onGenerateRenderSmartCover}
|
||||
onUploadCover={onUploadCover}
|
||||
onClose={onClose}
|
||||
onCoverSelected={onCoverSelected}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: "8px 0" }}>
|
||||
{renderJob && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
background: "rgba(16, 185, 129, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary, #666)",
|
||||
}}
|
||||
>
|
||||
🎬 从渲染成片中智能选帧
|
||||
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
|
||||
<>
|
||||
{" "}
|
||||
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{/* 左:封面预览 */}
|
||||
<div
|
||||
style={{
|
||||
width: 180,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="xx-ce-canvas"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "9 / 16",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
background: "linear-gradient(135deg, #1e3a8a 0%, #312e81 100%)",
|
||||
border: previewUrl ? "none" : "1px dashed #d9d9d9",
|
||||
}}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="封面预览"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
gap: 6,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>
|
||||
{isRenderCompleted ? "点击下方按钮生成/上传" : "视频生成后可选择封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{shared.generating && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
<span>AI 选帧中…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
color: "#8c8ca1",
|
||||
}}
|
||||
>
|
||||
9:16 竖版封面
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右:操作按钮 */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => void shared.generateAutoCover()}
|
||||
disabled={!isRenderCompleted || shared.generating}
|
||||
loading={shared.generating}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() => shared.setShowCoverSettings(true)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
⚙️ 封面模板
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={shared.handleUploadClick}
|
||||
disabled={!isRenderCompleted || shared.generating}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
📷 本地上传
|
||||
</Button>
|
||||
<input
|
||||
ref={shared.uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={shared.handleFileInputChange}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#8c8ca1",
|
||||
lineHeight: 1.5,
|
||||
marginTop: 4,
|
||||
padding: "6px 8px",
|
||||
background: "#f7f8fa",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模板选择弹窗 */}
|
||||
<CoverSettingsModal
|
||||
open={shared.showCoverSettings}
|
||||
onClose={() => shared.setShowCoverSettings(false)}
|
||||
templates={shared.templates}
|
||||
loading={shared.templatesLoading}
|
||||
error={shared.templatesError}
|
||||
selectedTemplateId={shared.selectedTemplateId}
|
||||
onSelectTemplate={shared.handleSelectTemplate}
|
||||
onEditTemplate={shared.handleEditTemplate}
|
||||
onDeleteTemplate={shared.handleDeleteTemplate}
|
||||
onCreateNew={shared.handleCreateTemplate}
|
||||
/>
|
||||
|
||||
{/* 自定义编辑器弹窗 */}
|
||||
<CoverEditorModal
|
||||
open={shared.showCoverEditor}
|
||||
onClose={() => shared.setShowCoverEditor(false)}
|
||||
template={shared.editingTemplate}
|
||||
onSave={shared.handleSaveTemplate}
|
||||
/>
|
||||
|
||||
{/* 自动生成 loading 兜底弹窗(shared.generating 时按钮已自带 loading,这里保险) */}
|
||||
<AntModal open={shared.generating} closable={false} footer={null} centered width={320}>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
|
||||
AI 正在从最终成片选帧,请稍候...
|
||||
</p>
|
||||
</div>
|
||||
</AntModal>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
/**
|
||||
* AI数字人 — 面板5 / 封面选择弹窗内容:
|
||||
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段;
|
||||
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。
|
||||
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览),
|
||||
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。
|
||||
*
|
||||
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。
|
||||
* AI数字人 — 面板5 / 生成配置面板(渲染前)
|
||||
* #2033 重构后:只保留 setup 变体(分辨率/配置摘要/生成按钮)
|
||||
* 封面相关功能已迁移到 ModalCoverSelect(复用智能剪辑共享封面组件)
|
||||
*/
|
||||
import React, { useRef, useState } from "react"
|
||||
import type { AiAvatarCoverConfig, RenderJob } from "../types"
|
||||
|
||||
type PanelVariant = "setup" | "select-cover"
|
||||
import React from "react"
|
||||
import type { RenderJob } from "../types"
|
||||
|
||||
interface PanelCoverAndGenerateProps {
|
||||
variant?: PanelVariant
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
resolution?: string
|
||||
onResolutionChange?: (r: string) => void
|
||||
isGenerating?: boolean
|
||||
onGenerate?: () => void
|
||||
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */
|
||||
/** 当前渲染任务 */
|
||||
renderJob: RenderJob | null
|
||||
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */
|
||||
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
|
||||
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */
|
||||
onUploadCover?: (file: File) => void
|
||||
/** 弹窗关闭回调(传入则表示在弹窗中使用,底部显示「确定」按钮) */
|
||||
onClose?: () => void
|
||||
/** 封面选好(智能抽帧/自定义上传成功)后通知父组件,参数为封面 URL */
|
||||
onCoverSelected?: (coverUrl: string) => void
|
||||
/** 配置汇总信息(仅 variant="setup" 使用) */
|
||||
/** 配置汇总信息 */
|
||||
summary?: {
|
||||
videoName: string | null
|
||||
voiceName: string | null
|
||||
@@ -38,7 +21,6 @@ interface PanelCoverAndGenerateProps {
|
||||
lipsyncStatus: string | null
|
||||
brollCount: number
|
||||
hasTitle: boolean
|
||||
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
|
||||
coverStatus: "not_ready" | "pending" | "selected"
|
||||
}
|
||||
}
|
||||
@@ -58,89 +40,15 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
}
|
||||
|
||||
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
variant = "setup",
|
||||
coverConfig,
|
||||
onCoverConfigChange,
|
||||
resolution = "720p",
|
||||
onResolutionChange,
|
||||
isGenerating = false,
|
||||
onGenerate,
|
||||
renderJob,
|
||||
onGenerateRenderSmartCover,
|
||||
onUploadCover,
|
||||
onClose,
|
||||
onCoverSelected,
|
||||
renderJob: _renderJob,
|
||||
summary,
|
||||
}) => {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
// 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序)
|
||||
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
|
||||
|
||||
/** 自定义上传封面 */
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click()
|
||||
}
|
||||
|
||||
const _applyCoverUrl = (url: string, mode: "upload" | "auto_frame") => {
|
||||
const partial: Partial<AiAvatarCoverConfig> = {
|
||||
mode,
|
||||
thumbnail_url: url,
|
||||
}
|
||||
if (mode === "auto_frame") {
|
||||
partial.smart_cover_url = url
|
||||
} else {
|
||||
partial.upload_url = url
|
||||
}
|
||||
onCoverConfigChange(partial)
|
||||
onCoverSelected?.(url)
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (onUploadCover) {
|
||||
onUploadCover(file)
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
|
||||
const url = URL.createObjectURL(file)
|
||||
_applyCoverUrl(url, "upload")
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 智能获取封面(从最终成片抽帧;必须等 render 完成) */
|
||||
const handleSmartCover = async () => {
|
||||
if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return
|
||||
setSmartCoverLoading(true)
|
||||
try {
|
||||
const res = await onGenerateRenderSmartCover(renderJob.id)
|
||||
if (res.cover_url) {
|
||||
_applyCoverUrl(res.cover_url, "auto_frame")
|
||||
} else {
|
||||
// 失败由父组件 message 提示,这里不重复弹窗
|
||||
console.warn("[智能封面] 返回空 cover_url:", res.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[智能封面] 调用失败:", err)
|
||||
} finally {
|
||||
setSmartCoverLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating
|
||||
// 渲染已完成 → 封面区可用
|
||||
const isRenderCompleted = renderJob?.status === "completed"
|
||||
const canSmartCover = isRenderCompleted && !smartCoverLoading
|
||||
|
||||
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
|
||||
const coverUrl =
|
||||
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
|
||||
const hasCoverImage = Boolean(coverUrl)
|
||||
|
||||
/** 封面区占位文字 */
|
||||
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
|
||||
|
||||
/** 配置摘要中的封面状态标签 */
|
||||
const coverSummaryNode = (() => {
|
||||
@@ -154,69 +62,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
return <span className="aa-config-summary__empty">生成视频后可选</span>
|
||||
})()
|
||||
|
||||
// ── 封面选择区(两种 variant 共用) ─────────────────────────────────
|
||||
const coverSection = (
|
||||
<div className="aa-cover-section" style={{ marginTop: variant === "select-cover" ? 0 : 16 }}>
|
||||
<div className="aa-label" style={{ marginBottom: 8 }}>
|
||||
{variant === "select-cover" ? "选择封面" : "封面"}
|
||||
</div>
|
||||
{/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */}
|
||||
<div className="aa-cover-preview" style={{ opacity: isRenderCompleted ? 1 : 0.5 }}>
|
||||
{hasCoverImage ? (
|
||||
<img src={coverUrl!} alt="封面预览" draggable={false} />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
|
||||
)}
|
||||
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
|
||||
onClick={handleSmartCover}
|
||||
disabled={!canSmartCover}
|
||||
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
|
||||
>
|
||||
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
disabled={!isRenderCompleted || smartCoverLoading}
|
||||
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
|
||||
>
|
||||
📷 自定义上传
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ──
|
||||
if (variant === "select-cover") {
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{coverSection}
|
||||
{onClose && (
|
||||
<div style={{ marginTop: 16, display: "flex", justifyContent: "flex-end" }}>
|
||||
<button type="button" className="aa-btn aa-btn--primary" onClick={onClose}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ──
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{/* 分辨率选择 */}
|
||||
|
||||
@@ -1,83 +1,115 @@
|
||||
/**
|
||||
* Step 5 选择封面(Issue #1677 批量生成改造)
|
||||
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
|
||||
* Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复)
|
||||
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传)
|
||||
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
|
||||
*
|
||||
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
|
||||
*/
|
||||
import React, { useRef } from "react"
|
||||
import React, { useEffect } from "react"
|
||||
import { Modal, Spin } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import { useBatchCovers } from "../hooks/useBatchCovers"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
||||
import CoverEditorModal from "./cover-settings/CoverEditorModal"
|
||||
import { useSharedCover } from "@/components/cover/useSharedCover"
|
||||
import { generateCover as apiGenerateCover } from "@/api/generation"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于封面叠加标题 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 确认生成步骤产出的最终视频列表 */
|
||||
generatedVideos: GeneratedVideo[]
|
||||
/* ── 批量生成(#1677)── */
|
||||
previewCount?: number
|
||||
/** 每个变体的标题文字 */
|
||||
previewTitles?: string[]
|
||||
/** 每个变体的封面URL(按变体索引) */
|
||||
previewCovers?: string[]
|
||||
onPreviewCoversChange?: (urls: string[]) => void
|
||||
/** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */
|
||||
selectedVariantIndexes?: number[]
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
finalVideo,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
selectedTemplateId,
|
||||
editingTemplate,
|
||||
coverTemplates,
|
||||
templatesLoading,
|
||||
templatesError,
|
||||
handleSelectTemplate,
|
||||
handleEditTemplate,
|
||||
handleSaveTemplate,
|
||||
handleDeleteTemplate,
|
||||
} = useStep6Cover({
|
||||
coverSettings: props.coverSettings,
|
||||
onCoverSettingsChange: props.onCoverSettingsChange,
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
titleSettings: props.titleSettings,
|
||||
generatedVideos: props.generatedVideos,
|
||||
})
|
||||
|
||||
const previewCount = props.previewCount || 1
|
||||
const isBatch = previewCount > 1
|
||||
const previewTitles = props.previewTitles || []
|
||||
const previewCovers = props.previewCovers || []
|
||||
/** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */
|
||||
const cardIndexes =
|
||||
isBatch && props.selectedVariantIndexes?.length
|
||||
? props.selectedVariantIndexes
|
||||
: Array.from({ length: previewCount }, (_, i) => i)
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const uploadTargetRef = useRef<number>(0)
|
||||
|
||||
const completedVideos = props.generatedVideos.filter(
|
||||
(v) =>
|
||||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
)
|
||||
/** 最终成片:取第一个已完成视频(单视频场景) */
|
||||
const finalVideo =
|
||||
props.generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") ||
|
||||
props.generatedVideos[0]
|
||||
|
||||
const shared = useSharedCover({
|
||||
canGenerate: !!finalVideo,
|
||||
disabledHint: "请先生成视频再选择封面",
|
||||
initialTemplateId: "default",
|
||||
generateFn: async (tplId) => {
|
||||
if (!finalVideo) return null
|
||||
const response = await apiGenerateCover(tplId, {
|
||||
generated_video_id: finalVideo.id,
|
||||
video_url: finalVideo.file_url || finalVideo.download_url || "",
|
||||
cover_type: "ai_frame",
|
||||
...(props.titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: props.titleSettings.title,
|
||||
font: props.titleSettings.font,
|
||||
font_size: props.titleSettings.size,
|
||||
font_color: props.titleSettings.color,
|
||||
position: props.titleSettings.position,
|
||||
bold: props.titleSettings.bold,
|
||||
stroke: props.titleSettings.stroke,
|
||||
shadow: props.titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
const url = response.cover?.image_url || ""
|
||||
if (url) {
|
||||
props.onCoverSettingsChange({
|
||||
...props.coverSettings,
|
||||
thumbnail_url: url,
|
||||
ai_suggested_time: response.cover?.frame_time ?? null,
|
||||
})
|
||||
}
|
||||
return url
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
shared.setOnUploadFile(() => null)
|
||||
}, [shared])
|
||||
|
||||
const batchUploadRef = React.useRef<HTMLInputElement>(null)
|
||||
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
|
||||
const handleBatchUploadClick = (cardPos: number) => {
|
||||
setBatchUploadCard(cardPos)
|
||||
batchUploadRef.current?.click()
|
||||
}
|
||||
const handleBatchUploadChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
const cardPos = batchUploadCard
|
||||
setBatchUploadCard(null)
|
||||
if (!file || cardPos == null) return
|
||||
const url = URL.createObjectURL(file)
|
||||
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
|
||||
const nextCardView = [...prevCardView]
|
||||
nextCardView[cardPos] = url
|
||||
const next = [...(props.previewCovers || [])]
|
||||
cardIndexes.forEach((vi, i) => {
|
||||
next[vi] = nextCardView[i] || ""
|
||||
})
|
||||
props.onPreviewCoversChange?.(next)
|
||||
}
|
||||
|
||||
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
|
||||
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
|
||||
const batchCovers = useBatchCovers({
|
||||
@@ -95,7 +127,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
},
|
||||
covers: batchCoversList,
|
||||
onCoversChange: (updater) => {
|
||||
// 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖)
|
||||
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
|
||||
const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater
|
||||
const next = [...(props.previewCovers || [])]
|
||||
@@ -106,22 +137,11 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
},
|
||||
})
|
||||
|
||||
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
|
||||
const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url
|
||||
|
||||
const handleUploadClick = (variantIndex: number) => {
|
||||
uploadTargetRef.current = variantIndex
|
||||
uploadInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (file) {
|
||||
const variantIndex = uploadTargetRef.current
|
||||
const cardPos = cardIndexes.indexOf(variantIndex)
|
||||
if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file)
|
||||
}
|
||||
}
|
||||
const completedVideos = props.generatedVideos.filter(
|
||||
(v) => v.status === "completed" || v.status === "awaiting_cover",
|
||||
)
|
||||
|
||||
/* ── 批量封面 ── */
|
||||
if (isBatch) {
|
||||
@@ -212,7 +232,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
|
||||
onClick={() => handleUploadClick(variantIndex)}
|
||||
onClick={() => handleBatchUploadClick(cardPos)}
|
||||
disabled={isLoading || isUploading}
|
||||
>
|
||||
📤 上传
|
||||
@@ -222,24 +242,15 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 单视频:原有流程保持不变 ── */
|
||||
/* ── 单视频 ── */
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 最终成片信息 */}
|
||||
{finalVideo && (
|
||||
<div
|
||||
style={{
|
||||
@@ -253,16 +264,55 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
🎬 封面将从最终成片「{finalVideo.name}」中智能选帧
|
||||
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
|
||||
<>
|
||||
{" "}
|
||||
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-cover-actions">
|
||||
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => void shared.generateAutoCover()}
|
||||
disabled={!finalVideo || shared.generating}
|
||||
loading={shared.generating}
|
||||
>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
|
||||
⚙️ 封面设置
|
||||
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
|
||||
⚙️ 封面模板
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={shared.handleUploadClick}>
|
||||
📷 本地上传
|
||||
</Button>
|
||||
<input
|
||||
ref={shared.uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (!file) return
|
||||
const url = URL.createObjectURL(file)
|
||||
props.onCoverSettingsChange({
|
||||
...props.coverSettings,
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
mode: "upload",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={batchUploadRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleBatchUploadChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
@@ -279,30 +329,26 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<CoverSettingsModal
|
||||
open={showCoverSettings}
|
||||
onClose={() => setShowCoverSettings(false)}
|
||||
templates={coverTemplates}
|
||||
loading={templatesLoading}
|
||||
error={templatesError}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
onEditTemplate={handleEditTemplate}
|
||||
onDeleteTemplate={handleDeleteTemplate}
|
||||
onCreateNew={() => {
|
||||
setShowCoverSettings(false)
|
||||
setShowCoverEditor(true)
|
||||
}}
|
||||
open={shared.showCoverSettings}
|
||||
onClose={() => shared.setShowCoverSettings(false)}
|
||||
templates={shared.templates}
|
||||
loading={shared.templatesLoading}
|
||||
error={shared.templatesError}
|
||||
selectedTemplateId={shared.selectedTemplateId}
|
||||
onSelectTemplate={shared.handleSelectTemplate}
|
||||
onEditTemplate={shared.handleEditTemplate}
|
||||
onDeleteTemplate={shared.handleDeleteTemplate}
|
||||
onCreateNew={shared.handleCreateTemplate}
|
||||
/>
|
||||
|
||||
<CoverEditorModal
|
||||
open={showCoverEditor}
|
||||
onClose={() => setShowCoverEditor(false)}
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
open={shared.showCoverEditor}
|
||||
onClose={() => shared.setShowCoverEditor(false)}
|
||||
template={shared.editingTemplate}
|
||||
onSave={shared.handleSaveTemplate}
|
||||
/>
|
||||
|
||||
{/* AI 生成封面进度弹窗 */}
|
||||
<Modal open={generating} closable={false} footer={null} centered>
|
||||
<Modal open={shared.generating} closable={false} footer={null} centered>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { DEFAULT_EDITOR_CONFIG, PRESET_FONTS, SYSTEM_FONTS, ALL_FONTS } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
import "@/components/cover/cover.css"
|
||||
|
||||
/* ── Props ── */
|
||||
interface CoverEditorModalProps {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react"
|
||||
import type { CoverTemplate } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
import "@/components/cover/cover.css"
|
||||
|
||||
interface CoverSettingsModalProps {
|
||||
open: boolean
|
||||
|
||||
Reference in New Issue
Block a user