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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React, {useEffect, useMemo, useRef, useState} from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { import {
AudioOutlined, AudioOutlined,
CheckCircleOutlined, CheckCircleOutlined,
@ -6,36 +6,39 @@ 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";
import {listUsers} from "../../api"; import { listUsers } from "../../api";
import { import {
deleteSpeaker, deleteSpeaker,
getSpeakerPage, getSpeakerPage,
@ -46,26 +49,29 @@ 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 {useAuth} from "../../hooks/useAuth"; import FormDrawer from "../../components/shared/FormDrawer";
import type {SysUser} from "../../types"; 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"; import "./SpeakerReg.css";
const { Text } = Typography; 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,16 +276,12 @@ 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);
setAudioUrl(URL.createObjectURL(blob)); setAudioUrl(URL.createObjectURL(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>