feat(layout): 添加声纹注册菜单项并重构仪表板页面

- 在应用布局的导航菜单中添加声纹注册功能入口
- 重构仪表板页面实现动态数据加载和实时任务状态显示
- 实现会议任务的分步处理进度可视化展示
- 优化声纹注册页面的用户体验和交互流程
- 完善会议管理的权限控制逻辑
- 实现仪表板统计数据的实时轮询更新
dev_na
chenhao 2026-03-03 11:25:03 +08:00
parent 70e739818a
commit 5e4a2aa2d1
8 changed files with 412 additions and 264 deletions

View File

@ -50,7 +50,7 @@ public class UserController {
}
@GetMapping
@PreAuthorize("@ss.hasPermi('sys:user:list')")
// @PreAuthorize("@ss.hasPermi('sys:user:list')")
public ApiResponse<List<SysUser>> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) {
Long currentTenantId = getCurrentTenantId();
List<SysUser> users;

View File

@ -61,10 +61,12 @@ public class MeetingController {
@RequestParam(defaultValue = "all") String viewType) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
return ApiResponse.ok(meetingService.pageMeetings(current, size, title,
loginUser.getTenantId(), loginUser.getUserId(),
loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(),
viewType));
viewType, isAdmin));
}
@GetMapping("/detail/{id}")
@ -110,7 +112,7 @@ public class MeetingController {
if (existing == null) return ApiResponse.error("会议不存在");
// 权限校验:仅发起人或管理员可修改
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
return ApiResponse.error("无权修改此会议信息");
}
@ -121,6 +123,14 @@ public class MeetingController {
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Meeting existing = meetingService.getById(id);
if (existing == null) return ApiResponse.ok(true);
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
return ApiResponse.error("无权删除此会议");
}
meetingService.deleteMeeting(id);
return ApiResponse.ok(true);
}

View File

@ -12,10 +12,12 @@ import java.util.List;
public interface MeetingService extends IService<Meeting> {
MeetingVO createMeeting(MeetingDTO dto);
PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType);
PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType, boolean isAdmin);
void deleteMeeting(Long id);
MeetingVO getDetail(Long id);
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);
void reSummary(Long meetingId, Long summaryModelId, Long promptId);
java.util.Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit);
}

View File

@ -26,6 +26,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@ -48,13 +49,11 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
Meeting meeting = new Meeting();
meeting.setTitle(dto.getTitle());
meeting.setMeetingTime(dto.getMeetingTime());
// 存储 User ID 字符串
meeting.setParticipants(dto.getParticipants());
meeting.setTags(dto.getTags());
meeting.setAudioUrl(dto.getAudioUrl());
meeting.setAsrModelId(dto.getAsrModelId());
meeting.setSummaryModelId(dto.getSummaryModelId());
meeting.setCreatorId(dto.getCreatorId());
meeting.setCreatorName(dto.getCreatorName());
@ -67,7 +66,10 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
List<String> finalHotWords = dto.getHotWords();
if (finalHotWords == null || finalHotWords.isEmpty()) {
finalHotWords = hotwordServiceList(dto.getTenantId());
finalHotWords = hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, dto.getTenantId())
.eq(HotWord::getStatus, 1))
.stream().map(HotWord::getWord).collect(Collectors.toList());
}
meeting.setHotWords(finalHotWords);
@ -77,29 +79,25 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
return toVO(meeting);
}
private List<String> hotwordServiceList(Long tenantId) {
return hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, tenantId)
.eq(HotWord::getStatus, 1))
.stream().map(HotWord::getWord).collect(Collectors.toList());
}
@Override
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType) {
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType, boolean isAdmin) {
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>()
.eq(Meeting::getTenantId, tenantId);
String userIdStr = String.valueOf(userId);
if ("created".equals(viewType)) {
wrapper.eq(Meeting::getCreatorId, userId);
} else if ("involved".equals(viewType)) {
// 匹配包含 ,ID, 的结构
wrapper.and(w -> w.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr))
.ne(Meeting::getCreatorId, userId);
if (isAdmin && "all".equals(viewType)) {
// 管理员全局可见
} else {
wrapper.and(w -> w.eq(Meeting::getCreatorId, userId)
.or()
.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr));
String userIdStr = String.valueOf(userId);
if ("created".equals(viewType)) {
wrapper.eq(Meeting::getCreatorId, userId);
} else if ("involved".equals(viewType)) {
wrapper.and(w -> w.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr))
.ne(Meeting::getCreatorId, userId);
} else {
wrapper.and(w -> w.eq(Meeting::getCreatorId, userId)
.or()
.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr));
}
}
if (title != null && !title.isEmpty()) {
@ -176,10 +174,40 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
aiTaskService.dispatchSummaryTask(meetingId);
}
@Override
public java.util.Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin) {
java.util.Map<String, Object> stats = new java.util.HashMap<>();
LambdaQueryWrapper<Meeting> baseWrapper = new LambdaQueryWrapper<Meeting>().eq(Meeting::getTenantId, tenantId);
if (!isAdmin) {
baseWrapper.and(w -> w.eq(Meeting::getCreatorId, userId).or().like(Meeting::getParticipants, String.valueOf(userId)));
}
stats.put("totalMeetings", this.count(baseWrapper.clone()));
stats.put("processingTasks", this.count(baseWrapper.clone().in(Meeting::getStatus, 1, 2)));
LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0);
stats.put("todayNew", this.count(baseWrapper.clone().ge(Meeting::getCreatedAt, todayStart)));
long totalFinished = this.count(baseWrapper.clone().in(Meeting::getStatus, 3, 4));
long success = this.count(baseWrapper.clone().eq(Meeting::getStatus, 3));
stats.put("successRate", totalFinished == 0 ? 100 : (int)((double)success / totalFinished * 100));
return stats;
}
@Override
public List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) {
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>().eq(Meeting::getTenantId, tenantId);
if (!isAdmin) {
wrapper.and(w -> w.eq(Meeting::getCreatorId, userId).or().like(Meeting::getParticipants, String.valueOf(userId)));
}
wrapper.orderByDesc(Meeting::getCreatedAt).last("LIMIT " + limit);
return this.list(wrapper).stream().map(this::toVO).collect(Collectors.toList());
}
private MeetingVO toVO(Meeting meeting) {
MeetingVO vo = new MeetingVO();
vo.setId(meeting.getId());
vo.setTenantId(meeting.getTenantId());
vo.setCreatorId(meeting.getCreatorId());
vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime());
vo.setTags(meeting.getTags());
@ -188,28 +216,19 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
vo.setSummaryContent(meeting.getSummaryContent());
vo.setCreatedAt(meeting.getCreatedAt());
// 解析参与者 ID 列表为 姓名
if (meeting.getParticipants() != null && !meeting.getParticipants().isEmpty()) {
try {
List<Long> userIds = Arrays.stream(meeting.getParticipants().split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Long::valueOf)
.collect(Collectors.toList());
.map(String::trim).filter(s -> !s.isEmpty()).map(Long::valueOf).collect(Collectors.toList());
if (!userIds.isEmpty()) {
List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
String names = users.stream()
.map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername())
.collect(Collectors.joining(", "));
String names = users.stream().map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()).collect(Collectors.joining(", "));
vo.setParticipants(names);
}
} catch (Exception e) {
// 兼容老数据(如果以前存的是姓名)
vo.setParticipants(meeting.getParticipants());
}
}
return vo;
}
}

View File

@ -205,6 +205,11 @@ export default function AppLayout() {
label: <Link to="/profile">{t('layout.profile')}</Link>,
icon: <UserOutlined />
},
{
key: 'speaker',
label: <Link to="/speaker-reg"></Link>,
icon: <AudioOutlined />
}
];
let profile: any = {};

View File

@ -1,147 +1,187 @@
import { Row, Col, Card, Typography, Table, Tag, Space, Skeleton, Button } from "antd";
import React, { useState, useEffect } from 'react';
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
import {
VideoCameraOutlined,
DesktopOutlined,
UserOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
SyncOutlined,
ArrowRightOutlined
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import StatCard from "../components/shared/StatCard/StatCard";
import PageHeader from "../components/shared/PageHeader";
HistoryOutlined, CheckCircleOutlined, LoadingOutlined,
ArrowRightOutlined, AudioOutlined, RobotOutlined,
CalendarOutlined, TeamOutlined, RiseOutlined, ClockCircleOutlined,
PlayCircleOutlined, FileTextOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown';
import { getDashboardStats, getRecentTasks, DashboardStats } from '../api/business/dashboard';
import { MeetingVO } from '../api/business/meeting';
const { Title, Text } = Typography;
const { Title, Text, Paragraph } = Typography;
export default function Dashboard() {
const { t } = useTranslation();
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
const [loading, setLoading] = useState(true);
const recentMeetings = [
{ key: '1', name: '产品周会', type: '转录中', time: '2024-02-10 14:00', duration: '45min', status: 'processing' },
{ key: '2', name: '技术分享', type: '已完成', time: '2024-02-10 10:00', duration: '60min', status: 'success' },
{ key: '3', name: '部门早会', type: '已完成', time: '2024-02-10 09:00', duration: '15min', status: 'success' },
{ key: '4', name: '客户会议', type: '待开始', time: '2024-02-10 16:30', duration: '30min', status: 'default' },
];
useEffect(() => {
fetchDashboardData();
const timer = setInterval(fetchDashboardData, 5000); // 提高频率到 5 秒,感知更实时
return () => clearInterval(timer);
}, []);
const columns = [
{
title: t('dashboard.meetingName'),
dataIndex: 'name',
key: 'name',
render: (text: string) => <Text strong>{text}</Text>
},
{
title: t('dashboard.startTime'),
dataIndex: 'time',
key: 'time',
className: 'tabular-nums',
render: (text: string) => <Text type="secondary">{text}</Text>
},
{
title: t('dashboard.duration'),
dataIndex: 'duration',
key: 'duration',
width: 100,
className: 'tabular-nums'
},
{
title: t('common.status'),
dataIndex: 'status',
key: 'status',
width: 120,
render: (status: string) => {
if (status === 'processing') return <Tag icon={<SyncOutlined spin aria-hidden="true" />} color="processing"></Tag>;
if (status === 'success') return <Tag icon={<CheckCircleOutlined aria-hidden="true" />} color="success"></Tag>;
return <Tag color="default"></Tag>;
}
},
{
title: t('common.action'),
key: 'action',
width: 80,
render: () => <Button type="link" size="small" icon={<ArrowRightOutlined aria-hidden="true" />} aria-label={t('dashboard.viewAll')} />
const fetchDashboardData = async () => {
try {
const [statsRes, tasksRes] = await Promise.all([
getDashboardStats(),
getRecentTasks()
]);
setStats(statsRes.data.data);
setRecentTasks(tasksRes.data.data || []);
} catch (err) {
console.error('Dashboard data load failed', err);
} finally {
setLoading(false);
}
];
};
const renderTaskProgress = (item: MeetingVO) => {
// 0:待处理, 1:识别中, 2:总结中, 3:已完成, 4:失败
const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status);
return (
<div style={{ width: '100%', maxWidth: 450 }}>
<Steps
size="small"
current={currentStep}
status={item.status === 4 ? 'error' : (item.status === 3 ? 'finish' : 'process')}
items={[
{
title: '语音转录',
icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />,
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转换中' : '排队中')
},
{
title: '智能总结',
icon: item.status === 2 ? <LoadingOutlined spin /> : <RobotOutlined />,
description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在构思' : '待触发')
},
{
title: '分析完成',
icon: item.status === 3 ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : <FileTextOutlined />,
}
]}
/>
</div>
);
};
return (
<div className="dashboard-page p-6">
<PageHeader
title={t('dashboard.title')}
subtitle={t('dashboard.subtitle')}
extra={<Button icon={<SyncOutlined aria-hidden="true" />} size="small">{t('common.refresh')}</Button>}
/>
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}>
<StatCard
title={t('dashboard.todayMeetings')}
value={12}
icon={<VideoCameraOutlined aria-hidden="true" />}
color="blue"
trend={{ value: 8, direction: 'up' }}
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard
title={t('dashboard.activeDevices')}
value={45}
icon={<DesktopOutlined aria-hidden="true" />}
color="green"
trend={{ value: 2, direction: 'up' }}
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard
title={t('dashboard.transcriptionDuration')}
value={1280}
suffix="min"
icon={<ClockCircleOutlined aria-hidden="true" />}
color="orange"
trend={{ value: 5, direction: 'down' }}
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard
title={t('dashboard.totalUsers')}
value={320}
icon={<UserOutlined aria-hidden="true" />}
color="purple"
trend={{ value: 12, direction: 'up' }}
/>
</Col>
</Row>
<div style={{ padding: '24px', backgroundColor: '#f8f9fb', minHeight: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
{/* 顶部统计区 */}
<Row gutter={24} style={{ marginBottom: 24 }}>
{[
{ label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
{ label: '当前分析中任务', value: stats?.processingTasks, icon: <LoadingOutlined />, color: '#faad14' },
{ label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
{ label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
].map((s, idx) => (
<Col span={6} key={idx}>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
<Statistic
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
value={s.value || 0}
valueStyle={{ color: s.color, fontWeight: 700 }}
prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })}
/>
</Card>
</Col>
))}
</Row>
<Row gutter={[24, 24]} className="mt-6">
<Col xs={24} xl={16}>
<Card
title={t('dashboard.recentMeetings')}
bordered={false}
className="shadow-sm"
extra={<Button type="link" size="small">{t('dashboard.viewAll')}</Button>}
styles={{ body: { padding: 0 } }}
>
<Table
dataSource={recentMeetings}
columns={columns}
pagination={false}
size="middle"
className="roles-table"
/>
</Card>
</Col>
<Col xs={24} xl={8}>
<Card title={t('dashboard.deviceLoad')} bordered={false} className="shadow-sm">
<div className="flex flex-col items-center justify-center py-12">
<Skeleton active paragraph={{ rows: 4 }} />
<div className="mt-4 text-gray-400 flex items-center gap-2">
<SyncOutlined spin aria-hidden="true" />
<span></span>
</div>
{/* 核心任务流 - 垂直卡片列表 */}
<Card
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space><ClockCircleOutlined /> </Space>
<Button type="link" onClick={() => navigate('/meetings')}></Button>
</div>
</Card>
</Col>
</Row>
}
bordered={false}
style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}
>
<List
loading={loading}
dataSource={recentTasks}
renderItem={(item) => (
<List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}>
<div style={{ width: '100%' }}>
<Row gutter={32} align="middle">
{/* 左:会议基础信息 */}
<Col span={8}>
<Space direction="vertical" size={4}>
<Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}>
{item.title}
</Title>
<Space size={12} split={<Divider type="vertical" style={{ margin: 0 }} />}>
<Text type="secondary" size="small"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text>
<Text type="secondary" size="small"><TeamOutlined /> {item.participants || '系统记录'}</Text>
</Space>
<div style={{ marginTop: 8 }}>
{item.tags?.split(',').filter(Boolean).map(t => <Tag key={t} style={{ border: 'none', background: '#f0f5ff', color: '#1d39c4', borderRadius: 4, fontSize: 11 }}>{t}</Tag>)}
</div>
</Space>
</Col>
{/* 中:线性进度条 */}
<Col span={12}>
{renderTaskProgress(item)}
</Col>
{/* 右:操作入口 */}
<Col span={4} style={{ textAlign: 'right' }}>
<Button
type={item.status === 3 ? "primary" : "default"}
ghost={item.status === 3}
icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />}
onClick={() => navigate(`/meetings/${item.id}`)}
>
{item.status === 3 ? '查看纪要' : '监控详情'}
</Button>
</Col>
</Row>
{/* 针对已完成任务展示 AI 总结摘要 */}
{item.status === 3 && item.summaryContent && (
<div style={{
marginTop: 20,
padding: '16px 20px',
backgroundColor: '#fafafa',
borderRadius: 12,
border: '1px solid #f0f0f0',
marginLeft: '32px'
}}>
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}>
<RobotOutlined style={{ marginRight: 8, color: '#722ed1' }} />
<Text strong style={{ color: '#722ed1', fontSize: 13 }}>AI </Text>
</div>
<div className="summary-preview" style={{ maxHeight: 60, overflow: 'hidden' }}>
<ReactMarkdown>{item.summaryContent.length > 150 ? item.summaryContent.substring(0, 150) + '...' : item.summaryContent}</ReactMarkdown>
</div>
</div>
)}
</div>
</List.Item>
)}
locale={{ emptyText: <Empty description="暂无近期分析任务" /> }}
/>
</Card>
</div>
<style>{`
.summary-preview p { margin-bottom: 0; font-size: 13px; color: #595959; }
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
.ant-steps-item-description { font-size: 11px !important; }
`}</style>
</div>
);
}
};
export default Dashboard;

View File

@ -1,9 +1,17 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Card, Button, Form, Input, Space, message, Typography, Divider, List, Tag } from 'antd';
import { AudioOutlined, StopOutlined, CloudUploadOutlined, DeleteOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { Card, Button, Space, message, Typography, Divider, Tag, Progress, Row, Col, Empty, Badge } from 'antd';
import {
AudioOutlined, StopOutlined, CloudUploadOutlined,
DeleteOutlined, CheckCircleOutlined, InfoCircleOutlined,
ClockCircleOutlined, FormOutlined
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { registerSpeaker, getSpeakerList, SpeakerVO } from '../../api/business/speaker';
const { Title, Text } = Typography;
const { Title, Text, Paragraph } = Typography;
const REG_CONTENT = "iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。";
const DEFAULT_DURATION = 10; // 默认录音时长 10 秒
const SpeakerReg: React.FC = () => {
const [recording, setRecording] = useState(false);
@ -13,6 +21,10 @@ const SpeakerReg: React.FC = () => {
const [existingSpeaker, setExistingSpeaker] = useState<SpeakerVO | null>(null);
const [listLoading, setListLoading] = useState(false);
// 计时状态
const [seconds, setSeconds] = useState(0);
const timerRef = useRef<any>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
@ -28,13 +40,14 @@ const SpeakerReg: React.FC = () => {
useEffect(() => {
fetchSpeakers();
return () => stopTimer();
}, []);
const fetchSpeakers = async () => {
setListLoading(true);
try {
const res = await getSpeakerList();
if (res.data && Array.isArray(res.data.data) && res.data.data.length > 0) {
if (res.data && res.data.data && res.data.data.length > 0) {
setExistingSpeaker(res.data.data[0]);
} else {
setExistingSpeaker(null);
@ -46,6 +59,26 @@ const SpeakerReg: React.FC = () => {
}
};
const startTimer = () => {
setSeconds(0);
timerRef.current = setInterval(() => {
setSeconds(prev => {
if (prev + 1 >= DEFAULT_DURATION) {
stopRecording();
return DEFAULT_DURATION;
}
return prev + 1;
});
}, 1000);
};
const stopTimer = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@ -54,31 +87,32 @@ 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 = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' });
setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob));
stopTimer();
};
mediaRecorder.start();
setRecording(true);
setAudioBlob(null);
setAudioUrl(null);
startTimer();
} catch (err) {
message.error('无法访问麦克风,请检查权限设置');
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && recording) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
setRecording(false);
stopTimer();
}
};
@ -90,12 +124,11 @@ const SpeakerReg: React.FC = () => {
setLoading(true);
try {
// 后端会自动获取当前登录人作为 name 和 userId
await registerSpeaker({
name: '', // 后端会覆盖此值
name: '',
file: audioBlob
});
message.success(existingSpeaker ? '声纹更新成功' : '声纹注册成功');
message.success(existingSpeaker ? '声纹更新' : '声纹注册成功');
setAudioBlob(null);
setAudioUrl(null);
fetchSpeakers();
@ -107,94 +140,133 @@ const SpeakerReg: React.FC = () => {
};
return (
<div style={{ padding: '24px', height: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<Title level={3}></Title>
<Text type="secondary"></Text>
<div style={{ padding: '32px 24px', height: '100%', overflowY: 'auto', backgroundColor: '#f8f9fa' }}>
<div style={{ maxWidth: 800, margin: '0 auto' }}>
<Title level={3}></Title>
<Text type="secondary"> AI </Text>
<Card style={{ marginTop: '24px', boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }} loading={listLoading}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{existingSpeaker ? (
<div style={{ textAlign: 'center', marginBottom: '24px', width: '100%', background: '#f6ffed', padding: '16px', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
<Tag color="success" icon={<CheckCircleOutlined />} style={{ marginBottom: '8px' }}></Tag>
<div style={{ marginBottom: '8px' }}>
<Text strong>线</Text>
<Text type="secondary" size="small">
( {new Date(existingSpeaker.createdAt).toLocaleString()})
<Row gutter={24} style={{ marginTop: 24 }}>
{/* 左侧:采集与录音 */}
<Col span={15}>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
<Divider orientation="left" style={{ marginTop: 0 }}><FormOutlined /> </Divider>
<div style={{
padding: '24px',
backgroundColor: '#fafafa',
borderRadius: 12,
border: '1px solid #f0f0f0',
marginBottom: 24,
textAlign: 'center'
}}>
<Paragraph style={{ fontSize: 18, color: recording ? '#1890ff' : '#262626', fontWeight: 500, lineHeight: 1.8 }}>
{REG_CONTENT}
</Paragraph>
<Text type="secondary" size="small"></Text>
</div>
<div style={{ textAlign: 'center', margin: '32px 0' }}>
<div style={{ position: 'relative', display: 'inline-block', marginBottom: 16 }}>
{!recording ? (
<Button
type="primary"
danger
shape="circle"
style={{ width: 80, height: 80, boxShadow: '0 4px 14px rgba(255, 77, 79, 0.3)' }}
icon={<AudioOutlined style={{ fontSize: 32 }} />}
onClick={startRecording}
/>
) : (
<Button
type="primary"
shape="circle"
style={{ width: 80, height: 80, boxShadow: '0 4px 14px rgba(24, 144, 255, 0.3)' }}
icon={<StopOutlined style={{ fontSize: 32 }} />}
onClick={stopRecording}
/>
)}
{recording && (
<div style={{ position: 'absolute', top: -10, right: -10 }}>
<Badge count={`${DEFAULT_DURATION - seconds}s`} color="#ff4d4f" />
</div>
)}
</div>
<div>
<Text strong type={recording ? "danger" : "secondary"}>
{recording ? `录制中... ${seconds}/${DEFAULT_DURATION}s` : "点击红色图标开始录音"}
</Text>
</div>
<audio
src={`${resourcePrefix}${existingSpeaker.voicePath}`}
controls
style={{ width: '100%', height: '32px' }}
/>
{recording && (
<div style={{ maxWidth: 300, margin: '16px auto 0' }}>
<Progress percent={(seconds / DEFAULT_DURATION) * 100} showInfo={false} strokeColor="#ff4d4f" />
</div>
)}
</div>
) : (
<div style={{ textAlign: 'center', marginBottom: '24px', padding: '16px', background: '#fff7e6', borderRadius: '8px', border: '1px solid #ffd591', width: '100%' }}>
<Tag color="warning" style={{ marginBottom: '8px' }}></Tag>
<p style={{ margin: 0 }}> 5-10 </p>
</div>
)}
<Divider style={{ margin: '12px 0' }}>{existingSpeaker ? '更新声纹' : '开始采集'}</Divider>
<div style={{ textAlign: 'center', margin: '20px 0' }}>
{!recording ? (
<Button
type="primary"
danger
shape="circle"
style={{ width: 70, height: 70, boxShadow: '0 4px 10px rgba(255, 77, 79, 0.3)' }}
icon={<AudioOutlined style={{ fontSize: 28 }} />}
onClick={startRecording}
/>
) : (
<Button
type="primary"
shape="circle"
style={{ width: 70, height: 70, boxShadow: '0 4px 10px rgba(22, 119, 255, 0.3)' }}
icon={<StopOutlined style={{ fontSize: 28 }} />}
onClick={stopRecording}
/>
{audioUrl && (
<div style={{ backgroundColor: '#f0f5ff', padding: '16px', borderRadius: 12, marginBottom: 24, border: '1px solid #adc6ff' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
<Text strong></Text>
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => { setAudioBlob(null); setAudioUrl(null); }}></Button>
</div>
<audio src={audioUrl} controls style={{ width: '100%', height: 32 }} />
</div>
)}
<div style={{ marginTop: '12px' }}>
<Text strong type={recording ? "danger" : "secondary"}>
{recording ? "录制中,请持续说话..." : "点击红色图标开始录音"}
</Text>
</div>
</div>
{audioUrl && (
<div style={{ width: '100%', textAlign: 'center', background: '#f0f5ff', padding: '16px', borderRadius: '8px', marginBottom: '20px', border: '1px solid #adc6ff' }}>
<Text strong></Text>
<audio src={audioUrl} controls style={{ width: '100%', marginTop: '8px', height: '32px' }} />
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => { setAudioBlob(null); setAudioUrl(null); }}
style={{ marginTop: '4px' }}
>
</Button>
</div>
)}
<Button
type="primary"
size="large"
block
icon={<CloudUploadOutlined />}
onClick={handleSubmit}
loading={loading}
disabled={recording || !audioBlob}
style={{ height: 50, borderRadius: 8, fontSize: 16, fontWeight: 600 }}
>
{existingSpeaker ? '覆盖原有声纹' : '提交保存声纹'}
</Button>
</Card>
</Col>
<Button
type="primary"
size="large"
icon={<CloudUploadOutlined />}
onClick={handleSubmit}
loading={loading}
disabled={recording || !audioBlob}
style={{ width: '100%', height: '45px', marginTop: '10px' }}
>
{existingSpeaker ? '立即覆盖原有声纹' : '提交保存声纹'}
</Button>
</div>
</Card>
{/* 右侧:当前状态 */}
<Col span={9}>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Card title={<span><CheckCircleOutlined /> </span>} bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
{existingSpeaker ? (
<div style={{ textAlign: 'center' }}>
<Tag color="success" style={{ marginBottom: 16, padding: '4px 12px' }}></Tag>
<div style={{ marginBottom: 16 }}>
<Text type="secondary" size="small" style={{ display: 'block' }}></Text>
<Text strong>{dayjs(existingSpeaker.createdAt).format('YYYY-MM-DD HH:mm')}</Text>
</div>
<Divider style={{ margin: '12px 0' }} />
<audio
src={`${resourcePrefix}${existingSpeaker.voicePath}`}
controls
style={{ width: '100%', height: 32 }}
/>
</div>
) : (
<Empty description="尚未录入声纹" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
<Card size="small" bordered={false} style={{ borderRadius: 16, backgroundColor: '#fffbe6', border: '1px solid #ffe58f' }}>
<Space align="start">
<InfoCircleOutlined style={{ color: '#faad14', marginTop: 4 }} />
<div style={{ fontSize: 12, color: '#856404' }}>
<b></b><br/>
1. <br/>
2. 使<br/>
3.
</div>
</Space>
</Card>
</Space>
</Col>
</Row>
</div>
</div>
);

View File

@ -37,7 +37,6 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/devices", label: "设备管理", element: <Devices />, perm: "menu:devices" },
{ path: "/user-roles", label: "用户角色绑定", element: <UserRoleBinding />, perm: "menu:user-roles" },
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" },
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg />, perm: "menu:speaker" },
{ path: "/hotwords", label: "热词管理", element: <HotWords />, perm: "menu:hotword" },
{ path: "/prompts", label: "总结模板", element: <PromptTemplates />, perm: "menu:prompt" },
{ path: "/aimodels", label: "模型配置", element: <AiModels />, perm: "menu:aimodel" },
@ -46,5 +45,6 @@ export const menuRoutes: MenuRoute[] = [
];
export const extraRoutes = [
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" }
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" },
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg /> }
];