提交 8ce62951 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增数字人管理

上级 6521fc27
/* 文本创作中心通用样式 */
/* ========== 页面布局 ========== */
.text-creation-page {
padding: 0;
margin-top: 0;
border: none;
border-top: none !important;
border-bottom: none !important;
box-shadow: none !important;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
padding: 0;
border: none !important;
border-top: none !important;
border-bottom: none !important;
box-shadow: none !important;
background-image: none !important;
}
.page-header::after,
.page-header::before,
.page-nav::after,
.page-nav::before,
.page-nav h2::after,
.page-nav h2::before,
.text-creation-page::after,
.text-creation-page::before {
display: none !important;
content: none !important;
border: none !important;
border-top: none !important;
border-bottom: none !important;
background: none !important;
background-image: none !important;
height: 0 !important;
width: 0 !important;
opacity: 0 !important;
visibility: hidden !important;
}
.page-nav {
border: none !important;
border-top: none !important;
border-bottom: none !important;
background-image: none !important;
}
.page-nav h2 {
font-size: 22px;
color: #333;
margin: 0;
border: none !important;
border-top: none !important;
border-bottom: none !important;
padding-bottom: 0;
position: relative;
background-image: none !important;
text-decoration: none !important;
}
.page-actions {
display: flex;
gap: 8px;
}
/* ========== 主容器和布局 ========== */
.main-container {
display: flex;
gap: 15px;
margin-top: 5px;
}
.input-section {
width: 45%;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
padding: 12px;
}
.right-column {
width: 55%;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ========== 通用区块 ========== */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background-color: #fff;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.section-title {
display: flex;
align-items: center;
color: var(--primary-color, #ba003f);
font-size: 16px;
margin: 0 0 10px 0;
font-weight: 600;
}
.section-title i {
margin-right: 8px;
font-size: 20px;
color: var(--primary-color, #ba003f);
}
/* ========== 表单元素样式 ========== */
.form-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.form-group {
flex: 1;
margin-bottom: 12px;
border-bottom: none !important;
position: relative;
transition: all 0.3s ease;
}
/* 修改这条规则,它会导致表单标签文字在悬停时变色 */
/* .form-group:hover label {
color: var(--primary-color, #ba003f);
} */
/* 添加修改后的规则 */
.form-group:hover label.form-control-label {
color: var(--primary-color, #ba003f);
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #444;
font-size: 14px;
transition: color 0.3s ease;
}
.required:after {
content: ' *';
color: var(--primary-color, #ba003f);
}
.form-control {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-control:focus {
border-color: var(--primary-color, #ba003f);
outline: none;
box-shadow: 0 0 0 3px rgba(186, 0, 63, 0.1);
}
textarea.form-control {
min-height: 100px;
resize: vertical;
line-height: 1.5;
background-color: #fafafa;
}
textarea.form-control:focus {
background-color: #fff;
}
/* ========== 下拉菜单样式 ========== */
select.form-control {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23666' viewBox='0 0 16 16'><path d='M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/></svg>");
background-repeat: no-repeat;
background-position: calc(100% - 12px) center;
background-size: 12px;
padding-right: 32px;
cursor: pointer;
transition: all 0.3s;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
select.form-control:hover {
border-color: #bbb;
background-color: #f9f9f9;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.08);
}
select.form-control:focus {
border-color: var(--primary-color, #ba003f);
box-shadow: 0 0 0 3px rgba(186, 0, 63, 0.1);
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ba003f' viewBox='0 0 16 16'><path d='M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/></svg>");
}
select.form-control option {
font-weight: normal;
background-color: white;
color: #333;
padding: 8px;
}
/* ========== 复选框样式 ========== */
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 10px;
}
.checkbox-item {
position: relative;
background: #fff;
border-radius: 30px;
border: 1px solid #eee;
transition: all 0.2s ease;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
}
.checkbox-item:hover {
border-color: #ddd;
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.checkbox-active {
background-color: var(--primary-color, #ba003f);
border-color: var(--primary-color, #ba003f);
}
.checkbox-active .checkbox-label {
color: #fff;
}
.checkbox-label {
display: block;
padding: 4px 18px;
margin: 0;
font-weight: normal;
cursor: pointer;
color: #444;
font-size: 14px;
transition: color 0.2s;
text-align: center;
line-height: 1.5;
}
/* 移除这两条规则,以防止它们被激活或干扰其他规则 */
/* .checkbox-item:hover .checkbox-label {
color: #444;
} */
/* .checkbox-active:hover .checkbox-label {
color: #fff;
} */
/* 添加明确的规则来阻止任何颜色变化 */
.checkbox-item:hover:not(.checkbox-active) .checkbox-label {
color: #444 !important; /* 使用!important确保优先级 */
}
.checkbox-active:hover .checkbox-label {
color: #ffffff !important; /* 使用!important确保优先级 */
}
.checkbox-item input[type='checkbox'] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
/* ========== 单选框样式 ========== */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.radio-item {
position: relative;
background-color: #fcf2f5;
border-radius: 20px;
border: 1px solid #f9e0e7;
transition: all 0.3s ease;
overflow: hidden;
display: flex;
align-items: center;
cursor: pointer;
box-shadow: 0 1px 3px rgba(186, 0, 63, 0.05);
}
.radio-item:hover {
background-color: #f9e0e7;
transform: translateY(-2px);
box-shadow: 0 3px 6px rgba(186, 0, 63, 0.1);
}
.radio-active {
background-color: var(--primary-color, #ba003f);
border-color: var(--primary-color, #ba003f);
}
.radio-active .radio-label {
color: #fff;
}
.radio-label {
display: block;
padding: 8px 14px;
margin: 0;
font-weight: 500;
cursor: pointer;
color: #555;
font-size: 14px;
transition: color 0.3s;
position: relative;
line-height: 1.4;
text-align: center;
}
.radio-item input[type='radio'] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
/* ========== 按钮样式 ========== */
.action-buttons {
display: flex;
gap: 8px;
margin-top: 15px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 54px;
padding: 0 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn i {
font-size: 16px;
}
/* 紧凑型按钮样式 */
.btn-compact {
height: 27px;
padding: 0 24px;
font-size: 12px;
}
.btn-compact i {
font-size: 14px;
}
/* 紧凑型小按钮样式 */
.btn-compact-sm {
height: 24px;
padding: 0 12px;
font-size: 12px;
min-width: 60px;
}
.btn-compact-sm i {
font-size: 13px;
}
.btn-primary {
background-color: var(--primary-color, #ba003f);
color: white;
flex: 1;
}
.btn-primary:hover {
background-color: #980034;
box-shadow: 0 4px 12px rgba(186, 0, 63, 0.2);
transform: translateY(-2px);
}
.btn-primary:active {
transform: scale(0.96);
}
.btn-primary:disabled {
background-color: #ddd;
cursor: not-allowed;
}
.btn-secondary {
background-color: #f5f5f5;
color: #333;
border: 1px solid #eee;
}
.btn-secondary:hover {
background-color: #e5e5e5;
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.15);
transform: translateY(-2px);
}
.btn-secondary:active {
transform: scale(0.96);
}
/* ========== 功能按钮样式 ========== */
.learn-button {
background-color: var(--primary-color, #ba003f);
color: white;
border: none;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
height: auto;
}
.learn-button:hover {
background-color: #980034;
}
.learn-button i {
font-size: 16px;
margin-right: 0;
}
.action-button {
background: none;
border: 1px solid #eee;
color: #666;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s ease;
}
.action-button:hover {
color: var(--primary-color, #ba003f);
background-color: #f8f8f8;
border-color: #ddd;
transform: translateY(-1px);
}
.action-button i {
font-size: 16px;
}
.primary-button {
background-color: var(--primary-color, #ba003f);
color: white;
border: none;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
}
.primary-button:hover {
background-color: #980034;
}
.secondary-button {
background-color: #f5f5f5;
color: #333;
border: 1px solid #eee;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
}
.secondary-button:hover {
background-color: #e5e5e5;
}
/* 禁用状态 */
.primary-button:disabled,
.secondary-button:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: #ccc;
color: #666;
border-color: #ccc;
}
.primary-button:disabled:hover {
background-color: #ccc;
transform: none;
box-shadow: none;
}
.secondary-button:disabled:hover {
background-color: #f5f5f5;
transform: none;
}
/* ========== 内容展示区域 ========== */
.result-section {
flex: 1;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 0;
position: relative;
overflow: hidden;
}
.result-content-wrapper {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 300px;
}
/* ========== 数据表格样式 ========== */
.data-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.data-table th,
.data-table td {
padding: 12px 16px;
text-align: center;
border-bottom: 1px solid #f0f0f0;
}
.data-table th {
background-color: #f9f9f9;
color: #333;
font-weight: 500;
white-space: nowrap;
}
.data-table tr:hover {
background-color: #f9f9f9;
}
.data-table td .tag {
text-align: center;
}
.operations {
white-space: nowrap;
min-width: 200px;
}
.operation-buttons {
display: flex;
justify-content: flex-start;
gap: 8px;
}
.table-button {
margin-right: 8px;
padding: 4px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: #fff;
background-color: #ba003f;
}
.table-button.edit {
background-color: #409eff;
}
.table-button.view {
background-color: #67c23a;
}
.table-button.delete {
background-color: #f56c6c;
}
.tag {
display: inline-block;
padding: 2px 6px;
margin-right: 6px;
background-color: #f0f2f5;
color: #606266;
font-size: 12px;
border-radius: 4px;
}
.empty-data {
text-align: center;
padding: 30px;
color: #909399;
}
.section-content {
margin-bottom: 20px;
}
/* ========== 分页样式 ========== */
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.pagination {
display: flex;
align-items: center;
}
.pagination-button {
width: 32px;
height: 32px;
border: 1px solid #dcdfe6;
background: #fff;
color: #606266;
border-radius: 4px;
cursor: pointer;
margin: 0 5px;
display: flex;
align-items: center;
justify-content: center;
}
.pagination-button:disabled {
cursor: not-allowed;
color: #c0c4cc;
}
.page-info {
margin: 0 10px;
font-size: 14px;
color: #606266;
}
.page-size-selector {
margin-left: 15px;
display: flex;
align-items: center;
font-size: 14px;
color: #606266;
}
.page-size-selector select {
margin: 0 5px;
padding: 2px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
}
/* ========== 表单宽度变体 ========== */
.form-group-wide {
flex: 2;
}
.form-group-medium {
flex: 1.5;
}
/* ========== 按钮类型变体 ========== */
.btn-danger {
background-color: #f56c6c;
color: white;
border: none;
}
.btn-danger:hover {
background-color: #f78989;
transform: translateY(-1px);
}
/* ========== 空状态样式 ========== */
.empty-result {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 350px;
background-color: #fff;
border-radius: 8px;
color: #666;
text-align: center;
}
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 30px;
}
.empty-image {
width: 120px;
height: 120px;
margin-bottom: 20px;
}
.empty-message {
margin: 0 0 20px;
font-size: 16px;
color: #666;
}
/* ========== 加载状态样式 ========== */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.8);
z-index: 5;
border-radius: 0 0 8px 8px;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid rgba(186, 0, 63, 0.1);
border-radius: 50%;
border-top-color: var(--primary-color, #ba003f);
animation: spin 1s ease-in-out infinite;
margin: 0 auto 20px;
}
.loading-text {
font-size: 16px;
color: var(--primary-color, #ba003f);
font-weight: 500;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.spinning {
animation: spin 1.5s linear infinite;
display: inline-block;
}
.blur-content {
filter: blur(1px);
opacity: 0.6;
pointer-events: none;
}
/* ========== 模态框样式 ========== */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: #fff;
border-radius: 8px;
width: 80%;
max-width: 800px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
animation: modal-pop 0.3s ease-out;
}
@keyframes modal-pop {
0% {
transform: scale(0.9);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 8px;
}
.modal-header h3 i {
color: var(--primary-color, #ba003f);
}
.close-btn {
background: none;
border: none;
color: #666;
font-size: 20px;
cursor: pointer;
}
.modal-body {
padding: 20px;
overflow-y: auto;
max-height: calc(80vh - 60px);
}
/* ========== 知识学习侧边栏 ========== */
.knowledge-drawer :deep(.el-drawer__header) {
margin-bottom: 0;
padding: 15px 20px;
border-bottom: 1px solid #eaeaea;
color: #ba003f;
font-weight: bold;
}
.knowledge-drawer :deep(.el-drawer__title) {
font-size: 18px;
color: #ba003f;
}
.knowledge-content {
padding: 20px;
height: 100%;
overflow-y: auto;
}
.knowledge-section {
margin-bottom: 25px;
}
.knowledge-subtitle {
color: #ba003f;
margin-top: 0;
margin-bottom: 12px;
font-size: 18px;
font-weight: 600;
border-bottom: 1px solid rgba(186, 0, 63, 0.2);
padding-bottom: 8px;
display: flex;
align-items: center;
}
.knowledge-text {
font-size: 15px;
line-height: 1.6;
color: #333;
}
.knowledge-text strong {
color: #ba003f;
font-weight: 600;
}
.knowledge-text ul {
padding-left: 20px;
margin: 10px 0;
}
.knowledge-text li {
margin-bottom: 8px;
line-height: 1.5;
}
.knowledge-icon {
display: inline-block;
margin-right: 8px;
width: 28px;
height: 28px;
line-height: 28px;
text-align: center;
border-radius: 50%;
background-color: rgba(186, 0, 63, 0.1);
color: #ba003f;
font-size: 16px;
vertical-align: middle;
}
/* ========== 响应式布局 ========== */
@media (max-width: 992px) {
.main-container {
flex-direction: column;
}
.input-section,
.right-column {
width: 100%;
}
}
@media (max-width: 768px) {
.knowledge-drawer :deep(.el-drawer) {
width: 90% !important;
}
.form-row {
flex-direction: column;
}
}
/* ========== 参考案例样式 ========== */
.examples-section {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
padding: 15px;
margin-bottom: 0; /* 移除底部边距 */
}
.examples-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.example-carousel {
position: relative;
overflow: hidden;
width: 100%;
padding-bottom: 5px; /* 添加底部间距避免阴影被裁剪 */
}
.example-cards {
display: flex;
gap: 20px; /* 增加卡片间距 */
transition: transform 0.3s ease;
will-change: transform;
padding: 5px 0;
width: max-content; /* 确保足够宽以容纳所有内容 */
}
.example-card {
flex: 0 0 220px; /* 增加卡片宽度 */
height: auto; /* 改为自适应高度 */
min-height: 120px; /* 设置最小高度 */
margin-right: 10px;
cursor: pointer;
position: relative;
border-radius: 12px;
background: white;
transition: all 0.3s;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid #eee;
}
.example-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.12);
border-color: var(--primary-color, #ba003f);
}
/* 卡片头部区域 - 顶部固定部分 */
.example-card-header {
position: relative;
background: linear-gradient(to right, rgba(186, 0, 63, 0.02), rgba(186, 0, 63, 0.08));
padding: 16px;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: flex-start; /* 改为顶部对齐,适合多行标题 */
gap: 15px;
min-height: 85px;
}
/* 图标样式 */
.example-icon {
width: 55px;
height: 55px;
min-width: 55px;
background-color: rgba(186, 0, 63, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
box-shadow: 0 3px 6px rgba(186, 0, 63, 0.1);
}
.example-icon i,
.example-icon svg {
font-size: 26px;
color: var(--primary-color, #ba003f);
display: inline-block; /* 确保图标可见 */
line-height: 1; /* 修正行高,防止图标被裁剪 */
}
/* 标题区域 */
.example-info {
display: flex;
flex-direction: column;
overflow: hidden;
}
.example-title {
font-weight: 600;
font-size: 16px;
color: #222;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
transition: all 0.3s;
flex: 1;
min-height: 42px; /* 确保有足够的高度容纳两行文字 */
}
.example-desc {
font-size: 14px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
white-space: normal;
line-height: 1.5;
}
/* 卡片内容区域 - 底部描述部分,无内容时隐藏 */
.example-content {
padding: 16px;
display: flex;
flex-direction: column;
background-color: white;
flex: 1;
min-height: 60px;
transition: all 0.3s ease;
}
.example-content:empty {
display: none;
}
.example-detail {
font-size: 14px;
line-height: 1.6;
color: #444;
display: -webkit-box;
-webkit-line-clamp: 3; /* 默认显示3行 */
-webkit-box-orient: vertical;
overflow: hidden;
position: relative;
margin-bottom: 5px; /* 添加底部边距 */
transition: all 0.3s ease;
}
/* 移除渐变遮罩,在文本末尾添加省略号 */
.example-detail::after {
display: none; /* 移除渐变遮罩 */
}
/* 卡片悬停效果优化 */
.example-card:hover .example-detail {
-webkit-line-clamp: 8; /* 悬停时显示更多行 */
color: #333; /* 文字颜色加深 */
}
/* 添加更好的内容展开交互 */
.example-card:hover .example-content {
background-color: #fafafa; /* 轻微背景色变化 */
}
/* 当描述为空时隐藏卡片内容部分 */
.example-detail:empty {
display: none;
}
/* 标签区域 */
.example-tags {
padding: 0 16px 16px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.example-tag {
padding: 4px 10px;
background-color: rgba(186, 0, 63, 0.08);
color: var(--primary-color, #ba003f);
border-radius: 20px;
font-size: 12px;
white-space: nowrap;
}
/* 标签为空时隐藏 */
.example-tags:empty {
display: none;
padding: 0;
}
/* 鼠标悬停时的动画效果 */
.example-card:hover .example-title {
color: var(--primary-color, #ba003f);
}
.example-card:hover .example-detail::after {
opacity: 0.5;
}
/* 轮播控制样式 */
.example-carousel {
position: relative;
overflow: hidden;
width: 100%;
padding-bottom: 15px; /* 增加底部边距以适应更大的卡片阴影 */
}
.example-cards {
display: flex;
gap: 25px; /* 增加间距 */
transition: transform 0.4s ease;
will-change: transform;
padding: 10px 0;
width: max-content;
}
.carousel-controls {
display: flex;
gap: 8px;
}
.carousel-control {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: white;
border: 1px solid #eee;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
}
.carousel-control i {
font-size: 20px;
color: #666;
display: inline-block; /* 确保图标可见 */
line-height: 1; /* 修正行高 */
}
.carousel-control:hover {
background-color: rgba(186, 0, 63, 0.05);
border-color: var(--primary-color, #ba003f);
}
.carousel-control:hover i {
color: var(--primary-color, #ba003f);
}
.carousel-control.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 大尺寸示例卡片 */
.example-card-large {
display: flex;
flex-direction: column;
height: 400px; /* 比标准卡片高度高一倍 */
min-width: 300px; /* 更宽的最小宽度 */
max-width: 400px;
background-color: white;
border-radius: 8px;
border: 1px solid #eaeaea;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
overflow: hidden;
cursor: pointer;
}
.example-card-large:hover {
border-color: var(--primary-color, #ba003f);
box-shadow: 0 4px 12px rgba(186, 0, 63, 0.15);
height: auto; /* 悬停时可以自动调整高度 */
}
.example-card-large .example-icon {
width: 60px;
height: 60px;
border-radius: 30px;
background-color: rgba(186, 0, 63, 0.1);
display: flex;
align-items: center;
justify-content: center;
padding: 15px;
}
.example-card-large .example-icon i,
.example-card-large .example-icon svg {
font-size: 32px;
}
.example-card-large .example-title {
font-size: 18px;
margin-bottom: 10px;
-webkit-line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
}
.example-card-large .example-desc {
font-size: 15px;
white-space: normal;
-webkit-line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
}
.example-card-large .example-content {
padding: 20px;
max-height: 250px; /* 增加内容区域的高度 */
}
.example-card-large .example-detail {
-webkit-line-clamp: 8; /* 显示更多行 */
font-size: 15px;
}
.example-card-large .example-tags {
padding: 0 20px 20px;
}
.example-card-large .example-tag {
padding: 5px 12px;
font-size: 13px;
}
/* 大尺寸卡片的轮播样式调整 */
.example-cards-large {
display: flex;
gap: 30px;
transition: transform 0.4s ease;
will-change: transform;
padding: 15px 0;
width: max-content;
}
/* ========== AI应用案例编辑特有样式 ========== */
/* 标签预览区域 */
.tags-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
margin-bottom: 20px;
}
/* 编辑表单容器 */
.edit-form-container {
background-color: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
/* 错误信息提示 */
.error-message {
color: #f56c6c;
font-size: 12px;
margin-top: 5px;
}
/* 按钮与输入框的容器样式 */
.form-group > div {
display: flex;
align-items: center;
gap: 10px;
}
/* 案例封面上传样式 */
.cover-upload-container {
margin-top: 10px;
width: 100%;
}
.cover-preview {
position: relative;
width: 320px;
height: 180px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-remove-btn {
position: absolute;
top: 10px;
right: 10px;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.6);
border: none;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
}
.cover-remove-btn:hover {
background-color: rgba(0, 0, 0, 0.8);
}
.cover-upload {
width: 320px;
height: 180px;
border: 2px dashed #dcdfe6;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cover-upload-button {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: #909399;
cursor: pointer;
}
.cover-upload-button i {
font-size: 32px;
}
/* 文件上传样式 */
.file-upload-container {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.file-input {
display: none;
}
.file-upload-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 8px 16px;
background-color: #f5f5f5;
color: #333;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.file-upload-button:hover {
background-color: #e8e8e8;
border-color: #ccc;
}
.file-name {
margin-left: 12px;
color: #606266;
font-size: 14px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.uploaded-files {
margin-top: 15px;
}
.uploaded-file {
display: flex;
align-items: center;
background-color: #f9f9f9;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 8px;
}
.file-icon {
margin-right: 10px;
color: #409eff;
font-size: 18px;
}
.file-info {
flex: 1;
font-size: 14px;
color: #606266;
}
/* 红色危险按钮样式 */
.btn-danger {
background-color: #f56c6c;
color: white;
border: none;
}
.btn-danger:hover {
background-color: #f78989;
}
/* 绿色成功按钮样式 */
.btn-success {
background-color: #67c23a;
color: white;
border: none;
}
.btn-success:hover {
background-color: #73d13d;
}
/* AI设计案例提示词工程样式 */
.prompt-section {
margin-bottom: 20px;
}
.prompt-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.radio-group {
display: flex;
gap: 10px;
}
.radio-label {
display: flex;
align-items: center;
gap: 5px;
}
/* 紫荆红色单选框样式 */
.radio-label input[type='radio'] {
accent-color: #c10055;
width: 16px;
height: 16px;
}
.radio-label input[type='radio']:checked + span {
color: #c10055;
font-weight: 500;
}
.prompt-content {
margin-bottom: 10px;
}
.prompt-content.file-content {
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
}
.file-placeholder {
color: #909399;
font-size: 12px;
}
.prompt-actions {
margin-top: 10px;
text-align: center;
}
.generated-prompt {
margin-top: 20px;
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.prompt-controls {
display: flex;
gap: 10px;
}
.prompt-editor {
margin-top: 10px;
}
.prompt-editor textarea {
height: auto !important;
min-height: 300px;
}
/* 一键创建案例按钮样式 */
.create-case-action {
margin-top: 20px;
text-align: center;
}
.create-case-action .btn-lg {
padding: 12px 30px;
font-size: 16px;
transition: all 0.3s ease;
}
.create-case-action .btn-lg:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.create-case-action .btn-lg:active {
transform: translateY(0);
}
/* 案例创建进度样式 */
.case-creation-progress {
margin-top: 20px;
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.progress-header {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.progress-header h4 {
margin-right: 15px;
min-width: 120px;
}
.progress-bar-container {
flex: 1;
height: 10px;
background-color: #f0f0f0;
border-radius: 5px;
overflow: hidden;
margin-right: 10px;
}
.progress-bar {
height: 100%;
background-color: #c10055; /* 紫荆红色进度条 */
transition: width 0.3s ease;
}
.progress-percentage {
font-size: 14px;
font-weight: 500;
color: #c10055;
min-width: 45px;
text-align: right;
}
.creation-steps {
margin-top: 20px;
margin-bottom: 20px;
border-left: 2px solid #f0f0f0;
padding-left: 20px;
}
.creation-step {
position: relative;
padding: 15px 0;
transition: all 0.3s ease;
}
.creation-step:not(:last-child) {
border-bottom: 1px dashed #f0f0f0;
}
.step-indicator {
position: absolute;
left: -31px;
top: 15px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border: 2px solid #f0f0f0;
transition: all 0.3s ease;
}
.step-indicator i {
font-size: 12px;
}
.step-current .step-indicator {
border-color: #c10055;
background-color: #fff;
}
.step-current .step-indicator i.fa-spinner {
color: #c10055;
animation: rotate 1s infinite linear;
}
.step-completed .step-indicator {
border-color: #c10055;
background-color: #c10055;
}
.step-completed .step-indicator i {
color: #fff;
}
.step-content {
padding-left: 10px;
}
.step-name {
font-size: 16px;
font-weight: 500;
margin-bottom: 5px;
color: #303133;
transition: all 0.3s ease;
}
.step-current .step-name {
color: #c10055;
font-weight: 600;
}
.step-description {
font-size: 14px;
color: #606266;
margin-bottom: 10px;
}
.creation-complete-actions {
margin-top: 20px;
text-align: center;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.creation-complete-actions .action-btn {
margin: 0 10px;
padding: 12px 30px;
font-size: 16px;
transition: all 0.3s ease;
min-width: 180px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.creation-complete-actions .action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.creation-complete-actions .action-btn:active {
transform: translateY(0);
}
.creation-complete-actions .action-btn i {
margin-right: 8px;
font-size: 16px;
}
/* 图标样式补充 */
.icon-left {
margin-right: 5px;
}
.btn .icon-left {
margin-right: 8px;
}
/* 旋转动画 */
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 确保优化按钮高度匹配输入框 */
.form-group button.btn {
height: 40px !important;
box-sizing: border-box !important;
line-height: 38px !important;
padding: 0 15px !important;
vertical-align: middle !important;
font-size: 14px !important;
border-radius: 4px !important;
}
/* 统一表单控件高度 */
.form-control {
height: 40px !important;
box-sizing: border-box !important;
}
/* 宽按钮样式 */
.wide-btn {
width: 300px !important; /* 宽度约为普通按钮的两倍 */
margin: 0 auto !important;
display: block !important; /* 确保按钮是块级元素 */
font-size: 16px !important;
padding: 10px 30px !important;
}
.wide-btn:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 12px rgba(193, 0, 85, 0.2) !important;
}
/* 生成提示词按钮容器样式 */
.prompt-actions {
margin-top: 20px !important;
text-align: center !important;
display: flex !important;
justify-content: center !important;
}
/* 一键创建案例按钮样式 */
.create-case-action {
margin-top: 20px !important;
text-align: center !important;
display: flex !important;
justify-content: center !important;
}
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/components/layout/Index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/material/digital-human',
component: Layout,
children: [{ path: '', component: () => import('./views/BaiduDigitalHuman.vue') }],
},
]
export { routes }
<template>
<AppCard>
<div class="text-creation-page">
<!-- 分步骤视频合成流程 -->
<div class="main-container">
<!-- 左侧步骤条 -->
<div class="steps-container slim-steps" style="margin-left: 10px">
<!-- 步骤指示器 -->
<div class="creation-steps">
<!-- 步骤1 -->
<div
class="creation-step"
:class="{ 'step-completed': currentStep > 1, 'step-current': currentStep === 1 }">
<div class="step-indicator">
<i v-if="currentStep > 1" class="ri-check-line"></i>
<i v-else-if="currentStep === 1" class="ri-edit-line"></i>
<span v-else>1</span>
</div>
<div class="step-content">
<div class="step-name">输入文本</div>
<div class="step-description">添加内容</div>
</div>
</div>
<!-- 步骤2 -->
<div
class="creation-step"
:class="{ 'step-completed': currentStep > 2, 'step-current': currentStep === 2 }">
<div class="step-indicator">
<i v-if="currentStep > 2" class="ri-check-line"></i>
<i v-else-if="currentStep === 2" class="ri-user-voice-line"></i>
<span v-else>2</span>
</div>
<div class="step-content">
<div class="step-name">形象音色</div>
<div class="step-description">选择配置</div>
</div>
</div>
<!-- 步骤3 -->
<div
class="creation-step"
:class="{ 'step-completed': currentStep > 3, 'step-current': currentStep === 3 }">
<div class="step-indicator">
<i v-if="currentStep > 3" class="ri-check-line"></i>
<i v-else-if="currentStep === 3" class="ri-settings-3-line"></i>
<span v-else>3</span>
</div>
<div class="step-content">
<div class="step-name">视频参数</div>
<div class="step-description">调整设置</div>
</div>
</div>
<!-- 步骤4 -->
<div
class="creation-step"
:class="{ 'step-completed': currentStep > 4, 'step-current': currentStep === 4 }">
<div class="step-indicator">
<i v-if="currentStep > 4" class="ri-check-line"></i>
<i v-else-if="currentStep === 4" class="ri-upload-cloud-line"></i>
<span v-else>4</span>
</div>
<div class="step-content">
<div class="step-name">提交任务</div>
<div class="step-description">确认信息</div>
</div>
</div>
<!-- 步骤5 -->
<div
class="creation-step"
:class="{ 'step-completed': currentStep > 5, 'step-current': currentStep === 5 }">
<div class="step-indicator">
<i v-if="currentStep > 5" class="ri-check-line"></i>
<i v-else-if="currentStep === 5" class="ri-video-line"></i>
<span v-else>5</span>
</div>
<div class="step-content">
<div class="step-name">查看结果</div>
<div class="step-description">获取视频</div>
</div>
</div>
</div>
</div>
<!-- 右侧内容区 -->
<div class="step-content-container wide-content">
<!-- 步骤1:输入文本内容 -->
<div v-if="currentStep === 1" class="step-content-panel">
<div class="section-title">
<i class="ri-chat-1-line"></i>
<span>输入文本内容</span>
</div>
<div class="form-group">
<label for="textContent" class="form-control-label required">文本内容</label>
<div class="text-input-wrapper">
<textarea
id="textContent"
v-model="formData.text"
class="form-control"
placeholder="请输入要数字人播报的文本内容,例如:欢迎使用紫荆AI图文创作工具,我可以为您生成各种风格的数字人视频。"
rows="10"></textarea>
<div class="input-actions">
<button class="text-action-button" @click="useExampleText" title="使用示例文本">
<i class="ri-file-list-line"></i> 使用示例文本
</button>
</div>
</div>
</div>
<div class="action-buttons">
<button class="primary-button" @click="nextStep" :disabled="!formData.text">
<span>下一步</span>
<i class="ri-arrow-right-line"></i>
</button>
</div>
</div>
<!-- 步骤2:选择数字人和音色 -->
<div v-if="currentStep === 2" class="step-content-panel">
<div class="section-title">
<i class="ri-user-voice-line"></i>
<span>选择数字人和音色</span>
</div>
<div class="form-group">
<label class="form-control-label required">数字人选择</label>
<div class="digital-human-grid inline-grid">
<div
v-for="human in digitalHumans"
:key="human.id"
class="digital-human-card compact-card"
:class="{ selected: formData.figureId === human.id }"
@click="selectDigitalHuman(human.id)">
<div class="human-avatar">
<img :src="human.avatar" :alt="human.name" />
</div>
<div class="human-name">{{ human.name }}</div>
</div>
</div>
</div>
<div class="form-group">
<label class="form-control-label required"
>音色选择
<span class="voice-filter-info"
>({{ getSelectedHumanGender() === '女' ? '女' : '男' }}性音色)</span
></label
>
<div class="voice-filter">
<input
type="text"
v-model="voiceSearchText"
placeholder="搜索音色名称或风格"
class="form-control voice-search-input" />
</div>
<div class="voice-list multi-column-list">
<div
v-for="voice in filteredActiveVoices"
:key="voice.id"
class="voice-item compact-voice-item"
:class="{ selected: formData.ttsParams.person === voice.id }"
@click="selectVoice(voice.id)">
<div class="voice-icon">
<i class="ri-voice-recognition-line"></i>
</div>
<div class="voice-info">
<div class="voice-name">{{ voice.name }}</div>
<div class="voice-desc">{{ voice.gender }} · {{ voice.style }}</div>
</div>
<div class="voice-preview">
<button
class="preview-button"
@click.stop="previewVoice(voice.id)"
title="试听音色"
:disabled="!getVoicePreviewUrl(voice.id)">
<i class="ri-play-circle-line"></i>
</button>
</div>
</div>
<!-- 添加音频播放元素 -->
<audio ref="audioPlayer" v-if="previewAudio" :src="previewAudio" controls style="display: none"></audio>
</div>
<div v-if="filteredActiveVoices.length === 0" class="no-voices-found">
<div class="empty-content">
<div class="empty-image">
<i class="ri-error-warning-line"></i>
</div>
<div class="empty-message">没有找到匹配的音色</div>
</div>
</div>
</div>
<div class="action-buttons">
<button class="secondary-button" @click="prevStep">
<i class="ri-arrow-left-line"></i>
<span>上一步</span>
</button>
<button class="primary-button" @click="nextStep">
<span>下一步</span>
<i class="ri-arrow-right-line"></i>
</button>
</div>
</div>
<!-- 步骤3:设置视频参数 -->
<div v-if="currentStep === 3" class="step-content-panel">
<div class="section-title">
<i class="ri-settings-3-line"></i>
<span>设置视频参数</span>
</div>
<div class="form-group">
<label class="form-control-label">视频分辨率</label>
<div class="resolution-options">
<div
v-for="option in resolutionOptions"
:key="option.value"
class="resolution-option"
:class="{ active: isResolutionSelected(option.value) }"
@click="selectResolution(option.value)">
{{ option.label }}
</div>
</div>
</div>
<div class="form-group" style="display: none">
<label class="form-control-label">视频选项</label>
<div class="checkbox-group">
<div
class="checkbox-item"
:class="{ 'checkbox-active': formData.videoParams.transparent }"
@click="formData.videoParams.transparent = !formData.videoParams.transparent">
<input
type="checkbox"
id="transparent"
v-model="formData.videoParams.transparent"
style="display: none" />
<label class="checkbox-label" for="transparent">透明背景</label>
</div>
<div
class="checkbox-item"
:class="{ 'checkbox-active': formData.autoAnimoji }"
@click="formData.autoAnimoji = !formData.autoAnimoji">
<input type="checkbox" id="autoAnimoji" v-model="formData.autoAnimoji" style="display: none" />
<label class="checkbox-label" for="autoAnimoji">自动添加动作</label>
</div>
<div
class="checkbox-item"
:class="{ 'checkbox-active': formData.subtitleParams.enabled }"
@click="formData.subtitleParams.enabled = !formData.subtitleParams.enabled">
<input
type="checkbox"
id="subtitleEnabled"
v-model="formData.subtitleParams.enabled"
style="display: none" />
<label class="checkbox-label" for="subtitleEnabled">显示字幕</label>
</div>
</div>
</div>
<div class="action-buttons">
<button class="secondary-button" @click="prevStep">
<i class="ri-arrow-left-line"></i>
<span>上一步</span>
</button>
<button class="primary-button" @click="nextStep">
<span>下一步</span>
<i class="ri-arrow-right-line"></i>
</button>
</div>
</div>
<!-- 步骤4:提交任务 -->
<div v-if="currentStep === 4" class="step-content-panel">
<div class="section-title">
<i class="ri-upload-cloud-line"></i>
<span>提交任务</span>
</div>
<div class="task-summary-container">
<h3 class="summary-heading">
<i class="ri-file-list-3-line"></i>
任务信息确认
</h3>
<div class="task-summary">
<div class="summary-item">
<div class="summary-label">
<i class="ri-text"></i>
文本内容:
</div>
<div class="summary-value text-value">
{{ formData.text.length > 100 ? formData.text.substring(0, 100) + '...' : formData.text }}
</div>
</div>
<div class="summary-item">
<div class="summary-label">
<i class="ri-user-line"></i>
数字人形象:
</div>
<div class="summary-value">{{ getHumanName(formData.figureId) }}</div>
</div>
<div class="summary-item">
<div class="summary-label">
<i class="ri-volume-up-line"></i>
音色:
</div>
<div class="summary-value">{{ getVoiceName(formData.ttsParams.person) }}</div>
</div>
<div class="summary-item">
<div class="summary-label">
<i class="ri-aspect-ratio-line"></i>
视频分辨率:
</div>
<div class="summary-value">
{{ getResolutionLabel(formData.videoParams.width, formData.videoParams.height) }}
</div>
</div>
<div class="summary-item">
<div class="summary-label">
<i class="ri-settings-3-line"></i>
高级选项:
</div>
<div class="summary-value">
<span v-if="formData.videoParams.transparent" class="summary-tag">
<i class="ri-contrast-drop-line"></i>
透明背景
</span>
<span v-if="formData.autoAnimoji" class="summary-tag">
<i class="ri-emotion-line"></i>
自动添加动作
</span>
<span v-if="formData.subtitleParams.enabled" class="summary-tag">
<i class="ri-subtitle"></i>
显示字幕
</span>
</div>
</div>
</div>
</div>
<div class="action-buttons">
<button class="secondary-button" @click="prevStep">
<i class="ri-arrow-left-line"></i>
<span>上一步</span>
</button>
<button class="primary-button submit-button" @click="submitTask" :disabled="isSubmitting">
<i v-if="isSubmitting" class="ri-loader-4-line spinning"></i>
<span>{{ isSubmitting ? '提交中...' : '确认提交' }}</span>
</button>
</div>
</div>
<!-- 步骤5:查看结果 -->
<div v-if="currentStep === 5" class="step-content-panel">
<div class="section-title">
<i class="ri-video-line"></i>
<span>查看结果</span>
</div>
<div class="task-result-container">
<!-- 任务状态卡片 -->
<div class="task-status-card">
<div class="status-header">
<div class="task-id">
<span class="label">任务ID:</span>
<span class="value">{{ taskResult?.taskId || '未知' }}</span>
</div>
<div :class="['status-badge', getStatusClass(taskStatus?.status)]">
{{ getStatusText(taskStatus?.status) }}
</div>
</div>
<!-- 处理中状态显示 -->
<div
v-if="taskStatus?.status === 'PROCESSING' || taskStatus?.status === 'GENERATING'"
class="processing-section">
<div class="processing-indicator">
<i class="ri-loader-4-line animate-spin"></i>
<span>视频生成中,请稍候...</span>
</div>
<div class="processing-info">
<p>百度数字人视频生成通常需要10-60秒。生成完成后您将看到视频播放界面。</p>
</div>
</div>
<!-- 失败状态显示 -->
<div v-if="taskStatus?.status === 'FAILED'" class="failure-section">
<div class="failure-icon">
<i class="ri-error-warning-line"></i>
</div>
<div class="failure-message">
<h3>生成失败</h3>
<p>{{ taskStatus?.failedMessage || '未知错误' }}</p>
</div>
</div>
<!-- 成功状态显示 -->
<div v-if="taskStatus?.status === 'SUCCESS'" class="success-section">
<div class="video-container">
<!-- 视频播放器 -->
<div class="video-player-wrapper">
<video
ref="videoPlayer"
class="video-player"
:src="taskStatus.videoUrl"
controls
autoplay
controlsList="nodownload"
:poster="taskStatus.previewUrl"></video>
</div>
<div class="video-info">
<p>创建时间: {{ formatDate(taskStatus.createTime) }}</p>
</div>
<div class="video-controls">
<div class="video-actions">
<button class="action-button" @click="downloadVideo">
<i class="ri-download-cloud-line"></i>
<span>下载视频</span>
</button>
<button class="action-button" @click="copyVideoLink">
<i class="ri-link"></i>
<span>复制链接</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 更新按钮(当视频未生成完成时显示) -->
<div v-if="taskStatus?.status !== 'SUCCESS'" class="update-actions">
<div class="auto-update-info" v-if="autoQueryInterval">
<i class="ri-time-line"></i>
<span>系统正在自动刷新状态 (每5秒)</span>
</div>
<button class="update-button" @click="queryTask" :disabled="isQuerying">
<i class="ri-refresh-line" :class="{ 'animate-spin': isQuerying }"></i>
<span>{{ isQuerying ? '刷新中...' : '手动刷新' }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</AppCard>
</template>
<script>
import digitalHumanAPI from '@/utils/digitalHumanAPI'
import { ref, computed, onMounted, nextTick } from 'vue'
import axios from 'axios'
import { saveAs } from 'file-saver'
export default {
name: 'BaiduDigitalHuman',
setup() {
// 状态定义
const currentStep = ref(1)
const formData = ref({
text: '',
figureId: 'A2A_V2-xinxin',
driveType: 'TEXT',
ttsParams: {
person: 'CAP_4146',
speed: '5',
volume: '5',
pitch: '5',
},
videoParams: {
width: 1080,
height: 1920,
transparent: false,
},
autoAnimoji: true,
subtitleParams: {
enabled: false,
subtitlePolicy: 'SRT',
},
backgroundImageUrl: '',
callbackUrl: '',
})
const digitalHumans = ref([
{
id: 'A2A_V2-xinxin',
name: '梓欣',
gender: 'female',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%2089_8dc1165.png',
},
{
id: 'A2A_V2-xixi',
name: '筱萱',
gender: 'female',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%2090_2cae36d.png',
},
{
id: 'A2A_V2-xiaomeng2',
name: '乔雅',
gender: 'female',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%2091_70a3d4d.png',
},
{
id: 'A2A_V2-aning',
name: '嘉睿',
gender: 'male',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/%E5%98%89%E7%9D%BF-2_34e59dc.png',
},
{
id: 'A2A_V2-aning_red',
name: '嘉霖',
gender: 'male',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%2094_10f09cc.png',
},
{
id: 'A2A_V2-gaoming',
name: '纪楚',
gender: 'male',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%2095_ed96bc7.png',
},
])
const femaleVoices = ref([
{
id: 'CAP_4146',
name: '度禧禧',
gender: '女声',
style: '温柔甜美',
previewUrl:
'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/1e9d042c-f9d7-417f-88d3-4209f5516338/4146.wav',
},
{
id: 'BV502_streaming',
name: '度小夏',
gender: '女声',
style: '标准音',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/075ca6ce61d49629f62c520734b9e70e.wav',
},
{
id: '7011_moxingxiaoxiao_16k',
name: '专业靠谱爽朗女',
gender: '女声',
style: '专业娴熟/沉稳冷静/激情饱满',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/314a0e551c40beaead431bb4a30c7f43.wav',
},
{
id: '7011_moxingkangxi_16k',
name: '热情悦耳女主播',
gender: '女声',
style: '元气活力/权威靠谱/激情饱满',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/6cd5a9da434866bf285ddf6fe0411bbc.wav',
},
{
id: '7011_moxinghuanhuan_16k',
name: '自信活泼小姐姐',
gender: '女声',
style: '元气活力/权威靠谱/沉稳冷静',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/e7e878850475aaf2f34f299d4850ff90.wav',
},
{
id: '7011_vc0020_16k',
name: '自然朴实小妹妹',
gender: '女声',
style: '专业娴熟/亲和力强/权威靠谱',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/ec2da60778a9dad8f973c976876e50a4.wav',
},
{
id: '7011_vc0053_16k',
name: '专注真诚大姐姐',
gender: '女声',
style: '专业娴熟/亲和力强/权威靠谱',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/73ffa1c551a7f1b4b252f89e48909036.wav',
},
{
id: '7011_vc0033_16k',
name: '职业霸气御姐',
gender: '女声',
style: '专业娴熟/权威靠谱/沉稳冷静',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/0c4a0336a2c3f0f77cad7b841a8fa15e.wav',
},
{
id: '7011_vc0019_16k',
name: '知性优雅叙事女声',
gender: '女声',
style: '专业娴熟/亲和力强/沉稳冷静',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/5a4d90dad835e2347e99ec4746f265ca.wav',
},
{
id: '7011_vc0048_16k',
name: '幽默东北大妹子',
gender: '女声',
style: '亲和力强/权威靠谱/激情饱满',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/86d9023df2473565cd6a0b3ee6a11e59.wav',
},
{
id: '7011_vc0114_16k',
name: '温柔亲和女主播',
gender: '女声',
style: '元气活力/权威靠谱/激情饱满',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/b2ef65039c63cad57ffed13dd8867dcd.wav',
},
{
id: '7011_vc0100_16k',
name: '北京口音女声',
gender: '女声',
style: '亲和力强/权威靠谱/沉稳冷静',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/b1ea9bae92b4b003b239527594a20ef8.wav',
},
])
const maleVoices = ref([
{
id: 'CAP_4193',
name: '度泽言',
gender: '男声',
style: '温柔青年',
previewUrl:
'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/a545f018-54a1-4a89-a279-2c56a901bd5b/4193.wav',
},
{
id: 'CAP_4195',
name: '度怀安',
gender: '男声',
style: '磁性深情',
previewUrl:
'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/029dd3eb-1bd9-455b-a5fe-3cc3d32f85c3/4195.wav',
},
{
id: '4001',
name: '度小科',
gender: '男声',
style: '权威靠谱',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/6765196a1b6b66e8357fb833dad02404.wav',
},
{
id: '7011_moxingchuyi_16k',
name: '专业自信男主播',
gender: '男声',
style: '专业娴熟/亲和力强/沉稳冷静',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/91affdc041d5b0dc6665408cf3b33a27.wav',
},
{
id: '7011_vc0104_16k',
name: '自信坦诚大男孩',
gender: '男声',
style: '专业娴熟/元气活力/幽默有趣',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/f2af2ed9c71b950c753a6db96ad92c1c.wav',
},
{
id: '7011_vc0041_16k',
name: '直接果断男主播',
gender: '男声',
style: '亲和力强/元气活力/幽默有趣',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/6c94172414c967a06d5a14030e2790da.wav',
},
{
id: '7011_vc0049_16k',
name: '硬朗自信小哥哥',
gender: '男声',
style: '元气活力/幽默有趣/激情饱满',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/7027e7bf631cb34fa5b079ba71709b3b.wav',
},
{
id: '7011_vc0147_16k',
name: '雄浑宽广男主播',
gender: '男声',
style: '专业娴熟/权威靠谱/沉稳冷静',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/cd71c7fb34ccfe009082d8b2ac3eee4a.wav',
},
{
id: '7011_vc0079_16k',
name: '头头是道讲解员',
gender: '男声',
style: '亲和力强/元气活力/激情饱满',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/faf238c938ec771252217912c50d96ad.wav',
},
{
id: '7011_vc0067_16k',
name: '东北磁性男声',
gender: '男声',
style: '专业商务',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/de3759ffdedde37d69bbcaab8662c37b.wav',
},
])
const activeVoices = ref([])
const isSubmitting = ref(false)
const isQuerying = ref(false)
const taskResult = ref(null)
const taskStatus = ref(null)
const errorMessage = ref('')
const autoQueryInterval = ref(null)
const voiceSearchText = ref('')
const audioPlayer = ref(null)
const videoPlayer = ref(null)
const previewAudio = ref(null)
// 分辨率选项
const resolutionOptions = ref([
{ label: '720p (1280x720)', value: '1280x720' },
{ label: '1080p (1920x1080)', value: '1920x1080' },
{ label: '竖屏 (720x1280)', value: '720x1280' },
{ label: '竖屏 (1080x1920)', value: '1080x1920' },
])
// 计算属性
const filteredActiveVoices = computed(() => {
if (!voiceSearchText.value) {
return activeVoices.value
}
const searchText = voiceSearchText.value.toLowerCase()
return activeVoices.value.filter(
(voice) => voice.name.toLowerCase().includes(searchText) || voice.style.toLowerCase().includes(searchText)
)
})
// 将计算属性改为方法
const getSelectedHumanGender = () => {
const selectedHuman = digitalHumans.value.find((h) => h.id === formData.value.figureId)
return selectedHuman?.gender === 'female' ? '女' : '男'
}
// 方法定义
const initVoices = () => {
const selectedHuman = digitalHumans.value.find((h) => h.id === formData.value.figureId)
if (selectedHuman) {
if (selectedHuman.gender === 'female') {
activeVoices.value = femaleVoices.value
} else {
activeVoices.value = maleVoices.value
}
} else {
activeVoices.value = femaleVoices.value
}
}
const nextStep = () => {
if (currentStep.value < 5) {
currentStep.value++
}
}
const prevStep = () => {
if (currentStep.value > 1) {
currentStep.value--
}
}
const selectDigitalHuman = (humanId) => {
formData.value.figureId = humanId
const selectedHuman = digitalHumans.value.find((h) => h.id === humanId)
if (selectedHuman) {
activeVoices.value = selectedHuman.gender === 'female' ? femaleVoices.value : maleVoices.value
const currentVoiceExists = activeVoices.value.some((v) => v.id === formData.value.ttsParams.person)
if (!currentVoiceExists && activeVoices.value.length > 0) {
formData.value.ttsParams.person = activeVoices.value[0].id
}
} else {
activeVoices.value = femaleVoices.value
if (activeVoices.value.length > 0) {
formData.value.ttsParams.person = activeVoices.value[0].id
}
}
}
const selectVoice = (voiceId) => {
formData.value.ttsParams.person = voiceId
}
// 关键方法:获取语音预览URL
const getVoicePreviewUrl = (voiceId) => {
const allVoices = [...femaleVoices.value, ...maleVoices.value]
const voice = allVoices.find((v) => v.id === voiceId)
return voice && voice.previewUrl ? voice.previewUrl : ''
}
const previewVoice = (voiceId) => {
console.log('预览音色:', voiceId)
// 停止之前正在播放的音频
if (audioPlayer.value) {
audioPlayer.value.pause()
audioPlayer.value = null
}
const previewUrl = getVoicePreviewUrl(voiceId)
console.log('音频URL:', previewUrl)
if (previewUrl) {
try {
// 判断是否为本地开发环境
const isLocalDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
if (isLocalDev) {
// 本地开发环境使用代理API
console.log('本地开发环境,通过代理API获取音频...')
axios
.get(`/api/duan/api/v1/digital_human/proxy-resource?url=${encodeURIComponent(previewUrl)}&type=audio`)
.then((response) => {
if (response.data && response.data.success) {
if (response.data.data && response.data.data.base64Data) {
const audioSrc = response.data.data.base64Data
previewAudio.value = audioSrc
nextTick(() => {
if (audioPlayer.value) {
audioPlayer.value.play().catch((e) => {
console.error('代理音频播放失败:', e)
alert('音频播放失败,请检查您的浏览器设置是否允许自动播放')
})
}
})
} else {
console.error(
'通过代理获取音频数据成功,但 base64Data 字段缺失。查看响应详情:',
'Response Data:',
JSON.stringify(response.data),
'Response Data.data:',
JSON.stringify(response.data.data)
)
alert('通过代理获取音频失败(数据字段缺失),请稍后再试')
}
} else {
console.error('通过代理获取音频数据返回非成功状态:', response.data)
alert('通过代理获取音频失败(非成功状态),请稍后再试')
}
})
.catch((e) => {
console.error('音频代理API请求异常:', e)
alert('音频加载失败,无法播放')
})
} else {
// 生产环境直接播放
console.log('生产环境,直接播放音频...')
audioPlayer.value = new Audio(previewUrl)
audioPlayer.value.onerror = (e) => {
console.error('直接播放音频失败:', e, previewUrl)
alert(`音频加载失败: ${previewUrl}`)
audioPlayer.value = null
}
audioPlayer.value.onloadeddata = () => console.log('音频已加载,准备播放')
audioPlayer.value.onended = () => console.log('音频播放完成')
console.log('开始直接播放音频:', previewUrl)
const playPromise = audioPlayer.value.play()
if (playPromise !== undefined) {
playPromise
.then(() => console.log('音频开始直接播放成功'))
.catch((e) => {
console.error('直接播放音频失败:', e)
alert('无法播放试听音频,请检查音频链接或浏览器设置。')
audioPlayer.value = null
})
}
}
} catch (err) {
console.error('创建音频播放器逻辑出错:', err)
alert('音频播放器初始化失败,请稍后再试。')
}
} else {
const allVoices = [...femaleVoices.value, ...maleVoices.value]
const voice = allVoices.find((v) => v.id === voiceId)
alert(`音色 ${voice ? voice.name : voiceId} 暂无试听音频链接。`)
}
}
const getHumanName = (humanId) => {
const human = digitalHumans.value.find((h) => h.id === humanId)
return human ? human.name : humanId
}
const getVoiceName = (voiceId) => {
const allVoices = [...femaleVoices.value, ...maleVoices.value]
const voice = allVoices.find((v) => v.id === voiceId)
return voice ? voice.name : voiceId
}
const submitTask = async () => {
if (!formData.value.text) {
alert('请输入文本内容')
return
}
isSubmitting.value = true
errorMessage.value = ''
// 构建基础数字人视频所需的参数格式
const params = {
text: formData.value.text,
figureId: formData.value.figureId,
driveType: formData.value.driveType,
ttsParams: {
person: formData.value.ttsParams.person,
speed: formData.value.ttsParams.speed,
volume: formData.value.ttsParams.volume,
pitch: formData.value.ttsParams.pitch,
},
videoParams: {
width: formData.value.videoParams.width,
height: formData.value.videoParams.height,
transparent: formData.value.videoParams.transparent,
},
autoAnimoji: formData.value.autoAnimoji,
}
// 仅当字幕启用时添加字幕参数
if (formData.value.subtitleParams.enabled) {
params.subtitleParams = formData.value.subtitleParams
}
console.log('提交基础数字人视频任务参数:', JSON.stringify(params))
try {
const response = await digitalHumanAPI.submitBasicVideoTask(params)
console.log('API响应:', response.data)
if (response.data.success) {
taskResult.value = response.data.data
console.log('任务提交成功:', taskResult.value)
startAutoQuery()
currentStep.value = 5
} else {
errorMessage.value = response.data.message || '任务提交失败'
alert(errorMessage.value)
}
} catch (error) {
console.error('提交任务出错:', error)
errorMessage.value = error.response?.data?.message || '网络错误,请稍后重试'
alert(errorMessage.value)
} finally {
isSubmitting.value = false
}
}
const startAutoQuery = () => {
stopAutoQuery()
autoQueryInterval.value = setInterval(() => {
if (taskResult.value && taskResult.value.taskId) {
queryTask(false)
if (taskStatus.value && (taskStatus.value.status === 'SUCCESS' || taskStatus.value.status === 'FAILED')) {
stopAutoQuery()
}
} else {
stopAutoQuery()
}
}, 3000)
}
const stopAutoQuery = () => {
if (autoQueryInterval.value) {
clearInterval(autoQueryInterval.value)
autoQueryInterval.value = null
}
}
const queryTask = async (showLoading = true) => {
if (!taskResult.value || !taskResult.value.taskId) {
alert('请先提交任务')
return
}
if (showLoading) {
isQuerying.value = true
}
try {
const response = await digitalHumanAPI.queryBasicVideoTask(taskResult.value.taskId)
console.log('查询响应:', response.data)
if (response.data.success) {
taskStatus.value = response.data.data
console.log('任务状态:', taskStatus.value)
// 本地开发环境处理跨域视频和封面图
const isLocalDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
if (isLocalDev && taskStatus.value) {
// 处理视频URL
if (taskStatus.value.videoUrl && taskStatus.value.videoUrl.startsWith('http')) {
const originalVideoUrl = taskStatus.value.videoUrl
taskStatus.value.videoUrl = '' // 临时清空,避免直接加载
console.log(`本地开发环境,代理视频URL: ${originalVideoUrl}`)
axios
.get(
`/api/duan/api/v1/digital_human/proxy-resource?url=${encodeURIComponent(originalVideoUrl)}&type=video`
)
.then((proxyResponse) => {
if (
proxyResponse.data &&
proxyResponse.data.success &&
proxyResponse.data.data &&
proxyResponse.data.data.base64Data
) {
taskStatus.value.videoUrl = proxyResponse.data.data.base64Data
console.log('视频URL已通过代理更新为Base64')
} else {
console.error('通过代理获取视频URL失败或base64Data字段缺失:', proxyResponse.data)
taskStatus.value.videoUrl = originalVideoUrl // 代理失败,尝试用回原始URL(可能依然失败)
}
})
.catch((error) => {
console.error('视频URL代理请求失败:', error)
taskStatus.value.videoUrl = originalVideoUrl // 代理失败,尝试用回原始URL
})
}
// 处理封面图URL (poster)
if (taskStatus.value.previewUrl && taskStatus.value.previewUrl.startsWith('http')) {
const originalPreviewUrl = taskStatus.value.previewUrl
taskStatus.value.previewUrl = '/img/default-avatar.png' // 临时占位图
console.log(`本地开发环境,代理封面图URL: ${originalPreviewUrl}`)
axios
.get(
`/api/duan/api/v1/digital_human/proxy-resource?url=${encodeURIComponent(
originalPreviewUrl
)}&type=image`
)
.then((proxyResponse) => {
if (
proxyResponse.data &&
proxyResponse.data.success &&
proxyResponse.data.data &&
proxyResponse.data.data.base64Data
) {
taskStatus.value.previewUrl = proxyResponse.data.data.base64Data
console.log('封面图URL已通过代理更新为Base64')
} else {
console.error('通过代理获取封面图URL失败或base64Data字段缺失:', proxyResponse.data)
// 代理失败,保留占位图或尝试用回原始URL
// taskStatus.value.previewUrl = originalPreviewUrl;
}
})
.catch((error) => {
console.error('封面图URL代理请求失败:', error)
// taskStatus.value.previewUrl = originalPreviewUrl;
})
}
}
} else {
errorMessage.value = response.data.message || '查询任务失败'
if (showLoading) {
alert(errorMessage.value)
} else {
console.error(errorMessage.value)
}
}
} catch (error) {
console.error('查询任务出错:', error)
errorMessage.value = error.response?.data?.message || '网络错误,请稍后重试'
if (showLoading) {
alert(errorMessage.value)
} else {
console.error(errorMessage.value)
}
} finally {
if (showLoading) {
isQuerying.value = false
}
}
}
const resetForm = () => {
formData.value = {
text: '',
figureId: 'A2A_V2-xinxin',
driveType: 'TEXT',
ttsParams: {
person: 'CAP_4146',
speed: '5',
volume: '5',
pitch: '5',
},
videoParams: {
width: 1080,
height: 1920,
transparent: false,
},
autoAnimoji: true,
subtitleParams: {
enabled: false,
subtitlePolicy: 'SRT',
},
backgroundImageUrl: '',
callbackUrl: '',
}
taskResult.value = null
taskStatus.value = null
currentStep.value = 1
}
const getStatusText = (status) => {
const statusMap = {
PROCESSING: '处理中',
SUCCESS: '成功',
FAILED: '失败',
null: '未知',
}
return statusMap[status] || status
}
const getStatusClass = (status) => {
if (!status) return 'status-unknown'
const statusLower = status.toLowerCase()
if (statusLower === 'processing') return 'status-processing'
if (statusLower === 'success') return 'status-success'
if (statusLower === 'failed') return 'status-failed'
return 'status-unknown'
}
const formatTime = (seconds) => {
if (!seconds || isNaN(seconds)) return ''
try {
const totalSeconds = parseInt(seconds)
const minutes = Math.floor(totalSeconds / 60)
const remainingSeconds = totalSeconds % 60
return `${padZero(minutes)}:${padZero(remainingSeconds)}`
} catch (e) {
console.error('formatTime error:', e)
return seconds
}
}
const padZero = (num) => {
return num < 10 ? `0${num}` : `${num}`
}
// 添加formatDate函数,处理日期格式化
const formatDate = (timeString) => {
if (!timeString) return ''
try {
const date = new Date(timeString)
return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ${padZero(
date.getHours()
)}:${padZero(date.getMinutes())}:${padZero(date.getSeconds())}`
} catch (e) {
console.error('formatDate error:', e)
return timeString
}
}
const downloadVideo = () => {
if (taskStatus.value && taskStatus.value.videoUrl) {
saveAs(taskStatus.value.videoUrl, `数字人视频_${new Date().getTime()}.mp4`)
} else {
alert('视频链接无效,无法下载')
}
}
const copyVideoLink = () => {
if (taskStatus.value && taskStatus.value.videoUrl) {
const textarea = document.createElement('textarea')
textarea.value = taskStatus.value.videoUrl
document.body.appendChild(textarea)
textarea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
alert('视频链接已复制到剪贴板')
} else {
alert('复制失败,请手动复制')
}
} catch (err) {
alert('复制失败: ' + err)
}
document.body.removeChild(textarea)
} else {
alert('视频链接无效,无法复制')
}
}
const getResolutionLabel = (width, height) => {
const resolution = `${width}x${height}`
const found = resolutionOptions.value.find((option) => option.value === resolution)
return found ? found.label : `${width}x${height}`
}
const selectResolution = (resolution) => {
const [width, height] = resolution.split('x')
formData.value.videoParams.width = parseInt(width)
formData.value.videoParams.height = parseInt(height)
}
const isResolutionSelected = (resolution) => {
return `${formData.value.videoParams.width}x${formData.value.videoParams.height}` === resolution
}
const useExampleText = () => {
formData.value.text = '欢迎使用紫荆AI图文创作工具,我可以为您生成各种风格的数字人视频。'
}
const adjustVideoSize = () => {
// 获取视频播放器包装元素
const playerWrapper = document.querySelector('.video-player-wrapper')
if (playerWrapper) {
playerWrapper.style.maxWidth = '640px'
}
}
// 生命周期钩子
onMounted(() => {
initVoices()
// 判断是否为本地开发环境
const isLocalDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
if (isLocalDev) {
// 本地环境处理图片跨域问题
console.log('本地开发环境,处理图片跨域问题...')
digitalHumans.value.forEach((human) => {
if (human.avatar && human.avatar.startsWith('http')) {
const originalAvatar = human.avatar
human.avatar = '/img/default-avatar.png' // 确保这个占位图片存在于 public/img/ 目录下
axios
.get(`/api/duan/api/v1/digital_human/proxy-resource?url=${encodeURIComponent(originalAvatar)}&type=image`)
.then((response) => {
if (response.data && response.data.success) {
if (response.data.data && response.data.data.base64Data) {
human.avatar = response.data.data.base64Data
console.log(`数字人 ${human.name} 头像已转换为Base64格式`)
} else {
console.warn(
`加载数字人 ${human.name} 头像通过代理返回成功,但 base64Data 字段缺失。查看响应详情:`,
'Response Data:',
JSON.stringify(response.data),
'Response Data.data:',
JSON.stringify(response.data.data)
)
}
} else {
console.warn(`加载数字人 ${human.name} 头像通过代理返回非成功状态:`, response.data)
}
})
.catch((error) => {
console.error(`加载数字人 ${human.name} 头像代理请求失败:`, error)
})
}
})
}
})
// 返回模板中需要使用的内容
return {
currentStep,
formData,
digitalHumans,
femaleVoices,
maleVoices,
activeVoices,
filteredActiveVoices,
isSubmitting,
isQuerying,
taskResult,
taskStatus,
errorMessage,
voiceSearchText,
videoPlayer,
audioPlayer,
previewAudio,
// 方法
nextStep,
prevStep,
selectDigitalHuman,
selectVoice,
previewVoice,
getVoicePreviewUrl,
getHumanName,
getVoiceName,
getSelectedHumanGender,
submitTask,
queryTask,
resetForm,
getStatusText,
getStatusClass,
formatTime,
formatDate,
downloadVideo,
copyVideoLink,
getResolutionLabel,
selectResolution,
isResolutionSelected,
resolutionOptions,
useExampleText,
adjustVideoSize,
}
},
}
</script>
<style scoped>
@import '@/assets/css/text-creation-common.css';
/* 主容器布局调整 */
.main-container {
display: flex;
gap: 15px;
height: 100%;
}
/* 文本输入区域样式增强 */
.text-input-wrapper {
position: relative;
}
.input-actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.text-action-button {
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 5px 10px;
font-size: 12px;
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
transition: all 0.2s;
}
.text-action-button:hover {
background-color: #e9e9e9;
border-color: #ccc;
}
.text-action-button i {
font-size: 14px;
}
/* 左侧步骤条变窄 */
.steps-container.slim-steps {
width: 200px; /* 调小左侧宽度 */
flex-shrink: 0;
margin-bottom: 0; /* 移除底部间距,因为右侧会滚动 */
}
/* 右侧内容区变宽 */
.step-content-container.wide-content {
flex-grow: 1; /* 占据剩余宽度 */
max-height: calc(100vh - 120px); /* 限制高度,留出顶部导航空间 */
overflow-y: auto; /* 内容超出时允许滚动 */
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* 步骤内容面板 */
.step-content-panel {
padding: 20px;
height: auto;
display: flex;
flex-direction: column;
}
/* 表单组样式 */
.form-group {
margin-bottom: 20px;
flex-shrink: 0;
}
/* 优化数字人选择布局 */
.digital-human-grid.inline-grid {
display: grid;
grid-template-columns: repeat(6, 1fr); /* 六列布局 */
gap: 15px; /* 增加间距 */
max-height: 220px; /* 提高高度,原来是150px */
overflow-y: auto; /* 超出时添加滚动 */
padding: 10px; /* 增加内边距 */
border: 1px solid #eee;
border-radius: 8px;
}
.digital-human-card.compact-card {
width: auto;
margin: 0;
padding: 8px; /* 增加内边距 */
border: 2px solid transparent;
border-radius: 8px;
transition: all 0.2s;
}
.digital-human-card.compact-card:hover {
background-color: #f9f9f9;
transform: translateY(-2px);
}
.digital-human-card.compact-card.selected {
border-color: var(--primary-color, #ba003f);
background-color: rgba(186, 0, 63, 0.05);
}
.compact-card .human-avatar {
width: 70px; /* 增加头像尺寸,原来是50px */
height: 70px; /* 增加头像尺寸,原来是50px */
margin: 0 auto 8px; /* 增加底部间距 */
border-radius: 50%;
overflow: hidden;
}
.compact-card .human-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.compact-card .human-name {
font-size: 13px; /* 增大字体 */
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 优化音色选择列表布局,适应更多音色 */
.voice-list.multi-column-list {
display: grid;
grid-template-columns: repeat(3, 1fr); /* 三列 */
gap: 10px;
max-height: 300px; /* 增加高度,显示更多音色 */
overflow-y: auto;
padding: 5px;
border: 1px solid #eee;
border-radius: 8px;
}
.voice-item.compact-voice-item {
padding: 8px 10px;
border: 2px solid #eee;
border-radius: 8px;
transition: all 0.2s;
display: flex;
align-items: center;
}
.voice-item.compact-voice-item:hover {
border-color: var(--primary-color, #ba003f);
background-color: #f9f9f9;
transform: translateY(-2px);
}
.voice-item.compact-voice-item.selected {
border-color: var(--primary-color, #ba003f);
background-color: rgba(186, 0, 63, 0.05);
}
.voice-icon {
font-size: 20px;
color: #666;
margin-right: 10px;
flex-shrink: 0;
}
.voice-info {
flex: 1;
min-width: 0; /* 防止flex子项溢出 */
}
.voice-name {
font-weight: bold;
color: #333;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.voice-desc {
color: #666;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.voice-preview {
margin-left: 8px;
flex-shrink: 0;
}
/* 试听按钮强调 */
.preview-button {
background: none;
border: none;
color: var(--primary-color, #ba003f);
font-size: 20px;
cursor: pointer;
padding: 4px;
transition: transform 0.2s;
line-height: 1;
}
.preview-button:hover {
transform: scale(1.2);
}
.preview-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 音色过滤器 */
.voice-filter {
margin-bottom: 10px;
}
.voice-search-input {
padding: 8px 12px;
border-radius: 20px;
border: 1px solid #ddd;
width: 100%;
font-size: 14px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23999' viewBox='0 0 16 16'%3E%3Cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 10px center;
background-size: 16px;
padding-left: 32px;
}
.voice-filter-info {
font-size: 12px;
color: #666;
background-color: #f0f0f0;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
}
/* 动作按钮 */
.action-buttons {
margin-top: auto; /* 推到底部 */
display: flex;
justify-content: space-between;
padding-top: 20px;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.digital-human-grid.inline-grid {
grid-template-columns: repeat(4, 1fr); /* 四列 */
max-height: 280px; /* 响应式调整高度 */
}
.voice-list.multi-column-list {
grid-template-columns: repeat(2, 1fr); /* 两列 */
}
}
@media (max-width: 768px) {
.main-container {
flex-direction: column;
}
.steps-container.slim-steps {
width: 100%;
margin-bottom: 15px;
}
.step-content-container.wide-content {
max-height: none;
}
.digital-human-grid.inline-grid {
grid-template-columns: repeat(3, 1fr); /* 三列 */
max-height: 330px; /* 在移动端增加更多高度 */
}
.voice-list.multi-column-list {
grid-template-columns: 1fr; /* 单列 */
}
}
/* 机位选择样式 */
.camera-options {
display: flex;
gap: 15px;
margin-top: 10px;
}
.camera-option {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15px;
border: 1px solid #ddd;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
width: 100px;
height: 100px;
}
.camera-option i {
font-size: 2rem;
margin-bottom: 10px;
}
.camera-option.active {
border-color: #007bff;
background-color: rgba(0, 123, 255, 0.1);
color: #007bff;
}
.resolution-options {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.resolution-option {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
}
.resolution-option.active {
border-color: #007bff;
background-color: rgba(0, 123, 255, 0.1);
color: #007bff;
}
/* 提交任务和查看结果步骤样式优化 */
.task-summary-container {
background-color: #f9f9f9;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
padding: 20px;
margin-bottom: 20px;
}
.summary-heading {
font-size: 20px;
color: #333;
margin-bottom: 16px;
display: flex;
align-items: center;
border-bottom: 1px solid #eee;
padding-bottom: 12px;
}
.summary-heading i {
margin-right: 8px;
color: var(--primary-color, #ba003f);
}
.task-summary {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.summary-item {
padding: 12px;
background-color: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: transform 0.2s;
}
.summary-item:hover {
transform: translateY(-2px);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
}
.summary-label {
font-weight: bold;
color: #555;
margin-bottom: 6px;
display: flex;
align-items: center;
}
.summary-label i {
margin-right: 6px;
color: var(--primary-color, #ba003f);
font-size: 16px;
}
.summary-value {
font-size: 15px;
color: #333;
line-height: 1.5;
}
.text-value {
max-height: 80px;
overflow-y: auto;
white-space: pre-line;
padding: 8px;
background-color: #f5f5f5;
border-radius: 4px;
font-family: monospace;
font-size: 14px;
}
.summary-tag {
display: inline-flex;
align-items: center;
background-color: rgba(186, 0, 63, 0.1);
color: var(--primary-color, #ba003f);
border-radius: 16px;
padding: 4px 10px;
margin-right: 8px;
margin-bottom: 6px;
font-size: 13px;
}
.summary-tag i {
margin-right: 4px;
}
.submit-button {
background-color: var(--primary-color, #ba003f);
font-size: 16px;
padding: 10px 20px;
transition: all 0.3s;
}
.submit-button:hover {
background-color: darken(var(--primary-color, #ba003f), 10%);
transform: translateY(-3px);
box-shadow: 0 4px 8px rgba(186, 0, 63, 0.3);
}
/* 查看结果样式 */
.task-result-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.task-status-card {
background-color: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 20px;
transition: transform 0.3s;
}
.task-status-card:hover {
transform: translateY(-4px);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
border-bottom: 1px solid #eee;
padding-bottom: 12px;
}
.task-id {
font-weight: bold;
color: #333;
font-size: 18px;
}
.status-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
display: flex;
align-items: center;
}
.status-processing {
background-color: #e3f2fd;
color: #1976d2;
}
.status-success {
background-color: #e8f5e9;
color: #2e7d32;
}
.status-failed {
background-color: #ffebee;
color: #c62828;
}
.status-unknown {
background-color: #f5f5f5;
color: #757575;
}
.processing-section {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.processing-indicator {
font-size: 24px;
margin-bottom: 10px;
}
.processing-info {
font-size: 14px;
color: #666;
}
.failure-section {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.failure-icon {
font-size: 48px;
color: #c62828;
margin-bottom: 10px;
}
.failure-message {
font-size: 18px;
color: #c62828;
}
.success-section {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.video-container {
width: 100%;
margin: 20px auto;
transition: all 0.3s ease;
}
/* 视频播放器样式 */
.video-player-wrapper {
width: 100%;
margin-bottom: 20px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
}
.video-player {
width: 100%;
height: auto;
max-height: 600px;
border-radius: 8px;
background-color: #000;
}
/* 视频尺寸控制 */
.video-size-controls {
display: none;
}
.size-label {
display: none;
}
.size-btn {
display: none;
}
.video-info {
background-color: #f9f9f9;
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
}
.video-info p {
margin: 6px 0;
color: #555;
font-size: 14px;
}
.video-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
justify-content: center;
}
.action-button {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
background-color: var(--primary-color, #ba003f);
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.action-button:hover {
background-color: #d4004c;
transform: translateY(-2px);
}
.action-button i {
font-size: 16px;
}
.update-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.auto-update-info {
font-size: 14px;
color: #666;
}
.update-button {
background-color: #e3f2fd;
color: #1976d2;
}
.update-button:hover {
background-color: #bbdefb;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
......@@ -297,6 +297,13 @@ const adminMenus: IMenuItem[] = [
icon: markRaw(IconAudio),
// tag: 'v1-experiment-marketing-material-list',
},
// {
// id: 14,
// name: '数字人管理',
// path: '/material/digital-human',
// icon: markRaw(IconVideo),
// // tag: 'v1-experiment-marketing-material-list',
// },
{
id: 14,
name: '视频资料管理',
......@@ -392,7 +399,7 @@ export const useMenuStore = defineStore({
const filterMenus = (menus: IMenuItem[]): IMenuItem[] => {
return menus
.filter((menu) => userPermissions.some((perm) => perm.id === menu.id))
.filter((menu) => menu.role ? menu.role?.includes(userRole) : true)
.filter((menu) => (menu.role ? menu.role?.includes(userRole) : true))
.map((menu) => {
const filteredMenu: IMenuItem = {
...menu,
......
/**
* 百度云BOS直接上传工具
* 实现前端直接上传图片到百度云BOS对象存储
*/
import axios from 'axios'
import CryptoJS from 'crypto-js'
// 百度云BOS配置
const BOS_CONFIG = {
accessKeyId: '', // 将在使用时从后端获取
secretAccessKey: '', // 将在使用时从后端获取
endpoint: 'bj.bcebos.com',
bucketName: 'ezijing',
domain: 'https://ezijing.bj.bcebos.com',
}
/**
* 生成ISO8601格式的时间戳
* @returns {string} ISO8601格式的时间戳
*/
function getISODateString() {
return new Date().toISOString().replace(/\.\d+Z$/, 'Z')
}
/**
* 计算文件的MD5值
* @param {File} file - 文件对象
* @returns {Promise<string>} - 返回base64编码的MD5值
*/
function calculateFileMD5(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const wordArray = CryptoJS.lib.WordArray.create(e.target.result)
const md5 = CryptoJS.MD5(wordArray)
const base64Md5 = CryptoJS.enc.Base64.stringify(md5)
resolve(base64Md5)
} catch (err) {
reject(err)
}
}
reader.onerror = (err) => reject(err)
reader.readAsArrayBuffer(file)
})
}
/**
* 生成上传策略和签名
* @param {Object} options - 配置选项
* @returns {Object} - 包含policy和signature的对象
*/
function generatePolicyAndSignature(options = {}) {
const {
expiration = new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 默认30分钟过期
contentLengthRange = [0, 10 * 1024 * 1024], // 默认最大10MB
keyPattern = null, // 对象键匹配模式
bucket = BOS_CONFIG.bucketName,
} = options
// 构建policy对象 - 按照百度云BOS要求的格式
const policy = {
expiration,
conditions: [{ bucket }],
}
// 添加content-length-range
policy.conditions.push(['content-length-range', ...contentLengthRange])
// 如果指定了keyPattern,添加到conditions中
if (keyPattern) {
policy.conditions.push({ key: keyPattern })
}
// 将policy转为JSON
const policyJson = JSON.stringify(policy)
console.log('Policy JSON:', policyJson)
// 对policy进行base64编码
// 确保使用UTF-8编码,完全模拟Python中的base64.b64encode(policy)
const policyBase64 = btoa(unescape(encodeURIComponent(policyJson)))
// 根据Python示例代码精确实现签名方法
// Python: hmac.new(sk, base64.b64encode(policy), hashlib.sha256).hexdigest()
// 使用CryptoJS的HMAC-SHA256算法计算签名
const signature = CryptoJS.HmacSHA256(
policyBase64, // 使用base64编码后的policy作为消息
BOS_CONFIG.secretAccessKey // 使用SK作为密钥
).toString(CryptoJS.enc.Hex) // 输出为十六进制字符串
console.log('Generated policy:', policyBase64)
console.log('Generated signature:', signature)
return {
policy: policyBase64,
signature,
}
}
/**
* 生成百度云BOS的V1签名
* @param {Object} options - 签名选项
* @returns {Object} - 包含authorization和headers的对象
*/
function generateBosV1Signature(options) {
const { method, uri, params = {}, headers = {}, timestamp = getISODateString() } = options
// 1. 生成CanonicalRequest
// 1.1 添加HTTP方法
let canonicalRequest = `${method.toUpperCase()}\n`
// 1.2 添加URI
canonicalRequest += `${uri}\n`
// 1.3 添加查询参数
const queryParams = []
Object.keys(params)
.sort()
.forEach((key) => {
queryParams.push(`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
})
canonicalRequest += `${queryParams.join('&')}\n`
// 1.4 添加规范化头信息
const canonicalHeaders = {}
// 添加host头
canonicalHeaders['host'] = `${BOS_CONFIG.bucketName}.${BOS_CONFIG.endpoint}`
// 添加指定的头
Object.keys(headers).forEach((key) => {
const lowerKey = key.toLowerCase()
// 只保留必要的headers
if (
lowerKey === 'content-type' ||
lowerKey === 'content-md5' ||
lowerKey === 'date' ||
lowerKey.startsWith('x-bce-')
) {
canonicalHeaders[lowerKey] = headers[key]
}
})
// 如果没有Date头,添加x-bce-date头
if (!canonicalHeaders['date']) {
canonicalHeaders['x-bce-date'] = timestamp
}
// 构建规范化头部字符串
const canonicalHeadersArray = []
Object.keys(canonicalHeaders)
.sort()
.forEach((key) => {
canonicalHeadersArray.push(`${key}:${canonicalHeaders[key]}`)
})
canonicalRequest += canonicalHeadersArray.join('\n')
console.log('规范化请求字符串:', canonicalRequest)
// 2. 生成签名密钥
const authStringPrefix = `bce-auth-v1/${BOS_CONFIG.accessKeyId}/${timestamp}/1800`
const signingKey = CryptoJS.HmacSHA256(authStringPrefix, BOS_CONFIG.secretAccessKey).toString(CryptoJS.enc.Hex)
console.log('签名密钥:', signingKey)
// 3. 计算签名
const signature = CryptoJS.HmacSHA256(canonicalRequest, signingKey).toString(CryptoJS.enc.Hex)
console.log('签名结果:', signature)
// 4. 生成Authorization字符串
const headersToSign = Object.keys(canonicalHeaders).sort().join(';')
const authorization = `${authStringPrefix}/host;${headersToSign}/${signature}`
return {
authorization,
headers: canonicalHeaders,
}
}
/**
* 生成上传表单数据
* @param {File} file - 要上传的文件
* @param {Object} options - 上传选项
* @returns {FormData} - 填充好的FormData对象
*/
function prepareUploadFormData(file, options = {}) {
const {
key = `uploads/${new Date().toISOString().slice(0, 10).replace(/-/g, '')}/${Date.now()}_${file.name}`,
contentType = file.type || 'application/octet-stream',
successRedirectUrl = null,
contentDisposition = null,
metadata = {},
} = options
// 生成policy和signature
const { policy, signature } = generatePolicyAndSignature({
contentLengthRange: [0, file.size + 1024], // 允许的文件大小范围
keyPattern: key,
bucket: BOS_CONFIG.bucketName,
})
// 创建FormData
const formData = new FormData()
// BOS API要求使用accessKey而不是accessKeyId
formData.append('accessKey', BOS_CONFIG.accessKeyId)
formData.append('policy', policy)
formData.append('signature', signature)
formData.append('key', key)
// 添加Content-Type
if (contentType) {
formData.append('Content-Type', contentType)
}
// 添加Content-Disposition (如果指定)
if (contentDisposition) {
formData.append('Content-Disposition', contentDisposition)
}
// 添加自定义元数据
Object.entries(metadata).forEach(([key, value]) => {
formData.append(`x-bce-meta-${key}`, value)
})
// 添加成功重定向URL (如果指定)
if (successRedirectUrl) {
formData.append('success-redirect-url', successRedirectUrl)
}
// 添加文件数据 (必须放在最后)
formData.append('file', file)
return formData
}
/**
* 获取安全凭证
* 从后端获取临时上传凭证,避免在前端暴露永久凭证
* @returns {Promise<Object>} - 包含accessKeyId和secretAccessKey的对象
*/
async function getCredentials() {
try {
// 直接使用硬编码的访问凭证(临时解决方案)
BOS_CONFIG.accessKeyId = '05c5a1cf304f45c6b17f55967449bc9b'
BOS_CONFIG.secretAccessKey = '42d4dea8b0614fc09488c9386bad9909'
return {
accessKeyId: '05c5a1cf304f45c6b17f55967449bc9b',
secretAccessKey: '42d4dea8b0614fc09488c9386bad9909',
}
/* 注释掉原始的API调用代码
const response = await axios.get('/api/duan/api/v1/upload/bos_credentials');
if (response.data && response.data.success) {
BOS_CONFIG.accessKeyId = response.data.accessKeyId;
BOS_CONFIG.secretAccessKey = response.data.secretAccessKey;
return {
accessKeyId: response.data.accessKeyId,
secretAccessKey: response.data.secretAccessKey
};
} else {
throw new Error('获取上传凭证失败');
}
*/
} catch (error) {
console.error('获取上传凭证异常:', error)
throw error
}
}
/**
* 使用表单方式直接上传文件到百度云BOS
* @param {File} file - 要上传的文件对象
* @param {Object} options - 上传选项
* @param {Function} onProgress - 上传进度回调
* @returns {Promise<string>} - 上传成功的文件URL
*/
export async function uploadFileByForm(file, options = {}, onProgress = null) {
try {
// 首先获取上传凭证
await getCredentials()
// 准备上传参数
const {
prefix = 'uploads',
fileType = 'general',
key = `${prefix}/${fileType}/${new Date().toISOString().slice(0, 10).replace(/-/g, '')}/${Date.now()}_${
file.name
}`,
} = options
// 准备表单数据
const formData = prepareUploadFormData(file, {
key,
contentType: file.type || 'application/octet-stream',
})
// 设置上传URL
const uploadUrl = `https://${BOS_CONFIG.bucketName}.${BOS_CONFIG.endpoint}/`
console.log('上传请求URL:', uploadUrl)
// 执行上传请求
const response = await axios.post(uploadUrl, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted, progressEvent)
}
},
})
console.log('表单上传请求响应:', response)
// 构建文件URL
const fileUrl = `${BOS_CONFIG.domain}/${key}`
return fileUrl
} catch (error) {
console.error('表单上传到百度云BOS失败:', error)
if (error.response) {
console.error('响应状态:', error.response.status)
console.error('响应数据:', error.response.data)
console.error('响应头:', error.response.headers)
}
throw error
}
}
/**
* 生成预签名PUT上传URL
* @param {string} key - 文件的对象键
* @param {string} contentType - 文件内容类型
* @param {number} contentLength - 文件大小
* @param {string} contentMD5 - 文件MD5值(可选)
* @param {number} expireInSeconds - 链接有效期(秒)
* @returns {string} - 预签名URL
*/
function generatePresignedPutUrl(key, contentType, contentLength, contentMD5 = '', expireInSeconds = 1800) {
// 获取当前时间戳
const timestamp = getISODateString()
// 准备查询参数
const params = {
'X-Bce-Date': timestamp,
'Content-Type': contentType,
'Content-Length': contentLength.toString(),
Host: `${BOS_CONFIG.bucketName}.${BOS_CONFIG.endpoint}`,
}
// 如果有Content-MD5,添加到参数
if (contentMD5) {
params['Content-MD5'] = contentMD5
}
// 计算签名有效期截止时间
const expirationTimestamp = Math.floor(Date.now() / 1000) + expireInSeconds
params['X-Bce-Expires'] = expirationTimestamp.toString()
// 准备签名所需的参数
const signOptions = {
method: 'PUT',
uri: `/${key}`,
params,
headers: {
'Content-Type': contentType,
'Content-Length': contentLength.toString(),
Host: `${BOS_CONFIG.bucketName}.${BOS_CONFIG.endpoint}`,
},
timestamp,
}
// 如果有Content-MD5,添加到请求头
if (contentMD5) {
signOptions.headers['Content-MD5'] = contentMD5
}
// 生成签名
const { authorization } = generateBosV1Signature(signOptions)
// 构建查询参数字符串
const queryParams = {
authorization: authorization,
'x-bce-date': timestamp,
expires: expirationTimestamp.toString(),
'content-type': contentType,
}
if (contentMD5) {
queryParams['content-md5'] = contentMD5
}
// 拼接查询参数
const queryString = Object.entries(queryParams)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
// 生成预签名URL
return `https://${BOS_CONFIG.bucketName}.${BOS_CONFIG.endpoint}/${key}?${queryString}`
}
/**
* 使用PutObject方式直接上传文件到百度云BOS
* @param {File} file - 要上传的文件对象
* @param {Object} options - 上传选项
* @param {Function} onProgress - 上传进度回调
* @returns {Promise<string>} - 上传成功的文件URL
*/
export async function uploadFileByPut(file, options = {}, onProgress = null) {
try {
// 首先获取上传凭证
await getCredentials()
// 准备上传参数
const {
prefix = 'uploads',
fileType = 'general',
key = `${prefix}/${fileType}/${new Date().toISOString().slice(0, 10).replace(/-/g, '')}/${Date.now()}_${
file.name
}`,
} = options
// 计算文件的Content-MD5
let contentMD5 = ''
try {
contentMD5 = await calculateFileMD5(file)
console.log('计算的Content-MD5:', contentMD5)
} catch (error) {
console.warn('MD5计算失败,继续上传:', error)
}
// 检测是否在浏览器环境中
const isInBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined'
if (isInBrowser) {
console.log('在浏览器环境中使用预签名URL方式上传...')
// 生成预签名URL
const presignedUrl = generatePresignedPutUrl(key, file.type || 'application/octet-stream', file.size, contentMD5)
console.log('生成的预签名URL:', presignedUrl)
// 执行PUT上传请求(使用预签名URL,不需要额外的授权头)
try {
const response = await axios.put(presignedUrl, file, {
// 使用最简化的头,避免CORS和浏览器安全限制问题
headers: {
'Content-Type': file.type || 'application/octet-stream',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted, progressEvent)
}
},
})
console.log('预签名URL PUT上传成功:', response.status)
// 生成访问URL
const fileUrl = `${BOS_CONFIG.domain}/${key}`
return fileUrl
} catch (putError) {
console.error('预签名URL PUT上传失败,错误详情:', putError)
if (putError.response) {
console.error(' - 响应状态:', putError.response.status)
console.error(' - 响应数据:', putError.response.data)
console.error(' - 响应头:', putError.response.headers)
}
console.log('尝试使用标准PUT方法上传...')
// 获取当前时间戳
const timestamp = getISODateString()
// 准备签名所需的参数(使用最简化的头信息)
const signOptions = {
method: 'PUT',
uri: `/${key}`,
params: {},
headers: {
'Content-Type': file.type || 'application/octet-stream',
'x-bce-date': timestamp,
},
timestamp,
}
// 如果有Content-MD5,添加到请求头
if (contentMD5) {
signOptions.headers['Content-MD5'] = contentMD5
}
// 生成签名
const { authorization } = generateBosV1Signature(signOptions)
// 设置上传URL
const uploadUrl = `https://${BOS_CONFIG.bucketName}.${BOS_CONFIG.endpoint}/${key}`
try {
const response = await axios.put(uploadUrl, file, {
headers: {
Authorization: authorization,
'Content-Type': file.type || 'application/octet-stream',
'x-bce-date': timestamp,
...(contentMD5 ? { 'Content-MD5': contentMD5 } : {}),
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted, progressEvent)
}
},
})
console.log('标准PUT上传成功:', response.status)
// 生成访问URL
const fileUrl = `${BOS_CONFIG.domain}/${key}`
return fileUrl
} catch (standardPutError) {
console.error('标准PUT上传也失败,尝试使用表单上传:', standardPutError)
// 如果标准PUT上传失败,尝试使用表单上传(作为备选方案)
return uploadFileByForm(file, options, onProgress)
}
}
} else {
// 非浏览器环境(如Node.js),可以直接使用PUT
console.log('非浏览器环境使用PUT方法上传')
// 获取当前时间戳
const timestamp = getISODateString()
// 生成签名
const { authorization, headers: signedHeaders } = generateBosV1Signature({
method: 'PUT',
uri: `/${key}`,
params: {},
headers: {
'Content-Type': file.type || 'application/octet-stream',
'Content-Length': file.size.toString(),
...(contentMD5 ? { 'Content-MD5': contentMD5 } : {}),
},
timestamp,
})
console.log('非浏览器环境PUT上传请求头:', signedHeaders)
// 设置上传URL
const uploadUrl = `https://${BOS_CONFIG.bucketName}.${BOS_CONFIG.endpoint}/${key}`
// 构建请求头
const requestHeaders = {
Host: `${BOS_CONFIG.bucketName}.${BOS_CONFIG.endpoint}`,
Authorization: authorization,
'Content-Type': file.type || 'application/octet-stream',
'Content-Length': file.size.toString(),
'x-bce-date': timestamp,
}
// 如果有Content-MD5,添加到请求头
if (contentMD5) {
requestHeaders['Content-MD5'] = contentMD5
}
// 执行上传请求
const response = await axios.put(uploadUrl, file, {
headers: requestHeaders,
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted, progressEvent)
}
},
})
console.log('非浏览器环境PUT上传请求响应:', response)
// 生成访问URL
const fileUrl = `${BOS_CONFIG.domain}/${key}`
return fileUrl
}
} catch (error) {
console.error('PUT上传到百度云BOS失败:', error)
if (error.response) {
console.error('响应状态:', error.response.status)
console.error('响应数据:', error.response.data)
console.error('响应头:', error.response.headers)
}
throw error
}
}
/**
* 直接上传文件到百度云BOS (优先使用PUT方法)
* @param {File} file - 要上传的文件对象
* @param {Object} options - 上传选项
* @param {Function} onProgress - 上传进度回调
* @returns {Promise<string>} - 上传成功的文件URL
*/
export async function uploadFileToBos(file, options = {}, onProgress = null) {
// 检测是否在浏览器环境中
const isInBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined'
console.log('isInBrowser:', isInBrowser)
// 使用指定的上传方法或默认使用PUT
const { method = 'PUT' } = options
if (method.toUpperCase() === 'POST' || method.toUpperCase() === 'FORM') {
// 用户明确要求使用表单上传
return uploadFileByForm(file, options, onProgress)
} else {
// 优先使用PUT上传,失败时自动回退到表单上传
try {
return await uploadFileByPut(file, options, onProgress)
} catch (error) {
console.warn('PUT上传失败,自动回退到表单上传:', error)
console.log('使用表单方式重试上传...')
return uploadFileByForm(file, options, onProgress)
}
}
}
/**
* 上传图片到百度云BOS
* @param {File} file - 图片文件
* @param {string} type - 图片类型 (background|logo)
* @param {Function} onProgress - 上传进度回调
* @returns {Promise<string>} - 上传成功的图片URL
*/
export async function uploadImageToBos(file, type = 'general', onProgress = null) {
return uploadFileToBos(
file,
{
prefix: 'images',
fileType: type,
},
onProgress
)
}
export default {
uploadFileToBos,
uploadImageToBos,
uploadFileByForm,
uploadFileByPut,
}
import axios from 'axios'
import { uploadImageToBos } from './bosDirectUpload'
const baseURL = '/api/duan/api/v1'
/**
* 百度数字人视频合成服务API
*/
const digitalHumanAPI = {
/**
* 提交百度数字人视频合成任务
* @param {Object} params - 任务参数
* @returns {Promise} - axios请求Promise
*/
submitVideoTask(params) {
return axios.post(`${baseURL}/digital_human/video/advanced/submit`, params)
},
/**
* 提交基础数字人视频任务
* @param {Object} params - 任务参数
* @returns {Promise} - axios请求Promise
*/
submitBasicVideoTask(params) {
return axios.post(`${baseURL}/digital_human/video/submit`, params)
},
/**
* 查询百度数字人视频合成任务状态
* @param {string} taskId - 任务ID
* @returns {Promise} - axios请求Promise
*/
queryVideoTask(taskId) {
return axios.get(`${baseURL}/digital_human/video/advanced/query`, {
params: { taskId },
})
},
/**
* 查询基础数字人视频任务状态
* @param {string} taskId - 任务ID
* @returns {Promise} - axios请求Promise
*/
queryBasicVideoTask(taskId) {
return axios.get(`${baseURL}/digital_human/video/query`, {
params: { taskId },
})
},
/**
* 上传图片到阿里云OSS
* @param {File|string} imageData - 图片文件对象或Base64编码的图片数据
* @param {string} type - 图片类型 (background|logo)
* @param {Object} options - 上传选项
* @param {Function} options.onProgress - 上传进度回调函数
* @param {Number} options.retryCount - 失败重试次数,默认为2
* @param {Number} options.retryDelay - 重试间隔(毫秒),默认为1000
* @returns {Promise} - axios请求Promise
*/
uploadImageToAliyunOSS(imageData, type, options = {}) {
const { onProgress = null, retryCount = 2, retryDelay = 1000 } = options
let currentRetry = 0
const executeUpload = () => {
// 使用FormData封装数据
const formData = new FormData()
if (imageData instanceof File) {
formData.append('file', imageData)
} else {
// 如果是Base64,使用Blob转换
try {
// 提取Base64数据
const base64Data = imageData.split(',')[1]
const blob = this._base64ToBlob(base64Data, 'image/png')
formData.append('file', blob, `image_${Date.now()}.png`)
} catch (error) {
console.error('Base64转换失败:', error)
formData.append('image', imageData)
}
}
formData.append('type', type)
// 构建上传请求
const uploadEndpoint = `${baseURL}/uploads/image`
return axios
.post(uploadEndpoint, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
// 添加上传进度回调
onUploadProgress: onProgress
? (progressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted, progressEvent)
}
: undefined,
})
.catch((error) => {
// 实现重试逻辑
if (currentRetry < retryCount) {
currentRetry++
console.log(`上传失败,第${currentRetry}次重试...`)
// 延迟后重试
return new Promise((resolve) => {
setTimeout(() => {
resolve(executeUpload())
}, retryDelay)
})
}
// 所有重试都失败,抛出错误
throw error
})
}
return executeUpload()
},
/**
* 上传视频到阿里云OSS
* @param {File} videoFile - 视频文件对象
* @param {string} type - 视频类型 (opening-video|ending-video)
* @param {Object} options - 上传选项
* @param {Function} options.onProgress - 上传进度回调函数
* @returns {Promise} - axios请求Promise
*/
uploadVideoToAliyunOSS(videoFile, type, options = {}) {
const { onProgress = null } = options
const formData = new FormData()
formData.append('file', videoFile)
formData.append('type', type)
formData.append('is_video', 'true')
const uploadEndpoint = `${baseURL}/uploads/video`
return axios.post(uploadEndpoint, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: onProgress
? (progressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted, progressEvent)
}
: undefined,
})
},
/**
* 将Base64数据转换为Blob对象
* @private
* @param {string} base64Data - Base64编码的数据
* @param {string} contentType - 内容类型
* @returns {Blob} - Blob对象
*/
_base64ToBlob(base64Data, contentType) {
const byteCharacters = atob(base64Data)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, { type: contentType })
},
/**
* 提交高级视频合成任务
* @param {Object} data 请求参数
* @returns {Promise} 请求结果
*/
submitAdvancedVideoTask(data) {
return axios.post(`${baseURL}/digital_human/video/advanced/submit`, data)
},
/**
* 查询高级视频合成任务状态
* @param {String} taskId 任务ID
* @returns {Promise} 请求结果
*/
queryAdvancedVideoTask(taskId) {
return axios.get(`${baseURL}/digital_human/video/advanced/query?taskId=${taskId}`)
},
/**
* 获取模板列表
* @returns {Array} 模板列表
*/
getTemplateList() {
return [
{ id: 't-pf4kqasspwzwyexyte121', name: '模板1', ratio: '9:16', isVertical: true },
{ id: 't-af4keqsspfzwyexyte123', name: '模板2', ratio: '9:16', isVertical: true },
{ id: 't-ad4eeqsspfzwyqxyte125', name: '模板3', ratio: '9:16', isVertical: true },
{ id: 't-cd4eeqsspfzwyqxyte127', name: '模板4', ratio: '9:16', isVertical: true },
{ id: 't-hb4eeqsspfzwyqxyte129', name: '模板5', ratio: '16:9', isVertical: false },
{ id: 't-cd4eeqsspfzwyqxyteditu3', name: '模板6', ratio: '9:16', isVertical: true },
{ id: 't-cd4eeqsspfzwyqxyteditu4', name: '模板7', ratio: '9:16', isVertical: true },
{ id: 't-cd4eeqsspfzwyqxyteditu5', name: '无数字人模板', ratio: '9:16', isVertical: true, noDigitalHuman: true },
]
},
/**
* 上传图片到存储服务 (兼容方法)
* @param {File|string} imageData - 图片文件对象或Base64编码的图片数据
* @param {string} type - 图片类型 (background|logo)
* @param {boolean} useBaiduCloud - 是否使用百度云存储,默认false使用阿里云OSS
* @returns {Promise} - axios请求Promise
*/
uploadImageToOSS(imageData, type, useBaiduCloud = false) {
console.log('uploadImageToOSS', imageData, type, useBaiduCloud)
// 统一使用阿里云OSS上传,忽略useBaiduCloud参数
console.log('使用阿里云OSS上传图片')
// 如果传入的是File对象,使用FormData
if (imageData instanceof File) {
return this.uploadImageToAliyunOSS(imageData, type, {})
}
// 否则按原来的方式处理
const formData = new FormData()
const blob = this._base64ToBlob(imageData.split(',')[1], 'image/png')
formData.append('file', blob, `image_${Date.now()}.png`)
formData.append('type', type)
const uploadEndpoint = `${baseURL}/uploads/image`
return axios.post(uploadEndpoint, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
},
/**
* 上传视频到存储服务 (兼容方法)
* @param {File} videoFile - 视频文件对象
* @param {string} type - 视频类型 (opening-video|ending-video)
* @param {boolean} useBaiduCloud - 是否使用百度云存储,默认false使用阿里云OSS
* @returns {Promise} - axios请求Promise
*/
uploadVideoToOSS(videoFile, type, useBaiduCloud = false) {
console.log('uploadVideoToOSS', videoFile, type, useBaiduCloud)
// 统一使用阿里云OSS上传,忽略useBaiduCloud参数
console.log('使用阿里云OSS上传视频')
return this.uploadVideoToAliyunOSS(videoFile, type, {})
},
/**
* 上传图片到百度云BOS
* @param {File|string} imageData - 图片文件对象或Base64编码的图片数据
* @param {string} type - 图片类型 (background|logo)
* @param {Object} options - 上传选项
* @returns {Promise} - 包含上传结果的Promise
*/
uploadImageToBaiduBOS(imageData, type, options = {}) {
console.log('使用百度云BOS PUT方式上传图片')
// 处理Base64图片数据
if (typeof imageData === 'string' && imageData.startsWith('data:')) {
// 转换Base64到Blob对象
const blob = this._base64ToBlob(imageData.split(',')[1], 'image/png')
const file = new File([blob], `image_${Date.now()}.png`, { type: 'image/png' })
// 使用直接上传到百度云BOS的方法,明确指定PUT方法
return uploadImageToBos(
file,
{
fileType: type,
method: 'PUT', // 明确指定使用PUT上传
},
options.onProgress
).then((url) => {
console.log('百度云BOS上传成功,文件URL:', url)
return {
data: {
success: true,
data: { url },
},
}
})
}
// 处理File对象,直接上传到百度云BOS
if (imageData instanceof File) {
return uploadImageToBos(
imageData,
{
fileType: type,
method: 'PUT', // 明确指定使用PUT上传
},
options.onProgress
).then((url) => {
console.log('百度云BOS上传成功,文件URL:', url)
return {
data: {
success: true,
data: { url },
},
}
})
}
// 如果不是File或Base64,返回错误
return Promise.reject(new Error('不支持的图片数据格式,请提供File对象或Base64编码的图片数据'))
},
/**
* 上传视频到百度云BOS (兼容方法,实际使用阿里云OSS)
* @param {File} videoFile - 视频文件对象
* @param {string} type - 视频类型 (opening-video|ending-video)
* @param {Object} options - 上传选项
* @returns {Promise} - axios请求Promise
*/
uploadVideoToBaiduBOS(videoFile, type, options = {}) {
console.warn('uploadVideoToBaiduBOS已废弃,请使用uploadVideoToAliyunOSS')
return this.uploadVideoToAliyunOSS(videoFile, type, options)
},
}
export default digitalHumanAPI
......@@ -50,6 +50,11 @@ export default defineConfig(() => ({
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/qianfan/, ''),
},
'/api/duan': {
target: 'https://saas-ai.ezijing.com',
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api\/duan/, ''),
},
// '/api/lab': {
// target: 'http://local-com-resource-api.frontend.ezijing.com',
// changeOrigin: true,
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论