Add files via upload

This commit is contained in:
公明
2026-07-13 15:43:04 +08:00
committed by GitHub
parent 41f683ce6c
commit 25cf3c567b
9 changed files with 1321 additions and 599 deletions
+111 -117
View File
@@ -2178,19 +2178,20 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
timeDiv.dataset.messageTime = messageTime.toISOString();
} catch (e) { /* ignore */ }
contentWrapper.appendChild(timeDiv);
messageDiv.appendChild(contentWrapper);
// 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建)
if (role === 'assistant' && (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) && !progressId) {
if (options && options.deferMcpButtons) {
try {
messageDiv.dataset.pendingMcpExecutionIds = JSON.stringify(mcpExecutionIds);
const ids = cacheMcpExecutionIds(messageDiv, mcpExecutionIds);
messageDiv.dataset.pendingMcpExecutionIds = JSON.stringify(ids);
} catch (e) { /* ignore */ }
} else {
appendMcpCallButtons(messageDiv, mcpExecutionIds);
setMcpCallExecutionIds(messageDiv, mcpExecutionIds);
}
}
messageDiv.appendChild(contentWrapper);
// 标记「系统就绪」占位消息,便于切换语言后刷新文案
if (options && options.systemReadyMessage) {
messageDiv.setAttribute('data-system-ready-message', '1');
@@ -2935,23 +2936,6 @@ window.addEventListener('beforeunload', () => {
}
});
// 异步获取工具名称并更新按钮文本
async function updateButtonWithToolName(button, executionId, index) {
try {
const response = await apiFetch(`/api/monitor/execution/${executionId}`);
if (response.ok) {
const exec = await response.json();
const toolName = exec.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
// 格式化工具名称(如果是 name::toolName 格式,只显示 toolName 部分)
const displayToolName = toolName.includes('::') ? toolName.split('::')[1] : toolName;
button.querySelector('span').textContent = `${displayToolName} #${index}`;
}
} catch (error) {
// 如果获取失败,保持原有文本不变
console.error('获取工具名称失败:', error);
}
}
function getPendingMcpExecutionCount(messageElement) {
if (!messageElement || !messageElement.dataset || !messageElement.dataset.pendingMcpExecutionIds) {
return 0;
@@ -2983,7 +2967,7 @@ function getMcpExecutionCount(messageElement) {
if (pendingSummaries > 0) return pendingSummaries;
const toolList = messageElement && messageElement.querySelector('.mcp-tool-list');
if (toolList) {
const rendered = toolList.querySelectorAll('.mcp-detail-btn[data-exec-id], .mcp-detail-btn[data-tool-summary]').length;
const rendered = toolList.querySelectorAll('.mcp-detail-btn').length;
if (rendered > 0) return rendered;
}
if (messageElement && messageElement.dataset && messageElement.dataset.mcpExecutionCount) {
@@ -3013,16 +2997,43 @@ function collectMcpExecutionIdsFromProcessDetails(processDetails) {
return ids;
}
function normalizeMcpExecutionIds(executionIds) {
if (!Array.isArray(executionIds)) return [];
const seen = new Set();
return executionIds.reduce((ids, value) => {
const id = value == null ? '' : String(value).trim();
if (id && !seen.has(id)) {
seen.add(id);
ids.push(id);
}
return ids;
}, []);
}
function cacheMcpExecutionIds(messageElement, executionIds) {
if (!messageElement || !messageElement.dataset) return [];
const ids = normalizeMcpExecutionIds(executionIds);
if (ids.length > 0) {
messageElement.dataset.mcpExecutionIds = JSON.stringify(ids);
} else {
delete messageElement.dataset.mcpExecutionIds;
}
return ids;
}
function getCachedMcpExecutionIds(messageElement) {
if (!messageElement || !messageElement.dataset || !messageElement.dataset.mcpExecutionIds) return [];
try {
return normalizeMcpExecutionIds(JSON.parse(messageElement.dataset.mcpExecutionIds));
} catch (e) {
delete messageElement.dataset.mcpExecutionIds;
return [];
}
}
function setPendingMcpExecutionIds(messageElement, executionIds) {
if (!messageElement || !messageElement.dataset || !Array.isArray(executionIds)) return;
const seen = new Set();
const ids = [];
executionIds.forEach((value) => {
const id = value == null ? '' : String(value).trim();
if (!id || seen.has(id)) return;
seen.add(id);
ids.push(id);
});
const ids = cacheMcpExecutionIds(messageElement, executionIds);
if (ids.length > 0) {
messageElement.dataset.pendingMcpExecutionIds = JSON.stringify(ids);
} else {
@@ -3051,6 +3062,8 @@ function cacheToolExecutionSummaries(messageElement, summaries) {
.filter((item) => item.toolName || item.executionId || item.toolCallId);
if (normalized.length > 0) {
messageElement.dataset.toolExecutionSummaries = JSON.stringify(normalized);
} else {
delete messageElement.dataset.toolExecutionSummaries;
}
return normalized;
}
@@ -3075,8 +3088,8 @@ function setPendingToolExecutionSummaries(messageElement, summaries) {
delete messageElement.dataset.pendingToolExecutionSummaries;
}
const renderedToolList = messageElement.querySelector('.mcp-tool-list');
if (normalized.length > 0 && renderedToolList && renderedToolList.querySelector('.mcp-detail-btn[data-exec-id], .mcp-detail-btn[data-tool-summary]')) {
appendMcpCallSummaryButtons(messageElement, normalized);
if (normalized.length > 0 && renderedToolList && renderedToolList.querySelector('.mcp-detail-btn')) {
setMcpCallSummaries(messageElement, normalized);
delete messageElement.dataset.pendingToolExecutionSummaries;
delete messageElement.dataset.pendingMcpExecutionIds;
}
@@ -3136,17 +3149,10 @@ function ensureMcpCallSectionChrome(messageElement, messageId) {
}
let toolbar = mcpSection.querySelector('.mcp-call-toolbar');
const legacyButtons = mcpSection.querySelector('.mcp-call-buttons');
if (!toolbar) {
toolbar = document.createElement('div');
toolbar.className = 'mcp-call-toolbar';
if (legacyButtons) {
const processBtn = legacyButtons.querySelector('.process-detail-btn');
if (processBtn) toolbar.appendChild(processBtn);
mcpSection.replaceChild(toolbar, legacyButtons);
} else {
mcpSection.appendChild(toolbar);
}
mcpSection.appendChild(toolbar);
}
let toolList = mcpSection.querySelector('.mcp-tool-list');
@@ -3161,11 +3167,6 @@ function ensureMcpCallSectionChrome(messageElement, messageId) {
}
}
if (legacyButtons && legacyButtons.parentNode === mcpSection) {
legacyButtons.querySelectorAll('.mcp-detail-btn[data-exec-id]').forEach((btn) => toolList.appendChild(btn));
legacyButtons.remove();
}
const clientId = messageId || messageElement.id;
if (clientId && !toolbar.querySelector('.process-detail-btn')) {
const processDetailBtn = document.createElement('button');
@@ -3310,7 +3311,7 @@ function toggleMcpToolList(assistantMessageId) {
if (
!getPendingMcpExecutionCount(messageEl) &&
!getPendingToolExecutionSummaryCount(messageEl) &&
!toolList.querySelector('.mcp-detail-btn[data-exec-id], .mcp-detail-btn[data-tool-summary]')
!toolList.querySelector('.mcp-detail-btn')
) {
if (typeof toggleProcessDetails === 'function') {
toggleProcessDetails(null, assistantMessageId);
@@ -3319,7 +3320,7 @@ function toggleMcpToolList(assistantMessageId) {
}
const willExpand = !toolList.classList.contains('expanded');
if (willExpand) {
ensureMcpCallButtons(messageEl);
renderPendingMcpCallButtons(messageEl);
toolList.classList.add('expanded');
} else {
toolList.classList.remove('expanded');
@@ -3336,85 +3337,78 @@ window.setMcpExecutionSummaryCount = setMcpExecutionSummaryCount;
window.setPendingMcpExecutionIds = setPendingMcpExecutionIds;
window.setPendingToolExecutionSummaries = setPendingToolExecutionSummaries;
/** 将 MCP 工具按钮挂到独立工具列表,并批量解析工具名 */
function appendMcpCallButtons(messageElement, executionIds) {
if (!messageElement || !Array.isArray(executionIds) || executionIds.length === 0) {
return;
}
/**
* 声明式渲染工具调用列表
* 过程摘要是展示与定位的唯一模型executionIds 仅在摘要尚未到达时提供占位
* 每次更新整体替换列表避免增量追加产生双重状态
*/
function renderMcpCallButtons(messageElement) {
if (!messageElement) return;
const chrome = ensureMcpCallSectionChrome(messageElement, messageElement.id);
if (!chrome) return;
const toolList = chrome.toolList;
const executionIds = getCachedMcpExecutionIds(messageElement);
const summaries = getCachedToolExecutionSummaries(messageElement);
const items = summaries.length > 0
? summaries
: executionIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId }));
executionIds.forEach((execId, index) => {
if (toolList.querySelector('.mcp-detail-btn[data-exec-id="' + CSS.escape(String(execId)) + '"]')) {
return;
}
const detailBtn = document.createElement('button');
detailBtn.className = 'mcp-detail-btn';
detailBtn.dataset.execId = execId;
detailBtn.dataset.execIndex = String(index + 1);
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>';
detailBtn.onclick = async () => {
const summary = await resolveToolExecutionSummaryForFocus(messageElement, execId, index);
if (summary && (summary.processDetailId || summary.toolCallId)) {
await focusToolExecutionInProcessDetails(messageElement, summary, index);
return;
}
showMCPDetail(execId);
};
toolList.appendChild(detailBtn);
});
batchUpdateButtonToolNames(toolList, executionIds);
syncMcpToolsToggleButton(messageElement);
}
function appendMcpCallSummaryButtons(messageElement, summaries) {
if (!messageElement || !Array.isArray(summaries) || summaries.length === 0) {
return;
}
const chrome = ensureMcpCallSectionChrome(messageElement, messageElement.id);
if (!chrome) return;
const toolList = chrome.toolList;
summaries.forEach((raw, index) => {
const item = normalizeToolExecutionSummaryForButton(raw);
const key = item.executionId || item.toolCallId || `${item.toolName || 'tool'}-${index + 1}`;
const selector = '.mcp-detail-btn[data-tool-summary="' + CSS.escape(String(key)) + '"]';
const existingSummaryBtn = toolList.querySelector(selector);
const existingExecBtn = item.executionId
? toolList.querySelector('.mcp-detail-btn[data-exec-id="' + CSS.escape(String(item.executionId)) + '"]')
: null;
if (existingSummaryBtn) {
return;
}
// 历史会话可能先按 executionId 渲染旧按钮,随后摘要才异步到达。
// 复用并升级该按钮,避免它永久保留“只看执行详情、不定位上下文”的旧处理器。
const btn = existingExecBtn || document.createElement('button');
const renderVersion = String((parseInt(toolList.dataset.renderVersion, 10) || 0) + 1);
toolList.dataset.renderVersion = renderVersion;
const fragment = document.createDocumentFragment();
items.forEach((item, index) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'mcp-detail-btn';
btn.dataset.toolSummary = key;
btn.dataset.execIndex = String(index + 1);
if (item.executionId) {
btn.dataset.execId = item.executionId;
}
if (item.processDetailId || item.toolCallId) {
btn.onclick = async () => {
await focusToolExecutionInProcessDetails(messageElement, item, index);
};
} else if (item.executionId) {
btn.onclick = () => showMCPDetail(item.executionId);
if (item.toolCallId) {
btn.dataset.toolCallId = item.toolCallId;
}
btn.onclick = async () => {
let focusItem = item;
if (!focusItem.processDetailId && !focusItem.toolCallId && focusItem.executionId) {
focusItem = await resolveToolExecutionSummaryForFocus(
messageElement,
focusItem.executionId,
index
) || focusItem;
}
await focusToolExecutionInProcessDetails(messageElement, focusItem, index);
};
if (item.toolName) {
renderToolExecutionButtonContent(btn, item.toolName, String(index + 1), item.status);
} else {
btn.onclick = async () => {
await focusToolExecutionInProcessDetails(messageElement, item, index);
};
}
renderToolExecutionButtonContent(btn, item.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'), String(index + 1), item.status);
if (!existingExecBtn) {
toolList.appendChild(btn);
btn.innerHTML = '<span>' + (typeof window.t === 'function'
? window.t('chat.callNumber', { n: index + 1 })
: '调用 #' + (index + 1)) + '</span>';
}
fragment.appendChild(btn);
});
toolList.replaceChildren(fragment);
if (summaries.length === 0 && executionIds.length > 0) {
batchUpdateButtonToolNames(toolList, executionIds, renderVersion);
}
syncMcpToolsToggleButton(messageElement);
}
/** 历史会话懒加载:用户展开工具列表时再渲染工具按钮 */
function ensureMcpCallButtons(messageElement) {
function setMcpCallExecutionIds(messageElement, executionIds) {
if (!messageElement || !Array.isArray(executionIds)) return;
cacheMcpExecutionIds(messageElement, executionIds);
renderMcpCallButtons(messageElement);
}
function setMcpCallSummaries(messageElement, summaries) {
if (!messageElement || !Array.isArray(summaries)) return;
cacheToolExecutionSummaries(messageElement, summaries);
renderMcpCallButtons(messageElement);
}
/** 懒加载:用户展开工具列表时提交待渲染的数据模型。 */
function renderPendingMcpCallButtons(messageElement) {
if (!messageElement || !messageElement.dataset) {
return;
}
@@ -3428,7 +3422,7 @@ function ensureMcpCallButtons(messageElement) {
summaries = [];
}
if (Array.isArray(summaries) && summaries.length > 0) {
appendMcpCallSummaryButtons(messageElement, summaries);
setMcpCallSummaries(messageElement, summaries);
renderedSummaryExecutions = true;
}
delete messageElement.dataset.pendingToolExecutionSummaries;
@@ -3446,15 +3440,13 @@ function ensureMcpCallButtons(messageElement) {
executionIds = [];
}
if (Array.isArray(executionIds) && executionIds.length > 0) {
appendMcpCallButtons(messageElement, executionIds);
setMcpCallExecutionIds(messageElement, executionIds);
}
delete messageElement.dataset.pendingMcpExecutionIds;
}
}
window.ensureMcpCallButtons = ensureMcpCallButtons;
window.appendMcpCallButtons = appendMcpCallButtons;
window.appendMcpCallSummaryButtons = appendMcpCallSummaryButtons;
window.setMcpCallExecutionIds = setMcpCallExecutionIds;
function normalizeToolExecutionSummary(raw) {
if (typeof raw === 'string') {
@@ -3514,7 +3506,7 @@ function renderToolExecutionButtonContent(btn, displayToolName, index, status) {
}
// 批量获取工具摘要并更新按钮(消除 N 次单独 API 请求,合并为 1 次)
async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
async function batchUpdateButtonToolNames(buttonsContainer, executionIds, renderVersion) {
if (!executionIds || executionIds.length === 0) return;
try {
const response = await apiFetch('/api/monitor/executions/names', {
@@ -3524,6 +3516,8 @@ async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
});
if (!response.ok) return;
const nameMap = await response.json(); // { execId: toolName } 或 { execId: { toolName, status } }
// 等待请求期间如果摘要触发了新一轮渲染,旧响应不得覆盖新状态。
if (renderVersion && buttonsContainer.dataset.renderVersion !== renderVersion) return;
// 更新对应按钮的文本
const buttons = buttonsContainer.querySelectorAll('.mcp-detail-btn[data-exec-id]');
buttons.forEach(btn => {
+2 -2
View File
@@ -1314,8 +1314,8 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
return;
}
if (mcpIds.length > 0 && typeof window.appendMcpCallButtons === 'function') {
window.appendMcpCallButtons(assistantElement, mcpIds);
if (mcpIds.length > 0 && typeof window.setMcpCallExecutionIds === 'function') {
window.setMcpCallExecutionIds(assistantElement, mcpIds);
const toolList = mcpSection.querySelector('.mcp-tool-list');
if (toolList) toolList.classList.remove('expanded');
}
+7 -1
View File
@@ -140,7 +140,13 @@
return;
}
if ((item.type === 'task_completed' || item.type === 'long_running_tasks') && item.conversationId) {
window.location.hash = 'chat?conversation=' + encodeURIComponent(item.conversationId);
// 统一走路由提供的会话导航入口。直接修改 hash 会先触发 hashchange
// 切页,再由路由延迟加载会话,视觉上会多闪一次。
if (typeof window.navigateToConversation === 'function') {
window.navigateToConversation(item.conversationId);
} else {
window.location.hash = 'chat?conversation=' + encodeURIComponent(item.conversationId);
}
return;
}
if (item.type === 'task_failed' && item.executionId) {
+200 -47
View File
@@ -16,8 +16,9 @@ let rbacState = {
resourceSearchTimer: null,
resourceRequestSeq: 0,
resourcePage: 0,
resourcePageSize: 12,
resourcePageSize: 8,
resourceHasMore: false,
resourceTotal: 0,
assignmentSearch: '',
assignmentType: 'all',
assignmentPage: 0,
@@ -26,6 +27,11 @@ let rbacState = {
showEffectivePermissions: false,
auditLogs: [],
auditLoading: false,
auditPage: 0,
auditPageSize: 20,
auditTotal: 0,
auditAction: '',
auditResourceType: '',
pendingRoleUserId: '',
pendingUserRoles: new Set(),
editingRoleIsSystem: false,
@@ -39,6 +45,7 @@ const rbacScopeMeta = {
};
const rbacResourceLabels = {
user: '平台用户',
project: '项目', conversation: '对话', vulnerability: '漏洞', webshell: 'WebShell 连接',
batch_task: '批量任务', c2_listener: 'C2 监听器',
};
@@ -202,12 +209,27 @@ function renderRbacPendingSelection() {
<div class="rbac-pending-item">
<div class="rbac-pending-item-main">
<strong>${rbacEscape(item.meta.label || item.id)}</strong>
<small title="${rbacEscape(item.id)}">${rbacEscape(rbacShortId(item.id))}</small>
${(item.meta.label || item.id) === item.id ? '' : `<small title="${rbacEscape(item.id)}">${rbacEscape(rbacShortId(item.id))}</small>`}
</div>
${item.manual ? '' : `<button type="button" class="btn-link btn-small" onclick="toggleRbacResourceSelection('${rbacEscape(item.id)}', false)">${rbacEscape(rbacT('rbac.removePending', '移除'))}</button>`}
<button type="button" class="rbac-pending-remove" data-resource-id="${rbacEscape(item.id)}" data-manual="${item.manual ? 'true' : 'false'}" onclick="removeRbacPendingItem(this)" title="${rbacEscape(rbacT('rbac.removePending', '移除'))}" aria-label="${rbacEscape(rbacT('rbac.removePending', '移除'))}">×</button>
</div>`).join('');
}
function removeRbacPendingItem(button) {
const resourceId = button?.dataset?.resourceId || '';
if (!resourceId) return;
if (button.dataset.manual === 'true') removeRbacManualPending(resourceId);
else toggleRbacResourceSelection(resourceId, false);
}
function removeRbacManualPending(resourceId) {
const input = document.getElementById('rbac-assignment-id');
if (!input) return;
const ids = input.value.split(/[\s,;]+/).map(id => id.trim()).filter(Boolean);
input.value = ids.filter(id => id !== resourceId).join(', ');
syncRbacAssignmentSubmit();
}
function rbacNotify(message, type = 'info') {
if (typeof notifyApiError === 'function') {
notifyApiError(message, type);
@@ -443,6 +465,29 @@ async function loadPlatformRbac() {
rbacState.selectedUserId = rbacState.users[0] ? rbacState.users[0].id : '';
}
renderRbac();
if (rbacState.mainView === 'users' && rbacState.activeTab === 'assignments') {
rbacState.resourcePage = 0;
await loadRbacResourceOptions();
}
}
async function refreshPlatformRbac(button) {
const originalText = button ? button.textContent : '';
if (button) {
button.disabled = true;
button.textContent = rbacT('common.loading', '刷新中…');
}
try {
await loadPlatformRbac();
initRbacSelects();
} catch (error) {
rbacNotify(`${rbacT('rbac.errors.loadFailed', '刷新失败')}: ${error.message || error}`, 'error');
} finally {
if (button) {
button.disabled = false;
button.textContent = originalText || rbacT('common.refresh', '刷新');
}
}
}
function selectedRbacUser() {
@@ -491,15 +536,15 @@ function renderRbacOverview() {
if (context) {
if (!user) {
context.hidden = true;
context.textContent = '';
context.innerHTML = '';
} else {
const access = resolveUserEffectiveAccess(user);
context.hidden = false;
context.textContent = rbacT('rbac.metricUserContext', '当前成员:{{name}} · {{roles}} 个角色 · {{grants}} 项授权', {
name: rbacUserDisplayName(user),
roles: access.userRoles.length,
grants: access.assignmentCount,
});
context.innerHTML = `
<span class="rbac-context-label">${rbacEscape(rbacT('rbac.currentMemberLabel', '当前成员'))}</span>
<strong class="rbac-context-member" title="${rbacEscape(rbacUserDisplayName(user))}">${rbacEscape(rbacUserDisplayName(user))}</strong>
<span class="rbac-context-stat"><b>${access.userRoles.length}</b>${rbacEscape(rbacT('rbac.metricRolesPill', '个角色'))}</span>
<span class="rbac-context-stat"><b>${access.assignmentCount}</b>${rbacEscape(rbacT('rbac.metricAssignmentsPill', '项授权'))}</span>`;
}
}
}
@@ -551,9 +596,11 @@ function selectRbacUser(userId) {
rbacState.selectedResourceMeta.clear();
rbacState.resourcePage = 0;
rbacState.assignmentPage = 0;
rbacState.auditPage = 0;
rbacState.showEffectivePermissions = false;
renderRbac();
if (rbacState.activeTab === 'assignments') loadRbacResourceOptions();
if (rbacState.activeTab === 'audit') loadRbacUserAuditLogs();
}
function setRbacRoleSearch(value) {
@@ -622,7 +669,7 @@ function renderRbacRoles() {
const status = user.enabled ? rbacT('rbac.statusEnabled', '启用') : rbacT('rbac.statusDisabled', '停用');
const displayName = rbacUserDisplayName(user);
const handle = user.username === displayName ? '' : `@${user.username} · `;
title.innerHTML = `<span class="rbac-detail-name">${rbacEscape(displayName)}</span><span class="rbac-detail-meta">${rbacEscape(handle)}${rbacEscape(status)}</span>`;
title.innerHTML = `<span class="rbac-detail-name" title="${rbacEscape(displayName)}">${rbacEscape(displayName)}</span><span class="rbac-detail-meta" title="${rbacEscape(`${handle}${status}`)}">${rbacEscape(handle)}${rbacEscape(status)}</span>`;
} else {
title.textContent = rbacT('rbac.selectUser', '选择用户');
}
@@ -771,7 +818,10 @@ function switchRbacTab(tab) {
if (assignments) assignments.hidden = tab !== 'assignments';
if (audit) audit.hidden = tab !== 'audit';
if (tab === 'assignments') loadRbacResourceOptions();
if (tab === 'audit') loadRbacUserAuditLogs();
if (tab === 'audit') {
rbacState.auditPage = 0;
loadRbacUserAuditLogs();
}
initRbacSelects();
}
@@ -840,18 +890,16 @@ function renderRbacAssignments() {
const detail = rbacFormatResourceDetail(type, row.resourceDetail || row.resource_detail || '');
return `
<div class="rbac-assignment-row">
<span class="rbac-assignment-resource">
<span class="rbac-assignment-identity">
<span class="rbac-pill is-info">${rbacEscape(rbacResourceLabel(type))}</span>
<span class="rbac-assignment-resource-main">
<strong>${rbacEscape(label)}</strong>
<span class="rbac-assignment-resource-meta">
<code title="${rbacEscape(id)}">${rbacEscape(rbacShortId(id))}</code>
${detail ? `<span>${rbacEscape(detail)}</span>` : ''}
<button type="button" class="btn-link btn-small" onclick="rbacCopyResourceId('${rbacEscape(id)}')">${rbacEscape(rbacT('rbac.copyId', '复制 ID'))}</button>
</span>
</span>
<strong title="${rbacEscape(label)}">${rbacEscape(label)}</strong>
</span>
<span class="rbac-assignment-id" title="${rbacEscape(id)}">${rbacEscape(rbacShortId(id))}</span>
<span class="rbac-assignment-detail">${detail ? rbacEscape(detail) : '—'}</span>
<span class="rbac-assignment-actions">
<button type="button" class="btn-link btn-small" onclick="rbacCopyResourceId('${rbacEscape(id)}')">${rbacEscape(rbacT('rbac.copyId', '复制 ID'))}</button>
<button type="button" class="btn-link btn-small is-danger" onclick="deleteRbacAssignment('${rbacEscape(row.id)}')">${rbacT('rbac.revoke', '撤销')}</button>
</span>
<button type="button" class="btn-secondary btn-small" onclick="deleteRbacAssignment('${rbacEscape(row.id)}')">${rbacT('rbac.revoke', '撤销')}</button>
</div>`;
}).join('');
renderRbacAssignmentPagination(filteredRows.length, totalPages);
@@ -862,6 +910,7 @@ function renderRbacAssignmentPagination(total, totalPages) {
const pagination = document.getElementById('rbac-assignment-pagination');
if (!pagination) return;
const pages = Math.max(1, totalPages || 1);
pagination.hidden = pages <= 1;
rbacState.assignmentPage = Math.min(rbacState.assignmentPage, pages - 1);
const info = pagination.querySelector('[data-rbac-page-info]');
const previous = pagination.querySelector('[data-rbac-page-previous]');
@@ -940,10 +989,12 @@ async function loadRbacResourceOptions() {
if (requestSeq !== rbacState.resourceRequestSeq) return;
rbacState.resourceOptions = result.resources || [];
rbacState.resourceHasMore = !!result.has_more;
rbacState.resourceTotal = Number(result.total || 0);
renderRbacResourcePicker();
} catch (error) {
if (requestSeq !== rbacState.resourceRequestSeq) return;
rbacState.resourceHasMore = false;
rbacState.resourceTotal = 0;
picker.innerHTML = `<div class="rbac-picker-status is-error">${rbacEscape(error.message || rbacT('rbac.errors.loadResourcesFailed', '加载资源失败'))} <button type="button" class="btn-link" onclick="loadRbacResourceOptions()">${rbacT('rbac.retry', '重试')}</button></div>`;
syncRbacResourcePagination(false);
}
@@ -969,14 +1020,12 @@ function renderRbacResourcePicker() {
return `<label class="rbac-resource-option${alreadyAssigned ? ' is-assigned' : ''}">
<input type="checkbox" class="modern-checkbox" value="${rbacEscape(resource.id)}" ${checked ? 'checked' : ''} ${alreadyAssigned ? 'disabled' : ''} onchange="toggleRbacResourceSelection(this.value, this.checked)">
<span class="checkbox-custom"></span>
<span class="rbac-resource-option-main">
<strong title="${rbacEscape(resource.label || resource.id)}">${rbacEscape(resource.label || resource.id)}</strong>
<small>
<code title="${rbacEscape(resource.id)}">${rbacEscape(rbacShortId(resource.id))}</code>
<button type="button" class="btn-link btn-small" onclick="event.preventDefault(); rbacCopyResourceId('${rbacEscape(resource.id)}')">${rbacEscape(rbacT('rbac.copyId', '复制 ID'))}</button>
</small>
<span class="rbac-resource-option-main" title="${rbacEscape(resource.label || resource.id)}">
<strong>${rbacEscape(resource.label || resource.id)}</strong>
</span>
<span class="rbac-resource-option-detail">${alreadyAssigned ? rbacT('rbac.alreadyAssigned', '已授权') : rbacEscape(detail)}</span>
<span class="rbac-resource-option-id" title="${rbacEscape(resource.id)}">${rbacEscape(rbacShortId(resource.id))}</span>
<span class="rbac-resource-option-detail">${alreadyAssigned ? rbacT('rbac.alreadyAssigned', '已授权') : (rbacEscape(detail) || '—')}</span>
<button type="button" class="btn-link btn-small rbac-resource-copy" onclick="event.preventDefault(); event.stopPropagation(); rbacCopyResourceId('${rbacEscape(resource.id)}')">${rbacEscape(rbacT('rbac.copyId', '复制 ID'))}</button>
</label>`;
}).join('');
syncRbacResourcePagination(false);
@@ -988,9 +1037,17 @@ function syncRbacResourcePagination(loading) {
const previous = pagination.querySelector('[data-rbac-page-previous]');
const next = pagination.querySelector('[data-rbac-page-next]');
const info = pagination.querySelector('[data-rbac-page-info]');
pagination.hidden = !loading && rbacState.resourcePage === 0 && !rbacState.resourceHasMore;
if (previous) previous.disabled = loading || rbacState.resourcePage === 0;
if (next) next.disabled = loading || !rbacState.resourceHasMore;
if (info) info.textContent = rbacT('rbac.pagination.resourcePage', '第 {{page}} 页', { page: rbacState.resourcePage + 1 });
if (info) {
const pages = Math.max(1, Math.ceil(rbacState.resourceTotal / rbacState.resourcePageSize));
info.textContent = rbacT('rbac.pagination.pageSummary', '第 {{page}} / {{pages}} 页 · 共 {{total}} 项', {
page: rbacState.resourcePage + 1,
pages,
total: rbacState.resourceTotal,
});
}
}
function changeRbacResourcePage(delta) {
@@ -1030,6 +1087,9 @@ async function loadRbacUserAuditLogs() {
if (!box) return;
if (!user) {
box.innerHTML = `<div class="rbac-empty"><strong>${rbacT('rbac.empty.selectUserFirst', '请先选择用户')}</strong></div>`;
rbacState.auditPage = 0;
rbacState.auditTotal = 0;
renderRbacAuditPagination();
return;
}
rbacState.auditLoading = true;
@@ -1037,27 +1097,96 @@ async function loadRbacUserAuditLogs() {
try {
const params = new URLSearchParams({
category: 'rbac',
q: user.id,
page: '1',
page_size: '20',
related_user_id: user.id,
page: String(rbacState.auditPage + 1),
page_size: String(rbacState.auditPageSize),
});
if (rbacState.auditAction) params.set('action', rbacState.auditAction);
if (rbacState.auditResourceType) params.set('resource_type', rbacState.auditResourceType);
const res = await apiFetch(`/api/audit/logs?${params.toString()}`);
const result = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(result.error || rbacT('rbac.errors.loadAuditFailed', '加载审计记录失败'));
rbacState.auditLogs = (result.logs || []).filter(log => {
const resourceId = log.resourceId || log.resource_id || '';
const detail = log.detail || {};
const detailUserId = detail.user_id || detail.userId || '';
return resourceId === user.id || detailUserId === user.id || String(log.message || '').includes(user.username || '');
});
rbacState.auditLogs = result.logs || [];
rbacState.auditTotal = Number(result.total || 0);
renderRbacAuditLogs();
renderRbacAuditPagination();
} catch (error) {
box.innerHTML = `<div class="rbac-picker-status is-error">${rbacEscape(error.message || rbacT('rbac.errors.loadAuditFailed', '加载审计记录失败'))} <button type="button" class="btn-link" onclick="loadRbacUserAuditLogs()">${rbacT('rbac.retry', '重试')}</button></div>`;
renderRbacAuditPagination();
} finally {
rbacState.auditLoading = false;
}
}
function renderRbacAuditPagination() {
const pagination = document.getElementById('rbac-audit-pagination');
if (!pagination) return;
const pages = Math.max(1, Math.ceil(rbacState.auditTotal / rbacState.auditPageSize));
rbacState.auditPage = Math.min(rbacState.auditPage, pages - 1);
pagination.hidden = false;
const first = pagination.querySelector('[data-rbac-page-first]');
const previous = pagination.querySelector('[data-rbac-page-previous]');
const next = pagination.querySelector('[data-rbac-page-next]');
const last = pagination.querySelector('[data-rbac-page-last]');
const info = pagination.querySelector('[data-rbac-page-info]');
const range = pagination.querySelector('[data-rbac-page-range]');
const atFirst = rbacState.auditPage === 0;
const atLast = rbacState.auditPage >= pages - 1;
if (first) first.disabled = atFirst;
if (previous) previous.disabled = atFirst;
if (next) next.disabled = atLast;
if (last) last.disabled = atLast;
if (info) info.textContent = rbacT('rbac.pagination.pageIndicator', '第 {{page}} / {{pages}} 页', {
page: rbacState.auditPage + 1,
pages,
});
const start = rbacState.auditTotal === 0 ? 0 : rbacState.auditPage * rbacState.auditPageSize + 1;
const end = Math.min(rbacState.auditTotal, (rbacState.auditPage + 1) * rbacState.auditPageSize);
if (range) range.textContent = rbacT('rbac.pagination.recordRange', '显示 {{start}}-{{end}} / 共 {{total}} 条记录', {
start,
end,
total: rbacState.auditTotal,
});
}
function changeRbacAuditPage(delta) {
const pages = Math.max(1, Math.ceil(rbacState.auditTotal / rbacState.auditPageSize));
const nextPage = rbacState.auditPage + delta;
if (nextPage < 0 || nextPage >= pages || rbacState.auditLoading) return;
rbacState.auditPage = nextPage;
loadRbacUserAuditLogs();
}
function setRbacAuditPage(page) {
if (rbacState.auditLoading) return;
const pages = Math.max(1, Math.ceil(rbacState.auditTotal / rbacState.auditPageSize));
const nextPage = page < 0 ? pages - 1 : Math.max(0, Math.min(Number(page) || 0, pages - 1));
if (nextPage === rbacState.auditPage) return;
rbacState.auditPage = nextPage;
loadRbacUserAuditLogs();
}
function refreshRbacUserAuditLogs() {
rbacState.auditPage = 0;
loadRbacUserAuditLogs();
}
function changeRbacAuditPageSize(value) {
const pageSize = Number(value);
if (![20, 50, 100].includes(pageSize)) return;
rbacState.auditPageSize = pageSize;
rbacState.auditPage = 0;
loadRbacUserAuditLogs();
}
function setRbacAuditFilter(filter, value) {
if (filter === 'action') rbacState.auditAction = String(value || '').trim();
else if (filter === 'resourceType') rbacState.auditResourceType = String(value || '').trim();
else return;
rbacState.auditPage = 0;
loadRbacUserAuditLogs();
}
function renderRbacAuditLogs() {
const box = document.getElementById('rbac-audit-list');
if (!box) return;
@@ -1068,14 +1197,32 @@ function renderRbacAuditLogs() {
const formatTime = typeof formatAuditTime === 'function'
? formatAuditTime
: (iso) => rbacText(iso);
box.innerHTML = rbacState.auditLogs.map(log => `
<article class="rbac-audit-row">
<strong class="rbac-audit-action">${rbacEscape(rbacAuditActionLabel(log))}</strong>
<span class="rbac-pill rbac-audit-actor">${rbacEscape(log.actor || rbacT('rbac.unknownActor', '未知操作'))}</span>
${log.resourceType || log.resource_type ? `<span class="rbac-audit-detail">${rbacEscape(rbacResourceLabel(log.resourceType || log.resource_type))}</span>` : ''}
${log.resourceId || log.resource_id ? `<code class="rbac-audit-detail" title="${rbacEscape(log.resourceId || log.resource_id)}">${rbacEscape(rbacShortId(log.resourceId || log.resource_id))}</code>` : ''}
<span class="rbac-audit-time">${rbacEscape(formatTime(log.createdAt || log.created_at))}</span>
</article>`).join('');
const header = `
<div class="rbac-audit-table-head" role="row">
<span role="columnheader">${rbacEscape(rbacT('rbac.auditColumns.action', '操作'))}</span>
<span role="columnheader">${rbacEscape(rbacT('rbac.auditColumns.actor', '操作'))}</span>
<span role="columnheader">${rbacEscape(rbacT('rbac.auditColumns.resourceType', '资源类型'))}</span>
<span role="columnheader">${rbacEscape(rbacT('rbac.auditColumns.resourceId', '资源 ID'))}</span>
<span role="columnheader">${rbacEscape(rbacT('rbac.auditColumns.time', '操作时间'))}</span>
</div>`;
const rows = rbacState.auditLogs.map(log => {
const resourceType = log.resourceType || log.resource_type;
const resourceId = log.resourceId || log.resource_id;
return `
<article class="rbac-audit-row" role="row">
<strong class="rbac-audit-action" role="cell" data-label="${rbacEscape(rbacT('rbac.auditColumns.action', '操作'))}"><span class="rbac-audit-cell-value">${rbacEscape(rbacAuditActionLabel(log))}</span></strong>
<span class="rbac-audit-actor" role="cell" data-label="${rbacEscape(rbacT('rbac.auditColumns.actor', '操作人'))}"><span class="rbac-pill rbac-audit-cell-value">${rbacEscape(log.actor || rbacT('rbac.unknownActor', '未知操作者'))}</span></span>
<span class="rbac-audit-detail" role="cell" data-label="${rbacEscape(rbacT('rbac.auditColumns.resourceType', '资源类型'))}"><span class="rbac-audit-cell-value">${resourceType ? rbacEscape(rbacResourceLabel(resourceType)) : '—'}</span></span>
<span class="rbac-audit-detail rbac-audit-resource-id" role="cell" data-label="${rbacEscape(rbacT('rbac.auditColumns.resourceId', '资源 ID'))}"><span class="rbac-audit-cell-value">${resourceId ? rbacEscape(resourceId) : '—'}</span></span>
<span class="rbac-audit-time" role="cell" data-label="${rbacEscape(rbacT('rbac.auditColumns.time', '操作时间'))}"><span class="rbac-audit-cell-value">${rbacEscape(formatTime(log.createdAt || log.created_at))}</span></span>
</article>`;
}).join('');
box.removeAttribute('role');
box.innerHTML = `
<div class="rbac-audit-table" role="table">
${header}
<div class="rbac-audit-table-body" role="rowgroup">${rows}</div>
</div>`;
}
function openRbacUserModal(userId = '') {
@@ -1327,7 +1474,7 @@ async function createRbacAssignment() {
const res = await apiFetch('/api/rbac/resource-assignments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: user.id, resource_type: resourceType, resource_ids: resourceIds }),
body: JSON.stringify({ user_id: user.id, resource_type: resourceType, resource_ids: resourceIds, auto_detect: true }),
});
const result = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(result.error || rbacT('rbac.errors.batchGrantFailed', '批量授权失败'));
@@ -1379,9 +1526,15 @@ window.openRbacRolesFromUserModal = openRbacRolesFromUserModal;
window.focusRbacRoleAssignment = focusRbacRoleAssignment;
window.toggleRbacEffectivePermissions = toggleRbacEffectivePermissions;
window.loadRbacUserAuditLogs = loadRbacUserAuditLogs;
window.refreshRbacUserAuditLogs = refreshRbacUserAuditLogs;
window.changeRbacAuditPage = changeRbacAuditPage;
window.setRbacAuditPage = setRbacAuditPage;
window.changeRbacAuditPageSize = changeRbacAuditPageSize;
window.setRbacAuditFilter = setRbacAuditFilter;
window.clearRbacResourceSelection = clearRbacResourceSelection;
window.rbacCopyResourceId = rbacCopyResourceId;
window.initPlatformRbacPage = initPlatformRbacPage;
window.refreshPlatformRbac = refreshPlatformRbac;
window.initRbacSelects = initRbacSelects;
window.loadPlatformRbac = loadPlatformRbac;
window.selectRbacUser = selectRbacUser;
-2
View File
@@ -168,10 +168,8 @@ function renderSkillsList() {
? _t('skills.cardFiles', { count: skill.file_count })
: '';
const metaItems = [ver, fc, sc].filter(Boolean);
const initial = (skill.name || sid || 'S').trim().charAt(0).toUpperCase();
return `
<div class="skill-card">
<div class="skill-card-mark" aria-hidden="true">${escapeHtml(initial)}</div>
<div class="skill-card-body">
<div class="skill-card-header">
<div class="skill-card-title-row">