fix(ui): add batch bar exit animation and chunked diff cards

This commit is contained in:
Supra4E8C
2026-02-16 23:51:23 +08:00
parent 47c3874244
commit 32bf103f15
3 changed files with 175 additions and 94 deletions

View File

@@ -12,57 +12,92 @@
.content { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-sm;
height: 70vh; height: 70vh;
min-height: 420px; min-height: 420px;
} }
.columnLabels { .emptyState {
flex: 1;
border: 1px dashed var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 14px;
display: grid;
place-items: center;
}
.diffList {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: $spacing-sm;
overflow: auto;
padding-right: 2px;
}
.diffCard {
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
overflow: hidden;
}
.diffCardHeader {
padding: 8px 10px;
border-bottom: 1px dashed var(--border-color);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
}
.diffColumns {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: $spacing-sm; gap: $spacing-sm;
font-size: 12px; padding: $spacing-sm;
color: var(--text-secondary);
font-weight: 600;
span {
padding: 0 2px;
}
} }
.mergeRoot { .diffColumn {
flex: 1; min-width: 0;
min-height: 0;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: $radius-md; border-radius: $radius-sm;
overflow: hidden; overflow: hidden;
background: var(--bg-secondary);
:global(.cm-mergeView) {
height: 100%;
overflow: auto !important;
-webkit-overflow-scrolling: touch;
background: var(--bg-primary); background: var(--bg-primary);
} }
:global(.cm-mergeViewEditors) { .diffColumnHeader {
min-height: 100%; display: flex;
} align-items: center;
justify-content: space-between;
:global(.cm-mergeViewEditor) { gap: $spacing-sm;
height: 100%; padding: 8px 10px;
} border-bottom: 1px solid var(--border-color);
font-size: 12px;
:global(.cm-gutters) { font-weight: 600;
color: var(--text-secondary);
background: var(--bg-secondary); background: var(--bg-secondary);
border-right: 1px solid var(--border-color); }
}
:global(.cm-mergeGap) { .lineRange {
background: var(--bg-secondary); font-size: 11px;
border-left: 1px solid var(--border-color); color: var(--text-tertiary);
border-right: 1px solid var(--border-color); font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
} }
.codeBlock {
margin: 0;
padding: 10px;
font-size: 12px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
overflow: auto;
max-height: 240px;
} }
@include mobile { @include mobile {
@@ -70,4 +105,8 @@
height: 65vh; height: 65vh;
min-height: 360px; min-height: 360px;
} }
.diffColumns {
grid-template-columns: 1fr;
}
} }

View File

@@ -1,12 +1,9 @@
import { useEffect, useRef } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EditorState } from '@codemirror/state'; import { Text } from '@codemirror/state';
import { EditorView } from '@codemirror/view'; import { Chunk } from '@codemirror/merge';
import { yaml } from '@codemirror/lang-yaml';
import { MergeView } from '@codemirror/merge';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useThemeStore } from '@/stores';
import styles from './DiffModal.module.scss'; import styles from './DiffModal.module.scss';
type DiffModalProps = { type DiffModalProps = {
@@ -18,6 +15,35 @@ type DiffModalProps = {
loading?: boolean; loading?: boolean;
}; };
type DiffChunkCard = {
id: string;
currentLines: string;
modifiedLines: string;
currentText: string;
modifiedText: string;
};
const clampPos = (doc: Text, pos: number) => Math.max(0, Math.min(pos, doc.length));
const getLineRangeLabel = (doc: Text, from: number, to: number): string => {
const start = clampPos(doc, from);
const end = clampPos(doc, to);
if (start === end) {
const linePos = Math.min(start, doc.length);
return String(doc.lineAt(linePos).number);
}
const startLine = doc.lineAt(start).number;
const endLine = doc.lineAt(Math.max(start, end - 1)).number;
return startLine === endLine ? String(startLine) : `${startLine}-${endLine}`;
};
const getChunkText = (doc: Text, from: number, to: number): string => {
const start = clampPos(doc, from);
const end = clampPos(doc, to);
if (start >= end) return '';
return doc.sliceString(start, end).trimEnd();
};
export function DiffModal({ export function DiffModal({
open, open,
original, original,
@@ -27,50 +53,20 @@ export function DiffModal({
loading = false loading = false
}: DiffModalProps) { }: DiffModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const mergeContainerRef = useRef<HTMLDivElement>(null);
const mergeViewRef = useRef<MergeView | null>(null);
useEffect(() => { const diffCards = useMemo<DiffChunkCard[]>(() => {
if (!open || !mergeContainerRef.current) return; const currentDoc = Text.of(original.split('\n'));
const modifiedDoc = Text.of(modified.split('\n'));
const chunks = Chunk.build(currentDoc, modifiedDoc);
const mountEl = mergeContainerRef.current; return chunks.map((chunk, index) => ({
mountEl.innerHTML = ''; id: `${index}-${chunk.fromA}-${chunk.toA}-${chunk.fromB}-${chunk.toB}`,
currentLines: getLineRangeLabel(currentDoc, chunk.fromA, chunk.toA),
const commonExtensions = [ modifiedLines: getLineRangeLabel(modifiedDoc, chunk.fromB, chunk.toB),
yaml(), currentText: getChunkText(currentDoc, chunk.fromA, chunk.toA),
EditorState.readOnly.of(true), modifiedText: getChunkText(modifiedDoc, chunk.fromB, chunk.toB)
EditorView.editable.of(false), }));
EditorView.lineWrapping, }, [modified, original]);
EditorView.theme(
{
'&': { height: '100%' },
'.cm-scroller': {
fontFamily: "'Consolas', 'Monaco', 'Menlo', monospace"
}
},
{ dark: resolvedTheme === 'dark' }
)
];
const view = new MergeView({
parent: mountEl,
a: { doc: original, extensions: commonExtensions },
b: { doc: modified, extensions: commonExtensions },
orientation: 'a-b',
highlightChanges: true,
gutter: true
});
mergeViewRef.current = view;
return () => {
view.destroy();
if (mergeViewRef.current === view) {
mergeViewRef.current = null;
}
mountEl.innerHTML = '';
};
}, [modified, open, original, resolvedTheme]);
return ( return (
<Modal <Modal
@@ -92,11 +88,33 @@ export function DiffModal({
} }
> >
<div className={styles.content}> <div className={styles.content}>
<div className={styles.columnLabels}> {diffCards.length === 0 ? (
<div className={styles.emptyState}>{t('config_management.diff.no_changes')}</div>
) : (
<div className={styles.diffList}>
{diffCards.map((card, index) => (
<article key={card.id} className={styles.diffCard}>
<div className={styles.diffCardHeader}>#{index + 1}</div>
<div className={styles.diffColumns}>
<section className={styles.diffColumn}>
<header className={styles.diffColumnHeader}>
<span>{t('config_management.diff.current')}</span> <span>{t('config_management.diff.current')}</span>
<span className={styles.lineRange}>L{card.currentLines}</span>
</header>
<pre className={styles.codeBlock}>{card.currentText || '-'}</pre>
</section>
<section className={styles.diffColumn}>
<header className={styles.diffColumnHeader}>
<span>{t('config_management.diff.modified')}</span> <span>{t('config_management.diff.modified')}</span>
<span className={styles.lineRange}>L{card.modifiedLines}</span>
</header>
<pre className={styles.codeBlock}>{card.modifiedText || '-'}</pre>
</section>
</div> </div>
<div className={styles.mergeRoot} ref={mergeContainerRef} /> </article>
))}
</div>
)}
</div> </div>
</Modal> </Modal>
); );

View File

@@ -57,8 +57,10 @@ export function AuthFilesPage() {
const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null); const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list'); const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const floatingBatchActionsRef = useRef<HTMLDivElement>(null); const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const previousSelectionCountRef = useRef(0); const previousSelectionCountRef = useRef(0);
const selectionCountRef = useRef(0);
const { keyStats, usageDetails, loadKeyStats } = useAuthFilesStats(); const { keyStats, usageDetails, loadKeyStats } = useAuthFilesStats();
const { const {
@@ -331,24 +333,46 @@ export function AuthFilesPage() {
window.removeEventListener('resize', updatePadding); window.removeEventListener('resize', updatePadding);
document.documentElement.style.removeProperty('--auth-files-action-bar-height'); document.documentElement.style.removeProperty('--auth-files-action-bar-height');
}; };
}, [batchActionBarVisible, selectionCount]);
useEffect(() => {
selectionCountRef.current = selectionCount;
if (selectionCount > 0) {
setBatchActionBarVisible(true);
}
}, [selectionCount]); }, [selectionCount]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!batchActionBarVisible) return;
const currentCount = selectionCount; const currentCount = selectionCount;
const previousCount = previousSelectionCountRef.current; const previousCount = previousSelectionCountRef.current;
const actionsEl = floatingBatchActionsRef.current; const actionsEl = floatingBatchActionsRef.current;
if (!actionsEl) return;
if (currentCount > 0 && previousCount === 0 && actionsEl) {
gsap.killTweensOf(actionsEl); gsap.killTweensOf(actionsEl);
if (currentCount > 0 && previousCount === 0) {
gsap.fromTo( gsap.fromTo(
actionsEl, actionsEl,
{ y: 56, autoAlpha: 0 }, { y: 56, autoAlpha: 0 },
{ y: 0, autoAlpha: 1, duration: 0.28, ease: 'power3.out' } { y: 0, autoAlpha: 1, duration: 0.28, ease: 'power3.out' }
); );
} else if (currentCount === 0 && previousCount > 0) {
gsap.to(actionsEl, {
y: 56,
autoAlpha: 0,
duration: 0.22,
ease: 'power2.in',
onComplete: () => {
if (selectionCountRef.current === 0) {
setBatchActionBarVisible(false);
}
}
});
} }
previousSelectionCountRef.current = currentCount; previousSelectionCountRef.current = currentCount;
}, [selectionCount]); }, [batchActionBarVisible, selectionCount]);
const renderFilterTags = () => ( const renderFilterTags = () => (
<div className={styles.filterTags}> <div className={styles.filterTags}>
@@ -584,7 +608,7 @@ export function AuthFilesPage() {
onChange={handlePrefixProxyChange} onChange={handlePrefixProxyChange}
/> />
{selectionCount > 0 && typeof document !== 'undefined' {batchActionBarVisible && typeof document !== 'undefined'
? createPortal( ? createPortal(
<div className={styles.batchActionContainer} ref={floatingBatchActionsRef}> <div className={styles.batchActionContainer} ref={floatingBatchActionsRef}>
<div className={styles.batchActionBar}> <div className={styles.batchActionBar}>