diff --git a/src/presentation/static/css/providers.css b/src/presentation/static/css/providers.css index c1807fd..2844f6d 100644 --- a/src/presentation/static/css/providers.css +++ b/src/presentation/static/css/providers.css @@ -308,6 +308,7 @@ align-items: center; gap: 8px; white-space: nowrap; + overflow: visible; } .providers-page .switch { @@ -449,6 +450,95 @@ color: #fff; } +.providers-page .btn-delete.is-active { + background: var(--danger-hover); + border-color: var(--danger-hover); + color: #fff; +} + +.providers-page .inline-confirm-wrap { + position: relative; + display: inline-flex; +} + +.providers-page .inline-confirm-popover { + position: fixed; + z-index: 1080; + width: max-content; + max-width: min(188px, calc(100vw - 24px)); + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + box-shadow: var(--shadow-soft); +} + +.providers-page .floating-delete-popover[hidden] { + display: none !important; +} + +.providers-page .inline-confirm-popover::after, +.providers-page .inline-confirm-popover::before { + content: ""; + position: absolute; + left: var(--popover-arrow-left, 50%); + transform: translateX(-50%); + border-style: solid; +} + +.providers-page .inline-confirm-popover[data-placement="top"]::after { + top: 100%; + border-width: 6px 6px 0 6px; + border-color: var(--line) transparent transparent transparent; +} + +.providers-page .inline-confirm-popover[data-placement="top"]::before { + top: 100%; + margin-top: -1px; + border-width: 5px 5px 0 5px; + border-color: #fff transparent transparent transparent; + z-index: 1; +} + +.providers-page .inline-confirm-popover[data-placement="bottom"]::after { + bottom: 100%; + border-width: 0 6px 6px 6px; + border-color: transparent transparent var(--line) transparent; +} + +.providers-page .inline-confirm-popover[data-placement="bottom"]::before { + bottom: 100%; + margin-bottom: -1px; + border-width: 0 5px 5px 5px; + border-color: transparent transparent #fff transparent; + z-index: 1; +} + +.providers-page .inline-confirm-text { + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + line-height: 1.35; + white-space: nowrap; +} + +.providers-page .inline-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + margin-top: 8px; +} + +.providers-page .inline-confirm-action { + min-width: 54px; + padding: 0 12px; +} + +.providers-page .inline-confirm-action:disabled { + opacity: 0.72; + cursor: wait; +} + :root[data-theme="dark"] .providers-page .btn-action { background: #0d1117; } @@ -845,6 +935,33 @@ border-color: #30363d !important; } +:root[data-theme="dark"] .providers-page .inline-confirm-popover { + border-color: #30363d; + background: #161b22; +} + +:root[data-theme="dark"] .providers-page .inline-confirm-popover::after { + border-top-color: #30363d; +} + +:root[data-theme="dark"] .providers-page .inline-confirm-popover::before { + border-top-color: #161b22; +} + +:root[data-theme="dark"] .providers-page .inline-confirm-popover[data-placement="bottom"]::after { + border-top-color: transparent; + border-bottom-color: #30363d; +} + +:root[data-theme="dark"] .providers-page .inline-confirm-popover[data-placement="bottom"]::before { + border-top-color: transparent; + border-bottom-color: #161b22; +} + +:root[data-theme="dark"] .providers-page .inline-confirm-action:disabled { + opacity: 0.78; +} + :root[data-theme="dark"] .providers-page .modal .btn-secondary { border-color: #3b4654; background: linear-gradient(180deg, rgba(28, 36, 49, 0.98), rgba(20, 27, 38, 0.98)); diff --git a/src/presentation/static/css/users.css b/src/presentation/static/css/users.css index 1ace110..eb48238 100644 --- a/src/presentation/static/css/users.css +++ b/src/presentation/static/css/users.css @@ -289,6 +289,7 @@ display: flex; align-items: center; gap: 8px; + overflow: visible; } .users-page .btn-action { @@ -336,6 +337,95 @@ border-color: var(--danger-hover); } +.users-page .btn-delete.is-active { + color: #fff; + background: var(--danger-hover); + border-color: var(--danger-hover); +} + +.users-page .inline-confirm-wrap { + position: relative; + display: inline-flex; +} + +.users-page .inline-confirm-popover { + position: fixed; + z-index: 1080; + width: max-content; + max-width: min(188px, calc(100vw - 24px)); + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + box-shadow: var(--shadow-soft); +} + +.users-page .floating-delete-popover[hidden] { + display: none !important; +} + +.users-page .inline-confirm-popover::after, +.users-page .inline-confirm-popover::before { + content: ""; + position: absolute; + left: var(--popover-arrow-left, 50%); + transform: translateX(-50%); + border-style: solid; +} + +.users-page .inline-confirm-popover[data-placement="top"]::after { + top: 100%; + border-width: 6px 6px 0 6px; + border-color: var(--line) transparent transparent transparent; +} + +.users-page .inline-confirm-popover[data-placement="top"]::before { + top: 100%; + margin-top: -1px; + border-width: 5px 5px 0 5px; + border-color: #fff transparent transparent transparent; + z-index: 1; +} + +.users-page .inline-confirm-popover[data-placement="bottom"]::after { + bottom: 100%; + border-width: 0 6px 6px 6px; + border-color: transparent transparent var(--line) transparent; +} + +.users-page .inline-confirm-popover[data-placement="bottom"]::before { + bottom: 100%; + margin-bottom: -1px; + border-width: 0 5px 5px 5px; + border-color: transparent transparent #fff transparent; + z-index: 1; +} + +.users-page .inline-confirm-text { + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + line-height: 1.35; + white-space: nowrap; +} + +.users-page .inline-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + margin-top: 8px; +} + +.users-page .inline-confirm-action { + min-width: 54px; + padding: 0 12px; +} + +.users-page .inline-confirm-action:disabled { + opacity: 0.72; + cursor: wait; +} + .users-page .switch { position: relative; display: inline-block; @@ -480,6 +570,9 @@ border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-soft) !important; + background: var(--surface); + color: var(--text-primary); + overflow: hidden; } .users-page .modal-header, @@ -488,14 +581,105 @@ padding-right: 20px; } +.users-page .modal-header, +.users-page .modal-body, +.users-page .modal-footer { + background: var(--surface); +} + .users-page .modal-title { color: var(--text-primary); } +.users-page .modal-subtitle { + margin-top: 4px; + color: var(--text-secondary); + font-size: 14px; +} + +.users-page .modal-footer .btn { + min-width: 92px; +} + +.users-page .modal .form-label { + color: var(--text-secondary); +} + +.users-page .modal .btn-secondary { + border-color: var(--line-strong); + background: rgba(255, 255, 255, 0.88); + color: var(--text-secondary); + font-weight: 700; +} + +.users-page .modal .btn-secondary:hover, +.users-page .modal .btn-secondary:focus { + border-color: var(--primary); + background: #ffffff; + color: var(--primary); + box-shadow: none; +} + +.users-page .btn-close { + opacity: 0.72; +} + +.users-page .btn-close:hover { + opacity: 1; +} + .users-page .btn-close:focus { box-shadow: 0 0 0 3px var(--focus); } +:root[data-theme="dark"] .users-page .modal-content, +:root[data-theme="dark"] .users-page .modal-header, +:root[data-theme="dark"] .users-page .modal-body, +:root[data-theme="dark"] .users-page .modal-footer { + background: #161b22; +} + +:root[data-theme="dark"] .users-page .modal-content { + border-color: #30363d; +} + +:root[data-theme="dark"] .users-page .modal .btn-secondary { + border-color: #3b4654; + background: linear-gradient(180deg, rgba(28, 36, 49, 0.98), rgba(20, 27, 38, 0.98)); + color: #bcc8d8; +} + +:root[data-theme="dark"] .users-page .btn-close { + filter: invert(1) grayscale(1); +} + +:root[data-theme="dark"] .users-page .inline-confirm-popover { + border-color: #30363d; + background: #161b22; +} + +:root[data-theme="dark"] .users-page .inline-confirm-popover::after { + border-top-color: #30363d; +} + +:root[data-theme="dark"] .users-page .inline-confirm-popover::before { + border-top-color: #161b22; +} + +:root[data-theme="dark"] .users-page .inline-confirm-popover[data-placement="bottom"]::after { + border-top-color: transparent; + border-bottom-color: #30363d; +} + +:root[data-theme="dark"] .users-page .inline-confirm-popover[data-placement="bottom"]::before { + border-top-color: transparent; + border-bottom-color: #161b22; +} + +:root[data-theme="dark"] .users-page .inline-confirm-action:disabled { + opacity: 0.78; +} + @media (max-width: 900px) { .users-page .header, .users-page .add-user-section, diff --git a/src/presentation/templates/providers.html b/src/presentation/templates/providers.html index 8bca769..693ae7c 100644 --- a/src/presentation/templates/providers.html +++ b/src/presentation/templates/providers.html @@ -166,6 +166,13 @@ + {% endblock %} {% block page_scripts %} @@ -178,6 +185,8 @@ let fetchModelPickerModalInstance = null; let modelDetailModalInstance = null; let activeModelProvider = null; + let pendingDeleteProviderName = null; + let deletingProviderName = null; let chatWhitelistEnabled = initialChatWhitelistEnabled; let chatWhitelistSaving = false; let fetchedModelCandidates = []; @@ -197,6 +206,94 @@ return decodeURIComponent(encodedName); } + document.addEventListener('click', function(event) { + if (!event.target.closest('.inline-confirm-wrap') && !event.target.closest('#providerDeletePopover')) { + closeDeleteProviderConfirm(); + } + }); + + document.addEventListener('keydown', function(event) { + if (event.key === 'Escape') { + closeDeleteProviderConfirm(); + } + }); + + window.addEventListener('resize', function() { + closeDeleteProviderConfirm(false); + }); + + window.addEventListener('scroll', function() { + closeDeleteProviderConfirm(false); + }, true); + + function getProviderDeletePopoverElement() { + return document.getElementById('providerDeletePopover'); + } + + function getProviderDeleteTrigger(name) { + const triggerKey = encodeURIComponent(name); + return Array.from(document.querySelectorAll('[data-provider-delete-trigger]')) + .find(element => element.dataset.providerDeleteTrigger === triggerKey) || null; + } + + function hideProviderDeletePopover() { + const popover = getProviderDeletePopoverElement(); + if (!popover) return; + popover.hidden = true; + popover.style.removeProperty('top'); + popover.style.removeProperty('left'); + popover.style.removeProperty('--popover-arrow-left'); + popover.dataset.placement = 'bottom'; + } + + function syncDeleteProviderPopover() { + const popover = getProviderDeletePopoverElement(); + if (!popover) return; + + if (pendingDeleteProviderName === null) { + hideProviderDeletePopover(); + return; + } + + const trigger = getProviderDeleteTrigger(pendingDeleteProviderName); + if (!trigger) { + pendingDeleteProviderName = null; + deletingProviderName = null; + hideProviderDeletePopover(); + return; + } + + popover.hidden = false; + const confirmButton = document.getElementById('confirmDeleteProviderBtn'); + if (confirmButton) { + confirmButton.disabled = deletingProviderName === pendingDeleteProviderName; + confirmButton.textContent = deletingProviderName === pendingDeleteProviderName ? '删除中...' : '确认'; + } + + const triggerRect = trigger.getBoundingClientRect(); + const popoverRect = popover.getBoundingClientRect(); + const viewportPadding = 12; + const offset = 10; + const canPlaceBottom = triggerRect.bottom + offset + popoverRect.height <= window.innerHeight - viewportPadding; + const shouldPlaceBottom = canPlaceBottom || triggerRect.top < popoverRect.height + viewportPadding + offset; + const top = shouldPlaceBottom + ? Math.min(window.innerHeight - popoverRect.height - viewportPadding, triggerRect.bottom + offset) + : Math.max(viewportPadding, triggerRect.top - popoverRect.height - offset); + const left = Math.min( + window.innerWidth - popoverRect.width - viewportPadding, + Math.max(viewportPadding, triggerRect.left + ((triggerRect.width - popoverRect.width) / 2)) + ); + const arrowLeft = Math.min( + popoverRect.width - 18, + Math.max(18, triggerRect.left + (triggerRect.width / 2) - left) + ); + + popover.dataset.placement = shouldPlaceBottom ? 'bottom' : 'top'; + popover.style.top = `${top}px`; + popover.style.left = `${left}px`; + popover.style.setProperty('--popover-arrow-left', `${arrowLeft}px`); + } + function updateChatWhitelistUi() { const card = document.getElementById('chatWhitelistCard'); const status = document.getElementById('chatWhitelistStatus'); @@ -576,6 +673,9 @@ async function loadProviders() { try { + pendingDeleteProviderName = null; + deletingProviderName = null; + hideProviderDeletePopover(); const response = await fetch('/api/providers'); const data = await response.json(); if (!response.ok) { @@ -616,6 +716,7 @@ providers.forEach(provider => { const models = Array.isArray(provider.model_list) ? provider.model_list : []; const preview = getModelPreview(models); + const isDeletePending = pendingDeleteProviderName === provider.name; const modelTags = preview.visible.length ? preview.visible.map(model => `${escapeHtml(model)}`).join('') : '未配置'; @@ -640,7 +741,14 @@
- +
+ +
@@ -649,6 +757,7 @@ html += ''; container.innerHTML = html; + syncDeleteProviderPopover(); } async function saveProvider() { @@ -687,8 +796,46 @@ } } - async function deleteProvider(name) { - if (!confirm(`确定删除 provider "${name}" 吗?`)) return; + function toggleDeleteProviderConfirm(name, event) { + event?.stopPropagation(); + if (deletingProviderName !== null) return; + + pendingDeleteProviderName = pendingDeleteProviderName === name ? null : name; + if (pendingDeleteProviderName !== name) { + deletingProviderName = null; + } + renderProviders(); + } + + function cancelDeleteProviderConfirm(event) { + event?.stopPropagation(); + closeDeleteProviderConfirm(true); + } + + function closeDeleteProviderConfirm(shouldRender = true) { + if (pendingDeleteProviderName === null && deletingProviderName === null) return; + if (deletingProviderName !== null) { + return; + } + pendingDeleteProviderName = null; + hideProviderDeletePopover(); + if (shouldRender) { + renderProviders(); + } else { + document.querySelectorAll('[data-provider-delete-trigger].is-active').forEach(element => { + element.classList.remove('is-active'); + }); + } + } + + async function confirmDeleteProvider(event) { + event?.stopPropagation(); + const name = pendingDeleteProviderName; + if (pendingDeleteProviderName !== name || deletingProviderName === name) return; + + deletingProviderName = name; + syncDeleteProviderPopover(); + renderProviders(); try { const response = await fetch(`/api/providers/${encodeURIComponent(name)}`, { @@ -698,10 +845,16 @@ if (!response.ok) { throw new Error(result.error || '删除 provider 失败'); } + pendingDeleteProviderName = null; + deletingProviderName = null; + hideProviderDeletePopover(); showMessage('Provider 删除成功,配置已重载', 'success'); await loadProviders(); } catch (error) { console.error('删除 provider 失败:', error); + deletingProviderName = null; + syncDeleteProviderPopover(); + renderProviders(); showMessage(error.message || '删除 provider 失败', 'error'); } } diff --git a/src/presentation/templates/users.html b/src/presentation/templates/users.html index 57b019f..8d49f87 100644 --- a/src/presentation/templates/users.html +++ b/src/presentation/templates/users.html @@ -105,6 +105,13 @@ + {% endblock %} {% block page_scripts %} @@ -121,6 +128,8 @@ let currentSearchKeyword = ''; let activeCustomSelect = null; let editModalInstance = null; + let pendingDeleteUserId = null; + let deletingUserId = null; let usersSortState = { key: 'total_tokens', direction: 'desc', @@ -337,17 +346,98 @@ if (!event.target.closest('.custom-select')) { closeAllCustomSelects(); } + if (!event.target.closest('.inline-confirm-wrap') && !event.target.closest('#userDeletePopover')) { + closeDeleteUserConfirm(); + } }); document.addEventListener('keydown', function(event) { if (event.key === 'Escape') { closeAllCustomSelects(); + closeDeleteUserConfirm(); } }); + window.addEventListener('resize', function() { + closeDeleteUserConfirm(false); + }); + + window.addEventListener('scroll', function() { + closeDeleteUserConfirm(false); + }, true); + + function getUserDeletePopoverElement() { + return document.getElementById('userDeletePopover'); + } + + function getUserDeleteTrigger(userId) { + return document.querySelector(`[data-user-delete-trigger="${userId}"]`); + } + + function hideUserDeletePopover() { + const popover = getUserDeletePopoverElement(); + if (!popover) return; + popover.hidden = true; + popover.style.removeProperty('top'); + popover.style.removeProperty('left'); + popover.style.removeProperty('--popover-arrow-left'); + popover.dataset.placement = 'bottom'; + } + + function syncDeleteUserPopover() { + const popover = getUserDeletePopoverElement(); + if (!popover) return; + + if (pendingDeleteUserId === null) { + hideUserDeletePopover(); + return; + } + + const trigger = getUserDeleteTrigger(pendingDeleteUserId); + if (!trigger) { + pendingDeleteUserId = null; + deletingUserId = null; + hideUserDeletePopover(); + return; + } + + popover.hidden = false; + const confirmButton = document.getElementById('confirmDeleteUserBtn'); + if (confirmButton) { + confirmButton.disabled = deletingUserId === pendingDeleteUserId; + confirmButton.textContent = deletingUserId === pendingDeleteUserId ? '删除中...' : '确认'; + } + + const triggerRect = trigger.getBoundingClientRect(); + const popoverRect = popover.getBoundingClientRect(); + const viewportPadding = 12; + const offset = 10; + const canPlaceBottom = triggerRect.bottom + offset + popoverRect.height <= window.innerHeight - viewportPadding; + const shouldPlaceBottom = canPlaceBottom || triggerRect.top < popoverRect.height + viewportPadding + offset; + const top = shouldPlaceBottom + ? Math.min(window.innerHeight - popoverRect.height - viewportPadding, triggerRect.bottom + offset) + : Math.max(viewportPadding, triggerRect.top - popoverRect.height - offset); + const left = Math.min( + window.innerWidth - popoverRect.width - viewportPadding, + Math.max(viewportPadding, triggerRect.left + ((triggerRect.width - popoverRect.width) / 2)) + ); + const arrowLeft = Math.min( + popoverRect.width - 18, + Math.max(18, triggerRect.left + (triggerRect.width / 2) - left) + ); + + popover.dataset.placement = shouldPlaceBottom ? 'bottom' : 'top'; + popover.style.top = `${top}px`; + popover.style.left = `${left}px`; + popover.style.setProperty('--popover-arrow-left', `${arrowLeft}px`); + } + // 加载用户列表 async function loadUsers() { try { + pendingDeleteUserId = null; + deletingUserId = null; + hideUserDeletePopover(); const params = new URLSearchParams({ page: currentPage, page_size: pageSize @@ -503,6 +593,7 @@ users.forEach(user => { const whitelistEnabled = !!user.whitelist_access_enabled; + const isDeletePending = pendingDeleteUserId === user.id; html += ` @@ -523,7 +614,14 @@
- +
+ +
@@ -532,6 +630,7 @@ html += ''; container.innerHTML = html; + syncDeleteUserPopover(); } // 更新分页 @@ -717,8 +816,46 @@ } // 删除用户 - async function deleteUser(userId) { - if (!confirm('确定要删除该用户吗?')) return; + function toggleDeleteUserConfirm(userId, event) { + event?.stopPropagation(); + if (deletingUserId !== null) return; + + pendingDeleteUserId = pendingDeleteUserId === userId ? null : userId; + if (pendingDeleteUserId !== userId) { + deletingUserId = null; + } + renderUsers(); + } + + function cancelDeleteUserConfirm(event) { + event?.stopPropagation(); + closeDeleteUserConfirm(true); + } + + function closeDeleteUserConfirm(shouldRender = true) { + if (pendingDeleteUserId === null && deletingUserId === null) return; + if (deletingUserId !== null) { + return; + } + pendingDeleteUserId = null; + hideUserDeletePopover(); + if (shouldRender) { + renderUsers(); + } else { + document.querySelectorAll('[data-user-delete-trigger].is-active').forEach(element => { + element.classList.remove('is-active'); + }); + } + } + + async function confirmDeleteUser(event) { + event?.stopPropagation(); + const userId = pendingDeleteUserId; + if (pendingDeleteUserId !== userId || deletingUserId === userId) return; + + deletingUserId = userId; + syncDeleteUserPopover(); + renderUsers(); try { const response = await fetch(`/api/users/${userId}`, { @@ -728,13 +865,22 @@ const result = await response.json(); if (response.ok) { + pendingDeleteUserId = null; + deletingUserId = null; + hideUserDeletePopover(); showMessage('用户删除成功', 'success'); - loadUsers(); + await loadUsers(); } else { + deletingUserId = null; + syncDeleteUserPopover(); + renderUsers(); showMessage(result.error || '删除失败', 'error'); } } catch (error) { console.error('删除用户失败:', error); + deletingUserId = null; + syncDeleteUserPopover(); + renderUsers(); showMessage('删除用户失败', 'error'); } }