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');
}
}