优化删除用户、provider时的提醒

This commit is contained in:
Von committed 2026-03-13 22:10:37 +08:00
1 parent 4131a8e7f2
commit b635bf5128
4 files changed
+607 -7

No files matched your search

+117
View File
@@ -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));
+184
View File
@@ -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,
+156 -3
View File
@@ -166,6 +166,13 @@
</div>
</div>
</div>
<div class="inline-confirm-popover floating-delete-popover" id="providerDeletePopover" data-placement="bottom" hidden>
<div class="inline-confirm-text">确认删除?</div>
<div class="inline-confirm-actions">
<button type="button" class="btn-action btn-edit inline-confirm-action" onclick="cancelDeleteProviderConfirm(event)">取消</button>
<button type="button" class="btn-action btn-delete inline-confirm-action" id="confirmDeleteProviderBtn" onclick="confirmDeleteProvider(event)">确认</button>
</div>
</div>
{% 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 => `<span class="model-tag">${escapeHtml(model)}</span>`).join('')
: '<span class="muted-text">未配置</span>';
@@ -640,7 +741,14 @@
<td>
<div class="action-buttons">
<button class="btn-action btn-edit" onclick="openEditModal(decodeProviderName('${encodeURIComponent(provider.name)}'))">编辑</button>
<button class="btn-action btn-delete" onclick="deleteProvider(decodeProviderName('${encodeURIComponent(provider.name)}'))">删除</button>
<div class="inline-confirm-wrap">
<button
type="button"
class="btn-action btn-delete ${isDeletePending ? 'is-active' : ''}"
data-provider-delete-trigger="${encodeURIComponent(provider.name)}"
onclick="toggleDeleteProviderConfirm(decodeProviderName('${encodeURIComponent(provider.name)}'), event)"
>删除</button>
</div>
</div>
</td>
</tr>
@@ -649,6 +757,7 @@
html += '</tbody></table>';
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');
}
}
+150 -4
View File
@@ -105,6 +105,13 @@
</div>
</div>
</div>
<div class="inline-confirm-popover floating-delete-popover" id="userDeletePopover" data-placement="bottom" hidden>
<div class="inline-confirm-text">确认删除?</div>
<div class="inline-confirm-actions">
<button type="button" class="btn-action btn-edit inline-confirm-action" onclick="cancelDeleteUserConfirm(event)">取消</button>
<button type="button" class="btn-action btn-delete inline-confirm-action" id="confirmDeleteUserBtn" onclick="confirmDeleteUser(event)">确认</button>
</div>
</div>
{% 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 += `
<tr>
@@ -523,7 +614,14 @@
<td>
<div class="action-buttons">
<button class="btn-action btn-edit" onclick="openEditModal(${user.id})">编辑</button>
<button class="btn-action btn-delete" onclick="deleteUser(${user.id})">删除</button>
<div class="inline-confirm-wrap">
<button
type="button"
class="btn-action btn-delete ${isDeletePending ? 'is-active' : ''}"
data-user-delete-trigger="${user.id}"
onclick="toggleDeleteUserConfirm(${user.id}, event)"
>删除</button>
</div>
</div>
</td>
</tr>
@@ -532,6 +630,7 @@
html += '</tbody></table>';
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');
}
}