feat(layout): 添加声纹注册菜单项并重构仪表板页面
- 在应用布局的导航菜单中添加声纹注册功能入口 - 重构仪表板页面实现动态数据加载和实时任务状态显示 - 实现会议任务的分步处理进度可视化展示 - 优化声纹注册页面的用户体验和交互流程 - 完善会议管理的权限控制逻辑 - 实现仪表板统计数据的实时轮询更新dev_na
parent
70e739818a
commit
5e4a2aa2d1
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {};
|
||||||
|
|
|
||||||
|
|
@ -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} style={{ marginBottom: 24 }}>
|
||||||
/>
|
{[
|
||||||
|
{ label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
|
||||||
<Row gutter={[24, 24]}>
|
{ label: '当前分析中任务', value: stats?.processingTasks, icon: <LoadingOutlined />, color: '#faad14' },
|
||||||
<Col xs={24} sm={12} lg={6}>
|
{ label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
|
||||||
<StatCard
|
{ label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
|
||||||
title={t('dashboard.todayMeetings')}
|
].map((s, idx) => (
|
||||||
value={12}
|
<Col span={6} key={idx}>
|
||||||
icon={<VideoCameraOutlined aria-hidden="true" />}
|
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
|
||||||
color="blue"
|
<Statistic
|
||||||
trend={{ value: 8, direction: 'up' }}
|
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
|
||||||
/>
|
value={s.value || 0}
|
||||||
</Col>
|
valueStyle={{ color: s.color, fontWeight: 700 }}
|
||||||
<Col xs={24} sm={12} lg={6}>
|
prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })}
|
||||||
<StatCard
|
/>
|
||||||
title={t('dashboard.activeDevices')}
|
</Card>
|
||||||
value={45}
|
</Col>
|
||||||
icon={<DesktopOutlined aria-hidden="true" />}
|
))}
|
||||||
color="green"
|
</Row>
|
||||||
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, 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;
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
{existingSpeaker ? (
|
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
|
||||||
<div style={{ textAlign: 'center', marginBottom: '24px', width: '100%', background: '#f6ffed', padding: '16px', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
|
<Divider orientation="left" style={{ marginTop: 0 }}><FormOutlined /> 录音指导文本</Divider>
|
||||||
<Tag color="success" icon={<CheckCircleOutlined />} style={{ marginBottom: '8px' }}>已完成注册</Tag>
|
|
||||||
<div style={{ marginBottom: '8px' }}>
|
<div style={{
|
||||||
<Text strong>当前在线声纹:</Text>
|
padding: '24px',
|
||||||
<Text type="secondary" size="small">
|
backgroundColor: '#fafafa',
|
||||||
(更新于 {new Date(existingSpeaker.createdAt).toLocaleString()})
|
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>
|
</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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 /> }
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue