diff --git a/frontend/src/pages/business/AiModels.css b/frontend/src/pages/business/AiModels.css index 50ec0bf..6765a1d 100644 --- a/frontend/src/pages/business/AiModels.css +++ b/frontend/src/pages/business/AiModels.css @@ -12,12 +12,7 @@ background: transparent; } -.ai-models-page .section-card__tabs { - margin-bottom: 0; - padding: 8px 8px 0; - border-radius: 4px 4px 0 0; - background-color: #f9fafe; -} + .ai-models-page .section-card__content { border-radius: 0 0 4px 4px; @@ -43,3 +38,7 @@ width: 100% !important; } } + +.ai-models-content-inner { + padding: 0px; +} diff --git a/frontend/src/pages/business/AiModels.tsx b/frontend/src/pages/business/AiModels.tsx index e7746ef..33f2413 100644 --- a/frontend/src/pages/business/AiModels.tsx +++ b/frontend/src/pages/business/AiModels.tsx @@ -537,7 +537,7 @@ const AiModels: React.FC = () => { ); return ( - + { /> } > - } - className="ai-models-search" - onSearch={(value) => { - setCurrent(1); - setSearchName(value.trim()); - }} +
+ } + className="ai-models-search" + onSearch={(value) => { + setCurrent(1); + setSearchName(value.trim()); + }} + /> + } + footer={ + { + setCurrent(page); + setSize(pageSize); + }} + /> + } + > + - } - footer={ - { - setCurrent(page); - setSize(pageSize); - }} - /> - } - > -
- + + .page-container__body { +.speaker-reg-page-v2 > .page-container__body { padding: 0; - overflow: hidden; border: none; - border-radius: 0; background: transparent; + flex: 1; + min-height: 0; + overflow: hidden; } +.speaker-reg-page-v2 .section-card__content { + border-radius: 0 0 4px 4px; + padding-top: 8px; +} + +/* Tabs Container */ .speaker-reg-section-content { overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; /* Important to pass height down from SectionCard */ } -.speaker-reg-layout { +.speaker-reg-content { flex: 1; - min-width: 0; - min-height: 0; - display: grid; - grid-template-columns: minmax(430px, 0.92fr) minmax(480px, 1.08fr); - gap: 8px; -} - -.speaker-reg-card.ant-card { - min-width: 0; - min-height: 0; display: flex; flex-direction: column; overflow: hidden; - border: 1px solid #e6e6e6; + height: 100%; +} + +.tab-pane-content { + flex: 1; + overflow-y: auto; + height: 100%; +} + +.speaker-reg-content-inner { + width: 100%; + height: 100%; + padding: 24px; + background-color: var(--app-surface-color, #fff); border-radius: 4px; - box-shadow: none; + display: flex; + flex-direction: column; + box-sizing: border-box; } -.speaker-reg-card > .ant-card-head { - min-height: 46px; - padding: 0 14px; - border-bottom: 1px solid #f0f0f0; +.speaker-reg-content-inner-others { + padding: 0; + background: transparent; + height: 100%; + display: flex; + flex-direction: column; } -.speaker-reg-card > .ant-card-head .ant-card-head-title { - color: #333; - font-size: 15px; +.speaker-reg-content-inner-others .data-list-panel { + flex: 1; +} + +/* --- My Voiceprint View --- */ +.my-voice-view { + max-width: 600px; + margin: 0 auto; + width: 100%; + background: transparent; + padding: 0; +} + +.my-voice-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.my-voice-header h3 { + margin: 0 0 8px 0; + font-size: 20px; + font-weight: 600; + color: #1f1f1f; +} + +.my-voice-header h3 { + margin: 0 0 8px 0; + font-size: 20px; font-weight: 600; } -.speaker-reg-card > .ant-card-body { - flex: 1; - min-height: 0; +.my-voice-header p { + margin: 0; + color: #8c8c8c; + font-size: 14px; +} + +.my-voice-card { + border: 1px solid #f0f0f0; + border-radius: 12px; + padding: 32px; + margin-bottom: 24px; + box-shadow: 0 4px 12px rgba(0,0,0,0.02); +} + +.my-voice-card-top { display: flex; - flex-direction: column; - padding: 12px; - overflow: auto; -} - -.speaker-reg-card-icon { - width: 28px; - height: 28px; - display: inline-flex; align-items: center; - justify-content: center; - border-radius: 4px; - border: 1px solid #b7cdfd; - background: #f3f6ff; - color: #3c70f5; + gap: 20px; + margin-bottom: 24px; } -.speaker-reg-form .ant-form-item { - margin-bottom: 12px; +.my-avatar { + background: #1677ff; + color: white; + font-size: 24px; + font-weight: 600; } -.speaker-reg-tabs { - flex-shrink: 0; +.my-info { + flex: 1; } -.speaker-reg-tabs .ant-tabs-nav { +.my-name-row { + display: flex; + align-items: center; + gap: 12px; margin-bottom: 8px; } -.recording-area { - padding: 12px; - border: 1px solid #e6e6e6; +.my-name { + font-size: 20px; + font-weight: 600; + color: #1f1f1f; +} + +.my-tag { + font-weight: normal; border-radius: 4px; - background: #fff; } -.script-box { - margin-bottom: 10px; - padding: 10px 12px; - border-left: 4px solid #3c70f5; - border-radius: 0 4px 4px 0; - background: #f9fafe; -} - -.script-box__label.ant-typography { - display: block; - margin-bottom: 6px; - color: #9095a1; - font-size: 13px; -} - -.script-box__content { - color: #333; +.my-desc { + margin: 0; + color: #8c8c8c; font-size: 14px; - font-weight: 500; - line-height: 22px; } -.record-controls { +.my-audio-wrapper { + margin-bottom: 32px; +} + +.my-audio-wrapper .custom-audio { + width: 100%; + height: 48px; + outline: none; +} + +.my-meta-grid { + display: flex; + gap: 48px; + margin-bottom: 32px; + padding: 16px 24px; + background: #fafafa; + border-radius: 8px; +} + +.meta-col { + display: flex; + flex-direction: column; + gap: 8px; +} + +.meta-label { + color: #8c8c8c; + font-size: 13px; display: flex; align-items: center; - justify-content: flex-start; + gap: 6px; +} + +.meta-value { + color: #1f1f1f; + font-size: 15px; + font-weight: 500; +} + +.my-actions { + display: flex; + gap: 16px; +} + +.action-btn-primary { + flex: 1; + height: 44px; + font-weight: 600; +} + +.action-btn-danger { + flex: 1; + height: 44px; + font-weight: 600; +} + +.tips-box { + display: flex; gap: 12px; + padding: 16px 20px; + background: #f0f5ff; + border-radius: 8px; + color: #595959; } -.btn-record { - width: 40px; - height: 32px; - flex: 0 0 auto; - display: inline-flex; +.tips-icon { + font-size: 20px; + color: #1677ff; + margin-top: 2px; +} + +.tips-content h4 { + margin: 0 0 6px 0; + color: #1f1f1f; + font-size: 15px; +} + +.tips-content p { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: #595959; +} + +/* --- Recording Area --- */ +.modern-recording-container { + max-width: 600px; + margin: 0 auto; + width: 100%; + display: flex; + flex-direction: column; + gap: 24px; +} + +.back-nav { + margin-bottom: 0; + display: flex; align-items: center; - justify-content: center; - border: 1px solid transparent; - border-radius: 4px; - color: #fff; - cursor: pointer; - transition: background 0.2s ease, border-color 0.2s ease; } -.btn-record .anticon { +.back-btn { + color: #1f1f1f; + padding: 6px 12px; + margin-left: -12px; + font-weight: 500; + font-size: 15px; + display: flex; + align-items: center; + gap: 4px; +} + +.back-btn .anticon { font-size: 16px; } -.btn-record.idle { - background: #ff4d4f; +.back-btn:hover { + color: #1677ff !important; + background: rgba(22, 119, 255, 0.08) !important; } -.btn-record.idle:hover { - background: #d9363e; -} - -.btn-record.recording { - background: #3c70f5; -} - -.btn-record.recording:hover { - background: #2458d9; -} - -.btn-record:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.record-progress { - flex: 1; - max-width: none; - min-width: 0; -} - -.record-progress__head { +.input-type-switch { display: flex; - justify-content: space-between; - gap: 12px; - margin-bottom: 4px; + justify-content: center; } -.record-progress__head .is-recording { - color: #3c70f5; +/* Customizing Segmented Control to make it less bulky */ +.input-type-switch .ant-segmented { + background-color: #f0f2f5; + padding: 4px; + border-radius: 8px; +} + +.input-type-switch .ant-segmented-item-label { + padding: 0 24px !important; + min-height: 36px; + line-height: 36px; + font-weight: 500; + color: #595959; +} + +.input-type-switch .ant-segmented-item-selected .ant-segmented-item-label { + color: #1677ff; +} + +.modern-record-area { + display: flex; + flex-direction: column; + gap: 24px; +} + +.script-card { + position: relative; + background: #f8f9fa; + padding: 16px 40px; + border-radius: 12px; + text-align: center; +} + +.quote-icon { + position: absolute; + font-size: 48px; + color: #1677ff; + opacity: 0.2; + font-family: Georgia, serif; + line-height: 1; +} + +.quote-icon.left { + top: 16px; + left: 20px; +} + +.quote-icon.right { + bottom: -16px; + right: 20px; +} + +.script-card p { + position: relative; + z-index: 1; + margin: 0; + color: #1f1f1f; + font-size: 16px; + font-weight: 500; + line-height: 1.6; +} + +.record-action { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 48px; + margin-bottom: 32px; +} + +.mic-button-wrapper { + position: relative; + width: 140px; + height: 140px; + display: flex; + align-items: center; + justify-content: center; +} + +.mic-ring { + position: absolute; + border-radius: 50%; + background: #e6f4ff; + transition: all 0.3s ease; +} + +.mic-ring.outer { + width: 100%; + height: 100%; + background: #f0f5ff; +} + +.mic-ring.inner { + width: 100px; + height: 100px; + background: #bae0ff; +} + +.mic-button { + position: relative; + z-index: 10; + width: 72px; + height: 72px; + border-radius: 50%; + background: #1677ff; + color: white; + border: none; + font-size: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 16px rgba(22, 119, 255, 0.3); + transition: transform 0.2s; +} + +.mic-button:hover:not(:disabled) { + transform: scale(1.05); +} + +.mic-button-wrapper.recording .mic-ring.outer { + animation: pulse-outer 1.5s infinite; +} + +.mic-button-wrapper.recording .mic-ring.inner { + animation: pulse-inner 1.5s infinite; +} + +.mic-button-wrapper.recording .mic-button { + background: #ffffff; + border: 2px solid #f0f0f0; + color: #ff4d4f; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); +} + +.stop-square { + width: 24px; + height: 24px; + background: #ff4d4f; + border-radius: 4px; +} + +@keyframes pulse-outer { + 0% { transform: scale(1); opacity: 0.8; } + 100% { transform: scale(1.3); opacity: 0; } +} + +@keyframes pulse-inner { + 0% { transform: scale(1); opacity: 0.8; } + 100% { transform: scale(1.2); opacity: 0; } +} + +.record-status { + text-align: center; + margin-top: 24px; + margin-bottom: 16px; +} + +.record-text { + font-size: 15px; + font-weight: 500; + color: #1f1f1f; + margin-bottom: 8px; +} + +.record-time { + font-size: 14px; + color: #8c8c8c; + font-variant-numeric: tabular-nums; +} + +.record-progress-bar { + width: 240px; +} + +.submit-btn { + margin-top: 24px; + height: 48px; + font-size: 16px; + font-weight: 600; +} + +.modern-upload-area { + margin-top: 24px; } -.speaker-reg-upload, .speaker-reg-upload .ant-upload { width: 100%; } -.upload-compact { +.upload-box { width: 100%; - min-height: 104px; + height: 240px; display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: 18px 16px; - border: 1px dashed #b7cdfd; - border-radius: 4px; - background: #fff; + border: 1px dashed #d9d9d9; + border-radius: 12px; + background: #fafafa; cursor: pointer; - text-align: center; - transition: border-color 0.2s ease, background 0.2s ease; + transition: all 0.3s; } -.upload-compact:hover { - border-color: #3c70f5; - background: #f9fafe; +.upload-box:hover { + border-color: #1677ff; + background: #f0f5ff; } -.upload-compact__icon { - margin-bottom: 8px; - color: #3c70f5; - font-size: 24px; +.upload-icon { + font-size: 40px; + color: #1677ff; + margin-bottom: 16px; } -.upload-compact__title { - margin-bottom: 4px; +.upload-box h4 { + margin: 0 0 8px 0; + font-size: 16px; + color: #1f1f1f; +} + +.upload-box p { + margin: 0; + color: #8c8c8c; font-size: 14px; } .speaker-reg-audio-ready { - margin-top: 12px; - padding: 10px 12px; - border: 1px solid #b7cdfd; - border-radius: 4px; - background: #f9fafe; + margin-top: 24px; + padding: 16px; + border-radius: 8px; + background: #f6ffed; + border: 1px solid #b7eb8f; } .speaker-reg-audio-ready__head { display: flex; align-items: center; justify-content: space-between; - gap: 12px; - margin-bottom: 8px; + margin-bottom: 12px; } -.speaker-reg-audio-ready__head .anticon { - color: #52c41a; +.success-text { + color: #389e0d; } -.speaker-reg-audio-ready audio, -.speaker-card audio { - width: 100%; - height: 32px; -} - -.speaker-reg-submit-area { +/* --- Others Voiceprint View --- */ +.others-voice-view { display: flex; flex-direction: column; - gap: 8px; - margin-top: 12px; + height: 100%; + flex: 1; } -.speaker-reg-submit-area .ant-btn { - height: 34px; - border-radius: 4px; +.item-user { + display: flex; + align-items: center; + gap: 16px; +} + +.item-avatar { + background: #e6f4ff; + color: #1677ff; font-weight: 600; + font-size: 18px; } -.info-strip { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 12px; - border-radius: 4px; - border: 1px solid #e6e6e6; - background: #f9fafe; - color: #596275; - font-size: 12px; - line-height: 20px; -} - -.info-strip .anticon { - flex: 0 0 auto; - margin-top: 2px; - color: #3c70f5; - font-size: 16px; -} - -.speaker-reg-library > .ant-card-body { - padding-top: 10px; -} - -.speaker-reg-library__tools .ant-input-search { - width: 220px; -} - -.speaker-reg-library__tools .ant-badge-count { - background: #3c70f5; -} - -.speaker-reg-library__hint.ant-typography { - flex-shrink: 0; - margin-bottom: 8px; - color: #9095a1; - font-size: 13px; -} - -.speaker-list { - flex: 1; - min-height: 0; - overflow: auto; - border: 1px solid #e6e6e6; - border-radius: 4px; - background: #fff; -} - -.speaker-card { - display: grid; - grid-template-columns: minmax(180px, 0.9fr) minmax(240px, 1.1fr) minmax(150px, 0.7fr); - align-items: center; - gap: 10px 14px; - padding: 10px 12px; - border: 0; - border-bottom: 1px solid #f0f0f0; - border-radius: 0; - background: #fff; - transition: border-color 0.2s ease, background 0.2s ease; -} - -.speaker-card + .speaker-card { - margin-top: 0; -} - -.speaker-card:hover { - border-color: #f0f0f0; - background: #f9fafe; -} - -.speaker-card__head { - grid-column: 1; - grid-row: 1 / span 2; - display: flex; - justify-content: space-between; - gap: 12px; - margin-bottom: 0; -} - -.speaker-card__identity { - flex: 1; - min-width: 0; -} - -.speaker-card__identity > .ant-typography { - display: block; - font-size: 15px; -} - -.speaker-card__meta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - margin-top: 4px; -} - -.speaker-card__meta .ant-tag { - margin: 0; - border-radius: 4px; - font-size: 11px; -} - -.speaker-card__meta .ant-typography { - font-size: 11px; -} - -.speaker-card__remark { - grid-column: 2; - grid-row: 1; - margin-bottom: 0; - padding: 6px 8px; - border-radius: 4px; - background: #f9fafe; - color: #596275; - font-size: 12px; - line-height: 20px; -} - -.speaker-card > audio { - grid-column: 2; - grid-row: 2; -} - -.speaker-card--no-remark > audio { - grid-row: 1 / span 2; -} - -.speaker-card__footer { - grid-column: 3; - grid-row: 1 / span 2; +.item-name-box { display: flex; flex-direction: column; - align-items: flex-end; - justify-content: center; - gap: 12px; - margin-top: 0; - color: #9095a1; - font-size: 11px; - text-align: right; } -.speaker-reg-empty { - margin-top: 40px; +.item-name { + font-size: 15px; + font-weight: 600; + color: #1f1f1f; } -.speaker-reg-library .app-pagination-container { - flex-shrink: 0; +.item-id { + font-size: 13px; + color: #8c8c8c; +} + +.item-time { + display: flex; + flex-direction: column; +} + +.time-label { + font-size: 12px; + color: #8c8c8c; + margin-bottom: 2px; +} + +.time-val { + font-size: 14px; + color: #1f1f1f; +} + + + +.action-text-btn { + color: #8c8c8c; +} + +.action-text-btn:hover { + background: #f0f5ff; +} + +.play-btn { + color: #1677ff; +} + +.hidden-audio { + display: none; +} + + + +.others-drawer-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.drawer-record-area { + flex: 1; + display: flex; + flex-direction: column; margin-top: 12px; - border-top: 1px solid #f0f0f0; -} - -@media (max-width: 1100px) { - .speaker-reg-section-content { - overflow: auto; - } - - .speaker-reg-layout { - display: flex; - flex-direction: column; - overflow: visible; - } - - .speaker-reg-card.ant-card { - min-height: 420px; - } } +/* Responsive */ @media (max-width: 768px) { - .speaker-reg-card > .ant-card-head { - align-items: flex-start; + .speaker-reg-page-v2 { padding: 12px; } + - .speaker-reg-card > .ant-card-head .ant-card-head-wrapper { + + .tab-pane-content { + padding: 16px 12px; + } + + .my-meta-grid { flex-direction: column; - gap: 12px; + gap: 16px; } - .speaker-reg-card > .ant-card-head .ant-card-extra, - .speaker-reg-library__tools, - .speaker-reg-library__tools .ant-input-search { - width: 100%; - } - - .record-controls { - align-items: stretch; + .my-actions { flex-direction: column; } - .record-progress { - max-width: none; - } - .speaker-card { - grid-template-columns: 1fr; - } - .speaker-card__head, - .speaker-card__remark, - .speaker-card > audio, - .speaker-card__footer, - .speaker-card--no-remark > audio { - grid-column: auto; - grid-row: auto; - } - .speaker-card__footer { - align-items: flex-start; - text-align: left; - } + +.item-actions { + display: flex; +} } diff --git a/frontend/src/pages/business/SpeakerReg.tsx b/frontend/src/pages/business/SpeakerReg.tsx index c81b9b5..9dc69c1 100644 --- a/frontend/src/pages/business/SpeakerReg.tsx +++ b/frontend/src/pages/business/SpeakerReg.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useRef, useState} from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { AudioOutlined, CheckCircleOutlined, @@ -6,36 +6,39 @@ import { DeleteOutlined, FormOutlined, SearchOutlined, - SoundOutlined, - StopOutlined, SyncOutlined, UserOutlined, - SafetyCertificateOutlined, + ClockCircleOutlined, + ArrowLeftOutlined, + BulbOutlined, + PlusOutlined, + PlayCircleOutlined, + InfoCircleOutlined, } from "@ant-design/icons"; import { App, + Avatar, Badge, Button, - Card, - Col, Empty, Form, Input, Popconfirm, Progress, - Row, Select, Space, Spin, - Tabs, + Segmented, Tag, Tooltip, Typography, Upload, + Tabs, + Alert, } from "antd"; -import type {UploadProps} from "antd"; +import type { UploadProps } from "antd"; import dayjs from "dayjs"; -import {listUsers} from "../../api"; +import { listUsers } from "../../api"; import { deleteSpeaker, getSpeakerPage, @@ -46,26 +49,29 @@ import { import AppPagination from "../../components/shared/AppPagination"; import PageContainer from "../../components/shared/PageContainer"; import SectionCard from "../../components/shared/SectionCard"; -import {useAuth} from "../../hooks/useAuth"; -import type {SysUser} from "../../types"; +import FormDrawer from "../../components/shared/FormDrawer"; +import DataListPanel from "../../components/shared/DataListPanel"; +import ListTable from "../../components/shared/ListTable/ListTable"; +import { useAuth } from "../../hooks/useAuth"; +import type { SysUser } from "../../types"; import "./SpeakerReg.css"; const { Text } = Typography; const { Search } = Input; const REG_CONTENT = - "iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。"; + "智听云智能会议系统,正在采集我的专属声纹,用于在会议中识别我的发言身份,提升会议记录的准确性。"; const DEFAULT_DURATION = 15; const DEFAULT_PAGE_SIZE = 8; const AUDIO_EXT_PATTERN = /\.(mp3|wav|m4a|aac|ogg|flac|webm)$/i; -const SPEAKER_STATUS_META: Record = { - PENDING: {label: "未同步", color: "default"}, - SYNCING: {label: "同步中", color: "processing"}, - SYNCED: {label: "已注册", color: "success"}, - FAILED: {label: "同步失败", color: "error"}, - STALE: {label: "待重新同步", color: "warning"}, - DELETED: {label: "待重新同步", color: "warning"}, +const SPEAKER_STATUS_META: Record = { + PENDING: { label: "未同步", color: "#595959", bgColor: "#f5f5f5", borderColor: "#d9d9d9" }, + SYNCING: { label: "同步中", color: "#1677ff", bgColor: "#e6f4ff", borderColor: "#91caff" }, + SYNCED: { label: "已注册", color: "#52c41a", bgColor: "#f6ffed", borderColor: "#b7eb8f" }, + FAILED: { label: "同步失败", color: "#ff4d4f", bgColor: "#fff2f0", borderColor: "#ffa39e" }, + STALE: { label: "待重新同步", color: "#faad14", bgColor: "#fffbe6", borderColor: "#ffe58f" }, + DELETED: { label: "待重新同步", color: "#faad14", bgColor: "#fffbe6", borderColor: "#ffe58f" }, }; const getSpeakerStatusMeta = (speaker: SpeakerVO) => { @@ -93,26 +99,42 @@ const getErrorMessage = (err: unknown, fallback: string) => { const SpeakerReg: React.FC = () => { const { message } = App.useApp(); const [form] = Form.useForm(); - const [recording, setRecording] = useState(false); - const [audioBlob, setAudioBlob] = useState(null); - const [audioUrl, setAudioUrl] = useState(null); - const [loading, setLoading] = useState(false); + + // Layout State + const [activeTab, setActiveTab] = useState("my"); + const [myViewMode, setMyViewMode] = useState<"view" | "record">("view"); + const [isAddModalVisible, setIsAddModalVisible] = useState(false); + const [inputType, setInputType] = useState<"record" | "upload">("record"); + + // Data State + const [mySpeaker, setMySpeaker] = useState(null); const [speakers, setSpeakers] = useState([]); + const [userOptions, setUserOptions] = useState([]); + const [editingSpeaker, setEditingSpeaker] = useState(null); + + // List State 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 [loading, setLoading] = useState(false); const [syncingSpeakerId, setSyncingSpeakerId] = useState(null); - const [userOptions, setUserOptions] = useState([]); - const [editingSpeaker, setEditingSpeaker] = useState(null); + + // Recording State + const [recording, setRecording] = useState(false); + const [audioBlob, setAudioBlob] = useState(null); + const [audioUrl, setAudioUrl] = useState(null); const [seconds, setSeconds] = useState(0); + + // Refs const timerRef = useRef | null>(null); const autoStopTimerRef = useRef | null>(null); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const mountedRef = useRef(true); + const { profile } = useAuth(); const isAdmin = !!(profile?.isAdmin || profile?.isPlatformAdmin); @@ -132,6 +154,7 @@ const SpeakerReg: React.FC = () => { useEffect(() => { mountedRef.current = true; void fetchUsers(); + void fetchMySpeaker(); return () => { mountedRef.current = false; stopTimer(); @@ -143,34 +166,39 @@ const SpeakerReg: React.FC = () => { }, []); useEffect(() => { - if (!audioUrl) { - return; - } - return () => { - URL.revokeObjectURL(audioUrl); - }; + if (!audioUrl) return; + return () => URL.revokeObjectURL(audioUrl); }, [audioUrl]); useEffect(() => { - void fetchSpeakers(current, pageSize, queryName); - }, [current, pageSize, queryName]); - - useEffect(() => { - if (!profile?.userId || isAdmin) { - return; + if (activeTab === "others") { + void fetchSpeakers(current, pageSize, queryName); } - form.setFieldValue("userId", profile.userId); - form.setFieldValue("name", profile.displayName); - }, [form, isAdmin, profile?.displayName, profile?.userId]); + }, [current, pageSize, queryName, activeTab]); + + const fetchMySpeaker = async () => { + if (!profile?.userId) return; + try { + // Query specifically for the current user's name to find their record + const res = await getSpeakerPage({ current: 1, size: 100, name: profile.displayName || profile.username }); + const payload: any = res.data || res; + const records = payload?.data?.records || payload?.records || []; + const mine = records.find((r: SpeakerVO) => r.userId === profile.userId); + setMySpeaker(mine || null); + if (!mine) { + setMyViewMode("record"); + } else { + setMyViewMode("view"); + } + } catch (err) { + console.error("Failed to fetch my speaker", err); + } + }; const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => { setListLoading(true); try { - const res = await getSpeakerPage({ - current: page, - size, - name: name || undefined, - }); + const res = await getSpeakerPage({ current: page, size, name: name || undefined }); const payload: any = res.data || res; const records = payload?.data?.records || payload?.records || []; const nextTotal = payload?.data?.total || payload?.total || 0; @@ -197,7 +225,6 @@ const SpeakerReg: React.FC = () => { } catch (err) { console.error(err); setUserOptions([]); - message.error("加载用户列表失败"); } }; @@ -210,10 +237,6 @@ const SpeakerReg: React.FC = () => { const resetFormState = () => { setEditingSpeaker(null); form.resetFields(["id", "name", "userId", "remark"]); - if (!isAdmin && profile?.userId) { - form.setFieldValue("userId", profile.userId); - form.setFieldValue("name", profile.displayName); - } resetAudioState(); }; @@ -241,10 +264,7 @@ const SpeakerReg: React.FC = () => { }; const startRecording = async () => { - if (loading) { - message.warning("声纹正在提交,请稍后再录制"); - return; - } + if (loading) return; if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") { message.error("当前浏览器不支持录音,请使用音频文件上传"); return; @@ -256,16 +276,12 @@ const SpeakerReg: React.FC = () => { audioChunksRef.current = []; mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - audioChunksRef.current.push(event.data); - } + if (event.data.size > 0) audioChunksRef.current.push(event.data); }; mediaRecorder.onstop = () => { - if (!mountedRef.current) { - return; - } - const blob = new Blob(audioChunksRef.current, {type: "audio/wav"}); + if (!mountedRef.current) return; + const blob = new Blob(audioChunksRef.current, { type: "audio/wav" }); resetAudioState(); setAudioBlob(blob); setAudioUrl(URL.createObjectURL(blob)); @@ -293,14 +309,7 @@ const SpeakerReg: React.FC = () => { const uploadProps: UploadProps = { beforeUpload: (file) => { - if (recording) { - message.warning("请先停止录音,再上传音频文件"); - return Upload.LIST_IGNORE; - } - if (loading) { - message.warning("声纹正在提交,请稍后再上传"); - return Upload.LIST_IGNORE; - } + if (recording || loading) return Upload.LIST_IGNORE; if (!isAudioFile(file)) { message.error("只能上传音频文件"); return Upload.LIST_IGNORE; @@ -314,32 +323,43 @@ const SpeakerReg: React.FC = () => { showUploadList: false, }; - const handleSubmit = async () => { - if (!audioBlob && !editingSpeaker) { + const handleSubmit = async (isMyVoice: boolean) => { + if (!audioBlob && !editingSpeaker && !isMyVoice) { message.warning("请先录制或上传声纹文件"); return; } try { - const values = await form.validateFields(); + let values = { name: "", userId: undefined as number | undefined, remark: "" }; + + if (isMyVoice) { + values.name = profile?.displayName || profile?.username || "未知用户"; + values.userId = profile?.userId; + } else { + values = await form.validateFields(); + } + setLoading(true); await registerSpeaker({ - id: editingSpeaker?.id, + id: isMyVoice ? mySpeaker?.id : editingSpeaker?.id, name: values.name.trim(), userId: values.userId ? Number(values.userId) : undefined, remark: values.remark?.trim(), file: audioBlob || undefined, }); - message.success(editingSpeaker ? "声纹保存成功,等待同步" : "声纹保存成功,等待同步"); + + message.success("声纹保存成功"); resetFormState(); - if (current !== 1) { - setCurrent(1); + + if (isMyVoice) { + setMyViewMode("view"); + void fetchMySpeaker(); } else { - void fetchSpeakers(1, pageSize, queryName); + setIsAddModalVisible(false); + if (current !== 1) setCurrent(1); + else void fetchSpeakers(1, pageSize, queryName); } } catch (err) { - if ((err as { errorFields?: unknown }).errorFields) { - return; - } + if ((err as { errorFields?: unknown }).errorFields) return; console.error(err); message.error(getErrorMessage(err, "声纹保存失败")); } finally { @@ -347,17 +367,16 @@ const SpeakerReg: React.FC = () => { } }; - const handleDelete = async (speaker: SpeakerVO) => { + const handleDelete = async (speaker: SpeakerVO, isMyVoice: boolean) => { try { await deleteSpeaker(speaker.id); message.success("声纹已删除"); - if (editingSpeaker?.id === speaker.id) { - resetFormState(); - } - if (speakers.length === 1 && current > 1) { - setCurrent(current - 1); + if (isMyVoice) { + setMySpeaker(null); + setMyViewMode("record"); } else { - void fetchSpeakers(current, pageSize, queryName); + if (speakers.length === 1 && current > 1) setCurrent(current - 1); + else void fetchSpeakers(current, pageSize, queryName); } } catch (err) { console.error(err); @@ -365,12 +384,13 @@ const SpeakerReg: React.FC = () => { } }; - const handleSync = async (speaker: SpeakerVO) => { + const handleSync = async (speaker: SpeakerVO, isMyVoice: boolean) => { setSyncingSpeakerId(speaker.id); try { await syncSpeaker(speaker.id); message.success("已提交声纹同步任务"); - void fetchSpeakers(current, pageSize, queryName); + if (isMyVoice) void fetchMySpeaker(); + else void fetchSpeakers(current, pageSize, queryName); } catch (err) { console.error(err); message.error(getErrorMessage(err, "同步声纹失败")); @@ -379,7 +399,13 @@ const SpeakerReg: React.FC = () => { } }; - const handleEdit = (speaker: SpeakerVO) => { + const openModalForAdd = () => { + resetFormState(); + setInputType("record"); + setIsAddModalVisible(true); + }; + + const openModalForEdit = (speaker: SpeakerVO) => { setEditingSpeaker(speaker); form.setFieldsValue({ id: speaker.id, @@ -388,282 +414,396 @@ const SpeakerReg: React.FC = () => { remark: speaker.remark, }); resetAudioState(); + setInputType("record"); + setIsAddModalVisible(true); }; - const handleUserChange = (userId?: number) => { - const selectedUser = userOptions.find((user) => user.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 ( - - 声纹引擎就绪} />} - contentClassName="speaker-reg-section-content" - > -
- - - - - {editingSpeaker ? "更新声纹档案" : "新建声纹档案"} - - } - > -
- -
- 声纹名称} - rules={[{required: true, message: "必填"}]}> - - - - - 绑定用户}> - - - - - 实时录制采集, - children: ( -
-
- 录音文本内容: -
{REG_CONTENT}
-
- -
- - -
-
- - {recording ? "正在采集声音..." : "等待录制"} - - {seconds}s / {DEFAULT_DURATION}s -
- -
-
-
- ), - }, - { - key: "upload", - label: 离线文件上传, - children: ( - -
- -
点击此处或将音频文件拖入
- 支持 MP3 / WAV / M4A,建议时长 5-15 秒 -
-
- ), - }, - ]} - /> - - {audioUrl && ( -
-
- 采样文件已就绪 - -
-
- )} - -
- - {editingSpeaker && ( - - )} - -
- -
- 数据将加密存储,仅用于会议期间的发言人识别与角色分离。保存成功后如状态仍是“未同步”或“同步失败”,可在列表中点击“同步”按钮补同步。 -
-
-
- - - - - 声纹列表 - - } - extra={ - - { - const nextValue = e.target.value; - setSearchKeyword(nextValue); - if (!nextValue.trim() && queryName) { - setCurrent(1); - setQueryName(""); - } - }} - onSearch={handleSearch} - placeholder="按名称搜索" - prefix={} - /> - - - } - > - - “已注册”表示已经同步到当前启用的 ASR;仅本地保存未同步时,可手动点击“同步”。 - - -
- - {speakers.length === 0 ? ( - {queryName ? "未找到匹配的声纹记录" : "暂无声纹记录"}} - className="speaker-reg-empty" - /> - ) : ( - speakers.map((speaker) => { - const statusMeta = getSpeakerStatusMeta(speaker); - const canSync = speaker.syncStatus !== "SYNCED" && syncingSpeakerId !== speaker.id; - const statusTag = ( - - {statusMeta.label} - - ); - return ( -
-
-
- {speaker.name} -
- {speaker.syncErrorMessage ? ( - {statusTag} - ) : statusTag} - {speaker.userId && ID:{speaker.userId}} - {speaker.syncStatus !== "SYNCED" && ( - - )} -
-
- - -
- - {speaker.remark &&
{speaker.remark}
} - -
- ); - }) - )} -
-
- - { - setCurrent(page); - setPageSize(size); + // UI Renderers + const renderRecordingArea = (isMyVoice: boolean) => ( +
+ {/* Only show back button if the user ALREADY has a voiceprint and is just updating it */} + {(isMyVoice ? mySpeaker : editingSpeaker) && ( +
+ +
+ )} + + {!isMyVoice && ( +
+ + + + + + + + )} + +
+ }, + { label: "离线上传", value: "upload", icon: }, + ]} + value={inputType} + onChange={(val) => setInputType(val as "record" | "upload")} + /> +
+ +
+ {inputType === "record" ? ( +
+ +
+ +

{REG_CONTENT}

+ +
+ +
+
+
+
+ +
+
+
{recording ? "正在录制声音..." : "点击开始录制"}
+
{seconds}s / {DEFAULT_DURATION}s
+
+ +
+
+ ) : ( +
+ +
+ +

点击或拖拽文件到此处

+

支持 MP3 / WAV / M4A,建议 10-15 秒

+
+
+
+ )} +
+ + {audioUrl && ( +
+
+ 采样文件已就绪 + +
+
+ )} + + +
+ ); + + const renderMyVoiceprint = () => { + if (myViewMode === "record" || !mySpeaker) { + return renderRecordingArea(true); + } + + const statusMeta = getSpeakerStatusMeta(mySpeaker); + return ( +
+
+
+

我的声纹

+

你的声纹用于在会议中识别你的发言身份

+
+
+ +
+
+ {profile?.displayName?.charAt(0) || "U"} +
+
+ {profile?.displayName || profile?.username} + {statusMeta.label} +
+

你的声纹已用于会议中的发言人识别

+
+
+ +
+
+ +
+
+ 更新时间 + {dayjs(mySpeaker.updatedAt).format("YYYY-MM-DD HH:mm")} +
+
+ 音频大小 + {((mySpeaker.voiceSize || 0) / 1024).toFixed(1)} KB +
+
+ +
+ + handleDelete(mySpeaker, true)}> + + +
+
+ +
+ +
+

温馨提示

+

定期更新声纹可以帮助系统更好地识别你的发言,建议每 3-6 个月更新一次。

+
+
+
+ ); + }; + + const renderOthersVoiceprint = () => { + const filteredSpeakers = speakers.filter(s => s.userId !== profile?.userId); + + const columns = [ + { + title: "用户", + key: "user", + width: 260, + render: (_: unknown, record: SpeakerVO) => ( +
+ {record.name.charAt(0)} +
+ {record.name} + {record.userId && {record.userId}} +
+
+ ), + }, + { + title: "状态", + key: "status", + width: 120, + render: (_: unknown, record: SpeakerVO) => { + const statusMeta = getSpeakerStatusMeta(record); + return {statusMeta.label}; + }, + }, + { + title: "更新时间", + key: "updatedAt", + width: 180, + render: (_: unknown, record: SpeakerVO) => ( + {dayjs(record.updatedAt).format("YYYY-MM-DD HH:mm")} + ), + }, + { + title: "操作", + key: "actions", + width: 240, + render: (_: unknown, record: SpeakerVO) => ( +
+
+ ), + }, + ]; + + return ( + + + + } + rightActions={ + { + const nextValue = e.target.value; + setSearchKeyword(nextValue); + if (!nextValue.trim() && queryName) { + setCurrent(1); + setQueryName(""); + } + }} + onSearch={(val) => { setCurrent(1); setQueryName(val.trim()); }} + placeholder="搜索姓名 / 手机号" + className="search-input" + style={{ width: 320 }} + /> + } + footer={ + { setCurrent(page); setPageSize(size); }} + /> + } + > + + + setIsAddModalVisible(false)} + title={editingSpeaker ? "更新声纹" : "添加声纹"} + size="md" + hideFooter + bodyDensity="default" + > +
+ {editingSpeaker ? ( +
+ + + + + + + + ) : ( +
+ + + + + )} + +
+ {renderRecordingArea(false)} +
+
+
+
+ ); + }; + + const sectionTabs = useMemo(() => { + const tabs = [{ key: "my", label: "我的声纹" }]; + if (isAdmin) { + tabs.push({ key: "others", label: "他人声纹" }); + } + return tabs; + }, [isAdmin]); + + return ( + + + } + > +
+ {activeTab === "my" ? ( +
+
+ {renderMyVoiceprint()} +
+
+ ) : activeTab === "others" && isAdmin ? ( +
+ {renderOthersVoiceprint()} +
+ ) : null}
); }; -export default SpeakerReg; +export default SpeakerReg; \ No newline at end of file