refactor(frontend): 重构声纹管理页面UI 优化AI模型页面布局
- 重写声纹注册页面的整体UI与代码逻辑,拆分我的声纹、他人声纹视图,优化录音上传与管理的交互体验 - 调整AI模型页面的样式结构,移除冗余样式并新增内容容器样式,修复代码缩进问题 - 统一前端页面的代码规范与样式命名规则,提升项目可维护性dev_na
parent
76410071e7
commit
ec8bb466da
|
|
@ -12,12 +12,7 @@
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
.ai-models-page .section-card__tabs {
|
||||
margin-bottom: 0;
|
||||
padding: 8px 8px 0;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background-color: #f9fafe;
|
||||
}
|
||||
|
||||
|
||||
.ai-models-page .section-card__content {
|
||||
border-radius: 0 0 4px 4px;
|
||||
|
|
@ -43,3 +38,7 @@
|
|||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-models-content-inner {
|
||||
padding: 0px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -557,6 +557,7 @@ const AiModels: React.FC = () => {
|
|||
/>
|
||||
}
|
||||
>
|
||||
<div className="tab-pane-content ai-models-content-inner">
|
||||
<DataListPanel
|
||||
className="ai-models-data-panel"
|
||||
leftActions={leftActions}
|
||||
|
|
@ -593,6 +594,7 @@ const AiModels: React.FC = () => {
|
|||
pagination={false}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer
|
||||
|
|
|
|||
|
|
@ -1,463 +1,639 @@
|
|||
.speaker-reg-page {
|
||||
/* Modern UI redesign for SpeakerReg based on mockup */
|
||||
.speaker-reg-page-v2 {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.speaker-reg-page > .page-container__body {
|
||||
.speaker-reg-page-v2 > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.speaker-reg-page-v2 .section-card__content {
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Tabs Container */
|
||||
.speaker-reg-section-content {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%; /* Important to pass height down from SectionCard */
|
||||
}
|
||||
|
||||
.speaker-reg-layout {
|
||||
.speaker-reg-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(430px, 0.92fr) minmax(480px, 1.08fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.speaker-reg-card.ant-card {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e6e6e6;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-pane-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.speaker-reg-content-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
background-color: var(--app-surface-color, #fff);
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.speaker-reg-card > .ant-card-head {
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
.speaker-reg-content-inner-others {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.speaker-reg-card > .ant-card-head .ant-card-head-title {
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
.speaker-reg-content-inner-others .data-list-panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* --- My Voiceprint View --- */
|
||||
.my-voice-view {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.my-voice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.my-voice-header h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.my-voice-header h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.speaker-reg-card > .ant-card-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
.my-voice-header p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.my-voice-card {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.my-voice-card-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.speaker-reg-card-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #b7cdfd;
|
||||
background: #f3f6ff;
|
||||
color: #3c70f5;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.speaker-reg-form .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
.my-avatar {
|
||||
background: #1677ff;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.speaker-reg-tabs {
|
||||
flex-shrink: 0;
|
||||
.my-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.speaker-reg-tabs .ant-tabs-nav {
|
||||
.my-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.recording-area {
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e6e6;
|
||||
.my-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.my-tag {
|
||||
font-weight: normal;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.script-box {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-left: 4px solid #3c70f5;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.script-box__label.ant-typography {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #9095a1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.script-box__content {
|
||||
color: #333;
|
||||
.my-desc {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.record-controls {
|
||||
.my-audio-wrapper {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.my-audio-wrapper .custom-audio {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.my-meta-grid {
|
||||
display: flex;
|
||||
gap: 48px;
|
||||
margin-bottom: 32px;
|
||||
padding: 16px 24px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.meta-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #8c8c8c;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: #1f1f1f;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.my-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-btn-primary {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-btn-danger {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tips-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: #f0f5ff;
|
||||
border-radius: 8px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.btn-record {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
.tips-icon {
|
||||
font-size: 20px;
|
||||
color: #1677ff;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tips-content h4 {
|
||||
margin: 0 0 6px 0;
|
||||
color: #1f1f1f;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.tips-content p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
/* --- Recording Area --- */
|
||||
.modern-recording-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.back-nav {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-record .anticon {
|
||||
.back-btn {
|
||||
color: #1f1f1f;
|
||||
padding: 6px 12px;
|
||||
margin-left: -12px;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.back-btn .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-record.idle {
|
||||
background: #ff4d4f;
|
||||
.back-btn:hover {
|
||||
color: #1677ff !important;
|
||||
background: rgba(22, 119, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
.btn-record.idle:hover {
|
||||
background: #d9363e;
|
||||
}
|
||||
|
||||
.btn-record.recording {
|
||||
background: #3c70f5;
|
||||
}
|
||||
|
||||
.btn-record.recording:hover {
|
||||
background: #2458d9;
|
||||
}
|
||||
|
||||
.btn-record:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.record-progress {
|
||||
flex: 1;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-progress__head {
|
||||
.input-type-switch {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.record-progress__head .is-recording {
|
||||
color: #3c70f5;
|
||||
/* Customizing Segmented Control to make it less bulky */
|
||||
.input-type-switch .ant-segmented {
|
||||
background-color: #f0f2f5;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.input-type-switch .ant-segmented-item-label {
|
||||
padding: 0 24px !important;
|
||||
min-height: 36px;
|
||||
line-height: 36px;
|
||||
font-weight: 500;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.input-type-switch .ant-segmented-item-selected .ant-segmented-item-label {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.modern-record-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.script-card {
|
||||
position: relative;
|
||||
background: #f8f9fa;
|
||||
padding: 16px 40px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quote-icon {
|
||||
position: absolute;
|
||||
font-size: 48px;
|
||||
color: #1677ff;
|
||||
opacity: 0.2;
|
||||
font-family: Georgia, serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.quote-icon.left {
|
||||
top: 16px;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.quote-icon.right {
|
||||
bottom: -16px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.script-card p {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
color: #1f1f1f;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.record-action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 48px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.mic-button-wrapper {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mic-ring {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: #e6f4ff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mic-ring.outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.mic-ring.inner {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: #bae0ff;
|
||||
}
|
||||
|
||||
.mic-button {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: #1677ff;
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.3);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.mic-button:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mic-button-wrapper.recording .mic-ring.outer {
|
||||
animation: pulse-outer 1.5s infinite;
|
||||
}
|
||||
|
||||
.mic-button-wrapper.recording .mic-ring.inner {
|
||||
animation: pulse-inner 1.5s infinite;
|
||||
}
|
||||
|
||||
.mic-button-wrapper.recording .mic-button {
|
||||
background: #ffffff;
|
||||
border: 2px solid #f0f0f0;
|
||||
color: #ff4d4f;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.stop-square {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #ff4d4f;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes pulse-outer {
|
||||
0% { transform: scale(1); opacity: 0.8; }
|
||||
100% { transform: scale(1.3); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes pulse-inner {
|
||||
0% { transform: scale(1); opacity: 0.8; }
|
||||
100% { transform: scale(1.2); opacity: 0; }
|
||||
}
|
||||
|
||||
.record-status {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.record-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #1f1f1f;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.record-progress-bar {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
margin-top: 24px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modern-upload-area {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.speaker-reg-upload,
|
||||
.speaker-reg-upload .ant-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-compact {
|
||||
.upload-box {
|
||||
width: 100%;
|
||||
min-height: 104px;
|
||||
height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed #b7cdfd;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upload-compact:hover {
|
||||
border-color: #3c70f5;
|
||||
background: #f9fafe;
|
||||
.upload-box:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.upload-compact__icon {
|
||||
margin-bottom: 8px;
|
||||
color: #3c70f5;
|
||||
font-size: 24px;
|
||||
.upload-icon {
|
||||
font-size: 40px;
|
||||
color: #1677ff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upload-compact__title {
|
||||
margin-bottom: 4px;
|
||||
.upload-box h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.upload-box p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.speaker-reg-audio-ready {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #b7cdfd;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
.speaker-reg-audio-ready__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.speaker-reg-audio-ready__head .anticon {
|
||||
color: #52c41a;
|
||||
.success-text {
|
||||
color: #389e0d;
|
||||
}
|
||||
|
||||
.speaker-reg-audio-ready audio,
|
||||
.speaker-card audio {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.speaker-reg-submit-area {
|
||||
/* --- Others Voiceprint View --- */
|
||||
.others-voice-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.speaker-reg-submit-area .ant-btn {
|
||||
height: 34px;
|
||||
border-radius: 4px;
|
||||
.item-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.item-avatar {
|
||||
background: #e6f4ff;
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-strip {
|
||||
.item-name-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e6e6e6;
|
||||
background: #f9fafe;
|
||||
color: #596275;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-strip .anticon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
color: #3c70f5;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.speaker-reg-library > .ant-card-body {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.speaker-reg-library__tools .ant-input-search {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.speaker-reg-library__tools .ant-badge-count {
|
||||
background: #3c70f5;
|
||||
}
|
||||
|
||||
.speaker-reg-library__hint.ant-typography {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 8px;
|
||||
color: #9095a1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.speaker-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.speaker-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.9fr) minmax(240px, 1.1fr) minmax(150px, 0.7fr);
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.speaker-card + .speaker-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.speaker-card:hover {
|
||||
border-color: #f0f0f0;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.speaker-card__head {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.speaker-card__identity {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.speaker-card__identity > .ant-typography {
|
||||
display: block;
|
||||
.item-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.speaker-card__meta {
|
||||
.item-id {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.speaker-card__meta .ant-tag {
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.speaker-card__meta .ant-typography {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.speaker-card__remark {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
margin-bottom: 0;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #596275;
|
||||
.time-label {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.speaker-card > audio {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
.time-val {
|
||||
font-size: 14px;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.speaker-card--no-remark > audio {
|
||||
grid-row: 1 / span 2;
|
||||
|
||||
|
||||
.action-text-btn {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.speaker-card__footer {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
.action-text-btn:hover {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.hidden-audio {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.others-drawer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 0;
|
||||
color: #9095a1;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.speaker-reg-empty {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.speaker-reg-library .app-pagination-container {
|
||||
flex-shrink: 0;
|
||||
.drawer-record-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.speaker-reg-section-content {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.speaker-reg-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.speaker-reg-card.ant-card {
|
||||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.speaker-reg-card > .ant-card-head {
|
||||
align-items: flex-start;
|
||||
.speaker-reg-page-v2 {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.speaker-reg-card > .ant-card-head .ant-card-head-wrapper {
|
||||
|
||||
|
||||
.tab-pane-content {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.my-meta-grid {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.speaker-reg-card > .ant-card-head .ant-card-extra,
|
||||
.speaker-reg-library__tools,
|
||||
.speaker-reg-library__tools .ant-input-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.record-controls {
|
||||
align-items: stretch;
|
||||
.my-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-progress {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.speaker-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.speaker-card__head,
|
||||
.speaker-card__remark,
|
||||
.speaker-card > audio,
|
||||
.speaker-card__footer,
|
||||
.speaker-card--no-remark > audio {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.speaker-card__footer {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, {useEffect, useMemo, useRef, useState} from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AudioOutlined,
|
||||
CheckCircleOutlined,
|
||||
|
|
@ -6,36 +6,39 @@ import {
|
|||
DeleteOutlined,
|
||||
FormOutlined,
|
||||
SearchOutlined,
|
||||
SoundOutlined,
|
||||
StopOutlined,
|
||||
SyncOutlined,
|
||||
UserOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
ClockCircleOutlined,
|
||||
ArrowLeftOutlined,
|
||||
BulbOutlined,
|
||||
PlusOutlined,
|
||||
PlayCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
App,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tabs,
|
||||
Segmented,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Upload,
|
||||
Tabs,
|
||||
Alert,
|
||||
} from "antd";
|
||||
import type {UploadProps} from "antd";
|
||||
import type { UploadProps } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import {listUsers} from "../../api";
|
||||
import { listUsers } from "../../api";
|
||||
import {
|
||||
deleteSpeaker,
|
||||
getSpeakerPage,
|
||||
|
|
@ -46,26 +49,29 @@ import {
|
|||
import AppPagination from "../../components/shared/AppPagination";
|
||||
import PageContainer from "../../components/shared/PageContainer";
|
||||
import SectionCard from "../../components/shared/SectionCard";
|
||||
import {useAuth} from "../../hooks/useAuth";
|
||||
import type {SysUser} from "../../types";
|
||||
import FormDrawer from "../../components/shared/FormDrawer";
|
||||
import DataListPanel from "../../components/shared/DataListPanel";
|
||||
import ListTable from "../../components/shared/ListTable/ListTable";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import type { SysUser } from "../../types";
|
||||
import "./SpeakerReg.css";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
const REG_CONTENT =
|
||||
"iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。";
|
||||
"智听云智能会议系统,正在采集我的专属声纹,用于在会议中识别我的发言身份,提升会议记录的准确性。";
|
||||
const DEFAULT_DURATION = 15;
|
||||
const DEFAULT_PAGE_SIZE = 8;
|
||||
const AUDIO_EXT_PATTERN = /\.(mp3|wav|m4a|aac|ogg|flac|webm)$/i;
|
||||
|
||||
const SPEAKER_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
PENDING: {label: "未同步", color: "default"},
|
||||
SYNCING: {label: "同步中", color: "processing"},
|
||||
SYNCED: {label: "已注册", color: "success"},
|
||||
FAILED: {label: "同步失败", color: "error"},
|
||||
STALE: {label: "待重新同步", color: "warning"},
|
||||
DELETED: {label: "待重新同步", color: "warning"},
|
||||
const SPEAKER_STATUS_META: Record<string, { label: string; color: string; bgColor: string; borderColor: string }> = {
|
||||
PENDING: { label: "未同步", color: "#595959", bgColor: "#f5f5f5", borderColor: "#d9d9d9" },
|
||||
SYNCING: { label: "同步中", color: "#1677ff", bgColor: "#e6f4ff", borderColor: "#91caff" },
|
||||
SYNCED: { label: "已注册", color: "#52c41a", bgColor: "#f6ffed", borderColor: "#b7eb8f" },
|
||||
FAILED: { label: "同步失败", color: "#ff4d4f", bgColor: "#fff2f0", borderColor: "#ffa39e" },
|
||||
STALE: { label: "待重新同步", color: "#faad14", bgColor: "#fffbe6", borderColor: "#ffe58f" },
|
||||
DELETED: { label: "待重新同步", color: "#faad14", bgColor: "#fffbe6", borderColor: "#ffe58f" },
|
||||
};
|
||||
|
||||
const getSpeakerStatusMeta = (speaker: SpeakerVO) => {
|
||||
|
|
@ -93,26 +99,42 @@ const getErrorMessage = (err: unknown, fallback: string) => {
|
|||
const SpeakerReg: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Layout State
|
||||
const [activeTab, setActiveTab] = useState<string>("my");
|
||||
const [myViewMode, setMyViewMode] = useState<"view" | "record">("view");
|
||||
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||
const [inputType, setInputType] = useState<"record" | "upload">("record");
|
||||
|
||||
// Data State
|
||||
const [mySpeaker, setMySpeaker] = useState<SpeakerVO | null>(null);
|
||||
const [speakers, setSpeakers] = useState<SpeakerVO[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
|
||||
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
||||
|
||||
// List State
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [queryName, setQueryName] = useState("");
|
||||
const [current, setCurrent] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncingSpeakerId, setSyncingSpeakerId] = useState<number | null>(null);
|
||||
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
|
||||
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
||||
|
||||
// Recording State
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
// Refs
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const autoStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const { profile } = useAuth();
|
||||
const isAdmin = !!(profile?.isAdmin || profile?.isPlatformAdmin);
|
||||
|
||||
|
|
@ -132,6 +154,7 @@ const SpeakerReg: React.FC = () => {
|
|||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void fetchUsers();
|
||||
void fetchMySpeaker();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
stopTimer();
|
||||
|
|
@ -143,34 +166,39 @@ const SpeakerReg: React.FC = () => {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl) {
|
||||
return;
|
||||
}
|
||||
return () => {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
if (!audioUrl) return;
|
||||
return () => URL.revokeObjectURL(audioUrl);
|
||||
}, [audioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "others") {
|
||||
void fetchSpeakers(current, pageSize, queryName);
|
||||
}, [current, pageSize, queryName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile?.userId || isAdmin) {
|
||||
return;
|
||||
}
|
||||
form.setFieldValue("userId", profile.userId);
|
||||
form.setFieldValue("name", profile.displayName);
|
||||
}, [form, isAdmin, profile?.displayName, profile?.userId]);
|
||||
}, [current, pageSize, queryName, activeTab]);
|
||||
|
||||
const fetchMySpeaker = async () => {
|
||||
if (!profile?.userId) return;
|
||||
try {
|
||||
// Query specifically for the current user's name to find their record
|
||||
const res = await getSpeakerPage({ current: 1, size: 100, name: profile.displayName || profile.username });
|
||||
const payload: any = res.data || res;
|
||||
const records = payload?.data?.records || payload?.records || [];
|
||||
const mine = records.find((r: SpeakerVO) => r.userId === profile.userId);
|
||||
setMySpeaker(mine || null);
|
||||
if (!mine) {
|
||||
setMyViewMode("record");
|
||||
} else {
|
||||
setMyViewMode("view");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch my speaker", err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
const res = await getSpeakerPage({
|
||||
current: page,
|
||||
size,
|
||||
name: name || undefined,
|
||||
});
|
||||
const res = await getSpeakerPage({ current: page, size, name: name || undefined });
|
||||
const payload: any = res.data || res;
|
||||
const records = payload?.data?.records || payload?.records || [];
|
||||
const nextTotal = payload?.data?.total || payload?.total || 0;
|
||||
|
|
@ -197,7 +225,6 @@ const SpeakerReg: React.FC = () => {
|
|||
} catch (err) {
|
||||
console.error(err);
|
||||
setUserOptions([]);
|
||||
message.error("加载用户列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -210,10 +237,6 @@ const SpeakerReg: React.FC = () => {
|
|||
const resetFormState = () => {
|
||||
setEditingSpeaker(null);
|
||||
form.resetFields(["id", "name", "userId", "remark"]);
|
||||
if (!isAdmin && profile?.userId) {
|
||||
form.setFieldValue("userId", profile.userId);
|
||||
form.setFieldValue("name", profile.displayName);
|
||||
}
|
||||
resetAudioState();
|
||||
};
|
||||
|
||||
|
|
@ -241,10 +264,7 @@ const SpeakerReg: React.FC = () => {
|
|||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
if (loading) {
|
||||
message.warning("声纹正在提交,请稍后再录制");
|
||||
return;
|
||||
}
|
||||
if (loading) return;
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
||||
message.error("当前浏览器不支持录音,请使用音频文件上传");
|
||||
return;
|
||||
|
|
@ -256,16 +276,12 @@ const SpeakerReg: React.FC = () => {
|
|||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
if (event.data.size > 0) audioChunksRef.current.push(event.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob(audioChunksRef.current, {type: "audio/wav"});
|
||||
if (!mountedRef.current) return;
|
||||
const blob = new Blob(audioChunksRef.current, { type: "audio/wav" });
|
||||
resetAudioState();
|
||||
setAudioBlob(blob);
|
||||
setAudioUrl(URL.createObjectURL(blob));
|
||||
|
|
@ -293,14 +309,7 @@ const SpeakerReg: React.FC = () => {
|
|||
|
||||
const uploadProps: UploadProps = {
|
||||
beforeUpload: (file) => {
|
||||
if (recording) {
|
||||
message.warning("请先停止录音,再上传音频文件");
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (loading) {
|
||||
message.warning("声纹正在提交,请稍后再上传");
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (recording || loading) return Upload.LIST_IGNORE;
|
||||
if (!isAudioFile(file)) {
|
||||
message.error("只能上传音频文件");
|
||||
return Upload.LIST_IGNORE;
|
||||
|
|
@ -314,32 +323,43 @@ const SpeakerReg: React.FC = () => {
|
|||
showUploadList: false,
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!audioBlob && !editingSpeaker) {
|
||||
const handleSubmit = async (isMyVoice: boolean) => {
|
||||
if (!audioBlob && !editingSpeaker && !isMyVoice) {
|
||||
message.warning("请先录制或上传声纹文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
let values = { name: "", userId: undefined as number | undefined, remark: "" };
|
||||
|
||||
if (isMyVoice) {
|
||||
values.name = profile?.displayName || profile?.username || "未知用户";
|
||||
values.userId = profile?.userId;
|
||||
} else {
|
||||
values = await form.validateFields();
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
await registerSpeaker({
|
||||
id: editingSpeaker?.id,
|
||||
id: isMyVoice ? mySpeaker?.id : editingSpeaker?.id,
|
||||
name: values.name.trim(),
|
||||
userId: values.userId ? Number(values.userId) : undefined,
|
||||
remark: values.remark?.trim(),
|
||||
file: audioBlob || undefined,
|
||||
});
|
||||
message.success(editingSpeaker ? "声纹保存成功,等待同步" : "声纹保存成功,等待同步");
|
||||
|
||||
message.success("声纹保存成功");
|
||||
resetFormState();
|
||||
if (current !== 1) {
|
||||
setCurrent(1);
|
||||
|
||||
if (isMyVoice) {
|
||||
setMyViewMode("view");
|
||||
void fetchMySpeaker();
|
||||
} else {
|
||||
void fetchSpeakers(1, pageSize, queryName);
|
||||
setIsAddModalVisible(false);
|
||||
if (current !== 1) setCurrent(1);
|
||||
else void fetchSpeakers(1, pageSize, queryName);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as { errorFields?: unknown }).errorFields) {
|
||||
return;
|
||||
}
|
||||
if ((err as { errorFields?: unknown }).errorFields) return;
|
||||
console.error(err);
|
||||
message.error(getErrorMessage(err, "声纹保存失败"));
|
||||
} finally {
|
||||
|
|
@ -347,17 +367,16 @@ const SpeakerReg: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (speaker: SpeakerVO) => {
|
||||
const handleDelete = async (speaker: SpeakerVO, isMyVoice: boolean) => {
|
||||
try {
|
||||
await deleteSpeaker(speaker.id);
|
||||
message.success("声纹已删除");
|
||||
if (editingSpeaker?.id === speaker.id) {
|
||||
resetFormState();
|
||||
}
|
||||
if (speakers.length === 1 && current > 1) {
|
||||
setCurrent(current - 1);
|
||||
if (isMyVoice) {
|
||||
setMySpeaker(null);
|
||||
setMyViewMode("record");
|
||||
} else {
|
||||
void fetchSpeakers(current, pageSize, queryName);
|
||||
if (speakers.length === 1 && current > 1) setCurrent(current - 1);
|
||||
else void fetchSpeakers(current, pageSize, queryName);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
|
@ -365,12 +384,13 @@ const SpeakerReg: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSync = async (speaker: SpeakerVO) => {
|
||||
const handleSync = async (speaker: SpeakerVO, isMyVoice: boolean) => {
|
||||
setSyncingSpeakerId(speaker.id);
|
||||
try {
|
||||
await syncSpeaker(speaker.id);
|
||||
message.success("已提交声纹同步任务");
|
||||
void fetchSpeakers(current, pageSize, queryName);
|
||||
if (isMyVoice) void fetchMySpeaker();
|
||||
else void fetchSpeakers(current, pageSize, queryName);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error(getErrorMessage(err, "同步声纹失败"));
|
||||
|
|
@ -379,7 +399,13 @@ const SpeakerReg: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleEdit = (speaker: SpeakerVO) => {
|
||||
const openModalForAdd = () => {
|
||||
resetFormState();
|
||||
setInputType("record");
|
||||
setIsAddModalVisible(true);
|
||||
};
|
||||
|
||||
const openModalForEdit = (speaker: SpeakerVO) => {
|
||||
setEditingSpeaker(speaker);
|
||||
form.setFieldsValue({
|
||||
id: speaker.id,
|
||||
|
|
@ -388,174 +414,269 @@ const SpeakerReg: React.FC = () => {
|
|||
remark: speaker.remark,
|
||||
});
|
||||
resetAudioState();
|
||||
setInputType("record");
|
||||
setIsAddModalVisible(true);
|
||||
};
|
||||
|
||||
const handleUserChange = (userId?: number) => {
|
||||
const selectedUser = userOptions.find((user) => user.userId === userId);
|
||||
if (selectedUser) {
|
||||
form.setFieldValue("name", selectedUser.displayName || selectedUser.username);
|
||||
}
|
||||
};
|
||||
// UI Renderers
|
||||
const renderRecordingArea = (isMyVoice: boolean) => (
|
||||
<div className="modern-recording-container">
|
||||
{/* Only show back button if the user ALREADY has a voiceprint and is just updating it */}
|
||||
{(isMyVoice ? mySpeaker : editingSpeaker) && (
|
||||
<div className="back-nav">
|
||||
<Button type="text" className="back-btn" icon={<ArrowLeftOutlined />} onClick={() => {
|
||||
if (isMyVoice) setMyViewMode("view");
|
||||
else setIsAddModalVisible(false);
|
||||
}}>
|
||||
返回{isMyVoice ? '我的声纹' : '列表'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
const handleSearch = (value?: string) => {
|
||||
const keyword = (value ?? searchKeyword).trim();
|
||||
setCurrent(1);
|
||||
setQueryName(keyword);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title={null} className="speaker-reg-page">
|
||||
<SectionCard
|
||||
title="声纹采集工作台"
|
||||
description="采集或上传声纹样本,并维护当前租户下的发言人声纹库。状态以当前启用 ASR 的同步结果为准。"
|
||||
extra={<Badge status="processing" text={<Text type="secondary">声纹引擎就绪</Text>} />}
|
||||
contentClassName="speaker-reg-section-content"
|
||||
>
|
||||
<div className="speaker-reg-layout">
|
||||
<Card
|
||||
className="speaker-reg-card speaker-reg-editor"
|
||||
title={
|
||||
<Space size={10}>
|
||||
<span className="speaker-reg-card-icon">
|
||||
<FormOutlined />
|
||||
</span>
|
||||
<span>{editingSpeaker ? "更新声纹档案" : "新建声纹档案"}</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="speaker-reg-form">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={14}>
|
||||
<Form.Item name="name" label={<Text strong>声纹名称</Text>}
|
||||
rules={[{required: true, message: "必填"}]}>
|
||||
<Input size="middle" placeholder="姓名 / 职位 / 编号" />
|
||||
{!isMyVoice && (
|
||||
<Form form={form} layout="vertical" className="speaker-modal-form">
|
||||
<Form.Item name="name" label="声纹名称" rules={[{ required: true, message: "请输入名称" }]}>
|
||||
<Input size="large" placeholder="姓名 / 职位 / 编号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={10}>
|
||||
<Form.Item name="userId" label={<Text strong>绑定用户</Text>}>
|
||||
<Form.Item name="userId" label="绑定系统用户 (可选)">
|
||||
<Select
|
||||
size="middle"
|
||||
placeholder="系统关联"
|
||||
disabled={!isAdmin}
|
||||
onChange={handleUserChange}
|
||||
size="large"
|
||||
placeholder="选择系统关联用户"
|
||||
allowClear
|
||||
options={userOptions.map((user) => ({
|
||||
label: user.displayName || user.username,
|
||||
value: user.userId,
|
||||
}))}
|
||||
onChange={(val) => {
|
||||
const u = userOptions.find(user => user.userId === val);
|
||||
if (u) form.setFieldValue("name", u.displayName || u.username);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="remark" label={<Text strong>备注(可选)</Text>}>
|
||||
<Input size="middle" placeholder="记录使用场景或特征说明" />
|
||||
<Form.Item name="remark" label="备注 (可选)">
|
||||
<Input size="large" placeholder="记录使用场景或特征说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="record"
|
||||
className="speaker-reg-tabs"
|
||||
size="middle"
|
||||
items={[
|
||||
{
|
||||
key: "record",
|
||||
label: <span><AudioOutlined /> 实时录制采集</span>,
|
||||
children: (
|
||||
<div className="recording-area">
|
||||
<div className="script-box">
|
||||
<Text strong className="script-box__label">录音文本内容:</Text>
|
||||
<div className="script-box__content">{REG_CONTENT}</div>
|
||||
<div className="input-type-switch">
|
||||
<Segmented
|
||||
block
|
||||
size="large"
|
||||
options={[
|
||||
{ label: "实时录制", value: "record", icon: <AudioOutlined /> },
|
||||
{ label: "离线上传", value: "upload", icon: <CloudUploadOutlined /> },
|
||||
]}
|
||||
value={inputType}
|
||||
onChange={(val) => setInputType(val as "record" | "upload")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="record-controls">
|
||||
<div className="input-content">
|
||||
{inputType === "record" ? (
|
||||
<div className="modern-record-area">
|
||||
|
||||
<div className="script-card">
|
||||
<span className="quote-icon left">“</span>
|
||||
<p>{REG_CONTENT}</p>
|
||||
<span className="quote-icon right">”</span>
|
||||
</div>
|
||||
|
||||
<div className="record-action">
|
||||
<div className={`mic-button-wrapper ${recording ? "recording" : ""}`}>
|
||||
<div className="mic-ring outer" />
|
||||
<div className="mic-ring inner" />
|
||||
<button
|
||||
className={`btn-record ${recording ? "recording" : "idle"}`}
|
||||
className="mic-button"
|
||||
onClick={recording ? stopRecording : startRecording}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
aria-label={recording ? "停止录制声纹样本" : "开始录制声纹样本"}
|
||||
>
|
||||
{recording ? <StopOutlined /> : <AudioOutlined />}
|
||||
{recording ? <div className="stop-square" /> : <AudioOutlined />}
|
||||
</button>
|
||||
|
||||
<div className="record-progress">
|
||||
<div className="record-progress__head">
|
||||
<Text strong className={recording ? "is-recording" : ""}>
|
||||
{recording ? "正在采集声音..." : "等待录制"}
|
||||
</Text>
|
||||
<Text type="secondary">{seconds}s / {DEFAULT_DURATION}s</Text>
|
||||
</div>
|
||||
<Progress percent={(seconds / DEFAULT_DURATION) * 100} showInfo={false} strokeColor="#3c70f5"
|
||||
size="small"/>
|
||||
<div className="record-status">
|
||||
<div className="record-text">{recording ? "正在录制声音..." : "点击开始录制"}</div>
|
||||
<div className="record-time">{seconds}s / {DEFAULT_DURATION}s</div>
|
||||
</div>
|
||||
<Progress
|
||||
className="record-progress-bar"
|
||||
percent={(seconds / DEFAULT_DURATION) * 100}
|
||||
showInfo={false}
|
||||
strokeColor="#1677ff"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "upload",
|
||||
label: <span><CloudUploadOutlined /> 离线文件上传</span>,
|
||||
children: (
|
||||
) : (
|
||||
<div className="modern-upload-area">
|
||||
<Upload {...uploadProps} accept="audio/*" className="speaker-reg-upload">
|
||||
<div className="upload-compact">
|
||||
<CloudUploadOutlined className="upload-compact__icon" />
|
||||
<div className="upload-compact__title"><Text strong>点击此处或将音频文件拖入</Text></div>
|
||||
<Text type="secondary">支持 MP3 / WAV / M4A,建议时长 5-15 秒</Text>
|
||||
<div className="upload-box">
|
||||
<CloudUploadOutlined className="upload-icon" />
|
||||
<h4>点击或拖拽文件到此处</h4>
|
||||
<p>支持 MP3 / WAV / M4A,建议 10-15 秒</p>
|
||||
</div>
|
||||
</Upload>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{audioUrl && (
|
||||
<div className="speaker-reg-audio-ready">
|
||||
<div className="speaker-reg-audio-ready__head">
|
||||
<Text strong><CheckCircleOutlined /> 采样文件已就绪</Text>
|
||||
<Button type="text" danger size="small" onClick={resetAudioState} icon={<DeleteOutlined/>}>
|
||||
<Text strong className="success-text"><CheckCircleOutlined /> 采样文件已就绪</Text>
|
||||
<Button type="text" danger size="small" onClick={resetAudioState} icon={<DeleteOutlined />}>
|
||||
重新采集
|
||||
</Button>
|
||||
</div>
|
||||
<audio src={audioUrl} controls />
|
||||
<audio src={audioUrl} controls className="custom-audio" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="speaker-reg-submit-area">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={handleSubmit}
|
||||
onClick={() => handleSubmit(isMyVoice)}
|
||||
loading={loading}
|
||||
disabled={recording || (!audioBlob && !editingSpeaker)}
|
||||
disabled={recording || (!audioBlob && !editingSpeaker && !isMyVoice)}
|
||||
className="submit-btn"
|
||||
>
|
||||
{editingSpeaker ? "确认保存声纹变更" : "保存声纹"}
|
||||
提交更新
|
||||
</Button>
|
||||
{editingSpeaker && (
|
||||
<Button size="middle" block onClick={resetFormState}>
|
||||
取消编辑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className="info-strip">
|
||||
<SafetyCertificateOutlined />
|
||||
const renderMyVoiceprint = () => {
|
||||
if (myViewMode === "record" || !mySpeaker) {
|
||||
return renderRecordingArea(true);
|
||||
}
|
||||
|
||||
const statusMeta = getSpeakerStatusMeta(mySpeaker);
|
||||
return (
|
||||
<div className="my-voice-view">
|
||||
<div className="my-voice-header">
|
||||
<div>
|
||||
数据将加密存储,仅用于会议期间的发言人识别与角色分离。保存成功后如状态仍是“未同步”或“同步失败”,可在列表中点击“同步”按钮补同步。
|
||||
<h3>我的声纹</h3>
|
||||
<p>你的声纹用于在会议中识别你的发言身份</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="speaker-reg-card speaker-reg-library"
|
||||
title={
|
||||
<Space size={8}>
|
||||
<SoundOutlined />
|
||||
<span>声纹列表</span>
|
||||
<div className="my-voice-card">
|
||||
<div className="my-voice-card-top">
|
||||
<Avatar size={64} className="my-avatar">{profile?.displayName?.charAt(0) || "U"}</Avatar>
|
||||
<div className="my-info">
|
||||
<div className="my-name-row">
|
||||
<span className="my-name">{profile?.displayName || profile?.username}</span>
|
||||
<Tag color={statusMeta.color} bordered={false} className="my-tag">{statusMeta.label}</Tag>
|
||||
</div>
|
||||
<p className="my-desc">你的声纹已用于会议中的发言人识别</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-audio-wrapper">
|
||||
<audio src={buildResourceUrl(resourcePrefix, mySpeaker.voicePath)} controls className="custom-audio" controlsList="nodownload" />
|
||||
</div>
|
||||
|
||||
<div className="my-meta-grid">
|
||||
<div className="meta-col">
|
||||
<span className="meta-label"><ClockCircleOutlined /> 更新时间</span>
|
||||
<span className="meta-value">{dayjs(mySpeaker.updatedAt).format("YYYY-MM-DD HH:mm")}</span>
|
||||
</div>
|
||||
<div className="meta-col">
|
||||
<span className="meta-label"><AudioOutlined /> 音频大小</span>
|
||||
<span className="meta-value">{((mySpeaker.voiceSize || 0) / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-actions">
|
||||
<Button type="primary" size="large" className="action-btn-primary" onClick={() => setMyViewMode("record")} icon={<SyncOutlined />}>
|
||||
更新声纹
|
||||
</Button>
|
||||
<Popconfirm title="确定删除声纹?会议将无法识别您的发言" onConfirm={() => handleDelete(mySpeaker, true)}>
|
||||
<Button danger size="large" className="action-btn-danger" icon={<DeleteOutlined />}>
|
||||
删除声纹
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tips-box">
|
||||
<BulbOutlined className="tips-icon" />
|
||||
<div className="tips-content">
|
||||
<h4>温馨提示</h4>
|
||||
<p>定期更新声纹可以帮助系统更好地识别你的发言,建议每 3-6 个月更新一次。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOthersVoiceprint = () => {
|
||||
const filteredSpeakers = speakers.filter(s => s.userId !== profile?.userId);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "用户",
|
||||
key: "user",
|
||||
width: 260,
|
||||
render: (_: unknown, record: SpeakerVO) => (
|
||||
<div className="item-user">
|
||||
<Avatar className="item-avatar" size={40}>{record.name.charAt(0)}</Avatar>
|
||||
<div className="item-name-box">
|
||||
<span className="item-name">{record.name}</span>
|
||||
{record.userId && <span className="item-id">{record.userId}</span>}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (_: unknown, record: SpeakerVO) => {
|
||||
const statusMeta = getSpeakerStatusMeta(record);
|
||||
return <Tag color={statusMeta.color} bordered={false}>{statusMeta.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
key: "updatedAt",
|
||||
width: 180,
|
||||
render: (_: unknown, record: SpeakerVO) => (
|
||||
<span className="time-val">{dayjs(record.updatedAt).format("YYYY-MM-DD HH:mm")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 240,
|
||||
render: (_: unknown, record: SpeakerVO) => (
|
||||
<div className="item-actions" style={{ justifyContent: "flex-start", gap: 12 }}>
|
||||
<audio src={buildResourceUrl(resourcePrefix, record.voicePath)} className="hidden-audio" id={`audio-${record.id}`} />
|
||||
<Button type="text" icon={<PlayCircleOutlined />} className="action-text-btn play-btn" onClick={() => {
|
||||
const audio = document.getElementById(`audio-${record.id}`) as HTMLAudioElement;
|
||||
if (audio) { audio.paused ? audio.play() : audio.pause(); }
|
||||
}}>播放</Button>
|
||||
<Button type="text" icon={<SyncOutlined />} className="action-text-btn" onClick={() => openModalForEdit(record)}>更新</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record, false)}>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} className="action-text-btn">删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openModalForAdd}>
|
||||
添加未注册用户
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space wrap size={8} className="speaker-reg-library__tools">
|
||||
rightActions={
|
||||
<Search
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
|
|
@ -567,99 +688,118 @@ const SpeakerReg: React.FC = () => {
|
|||
setQueryName("");
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
placeholder="按名称搜索"
|
||||
prefix={<SearchOutlined />}
|
||||
onSearch={(val) => { setCurrent(1); setQueryName(val.trim()); }}
|
||||
placeholder="搜索姓名 / 手机号"
|
||||
className="search-input"
|
||||
style={{ width: 320 }}
|
||||
/>
|
||||
<Badge count={total} overflowCount={999} />
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Text type="secondary" className="speaker-reg-library__hint">
|
||||
“已注册”表示已经同步到当前启用的 ASR;仅本地保存未同步时,可手动点击“同步”。
|
||||
</Text>
|
||||
|
||||
<div className="speaker-list">
|
||||
<Spin spinning={listLoading}>
|
||||
{speakers.length === 0 ? (
|
||||
<Empty
|
||||
description={<Text type="secondary">{queryName ? "未找到匹配的声纹记录" : "暂无声纹记录"}</Text>}
|
||||
className="speaker-reg-empty"
|
||||
/>
|
||||
) : (
|
||||
speakers.map((speaker) => {
|
||||
const statusMeta = getSpeakerStatusMeta(speaker);
|
||||
const canSync = speaker.syncStatus !== "SYNCED" && syncingSpeakerId !== speaker.id;
|
||||
const statusTag = (
|
||||
<Tag color={statusMeta.color} bordered={false}>
|
||||
{statusMeta.label}
|
||||
</Tag>
|
||||
);
|
||||
return (
|
||||
<div className={speaker.remark ? "speaker-card" : "speaker-card speaker-card--no-remark"}
|
||||
key={speaker.id}>
|
||||
<div className="speaker-card__head">
|
||||
<div className="speaker-card__identity">
|
||||
<Text strong ellipsis>{speaker.name}</Text>
|
||||
<div className="speaker-card__meta">
|
||||
{speaker.syncErrorMessage ? (
|
||||
<Tooltip title={speaker.syncErrorMessage}>{statusTag}</Tooltip>
|
||||
) : statusTag}
|
||||
{speaker.userId && <Text type="secondary"><UserOutlined/> ID:{speaker.userId}</Text>}
|
||||
{speaker.syncStatus !== "SYNCED" && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<SyncOutlined/>}
|
||||
loading={syncingSpeakerId === speaker.id}
|
||||
onClick={() => void handleSync(speaker)}
|
||||
>
|
||||
同步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Space size={0}>
|
||||
<Tooltip title="编辑档案">
|
||||
<Button type="text" size="small" icon={<FormOutlined/>}
|
||||
onClick={() => handleEdit(speaker)}/>
|
||||
</Tooltip>
|
||||
<Popconfirm title="确定要删除此声纹记录吗?" onConfirm={() => handleDelete(speaker)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined/>}/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{speaker.remark && <div className="speaker-card__remark">{speaker.remark}</div>}
|
||||
|
||||
<audio
|
||||
src={buildResourceUrl(resourcePrefix, speaker.voicePath)}
|
||||
controls
|
||||
controlsList="nodownload"
|
||||
/>
|
||||
|
||||
<div className="speaker-card__footer">
|
||||
<span>更新于 {dayjs(speaker.updatedAt).format("YYYY-MM-DD HH:mm")}</span>
|
||||
<span>{((speaker.voiceSize || 0) / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
footer={
|
||||
<AppPagination
|
||||
variant="card"
|
||||
current={current}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={(page, size) => {
|
||||
setCurrent(page);
|
||||
setPageSize(size);
|
||||
onChange={(page, size) => { setCurrent(page); setPageSize(size); }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={filteredSpeakers}
|
||||
loading={listLoading}
|
||||
scroll={{ x: 800, y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<FormDrawer
|
||||
open={isAddModalVisible}
|
||||
onClose={() => setIsAddModalVisible(false)}
|
||||
title={editingSpeaker ? "更新声纹" : "添加声纹"}
|
||||
size="md"
|
||||
hideFooter
|
||||
bodyDensity="default"
|
||||
>
|
||||
<div className="others-drawer-content">
|
||||
{editingSpeaker ? (
|
||||
<Form form={form} layout="vertical" initialValues={{ name: editingSpeaker.name, remark: editingSpeaker.remark }}>
|
||||
<Form.Item label="姓名" name="name" rules={[{ required: true, message: "请输入姓名" }]}>
|
||||
<Input placeholder="请输入姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item label="备注 (可选)" name="remark">
|
||||
<Input.TextArea placeholder="请输入备注" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : (
|
||||
<Form form={form} layout="vertical" initialValues={{ userId: undefined }}>
|
||||
<Form.Item label="选择系统用户" name="userId" rules={[{ required: true, message: "请选择用户" }]}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="搜索姓名或手机号"
|
||||
optionFilterProp="children"
|
||||
options={userOptions.map((u) => ({
|
||||
label: `${u.displayName || u.username} ${u.phone ? `(${u.phone})` : ""}`,
|
||||
value: u.userId,
|
||||
}))}
|
||||
onChange={(val) => {
|
||||
const user = userOptions.find((u) => u.userId === val);
|
||||
if (user) {
|
||||
form.setFieldValue("name", user.displayName || user.username);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="drawer-record-area">
|
||||
{renderRecordingArea(false)}
|
||||
</div>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
</DataListPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const sectionTabs = useMemo(() => {
|
||||
const tabs = [{ key: "my", label: "我的声纹" }];
|
||||
if (isAdmin) {
|
||||
tabs.push({ key: "others", label: "他人声纹" });
|
||||
}
|
||||
return tabs;
|
||||
}, [isAdmin]);
|
||||
|
||||
return (
|
||||
<PageContainer title={null} className="speaker-reg-page-v2">
|
||||
<SectionCard
|
||||
title="声纹采集中心"
|
||||
description="用于会议中的发言人识别,提升会议记录准确性"
|
||||
contentClassName="speaker-reg-section-content"
|
||||
tabs={
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={sectionTabs}
|
||||
size="middle"
|
||||
type="card"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="speaker-reg-content">
|
||||
{activeTab === "my" ? (
|
||||
<div className="tab-pane-content">
|
||||
<div className="speaker-reg-content-inner">
|
||||
{renderMyVoiceprint()}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "others" && isAdmin ? (
|
||||
<div className="tab-pane-content speaker-reg-content-inner-others">
|
||||
{renderOthersVoiceprint()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
|
|
|
|||
Loading…
Reference in New Issue