2026-03-02 11:59:47 +00:00
import React , { useState , useEffect , useRef } from 'react' ;
import { useParams , useNavigate } from 'react-router-dom' ;
2026-03-04 11:25:21 +00:00
import { Card , Row , Col , Typography , Tag , Space , Divider , Button , Skeleton , Empty , List , Avatar , Breadcrumb , Popover , Input , Select , message , Drawer , Form , Modal , Progress } from 'antd' ;
2026-03-02 11:59:47 +00:00
import { LeftOutlined , UserOutlined , ClockCircleOutlined , AudioOutlined , RobotOutlined , LoadingOutlined , EditOutlined , SyncOutlined , SettingOutlined } from '@ant-design/icons' ;
import ReactMarkdown from 'react-markdown' ;
import dayjs from 'dayjs' ;
2026-03-04 11:25:21 +00:00
import { getMeetingDetail , getTranscripts , updateSpeakerInfo , reSummary , updateMeeting , MeetingVO , MeetingTranscriptVO , getMeetingProgress , MeetingProgress } from '../../api/business/meeting' ;
2026-03-02 11:59:47 +00:00
import { getAiModelPage , getAiModelDefault , AiModelVO } from '../../api/business/aimodel' ;
import { getPromptPage , PromptTemplateVO } from '../../api/business/prompt' ;
import { useDict } from '../../hooks/useDict' ;
import { listUsers } from '../../api' ;
import { SysUser } from '../../types' ;
const { Title , Text } = Typography ;
const { Option } = Select ;
2026-03-04 09:19:41 +00:00
// 详情页进度显示组件
const MeetingProgressDisplay : React.FC < { meetingId : number ; onComplete : ( ) = > void } > = ( { meetingId , onComplete } ) = > {
const [ progress , setProgress ] = useState < MeetingProgress | null > ( null ) ;
useEffect ( ( ) = > {
const fetchProgress = async ( ) = > {
try {
const res = await getMeetingProgress ( meetingId ) ;
if ( res . data && res . data . data ) {
setProgress ( res . data . data ) ;
if ( res . data . data . percent === 100 ) {
onComplete ( ) ;
}
}
} catch ( err ) { }
} ;
fetchProgress ( ) ;
const timer = setInterval ( fetchProgress , 3000 ) ;
return ( ) = > clearInterval ( timer ) ;
} , [ meetingId ] ) ;
const percent = progress ? . percent || 0 ;
const isError = percent < 0 ;
2026-03-04 12:59:49 +00:00
// 格式化剩余时间 (ETA)
const formatETA = ( seconds? : number ) = > {
2026-03-05 01:36:41 +00:00
if ( ! seconds || seconds <= 0 ) return '正在分析中' ;
2026-03-04 12:59:49 +00:00
if ( seconds < 60 ) return ` ${ seconds } 秒 ` ;
const m = Math . floor ( seconds / 60 ) ;
const s = seconds % 60 ;
return s > 0 ? ` ${ m } 分 ${ s } 秒 ` : ` ${ m } 分钟 ` ;
} ;
2026-03-04 09:19:41 +00:00
return (
< div style = { {
height : '100%' , display : 'flex' , flexDirection : 'column' , justifyContent : 'center' , alignItems : 'center' ,
background : '#fff' , borderRadius : 16 , padding : 40
} } >
< div style = { { width : '100%' , maxWidth : 600 , textAlign : 'center' } } >
< Title level = { 3 } style = { { marginBottom : 24 } } > AI 智 能 分 析 中 < / Title >
< Progress
type = "circle"
percent = { isError ? 100 : percent }
status = { isError ? 'exception' : ( percent === 100 ? 'success' : 'active' ) }
strokeColor = { isError ? '#ff4d4f' : { '0%' : '#108ee9' , '100%' : '#87d068' } }
width = { 180 }
strokeWidth = { 8 }
/ >
< div style = { { marginTop : 32 } } >
< Text strong style = { { fontSize : 18 , color : isError ? '#ff4d4f' : '#1890ff' , display : 'block' , marginBottom : 8 } } >
{ progress ? . message || '正在准备计算资源...' }
< / Text >
2026-03-05 01:36:41 +00:00
< Text type = "secondary" > 分 析 过 程 中 , 请 耐 心 等 待 , 您 可 以 先 去 处 理 其 他 工 作 < / Text >
2026-03-04 09:19:41 +00:00
< / div >
< Divider style = { { margin : '32px 0' } } / >
< Row gutter = { 24 } >
< Col span = { 8 } >
< Space direction = "vertical" size = { 0 } >
< Text type = "secondary" size = "small" > 当 前 进 度 < / Text >
< Title level = { 4 } style = { { margin : 0 } } > { isError ? 'ERROR' : ` ${ percent } % ` } < / Title >
< / Space >
< / Col >
< Col span = { 8 } >
< Space direction = "vertical" size = { 0 } >
< Text type = "secondary" size = "small" > 预 计 剩 余 < / Text >
2026-03-04 12:59:49 +00:00
< Title level = { 4 } style = { { margin : 0 } } > { isError ? '--' : formatETA ( progress ? . eta ) } < / Title >
2026-03-04 09:19:41 +00:00
< / Space >
< / Col >
< Col span = { 8 } >
< Space direction = "vertical" size = { 0 } >
< Text type = "secondary" size = "small" > 任 务 状 态 < / Text >
< Title level = { 4 } style = { { margin : 0 , color : isError ? '#ff4d4f' : '#52c41a' } } > { isError ? '已中断' : '正常' } < / Title >
< / Space >
< / Col >
< / Row >
< / div >
< / div >
) ;
} ;
2026-03-02 11:59:47 +00:00
const SpeakerEditor : React.FC < {
meetingId : number ;
speakerId : string ;
initialName : string ;
initialLabel : string ;
onSuccess : ( ) = > void ;
} > = ( { meetingId , speakerId , initialName , initialLabel , onSuccess } ) = > {
const [ name , setName ] = useState ( initialName || speakerId ) ;
const [ label , setLabel ] = useState ( initialLabel ) ;
const [ loading , setLoading ] = useState ( false ) ;
const { items : speakerLabels } = useDict ( 'biz_speaker_label' ) ;
const handleSave = async ( e : React.MouseEvent ) = > {
e . stopPropagation ( ) ;
setLoading ( true ) ;
try {
await updateSpeakerInfo ( { meetingId , speakerId , newName : name , label } ) ;
message . success ( '发言人信息已全局更新' ) ;
onSuccess ( ) ;
} catch ( err ) {
console . error ( err ) ;
} finally {
setLoading ( false ) ;
}
} ;
return (
< div style = { { width : 250 , padding : '8px 4px' } } onClick = { e = > e . stopPropagation ( ) } >
< div style = { { marginBottom : 12 } } >
< Text type = "secondary" size = "small" > 发 言 人 姓 名 < / Text >
< Input value = { name } onChange = { e = > setName ( e . target . value ) } placeholder = "输入姓名" size = "small" style = { { marginTop : 4 } } / >
< / div >
< div style = { { marginBottom : 16 } } >
< Text type = "secondary" size = "small" > 角 色 标 签 < / Text >
< Select value = { label } onChange = { setLabel } placeholder = "选择角色" style = { { width : '100%' , marginTop : 4 } } size = "small" allowClear >
{ speakerLabels . map ( item = > < Select.Option key = { item . itemValue } value = { item . itemValue } > { item . itemLabel } < / Select.Option > ) }
< / Select >
< / div >
< Button type = "primary" size = "small" block onClick = { handleSave } loading = { loading } > 同 步 到 全 文 < / Button >
< / div >
) ;
} ;
const MeetingDetail : React.FC = ( ) = > {
const { id } = useParams < { id : string } > ( ) ;
const navigate = useNavigate ( ) ;
const [ form ] = Form . useForm ( ) ;
const [ summaryForm ] = Form . useForm ( ) ;
const [ meeting , setMeeting ] = useState < MeetingVO | null > ( null ) ;
const [ transcripts , setTranscripts ] = useState < MeetingTranscriptVO [ ] > ( [ ] ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ editVisible , setEditVisible ] = useState ( false ) ;
const [ summaryVisible , setSummaryVisible ] = useState ( false ) ;
const [ actionLoading , setActionLoading ] = useState ( false ) ;
const [ llmModels , setLlmModels ] = useState < AiModelVO [ ] > ( [ ] ) ;
const [ prompts , setPrompts ] = useState < PromptTemplateVO [ ] > ( [ ] ) ;
const [ userList , setUserList ] = useState < SysUser [ ] > ( [ ] ) ;
const { items : speakerLabels } = useDict ( 'biz_speaker_label' ) ;
const audioRef = useRef < HTMLAudioElement > ( null ) ;
// 核心权限判断
const isOwner = React . useMemo ( ( ) = > {
if ( ! meeting ) return false ;
const profileStr = sessionStorage . getItem ( "userProfile" ) ;
if ( profileStr ) {
const profile = JSON . parse ( profileStr ) ;
return profile . isPlatformAdmin === true || profile . userId === meeting . creatorId ;
}
return false ;
} , [ meeting ] ) ;
useEffect ( ( ) = > {
if ( id ) {
fetchData ( Number ( id ) ) ;
loadAiConfigs ( ) ;
loadUsers ( ) ;
}
} , [ id ] ) ;
const fetchData = async ( meetingId : number ) = > {
try {
const [ detailRes , transcriptRes ] = await Promise . all ( [
getMeetingDetail ( meetingId ) ,
getTranscripts ( meetingId )
] ) ;
setMeeting ( detailRes . data . data ) ;
setTranscripts ( transcriptRes . data . data || [ ] ) ;
} catch ( err ) {
console . error ( err ) ;
} finally {
setLoading ( false ) ;
}
} ;
const loadAiConfigs = async ( ) = > {
try {
const [ mRes , pRes , dRes ] = await Promise . all ( [
getAiModelPage ( { current : 1 , size : 100 , type : 'LLM' } ) ,
getPromptPage ( { current : 1 , size : 100 } ) ,
getAiModelDefault ( 'LLM' )
] ) ;
setLlmModels ( mRes . data . data . records . filter ( m = > m . status === 1 ) ) ;
setPrompts ( pRes . data . data . records . filter ( p = > p . status === 1 ) ) ;
summaryForm . setFieldsValue ( { summaryModelId : dRes.data.data?.id } ) ;
} catch ( e ) { }
} ;
const loadUsers = async ( ) = > {
try {
const users = await listUsers ( ) ;
setUserList ( users || [ ] ) ;
} catch ( err ) { }
} ;
const handleEditMeeting = ( ) = > {
if ( ! meeting || ! isOwner ) return ;
// 由于后端存储的是姓名字符串,而我们现在需要 ID 匹配,
// 这里简单处理:让发起人依然可以修改基础元数据。
// 如果需要修改参会人 ID, 需要前端存储 ID 列表快照。
form . setFieldsValue ( {
. . . meeting ,
tags : meeting.tags?.split ( ',' ) . filter ( Boolean )
} ) ;
setEditVisible ( true ) ;
} ;
const handleUpdateBasic = async ( ) = > {
const vals = await form . validateFields ( ) ;
setActionLoading ( true ) ;
try {
await updateMeeting ( {
. . . vals ,
id : meeting?.id ,
tags : vals.tags?.join ( ',' )
} ) ;
message . success ( '会议信息已更新' ) ;
setEditVisible ( false ) ;
fetchData ( Number ( id ) ) ;
} catch ( err ) {
console . error ( err ) ;
} finally {
setActionLoading ( false ) ;
}
} ;
const handleReSummary = async ( ) = > {
const vals = await summaryForm . validateFields ( ) ;
setActionLoading ( true ) ;
try {
await reSummary ( {
meetingId : Number ( id ) ,
summaryModelId : vals.summaryModelId ,
promptId : vals.promptId
} ) ;
message . success ( '已重新发起总结任务' ) ;
setSummaryVisible ( false ) ;
fetchData ( Number ( id ) ) ;
} catch ( err ) {
console . error ( err ) ;
} finally {
setActionLoading ( false ) ;
}
} ;
const formatTime = ( ms : number ) = > {
const seconds = Math . floor ( ms / 1000 ) ;
const m = Math . floor ( seconds / 60 ) ;
const s = seconds % 60 ;
return ` ${ m . toString ( ) . padStart ( 2 , '0' ) } : ${ s . toString ( ) . padStart ( 2 , '0' ) } ` ;
} ;
const seekTo = ( timeMs : number ) = > {
if ( audioRef . current ) {
audioRef . current . currentTime = timeMs / 1000 ;
audioRef . current . play ( ) ;
}
} ;
if ( loading ) return < div style = { { padding : '24px' } } > < Skeleton active / > < / div > ;
if ( ! meeting ) return < div style = { { padding : '24px' } } > < Empty description = "会议不存在" / > < / div > ;
return (
< div style = { { padding : '24px' , height : 'calc(100vh - 64px)' , overflow : 'hidden' , display : 'flex' , flexDirection : 'column' } } >
< Breadcrumb style = { { marginBottom : '16px' } } >
< Breadcrumb.Item > < a onClick = { ( ) = > navigate ( '/meetings' ) } > 会 议 中 心 < / a > < / Breadcrumb.Item >
< Breadcrumb.Item > 会 议 详 情 < / Breadcrumb.Item >
< / Breadcrumb >
< Card style = { { marginBottom : '16px' , flexShrink : 0 } } bodyStyle = { { padding : '16px 24px' } } >
< Row justify = "space-between" align = "middle" >
< Col >
< Space direction = "vertical" size = { 4 } >
< Title level = { 4 } style = { { margin : 0 } } >
{ meeting . title } { isOwner && < EditOutlined style = { { fontSize : 16 , cursor : 'pointer' , color : '#1890ff' } } onClick = { handleEditMeeting } / > }
< / Title >
< Space split = { < Divider type = "vertical" / > } >
< Text type = "secondary" > < ClockCircleOutlined / > { dayjs ( meeting . meetingTime ) . format ( 'YYYY-MM-DD HH:mm' ) } < / Text >
< Space >
{ meeting . tags ? . split ( ',' ) . filter ( Boolean ) . map ( t = > < Tag key = { t } color = "blue" > { t } < / Tag > ) }
< / Space >
< Text type = "secondary" > < UserOutlined / > { meeting . participants || '未指定' } < / Text >
< / Space >
< / Space >
< / Col >
< Col >
< Space >
2026-03-05 01:36:41 +00:00
{ isOwner && meeting . status === 3 && (
< Button
icon = { < SyncOutlined / > }
type = "primary"
ghost
onClick = { ( ) = > setSummaryVisible ( true ) }
disabled = { actionLoading }
>
重 新 总 结
< / Button >
) }
{ isOwner && meeting . status === 2 && (
< Button
icon = { < LoadingOutlined / > }
type = "primary"
ghost
disabled
loading
>
正 在 总 结
< / Button >
) }
2026-03-02 11:59:47 +00:00
< Button icon = { < LeftOutlined / > } onClick = { ( ) = > navigate ( '/meetings' ) } > 返 回 列 表 < / Button >
< / Space >
< / Col >
< / Row >
< / Card >
< div style = { { flex : 1 , minHeight : 0 } } >
2026-03-04 09:19:41 +00:00
{ ( meeting . status === 1 || meeting . status === 2 ) ? (
< MeetingProgressDisplay meetingId = { meeting . id } onComplete = { ( ) = > fetchData ( meeting . id ) } / >
) : (
< Row gutter = { 24 } style = { { height : '100%' } } >
< Col span = { 12 } style = { { height : '100%' } } >
< Card title = { < span > < AudioOutlined / > 语 音 转 录 < / span > } style = { { height : '100%' , display : 'flex' , flexDirection : 'column' } } bodyStyle = { { flex : 1 , overflowY : 'auto' , padding : '16px' , minHeight : 0 } }
extra = { meeting . audioUrl && < audio ref = { audioRef } src = { meeting . audioUrl } controls style = { { height : '32px' } } / > } >
< List dataSource = { transcripts } renderItem = { ( item ) = > (
< List.Item style = { { borderBottom : '1px solid #f0f0f0' , padding : '12px 0' , cursor : 'pointer' } } onClick = { ( ) = > seekTo ( item . startTime ) } >
< List.Item.Meta avatar = { < Avatar icon = { < UserOutlined / > } style = { { backgroundColor : '#87d068' } } / > }
title = { < Space >
{ isOwner ? (
< Popover content = { < SpeakerEditor meetingId = { meeting . id } speakerId = { item . speakerId } initialName = { item . speakerName } initialLabel = { item . speakerLabel } onSuccess = { ( ) = > fetchData ( meeting . id ) } / > } title = "编辑发言人" trigger = "click" >
< span style = { { color : '#1890ff' , cursor : 'pointer' } } onClick = { e = > e . stopPropagation ( ) } > { item . speakerName || item . speakerId || '发言人' } < EditOutlined style = { { fontSize : '12px' } } / > < / span >
< / Popover >
) : (
< Text strong > { item . speakerName || item . speakerId || '发言人' } < / Text >
) }
{ item . speakerLabel && < Tag color = "blue" > { speakerLabels . find ( l = > l . itemValue === item . speakerLabel ) ? . itemLabel || item . speakerLabel } < / Tag > }
< Text type = "secondary" size = "small" style = { { fontSize : '12px' } } > { formatTime ( item . startTime ) } < / Text >
< / Space > } description = { < Text style = { { color : '#333' } } > { item . content } < / Text > } / >
< / List.Item >
) } locale = { { emptyText : meeting.status < 3 ? '识别任务进行中...' : '暂无数据' } } / >
< / Card >
< / Col >
< Col span = { 12 } style = { { height : '100%' } } >
< Card title = { < span > < RobotOutlined / > AI 总 结 < / span > } style = { { height : '100%' , display : 'flex' , flexDirection : 'column' } } bodyStyle = { { flex : 1 , overflowY : 'auto' , padding : '24px' , minHeight : 0 } } >
{ meeting . summaryContent ? < div className = "markdown-body" > < ReactMarkdown > { meeting . summaryContent } < / ReactMarkdown > < / div > :
< div style = { { textAlign : 'center' , marginTop : '100px' } } > { meeting . status === 2 ? < Space direction = "vertical" > < LoadingOutlined style = { { fontSize : 24 } } spin / > < Text type = "secondary" > 正 在 重 新 总 结 . . . < / Text > < / Space > : < Empty description = "暂无总结" / > } < / div > }
< / Card >
< / Col >
< / Row >
) }
2026-03-02 11:59:47 +00:00
< / div >
2026-03-04 09:19:41 +00:00
< style > { `
. markdown - body { font - size : 14px ; line - height : 1.8 ; color : # 333 ; }
. markdown - body p { margin - bottom : 16px ; }
. markdown - body h1 , . markdown - body h2 , . markdown - body h3 { margin - top : 24px ; margin - bottom : 16px ; font - weight : 600 ; }
` }</style>
2026-03-02 11:59:47 +00:00
{ /* 修改基础信息弹窗 - 仅限 Owner */ }
{ isOwner && (
< Modal title = "编辑会议信息" open = { editVisible } onOk = { handleUpdateBasic } onCancel = { ( ) = > setEditVisible ( false ) } confirmLoading = { actionLoading } width = { 600 } >
< Form form = { form } layout = "vertical" style = { { marginTop : 16 } } >
< Form.Item name = "title" label = "会议标题" rules = { [ { required : true } ] } > < Input / > < / Form.Item >
< Form.Item name = "tags" label = "业务标签" > < Select mode = "tags" placeholder = "输入标签按回车" / > < / Form.Item >
< Text type = "warning" size = "small" > 注 : 参 会 人 员 ID 绑 定 后 暂 不 支 持 在 此 编 辑 , 如 需 调 整 请 联 系 系 统 管 理 员 。 < / Text >
< / Form >
< / Modal >
) }
{ /* 重新总结抽屉 - 仅限 Owner */ }
{ isOwner && (
< Drawer title = "重新生成 AI 总结" width = { 400 } onClose = { ( ) = > setSummaryVisible ( false ) } open = { summaryVisible } extra = { < Button type = "primary" onClick = { handleReSummary } loading = { actionLoading } > 开 始 总 结 < / Button > } >
< Form form = { summaryForm } layout = "vertical" >
< Form.Item name = "summaryModelId" label = "总结模型 (LLM)" rules = { [ { required : true } ] } >
< Select placeholder = "选择 LLM 模型" >
{ llmModels . map ( m = > < Option key = { m . id } value = { m . id } > { m . modelName } { m . isDefault === 1 && < Tag color = "gold" style = { { marginLeft : 4 } } > 默 认 < / Tag > } < / Option > ) }
< / Select >
< / Form.Item >
< Form.Item name = "promptId" label = "提示词模板" rules = { [ { required : true } ] } >
< Select placeholder = "选择新模板" >
{ prompts . map ( p = > < Option key = { p . id } value = { p . id } > { p . templateName } < / Option > ) }
< / Select >
< / Form.Item >
< Divider / >
< Text type = "secondary" size = "small" > 提 示 : 重 新 总 结 将 基 于 当 前 的 语 音 转 录 全 文 重 新 生 成 纪 要 , 原 有 的 总 结 内 容 将 被 覆 盖 。 < / Text >
< / Form >
< / Drawer >
) }
< / div >
) ;
} ;
export default MeetingDetail ;