600 lines
21 KiB
TypeScript
600 lines
21 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
AudioOutlined,
|
||
CheckCircleOutlined,
|
||
CloudUploadOutlined,
|
||
DeleteOutlined,
|
||
FormOutlined,
|
||
SearchOutlined,
|
||
StopOutlined,
|
||
UserOutlined,
|
||
SoundOutlined,
|
||
SafetyCertificateOutlined
|
||
} from '@ant-design/icons';
|
||
import { Badge, Button, Card, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Spin, Space, Tabs, Tag, Typography, Upload, Tooltip, App } from 'antd';
|
||
import type { UploadProps } from 'antd';
|
||
import dayjs from 'dayjs';
|
||
import { listUsers } from '../../api';
|
||
import { deleteSpeaker, getSpeakerPage, registerSpeaker, SpeakerVO } from '../../api/business/speaker';
|
||
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 './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<number, { label: string; color: string }> = {
|
||
1: { label: '已保存', color: 'default' },
|
||
2: { label: '注册中', color: 'processing' },
|
||
3: { label: '已注册', color: 'success' },
|
||
4: { label: '本地已保存,声纹同步失败', color: 'error' }
|
||
};
|
||
|
||
const getSpeakerStatusMeta = (status?: number) => {
|
||
return SPEAKER_STATUS_META[Number(status)] || SPEAKER_STATUS_META[1];
|
||
};
|
||
|
||
const isAudioFile = (file: File) => {
|
||
return file.type.startsWith('audio/') || AUDIO_EXT_PATTERN.test(file.name);
|
||
};
|
||
|
||
const buildResourceUrl = (prefix: string, resourcePath?: string) => {
|
||
if (!resourcePath) {
|
||
return '';
|
||
}
|
||
const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
||
const normalizedPath = resourcePath.startsWith('/') ? resourcePath.slice(1) : resourcePath;
|
||
return `${normalizedPrefix}${normalizedPath}`;
|
||
};
|
||
|
||
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);
|
||
const [speakers, setSpeakers] = useState<SpeakerVO[]>([]);
|
||
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 [userOptions, setUserOptions] = useState<SysUser[]>([]);
|
||
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
||
const [seconds, setSeconds] = useState(0);
|
||
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);
|
||
|
||
const resourcePrefix = useMemo(() => {
|
||
try {
|
||
const configStr = sessionStorage.getItem('platformConfig');
|
||
if (configStr) {
|
||
const config = JSON.parse(configStr);
|
||
return config.resourcePrefix || '/api/static/';
|
||
}
|
||
} catch (err) {
|
||
console.warn('Parse platformConfig failed', err);
|
||
}
|
||
return '/api/static/';
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void fetchUsers();
|
||
return () => {
|
||
mountedRef.current = false;
|
||
stopTimer();
|
||
if (mediaRecorderRef.current?.state === 'recording') {
|
||
mediaRecorderRef.current.stop();
|
||
}
|
||
mediaRecorderRef.current?.stream.getTracks().forEach(track => track.stop());
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!audioUrl) {
|
||
return;
|
||
}
|
||
return () => {
|
||
URL.revokeObjectURL(audioUrl);
|
||
};
|
||
}, [audioUrl]);
|
||
|
||
useEffect(() => {
|
||
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]);
|
||
|
||
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
|
||
setListLoading(true);
|
||
try {
|
||
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;
|
||
|
||
if (page > 1 && records.length === 0 && nextTotal > 0) {
|
||
setCurrent(page - 1);
|
||
return;
|
||
}
|
||
|
||
setSpeakers(records);
|
||
setTotal(nextTotal);
|
||
} catch (err) {
|
||
console.error(err);
|
||
message.error('加载声纹库失败');
|
||
} finally {
|
||
setListLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchUsers = async () => {
|
||
try {
|
||
const users = await listUsers();
|
||
setUserOptions(users || []);
|
||
} catch (err) {
|
||
console.error(err);
|
||
setUserOptions([]);
|
||
message.error('加载用户列表失败');
|
||
}
|
||
};
|
||
|
||
const resetAudioState = () => {
|
||
setAudioBlob(null);
|
||
setAudioUrl(null);
|
||
setSeconds(0);
|
||
};
|
||
|
||
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();
|
||
};
|
||
|
||
const startTimer = () => {
|
||
stopTimer();
|
||
setSeconds(0);
|
||
timerRef.current = setInterval(() => {
|
||
setSeconds(prev => Math.min(prev + 1, DEFAULT_DURATION));
|
||
}, 1000);
|
||
autoStopTimerRef.current = setTimeout(() => {
|
||
setSeconds(DEFAULT_DURATION);
|
||
stopRecording();
|
||
}, DEFAULT_DURATION * 1000);
|
||
};
|
||
|
||
const stopTimer = () => {
|
||
if (timerRef.current) {
|
||
clearInterval(timerRef.current);
|
||
timerRef.current = null;
|
||
}
|
||
if (autoStopTimerRef.current) {
|
||
clearTimeout(autoStopTimerRef.current);
|
||
autoStopTimerRef.current = null;
|
||
}
|
||
};
|
||
|
||
const startRecording = async () => {
|
||
if (loading) {
|
||
message.warning('声纹正在提交,请稍后再录制');
|
||
return;
|
||
}
|
||
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||
message.error('当前浏览器不支持录音,请使用音频文件上传');
|
||
return;
|
||
}
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
const mediaRecorder = new MediaRecorder(stream);
|
||
mediaRecorderRef.current = mediaRecorder;
|
||
audioChunksRef.current = [];
|
||
|
||
mediaRecorder.ondataavailable = event => {
|
||
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' });
|
||
resetAudioState();
|
||
setAudioBlob(blob);
|
||
setAudioUrl(URL.createObjectURL(blob));
|
||
stopTimer();
|
||
};
|
||
|
||
mediaRecorder.start();
|
||
setRecording(true);
|
||
resetAudioState();
|
||
startTimer();
|
||
} catch (err) {
|
||
console.error(err);
|
||
message.error('无法访问麦克风,请检查权限设置');
|
||
}
|
||
};
|
||
|
||
const stopRecording = () => {
|
||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
||
mediaRecorderRef.current.stop();
|
||
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
||
setRecording(false);
|
||
stopTimer();
|
||
}
|
||
};
|
||
|
||
const uploadProps: UploadProps = {
|
||
beforeUpload: file => {
|
||
if (recording) {
|
||
message.warning('请先停止录音,再上传音频文件');
|
||
return Upload.LIST_IGNORE;
|
||
}
|
||
if (loading) {
|
||
message.warning('声纹正在提交,请稍后再上传');
|
||
return Upload.LIST_IGNORE;
|
||
}
|
||
const isAudio = isAudioFile(file);
|
||
if (!isAudio) {
|
||
message.error('只能上传音频文件');
|
||
return Upload.LIST_IGNORE;
|
||
}
|
||
resetAudioState();
|
||
setAudioBlob(file);
|
||
setAudioUrl(URL.createObjectURL(file));
|
||
return false;
|
||
},
|
||
disabled: recording || loading,
|
||
showUploadList: false
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!audioBlob && !editingSpeaker) {
|
||
message.warning('请先录制或上传声纹文件');
|
||
return;
|
||
}
|
||
try {
|
||
const values = await form.validateFields();
|
||
setLoading(true);
|
||
await registerSpeaker({
|
||
id: editingSpeaker?.id,
|
||
name: values.name.trim(),
|
||
userId: values.userId ? Number(values.userId) : undefined,
|
||
remark: values.remark?.trim(),
|
||
file: audioBlob || undefined
|
||
});
|
||
message.success(editingSpeaker ? '声纹更新成功' : '声纹录入成功');
|
||
resetFormState();
|
||
if (current !== 1) {
|
||
setCurrent(1);
|
||
} else {
|
||
void fetchSpeakers(1, pageSize, queryName);
|
||
}
|
||
} catch (err) {
|
||
if ((err as { errorFields?: unknown }).errorFields) {
|
||
return;
|
||
}
|
||
console.error(err);
|
||
message.error('声纹录入失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (speaker: SpeakerVO) => {
|
||
try {
|
||
await deleteSpeaker(speaker.id);
|
||
message.success('声纹已删除');
|
||
if (editingSpeaker?.id === speaker.id) {
|
||
resetFormState();
|
||
}
|
||
if (speakers.length === 1 && current > 1) {
|
||
setCurrent(current - 1);
|
||
} else {
|
||
void fetchSpeakers(current, pageSize, queryName);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
message.error('删除声纹失败');
|
||
}
|
||
};
|
||
|
||
const handleEdit = (speaker: SpeakerVO) => {
|
||
setEditingSpeaker(speaker);
|
||
form.setFieldsValue({
|
||
id: speaker.id,
|
||
name: speaker.name,
|
||
userId: speaker.userId,
|
||
remark: speaker.remark
|
||
});
|
||
resetAudioState();
|
||
};
|
||
|
||
const handleUserChange = (userId?: number) => {
|
||
const selectedUser = userOptions.find(u => u.userId === userId);
|
||
if (selectedUser) {
|
||
form.setFieldValue('name', selectedUser.displayName || selectedUser.username);
|
||
}
|
||
};
|
||
|
||
const handleSearch = (value?: string) => {
|
||
const keyword = (value ?? searchKeyword).trim();
|
||
setCurrent(1);
|
||
setQueryName(keyword);
|
||
};
|
||
|
||
return (
|
||
<PageContainer title={null} className="speaker-reg-page">
|
||
<SectionCard
|
||
title="声纹采集工作台"
|
||
description="采集或上传声纹样本,并维护当前租户下的发言人声纹库。"
|
||
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>
|
||
</Col>
|
||
<Col xs={24} md={10}>
|
||
<Form.Item name="userId" label={<Text strong>绑定用户</Text>}>
|
||
<Select
|
||
size="middle"
|
||
placeholder="系统关联"
|
||
disabled={!isAdmin}
|
||
onChange={handleUserChange}
|
||
options={userOptions.map(u => ({ label: u.displayName || u.username, value: u.userId }))}
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
<Form.Item name="remark" label={<Text strong>备注 (可选)</Text>}>
|
||
<Input size="middle" placeholder="记录使用场景或特征说明" />
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<Tabs
|
||
defaultActiveKey="1"
|
||
className="speaker-reg-tabs"
|
||
size="middle"
|
||
items={[
|
||
{
|
||
key: "1",
|
||
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>
|
||
|
||
<div className="record-controls">
|
||
<button
|
||
className={`btn-record ${recording ? 'recording' : 'idle'}`}
|
||
onClick={recording ? stopRecording : startRecording}
|
||
disabled={loading}
|
||
type="button"
|
||
aria-label={recording ? '停止录制声纹样本' : '开始录制声纹样本'}
|
||
>
|
||
{recording ? <StopOutlined /> : <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>
|
||
</div>
|
||
</div>
|
||
)
|
||
},
|
||
{
|
||
key: "2",
|
||
label: <span><CloudUploadOutlined /> 离线文件上传</span>,
|
||
children: (
|
||
<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>
|
||
</Upload>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
|
||
{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 />}>重新采集</Button>
|
||
</div>
|
||
<audio src={audioUrl} controls />
|
||
</div>
|
||
)}
|
||
|
||
<div className="speaker-reg-submit-area">
|
||
<Button
|
||
type="primary"
|
||
size="large"
|
||
block
|
||
onClick={handleSubmit}
|
||
loading={loading}
|
||
disabled={recording || (!audioBlob && !editingSpeaker)}
|
||
>
|
||
{editingSpeaker ? '确认保存声纹变更' : '提交并同步到声纹库'}
|
||
</Button>
|
||
{editingSpeaker && (
|
||
<Button size="middle" block onClick={resetFormState}>取消编辑</Button>
|
||
)}
|
||
|
||
<div className="info-strip">
|
||
<SafetyCertificateOutlined />
|
||
<div>
|
||
数据将加密存储,仅用于会议期间的发言人识别与角色分离。同租户内声纹名称需保持唯一。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card
|
||
className="speaker-reg-card speaker-reg-library"
|
||
title={
|
||
<Space size={8}>
|
||
<SoundOutlined />
|
||
<span>已注册声纹库</span>
|
||
</Space>
|
||
}
|
||
extra={
|
||
<Space wrap size={8} className="speaker-reg-library__tools">
|
||
<Search
|
||
allowClear
|
||
value={searchKeyword}
|
||
onChange={e => {
|
||
const nextValue = e.target.value;
|
||
setSearchKeyword(nextValue);
|
||
if (!nextValue.trim() && queryName) {
|
||
setCurrent(1);
|
||
setQueryName('');
|
||
}
|
||
}}
|
||
onSearch={handleSearch}
|
||
placeholder="按名称搜索"
|
||
prefix={<SearchOutlined />}
|
||
/>
|
||
<Badge count={total} overflowCount={999} />
|
||
</Space>
|
||
}
|
||
>
|
||
<Text type="secondary" className="speaker-reg-library__hint">
|
||
按名称快速筛选当前声纹记录,支持试听、编辑和删除。
|
||
</Text>
|
||
|
||
<div className="speaker-list">
|
||
<Spin spinning={listLoading}>
|
||
{speakers.length === 0 ? (
|
||
<Empty
|
||
description={<Text type="secondary">{queryName ? '未找到匹配的声纹记录' : '暂无声纹记录'}</Text>}
|
||
className="speaker-reg-empty"
|
||
/>
|
||
) : (
|
||
speakers.map((s) => {
|
||
const statusMeta = getSpeakerStatusMeta(s.status);
|
||
return (
|
||
<div className={s.remark ? "speaker-card" : "speaker-card speaker-card--no-remark"} key={s.id}>
|
||
<div className="speaker-card__head">
|
||
<div className="speaker-card__identity">
|
||
<Text strong ellipsis>{s.name}</Text>
|
||
<div className="speaker-card__meta">
|
||
<Tag color={statusMeta.color} bordered={false}>
|
||
{statusMeta.label}
|
||
</Tag>
|
||
{s.userId && <Text type="secondary"><UserOutlined /> ID:{s.userId}</Text>}
|
||
</div>
|
||
</div>
|
||
<Space size={0}>
|
||
<Tooltip title="编辑档案">
|
||
<Button type="text" size="small" icon={<FormOutlined />} onClick={() => handleEdit(s)} />
|
||
</Tooltip>
|
||
<Popconfirm title="确定要删除此声纹记录吗?" onConfirm={() => handleDelete(s)}>
|
||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||
</Popconfirm>
|
||
</Space>
|
||
</div>
|
||
|
||
{s.remark && (
|
||
<div className="speaker-card__remark">{s.remark}</div>
|
||
)}
|
||
|
||
<audio
|
||
src={buildResourceUrl(resourcePrefix, s.voicePath)}
|
||
controls
|
||
controlsList="nodownload"
|
||
/>
|
||
|
||
<div className="speaker-card__footer">
|
||
<span>更新于 {dayjs(s.updatedAt).format('YYYY-MM-DD HH:mm')}</span>
|
||
<span>{((s.voiceSize || 0) / 1024).toFixed(1)} KB</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</Spin>
|
||
</div>
|
||
<AppPagination
|
||
variant="card"
|
||
current={current}
|
||
pageSize={pageSize}
|
||
total={total}
|
||
onChange={(page, size) => {
|
||
setCurrent(page);
|
||
setPageSize(size);
|
||
}}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
</SectionCard>
|
||
</PageContainer>
|
||
);
|
||
};
|
||
|
||
export default SpeakerReg;
|