Merge remote-tracking branch '个人/dev_na' into dev_na

dev_na
chenhao 2026-07-03 15:52:38 +08:00
commit 76410071e7
4 changed files with 642 additions and 658 deletions

View File

@ -13,7 +13,10 @@
}
.meeting-detail-section-card {
flex: 1;
height: 100%;
min-width: 0;
min-height: 0;
}
.meeting-detail-section-card .section-card__header {
@ -65,23 +68,34 @@
.meeting-detail-page-v2 .meeting-detail-title-wrap {
display: flex;
align-items: flex-start;
gap: 16px;
gap: 12px;
min-width: 0;
max-width: 100%;
}
.meeting-detail-page-v2 .meeting-detail-title-icon {
width: 42px;
height: 42px;
flex: 0 0 42px;
.meeting-detail-page-v2 .meeting-detail-back-button {
width: 30px;
height: 30px;
flex: 0 0 30px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0;
border: 1px solid #d8e3ff;
border-radius: 6px;
background: #ffffff;
color: #3c70f5;
line-height: 1;
font-size: 20px;
font-size: 14px;
cursor: pointer;
}
.meeting-detail-page-v2 .meeting-detail-title-icon .anticon {
.meeting-detail-page-v2 .meeting-detail-back-button:hover {
border-color: #9cb8ff;
background: #f3f7ff;
}
.meeting-detail-page-v2 .meeting-detail-back-button .anticon {
display: inline-flex;
align-items: center;
justify-content: center;
@ -92,13 +106,14 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 7px;
gap: 6px;
}
.meeting-detail-page-v2 .meeting-detail-title-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 30px;
min-width: 0;
max-width: 100%;
}
@ -195,6 +210,8 @@
}
.meeting-detail-section-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
@ -202,11 +219,22 @@
position: relative;
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.meeting-detail-page-v2 .meeting-detail-workspace > .ant-row {
flex: 1;
min-height: 0;
}
.meeting-detail-page-v2 .meeting-detail-workspace > .ant-row > .ant-col {
min-height: 0;
}
.meeting-detail-page-v2 .detail-side-column {
height: 100%;
min-height: 0;
@ -214,6 +242,17 @@
padding-right: 4px;
}
.meeting-detail-page-v2 .detail-left-column {
overflow: hidden;
max-height: 100%;
overscroll-behavior: contain;
}
.meeting-detail-page-v2 .detail-left-column .summary-panel {
flex: 1 1 auto;
min-height: 0;
}
.meeting-detail-page-v2 .left-flow-card.ant-card {
border: 1px solid #e6e6e6;
border-radius: 4px;
@ -268,14 +307,6 @@
padding: 12px;
}
.meeting-detail-page-v2 .transcript-player--floating {
z-index: 20;
border: 1px solid #e6e6e6;
border-radius: 4px;
background: #fff;
box-shadow: none;
}
.meeting-detail-page-v2 .transcript-player {
padding: 12px 14px !important;
border: 1px solid #e6e6e6 !important;
@ -348,7 +379,6 @@
color: #3c70f5 !important;
}
.meeting-detail-page-v2 .meeting-detail-title-icon,
.meeting-detail-page-v2 .role-detail-icon,
.meeting-detail-page-v2 .catalog-timeline-dot,
.meeting-detail-page-v2 .chapter-time::after,
@ -357,7 +387,6 @@
box-shadow: none !important;
}
.meeting-detail-page-v2 .meeting-detail-title-icon,
.meeting-detail-page-v2 .role-detail-icon {
border-radius: 4px !important;
}
@ -381,12 +410,38 @@
backdrop-filter: none !important;
}
.meeting-detail-page-v2 .summary-content-box,
.meeting-detail-page-v2 .transcript-keyword-bar,
.meeting-detail-page-v2 .empty-transcript-inline-note {
background: #f9fafe !important;
}
.meeting-detail-page-v2 .summary-panel.left-flow-card,
.meeting-detail-page-v2 .summary-panel.left-flow-card.ant-card {
display: flex;
flex-direction: column;
overflow: hidden;
border-color: #e5e7eb !important;
border-radius: 12px !important;
background: #ffffff !important;
}
.meeting-detail-page-v2 .summary-panel .ant-card-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 24px !important;
background: #ffffff !important;
}
.meeting-detail-page-v2 .summary-content-box {
min-height: 0;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: #ffffff !important;
}
.meeting-detail-page-v2 .meeting-detail-title-text {
color: #333333 !important;
font-size: 18px !important;
@ -577,14 +632,14 @@
@media (max-width: 768px) {
.meeting-detail-page-v2 .meeting-detail-title-wrap {
gap: 12px;
gap: 10px;
}
.meeting-detail-page-v2 .meeting-detail-title-icon {
width: 38px;
height: 38px;
flex-basis: 38px;
font-size: 18px;
.meeting-detail-page-v2 .meeting-detail-back-button {
width: 28px;
height: 28px;
flex-basis: 28px;
font-size: 13px;
}
.meeting-detail-page-v2 .meeting-detail-title-text {
@ -678,49 +733,77 @@
}
.meeting-detail-page-v2 .transcript-scroll-shell {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 24px 20px 18px 18px !important;
background: #ffffff !important;
}
.meeting-detail-page-v2 .transcript-player.transcript-player--floating {
.meeting-detail-page-v2 .transcript-player.transcript-player--embedded {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px !important;
flex: 0 0 auto;
min-height: 72px;
margin: 0;
padding: 13px 18px !important;
border: 0 !important;
border-radius: 18px !important;
background: rgba(255, 255, 255, 0.96) !important;
box-shadow: 0 16px 40px rgba(20, 35, 70, 0.16) !important;
backdrop-filter: blur(12px) !important;
border-top: 1px solid #e5e7eb !important;
border-radius: 0 !important;
background: #ffffff !important;
box-shadow: none !important;
backdrop-filter: none !important;
}
.meeting-detail-page-v2 .transcript-player .player-main-btn {
width: 46px !important;
height: 46px !important;
flex: 0 0 46px;
width: 40px !important;
height: 40px !important;
flex: 0 0 40px;
padding: 0 !important;
border: 0 !important;
border-radius: 50% !important;
background: linear-gradient(135deg, #3c70f5 0%, #6b8cff 100%) !important;
color: #ffffff !important;
box-shadow: 0 10px 24px rgba(60, 112, 245, 0.32) !important;
font-size: 16px;
line-height: 1;
box-shadow: 0 8px 20px rgba(60, 112, 245, 0.28) !important;
}
.meeting-detail-page-v2 .transcript-player .player-main-btn .anticon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
transform: translate(1px, 1px);
}
.meeting-detail-page-v2 .transcript-player .player-main-btn .anticon-pause {
transform: none;
}
.meeting-detail-page-v2 .transcript-player .player-ghost-btn {
height: 36px !important;
align-self: center;
padding: 0 14px !important;
border: 1px solid #dce6ff !important;
border-radius: 999px !important;
background: #f4f7ff !important;
color: #3c70f5 !important;
line-height: 1;
box-shadow: none !important;
}
.meeting-detail-page-v2 .transcript-player .player-progress-shell {
flex: 1;
min-width: 0;
gap: 6px;
}
.meeting-detail-page-v2 .transcript-player .player-range {
height: 6px !important;
margin: 0;
border-radius: 999px !important;
background: linear-gradient(
90deg,

View File

@ -9,7 +9,6 @@ import {
DownloadOutlined,
EditOutlined,
FastForwardOutlined,
FileTextOutlined,
LeftOutlined,
LinkOutlined,
LoadingOutlined,
@ -1242,10 +1241,6 @@ const MeetingDetail: React.FC = () => {
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const pendingTranscriptScrollIdRef = useRef<number | null>(null);
const leftColumnRef = useRef<HTMLDivElement>(null);
const transcriptSectionRef = useRef<HTMLDivElement>(null);
const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false);
const [floatingTranscriptPlayerLayout, setFloatingTranscriptPlayerLayout] = useState<{ left: number; width: number } | null>(null);
const [generationProgress, setGenerationProgress] = useState<MeetingProgress | null>(null);
const autoOpenedCatalogAttemptRef = useRef<string | null>(null);
@ -1559,60 +1554,6 @@ const MeetingDetail: React.FC = () => {
meeting?.status,
]);
useEffect(() => {
if (!playbackAudioUrl) {
setShowFloatingTranscriptPlayer(false);
setFloatingTranscriptPlayerLayout(null);
return undefined;
}
const updateFloatingPlayerState = () => {
const target = transcriptSectionRef.current;
if (!target) {
setShowFloatingTranscriptPlayer(false);
setFloatingTranscriptPlayerLayout(null);
return;
}
const rect = target.getBoundingClientRect();
const rootRect = leftColumnRef.current?.getBoundingClientRect();
setFloatingTranscriptPlayerLayout({
left: rect.left,
width: rect.width,
});
if (rootRect) {
const isVisible = rect.bottom > rootRect.top + 80 && rect.top < rootRect.bottom - 40;
setShowFloatingTranscriptPlayer(isVisible);
return;
}
const isVisible = rect.bottom > 120 && rect.top < window.innerHeight - 80;
setShowFloatingTranscriptPlayer(isVisible);
};
updateFloatingPlayerState();
const target = transcriptSectionRef.current;
const root = leftColumnRef.current;
const resizeObserver = target ? new ResizeObserver(() => updateFloatingPlayerState()) : null;
if (target && resizeObserver) {
resizeObserver.observe(target);
}
window.addEventListener('resize', updateFloatingPlayerState);
window.addEventListener('scroll', updateFloatingPlayerState, { passive: true });
root?.addEventListener('scroll', updateFloatingPlayerState, { passive: true });
return () => {
window.removeEventListener('resize', updateFloatingPlayerState);
window.removeEventListener('scroll', updateFloatingPlayerState);
root?.removeEventListener('scroll', updateFloatingPlayerState);
resizeObserver?.disconnect();
};
}, [meeting?.status, playbackAudioUrl]);
useEffect(() => {
if (!id) return;
fetchData(Number(id));
@ -2371,9 +2312,9 @@ const MeetingDetail: React.FC = () => {
contentClassName="meeting-detail-section-content"
title={(
<div className="meeting-detail-title-wrap">
<div className="meeting-detail-title-icon">
<FileTextOutlined />
</div>
<button type="button" className="meeting-detail-back-button" onClick={() => navigate('/meetings')} aria-label="返回会议列表">
<LeftOutlined />
</button>
<div className="meeting-detail-title-copy">
<div className="meeting-detail-title-row">
<span className="meeting-detail-title-text">{meeting.title}</span>
@ -2410,9 +2351,6 @@ const MeetingDetail: React.FC = () => {
)}
extra={(
<Space size={10} wrap>
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
</Button>
{canRetrySchedule && (
<Button icon={<SyncOutlined />} onClick={handleRetrySchedule} loading={actionLoading}>
@ -2519,8 +2457,8 @@ const MeetingDetail: React.FC = () => {
</>
) : (
<>
<Row gutter={24} style={{ height: '100%' }}>
<Col xs={24} xl={13} style={{ height: '100%' }}>
<Row gutter={24} style={{ height: '100%', minHeight: 0 }}>
<Col xs={24} xl={13} style={{ height: '100%', minHeight: 0 }}>
<div className="detail-side-column detail-left-column">
{(generationFailureNotice || emptyTranscriptFailureNotice || meeting.audioSaveStatus === 'FAILED') && (
<Alert
@ -2718,7 +2656,7 @@ const MeetingDetail: React.FC = () => {
</div>
)}
<div ref={transcriptSectionRef} className="transcript-player-anchor">
<div className="transcript-player-anchor">
<Card className="left-flow-card" variant="borderless" title={<Space size={8}><AudioOutlined /></Space>}>
{playbackAudioUrl && (
<audio ref={audioRef} style={{ display: 'none' }} preload="auto">
@ -2766,10 +2704,10 @@ const MeetingDetail: React.FC = () => {
</div>
</Col>
<Col xs={24} xl={11} style={{ height: '100%' }}>
<div ref={leftColumnRef} className="detail-side-column detail-right-column">
<Col xs={24} xl={11} style={{ height: '100%', minHeight: 0 }}>
<div className="detail-side-column detail-right-column">
{(
<div ref={transcriptSectionRef} className="transcript-player-anchor">
<div className="transcript-player-anchor">
<Card
className="left-flow-card transcript-workspace-card"
variant="borderless"
@ -2873,7 +2811,6 @@ const MeetingDetail: React.FC = () => {
<List
className="transcript-list"
dataSource={transcripts}
style={{ paddingBottom: playbackAudioUrl ? 156 : 0 }}
renderItem={(item, index) => {
const nextStartTime = transcripts[index + 1]?.startTime || Infinity;
const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime;
@ -2904,6 +2841,33 @@ const MeetingDetail: React.FC = () => {
</>
)}
</div>
{playbackAudioUrl && (
<div className="transcript-player transcript-player--embedded">
<button type="button" className="player-main-btn" onClick={toggleAudioPlayback} aria-label="toggle-audio">
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
</button>
<div className="player-progress-shell">
<div className="player-time-row">
<span>{formatPlayerTime(audioCurrentTime)}</span>
<span>{formatPlayerTime(audioDuration)}</span>
</div>
<input
className="player-range"
type="range"
min={0}
max={audioDuration || 0}
step={0.1}
value={Math.min(audioCurrentTime, audioDuration || 0)}
style={{ '--player-progress': `${playerProgressPercent}%` } as React.CSSProperties}
onChange={handleAudioProgressChange}
/>
</div>
<button type="button" className="player-ghost-btn" onClick={cyclePlaybackRate}>
<FastForwardOutlined />
{audioPlaybackRate}x
</button>
</div>
)}
</Card>
</div>
)}
@ -2915,37 +2879,6 @@ const MeetingDetail: React.FC = () => {
</div>
</SectionCard>
{playbackAudioUrl && showFloatingTranscriptPlayer && floatingTranscriptPlayerLayout && (
<div
className="transcript-player transcript-player--floating"
style={{ left: floatingTranscriptPlayerLayout.left, width: floatingTranscriptPlayerLayout.width }}
>
<button type="button" className="player-main-btn" onClick={toggleAudioPlayback} aria-label="toggle-audio">
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
</button>
<div className="player-progress-shell">
<div className="player-time-row">
<span>{formatPlayerTime(audioCurrentTime)}</span>
<span>{formatPlayerTime(audioDuration)}</span>
</div>
<input
className="player-range"
type="range"
min={0}
max={audioDuration || 0}
step={0.1}
value={Math.min(audioCurrentTime, audioDuration || 0)}
style={{ '--player-progress': `${playerProgressPercent}%` } as React.CSSProperties}
onChange={handleAudioProgressChange}
/>
</div>
<button type="button" className="player-ghost-btn" onClick={cyclePlaybackRate}>
<FastForwardOutlined />
{audioPlaybackRate}x
</button>
</div>
)}
<style>{`
@keyframes highlight-pulse {
0% { box-shadow: 0 0 0 rgba(95, 81, 255, 0); transform: scale(1); }
@ -3021,29 +2954,35 @@ const MeetingDetail: React.FC = () => {
.meeting-detail-title-wrap {
display: flex;
align-items: flex-start;
gap: 16px;
gap: 12px;
}
.meeting-detail-title-icon {
width: 42px;
height: 42px;
border-radius: 14px;
.meeting-detail-back-button {
width: 30px;
height: 30px;
flex: 0 0 30px;
margin-top: 0;
border: 1px solid #d8e3ff;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
background: linear-gradient(135deg, #5f51ff, #6c8cff);
box-shadow: 0 10px 24px rgba(95, 81, 255, 0.22);
flex-shrink: 0;
color: #3c70f5;
font-size: 14px;
line-height: 1;
background: #ffffff;
cursor: pointer;
}
.meeting-detail-title-copy {
display: grid;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.meeting-detail-title-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 30px;
flex-wrap: wrap;
}
.meeting-detail-title-text {
@ -3133,9 +3072,13 @@ const MeetingDetail: React.FC = () => {
min-width: 0;
}
.summary-panel .ant-card-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
overflow-x: hidden;
padding: 24px;
}
.summary-head {
@ -3153,10 +3096,11 @@ const MeetingDetail: React.FC = () => {
font-weight: 800;
}
.summary-content-box {
background: #f8faff;
border: 1px solid #eef1f9;
border-radius: 16px;
padding: 24px;
min-height: 0;
background: #ffffff;
border: 0;
border-radius: 0;
padding: 0;
}
.summary-section-title {
color: #9aa0bd;
@ -4070,11 +4014,10 @@ const MeetingDetail: React.FC = () => {
box-shadow: 0 18px 44px rgba(72, 86, 128, 0.16);
backdrop-filter: blur(24px);
}
.transcript-player--floating {
position: fixed;
bottom: 84px;
z-index: 100;
max-width: calc(100vw - 32px);
.transcript-player--embedded {
flex: 0 0 auto;
margin: 0;
max-width: none;
}
.player-main-btn,
.player-ghost-btn {
@ -4233,9 +4176,9 @@ const MeetingDetail: React.FC = () => {
.detail-left-column {
overflow: visible;
}
.transcript-player--floating {
bottom: 72px;
max-width: calc(100vw - 24px);
.transcript-player--embedded {
margin: 0;
max-width: none;
}
.section-divider-text {
white-space: normal;

View File

@ -19,7 +19,7 @@
.profile-shell {
display: flex;
flex-direction: column;
gap: 14px;
gap: 10px;
min-width: 0;
}
@ -28,10 +28,10 @@
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 18px;
gap: 14px;
min-width: 0;
overflow: hidden;
padding: 22px 24px;
padding: 14px 18px;
border: 1px solid #dce6f8;
border-radius: 8px;
background:
@ -59,7 +59,8 @@
pointer-events: none;
}
.profile-hero__identity {
.profile-hero__identity,
.profile-hero__facts {
position: relative;
z-index: 1;
}
@ -67,15 +68,15 @@
.profile-hero__identity {
display: flex;
align-items: center;
gap: 18px;
gap: 14px;
min-width: 0;
}
.profile-avatar-button {
position: relative;
flex: 0 0 auto;
width: 96px;
height: 96px;
width: 74px;
height: 74px;
padding: 0;
border: none;
border-radius: 50%;
@ -89,22 +90,22 @@
}
.profile-hero__avatar.ant-avatar {
width: 88px;
height: 88px;
border: 4px solid #ffffff;
width: 68px;
height: 68px;
border: 3px solid #ffffff;
background: #3c70f5;
box-shadow: 0 10px 28px rgba(60, 112, 245, 0.22);
}
.profile-avatar-button__badge {
position: absolute;
right: 4px;
bottom: 5px;
right: 0;
bottom: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
width: 24px;
height: 24px;
border: 2px solid #ffffff;
border-radius: 50%;
color: #ffffff;
@ -127,10 +128,10 @@
}
.profile-hero__title.ant-typography {
margin: 4px 0 2px;
margin: 2px 0 0;
color: #1f2937;
font-size: 26px;
line-height: 34px;
font-size: 22px;
line-height: 30px;
}
.profile-hero__username.ant-typography {
@ -139,23 +140,61 @@
}
.profile-hero__tags {
margin-top: 12px;
margin-top: 8px;
}
.profile-workbench {
display: grid;
grid-template-columns: minmax(292px, 360px) minmax(0, 1fr);
gap: 14px;
min-width: 0;
}
.profile-side-stack {
.profile-hero__facts {
display: flex;
flex-direction: column;
gap: 14px;
align-items: stretch;
justify-content: flex-end;
gap: 10px;
min-width: 280px;
margin-left: auto;
}
.profile-hero-fact {
display: flex;
align-items: center;
gap: 10px;
min-width: 150px;
max-width: 240px;
padding: 9px 12px;
border: 1px solid rgba(220, 230, 248, 0.9);
border-radius: 8px;
background: rgba(255, 255, 255, 0.74);
}
.profile-hero-fact > .anticon {
flex: 0 0 auto;
color: #2463eb;
font-size: 18px;
}
.profile-hero-fact div {
min-width: 0;
}
.profile-hero-fact span,
.profile-hero-fact strong {
display: block;
}
.profile-hero-fact span {
margin-bottom: 3px;
color: #64748b;
font-size: 12px;
line-height: 18px;
}
.profile-hero-fact strong {
color: #1f2937;
font-size: 14px;
line-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-panel {
min-width: 0;
border: 1px solid #e5e9f2;
@ -164,14 +203,7 @@
box-shadow: 0 8px 22px rgba(30, 41, 59, 0.04);
}
.profile-overview-panel,
.profile-security-panel {
padding: 18px;
}
.profile-panel__head,
.profile-editor-panel__head,
.profile-form-intro {
.profile-editor-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
@ -180,108 +212,11 @@
}
.profile-panel__title.ant-typography,
.profile-form-intro .ant-typography {
.profile-tab-summary .ant-typography {
margin: 2px 0 0;
color: #1f2937;
}
.profile-panel__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 8px;
color: #3c70f5;
background: #eef4ff;
font-size: 18px;
}
.profile-meta-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 18px;
}
.profile-meta-list__item {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
padding: 13px;
border: 1px solid #edf1f7;
border-radius: 8px;
background: #fafbfe;
}
.profile-meta-list__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 8px;
color: #2463eb;
background: #eef4ff;
}
.profile-meta-list__content {
min-width: 0;
}
.profile-meta-list__label {
margin-bottom: 4px;
color: #64748b;
font-size: 12px;
line-height: 18px;
}
.profile-meta-list__value {
min-width: 0;
color: #1f2937;
font-weight: 600;
line-height: 22px;
overflow-wrap: anywhere;
}
.profile-security-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 18px;
}
.profile-security-list > div {
padding-left: 12px;
border-left: 3px solid #d8e4fb;
}
.profile-security-list strong,
.profile-security-list span {
display: block;
}
.profile-security-list strong {
margin-bottom: 3px;
color: #1f2937;
font-size: 14px;
line-height: 22px;
}
.profile-security-list span {
color: #64748b;
line-height: 22px;
}
.profile-security-panel__alert,
.profile-security-alert {
margin-top: 16px;
border-radius: 8px;
}
.profile-editor-panel {
display: flex;
flex-direction: column;
@ -289,13 +224,13 @@
}
.profile-editor-panel__head {
padding: 18px 20px 14px;
padding: 12px 18px 10px;
border-bottom: 1px solid #edf1f7;
}
.profile-tabs .ant-tabs-nav {
margin: 0;
padding: 0 20px;
padding: 0 18px;
border-bottom: 1px solid #edf1f7;
background: #ffffff;
}
@ -305,7 +240,7 @@
}
.profile-tabs .ant-tabs-tab {
padding: 14px 0;
padding: 10px 0;
}
.profile-tabs .ant-tabs-tab + .ant-tabs-tab {
@ -313,29 +248,64 @@
}
.profile-tabs .ant-tabs-content-holder {
padding: 20px;
padding: 14px 18px 16px;
}
.profile-form {
max-width: 980px;
max-width: none;
}
.profile-form-intro {
.profile-tab-surface {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.profile-tab-summary {
display: flex;
align-items: center;
margin-bottom: 18px;
padding: 14px 16px;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 10px 12px;
border: 1px solid #edf1f7;
border-radius: 8px;
background: #fafbfe;
}
.profile-form-intro .ant-typography-secondary {
.profile-tab-summary .ant-typography-secondary {
display: block;
margin-top: 4px;
margin-top: 2px;
}
.profile-inline-note {
flex: 0 1 360px;
min-width: 0;
padding: 7px 10px;
border: 1px solid #dbe7ff;
border-radius: 6px;
color: #41607d;
background: #f5f9ff;
font-size: 12px;
line-height: 18px;
}
.profile-basic-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 388px);
gap: 16px;
align-items: start;
min-width: 0;
}
.profile-basic-main,
.profile-basic-media {
min-width: 0;
}
.profile-form .ant-form-item {
margin-bottom: 18px;
margin-bottom: 12px;
}
.profile-form .ant-input,
@ -343,15 +313,22 @@
border-radius: 6px;
}
.profile-readonly-input {
color: #334155;
background: #f8fafc !important;
cursor: default;
text-overflow: ellipsis;
}
.profile-avatar-control {
display: flex;
align-items: stretch;
gap: 18px;
gap: 14px;
box-sizing: border-box;
width: 100%;
min-width: 0;
margin-top: 2px;
padding: 16px;
margin-top: 0;
padding: 12px;
border: 1px dashed #9fbcfb;
border-radius: 8px;
background: linear-gradient(90deg, rgba(60, 112, 245, 0.07), rgba(25, 135, 84, 0.05));
@ -359,19 +336,25 @@
.profile-avatar-control__preview {
flex: 0 0 auto;
width: 132px;
height: 132px;
width: 104px;
height: 104px;
overflow: hidden;
border: 1px solid #dbe6fb;
border-radius: 8px;
background: #ffffff;
}
.profile-avatar-control__preview .ant-upload {
display: block;
width: 100%;
height: 100%;
}
.profile-avatar-control__preview .ant-image,
.profile-avatar-control__image {
display: block;
width: 132px !important;
height: 132px !important;
width: 104px !important;
height: 104px !important;
}
.profile-avatar-control__image {
@ -383,7 +366,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
gap: 8px;
width: 100%;
height: 100%;
padding: 0;
@ -391,16 +374,18 @@
color: #2463eb;
background: #f8fbff;
cursor: pointer;
text-align: center;
}
.profile-avatar-control__empty .anticon {
font-size: 26px;
font-size: 28px;
}
.profile-avatar-control__empty span {
color: #475569;
font-weight: 600;
line-height: 22px;
color: #64748b;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.profile-avatar-control__content {
@ -410,12 +395,12 @@
align-items: flex-start;
justify-content: space-between;
min-width: 0;
gap: 14px;
padding: 4px 0;
gap: 10px;
padding: 2px 0;
}
.profile-avatar-control__title {
margin-bottom: 6px;
margin-bottom: 4px;
color: #1f2937;
font-size: 15px;
font-weight: 600;
@ -426,34 +411,68 @@
max-width: 520px;
color: #64748b;
font-size: 13px;
line-height: 22px;
line-height: 20px;
}
.profile-credential-panel {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
.profile-credential-card {
.profile-credential-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.profile-credential-item {
min-width: 0;
padding: 10px 12px;
border: 1px solid #edf1f7;
border-radius: 8px;
overflow: hidden;
background: #fafbfe;
}
.profile-credential-panel__descriptions .ant-descriptions-view {
border-radius: 8px;
overflow: hidden;
.profile-credential-item--wide {
grid-column: span 2;
}
.profile-credential-panel__descriptions .ant-descriptions-item-label {
width: 180px;
color: #475569;
background: #f8fafc;
.profile-credential-item > span,
.profile-credential-item > strong {
display: block;
min-width: 0;
}
.profile-credential-item > span {
margin-bottom: 6px;
color: #64748b;
font-size: 12px;
line-height: 18px;
}
.profile-credential-item > strong {
color: #1f2937;
font-size: 13px;
font-weight: 600;
line-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-copy-value.ant-typography {
max-width: 100%;
margin-bottom: 0;
color: #1f2937;
line-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-tab-actions {
margin: 16px 0 0;
padding-top: 16px;
margin: 10px 0 0;
padding-top: 10px;
border-top: 1px solid #edf1f7;
}
@ -462,21 +481,24 @@
flex-direction: column;
}
.profile-workbench {
grid-template-columns: 1fr;
.profile-hero__facts {
justify-content: flex-start;
min-width: 0;
margin-left: 0;
}
}
@media (max-width: 768px) {
.profile-shell {
gap: 12px;
gap: 10px;
}
.profile-hero {
padding: 18px;
padding: 14px;
}
.profile-hero__identity {
.profile-hero__identity,
.profile-hero__facts {
align-items: flex-start;
}
@ -485,26 +507,43 @@
line-height: 30px;
}
.profile-workbench {
grid-template-columns: 1fr;
.profile-hero__facts {
flex-wrap: wrap;
}
.profile-editor-panel__head,
.profile-form-intro,
.profile-tab-summary,
.profile-avatar-control {
flex-direction: column;
align-items: flex-start;
}
.profile-basic-layout {
grid-template-columns: 1fr;
}
.profile-inline-note {
width: 100%;
flex-basis: auto;
}
.profile-credential-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.profile-credential-item--wide {
grid-column: span 2;
}
.profile-avatar-control__preview {
width: 112px;
height: 112px;
width: 104px;
height: 104px;
}
.profile-avatar-control__preview .ant-image,
.profile-avatar-control__image {
width: 112px !important;
height: 112px !important;
width: 104px !important;
height: 104px !important;
}
.profile-tabs .ant-tabs-nav,
@ -527,9 +566,15 @@
padding: 16px;
}
.profile-overview-panel,
.profile-security-panel,
.profile-tabs .ant-tabs-content-holder {
padding: 16px;
}
.profile-credential-grid {
grid-template-columns: 1fr;
}
.profile-credential-item--wide {
grid-column: auto;
}
}

View File

@ -1,9 +1,7 @@
import {
Alert,
Avatar,
Button,
Col,
Descriptions,
Form,
Image,
Input,
@ -17,17 +15,12 @@ import {
message
} from "antd";
import {
ApartmentOutlined,
CheckCircleOutlined,
CloudUploadOutlined,
IdcardOutlined,
KeyOutlined,
LockOutlined,
MailOutlined,
PhoneOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SaveOutlined,
SolutionOutlined,
UploadOutlined,
@ -196,32 +189,6 @@ export default function Profile() {
{user && !user.isPlatformAdmin && user.hasPlatformAdminPrivilege ? <Tag color="geekblue"></Tag> : null}
</>
);
const profileMetaItems = [
{
key: "email",
icon: <MailOutlined />,
label: t("users.email"),
value: renderValue(user?.email)
},
{
key: "phone",
icon: <PhoneOutlined />,
label: t("users.phone"),
value: renderValue(user?.phone)
},
{
key: "organization",
icon: <ApartmentOutlined />,
label: t("users.org"),
value: organization
},
{
key: "status",
icon: <CheckCircleOutlined />,
label: t("common.status"),
value: userStatus
}
];
return (
<PageContainer title={null} className="profile-page">
@ -262,73 +229,23 @@ export default function Profile() {
</div>
</div>
<div className="profile-hero__facts">
<div className="profile-hero-fact">
<CheckCircleOutlined />
<div>
<span>{t("common.status")}</span>
<strong>{userStatus}</strong>
</div>
</div>
</div>
</section>
<div className="profile-workbench">
<aside className="profile-side-stack">
<section className="profile-panel profile-overview-panel">
<div className="profile-panel__head">
<div>
<div className="profile-panel__eyebrow"></div>
<Title level={5} className="profile-panel__title"></Title>
</div>
<IdcardOutlined className="profile-panel__icon" />
</div>
<div className="profile-meta-list">
{profileMetaItems.map((item) => (
<div className="profile-meta-list__item" key={item.key}>
<div className="profile-meta-list__icon">{item.icon}</div>
<div className="profile-meta-list__content">
<div className="profile-meta-list__label">{item.label}</div>
<div className="profile-meta-list__value tabular-nums">{item.value}</div>
</div>
</div>
))}
</div>
</section>
<section className="profile-panel profile-security-panel">
<div className="profile-panel__head">
<div>
<div className="profile-panel__eyebrow"></div>
<Title level={5} className="profile-panel__title"></Title>
</div>
<SafetyCertificateOutlined className="profile-panel__icon" />
</div>
<div className="profile-security-list">
<div>
<strong></strong>
<span></span>
</div>
<div>
<strong></strong>
<span></span>
</div>
<div>
<strong>Bot </strong>
<span></span>
</div>
</div>
{user?.pwdResetRequired ? (
<Alert
showIcon
type="warning"
message="当前账号仍需要完成密码重置"
description="请尽快在“安全设置”页签中更新密码。"
className="profile-security-panel__alert"
/>
) : null}
</section>
</aside>
<section className="profile-panel profile-editor-panel">
<div className="profile-editor-panel__head">
<div>
<div className="profile-panel__eyebrow"></div>
<Title level={5} className="profile-panel__title"></Title>
</div>
<Text type="secondary"></Text>
</div>
<Tabs
defaultActiveKey="basic"
@ -343,95 +260,104 @@ export default function Profile() {
),
children: (
<Form form={profileForm} layout="vertical" onFinish={handleUpdateProfile} className="profile-form">
<div className="profile-form-intro">
<div>
<Title level={5}></Title>
<Text type="secondary"></Text>
</div>
</div>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t("users.displayName")} name="displayName" rules={[{required: true}]}>
<Input/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t("users.email")} name="email"
rules={[{required: true, message: "请输入邮箱地址"}, {
type: "email",
message: "请输入正确的邮箱格式"
}]}>
<Input/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t("users.phone")} name="phone">
<Input/>
</Form.Item>
</Col>
</Row>
<Form.Item name="avatarUrl" hidden>
<Input />
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}>
<div className="profile-avatar-control">
<div className="profile-avatar-control__preview">
{avatarUrl ? (
<Image
src={avatarUrl}
alt="头像预览"
className="profile-avatar-control__image"
preview={{ mask: "查看大图" }}
/>
) : (
<div className="profile-tab-surface">
<div className="profile-tab-summary">
<div>
<Title level={5}></Title>
<Text type="secondary"></Text>
</div>
</div>
<div className="profile-basic-layout">
<div className="profile-basic-main">
<Row gutter={16}>
<Col xs={24} lg={12}>
<Form.Item label={t("users.displayName")} name="displayName" rules={[{required: true}]}>
<Input/>
</Form.Item>
</Col>
<Col xs={24} lg={12}>
<Form.Item label={t("users.email")} name="email"
rules={[{required: true, message: "请输入邮箱地址"}, {
type: "email",
message: "请输入正确的邮箱格式"
}]}>
<Input/>
</Form.Item>
</Col>
<Col xs={24} lg={12}>
<Form.Item label={t("users.phone")} name="phone">
<Input/>
</Form.Item>
</Col>
<Col xs={24} lg={12}>
<Form.Item label={t("users.org")}>
<Tooltip title={organization === "-" ? undefined : organization}>
<Input value={organization} readOnly className="profile-readonly-input" />
</Tooltip>
</Form.Item>
</Col>
</Row>
</div>
<div className="profile-basic-media">
<div className="profile-avatar-control">
<div className="profile-avatar-control__preview">
{avatarUrl ? (
<Image
src={avatarUrl}
alt="头像预览"
className="profile-avatar-control__image"
preview={{ mask: "查看大图" }}
/>
) : (
<Upload
accept={AVATAR_UPLOAD_ACCEPT}
showUploadList={false}
beforeUpload={handleAvatarUpload}
disabled={avatarUploading}
>
<button type="button" className="profile-avatar-control__empty" aria-label="选择头像图片">
<CloudUploadOutlined />
<span>PNG / JPG</span>
</button>
</Upload>
)}
</div>
<div className="profile-avatar-control__content">
<div>
<div className="profile-avatar-control__title"></div>
<div className="profile-avatar-control__hint">
{avatarUrl ? "点击图片可放大预览。" : "支持 PNG/JPG 图片,上传后会先裁剪成统一尺寸。"}
</div>
</div>
<Upload
accept={AVATAR_UPLOAD_ACCEPT}
showUploadList={false}
beforeUpload={handleAvatarUpload}
disabled={avatarUploading}
>
<button type="button" className="profile-avatar-control__empty">
<CloudUploadOutlined />
<span></span>
</button>
<Button icon={<UploadOutlined/>} loading={avatarUploading}>
{avatarUrl ? "更换头像" : t("profile.uploadAvatar")}
</Button>
</Upload>
)}
</div>
<div className="profile-avatar-control__content">
<div>
<div className="profile-avatar-control__title"></div>
<div className="profile-avatar-control__hint">
{avatarUrl ? "当前头像已回显,点击图片可放大预览。" : "支持 PNG/JPG 图片,上传后会先裁剪成统一尺寸。"}
</div>
</div>
<Upload
accept={AVATAR_UPLOAD_ACCEPT}
showUploadList={false}
beforeUpload={handleAvatarUpload}
disabled={avatarUploading}
>
<Button icon={<UploadOutlined/>} loading={avatarUploading}>
{avatarUrl ? "更换头像" : t("profile.uploadAvatar")}
</Button>
</Upload>
</div>
</div>
</Col>
</Row>
<div className="app-page__content-toolbar profile-tab-actions">
<div className="app-page__content-toolbar-actions">
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => profileForm.submit()}>
{t("profile.saveChanges")}
</Button>
</div>
<div className="app-page__content-toolbar-filters" />
<div className="app-page__content-toolbar profile-tab-actions">
<div className="app-page__content-toolbar-actions">
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => profileForm.submit()}>
{t("profile.saveChanges")}
</Button>
</div>
<div className="app-page__content-toolbar-filters" />
</div>
</div>
</Form>
)
@ -445,81 +371,86 @@ export default function Profile() {
),
children: (
<Form form={pwdForm} layout="vertical" onFinish={handleUpdatePassword} className="profile-form">
<div className="profile-form-intro">
<div>
<Title level={5}></Title>
<Text type="secondary"></Text>
<div className="profile-tab-surface">
<div className="profile-tab-summary">
<div>
<Title level={5}></Title>
<Text type="secondary"></Text>
</div>
<div className="profile-inline-note"></div>
</div>
</div>
<Alert
showIcon
type="info"
message="密码更新后立即生效"
description="建议使用与其他系统不同的新密码,避免复用。"
className="profile-security-alert"
/>
<Form.Item label={t("profile.currentPassword")} name="oldPassword" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item
label={
<Space size={4}>
<span>{t("profile.newPassword")}</span>
<Tooltip
title={
policyHints.length > 0 ? (
<ul style={{paddingLeft: 18, margin: 0, fontSize: 13, lineHeight: 1.6}}>
{policyHints.map((hint) => (
<li key={hint}>{hint}</li>
))}
</ul>
) : (
t("profile.passwordRules")
)
<Row gutter={16}>
<Col xs={24} lg={8}>
<Form.Item label={t("profile.currentPassword")} name="oldPassword" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
</Col>
<Col xs={24} lg={8}>
<Form.Item
label={
<Space size={4}>
<span>{t("profile.newPassword")}</span>
<Tooltip
title={
policyHints.length > 0 ? (
<ul style={{paddingLeft: 18, margin: 0, fontSize: 13, lineHeight: 1.6}}>
{policyHints.map((hint) => (
<li key={hint}>{hint}</li>
))}
</ul>
) : (
t("profile.passwordRules")
)
}
overlayStyle={{maxWidth: 300}}
>
<QuestionCircleOutlined style={{color: "rgba(0,0,0,0.45)", cursor: "help"}}/>
</Tooltip>
</Space>
}
overlayStyle={{maxWidth: 300}}
name="newPassword"
validateFirst
rules={[
{required: true},
{
validator: buildPasswordPolicyValidator(policy, () => user?.username)
}
]}
>
<QuestionCircleOutlined style={{color: "rgba(0,0,0,0.45)", cursor: "help"}}/>
</Tooltip>
</Space>
}
name="newPassword"
validateFirst
rules={[
{required: true},
{
validator: buildPasswordPolicyValidator(policy, () => user?.username)
}
]}
>
<Input.Password />
</Form.Item>
<Form.Item
label={t("profile.confirmNewPassword")}
name="confirmPassword"
dependencies={["newPassword"]}
rules={[
{ required: true },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("newPassword") === value) {
return Promise.resolve();
}
return Promise.reject(new Error(t("profile.passwordsDoNotMatch")));
}
})
]}
>
<Input.Password />
</Form.Item>
<div className="app-page__content-toolbar profile-tab-actions">
<div className="app-page__content-toolbar-actions">
<Button type="primary" danger loading={saving} onClick={() => pwdForm.submit()}>
{t("profile.updatePassword")}
</Button>
<Input.Password />
</Form.Item>
</Col>
<Col xs={24} lg={8}>
<Form.Item
label={t("profile.confirmNewPassword")}
name="confirmPassword"
dependencies={["newPassword"]}
rules={[
{ required: true },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("newPassword") === value) {
return Promise.resolve();
}
return Promise.reject(new Error(t("profile.passwordsDoNotMatch")));
}
})
]}
>
<Input.Password />
</Form.Item>
</Col>
</Row>
<div className="app-page__content-toolbar profile-tab-actions">
<div className="app-page__content-toolbar-actions">
<Button type="primary" danger loading={saving} onClick={() => pwdForm.submit()}>
{t("profile.updatePassword")}
</Button>
</div>
<div className="app-page__content-toolbar-filters" />
</div>
<div className="app-page__content-toolbar-filters" />
</div>
</Form>
)
@ -533,80 +464,63 @@ export default function Profile() {
),
children: (
<div className="profile-credential-panel">
<div className="profile-form-intro">
<div>
<Title level={5}>Bot </Title>
<Text type="secondary"> Bot 便</Text>
<div className="profile-tab-surface">
<div className="profile-tab-summary">
<div>
<Title level={5}>Bot </Title>
<Text type="secondary"> Bot 便</Text>
</div>
<div className="profile-inline-note"></div>
</div>
</div>
<Alert
type="info"
showIcon
message={t("profile.botCredentialHint")}
description={t("profile.botCredentialHintDesc")}
/>
<div className="profile-credential-card" aria-busy={credentialLoading}>
<Descriptions
bordered
size="middle"
column={1}
className="profile-credential-panel__descriptions"
items={[
{
key: "bind-status",
label: t("profile.botBindStatus"),
children: credential?.bound ? <Tag color="success">{t("profile.botBound")}</Tag> :
<Tag>{t("profile.botUnbound")}</Tag>
},
{
key: "bot-id",
label: "X-Bot-Id",
children: credential?.botId ? (
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}>
{credential.botId}
</Paragraph>
) : (
"-"
)
},
{
key: "bot-secret",
label: "X-Bot-Secret",
children: credential?.botSecret ? (
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}>
{credential.botSecret}
</Paragraph>
) : (
t("profile.botSecretHidden")
)
},
{
key: "last-access-time",
label: t("profile.botLastAccessTime"),
children: <span className="tabular-nums">{renderValue(credential?.lastAccessTime)}</span>
},
{
key: "last-access-ip",
label: t("profile.botLastAccessIp"),
children: <span className="tabular-nums">{renderValue(credential?.lastAccessIp)}</span>
}
]}
/>
</div>
<div className="app-page__content-toolbar profile-tab-actions">
<div className="app-page__content-toolbar-actions">
<Button
type="primary"
icon={credential?.bound ? <ReloadOutlined/> : <KeyOutlined/>}
loading={credentialSaving}
onClick={handleGenerateCredential}
>
{credential?.bound ? t("profile.regenerateBotCredential") : t("profile.generateBotCredential")}
</Button>
<div className="profile-credential-grid" aria-busy={credentialLoading}>
<div className="profile-credential-item">
<span>{t("profile.botBindStatus")}</span>
<strong>{credential?.bound ? <Tag color="success">{t("profile.botBound")}</Tag> : <Tag>{t("profile.botUnbound")}</Tag>}</strong>
</div>
<div className="profile-credential-item profile-credential-item--wide">
<span>X-Bot-Id</span>
{credential?.botId ? (
<Paragraph copyable={{ text: credential.botId }} className="profile-copy-value">
{credential.botId}
</Paragraph>
) : (
<strong>-</strong>
)}
</div>
<div className="profile-credential-item profile-credential-item--wide">
<span>X-Bot-Secret</span>
{credential?.botSecret ? (
<Paragraph copyable={{ text: credential.botSecret }} className="profile-copy-value">
{credential.botSecret}
</Paragraph>
) : (
<strong>{t("profile.botSecretHidden")}</strong>
)}
</div>
<div className="profile-credential-item">
<span>{t("profile.botLastAccessTime")}</span>
<strong className="tabular-nums">{renderValue(credential?.lastAccessTime)}</strong>
</div>
<div className="profile-credential-item">
<span>{t("profile.botLastAccessIp")}</span>
<strong className="tabular-nums">{renderValue(credential?.lastAccessIp)}</strong>
</div>
</div>
<div className="app-page__content-toolbar profile-tab-actions">
<div className="app-page__content-toolbar-actions">
<Button
type="primary"
icon={credential?.bound ? <ReloadOutlined/> : <KeyOutlined/>}
loading={credentialSaving}
onClick={handleGenerateCredential}
>
{credential?.bound ? t("profile.regenerateBotCredential") : t("profile.generateBotCredential")}
</Button>
</div>
<div className="app-page__content-toolbar-filters" />
</div>
<div className="app-page__content-toolbar-filters" />
</div>
</div>
)
@ -614,7 +528,6 @@ export default function Profile() {
]}
/>
</section>
</div>
</div>
</SectionCard>