feat:调整会议详情

dev_na
puz 2026-07-03 15:41:45 +08:00
parent 37696b13a9
commit 2bc0b55e60
4 changed files with 642 additions and 658 deletions

View File

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

View File

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

View File

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

View File

@ -1,9 +1,7 @@
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Col, Col,
Descriptions,
Form, Form,
Image, Image,
Input, Input,
@ -17,17 +15,12 @@ import {
message message
} from "antd"; } from "antd";
import { import {
ApartmentOutlined,
CheckCircleOutlined, CheckCircleOutlined,
CloudUploadOutlined, CloudUploadOutlined,
IdcardOutlined,
KeyOutlined, KeyOutlined,
LockOutlined, LockOutlined,
MailOutlined,
PhoneOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
ReloadOutlined, ReloadOutlined,
SafetyCertificateOutlined,
SaveOutlined, SaveOutlined,
SolutionOutlined, SolutionOutlined,
UploadOutlined, UploadOutlined,
@ -196,32 +189,6 @@ export default function Profile() {
{user && !user.isPlatformAdmin && user.hasPlatformAdminPrivilege ? <Tag color="geekblue"></Tag> : null} {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 ( return (
<PageContainer title={null} className="profile-page"> <PageContainer title={null} className="profile-page">
@ -262,73 +229,23 @@ export default function Profile() {
</div> </div>
</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> </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"> <section className="profile-panel profile-editor-panel">
<div className="profile-editor-panel__head"> <div className="profile-editor-panel__head">
<div> <div>
<div className="profile-panel__eyebrow"></div> <div className="profile-panel__eyebrow"></div>
<Title level={5} className="profile-panel__title"></Title> <Title level={5} className="profile-panel__title"></Title>
</div> </div>
<Text type="secondary"></Text>
</div> </div>
<Tabs <Tabs
defaultActiveKey="basic" defaultActiveKey="basic"
@ -343,95 +260,104 @@ export default function Profile() {
), ),
children: ( children: (
<Form form={profileForm} layout="vertical" onFinish={handleUpdateProfile} className="profile-form"> <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> <Form.Item name="avatarUrl" hidden>
<Input /> <Input />
</Form.Item> </Form.Item>
<Row gutter={16}> <div className="profile-tab-surface">
<Col xs={24} md={12}> <div className="profile-tab-summary">
<div className="profile-avatar-control"> <div>
<div className="profile-avatar-control__preview"> <Title level={5}></Title>
{avatarUrl ? ( <Text type="secondary"></Text>
<Image </div>
src={avatarUrl} </div>
alt="头像预览"
className="profile-avatar-control__image" <div className="profile-basic-layout">
preview={{ mask: "查看大图" }} <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 <Upload
accept={AVATAR_UPLOAD_ACCEPT} accept={AVATAR_UPLOAD_ACCEPT}
showUploadList={false} showUploadList={false}
beforeUpload={handleAvatarUpload} beforeUpload={handleAvatarUpload}
disabled={avatarUploading} disabled={avatarUploading}
> >
<button type="button" className="profile-avatar-control__empty"> <Button icon={<UploadOutlined/>} loading={avatarUploading}>
<CloudUploadOutlined /> {avatarUrl ? "更换头像" : t("profile.uploadAvatar")}
<span></span> </Button>
</button>
</Upload> </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> </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>
</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>
<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> </div>
</Form> </Form>
) )
@ -445,81 +371,86 @@ export default function Profile() {
), ),
children: ( children: (
<Form form={pwdForm} layout="vertical" onFinish={handleUpdatePassword} className="profile-form"> <Form form={pwdForm} layout="vertical" onFinish={handleUpdatePassword} className="profile-form">
<div className="profile-form-intro"> <div className="profile-tab-surface">
<div> <div className="profile-tab-summary">
<Title level={5}></Title> <div>
<Text type="secondary"></Text> <Title level={5}></Title>
<Text type="secondary"></Text>
</div>
<div className="profile-inline-note"></div>
</div> </div>
</div>
<Alert
showIcon
type="info"
message="密码更新后立即生效"
description="建议使用与其他系统不同的新密码,避免复用。"
className="profile-security-alert"
/>
<Form.Item label={t("profile.currentPassword")} name="oldPassword" rules={[{ required: true }]}> <Row gutter={16}>
<Input.Password /> <Col xs={24} lg={8}>
</Form.Item> <Form.Item label={t("profile.currentPassword")} name="oldPassword" rules={[{ required: true }]}>
<Form.Item <Input.Password />
label={ </Form.Item>
<Space size={4}> </Col>
<span>{t("profile.newPassword")}</span> <Col xs={24} lg={8}>
<Tooltip <Form.Item
title={ label={
policyHints.length > 0 ? ( <Space size={4}>
<ul style={{paddingLeft: 18, margin: 0, fontSize: 13, lineHeight: 1.6}}> <span>{t("profile.newPassword")}</span>
{policyHints.map((hint) => ( <Tooltip
<li key={hint}>{hint}</li> title={
))} policyHints.length > 0 ? (
</ul> <ul style={{paddingLeft: 18, margin: 0, fontSize: 13, lineHeight: 1.6}}>
) : ( {policyHints.map((hint) => (
t("profile.passwordRules") <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"}}/> <Input.Password />
</Tooltip> </Form.Item>
</Space> </Col>
} <Col xs={24} lg={8}>
name="newPassword" <Form.Item
validateFirst label={t("profile.confirmNewPassword")}
rules={[ name="confirmPassword"
{required: true}, dependencies={["newPassword"]}
{ rules={[
validator: buildPasswordPolicyValidator(policy, () => user?.username) { required: true },
} ({ getFieldValue }) => ({
]} validator(_, value) {
> if (!value || getFieldValue("newPassword") === value) {
<Input.Password /> return Promise.resolve();
</Form.Item> }
<Form.Item return Promise.reject(new Error(t("profile.passwordsDoNotMatch")));
label={t("profile.confirmNewPassword")} }
name="confirmPassword" })
dependencies={["newPassword"]} ]}
rules={[ >
{ required: true }, <Input.Password />
({ getFieldValue }) => ({ </Form.Item>
validator(_, value) { </Col>
if (!value || getFieldValue("newPassword") === value) { </Row>
return Promise.resolve();
} <div className="app-page__content-toolbar profile-tab-actions">
return Promise.reject(new Error(t("profile.passwordsDoNotMatch"))); <div className="app-page__content-toolbar-actions">
} <Button type="primary" danger loading={saving} onClick={() => pwdForm.submit()}>
}) {t("profile.updatePassword")}
]} </Button>
> </div>
<Input.Password /> <div className="app-page__content-toolbar-filters" />
</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>
</div> </div>
<div className="app-page__content-toolbar-filters" />
</div> </div>
</Form> </Form>
) )
@ -533,80 +464,63 @@ export default function Profile() {
), ),
children: ( children: (
<div className="profile-credential-panel"> <div className="profile-credential-panel">
<div className="profile-form-intro"> <div className="profile-tab-surface">
<div> <div className="profile-tab-summary">
<Title level={5}>Bot </Title> <div>
<Text type="secondary"> Bot 便</Text> <Title level={5}>Bot </Title>
<Text type="secondary"> Bot 便</Text>
</div>
<div className="profile-inline-note"></div>
</div> </div>
</div>
<Alert
type="info"
showIcon
message={t("profile.botCredentialHint")}
description={t("profile.botCredentialHintDesc")}
/>
<div className="profile-credential-card" aria-busy={credentialLoading}> <div className="profile-credential-grid" aria-busy={credentialLoading}>
<Descriptions <div className="profile-credential-item">
bordered <span>{t("profile.botBindStatus")}</span>
size="middle" <strong>{credential?.bound ? <Tag color="success">{t("profile.botBound")}</Tag> : <Tag>{t("profile.botUnbound")}</Tag>}</strong>
column={1} </div>
className="profile-credential-panel__descriptions" <div className="profile-credential-item profile-credential-item--wide">
items={[ <span>X-Bot-Id</span>
{ {credential?.botId ? (
key: "bind-status", <Paragraph copyable={{ text: credential.botId }} className="profile-copy-value">
label: t("profile.botBindStatus"), {credential.botId}
children: credential?.bound ? <Tag color="success">{t("profile.botBound")}</Tag> : </Paragraph>
<Tag>{t("profile.botUnbound")}</Tag> ) : (
}, <strong>-</strong>
{ )}
key: "bot-id", </div>
label: "X-Bot-Id", <div className="profile-credential-item profile-credential-item--wide">
children: credential?.botId ? ( <span>X-Bot-Secret</span>
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}> {credential?.botSecret ? (
{credential.botId} <Paragraph copyable={{ text: credential.botSecret }} className="profile-copy-value">
</Paragraph> {credential.botSecret}
) : ( </Paragraph>
"-" ) : (
) <strong>{t("profile.botSecretHidden")}</strong>
}, )}
{ </div>
key: "bot-secret", <div className="profile-credential-item">
label: "X-Bot-Secret", <span>{t("profile.botLastAccessTime")}</span>
children: credential?.botSecret ? ( <strong className="tabular-nums">{renderValue(credential?.lastAccessTime)}</strong>
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}> </div>
{credential.botSecret} <div className="profile-credential-item">
</Paragraph> <span>{t("profile.botLastAccessIp")}</span>
) : ( <strong className="tabular-nums">{renderValue(credential?.lastAccessIp)}</strong>
t("profile.botSecretHidden") </div>
) </div>
},
{ <div className="app-page__content-toolbar profile-tab-actions">
key: "last-access-time", <div className="app-page__content-toolbar-actions">
label: t("profile.botLastAccessTime"), <Button
children: <span className="tabular-nums">{renderValue(credential?.lastAccessTime)}</span> type="primary"
}, icon={credential?.bound ? <ReloadOutlined/> : <KeyOutlined/>}
{ loading={credentialSaving}
key: "last-access-ip", onClick={handleGenerateCredential}
label: t("profile.botLastAccessIp"), >
children: <span className="tabular-nums">{renderValue(credential?.lastAccessIp)}</span> {credential?.bound ? t("profile.regenerateBotCredential") : t("profile.generateBotCredential")}
} </Button>
]} </div>
/> <div className="app-page__content-toolbar-filters" />
</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>
<div className="app-page__content-toolbar-filters" />
</div> </div>
</div> </div>
) )
@ -614,7 +528,6 @@ export default function Profile() {
]} ]}
/> />
</section> </section>
</div>
</div> </div>
</SectionCard> </SectionCard>