fix(frontend): 修复多个抽屉和模态框组件渲染问题
为多个抽屉和模态框组件添加 forceRender 属性,确保表单字段在隐藏后重新显示时能正确渲染 修复会议详情页转录时间线显示问题,移除多余的时间戳元素 优化实时ASR会话页面的UI布局和交互设计dev_na
parent
21c38355c3
commit
c802f63ada
|
|
@ -259,7 +259,8 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
open={open}
|
||||
onClose={onCancel}
|
||||
width={960}
|
||||
destroyOnClose
|
||||
forceRender
|
||||
destroyOnClose={false}
|
||||
placement="right"
|
||||
closable={false}
|
||||
footer={
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@ export default function Permissions() {
|
|||
<Table className="permissions-table-full" rowKey="permId" loading={loading} dataSource={treeData} columns={columns} pagination={false} size="middle" scroll={{ x: 'max-content', y: '100%' }} expandable={{ defaultExpandAllRows: false, rowExpandable: (record) => record.permType !== "button" && !!record.children?.length }} />
|
||||
</Card>
|
||||
|
||||
<Drawer title={<Space><ClusterOutlined aria-hidden="true" /><span>{editing ? t("permissions.drawerTitleEdit") : t("permissions.drawerTitleCreate")}</span></Space>} open={open} onClose={() => setOpen(false)} width={520} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ClusterOutlined aria-hidden="true" /><span>{editing ? t("permissions.drawerTitleEdit") : t("permissions.drawerTitleCreate")}</span></Space>} open={open} onClose={() => setOpen(false)} width={520} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical" className="permission-form" onValuesChange={(changed) => changed.permType === "button" && form.setFieldsValue({ isVisible: 0 })}>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
|
|
|
|||
|
|
@ -637,7 +637,7 @@ export default function Roles() {
|
|||
<Table rowKey="userId" size="small" dataSource={filteredModalUsers} pagination={{ pageSize: 6 }} rowSelection={{ selectedRowKeys: selectedUserKeys, onChange: (keys) => setSelectedUserKeys(keys as number[]) }} columns={[{ title: "显示名称", dataIndex: "displayName" }, { title: "用户名", dataIndex: "username" }, { title: "手机号", dataIndex: "phone" }]} />
|
||||
</Modal>
|
||||
|
||||
<Drawer title={editing ? "编辑角色" : "新增角色"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{"取消"}</Button><Button type="primary" loading={saving} onClick={() => void submitBasic()}>{"保存"}</Button></div>}>
|
||||
<Drawer title={editing ? "编辑角色" : "新增角色"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{"取消"}</Button><Button type="primary" loading={saving} onClick={() => void submitBasic()}>{"保存"}</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="租户" name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
|
||||
<Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} disabled={!!editing} />
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ export default function Users() {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical" className="user-form">
|
||||
<Title level={5} style={{ marginBottom: 16 }}>{t("usersExt.basicInfo")}</Title>
|
||||
<Row gutter={16}>
|
||||
|
|
|
|||
|
|
@ -414,6 +414,7 @@ const AiModels: React.FC = () => {
|
|||
open={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
title={<Title level={4} style={{ margin: 0 }}>{editingId ? "编辑模型" : "新增模型"}</Title>}
|
||||
forceRender
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
||||
|
|
|
|||
|
|
@ -307,8 +307,8 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
|
|||
percent={isError ? 100 : percent}
|
||||
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
|
||||
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
|
||||
width={180}
|
||||
size={8}
|
||||
size={180}
|
||||
strokeWidth={8}
|
||||
/>
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
|
||||
|
|
@ -430,7 +430,6 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
|||
|
||||
return (
|
||||
<List.Item className="transcript-row" onClick={() => onSeek(item.startTime)}>
|
||||
<div className="transcript-time">{formatTime(item.startTime)}</div>
|
||||
<div className="transcript-entry">
|
||||
<div className="transcript-meta">
|
||||
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
||||
|
|
@ -941,12 +940,13 @@ const MeetingDetail: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div style={{ padding: 24, height: 'calc(100vh - 64px)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<Breadcrumb style={{ marginBottom: 16 }}>
|
||||
<Breadcrumb.Item>
|
||||
<a onClick={() => navigate('/meetings')}>会议中心</a>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>会议详情</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
<Breadcrumb
|
||||
style={{ marginBottom: 16 }}
|
||||
items={[
|
||||
{ title: <a onClick={() => navigate('/meetings')}>会议中心</a> },
|
||||
{ title: '会议详情' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card style={{ marginBottom: 16, flexShrink: 0 }} styles={{ body: { padding: '16px 24px' } }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
|
|
@ -1504,16 +1504,14 @@ const MeetingDetail: React.FC = () => {
|
|||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.chapter-time,
|
||||
.transcript-time {
|
||||
.chapter-time {
|
||||
position: relative;
|
||||
padding-top: 10px;
|
||||
color: #58627f;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chapter-time::after,
|
||||
.transcript-time::after {
|
||||
.chapter-time::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
|
|
@ -1641,8 +1639,7 @@ const MeetingDetail: React.FC = () => {
|
|||
white-space: nowrap;
|
||||
}
|
||||
.ant-list-item.transcript-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
display: flex !important;
|
||||
justify-content: flex-start !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 12px;
|
||||
|
|
@ -1650,16 +1647,8 @@ const MeetingDetail: React.FC = () => {
|
|||
border-bottom: 0 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
.transcript-row:not(:last-child) .transcript-time::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 38px;
|
||||
width: 1px;
|
||||
height: calc(100% + 12px);
|
||||
background: rgba(218, 223, 243, 0.96);
|
||||
}
|
||||
.transcript-entry {
|
||||
flex: 1;
|
||||
justify-self: start;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
|
|
@ -1936,7 +1925,7 @@ const MeetingDetail: React.FC = () => {
|
|||
`}</style>
|
||||
|
||||
{isOwner && (
|
||||
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600}>
|
||||
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600} forceRender>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
|
|
@ -1956,6 +1945,7 @@ const MeetingDetail: React.FC = () => {
|
|||
onClose={() => setSummaryVisible(false)}
|
||||
open={summaryVisible}
|
||||
extra={<Button type="primary" onClick={handleReSummary} loading={actionLoading}>开始总结</Button>}
|
||||
forceRender
|
||||
>
|
||||
<Form form={summaryForm} layout="vertical">
|
||||
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
|
||||
|
|
|
|||
|
|
@ -220,7 +220,6 @@ const Meetings: React.FC = () => {
|
|||
const navigate = useNavigate();
|
||||
const { can } = usePermission();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [data, setData] = useState<MeetingVO[]>([]);
|
||||
|
|
@ -385,10 +384,15 @@ const Meetings: React.FC = () => {
|
|||
<Modal
|
||||
title="编辑参会人"
|
||||
open={participantsEditVisible}
|
||||
onCancel={() => setParticipantsEditVisible(false)}
|
||||
onCancel={() => {
|
||||
setParticipantsEditVisible(false);
|
||||
setEditingMeeting(null);
|
||||
}}
|
||||
onOk={handleUpdateParticipants}
|
||||
confirmLoading={participantsEditLoading}
|
||||
destroyOnHidden
|
||||
forceRender
|
||||
width={500}
|
||||
>
|
||||
<Form form={participantsEditForm} layout="vertical">
|
||||
<Form.Item
|
||||
|
|
|
|||
|
|
@ -354,6 +354,7 @@ const PromptTemplates: React.FC = () => {
|
|||
width="80%"
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
open={drawerVisible}
|
||||
forceRender
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||
import { Alert, Avatar, Badge, Button, Card, Col, Empty, Row, Space, Statistic, Tag, Typography, App } from 'antd';
|
||||
import {
|
||||
AudioOutlined,
|
||||
AudioMutedOutlined,
|
||||
ClockCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
SoundOutlined,
|
||||
SyncOutlined,
|
||||
UserOutlined,
|
||||
PoweroffOutlined,
|
||||
PauseOutlined,
|
||||
CaretRightOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
|
|
@ -646,209 +650,261 @@ export function RealtimeAsrSession() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden", background: "#f8fafc" }}>
|
||||
<style>{`
|
||||
.ant-list-item.transcript-row,
|
||||
.live-transcript-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
justify-content: flex-start !important;
|
||||
align-items: flex-start !important;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.transcript-time {
|
||||
position: relative;
|
||||
padding-top: 10px;
|
||||
color: #58627f;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.transcript-time::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: 8px;
|
||||
border-radius: 50%;
|
||||
background: #6e76ff;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.transcript-row:not(:last-child) .transcript-time::before,
|
||||
.live-transcript-row:not(:last-child) .transcript-time::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 38px;
|
||||
width: 1px;
|
||||
height: calc(100% + 12px);
|
||||
background: rgba(218, 223, 243, 0.96);
|
||||
.transcript-avatar-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.transcript-entry {
|
||||
justify-self: start;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.transcript-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
color: #8e98b8;
|
||||
}
|
||||
.transcript-avatar {
|
||||
background: linear-gradient(135deg, #7a84ff, #9363ff) !important;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.transcript-speaker {
|
||||
color: #5e698d;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.transcript-time {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.transcript-bubble {
|
||||
display: block;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 18px;
|
||||
border-radius: 16px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(234, 238, 248, 1);
|
||||
box-shadow: 0 12px 28px rgba(137, 149, 193, 0.08);
|
||||
color: #3f496a;
|
||||
line-height: 1.86;
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.04);
|
||||
color: #334155;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border: none;
|
||||
}
|
||||
.live-transcript-row.draft .transcript-bubble {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
@keyframes orb-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5); }
|
||||
70% { box-shadow: 0 0 0 15px rgba(59, 130, 246, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
|
||||
}
|
||||
.recording-orb {
|
||||
animation: orb-pulse 2s infinite;
|
||||
}
|
||||
/* Custom scrollbar for transcript area */
|
||||
.transcript-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.transcript-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.transcript-container::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
`}</style>
|
||||
<PageHeader
|
||||
title={meeting.title || "实时识别中"}
|
||||
subtitle={`会议编号 #${meeting.id} · ${dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm")}`}
|
||||
extra={<Badge color={statusColor} text={statusText} />}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
{/* 极简顶部信息栏 */}
|
||||
<div style={{
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
backdropFilter: "blur(8px)",
|
||||
borderBottom: "1px solid rgba(226, 232, 240, 0.8)",
|
||||
padding: "16px 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
zIndex: 10,
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.02)"
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<Title level={4} style={{ margin: 0, color: "#0f172a" }}>{meeting.title || "实时识别中"}</Title>
|
||||
<Badge color={statusColor} text={<span style={{ color: "#475569", fontWeight: 500 }}>{statusText}</span>} />
|
||||
</div>
|
||||
|
||||
{sessionDraft && (
|
||||
<Space size={12} split={<div style={{ width: 1, height: 12, background: '#cbd5e1' }} />}>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
字数 <strong style={{ color: "#334155" }}>{totalTranscriptChars}</strong>
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
模型 <strong style={{ color: "#334155" }}>{sessionDraft.asrModelName}</strong>
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
模式 <strong style={{ color: "#334155" }}>{sessionDraft.mode}</strong>
|
||||
</Text>
|
||||
{sessionDraft.hotwords && sessionDraft.hotwords.length > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
热词 <strong style={{ color: "#334155" }}>{sessionDraft.hotwords.length}</strong>
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative", display: "flex", padding: "0 24px" }}>
|
||||
{!sessionDraft ? (
|
||||
<Card variant="borderless" style={{ borderRadius: 18 }}>
|
||||
<div style={{ width: "100%", padding: 40 }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="缺少实时识别启动配置"
|
||||
description="这个会议的实时识别配置没有保存在当前浏览器中,请返回创建页重新进入。"
|
||||
action={<Button size="small" onClick={() => navigate("/meetings?action=create&type=realtime")}>返回创建页</Button>}
|
||||
style={{ borderRadius: 12, border: "none", boxShadow: "0 4px 12px rgba(0,0,0,0.05)" }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Row gutter={16} style={{ height: "100%" }}>
|
||||
<Col xs={24} xl={7} style={{ height: "100%" }}>
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={{ height: "100%", borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)" }}
|
||||
styles={{ body: { height: "100%", padding: 16, display: "flex", flexDirection: "column" } }}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<div style={{ padding: 14, borderRadius: 16, background: "linear-gradient(135deg, #0f172a 0%, #1e40af 60%, #60a5fa 100%)", color: "#fff" }}>
|
||||
<Space direction="vertical" size={8}>
|
||||
<Tag color="blue" style={{ width: "fit-content", margin: 0 }}>LIVE SESSION</Tag>
|
||||
<Title level={4} style={{ color: "#fff", margin: 0 }}>会中实时识别</Title>
|
||||
<Text style={{ color: "rgba(255,255,255,0.82)" }}>会中页面只保留控制区和实时转写流。</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Space style={{ width: "100%" }}>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} disabled={recording || connecting || finishing || pausing || hasRemoteActiveConnection} loading={connecting} onClick={() => void handleStart()} style={{ flex: 1, height: 42 }}>
|
||||
{sessionStatus?.status === "ACTIVE" && hasRemoteActiveConnection ? "连接占用中" : sessionStatus?.status === "PAUSED_EMPTY" || sessionStatus?.status === "PAUSED_RESUMABLE" ? "继续识别" : "开始识别"}
|
||||
</Button>
|
||||
<Button icon={<PauseCircleOutlined />} disabled={(!recording && !connecting) || finishing || pausing} loading={pausing} onClick={() => void handlePause()} style={{ flex: 1, height: 42 }}>
|
||||
暂停识别
|
||||
</Button>
|
||||
<Button danger icon={<PauseCircleOutlined />} disabled={(!recording && !connecting && !sessionStatus?.hasTranscript) || finishing || pausing} loading={finishing} onClick={() => void handleStop(true)} style={{ flex: 1, height: 42 }}>
|
||||
结束会议
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={12}><Statistic title="已识别片段" value={finalTranscriptCount} /></Col>
|
||||
<Col span={12}><Statistic title="实时字数" value={totalTranscriptChars} /></Col>
|
||||
<Col span={12}><Statistic title="已录时长" value={formatClock(elapsedSeconds)} prefix={<ClockCircleOutlined />} /></Col>
|
||||
<Col span={12}><Statistic title="说话人区分" value={sessionDraft.useSpkId ? "开启" : "关闭"} /></Col>
|
||||
</Row>
|
||||
</Space>
|
||||
<div style={{ marginTop: 12, padding: 14, borderRadius: 14, background: "#fafcff", border: "1px solid #edf2ff" }}>
|
||||
<Space direction="vertical" size={10} style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">ASR 模型</Text><Text strong>{sessionDraft.asrModelName}</Text></div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">总结模型</Text><Text strong>{sessionDraft.summaryModelName}</Text></div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">识别模式</Text><Text strong>{sessionDraft.mode}</Text></div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">热词数量</Text><Text strong>{sessionDraft.hotwords.length}</Text></div>
|
||||
<div>
|
||||
<Text type="secondary">麦克风输入</Text>
|
||||
<div style={{ marginTop: 8, height: 10, borderRadius: 999, background: "#e2e8f0", overflow: "hidden" }}>
|
||||
<div style={{ width: `${audioLevel}%`, height: "100%", background: "linear-gradient(90deg, #38bdf8, #2563eb)" }} />
|
||||
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
|
||||
{/* 核心转写区域 */}
|
||||
<div
|
||||
ref={transcriptRef}
|
||||
className="transcript-container"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
padding: "32px 0px 140px",
|
||||
scrollBehavior: "smooth"
|
||||
}}
|
||||
>
|
||||
{transcripts.length === 0 && !streamingText ? (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", opacity: 0.6 }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={hasRemoteActiveConnection ? "当前会议已有活跃连接,请先关闭旧连接后再继续。" : "会议已就绪,点击下方按钮开始识别。"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
{transcripts.map((item) => (
|
||||
<div key={item.id} className="live-transcript-row">
|
||||
<div className="transcript-avatar-container">
|
||||
<Avatar size={36} icon={<UserOutlined />} style={{ background: "linear-gradient(135deg, #7a84ff, #9363ff)", border: "none" }} />
|
||||
</div>
|
||||
<div className="transcript-entry">
|
||||
<div className="transcript-meta">
|
||||
<span className="transcript-speaker">{item.speakerName}</span>
|
||||
<span className="transcript-time">{formatTranscriptTime(item.startTime)}</span>
|
||||
</div>
|
||||
<div className="transcript-bubble">{item.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ marginTop: "auto" }}>
|
||||
<Alert type="info" showIcon message="异常关闭保护" description="最终转录会实时写入会议;页面关闭时会优先尝试暂停会议。当前还没有转录内容时,结束会议会被拦截并保留空会话。" />
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} xl={17} style={{ height: "100%" }}>
|
||||
<Card variant="borderless" style={{ borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)", height: "100%" }} styles={{ body: { padding: 0, height: "100%", display: "flex", flexDirection: "column" } }}>
|
||||
<div style={{ padding: "16px 20px", borderBottom: "1px solid #f0f0f0", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexShrink: 0 }}>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>实时转写流</Title>
|
||||
<Text type="secondary">优先展示最终片段,流式草稿保留在底部作为当前正在识别的内容。</Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Tag icon={<SoundOutlined />} color={recording ? "processing" : sessionStatus?.status === "ACTIVE" && hasRemoteActiveConnection ? "processing" : sessionStatus?.status === "PAUSED_RESUMABLE" || sessionStatus?.status === "PAUSED_EMPTY" ? "warning" : "default"}>{recording ? "采集中" : connecting ? "连接中" : sessionStatus?.status === "ACTIVE" && hasRemoteActiveConnection ? "连接占用中" : sessionStatus?.status === "PAUSED_RESUMABLE" || sessionStatus?.status === "PAUSED_EMPTY" ? "已暂停" : "待命"}</Tag>
|
||||
<Tag color="blue">{sessionDraft.asrModelName}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div ref={transcriptRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 18, background: "linear-gradient(180deg, #f8fafc 0%, #ffffff 65%, #f8fafc 100%)" }}>
|
||||
{transcripts.length === 0 && !streamingText ? (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<Empty description={hasRemoteActiveConnection ? "当前会议已有活跃连接,请先关闭旧连接后再继续。" : "会议已创建,点击左侧开始识别即可进入转写。"} />
|
||||
{streamingText ? (
|
||||
<div className="live-transcript-row draft">
|
||||
<div className="transcript-avatar-container">
|
||||
<Avatar size={36} icon={<UserOutlined />} style={{ background: "#cbd5e1", border: "none" }} />
|
||||
</div>
|
||||
<div className="transcript-entry">
|
||||
<div className="transcript-meta">
|
||||
<span className="transcript-speaker" style={{ color: "#94a3b8" }}>{streamingSpeaker}</span>
|
||||
<span className="transcript-time">...</span>
|
||||
</div>
|
||||
<div className="transcript-bubble">{streamingText}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
{transcripts.map((item) => (
|
||||
<div key={item.id} className="live-transcript-row">
|
||||
<div className="transcript-time">{formatTranscriptTime(item.startTime)}</div>
|
||||
<div className="transcript-entry">
|
||||
<div className="transcript-meta">
|
||||
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
||||
<span className="transcript-speaker">{item.speakerName}</span>
|
||||
{item.userId ? <Tag color="blue">UID: {item.userId}</Tag> : null}
|
||||
<Text type="secondary">{formatTranscriptTime(item.startTime)} - {formatTranscriptTime(item.endTime)}</Text>
|
||||
</div>
|
||||
<div className="transcript-bubble">{item.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{streamingText ? (
|
||||
<div className="live-transcript-row">
|
||||
<div className="transcript-time">--:--</div>
|
||||
<div className="transcript-entry">
|
||||
<div className="transcript-meta">
|
||||
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
||||
<span className="transcript-speaker">{streamingSpeaker}</span>
|
||||
<Tag color="processing">流式草稿</Tag>
|
||||
</div>
|
||||
<div className="transcript-bubble">{streamingText}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部悬浮控制条 */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: 32,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "auto",
|
||||
minWidth: 380,
|
||||
padding: "12px 20px",
|
||||
background: "rgba(255, 255, 255, 0.85)",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderRadius: 36,
|
||||
boxShadow: "0 12px 36px rgba(0,0,0,0.12)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 40,
|
||||
zIndex: 100,
|
||||
border: "1px solid rgba(255,255,255,0.6)"
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<div className={recording ? "recording-orb" : ""} style={{
|
||||
width: 48, height: 48, borderRadius: "50%",
|
||||
background: recording ? "linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%)" : "#f0f0f0",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
transition: "transform 0.1s",
|
||||
transform: recording ? `scale(${1 + audioLevel / 300})` : "scale(1)"
|
||||
}}>
|
||||
{recording ? <AudioOutlined style={{ color: "#fff", fontSize: 20 }} /> : <AudioMutedOutlined style={{ color: "#999", fontSize: 20 }} />}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: "#333", marginBottom: 2 }}>
|
||||
{recording ? "录音中..." : connecting ? "连接中..." : sessionStatus?.status === "PAUSED_RESUMABLE" || sessionStatus?.status === "PAUSED_EMPTY" ? "已暂停" : "待命"}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888", fontFamily: "monospace" }}>
|
||||
{formatClock(elapsedSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space size={12}>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
danger
|
||||
icon={<PoweroffOutlined />}
|
||||
disabled={(!recording && !connecting && !sessionStatus?.hasTranscript) || finishing || pausing}
|
||||
loading={finishing}
|
||||
onClick={() => void handleStop(true)}
|
||||
style={{ border: "none", background: "#fff1f0", color: "#ff4d4f", width: 44, height: 44 }}
|
||||
/>
|
||||
{recording ? (
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<PauseOutlined />}
|
||||
loading={pausing}
|
||||
onClick={() => void handlePause()}
|
||||
style={{ border: "none", background: "#f0f5ff", color: "#2f54eb", width: 44, height: 44 }}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
type="primary"
|
||||
icon={<CaretRightOutlined />}
|
||||
loading={connecting}
|
||||
disabled={connecting || finishing || pausing || hasRemoteActiveConnection}
|
||||
onClick={() => void handleStart()}
|
||||
style={{ width: 44, height: 44, boxShadow: "0 4px 12px rgba(24, 144, 255, 0.3)" }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ export default function Orgs() {
|
|||
)}
|
||||
</Card>
|
||||
|
||||
<Drawer title={<Space><ApartmentOutlined aria-hidden="true" /><span>{editing ? t("orgs.drawerTitleEdit") : t("orgs.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ApartmentOutlined aria-hidden="true" /><span>{editing ? t("orgs.drawerTitleEdit") : t("orgs.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label={t("users.tenant")} name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
|
||||
<Select disabled options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} />
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ export default function Tenants() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
|
|
|
|||
|
|
@ -250,13 +250,13 @@ export default function Dictionaries() {
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
<Drawer title={<Space><BookOutlined aria-hidden="true" /><span>{editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}</span></Space>} open={typeDrawerVisible} onClose={() => setTypeDrawerVisible(false)} width={400} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setTypeDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleTypeSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><BookOutlined aria-hidden="true" /><span>{editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}</span></Space>} open={typeDrawerVisible} onClose={() => setTypeDrawerVisible(false)} width={400} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setTypeDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleTypeSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={typeForm} layout="vertical">
|
||||
<Form.Item label={t("dicts.typeCode")} name="typeCode" rules={[{ required: true, message: t("dicts.typeCode") }]}>
|
||||
<Input disabled={!!editingType} placeholder={t("dictsExt.typeCodePlaceholder")} />
|
||||
<Input disabled={!!editingType} placeholder={t("dicts.typeCode")} className="tabular-nums" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("dicts.typeName")} name="typeName" rules={[{ required: true, message: t("dicts.typeName") }]}>
|
||||
<Input placeholder={t("dictsExt.typeNamePlaceholder")} />
|
||||
<Input placeholder={t("dicts.typeName")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("common.remark")} name="remark">
|
||||
<Input.TextArea placeholder={t("dictsExt.typeRemarkPlaceholder")} rows={3} />
|
||||
|
|
@ -264,7 +264,7 @@ export default function Dictionaries() {
|
|||
</Form>
|
||||
</Drawer>
|
||||
|
||||
<Drawer title={<Space><ProfileOutlined aria-hidden="true" /><span>{editingItem ? t("dicts.drawerTitleItemEdit") : t("dicts.drawerTitleItemCreate")}</span></Space>} open={itemDrawerVisible} onClose={() => setItemDrawerVisible(false)} width={400} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setItemDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleItemSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ProfileOutlined aria-hidden="true" /><span>{editingItem ? t("dicts.drawerTitleItemEdit") : t("dicts.drawerTitleItemCreate")}</span></Space>} open={itemDrawerVisible} onClose={() => setItemDrawerVisible(false)} width={400} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setItemDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleItemSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={itemForm} layout="vertical">
|
||||
<Form.Item label={t("dicts.typeCode")} name="typeCode"><Input disabled className="tabular-nums" /></Form.Item>
|
||||
<Form.Item label={t("dicts.itemLabel")} name="itemLabel" rules={[{ required: true, message: t("dicts.itemLabel") }]}><Input placeholder={t("dictsExt.itemLabelPlaceholder")} /></Form.Item>
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ export default function SysParams() {
|
|||
onClose={() => setDrawerOpen(false)}
|
||||
width={500}
|
||||
destroyOnHidden
|
||||
forceRender
|
||||
footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
|
|
|
|||
Loading…
Reference in New Issue