feat: 添加分页查询和前端搜索功能
- 在 `SpeakerController` 中添加分页查询接口 - 在 `SpeakerServiceImpl` 中实现分页查询逻辑 - 更新前端API和组件,支持分页查询和按名称搜索声纹记录dev_na
parent
5da9a97d55
commit
4f42fb50ad
|
|
@ -4,6 +4,7 @@ import com.imeeting.dto.biz.SpeakerRegisterDTO;
|
|||
import com.imeeting.dto.biz.SpeakerVO;
|
||||
import com.imeeting.service.biz.SpeakerService;
|
||||
import com.unisbase.common.ApiResponse;
|
||||
import com.unisbase.dto.PageResult;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
|
@ -32,6 +33,19 @@ public class SpeakerController {
|
|||
return ApiResponse.ok(speakerService.register(registerDTO, loginUser));
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<PageResult<List<SpeakerVO>>> page(
|
||||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "8") Integer size,
|
||||
@RequestParam(required = false) String name) {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser == null || loginUser.getUserId() == null) {
|
||||
return ApiResponse.error("未获取到用户信息");
|
||||
}
|
||||
return ApiResponse.ok(speakerService.pageVisible(current, size, name, loginUser));
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<List<SpeakerVO>> list() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
|||
import com.imeeting.dto.biz.SpeakerRegisterDTO;
|
||||
import com.imeeting.dto.biz.SpeakerVO;
|
||||
import com.imeeting.entity.biz.Speaker;
|
||||
import com.unisbase.dto.PageResult;
|
||||
import com.unisbase.security.LoginUser;
|
||||
|
||||
import java.util.List;
|
||||
|
|
@ -11,6 +12,8 @@ import java.util.List;
|
|||
public interface SpeakerService extends IService<Speaker> {
|
||||
SpeakerVO register(SpeakerRegisterDTO registerDTO, LoginUser loginUser);
|
||||
|
||||
PageResult<List<SpeakerVO>> pageVisible(Integer current, Integer size, String name, LoginUser loginUser);
|
||||
|
||||
List<SpeakerVO> listVisible(LoginUser loginUser);
|
||||
|
||||
void deleteSpeaker(Long id, LoginUser loginUser);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
|
|
@ -9,6 +10,8 @@ import com.imeeting.entity.biz.Speaker;
|
|||
import com.imeeting.mapper.biz.SpeakerMapper;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.SpeakerService;
|
||||
import com.unisbase.annotation.DataScope;
|
||||
import com.unisbase.dto.PageResult;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
|
@ -103,6 +106,29 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
|||
return toVO(speaker);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<List<SpeakerVO>> pageVisible(Integer current, Integer size, String name, LoginUser loginUser) {
|
||||
boolean admin = isAdmin(loginUser);
|
||||
String normalizedName = name == null ? null : name.trim();
|
||||
|
||||
Page<Speaker> page = this.lambdaQuery()
|
||||
.eq(Speaker::getTenantId, loginUser.getTenantId())
|
||||
.eq(!admin, Speaker::getUserId, loginUser.getUserId())
|
||||
.like(normalizedName != null && !normalizedName.isEmpty(), Speaker::getName, normalizedName)
|
||||
.orderByDesc(Speaker::getUpdatedAt)
|
||||
.page(new Page<>(current, size));
|
||||
|
||||
List<SpeakerVO> records = new ArrayList<>(page.getRecords().size());
|
||||
for (Speaker speaker : page.getRecords()) {
|
||||
records.add(toVO(speaker));
|
||||
}
|
||||
|
||||
PageResult<List<SpeakerVO>> result = new PageResult<>();
|
||||
result.setTotal(page.getTotal());
|
||||
result.setRecords(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SpeakerVO> listVisible(LoginUser loginUser) {
|
||||
boolean admin = isAdmin(loginUser);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ export interface SpeakerRegisterParams {
|
|||
file?: File | Blob;
|
||||
}
|
||||
|
||||
export interface SpeakerPageParams {
|
||||
current: number;
|
||||
size: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const registerSpeaker = (params: SpeakerRegisterParams) => {
|
||||
const formData = new FormData();
|
||||
if (params.id) formData.append("id", params.id.toString());
|
||||
|
|
@ -50,6 +56,13 @@ export const getSpeakerList = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export const getSpeakerPage = (params: SpeakerPageParams) => {
|
||||
return http.get<any, { code: string; data: { records: SpeakerVO[]; total: number }; msg: string }>(
|
||||
"/api/biz/speaker/page",
|
||||
{ params }
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteSpeaker = (id: number) => {
|
||||
return http.delete<any, { code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/speaker/${id}`
|
||||
|
|
|
|||
|
|
@ -6,41 +6,51 @@ import {
|
|||
DeleteOutlined,
|
||||
FormOutlined,
|
||||
InfoCircleOutlined,
|
||||
SearchOutlined,
|
||||
StopOutlined,
|
||||
UploadOutlined
|
||||
UploadOutlined,
|
||||
UserOutlined,
|
||||
ClockCircleOutlined,
|
||||
SoundOutlined,
|
||||
SafetyCertificateOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
List,
|
||||
message,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Row,
|
||||
Select,
|
||||
Spin,
|
||||
Space,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload
|
||||
Upload,
|
||||
Tooltip,
|
||||
Divider
|
||||
} from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { listUsers } from '../../api';
|
||||
import { deleteSpeaker, getSpeakerList, registerSpeaker, SpeakerVO } from '../../api/business/speaker';
|
||||
import { deleteSpeaker, getSpeakerPage, registerSpeaker, SpeakerVO } from '../../api/business/speaker';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import type { SysUser } from '../../types';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
const REG_CONTENT =
|
||||
'iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。';
|
||||
const DEFAULT_DURATION = 10;
|
||||
const DEFAULT_PAGE_SIZE = 8;
|
||||
|
||||
const SpeakerReg: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
|
|
@ -49,6 +59,11 @@ const SpeakerReg: React.FC = () => {
|
|||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [speakers, setSpeakers] = useState<SpeakerVO[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [queryName, setQueryName] = useState('');
|
||||
const [current, setCurrent] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
|
||||
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
||||
|
|
@ -69,16 +84,25 @@ const SpeakerReg: React.FC = () => {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSpeakers();
|
||||
void fetchUsers();
|
||||
return () => {
|
||||
stopTimer();
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl) {
|
||||
return;
|
||||
}
|
||||
return () => {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSpeakers(current, pageSize, queryName);
|
||||
}, [current, pageSize, queryName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile?.userId || isAdmin) {
|
||||
return;
|
||||
|
|
@ -86,11 +110,24 @@ const SpeakerReg: React.FC = () => {
|
|||
form.setFieldValue('userId', profile.userId);
|
||||
}, [form, isAdmin, profile?.userId]);
|
||||
|
||||
const fetchSpeakers = async () => {
|
||||
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
const res = await getSpeakerList();
|
||||
setSpeakers(res.data?.data || []);
|
||||
const res = await getSpeakerPage({
|
||||
current: page,
|
||||
size,
|
||||
name: name || undefined
|
||||
});
|
||||
const records = res.data?.data?.records || [];
|
||||
const nextTotal = res.data?.data?.total || 0;
|
||||
|
||||
if (page > 1 && records.length === 0 && nextTotal > 0) {
|
||||
setCurrent(page - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
setSpeakers(records);
|
||||
setTotal(nextTotal);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('加载声纹库失败');
|
||||
|
|
@ -219,7 +256,11 @@ const SpeakerReg: React.FC = () => {
|
|||
});
|
||||
message.success(editingSpeaker ? '声纹更新成功' : '声纹录入成功');
|
||||
resetFormState();
|
||||
void fetchSpeakers();
|
||||
if (current !== 1) {
|
||||
setCurrent(1);
|
||||
} else {
|
||||
void fetchSpeakers(1, pageSize, queryName);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as { errorFields?: unknown }).errorFields) {
|
||||
return;
|
||||
|
|
@ -235,7 +276,11 @@ const SpeakerReg: React.FC = () => {
|
|||
try {
|
||||
await deleteSpeaker(speaker.id);
|
||||
message.success('声纹已删除');
|
||||
void fetchSpeakers();
|
||||
if (speakers.length === 1 && current > 1) {
|
||||
setCurrent(current - 1);
|
||||
} else {
|
||||
void fetchSpeakers(current, pageSize, queryName);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('删除声纹失败');
|
||||
|
|
@ -253,280 +298,446 @@ const SpeakerReg: React.FC = () => {
|
|||
resetAudioState();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', height: '100%', boxSizing: 'border-box', overflowX: 'hidden', overflowY: 'auto', background: 'var(--app-bg-page)' }}>
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
|
||||
<Title level={3}>声纹库管理</Title>
|
||||
<Text type="secondary">支持录入仅有名称的声纹样本,也支持绑定系统用户,提交后同步到第三方声纹管理服务。</Text>
|
||||
const handleUserChange = (userId: number) => {
|
||||
const selectedUser = userOptions.find(u => u.userId === userId);
|
||||
if (selectedUser) {
|
||||
form.setFieldValue('name', selectedUser.displayName || selectedUser.username);
|
||||
}
|
||||
};
|
||||
|
||||
<Row gutter={24} style={{ marginTop: 24 }}>
|
||||
<Col xs={24} xl={14}>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
boxShadow: 'var(--app-shadow)',
|
||||
background: 'var(--app-bg-card)',
|
||||
border: '1px solid var(--app-border-color)',
|
||||
backdropFilter: 'blur(16px)'
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ id: undefined, name: '', userId: undefined, remark: '' }}>
|
||||
const handleSearch = (value?: string) => {
|
||||
const keyword = (value ?? searchKeyword).trim();
|
||||
setCurrent(1);
|
||||
setQueryName(keyword);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="speaker-reg-container">
|
||||
<style>{`
|
||||
.speaker-reg-container {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
color: var(--app-text-main);
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
--accent-blue: var(--app-primary-color);
|
||||
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
||||
--glass-bg: var(--app-bg-card);
|
||||
--glass-border: 1px solid var(--app-border-color);
|
||||
}
|
||||
|
||||
.dashboard-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-left {
|
||||
flex: 1.1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 480px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-right {
|
||||
flex: 0.9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--glass-bg);
|
||||
border: var(--glass-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--card-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(135deg, var(--accent-blue) 0%, #6366f1 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
border: var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--card-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px; /* Reduced from 24px to fit 1080p better */
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recording-area {
|
||||
background: var(--app-bg-surface-soft);
|
||||
border-radius: 12px;
|
||||
padding: 18px; /* Slightly tighter */
|
||||
border: 1px solid var(--app-border-color);
|
||||
}
|
||||
|
||||
.script-box {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: var(--app-text-main);
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in srgb, var(--accent-blue) 6%, transparent);
|
||||
border-left: 4px solid var(--accent-blue);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.record-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-record {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-record.idle {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.btn-record.idle:hover {
|
||||
transform: scale(1.05);
|
||||
background: #dc2626;
|
||||
}
|
||||
.btn-record.recording {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
animation: pulse-ring 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); }
|
||||
70% { box-shadow: 0 0 0 12px rgba(99, 102, 241, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); }
|
||||
}
|
||||
|
||||
.speaker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.speaker-card {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--app-bg-surface);
|
||||
border: 1px solid var(--app-border-color);
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.speaker-card:hover {
|
||||
border-color: var(--accent-blue);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.compact-form .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.upload-compact {
|
||||
border: 2px dashed var(--app-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.upload-compact:hover {
|
||||
border-color: var(--accent-blue);
|
||||
background: color-mix(in srgb, var(--accent-blue) 2%, transparent);
|
||||
}
|
||||
|
||||
.info-strip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
background: color-mix(in srgb, var(--accent-blue) 8%, transparent);
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.custom-tabs .ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.panel-left::-webkit-scrollbar,
|
||||
.speaker-list::-webkit-scrollbar,
|
||||
.glass-card::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
.panel-left::-webkit-scrollbar-thumb,
|
||||
.speaker-list::-webkit-scrollbar-thumb,
|
||||
.glass-card::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="header-section" style={{ marginBottom: 16 }}>
|
||||
<div>
|
||||
<h1 className="page-title">声纹采集工作台</h1>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>注册唯一的声纹特征,让系统识别您的专属声音</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Badge status="processing" text={<Text type="secondary" style={{ fontSize: 13 }}>声纹引擎就绪</Text>} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-layout">
|
||||
<div className="panel-left">
|
||||
<div className="glass-card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ background: 'var(--accent-blue)', width: 32, height: 32, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FormOutlined style={{ color: 'white', fontSize: 16 }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontWeight: 700, fontSize: 17 }}>{editingSpeaker ? '更新声纹档案' : '新建声纹档案'}</h3>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" className="compact-form">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={14}>
|
||||
<Col span={14}>
|
||||
<Form.Item
|
||||
label="声纹名称"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: '请输入声纹名称' },
|
||||
{ max: 100, message: '名称不能超过100个字符' }
|
||||
]}
|
||||
label={<Text strong>声纹名称</Text>}
|
||||
rules={[{ required: true, message: '必填' }]}
|
||||
>
|
||||
<Input prefix={<FormOutlined />} placeholder="例如:张三 / 财务总监 / 访客A" maxLength={100} />
|
||||
<Input size="middle" placeholder="姓名 / 职位 / 编号" style={{ borderRadius: 8 }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={10}>
|
||||
<Form.Item label="绑定用户" name="userId">
|
||||
<Col span={10}>
|
||||
<Form.Item name="userId" label={<Text strong>绑定用户</Text>}>
|
||||
<Select
|
||||
allowClear={isAdmin}
|
||||
size="middle"
|
||||
placeholder="系统关联"
|
||||
disabled={!isAdmin}
|
||||
showSearch={isAdmin}
|
||||
placeholder={isAdmin ? '可选,选择系统用户' : '默认绑定当前用户'}
|
||||
optionFilterProp="label"
|
||||
options={userOptions.map(user => ({
|
||||
label: `${user.displayName || user.username} (${user.username})`,
|
||||
value: user.userId
|
||||
}))}
|
||||
style={{ borderRadius: 8 }}
|
||||
onChange={handleUserChange}
|
||||
options={userOptions.map(u => ({ label: u.displayName || u.username, value: u.userId }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="备注" name="remark">
|
||||
<Input.TextArea placeholder="可选,记录来源、场景或备注信息" autoSize={{ minRows: 2, maxRows: 4 }} maxLength={500} />
|
||||
<Form.Item name="remark" label={<Text strong>备注 (可选)</Text>} style={{ marginTop: 16 }}>
|
||||
<Input size="middle" placeholder="记录使用场景或特征说明" style={{ borderRadius: 8 }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Tabs defaultActiveKey="record">
|
||||
<Tabs.TabPane tab="在线录制" key="record">
|
||||
<div
|
||||
style={{
|
||||
padding: '24px',
|
||||
background: 'var(--app-bg-surface-soft)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--app-border-color)',
|
||||
marginBottom: 24,
|
||||
textAlign: 'center'
|
||||
}}
|
||||
<Tabs defaultActiveKey="1" className="custom-tabs" size="middle">
|
||||
<Tabs.TabPane tab={<span><AudioOutlined /> 实时录制采集</span>} key="1">
|
||||
<div className="recording-area">
|
||||
<div className="script-box">
|
||||
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 6, opacity: 0.6 }}>录音文本内容:</Text>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{REG_CONTENT}</div>
|
||||
</div>
|
||||
|
||||
<div className="record-controls">
|
||||
<button
|
||||
className={`btn-record ${recording ? 'recording' : 'idle'}`}
|
||||
onClick={recording ? stopRecording : startRecording}
|
||||
>
|
||||
<Paragraph style={{ fontSize: 18, color: recording ? 'var(--app-primary-color)' : 'var(--app-text-main)', fontWeight: 500, lineHeight: 1.8 }}>
|
||||
“{REG_CONTENT}”
|
||||
</Paragraph>
|
||||
<Text type="secondary">请在点击录音后,自然、清晰地朗读以上内容</Text>
|
||||
</div>
|
||||
{recording ? <StopOutlined style={{ fontSize: 24 }} /> : <AudioOutlined style={{ fontSize: 24 }} />}
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: 'center', margin: '24px 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}
|
||||
<div style={{ flex: 1, maxWidth: 240 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Text strong style={{ fontSize: 14, color: recording ? 'var(--accent-blue)' : undefined }}>{recording ? '正在采集声音...' : '等待录制'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{seconds}s / {DEFAULT_DURATION}s</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={(seconds / DEFAULT_DURATION) * 100}
|
||||
showInfo={false}
|
||||
strokeColor="var(--accent-blue)"
|
||||
size="small"
|
||||
strokeWidth={6}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
{recording && (
|
||||
<div style={{ maxWidth: 300, margin: '16px auto 0' }}>
|
||||
<Progress percent={(seconds / DEFAULT_DURATION) * 100} showInfo={false} strokeColor="#ff4d4f" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane tab="本地上传" key="upload">
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 0',
|
||||
border: '1px dashed var(--app-border-color)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: 24,
|
||||
background: 'var(--app-bg-surface-soft)'
|
||||
}}
|
||||
>
|
||||
<Upload {...uploadProps} accept="audio/*">
|
||||
<Button icon={<UploadOutlined />} size="large">
|
||||
选择音频文件
|
||||
</Button>
|
||||
<Tabs.TabPane tab={<span><CloudUploadOutlined /> 离线文件上传</span>} key="2">
|
||||
<Upload {...uploadProps} accept="audio/*" style={{ width: '100%' }}>
|
||||
<div className="upload-compact">
|
||||
<CloudUploadOutlined style={{ fontSize: 32, color: 'var(--accent-blue)', marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 14, marginBottom: 4 }}><Text strong>点击此处或将音频文件拖入</Text></div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>支持 MP3 / WAV / M4A,建议时长 5-15秒</Text>
|
||||
</div>
|
||||
</Upload>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type="secondary">支持上传 mp3、wav、m4a 等常见音频格式</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
{audioUrl && (
|
||||
<div
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))',
|
||||
padding: '16px',
|
||||
borderRadius: 12,
|
||||
marginBottom: 24,
|
||||
border: '1px solid color-mix(in srgb, var(--app-primary-color) 32%, var(--app-border-color))'
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text strong>{editingSpeaker ? '已选择新的声纹文件,请试听:' : '采集完成,请试听:'}</Text>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={resetAudioState}>
|
||||
清除文件
|
||||
</Button>
|
||||
<div style={{ background: 'var(--app-bg-surface-soft)', padding: 12, borderRadius: 12, border: '1px solid var(--accent-blue)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong style={{ fontSize: 13 }}><CheckCircleOutlined style={{ color: 'var(--app-success-color)' }} /> 采样文件已就绪</Text>
|
||||
<Button type="text" danger size="small" onClick={resetAudioState} icon={<DeleteOutlined />}>重新采集</Button>
|
||||
</div>
|
||||
<audio src={audioUrl} controls style={{ width: '100%', height: 32 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Space style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={recording || (!audioBlob && !editingSpeaker)}
|
||||
style={{ height: 50, borderRadius: 8, fontSize: 16, fontWeight: 600 }}
|
||||
style={{ height: 48, borderRadius: 10, fontWeight: 600, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.2)' }}
|
||||
>
|
||||
{editingSpeaker ? '保存修改' : '提交到声纹库'}
|
||||
{editingSpeaker ? '确认保存声纹变更' : '提交并同步到声纹库'}
|
||||
</Button>
|
||||
{editingSpeaker && (
|
||||
<Button size="large" onClick={resetFormState} style={{ height: 50, borderRadius: 8 }}>
|
||||
取消编辑
|
||||
</Button>
|
||||
<Button size="middle" block onClick={resetFormState} style={{ height: 40, borderRadius: 10 }}>取消编辑</Button>
|
||||
)}
|
||||
</Space>
|
||||
</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 className="info-strip">
|
||||
<SafetyCertificateOutlined style={{ color: 'var(--accent-blue)', fontSize: 16 }} />
|
||||
<div style={{ color: 'var(--app-text-secondary)', fontSize: 12 }}>
|
||||
数据将加密存储,仅用于会议期间的发言人识别与角色分离。同租户内声纹名称需保持唯一。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} xl={10}>
|
||||
<div style={{ position: 'sticky', top: 24 }}>
|
||||
<Card
|
||||
title={
|
||||
<span>
|
||||
<CheckCircleOutlined /> 我的声纹库
|
||||
</span>
|
||||
}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)', display: 'flex', flexDirection: 'column', maxHeight: 'calc(100vh - 48px)' }}
|
||||
styles={{ body: { overflowY: 'auto', flex: 1, minHeight: 0 } }}
|
||||
bodyStyle={{ overflowY: 'auto', flex: 1, minHeight: 0 }}
|
||||
loading={listLoading}
|
||||
>
|
||||
{speakers.length === 0 ? (
|
||||
<Empty description="尚未录入声纹" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<List
|
||||
dataSource={speakers}
|
||||
itemLayout="vertical"
|
||||
renderItem={speaker => (
|
||||
<List.Item
|
||||
key={speaker.id}
|
||||
actions={[
|
||||
...(isAdmin
|
||||
? [
|
||||
<Button key="edit" type="link" onClick={() => handleEdit(speaker)}>
|
||||
编辑
|
||||
</Button>
|
||||
]
|
||||
: []),
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确认删除这条声纹吗?"
|
||||
description="会同步尝试删除第三方声纹记录。"
|
||||
onConfirm={() => handleDelete(speaker)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button danger type="link" icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
]}
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<Space wrap>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{speaker.name}
|
||||
<div className="panel-right">
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--app-border-color)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 style={{ margin: 0, fontWeight: 700, fontSize: 17, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SoundOutlined style={{ color: 'var(--accent-blue)' }} />
|
||||
已注册声纹库
|
||||
</h3>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
按名称快速筛选当前声纹记录
|
||||
</Text>
|
||||
<Tag color={speaker.userId ? 'blue' : 'default'}>
|
||||
{speaker.userId ? `绑定用户 ${speaker.userId}` : '未绑定用户'}
|
||||
</Tag>
|
||||
<Tag color={speaker.status === 3 ? 'success' : speaker.status === 4 ? 'error' : 'processing'}>
|
||||
{speaker.status === 3 ? '已同步' : speaker.status === 4 ? '同步失败' : '处理中'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text type="secondary">{dayjs(speaker.updatedAt).format('MM-DD HH:mm')}</Text>
|
||||
</div>
|
||||
|
||||
{speaker.remark && <Text type="secondary">{speaker.remark}</Text>}
|
||||
|
||||
<Space wrap size={[8, 8]}>
|
||||
<Tag>记录ID {speaker.id}</Tag>
|
||||
{speaker.externalSpeakerId && <Tag color="purple">第三方ID {speaker.externalSpeakerId}</Tag>}
|
||||
<Tag>{speaker.voiceExt || 'unknown'}</Tag>
|
||||
<Tag>{((speaker.voiceSize || 0) / 1024).toFixed(1)} KB</Tag>
|
||||
</Space>
|
||||
|
||||
<audio src={`${resourcePrefix}${speaker.voicePath}`} controls style={{ width: '100%', height: 32 }} />
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
<Search
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
onChange={e => {
|
||||
const nextValue = e.target.value;
|
||||
setSearchKeyword(nextValue);
|
||||
if (!nextValue.trim() && queryName) {
|
||||
setCurrent(1);
|
||||
setQueryName('');
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
placeholder="按名称搜索"
|
||||
style={{ width: 220, borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<Badge count={total} overflowCount={999} style={{ backgroundColor: 'var(--accent-blue)' }} />
|
||||
</div>
|
||||
|
||||
<div className="speaker-list">
|
||||
<Spin spinning={listLoading}>
|
||||
{speakers.length === 0 ? (
|
||||
<Empty
|
||||
description={<Text type="secondary">{queryName ? '未找到匹配的声纹记录' : '暂无声纹记录'}</Text>}
|
||||
style={{ marginTop: 40 }}
|
||||
/>
|
||||
) : (
|
||||
speakers.map((s) => (
|
||||
<div className="speaker-card" key={s.id}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong style={{ fontSize: 15 }} ellipsis>{s.name}</Text>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4, alignItems: 'center' }}>
|
||||
<Tag color={s.status === 3 ? 'success' : 'processing'} bordered={false} style={{ margin: 0, fontSize: 11, borderRadius: 4 }}>
|
||||
{s.status === 3 ? '已就绪' : '处理中'}
|
||||
</Tag>
|
||||
{s.userId && <Text type="secondary" style={{ fontSize: 11 }}><UserOutlined /> ID:{s.userId}</Text>}
|
||||
</div>
|
||||
</div>
|
||||
<Space size={0}>
|
||||
<Tooltip title="编辑档案">
|
||||
<Button type="text" size="small" icon={<FormOutlined />} onClick={() => handleEdit(s)} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="确定要删除此声纹记录吗?" onConfirm={() => handleDelete(s)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{s.remark && (
|
||||
<div style={{ fontSize: 12, opacity: 0.7, marginBottom: 10, background: 'var(--app-bg-surface-soft)', padding: '6px 10px', borderRadius: 6 }}>{s.remark}</div>
|
||||
)}
|
||||
|
||||
<audio
|
||||
src={`${resourcePrefix}${s.voicePath}`}
|
||||
controls
|
||||
controlsList="nodownload"
|
||||
style={{ width: '100%', height: 28, marginTop: 4 }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8, fontSize: 11, opacity: 0.5 }}>
|
||||
<span>更新于 {dayjs(s.updatedAt).format('YYYY-MM-DD HH:mm')}</span>
|
||||
<span>{((s.voiceSize || 0) / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
<div style={{ padding: '12px 20px 16px', borderTop: '1px solid var(--app-border-color)', display: 'flex', justifyContent: 'center' }}>
|
||||
<Pagination
|
||||
current={current}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
size="small"
|
||||
showSizeChanger
|
||||
pageSizeOptions={['8', '12', '20', '50']}
|
||||
onChange={(page, size) => {
|
||||
setCurrent(page);
|
||||
setPageSize(size);
|
||||
}}
|
||||
showTotal={count => `共 ${count} 条`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue