mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 02:30:51 +08:00
fix(ui): add batch bar exit animation and chunked diff cards
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user