feat: 添加分页查询和前端搜索功能

- 在 `SpeakerController` 中添加分页查询接口
- 在 `SpeakerServiceImpl` 中实现分页查询逻辑
- 更新前端API和组件,支持分页查询和按名称搜索声纹记录
dev_na
chenhao 2026-04-02 15:52:54 +08:00
parent 5da9a97d55
commit 4f42fb50ad
5 changed files with 539 additions and 272 deletions

View File

@ -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() {

View File

@ -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);

View File

@ -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);

View File

@ -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}`

View File

@ -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();
};
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 (
<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>
<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);
}
<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: '' }}>
<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>
.dashboard-layout {
display: flex;
gap: 20px;
flex: 1;
overflow: hidden;
min-height: 0;
}
<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'
}}
>
<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>
.panel-left {
flex: 1.1;
display: flex;
flex-direction: column;
min-width: 480px;
overflow: hidden;
}
<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}
/>
) : (
<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}
/>
)}
.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;
}
{recording && (
<div style={{ position: 'absolute', top: -10, right: -10 }}>
<Badge count={`${DEFAULT_DURATION - seconds}s`} color="#ff4d4f" />
</div>
)}
</div>
.header-section {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
<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>
.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;
}
<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>
</Upload>
<div style={{ marginTop: 16 }}>
<Text type="secondary"> mp3wavm4a </Text>
</div>
</div>
</Tabs.TabPane>
</Tabs>
.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;
}
{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>
<audio src={audioUrl} controls style={{ width: '100%', height: 32 }} />
</div>
)}
.recording-area {
background: var(--app-bg-surface-soft);
border-radius: 12px;
padding: 18px; /* Slightly tighter */
border: 1px solid var(--app-border-color);
}
<Space style={{ width: '100%' }}>
<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 }}
>
{editingSpeaker ? '保存修改' : '提交到声纹库'}
</Button>
{editingSpeaker && (
<Button size="large" onClick={resetFormState} style={{ height: 50, borderRadius: 8 }}>
</Button>
)}
</Space>
</Card>
.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;
}
<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>
.record-controls {
display: flex;
align-items: center;
gap: 20px;
justify-content: center;
}
<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}
</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>
.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;
}
{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]}>
<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>
@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); }
}
<audio src={`${resourcePrefix}${speaker.voicePath}`} controls style={{ width: '100%', height: 32 }} />
</Space>
</List.Item>
)}
/>
)}
</Card>
.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>
</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>
);