mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-16 08:57:30 +02:00
Add files via upload
This commit is contained in:
+111
-117
@@ -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 => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user