mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-19 10:28:09 +02:00
1058 lines
59 KiB
JavaScript
1058 lines
59 KiB
JavaScript
const ASSET_PAGE_SIZE_KEY = 'cyberstrike.asset_page_size';
|
||
function getAssetPageSize() {
|
||
try {
|
||
const value = Number(localStorage.getItem(ASSET_PAGE_SIZE_KEY));
|
||
return [10, 20, 50, 100].includes(value) ? value : 20;
|
||
} catch (error) {
|
||
return 20;
|
||
}
|
||
}
|
||
const assetPageState = { page: 1, pageSize: getAssetPageSize(), total: 0, totalPages: 1, items: [], projects: [], projectsLoaded: false, detailIndex: -1, editIndex: -1, detailAsset: null, editAsset: null, selected: new Map(), scanMode: 'chat', scanAssets: [], editorTags: [], editorDirty: false, editorBusy: false, editorReturnFocus: null, editorInteractionsReady: false, editorParsedTarget: '' };
|
||
let assetOverviewDays = 30;
|
||
|
||
const ASSET_CUSTOM_SELECT_IDS = [
|
||
'asset-status-filter',
|
||
'asset-project-filter',
|
||
'asset-batch-project',
|
||
'asset-edit-project',
|
||
'asset-edit-status',
|
||
'asset-page-size-pagination'
|
||
];
|
||
|
||
function enhanceAssetSelect(select) {
|
||
if (!select || typeof enhanceSettingsSelect !== 'function') return;
|
||
enhanceSettingsSelect(select);
|
||
const wrapper = select.closest('.settings-custom-select');
|
||
if (!wrapper) return;
|
||
wrapper.classList.add('asset-custom-select');
|
||
wrapper.classList.toggle('asset-custom-select--filter', select.id === 'asset-status-filter' || select.id === 'asset-project-filter');
|
||
wrapper.classList.toggle('asset-custom-select--pagination', select.id === 'asset-page-size-pagination');
|
||
}
|
||
|
||
function initAssetCustomSelects(root) {
|
||
const scope = root || document;
|
||
ASSET_CUSTOM_SELECT_IDS.forEach(id => {
|
||
const select = scope.getElementById ? scope.getElementById(id) : scope.querySelector(`#${id}`);
|
||
if (select) enhanceAssetSelect(select);
|
||
});
|
||
}
|
||
|
||
function syncAssetSelect(selectOrId) {
|
||
const select = typeof selectOrId === 'string' ? document.getElementById(selectOrId) : selectOrId;
|
||
if (!select) return;
|
||
enhanceAssetSelect(select);
|
||
if (typeof syncSettingsCustomSelect === 'function') syncSettingsCustomSelect(select);
|
||
}
|
||
|
||
function assetT(key, fallback, options) {
|
||
if (window.i18next && typeof window.i18next.t === 'function') {
|
||
const value = window.i18next.t(key, options || {});
|
||
if (value && value !== key) return value;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
async function loadAssetOverview() {
|
||
try {
|
||
const response = await apiFetch('/api/assets/stats?days=' + assetOverviewDays);
|
||
if (!response.ok) throw new Error(await response.text());
|
||
const stats = await response.json();
|
||
['total', 'ips', 'domains', 'ports', 'recent'].forEach(key => {
|
||
const el = document.getElementById('asset-stat-' + key);
|
||
if (el) el.textContent = Number(stats[key] || 0).toLocaleString();
|
||
});
|
||
renderAssetRecentSummary(Number(stats.recent || 0), Number(stats.total || 0));
|
||
renderAssetTrendCharts(stats.asset_trend || [], stats.risk_trend || []);
|
||
renderAssetCoverage(stats.coverage || {}, Number(stats.total || 0));
|
||
renderAssetProtocolChart(stats.protocols || [], Number(stats.total || 0));
|
||
} catch (error) {
|
||
console.error('加载资产概览失败:', error);
|
||
if (typeof showInlineToast === 'function') showInlineToast(assetT('assets.loadFailed', '加载资产失败') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
function setAssetOverviewPeriod(days) {
|
||
const normalized = [7, 30, 90].includes(Number(days)) ? Number(days) : 30;
|
||
if (normalized === assetOverviewDays) return;
|
||
assetOverviewDays = normalized;
|
||
document.querySelectorAll('.asset-period-switch button').forEach(button => {
|
||
const active = Number(button.dataset.days) === normalized;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||
});
|
||
loadAssetOverview();
|
||
}
|
||
|
||
function renderAssetRecentSummary(recent, total) {
|
||
const percent = total ? Math.min(100, Math.round(recent / total * 100)) : 0;
|
||
const bar = document.getElementById('asset-recent-progress');
|
||
const caption = document.getElementById('asset-recent-rate');
|
||
if (bar) bar.style.width = percent + '%';
|
||
if (caption) caption.textContent = assetT('assets.recentShare', `占当前资产总量的 ${percent}%`, { percent });
|
||
}
|
||
|
||
function renderAssetTrendCharts(assetTrend, riskTrend) {
|
||
renderAssetLineChart('asset-growth-chart', 'asset-growth-summary', assetTrend, [
|
||
{ key: 'added', label: assetT('assets.addedAssets', '新增资产'), color: '#3b82f6', fill: 'rgba(59,130,246,.13)' },
|
||
{ key: 'inactive', label: assetT('assets.inactiveAssets', '停用资产'), color: '#8b5cf6' }
|
||
]);
|
||
renderAssetLineChart('asset-risk-chart', 'asset-risk-summary', riskTrend, [
|
||
{ key: 'discovered', label: assetT('assets.discoveredRisks', '新增漏洞'), color: '#f59e0b', fill: 'rgba(245,158,11,.12)' },
|
||
{ key: 'high_risk', label: assetT('assets.highRisks', '高危及严重'), color: '#ef4444' }
|
||
]);
|
||
}
|
||
|
||
function renderAssetLineChart(rootId, summaryId, points, series) {
|
||
const root = document.getElementById(rootId);
|
||
if (!root) return;
|
||
const data = Array.isArray(points) ? points : [];
|
||
const totals = series.map(item => data.reduce((sum, point) => sum + Number(point[item.key] || 0), 0));
|
||
const summaryRoot = document.getElementById(summaryId);
|
||
const summaryContent = series.map((item, index) => `<div><span>${escapeHtml(item.label)}</span><strong style="color:${item.color}">${totals[index].toLocaleString()}</strong></div>`).join('');
|
||
if (summaryRoot) summaryRoot.innerHTML = summaryContent;
|
||
if (!data.length) {
|
||
root.innerHTML = '<div class="muted">' + escapeHtml(assetT('common.noData', '暂无数据')) + '</div>';
|
||
return;
|
||
}
|
||
const width = 680, height = 220, left = 34, right = 12, top = 18, bottom = 28;
|
||
const plotWidth = width - left - right, plotHeight = height - top - bottom;
|
||
const maxValue = Math.max(1, ...data.flatMap(point => series.map(item => Number(point[item.key] || 0))));
|
||
const x = index => left + (data.length === 1 ? plotWidth / 2 : index / (data.length - 1) * plotWidth);
|
||
const y = value => top + plotHeight - (Number(value || 0) / maxValue * plotHeight);
|
||
const grid = [0, .25, .5, .75, 1].map(ratio => {
|
||
const gy = top + plotHeight * ratio;
|
||
const label = Math.round(maxValue * (1 - ratio));
|
||
return `<line x1="${left}" y1="${gy}" x2="${width - right}" y2="${gy}"/><text x="${left - 8}" y="${gy + 4}" text-anchor="end">${label}</text>`;
|
||
}).join('');
|
||
const paths = series.map(item => {
|
||
const coords = data.map((point, index) => `${x(index).toFixed(1)},${y(point[item.key]).toFixed(1)}`);
|
||
const line = `<polyline points="${coords.join(' ')}" fill="none" stroke="${item.color}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`;
|
||
if (!item.fill || !coords.length) return line;
|
||
const area = `M ${x(0).toFixed(1)} ${top + plotHeight} L ${coords.join(' L ')} L ${x(data.length - 1).toFixed(1)} ${top + plotHeight} Z`;
|
||
return `<path d="${area}" fill="${item.fill}"/>${line}`;
|
||
}).join('');
|
||
const labelIndexes = [...new Set([0, Math.floor((data.length - 1) / 2), data.length - 1])];
|
||
const labels = labelIndexes.map(index => `<text x="${x(index)}" y="${height - 7}" text-anchor="${index === 0 ? 'start' : index === data.length - 1 ? 'end' : 'middle'}">${escapeHtml(String(data[index].date || '').slice(5))}</text>`).join('');
|
||
const aria = series.map((item, index) => `${item.label} ${totals[index]}`).join(',');
|
||
root.innerHTML = `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(aria)}" preserveAspectRatio="none"><g class="asset-chart-grid">${grid}${labels}</g>${paths}</svg>`;
|
||
}
|
||
|
||
function renderAssetCoverage(coverage, total) {
|
||
const rate = Math.max(0, Math.min(100, Number(coverage.rate || 0)));
|
||
const recentRate = Math.max(0, Math.min(100, Number(coverage.recent_rate || 0)));
|
||
const values = {
|
||
scanned: Number(coverage.scanned || 0),
|
||
recent: Number(coverage.scanned_30d || 0),
|
||
never: Number(coverage.never_scanned || 0),
|
||
stale: Number(coverage.stale || 0)
|
||
};
|
||
const gauge = document.getElementById('asset-coverage-gauge');
|
||
if (gauge) gauge.style.setProperty('--asset-coverage-value', rate + '%');
|
||
const setText = (id, value) => { const el = document.getElementById(id); if (el) el.textContent = value; };
|
||
setText('asset-coverage-rate', rate + '%');
|
||
setText('asset-coverage-scanned', values.scanned.toLocaleString());
|
||
setText('asset-coverage-recent', values.recent.toLocaleString());
|
||
setText('asset-coverage-recent-rate', assetT('assets.coverageOfTotal', `占全部资产 ${recentRate}%`, { percent: recentRate }));
|
||
setText('asset-coverage-never', values.never.toLocaleString());
|
||
setText('asset-coverage-stale', values.stale.toLocaleString());
|
||
setText('asset-coverage-status', total ? assetT('assets.coverageMeta', `${values.scanned} / ${total} 已覆盖`, { scanned: values.scanned, total }) : '—');
|
||
}
|
||
|
||
function renderAssetProtocolChart(items, total) {
|
||
const root = document.getElementById('asset-protocol-chart');
|
||
const summary = document.getElementById('asset-protocol-summary');
|
||
const meta = document.getElementById('asset-protocol-meta');
|
||
const topList = document.getElementById('asset-protocol-top-list');
|
||
if (!root) return;
|
||
if (!items.length) {
|
||
root.innerHTML = '<div class="muted">' + escapeHtml(assetT('common.noData', '暂无数据')) + '</div>';
|
||
if (summary) summary.hidden = true;
|
||
if (topList) topList.innerHTML = '';
|
||
if (meta) meta.textContent = assetT('assets.protocolKinds', '0 种协议', { count: 0 });
|
||
return;
|
||
}
|
||
if (summary) summary.hidden = false;
|
||
if (meta) meta.textContent = assetT('assets.protocolKinds', `${items.length} 种协议`, { count: items.length });
|
||
const max = Math.max(...items.map(item => Number(item.count || 0)), 1);
|
||
const protocolTotal = items.reduce((sum, item) => sum + Number(item.count || 0), 0);
|
||
const leading = items.reduce((best, item) => Number(item.count || 0) > Number(best.count || 0) ? item : best, items[0]);
|
||
const leadingCount = Number(leading.count || 0);
|
||
const leadingPercent = (total || protocolTotal) ? Math.round(leadingCount / (total || protocolTotal) * 100) : 0;
|
||
const colors = ['#4f7df3', '#6d5df6', '#13b8a6', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6', '#94a3b8'];
|
||
if (summary) {
|
||
const donut = summary.querySelector('.asset-protocol-donut');
|
||
const percentNode = donut?.querySelector('strong');
|
||
const leadName = summary.querySelector('.asset-protocol-lead strong');
|
||
const leadCount = summary.querySelector('.asset-protocol-lead small');
|
||
if (donut) {
|
||
const denominator = Math.max(total || protocolTotal, 1);
|
||
let cursor = 0;
|
||
const stops = items.map((item, index) => {
|
||
const start = cursor;
|
||
cursor = Math.min(100, cursor + Number(item.count || 0) / denominator * 100);
|
||
return `${colors[index % colors.length]} ${start.toFixed(2)}% ${cursor.toFixed(2)}%`;
|
||
});
|
||
if (cursor < 100) stops.push(`var(--border-color,#e5e7eb) ${cursor.toFixed(2)}% 100%`);
|
||
donut.style.background = `conic-gradient(from -90deg,${stops.join(',')})`;
|
||
}
|
||
if (percentNode) percentNode.textContent = leadingPercent + '%';
|
||
if (leadName) leadName.textContent = leading.name || 'unknown';
|
||
if (leadCount) leadCount.textContent = assetT('assets.assetCountUnit', `${leadingCount} 个资产`, { count: leadingCount });
|
||
}
|
||
if (topList) {
|
||
topList.innerHTML = items.slice(0, 3).map((item, index) => {
|
||
const count = Number(item.count || 0);
|
||
const percent = total ? count / total * 100 : 0;
|
||
const displayPercent = percent > 0 && percent < 1 ? '<1%' : Math.round(percent) + '%';
|
||
return `<div><i style="background:${colors[index]}"></i><span>${escapeHtml(item.name || 'unknown')}</span><strong>${escapeHtml(displayPercent)}</strong></div>`;
|
||
}).join('');
|
||
}
|
||
root.innerHTML = items.map((item, index) => {
|
||
const count = Number(item.count || 0);
|
||
const width = Math.max(3, Math.round(count / max * 100));
|
||
const percent = total ? count / total * 100 : 0;
|
||
const displayPercent = percent > 0 && percent < 1 ? '<1%' : Math.round(percent) + '%';
|
||
const color = colors[index % colors.length];
|
||
return `<div class="asset-bar-row" style="--protocol-color:${color}"><span class="asset-bar-rank">${String(index + 1).padStart(2, '0')}</span><span class="asset-bar-label">${escapeHtml(item.name || 'unknown')}</span><div class="asset-bar-track"><i style="width:${width}%"></i></div><strong>${count}</strong><small>${displayPercent}</small></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function loadAssets(page) {
|
||
// 无显式页码表示进入资产库或点击顶部“刷新”,此时同步项目筛选项。
|
||
// 翻页、搜索等带页码的操作继续复用缓存,避免重复请求项目列表。
|
||
await ensureAssetProjects(page == null);
|
||
assetPageState.page = Number(page || assetPageState.page || 1);
|
||
const params = new URLSearchParams({ page: assetPageState.page, page_size: assetPageState.pageSize });
|
||
const q = document.getElementById('asset-search')?.value.trim() || '';
|
||
const status = document.getElementById('asset-status-filter')?.value || '';
|
||
const projectId = document.getElementById('asset-project-filter')?.value || '';
|
||
if (q) params.set('q', q);
|
||
if (status) params.set('status', status);
|
||
if (projectId) params.set('project_id', projectId);
|
||
const body = document.getElementById('asset-table-body');
|
||
if (body) body.innerHTML = '<tr><td colspan="9" class="muted">' + escapeHtml(assetT('common.loading', '加载中...')) + '</td></tr>';
|
||
try {
|
||
const response = await apiFetch('/api/assets?' + params.toString());
|
||
if (!response.ok) throw new Error(await response.text());
|
||
const data = await response.json();
|
||
assetPageState.items = data.assets || [];
|
||
assetPageState.total = data.total || 0;
|
||
assetPageState.totalPages = data.total_pages || 1;
|
||
assetPageState.page = data.page || 1;
|
||
if (assetPageState.page > assetPageState.totalPages) {
|
||
return loadAssets(assetPageState.totalPages);
|
||
}
|
||
renderAssetRows();
|
||
updateAssetSelectionUI();
|
||
renderAssetPagination();
|
||
const meta = document.getElementById('asset-list-meta');
|
||
if (meta) meta.textContent = assetT('assets.totalMeta', `共 ${data.total || 0} 条`, { count: data.total || 0 });
|
||
} catch (error) {
|
||
console.error('加载资产失败:', error);
|
||
assetPageState.items = [];
|
||
assetPageState.total = 0;
|
||
assetPageState.totalPages = 1;
|
||
if (body) body.innerHTML = '<tr><td colspan="9" class="muted">' + escapeHtml(assetT('assets.loadFailed', '加载资产失败')) + '</td></tr>';
|
||
renderAssetPagination();
|
||
}
|
||
}
|
||
|
||
function assetTargetLabel(asset) {
|
||
return asset.host || asset.domain || asset.ip || '-';
|
||
}
|
||
|
||
function assetRiskPresentation(level) {
|
||
const normalized = ['critical', 'high', 'medium', 'low', 'info', 'normal'].includes(level) ? level : 'unassessed';
|
||
const labels = {
|
||
critical: assetT('assets.riskCritical', '严重'),
|
||
high: assetT('assets.riskHigh', '高危'),
|
||
medium: assetT('assets.riskMedium', '中危'),
|
||
low: assetT('assets.riskLow', '低危'),
|
||
info: assetT('assets.riskInfo', '提示'),
|
||
normal: assetT('assets.riskNormal', '正常'),
|
||
unassessed: assetT('assets.riskUnassessed', '未评估')
|
||
};
|
||
return { level: normalized, label: labels[normalized] };
|
||
}
|
||
|
||
function renderAssetRows() {
|
||
const body = document.getElementById('asset-table-body');
|
||
if (!body) return;
|
||
if (!assetPageState.items.length) {
|
||
body.innerHTML = '<tr><td colspan="9" class="muted">' + escapeHtml(assetT('common.noData', '暂无数据')) + '</td></tr>';
|
||
return;
|
||
}
|
||
body.innerHTML = assetPageState.items.map((asset, index) => {
|
||
const service = [asset.protocol, asset.port ? ':' + asset.port : ''].join('') || '-';
|
||
const targetHint = [asset.host, asset.ip, asset.domain].filter(Boolean).filter((value, i, values) => values.indexOf(value) === i).join(' · ');
|
||
const lastScan = asset.last_scan_at ? new Date(asset.last_scan_at).toLocaleString() : '-';
|
||
const vulnerabilityCount = Number(asset.vulnerability_count || 0);
|
||
const risk = assetRiskPresentation(asset.risk_level);
|
||
const statusLabel = asset.status === 'inactive' ? assetT('assets.statusInactive', '停用') : assetT('assets.statusActive', '活跃');
|
||
return `<tr>
|
||
<td class="asset-check-cell"><input type="checkbox" ${assetPageState.selected.has(asset.id) ? 'checked' : ''} onchange="toggleAssetSelection(${index},this.checked)" aria-label="${escapeHtml(assetT('assets.selectAsset', '选择资产'))}"></td>
|
||
<td><button class="asset-target-link" title="${escapeHtml(targetHint)}" onclick="openAssetDetail(${index})">${escapeHtml(assetTargetLabel(asset))}</button></td>
|
||
<td><span class="asset-service" title="${escapeHtml(service)}">${escapeHtml(service)}</span></td>
|
||
<td>${asset.project_name ? `<span class="asset-project-badge">${escapeHtml(asset.project_name)}</span>` : '<span class="muted">-</span>'}</td>
|
||
<td>${escapeHtml(lastScan)}</td><td>${vulnerabilityCount > 0 ? `<button class="asset-vulnerability-link" onclick="openAssetVulnerabilities(${index})">${vulnerabilityCount}</button>` : '<span class="muted">0</span>'}</td>
|
||
<td><span class="asset-risk asset-risk--${risk.level}">${escapeHtml(risk.label)}</span></td>
|
||
<td><span class="asset-status asset-status--${escapeHtml(asset.status || 'active')}">${escapeHtml(statusLabel)}</span></td>
|
||
<td class="asset-row-actions"><button class="btn-link" onclick="openAssetScanModal('chat',${index})">${escapeHtml(assetT('assets.sendToChatShort', '扫描'))}</button><button class="btn-link" data-require-permission="asset:write" onclick="openAssetEditor(${index})">${escapeHtml(assetT('common.edit', '编辑'))}</button><button class="btn-link asset-delete" data-require-permission="asset:delete" onclick="deleteAsset(${index})">${escapeHtml(assetT('common.delete', '删除'))}</button></td>
|
||
</tr>`;
|
||
}).join('');
|
||
if (typeof applyRBACToUI === 'function') applyRBACToUI(body);
|
||
}
|
||
|
||
function toggleAssetSelection(index, checked) {
|
||
const asset = assetPageState.items[Number(index)];
|
||
if (!asset) return;
|
||
if (checked) assetPageState.selected.set(asset.id, asset);
|
||
else assetPageState.selected.delete(asset.id);
|
||
updateAssetSelectionUI();
|
||
}
|
||
|
||
function toggleAssetPageSelection(checked) {
|
||
assetPageState.items.forEach(asset => {
|
||
if (checked) assetPageState.selected.set(asset.id, asset);
|
||
else assetPageState.selected.delete(asset.id);
|
||
});
|
||
renderAssetRows();
|
||
updateAssetSelectionUI();
|
||
}
|
||
|
||
function clearAssetSelection() {
|
||
assetPageState.selected.clear();
|
||
renderAssetRows();
|
||
updateAssetSelectionUI();
|
||
}
|
||
|
||
function updateAssetSelectionUI() {
|
||
const count = assetPageState.selected.size;
|
||
const actions = document.getElementById('asset-batch-actions');
|
||
const label = document.getElementById('asset-selected-count');
|
||
if (actions) actions.hidden = count === 0;
|
||
if (label) label.textContent = assetT('assets.selectedCount', `已选择 ${count} 项`, { count });
|
||
const pageToggle = document.getElementById('asset-select-page');
|
||
if (pageToggle) {
|
||
const selectedOnPage = assetPageState.items.filter(asset => assetPageState.selected.has(asset.id)).length;
|
||
pageToggle.checked = assetPageState.items.length > 0 && selectedOnPage === assetPageState.items.length;
|
||
pageToggle.indeterminate = selectedOnPage > 0 && selectedOnPage < assetPageState.items.length;
|
||
}
|
||
}
|
||
|
||
async function openAssetProjectModal() {
|
||
const assets = Array.from(assetPageState.selected.values());
|
||
if (!assets.length) {
|
||
alert(assetT('assets.selectAssetsFirst', '请先选择资产'));
|
||
return;
|
||
}
|
||
await ensureAssetProjects(true);
|
||
populateAssetProjectSelects();
|
||
const select = document.getElementById('asset-batch-project');
|
||
const projectIds = Array.from(new Set(assets.map(asset => asset.project_id || '')));
|
||
if (select) {
|
||
select.value = projectIds.length === 1 && projectIds[0] ? projectIds[0] : '';
|
||
if (typeof syncSettingsCustomSelect === 'function') syncSettingsCustomSelect(select);
|
||
}
|
||
const subtitle = document.getElementById('asset-project-subtitle');
|
||
if (subtitle) subtitle.textContent = assetT('assets.bindProjectCount', `将更新 ${assets.length} 个资产`, { count: assets.length });
|
||
if (typeof openAppModal === 'function') openAppModal('asset-project-modal');
|
||
else document.getElementById('asset-project-modal').style.display = 'flex';
|
||
if (select) select.focus();
|
||
}
|
||
|
||
function closeAssetProjectModal() {
|
||
if (typeof closeAppModal === 'function') closeAppModal('asset-project-modal');
|
||
else document.getElementById('asset-project-modal').style.display = 'none';
|
||
}
|
||
|
||
async function submitAssetProjectBinding() {
|
||
const ids = Array.from(assetPageState.selected.keys());
|
||
if (!ids.length) return;
|
||
const selectedProject = document.getElementById('asset-batch-project')?.value || '';
|
||
if (!selectedProject) {
|
||
alert(assetT('assets.selectProjectRequired', '请选择要绑定的项目'));
|
||
return;
|
||
}
|
||
const projectId = selectedProject;
|
||
const button = document.getElementById('asset-project-submit');
|
||
if (button) button.disabled = true;
|
||
try {
|
||
const response = await apiFetch('/api/assets/project-binding', {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ asset_ids: ids, project_id: projectId })
|
||
});
|
||
if (!response.ok) throw new Error(await assetEditorResponseError(response));
|
||
closeAssetProjectModal();
|
||
clearAssetSelection();
|
||
await loadAssets(assetPageState.page);
|
||
if (typeof showInlineToast === 'function') {
|
||
showInlineToast(assetT('assets.bindProjectDone', `已绑定 ${ids.length} 个资产`, { count: ids.length }));
|
||
}
|
||
} catch (error) {
|
||
alert(assetT('assets.bindProjectFailed', '绑定项目失败') + ': ' + error.message);
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function assetScanPromptDefault() {
|
||
const placeholders = { asset_id: '{{asset_id}}', target: '{{target}}', host: '{{host}}', ip: '{{ip}}', domain: '{{domain}}', port: '{{port}}' };
|
||
return assetT('assets.defaultScanPrompt', '请对资产 {{target}}(资产ID:{{asset_id}})进行授权安全扫描,优先检查暴露服务、已知漏洞、弱口令和常见 Web 风险;通过 record_vulnerability 保存确认的漏洞,完成后调用 complete_asset_scan(id={{asset_id}}) 回写上次扫描时间和相关漏洞。', placeholders);
|
||
}
|
||
|
||
function openAssetScanModal(mode, index) {
|
||
const one = Number.isInteger(index) ? assetPageState.items[index] : null;
|
||
const assets = one ? [one] : Array.from(assetPageState.selected.values());
|
||
if (!assets.length) {
|
||
alert(assetT('assets.selectAssetsFirst', '请先选择资产'));
|
||
return;
|
||
}
|
||
assetPageState.scanMode = mode === 'task' ? 'task' : 'chat';
|
||
assetPageState.scanAssets = assets;
|
||
const taskMode = assetPageState.scanMode === 'task';
|
||
document.getElementById('asset-scan-title').textContent = taskMode ? assetT('assets.createScanTask', '创建扫描任务') : assetT('assets.sendToChat', '发送到对话');
|
||
document.getElementById('asset-scan-subtitle').textContent = assetT('assets.scanAssetCount', `${assets.length} 个资产`, { count: assets.length });
|
||
document.getElementById('asset-scan-targets').innerHTML = assets.slice(0, 12).map(asset => `<span class="asset-scan-target-chip">${escapeHtml(assetTargetLabel(asset))}</span>`).join('') + (assets.length > 12 ? `<span class="muted">+${assets.length - 12}</span>` : '');
|
||
document.getElementById('asset-scan-prompt').value = assetScanPromptDefault();
|
||
document.getElementById('asset-scan-hint').textContent = assetT('assets.promptHint', '可使用 {{asset_id}}、{{target}}、{{host}}、{{ip}}、{{domain}}、{{port}} 占位符;创建任务时会为每个资产生成一条任务。', { asset_id: '{{asset_id}}', target: '{{target}}', host: '{{host}}', ip: '{{ip}}', domain: '{{domain}}', port: '{{port}}' });
|
||
const executeWrap = document.getElementById('asset-scan-execute-wrap');
|
||
executeWrap.hidden = !taskMode;
|
||
document.getElementById('asset-scan-submit').textContent = taskMode ? assetT('assets.confirmCreate', '创建任务') : assetT('assets.confirmSend', '确认发送');
|
||
if (typeof openAppModal === 'function') openAppModal('asset-scan-modal');
|
||
else document.getElementById('asset-scan-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeAssetScanModal() {
|
||
if (typeof closeAppModal === 'function') closeAppModal('asset-scan-modal');
|
||
else document.getElementById('asset-scan-modal').style.display = 'none';
|
||
}
|
||
|
||
function renderAssetScanPrompt(template, asset) {
|
||
const values = {
|
||
asset_id: asset.id || '', target: assetTargetLabel(asset), host: asset.host || '', ip: asset.ip || '', domain: asset.domain || '', port: asset.port || ''
|
||
};
|
||
return Object.keys(values).reduce((text, key) => text.replaceAll(`{{${key}}}`, String(values[key])), template);
|
||
}
|
||
|
||
function commonAssetProjectId(assets) {
|
||
const ids = Array.from(new Set(assets.map(asset => asset.project_id || '')));
|
||
return ids.length === 1 ? ids[0] : '';
|
||
}
|
||
|
||
async function recordAssetScanLinks(scans) {
|
||
const response = await apiFetch('/api/assets/scan-links', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scans }) });
|
||
if (!response.ok) throw new Error(await response.text());
|
||
}
|
||
|
||
async function submitAssetScan() {
|
||
const assets = assetPageState.scanAssets.slice();
|
||
const template = document.getElementById('asset-scan-prompt').value.trim();
|
||
if (!assets.length || !template) {
|
||
alert(assetT('assets.promptRequired', '请输入用户提示词'));
|
||
return;
|
||
}
|
||
const button = document.getElementById('asset-scan-submit');
|
||
button.disabled = true;
|
||
try {
|
||
if (assetPageState.scanMode === 'task') {
|
||
await createAssetScanTasks(assets, template);
|
||
} else {
|
||
await sendAssetsToChat(assets, template);
|
||
}
|
||
closeAssetScanModal();
|
||
clearAssetSelection();
|
||
await loadAssets(assetPageState.page);
|
||
} catch (error) {
|
||
console.error('提交资产扫描失败:', error);
|
||
alert(assetT('assets.scanSubmitFailed', '提交扫描失败') + ': ' + error.message);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function sendAssetsToChat(assets, template) {
|
||
const targets = assets.map(assetTargetLabel).join(', ');
|
||
const message = assets.map(asset => renderAssetScanPrompt(template, asset)).join('\n\n---\n\n');
|
||
const response = await apiFetch('/api/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: assetT('assets.scanConversationTitle', `资产扫描:${targets}`, { targets }), projectId: commonAssetProjectId(assets) }) });
|
||
if (!response.ok) throw new Error(await response.text());
|
||
const conversation = await response.json();
|
||
await recordAssetScanLinks(assets.map(asset => ({ asset_id: asset.id, conversation_id: conversation.id })));
|
||
switchPage('chat');
|
||
await loadConversation(conversation.id);
|
||
const input = document.getElementById('chat-input');
|
||
input.value = message;
|
||
if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input);
|
||
// 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。
|
||
void sendMessage();
|
||
}
|
||
|
||
async function createAssetScanTasks(assets, template) {
|
||
const tasks = assets.map(asset => renderAssetScanPrompt(template, asset));
|
||
const executeNow = !!document.getElementById('asset-scan-execute-now').checked;
|
||
const response = await apiFetch('/api/batch-tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: assetT('assets.scanQueueTitle', '资产批量扫描'), tasks, executeNow, projectId: commonAssetProjectId(assets), concurrency: 1, agentMode: 'eino_single', scheduleMode: 'manual' }) });
|
||
if (!response.ok) throw new Error(await response.text());
|
||
const result = await response.json();
|
||
const queueTasks = result.queue && Array.isArray(result.queue.tasks) ? result.queue.tasks : [];
|
||
if (queueTasks.length !== assets.length) throw new Error(assetT('assets.scanTaskLinkFailed', '任务已创建,但资产关联失败'));
|
||
await recordAssetScanLinks(assets.map((asset, index) => ({ asset_id: asset.id, queue_id: result.queueId, task_id: queueTasks[index].id })));
|
||
switchPage('tasks');
|
||
if (typeof showBatchQueueDetail === 'function') showBatchQueueDetail(result.queueId);
|
||
}
|
||
|
||
function openAssetVulnerabilities(index) {
|
||
const asset = assetPageState.items[Number(index)];
|
||
if (!asset) return;
|
||
if (asset.last_scan_task_id) {
|
||
window.location.hash = `vulnerabilities?task_id=${encodeURIComponent(asset.last_scan_task_id)}`;
|
||
return;
|
||
}
|
||
window.location.hash = asset.last_scan_conversation_id
|
||
? `vulnerabilities?conversation_id=${encodeURIComponent(asset.last_scan_conversation_id)}`
|
||
: 'vulnerabilities';
|
||
}
|
||
|
||
function renderAssetPagination() {
|
||
const root = document.getElementById('asset-pagination');
|
||
if (!root) return;
|
||
const page = assetPageState.page;
|
||
const totalPages = assetPageState.totalPages || 1;
|
||
const total = assetPageState.total || 0;
|
||
const pageSize = assetPageState.pageSize;
|
||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||
const end = total === 0 ? 0 : Math.min(page * pageSize, total);
|
||
const atFirst = page <= 1 || total === 0;
|
||
const atLast = page >= totalPages || total === 0;
|
||
root.innerHTML = `<div class="pagination">
|
||
<div class="pagination-info">
|
||
<span>${escapeHtml(assetT('skillsPage.paginationShow', `显示 ${start}-${end} / 共 ${total} 条`, { start, end, total }))}</span>
|
||
<label class="pagination-page-size">${escapeHtml(assetT('skillsPage.perPageLabel', '每页显示'))}
|
||
<select id="asset-page-size-pagination" onchange="changeAssetPageSize()">
|
||
${[10, 20, 50, 100].map(size => `<option value="${size}" ${size === pageSize ? 'selected' : ''}>${size}</option>`).join('')}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="pagination-controls">
|
||
<button class="btn-secondary" onclick="loadAssets(1)" ${atFirst ? 'disabled' : ''}>${escapeHtml(assetT('skillsPage.firstPage', '首页'))}</button>
|
||
<button class="btn-secondary" onclick="loadAssets(${Math.max(1, page - 1)})" ${atFirst ? 'disabled' : ''}>${escapeHtml(assetT('skillsPage.prevPage', '上一页'))}</button>
|
||
<span class="pagination-page">${escapeHtml(assetT('skillsPage.pageOf', `第 ${page} / ${totalPages} 页`, { current: page, total: totalPages }))}</span>
|
||
<button class="btn-secondary" onclick="loadAssets(${Math.min(totalPages, page + 1)})" ${atLast ? 'disabled' : ''}>${escapeHtml(assetT('skillsPage.nextPage', '下一页'))}</button>
|
||
<button class="btn-secondary" onclick="loadAssets(${totalPages})" ${atLast ? 'disabled' : ''}>${escapeHtml(assetT('skillsPage.lastPage', '尾页'))}</button>
|
||
</div>
|
||
</div>`;
|
||
syncAssetSelect('asset-page-size-pagination');
|
||
}
|
||
|
||
function changeAssetPageSize() {
|
||
const select = document.getElementById('asset-page-size-pagination');
|
||
const size = Number(select?.value);
|
||
if (![10, 20, 50, 100].includes(size)) return;
|
||
assetPageState.pageSize = size;
|
||
try { localStorage.setItem(ASSET_PAGE_SIZE_KEY, String(size)); } catch (error) { /* ignore */ }
|
||
loadAssets(1);
|
||
}
|
||
|
||
async function ensureAssetProjects(force) {
|
||
if (assetPageState.projectsLoaded && !force) return;
|
||
try {
|
||
const response = await apiFetch('/api/projects?limit=500');
|
||
if (!response.ok) throw new Error(await response.text());
|
||
const data = await response.json();
|
||
assetPageState.projects = data.projects || [];
|
||
assetPageState.projectsLoaded = true;
|
||
populateAssetProjectSelects();
|
||
} catch (error) {
|
||
console.warn('加载资产项目选项失败:', error);
|
||
assetPageState.projectsLoaded = true;
|
||
}
|
||
}
|
||
|
||
function populateAssetProjectSelects() {
|
||
const configs = [
|
||
['asset-project-filter', assetT('assets.allProjects', '全部项目')],
|
||
['asset-edit-project', assetT('assets.unboundProject', '暂不绑定')]
|
||
];
|
||
configs.forEach(([id, emptyLabel]) => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
const current = el.value;
|
||
el.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` + assetPageState.projects.map(project => `<option value="${escapeHtml(project.id)}">${escapeHtml(project.name)}${project.status === 'archived' ? ' · ' + escapeHtml(assetT('assets.archived', '已归档')) : ''}</option>`).join('');
|
||
el.value = current;
|
||
syncAssetSelect(el);
|
||
});
|
||
const batch = document.getElementById('asset-batch-project');
|
||
if (batch) {
|
||
const current = batch.value;
|
||
batch.innerHTML = `<option value="" disabled hidden>${escapeHtml(assetT('assets.chooseProject', '请选择项目'))}</option>` + assetPageState.projects.map(project => `<option value="${escapeHtml(project.id)}">${escapeHtml(project.name)}${project.status === 'archived' ? ' · ' + escapeHtml(assetT('assets.archived', '已归档')) : ''}</option>`).join('');
|
||
batch.value = current;
|
||
syncAssetSelect(batch);
|
||
}
|
||
}
|
||
|
||
async function openAssetEditor(indexOrAsset) {
|
||
// 项目可能在资产页首次加载后被新增、编辑或归档,打开编辑器时重新拉取,
|
||
// 避免下拉框长期复用 projectsLoaded 缓存而只能通过整页刷新更新。
|
||
await ensureAssetProjects(true);
|
||
const isIndex = Number.isInteger(indexOrAsset);
|
||
const asset = isIndex ? assetPageState.items[indexOrAsset] : (indexOrAsset && typeof indexOrAsset === 'object' ? indexOrAsset : null);
|
||
assetPageState.editIndex = isIndex && asset ? indexOrAsset : -1;
|
||
assetPageState.editAsset = asset;
|
||
assetPageState.editorReturnFocus = document.activeElement;
|
||
document.getElementById('asset-edit-id').value = asset?.id || '';
|
||
document.getElementById('asset-edit-host').value = asset?.host || '';
|
||
document.getElementById('asset-edit-ip').value = asset?.ip || '';
|
||
document.getElementById('asset-edit-domain').value = asset?.domain || '';
|
||
document.getElementById('asset-edit-port').value = asset?.port || '';
|
||
document.getElementById('asset-edit-protocol').value = asset?.protocol || '';
|
||
document.getElementById('asset-edit-server').value = asset?.server || '';
|
||
document.getElementById('asset-edit-project').value = asset?.project_id || '';
|
||
document.getElementById('asset-edit-country').value = asset?.country || '';
|
||
document.getElementById('asset-edit-province').value = asset?.province || '';
|
||
document.getElementById('asset-edit-city').value = asset?.city || '';
|
||
document.getElementById('asset-edit-title-value').value = asset?.title || '';
|
||
document.getElementById('asset-edit-tags').value = '';
|
||
assetPageState.editorTags = Array.from(new Set((asset?.tags || []).map(value => String(value).trim()).filter(Boolean)));
|
||
renderAssetEditorTags();
|
||
document.getElementById('asset-edit-status').value = asset?.status || 'active';
|
||
syncAssetSelect('asset-edit-project');
|
||
syncAssetSelect('asset-edit-status');
|
||
document.getElementById('asset-edit-target').value = assetEditorTargetFromAsset(asset);
|
||
assetPageState.editorParsedTarget = document.getElementById('asset-edit-target').value.trim();
|
||
clearAssetEditorErrors();
|
||
document.getElementById('asset-editor-title').textContent = asset ? assetT('assets.editAssetTitle', '编辑资产') : assetT('assets.addAssetTitle', '新增资产');
|
||
const submit = document.getElementById('asset-editor-submit');
|
||
submit.textContent = asset ? assetT('common.save', '保存') : assetT('assets.addAssetAction', '添加资产');
|
||
ensureAssetEditorInteractions();
|
||
assetPageState.editorDirty = false;
|
||
assetPageState.editorBusy = false;
|
||
setAssetEditorBusy(false);
|
||
if (typeof openAppModal === 'function') openAppModal('asset-editor-modal', { focusEl: document.getElementById('asset-edit-target') });
|
||
else document.getElementById('asset-editor-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeAssetEditor(force) {
|
||
if (!force && assetPageState.editorDirty && !confirm(assetT('assets.discardChanges', '放弃尚未保存的更改吗?'))) return;
|
||
if (typeof closeAppModal === 'function') closeAppModal('asset-editor-modal');
|
||
else document.getElementById('asset-editor-modal').style.display = 'none';
|
||
const returnFocus = assetPageState.editorReturnFocus;
|
||
assetPageState.editorDirty = false;
|
||
if (returnFocus && typeof returnFocus.focus === 'function') requestAnimationFrame(() => returnFocus.focus());
|
||
}
|
||
|
||
function assetEditorTargetFromAsset(asset) {
|
||
if (!asset) return '';
|
||
if (asset.host) return String(asset.host);
|
||
const target = asset.domain || asset.ip || '';
|
||
if (!target) return '';
|
||
const wrapped = String(target).includes(':') && !String(target).startsWith('[') ? `[${target}]` : target;
|
||
return `${wrapped}${Number(asset.port || 0) > 0 ? ':' + Number(asset.port) : ''}`;
|
||
}
|
||
|
||
function assetEditorDefaultPort(protocol) {
|
||
return ({ http: 80, https: 443, ssh: 22, ftp: 21, smtp: 25, rdp: 3389, mysql: 3306, postgresql: 5432, redis: 6379, mongodb: 27017 })[protocol] || 0;
|
||
}
|
||
|
||
function assetEditorProtocolForPort(port) {
|
||
return ({ 80: 'http', 443: 'https', 22: 'ssh', 21: 'ftp', 25: 'smtp', 3389: 'rdp', 3306: 'mysql', 5432: 'postgresql', 6379: 'redis', 27017: 'mongodb' })[port] || '';
|
||
}
|
||
|
||
function assetEditorIsIPv4(value) {
|
||
const parts = String(value).split('.');
|
||
return parts.length === 4 && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
||
}
|
||
|
||
function assetEditorIsIPv6(value) {
|
||
const candidate = String(value).replace(/^\[|\]$/g, '');
|
||
if (!candidate.includes(':') || !/^[0-9a-f:.]+$/i.test(candidate)) return false;
|
||
try { return new URL(`http://[${candidate}]/`).hostname.length > 2; } catch (error) { return false; }
|
||
}
|
||
|
||
function assetEditorNormalizeDomain(value) {
|
||
const raw = String(value || '').trim().replace(/\.$/, '');
|
||
if (!raw || raw.length > 253 || raw.includes('_') || /[\/?#@]/.test(raw)) return '';
|
||
try {
|
||
const hostname = new URL(`http://${raw}/`).hostname.toLowerCase().replace(/\.$/, '');
|
||
if (!hostname || hostname.length > 253 || hostname.split('.').some(label => !label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label))) return '';
|
||
return hostname;
|
||
} catch (error) { return ''; }
|
||
}
|
||
|
||
function parseAssetEditorTarget(value) {
|
||
const raw = String(value || '').trim();
|
||
if (!raw) throw new Error(assetT('assets.targetRequired', '请输入资产地址'));
|
||
const opaqueTarget = () => ({ host: raw, ip: '', domain: '', port: 0, protocol: '' });
|
||
let hostname = '';
|
||
let port = 0;
|
||
let protocol = '';
|
||
let host = '';
|
||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
|
||
let parsed;
|
||
try { parsed = new URL(raw); } catch (error) { return opaqueTarget(); }
|
||
if (!parsed.hostname || parsed.username || parsed.password) return opaqueTarget();
|
||
protocol = parsed.protocol.replace(':', '').toLowerCase();
|
||
hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
||
port = parsed.port ? Number(parsed.port) : assetEditorDefaultPort(protocol);
|
||
host = raw;
|
||
} else {
|
||
let authority = raw.replace(/\/$/, '');
|
||
const bracketed = authority.match(/^\[([^\]]+)](?::(\d+))?$/);
|
||
if (bracketed) {
|
||
hostname = bracketed[1];
|
||
port = Number(bracketed[2] || 0);
|
||
} else if ((authority.match(/:/g) || []).length === 1 && /:\d+$/.test(authority)) {
|
||
const splitAt = authority.lastIndexOf(':');
|
||
hostname = authority.slice(0, splitAt);
|
||
port = Number(authority.slice(splitAt + 1));
|
||
} else {
|
||
hostname = authority;
|
||
}
|
||
protocol = assetEditorProtocolForPort(port);
|
||
}
|
||
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(assetT('assets.portInvalid', '端口必须在 1–65535 之间'));
|
||
const isIP = assetEditorIsIPv4(hostname) || assetEditorIsIPv6(hostname);
|
||
const domain = isIP ? '' : assetEditorNormalizeDomain(hostname);
|
||
if (!isIP && (!domain || assetEditorIsIPv4(domain) || assetEditorIsIPv6(domain))) return opaqueTarget();
|
||
return { host, ip: isIP ? hostname.toLowerCase() : '', domain, port, protocol };
|
||
}
|
||
|
||
function applyAssetEditorTarget(showError) {
|
||
const input = document.getElementById('asset-edit-target');
|
||
try {
|
||
const parsed = parseAssetEditorTarget(input.value);
|
||
document.getElementById('asset-edit-host').value = parsed.host;
|
||
document.getElementById('asset-edit-ip').value = parsed.ip;
|
||
document.getElementById('asset-edit-domain').value = parsed.domain;
|
||
document.getElementById('asset-edit-port').value = parsed.port || '';
|
||
document.getElementById('asset-edit-protocol').value = parsed.protocol;
|
||
assetPageState.editorParsedTarget = input.value.trim();
|
||
setAssetEditorFieldError('asset-edit-target', '');
|
||
return parsed;
|
||
} catch (error) {
|
||
if (showError) setAssetEditorFieldError('asset-edit-target', error.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function setAssetEditorFieldError(inputId, message) {
|
||
const input = document.getElementById(inputId);
|
||
const error = document.getElementById(inputId + '-error');
|
||
if (input) input.setAttribute('aria-invalid', message ? 'true' : 'false');
|
||
if (error) { error.textContent = message || ''; error.hidden = !message; }
|
||
}
|
||
|
||
function setAssetEditorFormError(message) {
|
||
const error = document.getElementById('asset-editor-form-error');
|
||
if (!error) return;
|
||
error.textContent = message || '';
|
||
error.hidden = !message;
|
||
}
|
||
|
||
function clearAssetEditorErrors() {
|
||
['asset-edit-target', 'asset-edit-host', 'asset-edit-ip', 'asset-edit-domain', 'asset-edit-port', 'asset-edit-protocol'].forEach(id => setAssetEditorFieldError(id, ''));
|
||
setAssetEditorFormError('');
|
||
}
|
||
|
||
function addAssetEditorTags(raw) {
|
||
String(raw || '').split(/[,,]/).map(value => value.trim()).filter(Boolean).forEach(tag => {
|
||
if (!assetPageState.editorTags.includes(tag) && assetPageState.editorTags.length < 30) assetPageState.editorTags.push(tag);
|
||
});
|
||
document.getElementById('asset-edit-tags').value = '';
|
||
assetPageState.editorDirty = true;
|
||
renderAssetEditorTags();
|
||
}
|
||
|
||
function removeAssetEditorTag(index) {
|
||
assetPageState.editorTags.splice(Number(index), 1);
|
||
assetPageState.editorDirty = true;
|
||
renderAssetEditorTags();
|
||
}
|
||
|
||
function renderAssetEditorTags() {
|
||
const root = document.getElementById('asset-tag-chips');
|
||
if (!root) return;
|
||
root.replaceChildren(...assetPageState.editorTags.map((tag, index) => {
|
||
const chip = document.createElement('span');
|
||
chip.className = 'asset-tag-chip';
|
||
const text = document.createElement('span');
|
||
text.textContent = tag;
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.textContent = '×';
|
||
button.setAttribute('aria-label', assetT('assets.removeTag', `移除标签 ${tag}`, { tag }));
|
||
button.onclick = () => removeAssetEditorTag(index);
|
||
chip.append(text, button);
|
||
return chip;
|
||
}));
|
||
}
|
||
|
||
function ensureAssetEditorInteractions() {
|
||
if (assetPageState.editorInteractionsReady) return;
|
||
assetPageState.editorInteractionsReady = true;
|
||
const form = document.getElementById('asset-editor-form');
|
||
const target = document.getElementById('asset-edit-target');
|
||
const tagInput = document.getElementById('asset-edit-tags');
|
||
form.addEventListener('input', event => {
|
||
if (event.target !== tagInput) assetPageState.editorDirty = true;
|
||
if (event.target === target) setAssetEditorFieldError('asset-edit-target', '');
|
||
setAssetEditorFormError('');
|
||
});
|
||
target.addEventListener('blur', () => { if (target.value.trim()) applyAssetEditorTarget(true); });
|
||
tagInput.addEventListener('keydown', event => {
|
||
if ((event.key === 'Enter' || event.key === ',') && tagInput.value.trim()) { event.preventDefault(); addAssetEditorTags(tagInput.value); }
|
||
else if (event.key === 'Backspace' && !tagInput.value && assetPageState.editorTags.length) removeAssetEditorTag(assetPageState.editorTags.length - 1);
|
||
});
|
||
tagInput.addEventListener('blur', () => { if (tagInput.value.trim()) addAssetEditorTags(tagInput.value); });
|
||
document.addEventListener('keydown', event => {
|
||
if (typeof isAppModalOpen !== 'function' || !isAppModalOpen('asset-editor-modal')) return;
|
||
if (event.key === 'Escape') { event.preventDefault(); closeAssetEditor(); return; }
|
||
if (event.key !== 'Tab') return;
|
||
const focusable = Array.from(form.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])')).filter(el => !el.hidden && el.offsetParent !== null);
|
||
if (!focusable.length) return;
|
||
const first = focusable[0];
|
||
const last = focusable[focusable.length - 1];
|
||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||
});
|
||
}
|
||
|
||
function collectAssetEditor() {
|
||
const existing = assetPageState.editAsset || {};
|
||
return {
|
||
host: document.getElementById('asset-edit-host').value.trim(), ip: document.getElementById('asset-edit-ip').value.trim(),
|
||
domain: document.getElementById('asset-edit-domain').value.trim(), port: Number(document.getElementById('asset-edit-port').value || 0),
|
||
protocol: document.getElementById('asset-edit-protocol').value.trim(), server: document.getElementById('asset-edit-server').value.trim(),
|
||
title: document.getElementById('asset-edit-title-value').value.trim(), status: document.getElementById('asset-edit-status').value,
|
||
source: existing.source || 'manual', source_query: existing.source_query || '',
|
||
country: document.getElementById('asset-edit-country').value.trim(), province: document.getElementById('asset-edit-province').value.trim(), city: document.getElementById('asset-edit-city').value.trim(),
|
||
project_id: document.getElementById('asset-edit-project').value,
|
||
tags: assetPageState.editorTags.slice()
|
||
};
|
||
}
|
||
|
||
function validateAssetEditor() {
|
||
clearAssetEditorErrors();
|
||
const target = document.getElementById('asset-edit-target').value.trim();
|
||
let parsed = null;
|
||
if (target !== assetPageState.editorParsedTarget) parsed = applyAssetEditorTarget(true);
|
||
else {
|
||
try { parsed = parseAssetEditorTarget(target); }
|
||
catch (error) { setAssetEditorFieldError('asset-edit-target', error.message); }
|
||
}
|
||
if (!parsed) { document.getElementById('asset-edit-target').focus(); return false; }
|
||
const failAdvanced = (id, message) => {
|
||
setAssetEditorFieldError(id, message);
|
||
requestAnimationFrame(() => document.getElementById(id).focus());
|
||
return false;
|
||
};
|
||
const ip = document.getElementById('asset-edit-ip').value.trim();
|
||
if (ip && !assetEditorIsIPv4(ip) && !assetEditorIsIPv6(ip)) return failAdvanced('asset-edit-ip', assetT('assets.ipInvalid', 'IP 地址格式无效'));
|
||
const domainInput = document.getElementById('asset-edit-domain');
|
||
const domain = domainInput.value.trim();
|
||
if (domain) {
|
||
const normalizedDomain = assetEditorNormalizeDomain(domain);
|
||
if (!normalizedDomain) return failAdvanced('asset-edit-domain', assetT('assets.domainInvalid', '域名格式无效'));
|
||
domainInput.value = normalizedDomain;
|
||
}
|
||
const portInput = document.getElementById('asset-edit-port');
|
||
if (portInput.value !== '' && (!/^\d+$/.test(portInput.value) || Number(portInput.value) < 1 || Number(portInput.value) > 65535)) {
|
||
return failAdvanced('asset-edit-port', assetT('assets.portInvalid', '端口必须在 1–65535 之间'));
|
||
}
|
||
const protocol = document.getElementById('asset-edit-protocol').value.trim().toLowerCase();
|
||
if (protocol && !/^[a-z][a-z0-9+.-]{0,31}$/.test(protocol)) return failAdvanced('asset-edit-protocol', assetT('assets.protocolInvalid', '协议格式无效'));
|
||
document.getElementById('asset-edit-protocol').value = protocol;
|
||
return true;
|
||
}
|
||
|
||
function setAssetEditorBusy(busy) {
|
||
assetPageState.editorBusy = Boolean(busy);
|
||
const submit = document.getElementById('asset-editor-submit');
|
||
if (!submit) return;
|
||
submit.disabled = Boolean(busy);
|
||
submit.classList.toggle('asset-editor-submit-busy', Boolean(busy));
|
||
}
|
||
|
||
async function assetEditorResponseError(response) {
|
||
const text = await response.text();
|
||
try { return JSON.parse(text).error || text; } catch (error) { return text; }
|
||
}
|
||
|
||
async function saveAsset() {
|
||
if (assetPageState.editorBusy || !validateAssetEditor()) return;
|
||
const pendingTag = document.getElementById('asset-edit-tags').value.trim();
|
||
if (pendingTag) addAssetEditorTags(pendingTag);
|
||
const id = document.getElementById('asset-edit-id').value;
|
||
const asset = collectAssetEditor();
|
||
setAssetEditorBusy(true);
|
||
try {
|
||
const response = id
|
||
? await apiFetch('/api/assets/' + encodeURIComponent(id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(asset) })
|
||
: await apiFetch('/api/assets/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assets: [asset], source: 'manual' }) });
|
||
if (!response.ok) throw new Error(await assetEditorResponseError(response));
|
||
const result = await response.json();
|
||
if (!id && Number(result.skipped || 0) > 0 && Number(result.created || 0) === 0 && Number(result.updated || 0) === 0) {
|
||
throw new Error(assetT('assets.assetSkipped', '资产未保存,可能已存在且你没有更新权限'));
|
||
}
|
||
closeAssetEditor(true);
|
||
await loadAssets(id ? assetPageState.page : 1);
|
||
const projectAssetsPanel = document.getElementById('project-panel-assets');
|
||
if (projectAssetsPanel && !projectAssetsPanel.hidden && typeof window.loadProjectAssets === 'function') await window.loadProjectAssets();
|
||
const message = id
|
||
? assetT('assets.updatedSuccessfully', '资产已更新')
|
||
: Number(result.updated || 0) > 0
|
||
? assetT('assets.duplicateMerged', '资产已存在,信息已安全合并')
|
||
: assetT('assets.createdSuccessfully', '资产已添加');
|
||
if (typeof showInlineToast === 'function') showInlineToast(message);
|
||
} catch (error) {
|
||
setAssetEditorFormError(assetT('assets.saveFailed', '保存资产失败') + ': ' + error.message);
|
||
} finally {
|
||
setAssetEditorBusy(false);
|
||
}
|
||
}
|
||
|
||
async function deleteAsset(index) {
|
||
const asset = assetPageState.items[index];
|
||
if (!asset || !confirm(assetT('assets.deleteConfirm', '确定删除该资产吗?'))) return;
|
||
const response = await apiFetch('/api/assets/' + encodeURIComponent(asset.id), { method: 'DELETE' });
|
||
if (!response.ok) {
|
||
alert(assetT('assets.deleteFailed', '删除资产失败'));
|
||
return;
|
||
}
|
||
await loadAssets(assetPageState.page);
|
||
}
|
||
|
||
function fofaResultToAsset(row, fields) {
|
||
const value = name => {
|
||
if (row && !Array.isArray(row) && typeof row === 'object') {
|
||
return row[name] != null ? String(row[name]).trim() : '';
|
||
}
|
||
const idx = Array.isArray(fields) ? fields.indexOf(name) : -1;
|
||
return idx >= 0 && Array.isArray(row) && row[idx] != null ? String(row[idx]).trim() : '';
|
||
};
|
||
const port = Number.parseInt(value('port'), 10);
|
||
const rawIP = value('ip');
|
||
const ip = assetEditorIsIPv4(rawIP) || assetEditorIsIPv6(rawIP) ? rawIP.toLowerCase() : '';
|
||
const rawDomain = value('domain');
|
||
const domain = rawDomain && !assetEditorIsIPv4(rawDomain) && !assetEditorIsIPv6(rawDomain)
|
||
? assetEditorNormalizeDomain(rawDomain)
|
||
: '';
|
||
const rawProtocol = value('protocol').toLowerCase();
|
||
return {
|
||
host: value('host'), ip, port: Number.isFinite(port) ? port : 0, domain,
|
||
protocol: /^[a-z][a-z0-9+.-]{0,31}$/.test(rawProtocol) ? rawProtocol : '', title: value('title'), server: value('server'), country: value('country'),
|
||
province: value('province'), city: value('city'), source: 'fofa', status: 'active'
|
||
};
|
||
}
|
||
|
||
async function importFofaAssetsByIndexes(indexes) {
|
||
const payload = window.infoCollectState || infoCollectState;
|
||
const current = payload && payload.currentPayload;
|
||
if (!current || !current.results || !indexes.length) {
|
||
alert(assetT('assets.selectFirst', '请先选择需要入库的结果'));
|
||
return;
|
||
}
|
||
const converted = indexes.map(index => fofaResultToAsset(current.results[index], current.fields));
|
||
const assets = converted.filter(asset => asset.host || asset.ip || asset.domain);
|
||
const invalidCount = converted.length - assets.length;
|
||
if (!assets.length) {
|
||
alert(assetT('assets.noValidImportTarget', '所选结果中没有可入库的有效资产目标'));
|
||
return;
|
||
}
|
||
const response = await apiFetch('/api/assets/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assets, source: 'fofa', source_query: current.query || '' }) });
|
||
if (!response.ok) {
|
||
alert(assetT('assets.importFailed', '资产入库失败') + ': ' + await response.text());
|
||
return;
|
||
}
|
||
const result = await response.json();
|
||
if (typeof showInlineToast === 'function') {
|
||
const message = invalidCount > 0
|
||
? assetT('assets.importDoneWithInvalid', `已新增 ${result.created} 条,更新 ${result.updated} 条,跳过 ${invalidCount} 条无有效目标的结果`, { ...result, invalid: invalidCount })
|
||
: assetT('assets.importDone', `已新增 ${result.created} 条,更新 ${result.updated} 条`, result);
|
||
showInlineToast(message);
|
||
}
|
||
}
|
||
|
||
function importSelectedFofaAssets() {
|
||
const indexes = Array.from(infoCollectState.selectedRowIndexes || []).sort((a, b) => a - b);
|
||
return importFofaAssetsByIndexes(indexes);
|
||
}
|
||
|
||
function importFofaRowAsset(index) {
|
||
return importFofaAssetsByIndexes([Number(index)]);
|
||
}
|
||
|
||
function assetDetailItem(label, value, wide) {
|
||
return `<div class="asset-detail-item${wide ? ' asset-detail-item--wide' : ''}"><span>${escapeHtml(label)}</span><div>${value || '<span class="muted">-</span>'}</div></div>`;
|
||
}
|
||
|
||
function openAssetDetail(index) {
|
||
const asset = assetPageState.items[Number(index)];
|
||
assetPageState.detailIndex = Number(index);
|
||
openAssetDetailRecord(asset);
|
||
}
|
||
|
||
function openAssetDetailRecord(asset) {
|
||
if (!asset) return;
|
||
assetPageState.detailAsset = asset;
|
||
const subtitle = document.getElementById('asset-detail-subtitle');
|
||
if (subtitle) subtitle.textContent = assetTargetLabel(asset);
|
||
const tags = (asset.tags || []).map(tag => `<span class="asset-tag">${escapeHtml(tag)}</span>`).join('');
|
||
const values = [
|
||
assetDetailItem(assetT('assets.project', '所属项目'), escapeHtml(asset.project_name || '')),
|
||
assetDetailItem(assetT('assets.status', '状态'), escapeHtml(asset.status === 'inactive' ? assetT('assets.statusInactive', '停用') : assetT('assets.statusActive', '活跃'))),
|
||
assetDetailItem('Host', escapeHtml(asset.host || '')),
|
||
assetDetailItem('IP', escapeHtml(asset.ip || '')),
|
||
assetDetailItem(assetT('assets.domain', '域名'), escapeHtml(asset.domain || '')),
|
||
assetDetailItem(assetT('assets.service', '服务'), escapeHtml([asset.protocol, asset.port ? ':' + asset.port : ''].join(''))),
|
||
assetDetailItem(assetT('assets.title', '标题/指纹'), escapeHtml(asset.title || ''), true),
|
||
assetDetailItem(assetT('assets.server', '服务指纹'), escapeHtml(asset.server || '')),
|
||
assetDetailItem(assetT('assets.location', '地区'), escapeHtml([asset.country, asset.province, asset.city].filter(Boolean).join(' / '))),
|
||
assetDetailItem(assetT('assets.source', '来源'), escapeHtml(asset.source || '')),
|
||
assetDetailItem(assetT('assets.sourceQuery', '来源查询'), asset.source_query ? `<code>${escapeHtml(asset.source_query)}</code>` : ''),
|
||
assetDetailItem(assetT('assets.tagsLabel', '标签'), tags, true),
|
||
assetDetailItem(assetT('assets.firstSeen', '首次发现'), escapeHtml(asset.first_seen_at ? new Date(asset.first_seen_at).toLocaleString() : '')),
|
||
assetDetailItem(assetT('assets.lastSeen', '最近发现'), escapeHtml(asset.last_seen_at ? new Date(asset.last_seen_at).toLocaleString() : '')),
|
||
assetDetailItem(assetT('assets.lastScan', '上次扫描'), escapeHtml(asset.last_scan_at ? new Date(asset.last_scan_at).toLocaleString() : '')),
|
||
assetDetailItem(assetT('assets.relatedVulnerabilities', '相关漏洞'), String(Number(asset.vulnerability_count || 0)))
|
||
];
|
||
const grid = document.getElementById('asset-detail-grid');
|
||
if (grid) grid.innerHTML = values.join('');
|
||
if (typeof applyRBACToUI === 'function') applyRBACToUI(document.getElementById('asset-detail-modal'));
|
||
if (typeof openAppModal === 'function') openAppModal('asset-detail-modal');
|
||
else document.getElementById('asset-detail-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeAssetDetail() {
|
||
if (typeof closeAppModal === 'function') closeAppModal('asset-detail-modal');
|
||
else document.getElementById('asset-detail-modal').style.display = 'none';
|
||
}
|
||
|
||
function editAssetFromDetail() {
|
||
const asset = assetPageState.detailAsset;
|
||
closeAssetDetail();
|
||
if (asset) openAssetEditor(asset);
|
||
}
|
||
|
||
window.loadAssetOverview = loadAssetOverview;
|
||
window.loadAssets = loadAssets;
|
||
window.openAssetScanModal = openAssetScanModal;
|
||
window.closeAssetScanModal = closeAssetScanModal;
|
||
window.submitAssetScan = submitAssetScan;
|
||
window.toggleAssetSelection = toggleAssetSelection;
|
||
window.toggleAssetPageSelection = toggleAssetPageSelection;
|
||
window.clearAssetSelection = clearAssetSelection;
|
||
window.openAssetVulnerabilities = openAssetVulnerabilities;
|
||
window.changeAssetPageSize = changeAssetPageSize;
|
||
window.openAssetEditor = openAssetEditor;
|
||
window.closeAssetEditor = closeAssetEditor;
|
||
window.saveAsset = saveAsset;
|
||
window.deleteAsset = deleteAsset;
|
||
window.importSelectedFofaAssets = importSelectedFofaAssets;
|
||
window.importFofaRowAsset = importFofaRowAsset;
|
||
window.openAssetDetail = openAssetDetail;
|
||
window.openAssetDetailRecord = openAssetDetailRecord;
|
||
window.closeAssetDetail = closeAssetDetail;
|
||
window.editAssetFromDetail = editAssetFromDetail;
|
||
|
||
document.addEventListener('DOMContentLoaded', () => initAssetCustomSelects());
|
||
document.addEventListener('languagechange', () => ASSET_CUSTOM_SELECT_IDS.forEach(syncAssetSelect));
|