imeeting/frontend/src/pages/business/SpeakerReg.tsx

600 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;