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 {
display: flex;
flex-direction: column;
gap: $spacing-sm;
height: 70vh;
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;
grid-template-columns: 1fr 1fr;
gap: $spacing-sm;
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
span {
padding: 0 2px;
}
padding: $spacing-sm;
}
.mergeRoot {
flex: 1;
min-height: 0;
.diffColumn {
min-width: 0;
border: 1px solid var(--border-color);
border-radius: $radius-md;
border-radius: $radius-sm;
overflow: hidden;
background: var(--bg-primary);
}
.diffColumnHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
padding: 8px 10px;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-secondary);
}
:global(.cm-mergeView) {
height: 100%;
overflow: auto !important;
-webkit-overflow-scrolling: touch;
background: var(--bg-primary);
}
.lineRange {
font-size: 11px;
color: var(--text-tertiary);
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
}
:global(.cm-mergeViewEditors) {
min-height: 100%;
}
:global(.cm-mergeViewEditor) {
height: 100%;
}
:global(.cm-gutters) {
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
}
:global(.cm-mergeGap) {
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
border-right: 1px solid var(--border-color);
}
.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 {
@@ -70,4 +105,8 @@
height: 65vh;
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 { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { yaml } from '@codemirror/lang-yaml';
import { MergeView } from '@codemirror/merge';
import { Text } from '@codemirror/state';
import { Chunk } from '@codemirror/merge';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useThemeStore } from '@/stores';
import styles from './DiffModal.module.scss';
type DiffModalProps = {
@@ -18,6 +15,35 @@ type DiffModalProps = {
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({
open,
original,
@@ -27,50 +53,20 @@ export function DiffModal({
loading = false
}: DiffModalProps) {
const { t } = useTranslation();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const mergeContainerRef = useRef<HTMLDivElement>(null);
const mergeViewRef = useRef<MergeView | null>(null);
useEffect(() => {
if (!open || !mergeContainerRef.current) return;
const diffCards = useMemo<DiffChunkCard[]>(() => {
const currentDoc = Text.of(original.split('\n'));
const modifiedDoc = Text.of(modified.split('\n'));
const chunks = Chunk.build(currentDoc, modifiedDoc);
const mountEl = mergeContainerRef.current;
mountEl.innerHTML = '';
const commonExtensions = [
yaml(),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.lineWrapping,
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 chunks.map((chunk, index) => ({
id: `${index}-${chunk.fromA}-${chunk.toA}-${chunk.fromB}-${chunk.toB}`,
currentLines: getLineRangeLabel(currentDoc, chunk.fromA, chunk.toA),
modifiedLines: getLineRangeLabel(modifiedDoc, chunk.fromB, chunk.toB),
currentText: getChunkText(currentDoc, chunk.fromA, chunk.toA),
modifiedText: getChunkText(modifiedDoc, chunk.fromB, chunk.toB)
}));
}, [modified, original]);
return (
<Modal
@@ -92,11 +88,33 @@ export function DiffModal({
}
>
<div className={styles.content}>
<div className={styles.columnLabels}>
<span>{t('config_management.diff.current')}</span>
<span>{t('config_management.diff.modified')}</span>
</div>
<div className={styles.mergeRoot} ref={mergeContainerRef} />
{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 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 className={styles.lineRange}>L{card.modifiedLines}</span>
</header>
<pre className={styles.codeBlock}>{card.modifiedText || '-'}</pre>
</section>
</div>
</article>
))}
</div>
)}
</div>
</Modal>
);

View File

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