fix(frontend): 修复多个抽屉和模态框组件渲染问题

为多个抽屉和模态框组件添加 forceRender 属性,确保表单字段在隐藏后重新显示时能正确渲染
修复会议详情页转录时间线显示问题,移除多余的时间戳元素
优化实时ASR会话页面的UI布局和交互设计
dev_na
alanpaine 2026-04-08 16:16:21 +08:00
parent 21c38355c3
commit c802f63ada
13 changed files with 254 additions and 200 deletions

View File

@ -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={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -354,6 +354,7 @@ const PromptTemplates: React.FC = () => {
width="80%"
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
forceRender
extra={
<Space>
<Button onClick={() => setDrawerVisible(false)}></Button>

View File

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

View File

@ -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 }))} />

View File

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

View File

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

View File

@ -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">