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