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.dto.biz.SpeakerVO;
|
||||||
import com.imeeting.service.biz.SpeakerService;
|
import com.imeeting.service.biz.SpeakerService;
|
||||||
import com.unisbase.common.ApiResponse;
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import com.unisbase.dto.PageResult;
|
||||||
import com.unisbase.security.LoginUser;
|
import com.unisbase.security.LoginUser;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
@ -32,6 +33,19 @@ public class SpeakerController {
|
||||||
return ApiResponse.ok(speakerService.register(registerDTO, loginUser));
|
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")
|
@GetMapping("/list")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<List<SpeakerVO>> list() {
|
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.SpeakerRegisterDTO;
|
||||||
import com.imeeting.dto.biz.SpeakerVO;
|
import com.imeeting.dto.biz.SpeakerVO;
|
||||||
import com.imeeting.entity.biz.Speaker;
|
import com.imeeting.entity.biz.Speaker;
|
||||||
|
import com.unisbase.dto.PageResult;
|
||||||
import com.unisbase.security.LoginUser;
|
import com.unisbase.security.LoginUser;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -11,6 +12,8 @@ import java.util.List;
|
||||||
public interface SpeakerService extends IService<Speaker> {
|
public interface SpeakerService extends IService<Speaker> {
|
||||||
SpeakerVO register(SpeakerRegisterDTO registerDTO, LoginUser loginUser);
|
SpeakerVO register(SpeakerRegisterDTO registerDTO, LoginUser loginUser);
|
||||||
|
|
||||||
|
PageResult<List<SpeakerVO>> pageVisible(Integer current, Integer size, String name, LoginUser loginUser);
|
||||||
|
|
||||||
List<SpeakerVO> listVisible(LoginUser loginUser);
|
List<SpeakerVO> listVisible(LoginUser loginUser);
|
||||||
|
|
||||||
void deleteSpeaker(Long id, LoginUser loginUser);
|
void deleteSpeaker(Long id, LoginUser loginUser);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.imeeting.service.biz.impl;
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
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.mapper.biz.SpeakerMapper;
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.SpeakerService;
|
import com.imeeting.service.biz.SpeakerService;
|
||||||
|
import com.unisbase.annotation.DataScope;
|
||||||
|
import com.unisbase.dto.PageResult;
|
||||||
import com.unisbase.security.LoginUser;
|
import com.unisbase.security.LoginUser;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
@ -103,6 +106,29 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
||||||
return toVO(speaker);
|
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
|
@Override
|
||||||
public List<SpeakerVO> listVisible(LoginUser loginUser) {
|
public List<SpeakerVO> listVisible(LoginUser loginUser) {
|
||||||
boolean admin = isAdmin(loginUser);
|
boolean admin = isAdmin(loginUser);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ export interface SpeakerRegisterParams {
|
||||||
file?: File | Blob;
|
file?: File | Blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpeakerPageParams {
|
||||||
|
current: number;
|
||||||
|
size: number;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const registerSpeaker = (params: SpeakerRegisterParams) => {
|
export const registerSpeaker = (params: SpeakerRegisterParams) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (params.id) formData.append("id", params.id.toString());
|
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) => {
|
export const deleteSpeaker = (id: number) => {
|
||||||
return http.delete<any, { code: string; data: boolean; msg: string }>(
|
return http.delete<any, { code: string; data: boolean; msg: string }>(
|
||||||
`/api/biz/speaker/${id}`
|
`/api/biz/speaker/${id}`
|
||||||
|
|
|
||||||
|
|
@ -6,41 +6,51 @@ import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
FormOutlined,
|
FormOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
|
SearchOutlined,
|
||||||
StopOutlined,
|
StopOutlined,
|
||||||
UploadOutlined
|
UploadOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
SoundOutlined,
|
||||||
|
SafetyCertificateOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
Col,
|
Col,
|
||||||
Empty,
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
List,
|
List,
|
||||||
message,
|
message,
|
||||||
|
Pagination,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Progress,
|
Progress,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
|
Spin,
|
||||||
Space,
|
Space,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
Upload
|
Upload,
|
||||||
|
Tooltip,
|
||||||
|
Divider
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { UploadProps } from 'antd';
|
import type { UploadProps } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { listUsers } from '../../api';
|
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 { useAuth } from '../../hooks/useAuth';
|
||||||
import type { SysUser } from '../../types';
|
import type { SysUser } from '../../types';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
const { Search } = Input;
|
||||||
|
|
||||||
const REG_CONTENT =
|
const REG_CONTENT =
|
||||||
'iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。';
|
'iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。';
|
||||||
const DEFAULT_DURATION = 10;
|
const DEFAULT_DURATION = 10;
|
||||||
|
const DEFAULT_PAGE_SIZE = 8;
|
||||||
|
|
||||||
const SpeakerReg: React.FC = () => {
|
const SpeakerReg: React.FC = () => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
@ -49,6 +59,11 @@ const SpeakerReg: React.FC = () => {
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [speakers, setSpeakers] = useState<SpeakerVO[]>([]);
|
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 [listLoading, setListLoading] = useState(false);
|
||||||
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
|
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
|
||||||
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
||||||
|
|
@ -69,16 +84,25 @@ const SpeakerReg: React.FC = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchSpeakers();
|
|
||||||
void fetchUsers();
|
void fetchUsers();
|
||||||
return () => {
|
return () => {
|
||||||
stopTimer();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!profile?.userId || isAdmin) {
|
if (!profile?.userId || isAdmin) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -86,11 +110,24 @@ const SpeakerReg: React.FC = () => {
|
||||||
form.setFieldValue('userId', profile.userId);
|
form.setFieldValue('userId', profile.userId);
|
||||||
}, [form, isAdmin, profile?.userId]);
|
}, [form, isAdmin, profile?.userId]);
|
||||||
|
|
||||||
const fetchSpeakers = async () => {
|
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
|
||||||
setListLoading(true);
|
setListLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getSpeakerList();
|
const res = await getSpeakerPage({
|
||||||
setSpeakers(res.data?.data || []);
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
message.error('加载声纹库失败');
|
message.error('加载声纹库失败');
|
||||||
|
|
@ -219,7 +256,11 @@ const SpeakerReg: React.FC = () => {
|
||||||
});
|
});
|
||||||
message.success(editingSpeaker ? '声纹更新成功' : '声纹录入成功');
|
message.success(editingSpeaker ? '声纹更新成功' : '声纹录入成功');
|
||||||
resetFormState();
|
resetFormState();
|
||||||
void fetchSpeakers();
|
if (current !== 1) {
|
||||||
|
setCurrent(1);
|
||||||
|
} else {
|
||||||
|
void fetchSpeakers(1, pageSize, queryName);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as { errorFields?: unknown }).errorFields) {
|
if ((err as { errorFields?: unknown }).errorFields) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -235,7 +276,11 @@ const SpeakerReg: React.FC = () => {
|
||||||
try {
|
try {
|
||||||
await deleteSpeaker(speaker.id);
|
await deleteSpeaker(speaker.id);
|
||||||
message.success('声纹已删除');
|
message.success('声纹已删除');
|
||||||
void fetchSpeakers();
|
if (speakers.length === 1 && current > 1) {
|
||||||
|
setCurrent(current - 1);
|
||||||
|
} else {
|
||||||
|
void fetchSpeakers(current, pageSize, queryName);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
message.error('删除声纹失败');
|
message.error('删除声纹失败');
|
||||||
|
|
@ -253,280 +298,446 @@ const SpeakerReg: React.FC = () => {
|
||||||
resetAudioState();
|
resetAudioState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUserChange = (userId: number) => {
|
||||||
|
const selectedUser = userOptions.find(u => u.userId === userId);
|
||||||
|
if (selectedUser) {
|
||||||
|
form.setFieldValue('name', selectedUser.displayName || selectedUser.username);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value?: string) => {
|
||||||
|
const keyword = (value ?? searchKeyword).trim();
|
||||||
|
setCurrent(1);
|
||||||
|
setQueryName(keyword);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px', height: '100%', boxSizing: 'border-box', overflowX: 'hidden', overflowY: 'auto', background: 'var(--app-bg-page)' }}>
|
<div className="speaker-reg-container">
|
||||||
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
|
<style>{`
|
||||||
<Title level={3}>声纹库管理</Title>
|
.speaker-reg-container {
|
||||||
<Text type="secondary">支持录入仅有名称的声纹样本,也支持绑定系统用户,提交后同步到第三方声纹管理服务。</Text>
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
<Row gutter={24} style={{ marginTop: 24 }}>
|
.dashboard-layout {
|
||||||
<Col xs={24} xl={14}>
|
display: flex;
|
||||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
gap: 20px;
|
||||||
<Card
|
flex: 1;
|
||||||
bordered={false}
|
overflow: hidden;
|
||||||
style={{
|
min-height: 0;
|
||||||
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: '' }}>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col xs={24} sm={14}>
|
|
||||||
<Form.Item
|
|
||||||
label="声纹名称"
|
|
||||||
name="name"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请输入声纹名称' },
|
|
||||||
{ max: 100, message: '名称不能超过100个字符' }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input prefix={<FormOutlined />} placeholder="例如:张三 / 财务总监 / 访客A" maxLength={100} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={10}>
|
|
||||||
<Form.Item label="绑定用户" name="userId">
|
|
||||||
<Select
|
|
||||||
allowClear={isAdmin}
|
|
||||||
disabled={!isAdmin}
|
|
||||||
showSearch={isAdmin}
|
|
||||||
placeholder={isAdmin ? '可选,选择系统用户' : '默认绑定当前用户'}
|
|
||||||
optionFilterProp="label"
|
|
||||||
options={userOptions.map(user => ({
|
|
||||||
label: `${user.displayName || user.username} (${user.username})`,
|
|
||||||
value: user.userId
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Form.Item label="备注" name="remark">
|
|
||||||
<Input.TextArea placeholder="可选,记录来源、场景或备注信息" autoSize={{ minRows: 2, maxRows: 4 }} maxLength={500} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Tabs defaultActiveKey="record">
|
.panel-left {
|
||||||
<Tabs.TabPane tab="在线录制" key="record">
|
flex: 1.1;
|
||||||
<div
|
display: flex;
|
||||||
style={{
|
flex-direction: column;
|
||||||
padding: '24px',
|
min-width: 480px;
|
||||||
background: 'var(--app-bg-surface-soft)',
|
overflow: hidden;
|
||||||
borderRadius: 12,
|
}
|
||||||
border: '1px solid var(--app-border-color)',
|
|
||||||
marginBottom: 24,
|
|
||||||
textAlign: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div style={{ textAlign: 'center', margin: '24px 0' }}>
|
.panel-right {
|
||||||
<div style={{ position: 'relative', display: 'inline-block', marginBottom: 16 }}>
|
flex: 0.9;
|
||||||
{!recording ? (
|
display: flex;
|
||||||
<Button
|
flex-direction: column;
|
||||||
type="primary"
|
background: var(--glass-bg);
|
||||||
danger
|
border: var(--glass-border);
|
||||||
shape="circle"
|
border-radius: 16px;
|
||||||
style={{ width: 80, height: 80, boxShadow: '0 4px 14px rgba(255, 77, 79, 0.3)' }}
|
box-shadow: var(--card-shadow);
|
||||||
icon={<AudioOutlined style={{ fontSize: 32 }} />}
|
overflow: hidden;
|
||||||
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 && (
|
.header-section {
|
||||||
<div style={{ position: 'absolute', top: -10, right: -10 }}>
|
margin-bottom: 20px;
|
||||||
<Badge count={`${DEFAULT_DURATION - seconds}s`} color="#ff4d4f" />
|
display: flex;
|
||||||
</div>
|
justify-content: space-between;
|
||||||
)}
|
align-items: center;
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<div>
|
.page-title {
|
||||||
<Text strong type={recording ? 'danger' : 'secondary'}>
|
font-size: 24px;
|
||||||
{recording ? `录制中... ${seconds}/${DEFAULT_DURATION}s` : '点击红色图标开始录音'}
|
font-weight: 800;
|
||||||
</Text>
|
margin: 0;
|
||||||
</div>
|
letter-spacing: -0.5px;
|
||||||
{recording && (
|
background: linear-gradient(135deg, var(--accent-blue) 0%, #6366f1 100%);
|
||||||
<div style={{ maxWidth: 300, margin: '16px auto 0' }}>
|
-webkit-background-clip: text;
|
||||||
<Progress percent={(seconds / DEFAULT_DURATION) * 100} showInfo={false} strokeColor="#ff4d4f" />
|
-webkit-text-fill-color: transparent;
|
||||||
</div>
|
}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Tabs.TabPane>
|
|
||||||
|
|
||||||
<Tabs.TabPane tab="本地上传" key="upload">
|
.glass-card {
|
||||||
<div
|
background: var(--glass-bg);
|
||||||
style={{
|
border: var(--glass-border);
|
||||||
textAlign: 'center',
|
border-radius: 16px;
|
||||||
padding: '24px 0',
|
padding: 24px;
|
||||||
border: '1px dashed var(--app-border-color)',
|
box-shadow: var(--card-shadow);
|
||||||
borderRadius: '8px',
|
display: flex;
|
||||||
marginBottom: 24,
|
flex-direction: column;
|
||||||
background: 'var(--app-bg-surface-soft)'
|
gap: 20px; /* Reduced from 24px to fit 1080p better */
|
||||||
}}
|
height: 100%;
|
||||||
>
|
overflow-y: auto;
|
||||||
<Upload {...uploadProps} accept="audio/*">
|
}
|
||||||
<Button icon={<UploadOutlined />} size="large">
|
|
||||||
选择音频文件
|
|
||||||
</Button>
|
|
||||||
</Upload>
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<Text type="secondary">支持上传 mp3、wav、m4a 等常见音频格式</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs.TabPane>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{audioUrl && (
|
.recording-area {
|
||||||
<div
|
background: var(--app-bg-surface-soft);
|
||||||
style={{
|
border-radius: 12px;
|
||||||
background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))',
|
padding: 18px; /* Slightly tighter */
|
||||||
padding: '16px',
|
border: 1px solid var(--app-border-color);
|
||||||
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>
|
|
||||||
<audio src={audioUrl} controls style={{ width: '100%', height: 32 }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Space style={{ width: '100%' }}>
|
.script-box {
|
||||||
<Button
|
font-size: 15px;
|
||||||
type="primary"
|
line-height: 1.5;
|
||||||
size="large"
|
color: var(--app-text-main);
|
||||||
block
|
margin-bottom: 12px;
|
||||||
icon={<CloudUploadOutlined />}
|
padding: 12px 16px;
|
||||||
onClick={handleSubmit}
|
background: color-mix(in srgb, var(--accent-blue) 6%, transparent);
|
||||||
loading={loading}
|
border-left: 4px solid var(--accent-blue);
|
||||||
disabled={recording || (!audioBlob && !editingSpeaker)}
|
border-radius: 0 8px 8px 0;
|
||||||
style={{ height: 50, borderRadius: 8, fontSize: 16, fontWeight: 600 }}
|
}
|
||||||
>
|
|
||||||
{editingSpeaker ? '保存修改' : '提交到声纹库'}
|
|
||||||
</Button>
|
|
||||||
{editingSpeaker && (
|
|
||||||
<Button size="large" onClick={resetFormState} style={{ height: 50, borderRadius: 8 }}>
|
|
||||||
取消编辑
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card size="small" bordered={false} style={{ borderRadius: 16, backgroundColor: '#fffbe6', border: '1px solid #ffe58f' }}>
|
.record-controls {
|
||||||
<Space align="start">
|
display: flex;
|
||||||
<InfoCircleOutlined style={{ color: '#faad14', marginTop: 4 }} />
|
align-items: center;
|
||||||
<div style={{ fontSize: 12, color: '#856404' }}>
|
gap: 20px;
|
||||||
<b>录入说明:</b>
|
justify-content: center;
|
||||||
<br />
|
}
|
||||||
1. 普通用户默认绑定自己且不可修改,管理员可为本租户其他用户录入。
|
|
||||||
<br />
|
|
||||||
2. 同一租户下声纹名称不可重复。
|
|
||||||
<br />
|
|
||||||
3. 提交后系统会先保存本地文件,再调用第三方声纹管理接口注册。
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
</Space>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} xl={10}>
|
.btn-record {
|
||||||
<div style={{ position: 'sticky', top: 24 }}>
|
width: 60px;
|
||||||
<Card
|
height: 60px;
|
||||||
title={
|
border-radius: 50%;
|
||||||
<span>
|
border: none;
|
||||||
<CheckCircleOutlined /> 我的声纹库
|
display: flex;
|
||||||
</span>
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
bordered={false}
|
cursor: pointer;
|
||||||
style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)', display: 'flex', flexDirection: 'column', maxHeight: 'calc(100vh - 48px)' }}
|
transition: all 0.3s ease;
|
||||||
styles={{ body: { overflowY: 'auto', flex: 1, minHeight: 0 } }}
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
bodyStyle={{ overflowY: 'auto', flex: 1, minHeight: 0 }}
|
flex-shrink: 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}
|
|
||||||
</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>}
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
<Space wrap size={[8, 8]}>
|
@keyframes pulse-ring {
|
||||||
<Tag>记录ID {speaker.id}</Tag>
|
0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); }
|
||||||
{speaker.externalSpeakerId && <Tag color="purple">第三方ID {speaker.externalSpeakerId}</Tag>}
|
70% { box-shadow: 0 0 0 12px rgba(99, 102, 241, 0); }
|
||||||
<Tag>{speaker.voiceExt || 'unknown'}</Tag>
|
100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); }
|
||||||
<Tag>{((speaker.voiceSize || 0) / 1024).toFixed(1)} KB</Tag>
|
}
|
||||||
</Space>
|
|
||||||
|
|
||||||
<audio src={`${resourcePrefix}${speaker.voicePath}`} controls style={{ width: '100%', height: 32 }} />
|
.speaker-list {
|
||||||
</Space>
|
flex: 1;
|
||||||
</List.Item>
|
overflow-y: auto;
|
||||||
)}
|
padding: 20px;
|
||||||
/>
|
}
|
||||||
)}
|
|
||||||
</Card>
|
.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>
|
</div>
|
||||||
</Col>
|
|
||||||
</Row>
|
<Form form={form} layout="vertical" className="compact-form">
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={14}>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label={<Text strong>声纹名称</Text>}
|
||||||
|
rules={[{ required: true, message: '必填' }]}
|
||||||
|
>
|
||||||
|
<Input size="middle" placeholder="姓名 / 职位 / 编号" style={{ borderRadius: 8 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={10}>
|
||||||
|
<Form.Item name="userId" label={<Text strong>绑定用户</Text>}>
|
||||||
|
<Select
|
||||||
|
size="middle"
|
||||||
|
placeholder="系统关联"
|
||||||
|
disabled={!isAdmin}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
onChange={handleUserChange}
|
||||||
|
options={userOptions.map(u => ({ label: u.displayName || u.username, value: u.userId }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="remark" label={<Text strong>备注 (可选)</Text>} style={{ marginTop: 16 }}>
|
||||||
|
<Input size="middle" placeholder="记录使用场景或特征说明" style={{ borderRadius: 8 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
{recording ? <StopOutlined style={{ fontSize: 24 }} /> : <AudioOutlined style={{ fontSize: 24 }} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<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>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{audioUrl && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
disabled={recording || (!audioBlob && !editingSpeaker)}
|
||||||
|
style={{ height: 48, borderRadius: 10, fontWeight: 600, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.2)' }}
|
||||||
|
>
|
||||||
|
{editingSpeaker ? '确认保存声纹变更' : '提交并同步到声纹库'}
|
||||||
|
</Button>
|
||||||
|
{editingSpeaker && (
|
||||||
|
<Button size="middle" block onClick={resetFormState} style={{ height: 40, borderRadius: 10 }}>取消编辑</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<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 }}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue