fix(config): preserve mobile scroll after API key modal close and add one-click key copy

This commit is contained in:
LTbinglingfeng
2026-02-07 12:22:16 +08:00
parent e053854544
commit 525b152a76
5 changed files with 105 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconChevronDown } from '@/components/ui/icons'; import { IconChevronDown } from '@/components/ui/icons';
import { ConfigSection } from '@/components/config/ConfigSection'; import { ConfigSection } from '@/components/config/ConfigSection';
import { useNotificationStore } from '@/stores';
import styles from './VisualConfigEditor.module.scss'; import styles from './VisualConfigEditor.module.scss';
import type { import type {
PayloadFilterRule, PayloadFilterRule,
@@ -201,6 +202,7 @@ function ApiKeysCardEditor({
onChange: (nextValue: string) => void; onChange: (nextValue: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const apiKeys = useMemo( const apiKeys = useMemo(
() => () =>
value value
@@ -263,6 +265,34 @@ function ApiKeysCardEditor({
closeModal(); closeModal();
}; };
const handleCopy = async (apiKey: string) => {
const copyByExecCommand = () => {
const textarea = document.createElement('textarea');
textarea.value = apiKey;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
if (!copied) throw new Error('copy_failed');
};
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(apiKey);
} else {
copyByExecCommand();
}
showNotification(t('notification.link_copied'), 'success');
} catch {
showNotification(t('notification.copy_failed'), 'error');
}
};
return ( return (
<div className="form-group" style={{ marginBottom: 0 }}> <div className="form-group" style={{ marginBottom: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
@@ -294,6 +324,9 @@ function ApiKeysCardEditor({
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div> <div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
</div> </div>
<div className="item-actions"> <div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => handleCopy(key)} disabled={disabled}>
{t('common.copy')}
</Button>
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}> <Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}>
{t('config_management.visual.common.edit')} {t('config_management.visual.common.edit')}
</Button> </Button>

View File

@@ -16,11 +16,53 @@ const CLOSE_ANIMATION_DURATION = 350;
const MODAL_LOCK_CLASS = 'modal-open'; const MODAL_LOCK_CLASS = 'modal-open';
let activeModalCount = 0; let activeModalCount = 0;
const scrollLockSnapshot = {
scrollY: 0,
contentScrollTop: 0,
contentEl: null as HTMLElement | null,
bodyPosition: '',
bodyTop: '',
bodyLeft: '',
bodyRight: '',
bodyWidth: '',
bodyOverflow: '',
htmlOverflow: '',
};
const resolveContentScrollContainer = () => {
if (typeof document === 'undefined') return null;
const contentEl = document.querySelector('.content');
return contentEl instanceof HTMLElement ? contentEl : null;
};
const lockScroll = () => { const lockScroll = () => {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
if (activeModalCount === 0) { if (activeModalCount === 0) {
document.body?.classList.add(MODAL_LOCK_CLASS); const body = document.body;
document.documentElement?.classList.add(MODAL_LOCK_CLASS); const html = document.documentElement;
const contentEl = resolveContentScrollContainer();
scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0;
scrollLockSnapshot.contentEl = contentEl;
scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0;
scrollLockSnapshot.bodyPosition = body.style.position;
scrollLockSnapshot.bodyTop = body.style.top;
scrollLockSnapshot.bodyLeft = body.style.left;
scrollLockSnapshot.bodyRight = body.style.right;
scrollLockSnapshot.bodyWidth = body.style.width;
scrollLockSnapshot.bodyOverflow = body.style.overflow;
scrollLockSnapshot.htmlOverflow = html.style.overflow;
body.classList.add(MODAL_LOCK_CLASS);
html.classList.add(MODAL_LOCK_CLASS);
body.style.position = 'fixed';
body.style.top = `-${scrollLockSnapshot.scrollY}px`;
body.style.left = '0';
body.style.right = '0';
body.style.width = '100%';
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
} }
activeModalCount += 1; activeModalCount += 1;
}; };
@@ -29,8 +71,31 @@ const unlockScroll = () => {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
activeModalCount = Math.max(0, activeModalCount - 1); activeModalCount = Math.max(0, activeModalCount - 1);
if (activeModalCount === 0) { if (activeModalCount === 0) {
document.body?.classList.remove(MODAL_LOCK_CLASS); const body = document.body;
document.documentElement?.classList.remove(MODAL_LOCK_CLASS); const html = document.documentElement;
const scrollY = scrollLockSnapshot.scrollY;
const contentScrollTop = scrollLockSnapshot.contentScrollTop;
const contentEl = scrollLockSnapshot.contentEl;
body.classList.remove(MODAL_LOCK_CLASS);
html.classList.remove(MODAL_LOCK_CLASS);
body.style.position = scrollLockSnapshot.bodyPosition;
body.style.top = scrollLockSnapshot.bodyTop;
body.style.left = scrollLockSnapshot.bodyLeft;
body.style.right = scrollLockSnapshot.bodyRight;
body.style.width = scrollLockSnapshot.bodyWidth;
body.style.overflow = scrollLockSnapshot.bodyOverflow;
html.style.overflow = scrollLockSnapshot.htmlOverflow;
if (contentEl) {
contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' });
}
window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' });
scrollLockSnapshot.scrollY = 0;
scrollLockSnapshot.contentScrollTop = 0;
scrollLockSnapshot.contentEl = null;
} }
}; };

View File

@@ -1094,6 +1094,7 @@
"gemini_api_key": "Gemini API key", "gemini_api_key": "Gemini API key",
"codex_api_key": "Codex API key", "codex_api_key": "Codex API key",
"claude_api_key": "Claude API key", "claude_api_key": "Claude API key",
"copy_failed": "Copy failed",
"link_copied": "Link copied to clipboard" "link_copied": "Link copied to clipboard"
}, },
"language": { "language": {

View File

@@ -1099,6 +1099,7 @@
"gemini_api_key": "API-ключ Gemini", "gemini_api_key": "API-ключ Gemini",
"codex_api_key": "API-ключ Codex", "codex_api_key": "API-ключ Codex",
"claude_api_key": "API-ключ Claude", "claude_api_key": "API-ключ Claude",
"copy_failed": "Не удалось скопировать",
"link_copied": "Ссылка скопирована в буфер обмена" "link_copied": "Ссылка скопирована в буфер обмена"
}, },
"language": { "language": {

View File

@@ -1094,6 +1094,7 @@
"gemini_api_key": "Gemini API密钥", "gemini_api_key": "Gemini API密钥",
"codex_api_key": "Codex API密钥", "codex_api_key": "Codex API密钥",
"claude_api_key": "Claude API密钥", "claude_api_key": "Claude API密钥",
"copy_failed": "复制失败",
"link_copied": "已复制" "link_copied": "已复制"
}, },
"language": { "language": {