refactor(frontend): 重构声纹管理页面UI 优化AI模型页面布局
- 重写声纹注册页面的整体UI与代码逻辑,拆分我的声纹、他人声纹视图,优化录音上传与管理的交互体验 - 调整AI模型页面的样式结构,移除冗余样式并新增内容容器样式,修复代码缩进问题 - 统一前端页面的代码规范与样式命名规则,提升项目可维护性dev_na
parent
76410071e7
commit
ec8bb466da
|
|
@ -12,12 +12,7 @@
|
||||||
background: transparent;
|
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 {
|
.ai-models-page .section-card__content {
|
||||||
border-radius: 0 0 4px 4px;
|
border-radius: 0 0 4px 4px;
|
||||||
|
|
@ -43,3 +38,7 @@
|
||||||
width: 100% !important;
|
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
|
<DataListPanel
|
||||||
className="ai-models-data-panel"
|
className="ai-models-data-panel"
|
||||||
leftActions={leftActions}
|
leftActions={leftActions}
|
||||||
|
|
@ -593,6 +594,7 @@ const AiModels: React.FC = () => {
|
||||||
pagination={false}
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
</DataListPanel>
|
</DataListPanel>
|
||||||
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|
|
||||||
|
|
@ -1,463 +1,639 @@
|
||||||
.speaker-reg-page {
|
/* Modern UI redesign for SpeakerReg based on mockup */
|
||||||
|
.speaker-reg-page-v2 {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
background: #f5f6fa;
|
background: #f5f6fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-page > .page-container__body {
|
.speaker-reg-page-v2 > .page-container__body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
|
||||||
background: transparent;
|
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 {
|
.speaker-reg-section-content {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%; /* Important to pass height down from SectionCard */
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-layout {
|
.speaker-reg-content {
|
||||||
flex: 1;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
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;
|
border-radius: 4px;
|
||||||
box-shadow: none;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-card > .ant-card-head {
|
.speaker-reg-content-inner-others {
|
||||||
min-height: 46px;
|
padding: 0;
|
||||||
padding: 0 14px;
|
background: transparent;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-card > .ant-card-head .ant-card-head-title {
|
.speaker-reg-content-inner-others .data-list-panel {
|
||||||
color: #333;
|
flex: 1;
|
||||||
font-size: 15px;
|
}
|
||||||
|
|
||||||
|
/* --- 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;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-card > .ant-card-body {
|
.my-voice-header p {
|
||||||
flex: 1;
|
margin: 0;
|
||||||
min-height: 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;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
padding: 12px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speaker-reg-card-icon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 20px;
|
||||||
border-radius: 4px;
|
margin-bottom: 24px;
|
||||||
border: 1px solid #b7cdfd;
|
|
||||||
background: #f3f6ff;
|
|
||||||
color: #3c70f5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-form .ant-form-item {
|
.my-avatar {
|
||||||
margin-bottom: 12px;
|
background: #1677ff;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-tabs {
|
.my-info {
|
||||||
flex-shrink: 0;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-tabs .ant-tabs-nav {
|
.my-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-area {
|
.my-name {
|
||||||
padding: 12px;
|
font-size: 20px;
|
||||||
border: 1px solid #e6e6e6;
|
font-weight: 600;
|
||||||
|
color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-tag {
|
||||||
|
font-weight: normal;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.script-box {
|
.my-desc {
|
||||||
margin-bottom: 10px;
|
margin: 0;
|
||||||
padding: 10px 12px;
|
color: #8c8c8c;
|
||||||
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;
|
|
||||||
font-size: 14px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
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;
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #f0f5ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #595959;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-record {
|
.tips-icon {
|
||||||
width: 40px;
|
font-size: 20px;
|
||||||
height: 32px;
|
color: #1677ff;
|
||||||
flex: 0 0 auto;
|
margin-top: 2px;
|
||||||
display: inline-flex;
|
}
|
||||||
|
|
||||||
|
.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;
|
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;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-record.idle {
|
.back-btn:hover {
|
||||||
background: #ff4d4f;
|
color: #1677ff !important;
|
||||||
|
background: rgba(22, 119, 255, 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-record.idle:hover {
|
.input-type-switch {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-progress__head .is-recording {
|
/* Customizing Segmented Control to make it less bulky */
|
||||||
color: #3c70f5;
|
.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 {
|
.speaker-reg-upload .ant-upload {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-compact {
|
.upload-box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 104px;
|
height: 240px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 18px 16px;
|
border: 1px dashed #d9d9d9;
|
||||||
border: 1px dashed #b7cdfd;
|
border-radius: 12px;
|
||||||
border-radius: 4px;
|
background: #fafafa;
|
||||||
background: #fff;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: center;
|
transition: all 0.3s;
|
||||||
transition: border-color 0.2s ease, background 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-compact:hover {
|
.upload-box:hover {
|
||||||
border-color: #3c70f5;
|
border-color: #1677ff;
|
||||||
background: #f9fafe;
|
background: #f0f5ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-compact__icon {
|
.upload-icon {
|
||||||
margin-bottom: 8px;
|
font-size: 40px;
|
||||||
color: #3c70f5;
|
color: #1677ff;
|
||||||
font-size: 24px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-compact__title {
|
.upload-box h4 {
|
||||||
margin-bottom: 4px;
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #8c8c8c;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-audio-ready {
|
.speaker-reg-audio-ready {
|
||||||
margin-top: 12px;
|
margin-top: 24px;
|
||||||
padding: 10px 12px;
|
padding: 16px;
|
||||||
border: 1px solid #b7cdfd;
|
border-radius: 8px;
|
||||||
border-radius: 4px;
|
background: #f6ffed;
|
||||||
background: #f9fafe;
|
border: 1px solid #b7eb8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-audio-ready__head {
|
.speaker-reg-audio-ready__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
margin-bottom: 12px;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-audio-ready__head .anticon {
|
.success-text {
|
||||||
color: #52c41a;
|
color: #389e0d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-audio-ready audio,
|
/* --- Others Voiceprint View --- */
|
||||||
.speaker-card audio {
|
.others-voice-view {
|
||||||
width: 100%;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speaker-reg-submit-area {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
height: 100%;
|
||||||
margin-top: 12px;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-submit-area .ant-btn {
|
.item-user {
|
||||||
height: 34px;
|
display: flex;
|
||||||
border-radius: 4px;
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-avatar {
|
||||||
|
background: #e6f4ff;
|
||||||
|
color: #1677ff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-strip {
|
.item-name-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e6e6e6;
|
|
||||||
background: #f9fafe;
|
|
||||||
color: #596275;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-strip .anticon {
|
.item-name {
|
||||||
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;
|
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f1f1f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-card__meta {
|
.item-id {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-time {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-card__meta .ant-tag {
|
.time-label {
|
||||||
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;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
color: #8c8c8c;
|
||||||
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-card > audio {
|
.time-val {
|
||||||
grid-column: 2;
|
font-size: 14px;
|
||||||
grid-row: 2;
|
color: #1f1f1f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-card--no-remark > audio {
|
|
||||||
grid-row: 1 / span 2;
|
|
||||||
|
.action-text-btn {
|
||||||
|
color: #8c8c8c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-card__footer {
|
.action-text-btn:hover {
|
||||||
grid-column: 3;
|
background: #f0f5ff;
|
||||||
grid-row: 1 / span 2;
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-audio {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.others-drawer-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
height: 100%;
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 0;
|
|
||||||
color: #9095a1;
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-empty {
|
.drawer-record-area {
|
||||||
margin-top: 40px;
|
flex: 1;
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
.speaker-reg-library .app-pagination-container {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 12px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.speaker-reg-card > .ant-card-head {
|
.speaker-reg-page-v2 {
|
||||||
align-items: flex-start;
|
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-card > .ant-card-head .ant-card-head-wrapper {
|
|
||||||
|
|
||||||
|
.tab-pane-content {
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-meta-grid {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-reg-card > .ant-card-head .ant-card-extra,
|
.my-actions {
|
||||||
.speaker-reg-library__tools,
|
|
||||||
.speaker-reg-library__tools .ant-input-search {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.record-controls {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
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;
|
.item-actions {
|
||||||
text-align: left;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,32 +6,35 @@ import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
FormOutlined,
|
FormOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
SoundOutlined,
|
|
||||||
StopOutlined,
|
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
SafetyCertificateOutlined,
|
ClockCircleOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
BulbOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
Empty,
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Progress,
|
Progress,
|
||||||
Row,
|
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Tabs,
|
Segmented,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
Upload,
|
Upload,
|
||||||
|
Tabs,
|
||||||
|
Alert,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import type { UploadProps } from "antd";
|
import type { UploadProps } from "antd";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
@ -46,6 +49,9 @@ import {
|
||||||
import AppPagination from "../../components/shared/AppPagination";
|
import AppPagination from "../../components/shared/AppPagination";
|
||||||
import PageContainer from "../../components/shared/PageContainer";
|
import PageContainer from "../../components/shared/PageContainer";
|
||||||
import SectionCard from "../../components/shared/SectionCard";
|
import SectionCard from "../../components/shared/SectionCard";
|
||||||
|
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 { useAuth } from "../../hooks/useAuth";
|
||||||
import type { SysUser } from "../../types";
|
import type { SysUser } from "../../types";
|
||||||
import "./SpeakerReg.css";
|
import "./SpeakerReg.css";
|
||||||
|
|
@ -54,18 +60,18 @@ const { Text } = Typography;
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
|
|
||||||
const REG_CONTENT =
|
const REG_CONTENT =
|
||||||
"iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。";
|
"智听云智能会议系统,正在采集我的专属声纹,用于在会议中识别我的发言身份,提升会议记录的准确性。";
|
||||||
const DEFAULT_DURATION = 15;
|
const DEFAULT_DURATION = 15;
|
||||||
const DEFAULT_PAGE_SIZE = 8;
|
const DEFAULT_PAGE_SIZE = 8;
|
||||||
const AUDIO_EXT_PATTERN = /\.(mp3|wav|m4a|aac|ogg|flac|webm)$/i;
|
const AUDIO_EXT_PATTERN = /\.(mp3|wav|m4a|aac|ogg|flac|webm)$/i;
|
||||||
|
|
||||||
const SPEAKER_STATUS_META: Record<string, { label: string; color: string }> = {
|
const SPEAKER_STATUS_META: Record<string, { label: string; color: string; bgColor: string; borderColor: string }> = {
|
||||||
PENDING: {label: "未同步", color: "default"},
|
PENDING: { label: "未同步", color: "#595959", bgColor: "#f5f5f5", borderColor: "#d9d9d9" },
|
||||||
SYNCING: {label: "同步中", color: "processing"},
|
SYNCING: { label: "同步中", color: "#1677ff", bgColor: "#e6f4ff", borderColor: "#91caff" },
|
||||||
SYNCED: {label: "已注册", color: "success"},
|
SYNCED: { label: "已注册", color: "#52c41a", bgColor: "#f6ffed", borderColor: "#b7eb8f" },
|
||||||
FAILED: {label: "同步失败", color: "error"},
|
FAILED: { label: "同步失败", color: "#ff4d4f", bgColor: "#fff2f0", borderColor: "#ffa39e" },
|
||||||
STALE: {label: "待重新同步", color: "warning"},
|
STALE: { label: "待重新同步", color: "#faad14", bgColor: "#fffbe6", borderColor: "#ffe58f" },
|
||||||
DELETED: {label: "待重新同步", color: "warning"},
|
DELETED: { label: "待重新同步", color: "#faad14", bgColor: "#fffbe6", borderColor: "#ffe58f" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSpeakerStatusMeta = (speaker: SpeakerVO) => {
|
const getSpeakerStatusMeta = (speaker: SpeakerVO) => {
|
||||||
|
|
@ -93,26 +99,42 @@ const getErrorMessage = (err: unknown, fallback: string) => {
|
||||||
const SpeakerReg: React.FC = () => {
|
const SpeakerReg: React.FC = () => {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [recording, setRecording] = useState(false);
|
|
||||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
// Layout State
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
const [activeTab, setActiveTab] = useState<string>("my");
|
||||||
const [loading, setLoading] = useState(false);
|
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 [speakers, setSpeakers] = useState<SpeakerVO[]>([]);
|
||||||
|
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
|
||||||
|
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
||||||
|
|
||||||
|
// List State
|
||||||
const [searchKeyword, setSearchKeyword] = useState("");
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
const [queryName, setQueryName] = useState("");
|
const [queryName, setQueryName] = useState("");
|
||||||
const [current, setCurrent] = useState(1);
|
const [current, setCurrent] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [listLoading, setListLoading] = useState(false);
|
const [listLoading, setListLoading] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [syncingSpeakerId, setSyncingSpeakerId] = useState<number | null>(null);
|
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);
|
const [seconds, setSeconds] = useState(0);
|
||||||
|
|
||||||
|
// Refs
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const autoStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const audioChunksRef = useRef<Blob[]>([]);
|
const audioChunksRef = useRef<Blob[]>([]);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
const { profile } = useAuth();
|
const { profile } = useAuth();
|
||||||
const isAdmin = !!(profile?.isAdmin || profile?.isPlatformAdmin);
|
const isAdmin = !!(profile?.isAdmin || profile?.isPlatformAdmin);
|
||||||
|
|
||||||
|
|
@ -132,6 +154,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
void fetchUsers();
|
void fetchUsers();
|
||||||
|
void fetchMySpeaker();
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
stopTimer();
|
stopTimer();
|
||||||
|
|
@ -143,34 +166,39 @@ const SpeakerReg: React.FC = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!audioUrl) {
|
if (!audioUrl) return;
|
||||||
return;
|
return () => URL.revokeObjectURL(audioUrl);
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
URL.revokeObjectURL(audioUrl);
|
|
||||||
};
|
|
||||||
}, [audioUrl]);
|
}, [audioUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeTab === "others") {
|
||||||
void fetchSpeakers(current, pageSize, queryName);
|
void fetchSpeakers(current, pageSize, queryName);
|
||||||
}, [current, pageSize, queryName]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!profile?.userId || isAdmin) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
form.setFieldValue("userId", profile.userId);
|
}, [current, pageSize, queryName, activeTab]);
|
||||||
form.setFieldValue("name", profile.displayName);
|
|
||||||
}, [form, isAdmin, profile?.displayName, profile?.userId]);
|
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) => {
|
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
|
||||||
setListLoading(true);
|
setListLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getSpeakerPage({
|
const res = await getSpeakerPage({ current: page, size, name: name || undefined });
|
||||||
current: page,
|
|
||||||
size,
|
|
||||||
name: name || undefined,
|
|
||||||
});
|
|
||||||
const payload: any = res.data || res;
|
const payload: any = res.data || res;
|
||||||
const records = payload?.data?.records || payload?.records || [];
|
const records = payload?.data?.records || payload?.records || [];
|
||||||
const nextTotal = payload?.data?.total || payload?.total || 0;
|
const nextTotal = payload?.data?.total || payload?.total || 0;
|
||||||
|
|
@ -197,7 +225,6 @@ const SpeakerReg: React.FC = () => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setUserOptions([]);
|
setUserOptions([]);
|
||||||
message.error("加载用户列表失败");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -210,10 +237,6 @@ const SpeakerReg: React.FC = () => {
|
||||||
const resetFormState = () => {
|
const resetFormState = () => {
|
||||||
setEditingSpeaker(null);
|
setEditingSpeaker(null);
|
||||||
form.resetFields(["id", "name", "userId", "remark"]);
|
form.resetFields(["id", "name", "userId", "remark"]);
|
||||||
if (!isAdmin && profile?.userId) {
|
|
||||||
form.setFieldValue("userId", profile.userId);
|
|
||||||
form.setFieldValue("name", profile.displayName);
|
|
||||||
}
|
|
||||||
resetAudioState();
|
resetAudioState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -241,10 +264,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
if (loading) {
|
if (loading) return;
|
||||||
message.warning("声纹正在提交,请稍后再录制");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
||||||
message.error("当前浏览器不支持录音,请使用音频文件上传");
|
message.error("当前浏览器不支持录音,请使用音频文件上传");
|
||||||
return;
|
return;
|
||||||
|
|
@ -256,15 +276,11 @@ const SpeakerReg: React.FC = () => {
|
||||||
audioChunksRef.current = [];
|
audioChunksRef.current = [];
|
||||||
|
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
if (event.data.size > 0) {
|
if (event.data.size > 0) audioChunksRef.current.push(event.data);
|
||||||
audioChunksRef.current.push(event.data);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onstop = () => {
|
mediaRecorder.onstop = () => {
|
||||||
if (!mountedRef.current) {
|
if (!mountedRef.current) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const blob = new Blob(audioChunksRef.current, { type: "audio/wav" });
|
const blob = new Blob(audioChunksRef.current, { type: "audio/wav" });
|
||||||
resetAudioState();
|
resetAudioState();
|
||||||
setAudioBlob(blob);
|
setAudioBlob(blob);
|
||||||
|
|
@ -293,14 +309,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
|
|
||||||
const uploadProps: UploadProps = {
|
const uploadProps: UploadProps = {
|
||||||
beforeUpload: (file) => {
|
beforeUpload: (file) => {
|
||||||
if (recording) {
|
if (recording || loading) return Upload.LIST_IGNORE;
|
||||||
message.warning("请先停止录音,再上传音频文件");
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
if (loading) {
|
|
||||||
message.warning("声纹正在提交,请稍后再上传");
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
if (!isAudioFile(file)) {
|
if (!isAudioFile(file)) {
|
||||||
message.error("只能上传音频文件");
|
message.error("只能上传音频文件");
|
||||||
return Upload.LIST_IGNORE;
|
return Upload.LIST_IGNORE;
|
||||||
|
|
@ -314,32 +323,43 @@ const SpeakerReg: React.FC = () => {
|
||||||
showUploadList: false,
|
showUploadList: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (isMyVoice: boolean) => {
|
||||||
if (!audioBlob && !editingSpeaker) {
|
if (!audioBlob && !editingSpeaker && !isMyVoice) {
|
||||||
message.warning("请先录制或上传声纹文件");
|
message.warning("请先录制或上传声纹文件");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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);
|
setLoading(true);
|
||||||
await registerSpeaker({
|
await registerSpeaker({
|
||||||
id: editingSpeaker?.id,
|
id: isMyVoice ? mySpeaker?.id : editingSpeaker?.id,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
userId: values.userId ? Number(values.userId) : undefined,
|
userId: values.userId ? Number(values.userId) : undefined,
|
||||||
remark: values.remark?.trim(),
|
remark: values.remark?.trim(),
|
||||||
file: audioBlob || undefined,
|
file: audioBlob || undefined,
|
||||||
});
|
});
|
||||||
message.success(editingSpeaker ? "声纹保存成功,等待同步" : "声纹保存成功,等待同步");
|
|
||||||
|
message.success("声纹保存成功");
|
||||||
resetFormState();
|
resetFormState();
|
||||||
if (current !== 1) {
|
|
||||||
setCurrent(1);
|
if (isMyVoice) {
|
||||||
|
setMyViewMode("view");
|
||||||
|
void fetchMySpeaker();
|
||||||
} else {
|
} else {
|
||||||
void fetchSpeakers(1, pageSize, queryName);
|
setIsAddModalVisible(false);
|
||||||
|
if (current !== 1) setCurrent(1);
|
||||||
|
else void fetchSpeakers(1, pageSize, queryName);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as { errorFields?: unknown }).errorFields) {
|
if ((err as { errorFields?: unknown }).errorFields) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
message.error(getErrorMessage(err, "声纹保存失败"));
|
message.error(getErrorMessage(err, "声纹保存失败"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -347,17 +367,16 @@ const SpeakerReg: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (speaker: SpeakerVO) => {
|
const handleDelete = async (speaker: SpeakerVO, isMyVoice: boolean) => {
|
||||||
try {
|
try {
|
||||||
await deleteSpeaker(speaker.id);
|
await deleteSpeaker(speaker.id);
|
||||||
message.success("声纹已删除");
|
message.success("声纹已删除");
|
||||||
if (editingSpeaker?.id === speaker.id) {
|
if (isMyVoice) {
|
||||||
resetFormState();
|
setMySpeaker(null);
|
||||||
}
|
setMyViewMode("record");
|
||||||
if (speakers.length === 1 && current > 1) {
|
|
||||||
setCurrent(current - 1);
|
|
||||||
} else {
|
} else {
|
||||||
void fetchSpeakers(current, pageSize, queryName);
|
if (speakers.length === 1 && current > 1) setCurrent(current - 1);
|
||||||
|
else void fetchSpeakers(current, pageSize, queryName);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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);
|
setSyncingSpeakerId(speaker.id);
|
||||||
try {
|
try {
|
||||||
await syncSpeaker(speaker.id);
|
await syncSpeaker(speaker.id);
|
||||||
message.success("已提交声纹同步任务");
|
message.success("已提交声纹同步任务");
|
||||||
void fetchSpeakers(current, pageSize, queryName);
|
if (isMyVoice) void fetchMySpeaker();
|
||||||
|
else void fetchSpeakers(current, pageSize, queryName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
message.error(getErrorMessage(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);
|
setEditingSpeaker(speaker);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
id: speaker.id,
|
id: speaker.id,
|
||||||
|
|
@ -388,174 +414,269 @@ const SpeakerReg: React.FC = () => {
|
||||||
remark: speaker.remark,
|
remark: speaker.remark,
|
||||||
});
|
});
|
||||||
resetAudioState();
|
resetAudioState();
|
||||||
|
setInputType("record");
|
||||||
|
setIsAddModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUserChange = (userId?: number) => {
|
// UI Renderers
|
||||||
const selectedUser = userOptions.find((user) => user.userId === userId);
|
const renderRecordingArea = (isMyVoice: boolean) => (
|
||||||
if (selectedUser) {
|
<div className="modern-recording-container">
|
||||||
form.setFieldValue("name", selectedUser.displayName || selectedUser.username);
|
{/* 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) => {
|
{!isMyVoice && (
|
||||||
const keyword = (value ?? searchKeyword).trim();
|
<Form form={form} layout="vertical" className="speaker-modal-form">
|
||||||
setCurrent(1);
|
<Form.Item name="name" label="声纹名称" rules={[{ required: true, message: "请输入名称" }]}>
|
||||||
setQueryName(keyword);
|
<Input size="large" placeholder="姓名 / 职位 / 编号" />
|
||||||
};
|
|
||||||
|
|
||||||
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="姓名 / 职位 / 编号" />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
<Form.Item name="userId" label="绑定系统用户 (可选)">
|
||||||
<Col xs={24} md={10}>
|
|
||||||
<Form.Item name="userId" label={<Text strong>绑定用户</Text>}>
|
|
||||||
<Select
|
<Select
|
||||||
size="middle"
|
size="large"
|
||||||
placeholder="系统关联"
|
placeholder="选择系统关联用户"
|
||||||
disabled={!isAdmin}
|
allowClear
|
||||||
onChange={handleUserChange}
|
|
||||||
options={userOptions.map((user) => ({
|
options={userOptions.map((user) => ({
|
||||||
label: user.displayName || user.username,
|
label: user.displayName || user.username,
|
||||||
value: user.userId,
|
value: user.userId,
|
||||||
}))}
|
}))}
|
||||||
|
onChange={(val) => {
|
||||||
|
const u = userOptions.find(user => user.userId === val);
|
||||||
|
if (u) form.setFieldValue("name", u.displayName || u.username);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
<Form.Item name="remark" label="备注 (可选)">
|
||||||
</Row>
|
<Input size="large" placeholder="记录使用场景或特征说明" />
|
||||||
|
|
||||||
<Form.Item name="remark" label={<Text strong>备注(可选)</Text>}>
|
|
||||||
<Input size="middle" placeholder="记录使用场景或特征说明" />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs
|
<div className="input-type-switch">
|
||||||
defaultActiveKey="record"
|
<Segmented
|
||||||
className="speaker-reg-tabs"
|
block
|
||||||
size="middle"
|
size="large"
|
||||||
items={[
|
options={[
|
||||||
{
|
{ label: "实时录制", value: "record", icon: <AudioOutlined /> },
|
||||||
key: "record",
|
{ label: "离线上传", value: "upload", icon: <CloudUploadOutlined /> },
|
||||||
label: <span><AudioOutlined /> 实时录制采集</span>,
|
]}
|
||||||
children: (
|
value={inputType}
|
||||||
<div className="recording-area">
|
onChange={(val) => setInputType(val as "record" | "upload")}
|
||||||
<div className="script-box">
|
/>
|
||||||
<Text strong className="script-box__label">录音文本内容:</Text>
|
|
||||||
<div className="script-box__content">{REG_CONTENT}</div>
|
|
||||||
</div>
|
</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
|
<button
|
||||||
className={`btn-record ${recording ? "recording" : "idle"}`}
|
className="mic-button"
|
||||||
onClick={recording ? stopRecording : startRecording}
|
onClick={recording ? stopRecording : startRecording}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={recording ? "停止录制声纹样本" : "开始录制声纹样本"}
|
|
||||||
>
|
>
|
||||||
{recording ? <StopOutlined /> : <AudioOutlined />}
|
{recording ? <div className="stop-square" /> : <AudioOutlined />}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<Progress percent={(seconds / DEFAULT_DURATION) * 100} showInfo={false} strokeColor="#3c70f5"
|
<div className="record-status">
|
||||||
size="small"/>
|
<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>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
),
|
<div className="modern-upload-area">
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "upload",
|
|
||||||
label: <span><CloudUploadOutlined /> 离线文件上传</span>,
|
|
||||||
children: (
|
|
||||||
<Upload {...uploadProps} accept="audio/*" className="speaker-reg-upload">
|
<Upload {...uploadProps} accept="audio/*" className="speaker-reg-upload">
|
||||||
<div className="upload-compact">
|
<div className="upload-box">
|
||||||
<CloudUploadOutlined className="upload-compact__icon" />
|
<CloudUploadOutlined className="upload-icon" />
|
||||||
<div className="upload-compact__title"><Text strong>点击此处或将音频文件拖入</Text></div>
|
<h4>点击或拖拽文件到此处</h4>
|
||||||
<Text type="secondary">支持 MP3 / WAV / M4A,建议时长 5-15 秒</Text>
|
<p>支持 MP3 / WAV / M4A,建议 10-15 秒</p>
|
||||||
</div>
|
</div>
|
||||||
</Upload>
|
</Upload>
|
||||||
),
|
</div>
|
||||||
},
|
)}
|
||||||
]}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
{audioUrl && (
|
{audioUrl && (
|
||||||
<div className="speaker-reg-audio-ready">
|
<div className="speaker-reg-audio-ready">
|
||||||
<div className="speaker-reg-audio-ready__head">
|
<div className="speaker-reg-audio-ready__head">
|
||||||
<Text strong><CheckCircleOutlined /> 采样文件已就绪</Text>
|
<Text strong className="success-text"><CheckCircleOutlined /> 采样文件已就绪</Text>
|
||||||
<Button type="text" danger size="small" onClick={resetAudioState} icon={<DeleteOutlined />}>
|
<Button type="text" danger size="small" onClick={resetAudioState} icon={<DeleteOutlined />}>
|
||||||
重新采集
|
重新采集
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<audio src={audioUrl} controls />
|
<audio src={audioUrl} controls className="custom-audio" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="speaker-reg-submit-area">
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
block
|
block
|
||||||
onClick={handleSubmit}
|
onClick={() => handleSubmit(isMyVoice)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={recording || (!audioBlob && !editingSpeaker)}
|
disabled={recording || (!audioBlob && !editingSpeaker && !isMyVoice)}
|
||||||
|
className="submit-btn"
|
||||||
>
|
>
|
||||||
{editingSpeaker ? "确认保存声纹变更" : "保存声纹"}
|
提交更新
|
||||||
</Button>
|
</Button>
|
||||||
{editingSpeaker && (
|
</div>
|
||||||
<Button size="middle" block onClick={resetFormState}>
|
);
|
||||||
取消编辑
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="info-strip">
|
const renderMyVoiceprint = () => {
|
||||||
<SafetyCertificateOutlined />
|
if (myViewMode === "record" || !mySpeaker) {
|
||||||
|
return renderRecordingArea(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMeta = getSpeakerStatusMeta(mySpeaker);
|
||||||
|
return (
|
||||||
|
<div className="my-voice-view">
|
||||||
|
<div className="my-voice-header">
|
||||||
<div>
|
<div>
|
||||||
数据将加密存储,仅用于会议期间的发言人识别与角色分离。保存成功后如状态仍是“未同步”或“同步失败”,可在列表中点击“同步”按钮补同步。
|
<h3>我的声纹</h3>
|
||||||
|
<p>你的声纹用于在会议中识别你的发言身份</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
<div className="my-voice-card">
|
||||||
className="speaker-reg-card speaker-reg-library"
|
<div className="my-voice-card-top">
|
||||||
title={
|
<Avatar size={64} className="my-avatar">{profile?.displayName?.charAt(0) || "U"}</Avatar>
|
||||||
<Space size={8}>
|
<div className="my-info">
|
||||||
<SoundOutlined />
|
<div className="my-name-row">
|
||||||
<span>声纹列表</span>
|
<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>
|
</Space>
|
||||||
}
|
}
|
||||||
extra={
|
rightActions={
|
||||||
<Space wrap size={8} className="speaker-reg-library__tools">
|
|
||||||
<Search
|
<Search
|
||||||
allowClear
|
allowClear
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
|
|
@ -567,99 +688,118 @@ const SpeakerReg: React.FC = () => {
|
||||||
setQueryName("");
|
setQueryName("");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSearch={handleSearch}
|
onSearch={(val) => { setCurrent(1); setQueryName(val.trim()); }}
|
||||||
placeholder="按名称搜索"
|
placeholder="搜索姓名 / 手机号"
|
||||||
prefix={<SearchOutlined />}
|
className="search-input"
|
||||||
|
style={{ width: 320 }}
|
||||||
/>
|
/>
|
||||||
<Badge count={total} overflowCount={999} />
|
|
||||||
</Space>
|
|
||||||
}
|
}
|
||||||
>
|
footer={
|
||||||
<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>
|
|
||||||
|
|
||||||
<AppPagination
|
<AppPagination
|
||||||
variant="card"
|
|
||||||
current={current}
|
current={current}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
total={total}
|
total={total}
|
||||||
onChange={(page, size) => {
|
onChange={(page, size) => { setCurrent(page); setPageSize(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>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue