feat(layout): 添加声纹注册菜单项并重构仪表板页面
- 在应用布局的导航菜单中添加声纹注册功能入口 - 重构仪表板页面实现动态数据加载和实时任务状态显示 - 实现会议任务的分步处理进度可视化展示 - 优化声纹注册页面的用户体验和交互流程 - 完善会议管理的权限控制逻辑 - 实现仪表板统计数据的实时轮询更新dev_na
parent
70e739818a
commit
5e4a2aa2d1
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
/>
|
||||
<div style={{ padding: '24px', backgroundColor: '#f8f9fb', minHeight: '100%', overflowY: 'auto' }}>
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
|
||||
<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>
|
||||
{/* 顶部统计区 */}
|
||||
<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;
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
<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>
|
||||
|
||||
{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()})
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 /> }
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in New Issue