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 @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) { public ApiResponse<List<SysUser>> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) {
Long currentTenantId = getCurrentTenantId(); Long currentTenantId = getCurrentTenantId();
List<SysUser> users; List<SysUser> users;

View File

@ -61,10 +61,12 @@ public class MeetingController {
@RequestParam(defaultValue = "all") String viewType) { @RequestParam(defaultValue = "all") String viewType) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); 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, return ApiResponse.ok(meetingService.pageMeetings(current, size, title,
loginUser.getTenantId(), loginUser.getUserId(), loginUser.getTenantId(), loginUser.getUserId(),
loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(), loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(),
viewType)); viewType, isAdmin));
} }
@GetMapping("/detail/{id}") @GetMapping("/detail/{id}")
@ -110,7 +112,7 @@ public class MeetingController {
if (existing == null) return ApiResponse.error("会议不存在"); 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("无权修改此会议信息"); return ApiResponse.error("无权修改此会议信息");
} }
@ -121,6 +123,14 @@ public class MeetingController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) { 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); meetingService.deleteMeeting(id);
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }

View File

@ -12,10 +12,12 @@ import java.util.List;
public interface MeetingService extends IService<Meeting> { public interface MeetingService extends IService<Meeting> {
MeetingVO createMeeting(MeetingDTO dto); 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); void deleteMeeting(Long id);
MeetingVO getDetail(Long id); MeetingVO getDetail(Long id);
List<MeetingTranscriptVO> getTranscripts(Long meetingId); List<MeetingTranscriptVO> getTranscripts(Long meetingId);
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);
void reSummary(Long meetingId, Long summaryModelId, Long promptId); 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -48,13 +49,11 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
Meeting meeting = new Meeting(); Meeting meeting = new Meeting();
meeting.setTitle(dto.getTitle()); meeting.setTitle(dto.getTitle());
meeting.setMeetingTime(dto.getMeetingTime()); meeting.setMeetingTime(dto.getMeetingTime());
// 存储 User ID 字符串
meeting.setParticipants(dto.getParticipants()); meeting.setParticipants(dto.getParticipants());
meeting.setTags(dto.getTags()); meeting.setTags(dto.getTags());
meeting.setAudioUrl(dto.getAudioUrl()); meeting.setAudioUrl(dto.getAudioUrl());
meeting.setAsrModelId(dto.getAsrModelId()); meeting.setAsrModelId(dto.getAsrModelId());
meeting.setSummaryModelId(dto.getSummaryModelId()); meeting.setSummaryModelId(dto.getSummaryModelId());
meeting.setCreatorId(dto.getCreatorId()); meeting.setCreatorId(dto.getCreatorId());
meeting.setCreatorName(dto.getCreatorName()); meeting.setCreatorName(dto.getCreatorName());
@ -67,7 +66,10 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
List<String> finalHotWords = dto.getHotWords(); List<String> finalHotWords = dto.getHotWords();
if (finalHotWords == null || finalHotWords.isEmpty()) { 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); meeting.setHotWords(finalHotWords);
@ -77,29 +79,25 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
return toVO(meeting); 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 @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>() LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>()
.eq(Meeting::getTenantId, tenantId); .eq(Meeting::getTenantId, tenantId);
String userIdStr = String.valueOf(userId); if (isAdmin && "all".equals(viewType)) {
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);
} else { } else {
wrapper.and(w -> w.eq(Meeting::getCreatorId, userId) String userIdStr = String.valueOf(userId);
.or() if ("created".equals(viewType)) {
.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); 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()) { if (title != null && !title.isEmpty()) {
@ -176,10 +174,40 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
aiTaskService.dispatchSummaryTask(meetingId); 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) { private MeetingVO toVO(Meeting meeting) {
MeetingVO vo = new MeetingVO(); MeetingVO vo = new MeetingVO();
vo.setId(meeting.getId()); vo.setId(meeting.getId());
vo.setTenantId(meeting.getTenantId()); vo.setTenantId(meeting.getTenantId());
vo.setCreatorId(meeting.getCreatorId());
vo.setTitle(meeting.getTitle()); vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime()); vo.setMeetingTime(meeting.getMeetingTime());
vo.setTags(meeting.getTags()); vo.setTags(meeting.getTags());
@ -188,28 +216,19 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
vo.setSummaryContent(meeting.getSummaryContent()); vo.setSummaryContent(meeting.getSummaryContent());
vo.setCreatedAt(meeting.getCreatedAt()); vo.setCreatedAt(meeting.getCreatedAt());
// 解析参与者 ID 列表为 姓名
if (meeting.getParticipants() != null && !meeting.getParticipants().isEmpty()) { if (meeting.getParticipants() != null && !meeting.getParticipants().isEmpty()) {
try { try {
List<Long> userIds = Arrays.stream(meeting.getParticipants().split(",")) List<Long> userIds = Arrays.stream(meeting.getParticipants().split(","))
.map(String::trim) .map(String::trim).filter(s -> !s.isEmpty()).map(Long::valueOf).collect(Collectors.toList());
.filter(s -> !s.isEmpty())
.map(Long::valueOf)
.collect(Collectors.toList());
if (!userIds.isEmpty()) { if (!userIds.isEmpty()) {
List<SysUser> users = sysUserMapper.selectBatchIds(userIds); List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
String names = users.stream() String names = users.stream().map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()).collect(Collectors.joining(", "));
.map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername())
.collect(Collectors.joining(", "));
vo.setParticipants(names); vo.setParticipants(names);
} }
} catch (Exception e) { } catch (Exception e) {
// 兼容老数据(如果以前存的是姓名)
vo.setParticipants(meeting.getParticipants()); vo.setParticipants(meeting.getParticipants());
} }
} }
return vo; return vo;
} }
} }

View File

@ -205,6 +205,11 @@ export default function AppLayout() {
label: <Link to="/profile">{t('layout.profile')}</Link>, label: <Link to="/profile">{t('layout.profile')}</Link>,
icon: <UserOutlined /> icon: <UserOutlined />
}, },
{
key: 'speaker',
label: <Link to="/speaker-reg"></Link>,
icon: <AudioOutlined />
}
]; ];
let profile: any = {}; 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 { import {
VideoCameraOutlined, HistoryOutlined, CheckCircleOutlined, LoadingOutlined,
DesktopOutlined, ArrowRightOutlined, AudioOutlined, RobotOutlined,
UserOutlined, CalendarOutlined, TeamOutlined, RiseOutlined, ClockCircleOutlined,
ClockCircleOutlined, PlayCircleOutlined, FileTextOutlined
CheckCircleOutlined, } from '@ant-design/icons';
SyncOutlined, import { useNavigate } from 'react-router-dom';
ArrowRightOutlined import dayjs from 'dayjs';
} from "@ant-design/icons"; import ReactMarkdown from 'react-markdown';
import { useTranslation } from "react-i18next"; import { getDashboardStats, getRecentTasks, DashboardStats } from '../api/business/dashboard';
import StatCard from "../components/shared/StatCard/StatCard"; import { MeetingVO } from '../api/business/meeting';
import PageHeader from "../components/shared/PageHeader";
const { Title, Text } = Typography; const { Title, Text, Paragraph } = Typography;
export default function Dashboard() { const Dashboard: React.FC = () => {
const { t } = useTranslation(); const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
const [loading, setLoading] = useState(true);
const recentMeetings = [ useEffect(() => {
{ key: '1', name: '产品周会', type: '转录中', time: '2024-02-10 14:00', duration: '45min', status: 'processing' }, fetchDashboardData();
{ key: '2', name: '技术分享', type: '已完成', time: '2024-02-10 10:00', duration: '60min', status: 'success' }, const timer = setInterval(fetchDashboardData, 5000); // 提高频率到 5 秒,感知更实时
{ key: '3', name: '部门早会', type: '已完成', time: '2024-02-10 09:00', duration: '15min', status: 'success' }, return () => clearInterval(timer);
{ key: '4', name: '客户会议', type: '待开始', time: '2024-02-10 16:30', duration: '30min', status: 'default' }, }, []);
];
const columns = [ const fetchDashboardData = async () => {
{ try {
title: t('dashboard.meetingName'), const [statsRes, tasksRes] = await Promise.all([
dataIndex: 'name', getDashboardStats(),
key: 'name', getRecentTasks()
render: (text: string) => <Text strong>{text}</Text> ]);
}, setStats(statsRes.data.data);
{ setRecentTasks(tasksRes.data.data || []);
title: t('dashboard.startTime'), } catch (err) {
dataIndex: 'time', console.error('Dashboard data load failed', err);
key: 'time', } finally {
className: 'tabular-nums', setLoading(false);
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 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 ( return (
<div className="dashboard-page p-6"> <div style={{ padding: '24px', backgroundColor: '#f8f9fb', minHeight: '100%', overflowY: 'auto' }}>
<PageHeader <div style={{ maxWidth: 1400, margin: '0 auto' }}>
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}> <Row gutter={24} style={{ marginBottom: 24 }}>
<StatCard {[
title={t('dashboard.todayMeetings')} { label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
value={12} { label: '当前分析中任务', value: stats?.processingTasks, icon: <LoadingOutlined />, color: '#faad14' },
icon={<VideoCameraOutlined aria-hidden="true" />} { label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
color="blue" { label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
trend={{ value: 8, direction: 'up' }} ].map((s, idx) => (
/> <Col span={6} key={idx}>
</Col> <Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
<Col xs={24} sm={12} lg={6}> <Statistic
<StatCard title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
title={t('dashboard.activeDevices')} value={s.value || 0}
value={45} valueStyle={{ color: s.color, fontWeight: 700 }}
icon={<DesktopOutlined aria-hidden="true" />} prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })}
color="green" />
trend={{ value: 2, direction: 'up' }} </Card>
/> </Col>
</Col> ))}
<Col xs={24} sm={12} lg={6}> </Row>
<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>
<Row gutter={[24, 24]} className="mt-6"> {/* 核心任务流 - 垂直卡片列表 */}
<Col xs={24} xl={16}> <Card
<Card title={
title={t('dashboard.recentMeetings')} <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
bordered={false} <Space><ClockCircleOutlined /> </Space>
className="shadow-sm" <Button type="link" onClick={() => navigate('/meetings')}></Button>
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>
</div> </div>
</Card> }
</Col> bordered={false}
</Row> 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> </div>
); );
} };
export default Dashboard;

View File

@ -1,9 +1,17 @@
import React, { useState, useRef, useEffect, useMemo } from 'react'; import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Card, Button, Form, Input, Space, message, Typography, Divider, List, Tag } from 'antd'; import { Card, Button, Space, message, Typography, Divider, Tag, Progress, Row, Col, Empty, Badge } from 'antd';
import { AudioOutlined, StopOutlined, CloudUploadOutlined, DeleteOutlined, CheckCircleOutlined } from '@ant-design/icons'; 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'; 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 SpeakerReg: React.FC = () => {
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
@ -13,6 +21,10 @@ const SpeakerReg: React.FC = () => {
const [existingSpeaker, setExistingSpeaker] = useState<SpeakerVO | null>(null); const [existingSpeaker, setExistingSpeaker] = useState<SpeakerVO | null>(null);
const [listLoading, setListLoading] = useState(false); const [listLoading, setListLoading] = useState(false);
// 计时状态
const [seconds, setSeconds] = useState(0);
const timerRef = useRef<any>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]); const audioChunksRef = useRef<Blob[]>([]);
@ -28,13 +40,14 @@ const SpeakerReg: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchSpeakers(); fetchSpeakers();
return () => stopTimer();
}, []); }, []);
const fetchSpeakers = async () => { const fetchSpeakers = async () => {
setListLoading(true); setListLoading(true);
try { try {
const res = await getSpeakerList(); 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]); setExistingSpeaker(res.data.data[0]);
} else { } else {
setExistingSpeaker(null); 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 () => { const startRecording = async () => {
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@ -54,31 +87,32 @@ 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 = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' }); const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' });
setAudioBlob(blob); setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob)); setAudioUrl(URL.createObjectURL(blob));
stopTimer();
}; };
mediaRecorder.start(); mediaRecorder.start();
setRecording(true); setRecording(true);
setAudioBlob(null); setAudioBlob(null);
setAudioUrl(null); setAudioUrl(null);
startTimer();
} catch (err) { } catch (err) {
message.error('无法访问麦克风,请检查权限设置'); message.error('无法访问麦克风,请检查权限设置');
} }
}; };
const stopRecording = () => { const stopRecording = () => {
if (mediaRecorderRef.current && recording) { if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop(); mediaRecorderRef.current.stop();
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop()); mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
setRecording(false); setRecording(false);
stopTimer();
} }
}; };
@ -90,12 +124,11 @@ const SpeakerReg: React.FC = () => {
setLoading(true); setLoading(true);
try { try {
// 后端会自动获取当前登录人作为 name 和 userId
await registerSpeaker({ await registerSpeaker({
name: '', // 后端会覆盖此值 name: '',
file: audioBlob file: audioBlob
}); });
message.success(existingSpeaker ? '声纹更新成功' : '声纹注册成功'); message.success(existingSpeaker ? '声纹更新' : '声纹注册成功');
setAudioBlob(null); setAudioBlob(null);
setAudioUrl(null); setAudioUrl(null);
fetchSpeakers(); fetchSpeakers();
@ -107,94 +140,133 @@ const SpeakerReg: React.FC = () => {
}; };
return ( return (
<div style={{ padding: '24px', height: '100%', overflowY: 'auto' }}> <div style={{ padding: '32px 24px', height: '100%', overflowY: 'auto', backgroundColor: '#f8f9fa' }}>
<div style={{ maxWidth: 600, margin: '0 auto' }}> <div style={{ maxWidth: 800, margin: '0 auto' }}>
<Title level={3}></Title> <Title level={3}></Title>
<Text type="secondary"></Text> <Text type="secondary"> AI </Text>
<Card style={{ marginTop: '24px', boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }} loading={listLoading}> <Row gutter={24} style={{ marginTop: 24 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}> {/* 左侧:采集与录音 */}
<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>
{existingSpeaker ? ( <div style={{
<div style={{ textAlign: 'center', marginBottom: '24px', width: '100%', background: '#f6ffed', padding: '16px', borderRadius: '8px', border: '1px solid #b7eb8f' }}> padding: '24px',
<Tag color="success" icon={<CheckCircleOutlined />} style={{ marginBottom: '8px' }}></Tag> backgroundColor: '#fafafa',
<div style={{ marginBottom: '8px' }}> borderRadius: 12,
<Text strong>线</Text> border: '1px solid #f0f0f0',
<Text type="secondary" size="small"> marginBottom: 24,
( {new Date(existingSpeaker.createdAt).toLocaleString()}) 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> </Text>
</div> </div>
<audio {recording && (
src={`${resourcePrefix}${existingSpeaker.voicePath}`} <div style={{ maxWidth: 300, margin: '16px auto 0' }}>
controls <Progress percent={(seconds / DEFAULT_DURATION) * 100} showInfo={false} strokeColor="#ff4d4f" />
style={{ width: '100%', height: '32px' }} </div>
/> )}
</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> {audioUrl && (
<div style={{ backgroundColor: '#f0f5ff', padding: '16px', borderRadius: 12, marginBottom: 24, border: '1px solid #adc6ff' }}>
<div style={{ textAlign: 'center', margin: '20px 0' }}> <div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
{!recording ? ( <Text strong></Text>
<Button <Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => { setAudioBlob(null); setAudioUrl(null); }}></Button>
type="primary" </div>
danger <audio src={audioUrl} controls style={{ width: '100%', height: 32 }} />
shape="circle" </div>
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}
/>
)} )}
<div style={{ marginTop: '12px' }}>
<Text strong type={recording ? "danger" : "secondary"}>
{recording ? "录制中,请持续说话..." : "点击红色图标开始录音"}
</Text>
</div>
</div>
{audioUrl && ( <Button
<div style={{ width: '100%', textAlign: 'center', background: '#f0f5ff', padding: '16px', borderRadius: '8px', marginBottom: '20px', border: '1px solid #adc6ff' }}> type="primary"
<Text strong></Text> size="large"
<audio src={audioUrl} controls style={{ width: '100%', marginTop: '8px', height: '32px' }} /> block
<Button icon={<CloudUploadOutlined />}
type="link" onClick={handleSubmit}
danger loading={loading}
size="small" disabled={recording || !audioBlob}
icon={<DeleteOutlined />} style={{ height: 50, borderRadius: 8, fontSize: 16, fontWeight: 600 }}
onClick={() => { setAudioBlob(null); setAudioUrl(null); }} >
style={{ marginTop: '4px' }} {existingSpeaker ? '覆盖原有声纹' : '提交保存声纹'}
> </Button>
</Card>
</Button> </Col>
</div>
)}
<Button {/* 右侧:当前状态 */}
type="primary" <Col span={9}>
size="large" <Space direction="vertical" size={24} style={{ width: '100%' }}>
icon={<CloudUploadOutlined />} <Card title={<span><CheckCircleOutlined /> </span>} bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
onClick={handleSubmit} {existingSpeaker ? (
loading={loading} <div style={{ textAlign: 'center' }}>
disabled={recording || !audioBlob} <Tag color="success" style={{ marginBottom: 16, padding: '4px 12px' }}></Tag>
style={{ width: '100%', height: '45px', marginTop: '10px' }} <div style={{ marginBottom: 16 }}>
> <Text type="secondary" size="small" style={{ display: 'block' }}></Text>
{existingSpeaker ? '立即覆盖原有声纹' : '提交保存声纹'} <Text strong>{dayjs(existingSpeaker.createdAt).format('YYYY-MM-DD HH:mm')}</Text>
</Button> </div>
</div> <Divider style={{ margin: '12px 0' }} />
</Card> <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>
</div> </div>
); );

View File

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