Add files via upload

This commit is contained in:
公明
2026-07-17 16:51:35 +08:00
committed by GitHub
parent 217f8f9648
commit 9f092388e1
13 changed files with 1912 additions and 254 deletions
+722 -15
View File
@@ -1,4 +1,5 @@
const ASSET_PAGE_SIZE_KEY = 'cyberstrike.asset_page_size';
const ASSET_SAVED_VIEWS_KEY = 'cyberstrike.asset_saved_views';
function getAssetPageSize() {
try {
const value = Number(localStorage.getItem(ASSET_PAGE_SIZE_KEY));
@@ -7,15 +8,26 @@ function getAssetPageSize() {
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: '' };
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(), selectionQuery: '', allMatchingSelected: false, scanMode: 'chat', scanAssets: [], editorTags: [], editorDirty: false, editorBusy: false, editorReturnFocus: null, editorInteractionsReady: false, editorParsedTarget: '', importRows: [], importFileName: '', importBusy: false, importInteractionsReady: false, importReturnFocus: null };
let assetOverviewDays = 30;
const ASSET_CUSTOM_SELECT_IDS = [
'asset-status-filter',
'asset-project-filter',
'asset-risk-filter',
'asset-scan-filter',
'asset-environment-filter',
'asset-criticality-filter',
'asset-sort-filter',
'asset-saved-view-select',
'asset-batch-project',
'asset-edit-project',
'asset-edit-status',
'asset-edit-environment',
'asset-edit-criticality',
'asset-bulk-status',
'asset-bulk-environment',
'asset-bulk-criticality',
'asset-page-size-pagination'
];
@@ -25,7 +37,8 @@ function enhanceAssetSelect(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--filter', Boolean(select.closest('.asset-toolbar')));
wrapper.classList.toggle('asset-custom-select--saved-view', select.id === 'asset-saved-view-select');
wrapper.classList.toggle('asset-custom-select--pagination', select.id === 'asset-page-size-pagination');
}
@@ -52,6 +65,319 @@ function assetT(key, fallback, options) {
return fallback;
}
const ASSET_IMPORT_MAX_ROWS = 100000;
const ASSET_IMPORT_MAX_BYTES = 100 * 1024 * 1024;
const ASSET_IMPORT_COLUMNS = ['target', 'project', 'tags', 'host', 'ip', 'domain', 'port', 'protocol', 'title', 'server', 'country', 'province', 'city', 'responsible_person', 'department', 'business_system', 'environment', 'criticality', 'status'];
const ASSET_IMPORT_HEADER_ALIASES = {
target: ['target', 'asset', 'assetaddress', '目标', '资产', '资产地址'],
project: ['project', 'projectname', 'projectid', '项目', '项目名称', '项目id', '所属项目'],
tags: ['tags', 'tag', '标签'],
host: ['host', 'url', '完整url', '主机'],
ip: ['ip', 'ipaddress', 'ip地址'],
domain: ['domain', '域名'],
port: ['port', '端口'],
protocol: ['protocol', 'scheme', '协议'],
title: ['title', 'pagetitle', '标题', '页面标题'],
server: ['server', 'service', 'product', '服务', '产品', '服务指纹'],
country: ['country', 'countryregion', '国家', '国家地区'],
province: ['province', 'state', '省份', '州'],
city: ['city', '城市'],
responsible_person: ['responsibleperson', 'owner', '负责人', '责任人'],
department: ['department', '部门'],
business_system: ['businesssystem', 'system', '业务系统', '系统'],
environment: ['environment', 'env', '环境'],
criticality: ['criticality', 'importance', '重要性', '重要等级'],
status: ['status', '状态']
};
function normalizeAssetImportHeader(value) {
return String(value == null ? '' : value).replace(/^\uFEFF/, '').trim().toLowerCase().replace(/[\s_\-/.()()]+/g, '');
}
function assetImportColumnForHeader(value) {
const normalized = normalizeAssetImportHeader(value);
return ASSET_IMPORT_COLUMNS.find(column => ASSET_IMPORT_HEADER_ALIASES[column].includes(normalized)) || '';
}
function assetImportTemplateHeaders() {
return ASSET_IMPORT_COLUMNS.slice();
}
function downloadAssetTemplate(format) {
const headers = assetImportTemplateHeaders();
if (format === 'csv') {
const csv = '\uFEFF' + headers.join(',') + '\r\n';
const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
link.download = 'asset-import-template.csv';
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 0);
return;
}
if (!window.XLSX) {
setAssetImportError(assetT('assets.spreadsheetUnavailable', '表格组件加载失败,请刷新后重试'));
return;
}
const workbook = XLSX.utils.book_new();
const assetSheet = XLSX.utils.aoa_to_sheet([headers]);
assetSheet['!cols'] = headers.map(name => ({ wch: ['target', 'host'].includes(name) ? 28 : 16 }));
assetSheet['!autofilter'] = { ref: `A1:${XLSX.utils.encode_col(headers.length - 1)}1` };
const instructions = [
[assetT('assets.templateGuideTitle', '资产批量导入填写说明')],
[assetT('assets.templateGuideRequired', '必填:target,或 host / ip / domain 中至少一项')],
[assetT('assets.templateGuideTarget', 'target 示例:https://example.com:443、example.com、1.1.1.1:22')],
[assetT('assets.templateGuideProject', 'project 填写系统中已有的项目名称或项目 ID,留空表示不绑定')],
[assetT('assets.templateGuideTags', 'tags 使用逗号分隔,最多 30 个')],
[assetT('assets.templateGuideStatus', 'status 仅支持 active / inactive,留空默认为 active')],
[assetT('assets.templateGuideLimit', '请在“Assets”工作表中填写,最多 100000 行')]
];
const guideSheet = XLSX.utils.aoa_to_sheet(instructions);
guideSheet['!cols'] = [{ wch: 86 }];
XLSX.utils.book_append_sheet(workbook, assetSheet, 'Assets');
XLSX.utils.book_append_sheet(workbook, guideSheet, assetT('assets.instructionsSheet', '填写说明').slice(0, 31));
XLSX.writeFile(workbook, 'asset-import-template.xlsx');
}
async function openAssetImport() {
assetPageState.importReturnFocus = document.activeElement;
await ensureAssetProjects(true);
resetAssetImport();
ensureAssetImportInteractions();
if (typeof openAppModal === 'function') openAppModal('asset-import-modal', { focusEl: document.getElementById('asset-import-dropzone') });
else document.getElementById('asset-import-modal').style.display = 'flex';
}
function closeAssetImport(force) {
if (assetPageState.importBusy && !force) return;
if (typeof closeAppModal === 'function') closeAppModal('asset-import-modal');
else document.getElementById('asset-import-modal').style.display = 'none';
const returnFocus = assetPageState.importReturnFocus;
if (returnFocus && typeof returnFocus.focus === 'function') requestAnimationFrame(() => returnFocus.focus());
}
function resetAssetImport() {
assetPageState.importRows = [];
assetPageState.importFileName = '';
assetPageState.importBusy = false;
const file = document.getElementById('asset-import-file');
if (file) file.value = '';
const name = document.getElementById('asset-import-file-name');
if (name) name.textContent = assetT('assets.fileNotSelected', '尚未选择文件');
const preview = document.getElementById('asset-import-preview-section');
if (preview) preview.hidden = true;
const body = document.getElementById('asset-import-preview-body');
if (body) body.innerHTML = '';
setAssetImportError('');
setAssetImportBusy(false);
}
function ensureAssetImportInteractions() {
if (assetPageState.importInteractionsReady) return;
assetPageState.importInteractionsReady = true;
const dropzone = document.getElementById('asset-import-dropzone');
if (!dropzone) return;
['dragenter', 'dragover'].forEach(type => dropzone.addEventListener(type, event => {
event.preventDefault();
dropzone.classList.add('is-dragging');
}));
['dragleave', 'drop'].forEach(type => dropzone.addEventListener(type, event => {
event.preventDefault();
dropzone.classList.remove('is-dragging');
}));
dropzone.addEventListener('drop', event => handleAssetImportFile(event.dataTransfer?.files?.[0]));
}
function setAssetImportError(message) {
const root = document.getElementById('asset-import-error');
if (!root) return;
root.textContent = message || '';
root.hidden = !message;
}
function setAssetImportBusy(busy) {
assetPageState.importBusy = Boolean(busy);
const submit = document.getElementById('asset-import-submit');
if (submit) submit.disabled = Boolean(busy) || !assetPageState.importRows.some(row => !row.error);
const dropzone = document.getElementById('asset-import-dropzone');
if (dropzone) dropzone.disabled = Boolean(busy);
}
async function handleAssetImportFile(file) {
setAssetImportError('');
if (!file) return;
assetPageState.importRows = [];
assetPageState.importFileName = '';
const previousPreview = document.getElementById('asset-import-preview-section');
if (previousPreview) previousPreview.hidden = true;
const selectedName = document.getElementById('asset-import-file-name');
if (selectedName) selectedName.textContent = file.name || assetT('assets.fileNotSelected', '尚未选择文件');
setAssetImportBusy(false);
const extension = String(file.name || '').split('.').pop().toLowerCase();
if (!['xlsx', 'csv'].includes(extension)) {
setAssetImportError(assetT('assets.fileTypeInvalid', '仅支持 .xlsx 和 .csv 文件'));
return;
}
if (file.size > ASSET_IMPORT_MAX_BYTES) {
setAssetImportError(assetT('assets.fileTooLarge', '文件不能超过 100 MB'));
return;
}
if (!window.XLSX) {
setAssetImportError(assetT('assets.spreadsheetUnavailable', '表格组件加载失败,请刷新后重试'));
return;
}
setAssetImportBusy(true);
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array', cellDates: false });
const preferred = workbook.SheetNames.find(name => /^(assets?|资产)$/i.test(String(name).trim()));
const sheet = workbook.Sheets[preferred || workbook.SheetNames[0]];
const matrix = sheet ? XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false, blankrows: false }) : [];
parseAssetImportMatrix(matrix, file.name);
} catch (error) {
console.error('解析资产导入文件失败:', error);
setAssetImportError(error?.message || assetT('assets.fileParseFailed', '无法解析文件,请确认文件未损坏且格式正确'));
} finally {
setAssetImportBusy(false);
}
}
function parseAssetImportMatrix(matrix, fileName) {
if (!Array.isArray(matrix) || matrix.length < 2) {
throw new Error(assetT('assets.fileHasNoData', '文件中没有可导入的数据'));
}
const headers = matrix[0].map(assetImportColumnForHeader);
if (!headers.some(Boolean)) {
throw new Error(assetT('assets.headerInvalid', '未识别到模板字段,请使用下载的模板填写'));
}
const dataRows = matrix.slice(1).filter(row => Array.isArray(row) && row.some(value => String(value ?? '').trim() !== ''));
if (!dataRows.length) throw new Error(assetT('assets.fileHasNoData', '文件中没有可导入的数据'));
if (dataRows.length > ASSET_IMPORT_MAX_ROWS) {
throw new Error(assetT('assets.tooManyRows', `数据不能超过 ${ASSET_IMPORT_MAX_ROWS}`, { count: ASSET_IMPORT_MAX_ROWS }));
}
const seen = new Map();
assetPageState.importRows = dataRows.map((row, index) => {
const values = {};
headers.forEach((column, columnIndex) => {
if (column && values[column] == null) values[column] = String(row[columnIndex] ?? '').trim();
});
const parsed = assetImportRecord(values, index + 2);
if (!parsed.error) {
const asset = parsed.asset;
const key = `${String(asset.domain || asset.ip || asset.host).toLowerCase()}|${asset.port || 0}|${asset.protocol || ''}`;
if (seen.has(key)) parsed.error = assetT('assets.duplicateFileRow', `与第 ${seen.get(key)} 行重复`, { row: seen.get(key) });
else seen.set(key, parsed.rowNumber);
}
return parsed;
});
assetPageState.importFileName = fileName;
const name = document.getElementById('asset-import-file-name');
if (name) name.textContent = fileName;
renderAssetImportPreview();
}
function assetImportRecord(values, rowNumber) {
const fail = message => ({ rowNumber, values, asset: null, error: message });
const target = values.target || values.host || values.domain || values.ip || '';
if (!target) return fail(assetT('assets.importTargetRequired', '缺少目标地址'));
let parsed;
try { parsed = parseAssetEditorTarget(target); }
catch (error) { return fail(error.message); }
const rawPort = values.port;
const port = rawPort === '' || rawPort == null ? Number(parsed.port || 0) : Number(rawPort);
if (!Number.isInteger(port) || port < 0 || port > 65535) return fail(assetT('assets.portInvalid', '端口必须在 165535 之间'));
const ip = (values.ip || parsed.ip || '').toLowerCase();
if (ip && !assetEditorIsIPv4(ip) && !assetEditorIsIPv6(ip)) return fail(assetT('assets.ipInvalid', 'IP 地址格式无效'));
const rawDomain = values.domain || parsed.domain || '';
const domain = rawDomain ? assetEditorNormalizeDomain(rawDomain) : '';
if (rawDomain && !domain) return fail(assetT('assets.domainInvalid', '域名格式无效'));
const protocol = (values.protocol || parsed.protocol || '').toLowerCase();
if (protocol && !/^[a-z][a-z0-9+.-]{0,31}$/.test(protocol)) return fail(assetT('assets.protocolInvalid', '协议格式无效'));
const statuses = { active: 'active', inactive: 'inactive', '活跃': 'active', '停用': 'inactive' };
const status = statuses[String(values.status || 'active').toLowerCase()];
if (!status) return fail(assetT('assets.importStatusInvalid', '状态仅支持 active 或 inactive'));
const environments = { '': '', production: 'production', staging: 'staging', testing: 'testing', development: 'development', other: 'other', '生产': 'production', '预发布': 'staging', '测试': 'testing', '开发': 'development', '其他': 'other' };
const environment = environments[String(values.environment || '').toLowerCase()];
if (environment == null) return fail('环境值无效');
const criticalities = { '': '', critical: 'critical', high: 'high', medium: 'medium', low: 'low', '核心': 'critical', '重要': 'high', '一般': 'medium', '低': 'low' };
const criticality = criticalities[String(values.criticality || '').toLowerCase()];
if (criticality == null) return fail('重要性值无效');
let projectId = '';
let projectName = '';
if (values.project) {
const projectValue = String(values.project).trim().toLowerCase();
const project = assetPageState.projects.find(item => String(item.id).toLowerCase() === projectValue || String(item.name).trim().toLowerCase() === projectValue);
if (!project) return fail(assetT('assets.importProjectNotFound', `项目不存在或无权访问:${values.project}`, { project: values.project }));
projectId = project.id;
projectName = project.name;
}
const tags = String(values.tags || '').split(/[,;|]/).map(tag => tag.trim()).filter(Boolean);
if (tags.length > 30 || tags.some(tag => Array.from(tag).length > 64)) return fail(assetT('assets.importTagsInvalid', '标签最多 30 个,单个标签最多 64 个字符'));
const asset = {
host: values.host || parsed.host || '', ip, domain, port, protocol,
title: values.title || '', server: values.server || '', country: values.country || '',
province: values.province || '', city: values.city || '', responsible_person: values.responsible_person || '',
department: values.department || '', business_system: values.business_system || '', environment, criticality, status, tags,
project_id: projectId, source: 'manual-import'
};
const limits = { host: 500, title: 500, domain: 255, protocol: 255, server: 255, country: 255, province: 255, city: 255, responsible_person: 255, department: 255, business_system: 255 };
const oversized = Object.keys(limits).find(key => Array.from(String(asset[key] || '')).length > limits[key]);
if (oversized) return fail(assetT('assets.importFieldTooLong', `字段 ${oversized} 内容过长`, { field: oversized }));
return { rowNumber, values, asset, projectName, error: '' };
}
function renderAssetImportPreview() {
const rows = assetPageState.importRows;
const valid = rows.filter(row => !row.error).length;
const invalid = rows.length - valid;
const preview = document.getElementById('asset-import-preview-section');
if (preview) preview.hidden = false;
const summary = document.getElementById('asset-import-summary');
if (summary) summary.textContent = assetT('assets.importPreviewSummary', `${rows.length} 行,${valid} 行有效,${invalid} 行需修正`, { total: rows.length, valid, invalid });
const body = document.getElementById('asset-import-preview-body');
if (body) body.innerHTML = rows.slice(0, 100).map(row => {
const asset = row.asset || {};
const target = row.values.target || row.values.host || row.values.domain || row.values.ip || '-';
const result = row.error
? `<span class="asset-import-result asset-import-result--error">${escapeHtml(row.error)}</span>`
: `<span class="asset-import-result asset-import-result--ok">${escapeHtml(assetT('assets.validationPassed', '通过'))}</span>`;
return `<tr class="${row.error ? 'has-error' : ''}"><td>${row.rowNumber}</td><td title="${escapeHtml(target)}">${escapeHtml(target)}</td><td>${escapeHtml(row.projectName || row.values.project || '-')}</td><td>${escapeHtml(asset.status || row.values.status || '-')}</td><td>${result}</td></tr>`;
}).join('');
const note = document.getElementById('asset-import-preview-note');
if (note) note.textContent = rows.length > 100 ? assetT('assets.previewLimited', `仅展示前 100 行;提交时将处理全部 ${rows.length}`, { count: rows.length }) : '';
const submit = document.getElementById('asset-import-submit');
if (submit) {
submit.textContent = assetT('assets.importValidRowsCount', `导入 ${valid} 条有效数据`, { count: valid });
submit.disabled = valid === 0 || assetPageState.importBusy;
}
}
async function submitAssetImport() {
if (assetPageState.importBusy) return;
const assets = assetPageState.importRows.filter(row => !row.error).map(row => row.asset);
if (!assets.length) return;
setAssetImportError('');
setAssetImportBusy(true);
try {
const response = await apiFetch('/api/assets/import', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assets, source: 'manual-import', source_query: assetPageState.importFileName })
});
if (!response.ok) throw new Error(await assetEditorResponseError(response));
const result = await response.json();
const invalid = assetPageState.importRows.length - assets.length;
closeAssetImport(true);
await loadAssets(1);
if (typeof showInlineToast === 'function') {
showInlineToast(assetT('assets.bulkImportDone', `导入完成:新增 ${result.created} 条,更新 ${result.updated} 条,跳过 ${Number(result.skipped || 0) + invalid}`, {
created: result.created, updated: result.updated, skipped: Number(result.skipped || 0) + invalid
}));
}
} catch (error) {
setAssetImportError(assetT('assets.importFailed', '资产入库失败') + ': ' + error.message);
} finally {
setAssetImportBusy(false);
}
}
async function loadAssetOverview() {
try {
const response = await apiFetch('/api/assets/stats?days=' + assetOverviewDays);
@@ -217,20 +543,147 @@ function renderAssetProtocolChart(items, total) {
}).join('');
}
const ASSET_FILTER_FIELD_IDS = [
'asset-search', 'asset-status-filter', 'asset-project-filter', 'asset-risk-filter', 'asset-vuln-min-filter',
'asset-protocol-filter', 'asset-port-filter', 'asset-source-filter', 'asset-tag-filter', 'asset-scan-filter',
'asset-country-filter', 'asset-province-filter', 'asset-city-filter', 'asset-responsible-filter',
'asset-department-filter', 'asset-business-filter', 'asset-environment-filter', 'asset-criticality-filter',
'asset-first-seen-after-filter', 'asset-first-seen-before-filter', 'asset-last-seen-after-filter',
'asset-last-seen-before-filter', 'asset-sort-filter'
];
function assetFilterValues() {
return Object.fromEntries(ASSET_FILTER_FIELD_IDS.map(id => [id, document.getElementById(id)?.value || '']));
}
function nextAssetFilterDay(value) {
if (!value) return '';
const date = new Date(value + 'T00:00:00Z');
date.setUTCDate(date.getUTCDate() + 1);
return date.toISOString().slice(0, 10);
}
function buildAssetFilterParams() {
const params = new URLSearchParams();
const map = {
'asset-search': 'q', 'asset-status-filter': 'status', 'asset-project-filter': 'project_id',
'asset-risk-filter': 'risk_level', 'asset-vuln-min-filter': 'min_vulnerabilities',
'asset-protocol-filter': 'protocol', 'asset-port-filter': 'port', 'asset-source-filter': 'source',
'asset-tag-filter': 'tag', 'asset-country-filter': 'country', 'asset-province-filter': 'province',
'asset-city-filter': 'city', 'asset-responsible-filter': 'responsible_person',
'asset-department-filter': 'department', 'asset-business-filter': 'business_system',
'asset-environment-filter': 'environment', 'asset-criticality-filter': 'criticality',
'asset-first-seen-after-filter': 'first_seen_after', 'asset-last-seen-after-filter': 'last_seen_after'
};
Object.entries(map).forEach(([id, key]) => {
const value = document.getElementById(id)?.value.trim() || '';
if (value) params.set(key, value);
});
[
['asset-first-seen-before-filter', 'first_seen_before'],
['asset-last-seen-before-filter', 'last_seen_before']
].forEach(([id, key]) => {
const value = nextAssetFilterDay(document.getElementById(id)?.value || '');
if (value) params.set(key, value);
});
const scan = document.getElementById('asset-scan-filter')?.value || '';
if (scan === 'never' || scan === 'scanned') params.set('scan_state', scan);
else if (/^\d+$/.test(scan)) params.set('scan_overdue_days', scan);
const [sortBy, sortOrder] = (document.getElementById('asset-sort-filter')?.value || 'last_seen_at:desc').split(':');
if (sortBy) params.set('sort_by', sortBy);
if (sortOrder) params.set('sort_order', sortOrder);
return params;
}
function toggleAssetAdvancedFilters() {
const panel = document.getElementById('asset-advanced-filters');
const button = document.getElementById('asset-advanced-toggle');
if (!panel) return;
panel.hidden = !panel.hidden;
if (button) button.setAttribute('aria-expanded', String(!panel.hidden));
}
function resetAssetFilters() {
ASSET_FILTER_FIELD_IDS.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.value = id === 'asset-sort-filter' ? 'last_seen_at:desc' : '';
if (el.tagName === 'SELECT') syncAssetSelect(el);
});
const saved = document.getElementById('asset-saved-view-select');
if (saved) saved.value = '';
clearAssetSelection();
loadAssets(1);
}
function readAssetSavedViews() {
try {
const views = JSON.parse(localStorage.getItem(ASSET_SAVED_VIEWS_KEY) || '[]');
return Array.isArray(views) ? views : [];
} catch (error) {
return [];
}
}
function renderAssetSavedViews(selectedName) {
const select = document.getElementById('asset-saved-view-select');
if (!select) return;
const views = readAssetSavedViews();
select.innerHTML = `<option value="">${escapeHtml(assetT('assets.savedViews', '保存的筛选视图'))}</option>` + views.map(view => `<option value="${escapeHtml(view.name)}">${escapeHtml(view.name)}</option>`).join('');
select.value = selectedName || '';
syncAssetSelect(select);
}
function saveCurrentAssetView() {
const name = prompt('请输入筛选视图名称');
if (!name || !name.trim()) return;
const cleanName = name.trim().slice(0, 60);
const views = readAssetSavedViews().filter(view => view.name !== cleanName);
views.push({ name: cleanName, values: assetFilterValues() });
try { localStorage.setItem(ASSET_SAVED_VIEWS_KEY, JSON.stringify(views)); } catch (error) { return alert('保存筛选视图失败'); }
renderAssetSavedViews(cleanName);
if (typeof showInlineToast === 'function') showInlineToast('筛选视图已保存');
}
function applyAssetSavedView(name) {
if (!name) return;
const view = readAssetSavedViews().find(item => item.name === name);
if (!view) return;
Object.entries(view.values || {}).forEach(([id, value]) => {
const el = document.getElementById(id);
if (!el) return;
el.value = value || '';
if (el.tagName === 'SELECT') syncAssetSelect(el);
});
document.getElementById('asset-advanced-filters').hidden = false;
document.getElementById('asset-advanced-toggle')?.setAttribute('aria-expanded', 'true');
clearAssetSelection();
loadAssets(1);
}
function deleteCurrentAssetView() {
const select = document.getElementById('asset-saved-view-select');
const name = select?.value || '';
if (!name || !confirm(`删除筛选视图“${name}”吗?`)) return;
const views = readAssetSavedViews().filter(view => view.name !== name);
try { localStorage.setItem(ASSET_SAVED_VIEWS_KEY, JSON.stringify(views)); } catch (error) { return; }
renderAssetSavedViews();
}
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 filterParams = buildAssetFilterParams();
const filterQuery = filterParams.toString();
if (assetPageState.selectionQuery && assetPageState.selectionQuery !== filterQuery) clearAssetSelection();
assetPageState.selectionQuery = filterQuery;
const params = new URLSearchParams(filterParams);
params.set('page', assetPageState.page);
params.set('page_size', assetPageState.pageSize);
const body = document.getElementById('asset-table-body');
if (body) body.innerHTML = '<tr><td colspan="9" class="muted">' + escapeHtml(assetT('common.loading', '加载中...')) + '</td></tr>';
if (body) body.innerHTML = '<tr><td colspan="10" 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());
@@ -252,7 +705,7 @@ async function loadAssets(page) {
assetPageState.items = [];
assetPageState.total = 0;
assetPageState.totalPages = 1;
if (body) body.innerHTML = '<tr><td colspan="9" class="muted">' + escapeHtml(assetT('assets.loadFailed', '加载资产失败')) + '</td></tr>';
if (body) body.innerHTML = '<tr><td colspan="10" class="muted">' + escapeHtml(assetT('assets.loadFailed', '加载资产失败')) + '</td></tr>';
renderAssetPagination();
}
}
@@ -275,11 +728,43 @@ function assetRiskPresentation(level) {
return { level: normalized, label: labels[normalized] };
}
function assetOwnershipMarkup(asset) {
const owner = String(asset.responsible_person || '').trim();
const department = String(asset.department || '').trim();
if (!owner && !department) {
return `<span class="asset-ownership-empty">${escapeHtml(assetT('assets.unassigned', '未分配'))}</span>`;
}
const ownerLabel = assetT('assets.responsiblePerson', '负责人');
const departmentLabel = assetT('assets.department', '部门');
const title = [
owner ? `${ownerLabel}: ${owner}` : '',
department ? `${departmentLabel}: ${department}` : ''
].filter(Boolean).join(' · ');
const primary = owner || department;
return `<div class="asset-ownership" title="${escapeHtml(title)}">
<span class="asset-ownership__primary">${escapeHtml(primary)}</span>
${owner && department ? `<span class="asset-ownership__secondary">${escapeHtml(department)}</span>` : ''}
</div>`;
}
function ensureAssetOwnershipColumn() {
const table = document.querySelector('#page-asset-library .asset-table');
const headerRow = table?.querySelector('thead tr');
if (!headerRow || headerRow.querySelector('[data-i18n="assets.ownership"]')) return;
const lastScanHeader = headerRow.querySelector('[data-i18n="assets.lastScan"]');
if (!lastScanHeader) return;
const ownershipHeader = document.createElement('th');
ownershipHeader.setAttribute('data-i18n', 'assets.ownership');
ownershipHeader.textContent = assetT('assets.ownership', '归属');
headerRow.insertBefore(ownershipHeader, lastScanHeader);
table.querySelectorAll('#asset-table-body td[colspan="9"]').forEach(cell => cell.setAttribute('colspan', '10'));
}
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>';
body.innerHTML = '<tr><td colspan="10" class="muted">' + escapeHtml(assetT('common.noData', '暂无数据')) + '</td></tr>';
return;
}
body.innerHTML = assetPageState.items.map((asset, index) => {
@@ -290,10 +775,11 @@ function renderAssetRows() {
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 class="asset-check-cell"><input type="checkbox" class="theme-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>${assetOwnershipMarkup(asset)}</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>
@@ -307,7 +793,10 @@ 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);
else {
assetPageState.selected.delete(asset.id);
assetPageState.allMatchingSelected = false;
}
updateAssetSelectionUI();
}
@@ -316,22 +805,48 @@ function toggleAssetPageSelection(checked) {
if (checked) assetPageState.selected.set(asset.id, asset);
else assetPageState.selected.delete(asset.id);
});
if (!checked) assetPageState.allMatchingSelected = false;
renderAssetRows();
updateAssetSelectionUI();
}
function clearAssetSelection() {
assetPageState.selected.clear();
assetPageState.allMatchingSelected = false;
closeAssetBatchMenu();
renderAssetRows();
updateAssetSelectionUI();
}
function closeAssetBatchMenu() {
const menu = document.getElementById('asset-batch-more');
if (menu) menu.open = false;
}
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 selectAll = document.getElementById('asset-select-all-results');
if (selectAll) {
selectAll.hidden = count === 0 || count >= assetPageState.total || assetPageState.allMatchingSelected;
selectAll.textContent = assetT('assets.selectAllResults', `选择全部 ${assetPageState.total}`, { count: assetPageState.total });
}
const allSelected = document.getElementById('asset-all-results-selected');
if (allSelected) {
allSelected.hidden = !assetPageState.allMatchingSelected;
allSelected.textContent = assetT('assets.allResultsSelected', `已选择当前筛选结果中的 ${count}`, { count });
}
const merge = document.getElementById('asset-merge-selected');
if (merge) {
merge.disabled = count < 2;
merge.setAttribute('aria-disabled', count < 2 ? 'true' : 'false');
merge.title = count < 2
? assetT('assets.mergeRequiresMultiple', '请至少选择两个重复资产')
: '';
}
const pageToggle = document.getElementById('asset-select-page');
if (pageToggle) {
const selectedOnPage = assetPageState.items.filter(asset => assetPageState.selected.has(asset.id)).length;
@@ -340,6 +855,26 @@ function updateAssetSelectionUI() {
}
}
async function selectAllMatchingAssets() {
const button = document.getElementById('asset-select-all-results');
if (button) button.disabled = true;
try {
const params = buildAssetFilterParams();
const response = await apiFetch('/api/assets/selection?' + params.toString());
if (!response.ok) throw new Error(await assetEditorResponseError(response));
const data = await response.json();
assetPageState.selected.clear();
(data.assets || []).forEach(asset => assetPageState.selected.set(asset.id, asset));
assetPageState.allMatchingSelected = true;
renderAssetRows();
updateAssetSelectionUI();
} catch (error) {
alert('选择全部结果失败: ' + error.message);
} finally {
if (button) button.disabled = false;
}
}
async function openAssetProjectModal() {
const assets = Array.from(assetPageState.selected.values());
if (!assets.length) {
@@ -396,6 +931,134 @@ async function submitAssetProjectBinding() {
}
}
function openAssetBulkEdit() {
const count = assetPageState.selected.size;
if (!count) return alert('请先选择资产');
['asset-bulk-status', 'asset-bulk-responsible', 'asset-bulk-department', 'asset-bulk-business', 'asset-bulk-environment', 'asset-bulk-criticality', 'asset-bulk-add-tags', 'asset-bulk-remove-tags'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
document.getElementById('asset-bulk-edit-subtitle').textContent = `将修改 ${count} 个资产;空白字段保持原值`;
['asset-bulk-status', 'asset-bulk-environment', 'asset-bulk-criticality'].forEach(syncAssetSelect);
if (typeof openAppModal === 'function') openAppModal('asset-bulk-edit-modal');
else document.getElementById('asset-bulk-edit-modal').style.display = 'flex';
}
function closeAssetBulkEdit() {
if (typeof closeAppModal === 'function') closeAppModal('asset-bulk-edit-modal');
else document.getElementById('asset-bulk-edit-modal').style.display = 'none';
}
function assetCommaValues(id) {
return (document.getElementById(id)?.value || '').split(/[,]/).map(value => value.trim()).filter(Boolean);
}
async function submitAssetBulkEdit() {
const ids = Array.from(assetPageState.selected.keys());
if (!ids.length) return;
const body = { asset_ids: ids, add_tags: assetCommaValues('asset-bulk-add-tags'), remove_tags: assetCommaValues('asset-bulk-remove-tags') };
const fields = {
status: 'asset-bulk-status', responsible_person: 'asset-bulk-responsible', department: 'asset-bulk-department',
business_system: 'asset-bulk-business', environment: 'asset-bulk-environment', criticality: 'asset-bulk-criticality'
};
Object.entries(fields).forEach(([key, id]) => {
const value = document.getElementById(id)?.value.trim() || '';
if (value) body[key] = value;
});
if (Object.keys(body).length === 3 && body.add_tags.length === 0 && body.remove_tags.length === 0) return alert('请至少填写一项修改');
const button = document.getElementById('asset-bulk-edit-submit');
if (button) button.disabled = true;
try {
const response = await apiFetch('/api/assets/bulk', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!response.ok) throw new Error(await assetEditorResponseError(response));
closeAssetBulkEdit();
clearAssetSelection();
await loadAssets(assetPageState.page);
if (typeof showInlineToast === 'function') showInlineToast(`已更新 ${ids.length} 个资产`);
} catch (error) {
alert('批量编辑失败: ' + error.message);
} finally {
if (button) button.disabled = false;
}
}
function assetExportRows(assets) {
return assets.map(asset => ({
target: assetTargetLabel(asset), host: asset.host || '', ip: asset.ip || '', domain: asset.domain || '', port: asset.port || '',
protocol: asset.protocol || '', title: asset.title || '', server: asset.server || '', project: asset.project_name || '',
responsible_person: asset.responsible_person || '', department: asset.department || '', business_system: asset.business_system || '',
environment: asset.environment || '', criticality: asset.criticality || '', country: asset.country || '', province: asset.province || '',
city: asset.city || '', source: asset.source || '', status: asset.status || '', tags: (asset.tags || []).join(','),
risk_level: asset.risk_level || '', vulnerability_count: Number(asset.vulnerability_count || 0),
first_seen_at: asset.first_seen_at || '', last_seen_at: asset.last_seen_at || '', last_scan_at: asset.last_scan_at || ''
}));
}
function assetCsvCell(value) {
const text = String(value == null ? '' : value);
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
function downloadAssetBlob(blob, filename) {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 0);
}
function exportSelectedAssets(format) {
const rows = assetExportRows(Array.from(assetPageState.selected.values()));
if (!rows.length) return alert('请先选择资产');
const stamp = new Date().toISOString().slice(0, 10);
if (format === 'csv') {
const headers = Object.keys(rows[0]);
const csv = '\uFEFF' + [headers.join(','), ...rows.map(row => headers.map(key => assetCsvCell(row[key])).join(','))].join('\r\n');
downloadAssetBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), `assets-${stamp}.csv`);
return;
}
if (!window.XLSX) return alert('表格组件加载失败,请刷新后重试');
const workbook = XLSX.utils.book_new();
const sheet = XLSX.utils.json_to_sheet(rows);
sheet['!autofilter'] = { ref: sheet['!ref'] };
sheet['!cols'] = Object.keys(rows[0]).map(key => ({ wch: ['target', 'host', 'title'].includes(key) ? 30 : 16 }));
XLSX.utils.book_append_sheet(workbook, sheet, 'Assets');
XLSX.writeFile(workbook, `assets-${stamp}.xlsx`);
}
async function deleteSelectedAssets() {
const ids = Array.from(assetPageState.selected.keys());
if (!ids.length || !confirm(`确定永久删除选中的 ${ids.length} 个资产吗?`)) return;
try {
const response = await apiFetch('/api/assets/batch-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_ids: ids }) });
if (!response.ok) throw new Error(await assetEditorResponseError(response));
clearAssetSelection();
await loadAssets(1);
if (typeof showInlineToast === 'function') showInlineToast(`已删除 ${ids.length} 个资产`);
} catch (error) {
alert('批量删除失败: ' + error.message);
}
}
async function mergeSelectedAssets() {
const assets = Array.from(assetPageState.selected.values());
if (assets.length < 2) return alert('请至少选择两个重复资产');
const primary = assets[0];
if (!confirm(`将保留“${assetTargetLabel(primary)}”作为主资产,并合并其余 ${assets.length - 1} 个资产。继续吗?`)) return;
try {
const response = await apiFetch('/api/assets/merge', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_ids: assets.map(asset => asset.id), primary_id: primary.id })
});
if (!response.ok) throw new Error(await assetEditorResponseError(response));
clearAssetSelection();
await loadAssets(1);
if (typeof showInlineToast === 'function') showInlineToast(`已合并 ${assets.length} 个重复资产`);
} catch (error) {
alert('合并资产失败: ' + error.message);
}
}
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);
@@ -609,6 +1272,11 @@ async function openAssetEditor(indexOrAsset) {
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-responsible').value = asset?.responsible_person || '';
document.getElementById('asset-edit-department').value = asset?.department || '';
document.getElementById('asset-edit-business').value = asset?.business_system || '';
document.getElementById('asset-edit-environment').value = asset?.environment || '';
document.getElementById('asset-edit-criticality').value = asset?.criticality || '';
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)));
@@ -616,6 +1284,8 @@ async function openAssetEditor(indexOrAsset) {
document.getElementById('asset-edit-status').value = asset?.status || 'active';
syncAssetSelect('asset-edit-project');
syncAssetSelect('asset-edit-status');
syncAssetSelect('asset-edit-environment');
syncAssetSelect('asset-edit-criticality');
document.getElementById('asset-edit-target').value = assetEditorTargetFromAsset(asset);
assetPageState.editorParsedTarget = document.getElementById('asset-edit-target').value.trim();
clearAssetEditorErrors();
@@ -824,6 +1494,9 @@ function collectAssetEditor() {
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(),
responsible_person: document.getElementById('asset-edit-responsible').value.trim(), department: document.getElementById('asset-edit-department').value.trim(),
business_system: document.getElementById('asset-edit-business').value.trim(), environment: document.getElementById('asset-edit-environment').value,
criticality: document.getElementById('asset-edit-criticality').value,
project_id: document.getElementById('asset-edit-project').value,
tags: assetPageState.editorTags.slice()
};
@@ -1006,6 +1679,11 @@ function openAssetDetailRecord(asset) {
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.responsiblePerson', '负责人'), escapeHtml(asset.responsible_person || '')),
assetDetailItem(assetT('assets.department', '部门'), escapeHtml(asset.department || '')),
assetDetailItem(assetT('assets.businessSystem', '业务系统'), escapeHtml(asset.business_system || '')),
assetDetailItem(assetT('assets.environment', '环境'), escapeHtml(asset.environment || '')),
assetDetailItem(assetT('assets.criticality', '重要性'), escapeHtml(asset.criticality || '')),
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),
@@ -1040,12 +1718,30 @@ window.submitAssetScan = submitAssetScan;
window.toggleAssetSelection = toggleAssetSelection;
window.toggleAssetPageSelection = toggleAssetPageSelection;
window.clearAssetSelection = clearAssetSelection;
window.closeAssetBatchMenu = closeAssetBatchMenu;
window.selectAllMatchingAssets = selectAllMatchingAssets;
window.toggleAssetAdvancedFilters = toggleAssetAdvancedFilters;
window.resetAssetFilters = resetAssetFilters;
window.saveCurrentAssetView = saveCurrentAssetView;
window.applyAssetSavedView = applyAssetSavedView;
window.deleteCurrentAssetView = deleteCurrentAssetView;
window.openAssetBulkEdit = openAssetBulkEdit;
window.closeAssetBulkEdit = closeAssetBulkEdit;
window.submitAssetBulkEdit = submitAssetBulkEdit;
window.exportSelectedAssets = exportSelectedAssets;
window.deleteSelectedAssets = deleteSelectedAssets;
window.mergeSelectedAssets = mergeSelectedAssets;
window.openAssetVulnerabilities = openAssetVulnerabilities;
window.changeAssetPageSize = changeAssetPageSize;
window.openAssetEditor = openAssetEditor;
window.closeAssetEditor = closeAssetEditor;
window.saveAsset = saveAsset;
window.deleteAsset = deleteAsset;
window.openAssetImport = openAssetImport;
window.closeAssetImport = closeAssetImport;
window.downloadAssetTemplate = downloadAssetTemplate;
window.handleAssetImportFile = handleAssetImportFile;
window.submitAssetImport = submitAssetImport;
window.importSelectedFofaAssets = importSelectedFofaAssets;
window.importFofaRowAsset = importFofaRowAsset;
window.openAssetDetail = openAssetDetail;
@@ -1053,5 +1749,16 @@ window.openAssetDetailRecord = openAssetDetailRecord;
window.closeAssetDetail = closeAssetDetail;
window.editAssetFromDetail = editAssetFromDetail;
document.addEventListener('DOMContentLoaded', () => initAssetCustomSelects());
document.addEventListener('DOMContentLoaded', () => {
ensureAssetOwnershipColumn();
initAssetCustomSelects();
renderAssetSavedViews();
document.addEventListener('click', event => {
const menu = document.getElementById('asset-batch-more');
if (menu?.open && event.target instanceof Node && !menu.contains(event.target)) closeAssetBatchMenu();
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape') closeAssetBatchMenu();
});
});
document.addEventListener('languagechange', () => ASSET_CUSTOM_SELECT_IDS.forEach(syncAssetSelect));