diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx
index e8d923e..2e4ad4e 100644
--- a/frontend/src/pages/business/Meetings.tsx
+++ b/frontend/src/pages/business/Meetings.tsx
@@ -499,6 +499,11 @@ const Meetings: React.FC = () => {
const activeFilterCount = (statusFilter !== ALL_STATUS_FILTER ? 1 : 0) + (searchTitle ? 1 : 0);
+ const handlePaginationChange = (page: number, pageSize: number) => {
+ setCurrent(pageSize !== size ? 1 : page);
+ setSize(pageSize);
+ };
+
const handleDisplayModeChange = (mode: "card" | "list") => {
setDisplayMode(mode);
setSize(mode === "card" ? 8 : 10);
@@ -557,6 +562,13 @@ const Meetings: React.FC = () => {
void fetchData();
}, [current, size, searchTitle, viewType, statusFilter]);
+ useEffect(() => {
+ const maxPage = Math.max(1, Math.ceil(total / size));
+ if (current > maxPage) {
+ setCurrent(maxPage);
+ }
+ }, [current, size, total]);
+
useEffect(() => {
const trackedMeetings = data.filter(shouldTrackGenerationProgress);
if (trackedMeetings.length === 0) {
@@ -930,39 +942,42 @@ const Meetings: React.FC = () => {
style={{ flex: 1, minHeight: 0, overflow: "hidden", display: "flex", flexDirection: "column", border: 'none', background: 'transparent' }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
>
-
+
{displayMode === "card" ? (
-
- {
- const progress = progressMap[item.id] || null;
- const visualStatus = getEffectiveStatus(item, progress);
- const config = statusConfig[visualStatus] || statusConfig[0];
- return (
- { void handleRetrySchedule(meeting); }}
- onDelete={(id) => { deleteMeeting(id).then(() => { message.success('删除成功'); fetchData(); }) }}
- retrying={!!retryingMeetingIds[item.id]}
- />
- );
- }}
- locale={{ emptyText: }}
- />
-
+
+
+ {
+ const progress = progressMap[item.id] || null;
+ const visualStatus = getEffectiveStatus(item, progress);
+ const config = statusConfig[visualStatus] || statusConfig[0];
+ return (
+ { void handleRetrySchedule(meeting); }}
+ onDelete={(id) => { deleteMeeting(id).then(() => { message.success('删除成功'); fetchData(); }) }}
+ retrying={!!retryingMeetingIds[item.id]}
+ />
+ );
+ }}
+ locale={{ emptyText: }}
+ />
+
+
) : (
({ onClick: () => handleOpenMeeting(record), style: { cursor: "pointer" } })}
locale={{ emptyText: }}
/>
@@ -970,7 +985,7 @@ const Meetings: React.FC = () => {
-
{ setCurrent(p); setSize(s); }} />
+
@@ -990,6 +1005,17 @@ const Meetings: React.FC = () => {
.meeting-card-v2:hover .view-detail-text { max-width: 60px; opacity: 1; margin-left: 6px; }
.status-bar-active { animation: statusBreathing 2s infinite ease-in-out; }
@keyframes statusBreathing { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
+ .meetings-list-table.ant-table-wrapper,
+ .meetings-list-table.ant-table-wrapper .ant-spin-nested-loading,
+ .meetings-list-table.ant-table-wrapper .ant-spin-container,
+ .meetings-list-table.ant-table-wrapper .ant-table,
+ .meetings-list-table.ant-table-wrapper .ant-table-container {
+ height: 100%;
+ min-height: 0;
+ }
+ .meetings-list-table.ant-table-wrapper .ant-table-body {
+ max-height: calc(100vh - 430px) !important;
+ }
/* Premium Button Styles */
.ant-btn-primary:not(.ant-btn-dangerous) {
diff --git a/frontend/src/pages/business/SpeakerReg.tsx b/frontend/src/pages/business/SpeakerReg.tsx
index 7da6f31..61138a8 100644
--- a/frontend/src/pages/business/SpeakerReg.tsx
+++ b/frontend/src/pages/business/SpeakerReg.tsx
@@ -30,6 +30,31 @@ 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 = {
+ 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();
@@ -48,18 +73,23 @@ const SpeakerReg: React.FC = () => {
const [userOptions, setUserOptions] = useState([]);
const [editingSpeaker, setEditingSpeaker] = useState(null);
const [seconds, setSeconds] = useState(0);
- const timerRef = useRef(null);
- const autoStopTimerRef = useRef(null);
+ const timerRef = useRef | null>(null);
+ const autoStopTimerRef = useRef | null>(null);
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
+ const mountedRef = useRef(true);
const { profile } = useAuth();
const isAdmin = !!(profile?.isAdmin || profile?.isPlatformAdmin);
const resourcePrefix = useMemo(() => {
- const configStr = sessionStorage.getItem('platformConfig');
- if (configStr) {
- const config = JSON.parse(configStr);
- return config.resourcePrefix || '/api/static/';
+ 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/';
}, []);
@@ -67,7 +97,12 @@ const SpeakerReg: React.FC = () => {
useEffect(() => {
void fetchUsers();
return () => {
+ mountedRef.current = false;
stopTimer();
+ if (mediaRecorderRef.current?.state === 'recording') {
+ mediaRecorderRef.current.stop();
+ }
+ mediaRecorderRef.current?.stream.getTracks().forEach(track => track.stop());
};
}, []);
@@ -90,7 +125,7 @@ const SpeakerReg: React.FC = () => {
}
form.setFieldValue('userId', profile.userId);
form.setFieldValue('name', profile.displayName);
- }, [form, isAdmin, profile?.userId]);
+ }, [form, isAdmin, profile?.displayName, profile?.userId]);
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
setListLoading(true);
@@ -132,10 +167,8 @@ const SpeakerReg: React.FC = () => {
const resetAudioState = () => {
setAudioBlob(null);
- if (audioUrl) {
- URL.revokeObjectURL(audioUrl);
- }
setAudioUrl(null);
+ setSeconds(0);
};
const resetFormState = () => {
@@ -143,6 +176,7 @@ const SpeakerReg: React.FC = () => {
form.resetFields(['id', 'name', 'userId', 'remark']);
if (!isAdmin && profile?.userId) {
form.setFieldValue('userId', profile.userId);
+ form.setFieldValue('name', profile.displayName);
}
resetAudioState();
};
@@ -171,6 +205,14 @@ const SpeakerReg: React.FC = () => {
};
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);
@@ -184,6 +226,9 @@ const SpeakerReg: React.FC = () => {
};
mediaRecorder.onstop = () => {
+ if (!mountedRef.current) {
+ return;
+ }
const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' });
resetAudioState();
setAudioBlob(blob);
@@ -212,7 +257,15 @@ const SpeakerReg: React.FC = () => {
const uploadProps: UploadProps = {
beforeUpload: file => {
- const isAudio = file.type.startsWith('audio/');
+ 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;
@@ -222,6 +275,7 @@ const SpeakerReg: React.FC = () => {
setAudioUrl(URL.createObjectURL(file));
return false;
},
+ disabled: recording || loading,
showUploadList: false
};
@@ -262,6 +316,9 @@ const SpeakerReg: React.FC = () => {
try {
await deleteSpeaker(speaker.id);
message.success('声纹已删除');
+ if (editingSpeaker?.id === speaker.id) {
+ resetFormState();
+ }
if (speakers.length === 1 && current > 1) {
setCurrent(current - 1);
} else {
@@ -284,7 +341,7 @@ const SpeakerReg: React.FC = () => {
resetAudioState();
};
- const handleUserChange = (userId: number) => {
+ const handleUserChange = (userId?: number) => {
const selectedUser = userOptions.find(u => u.userId === userId);
if (selectedUser) {
form.setFieldValue('name', selectedUser.displayName || selectedUser.username);
@@ -569,6 +626,9 @@ const SpeakerReg: React.FC = () => {
@@ -678,14 +738,16 @@ const SpeakerReg: React.FC = () => {
style={{ marginTop: 40 }}
/>
) : (
- speakers.map((s) => (
+ speakers.map((s) => {
+ const statusMeta = getSpeakerStatusMeta(s.status);
+ return (
{s.name}
-
- {s.status === 3 ? '已就绪' : '处理中'}
+
+ {statusMeta.label}
{s.userId && ID:{s.userId}}
@@ -705,7 +767,7 @@ const SpeakerReg: React.FC = () => {
)}
- ))
+ );
+ })
)}