refactor(frontend): 重构声纹管理页面UI 优化AI模型页面布局

- 重写声纹注册页面的整体UI与代码逻辑,拆分我的声纹、他人声纹视图,优化录音上传与管理的交互体验
- 调整AI模型页面的样式结构,移除冗余样式并新增内容容器样式,修复代码缩进问题
- 统一前端页面的代码规范与样式命名规则,提升项目可维护性
dev_na
AlanPaine 2026-07-03 16:25:42 +08:00
parent 76410071e7
commit ec8bb466da
4 changed files with 1061 additions and 744 deletions

View File

@ -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;
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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>