diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index ae9118fe..9bb53324 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -58,6 +58,7 @@ "chat": "Chat", "infoCollect": "Recon", "tasks": "Tasks", + "projects": "Projects", "vulnerabilities": "Vulnerabilities", "webshell": "WebShell Management", "chatFiles": "File Management", @@ -222,6 +223,179 @@ "noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat", "startScanBtn": "Go to chat to scan" }, + "projects": { + "title": "Projects", + "showArchived": "Show archived", + "newProjectCta": "+ New project", + "projectList": "Project list", + "searchProjectsPlaceholder": "Search projects…", + "selectOrCreateTitle": "Select or create a project", + "selectOrCreateHint": "Projects share a cross-chat fact board; target, environment, auth and other facts are auto-injected in bound conversations.", + "createFirstProject": "Create first project", + "defaultProjectName": "Project", + "statusActive": "Active", + "statusArchived": "Archived", + "vulnerabilityManagement": "Vulnerability management", + "addFactCta": "+ Add fact", + "tabFacts": "Fact board", + "tabConversations": "Bound conversations", + "tabVulns": "Related vulnerabilities", + "tabSettings": "Settings", + "factToolbarHint": "Index includes key and summary only (must include what + where + how to verify); put attack chain / POC in body, and reproduce via get_project_fact.", + "searchFactsSr": "Search facts", + "searchFactsPlaceholder": "Search key, summary, body…", + "category": "Category", + "all": "All", + "confidence": "Confidence", + "confidenceConfirmed": "Confirmed", + "confidenceTentative": "Tentative", + "confidenceDeprecated": "Deprecated", + "displayOptions": "Display options", + "sparseOnly": "Sparse only", + "hideDeprecated": "Hide deprecated", + "summary": "Summary", + "updated": "Updated", + "boundConversationsHint": "Conversations bound to this project; click to open", + "titleLabel": "Title", + "projectVulnSummaryHint": "Vulnerability summary under this project", + "viewInVulnerabilityManagement": "View in vulnerability management", + "severity": "Severity", + "status": "Status", + "modalNewTitle": "New project", + "modalNewSubtitle": "After creation, bind conversations to share fact board across chats", + "projectName": "Project name", + "projectNamePlaceholder": "e.g. Client A Web pentest", + "projectDescription": "Project description", + "projectDescriptionPlaceholder": "Scope, authorization boundary, notes…", + "createProject": "Create project", + "newProject": "New project", + "chatSelectorButton": "Share fact board across chats after binding a project", + "selectProject": "Select project", + "noProject": "No project", + "factBodyEnvTitle": "Environment fact", + "factBodyHasDetail": "Has details", + "factBodySparseTitle": "Missing attack-chain/POC structure", + "factBodySparse": "Incomplete", + "factBodyReproducibleTitle": "Contains reproducible structure", + "factBodyReproducible": "Reproducible", + "factHintAttackSparse": "Attack-chain fact: fill complete body (steps, HTTP/command, response evidence); avoid conclusion-only notes. You can insert the attack-chain template.", + "factHintAttackReady": "Attack-chain fact: body is used for audit reproduction, keep original request/response and step-by-step flow.", + "factHintEnv": "Environment fact: body should include evidence source; for findings/exploitation use finding|chain|exploit|poc category.", + "confirmOverwriteBodyTemplate": "Overwrite current body content with template?", + "loadProjectsFailed": "Failed to load projects", + "restoreTitle": "Restore as tentative and re-index into board", + "restore": "Restore", + "deprecateTitle": "Mark as deprecated", + "deprecate": "Deprecate", + "editTitle": "Edit fields", + "viewBodyTitle": "View full body", + "details": "Details", + "deleteForeverTitle": "Delete permanently", + "noProjects": "No projects", + "noMatchingProjects": "No matching projects", + "pinned": "Pinned", + "archived": "Archived", + "statsFacts": "{{count}} facts", + "statsVulns": "{{count}} vulnerabilities", + "statsConversations": "{{count}} conversations", + "statsSparse": "{{count}} incomplete", + "projectNotFound": "Project not found", + "updatedPrefix": "Updated {{time}}", + "noMatchingFacts": "No matching facts, try adjusting filters", + "noFacts": "No facts yet. Click Add fact or let Agent write facts automatically", + "relatedVulnIdTitle": "Related vulnerability ID", + "noBoundConversations": "No bound conversations yet; select this project in chat to bind", + "untitledConversation": "Untitled conversation", + "open": "Open", + "unbindProjectTitle": "Unbind project", + "unbind": "Unbind", + "confirmUnbindConversation": "Unbind this conversation from current project?", + "unbindFailed": "Unbind failed", + "factMetaCategory": "Category: {{value}}", + "factMetaConfidence": "Confidence: {{value}}", + "factMetaUpdated": "Updated: {{time}}", + "factMetaRelatedVuln": "Related vulnerability: {{value}}", + "factMetaSourceConversation": "Source conversation: {{value}}", + "factMetaHasPrevious": "Has previous version", + "emptyBody": "(empty body)", + "factSparseWarn": "This fact belongs to attack-chain/exploit category, but body lacks reproducible structure (steps, HTTP/command, request/response, etc.). Edit and complete it for audit reproduction.", + "factPreviousMeta": "Archived at {{time}} · Summary: {{summary}} · Confidence: {{confidence}}", + "loadVulnerabilityListFailed": "Failed to load vulnerability list", + "noVulnerabilitiesInProject": "No vulnerabilities in this project yet. Create one first or let Agent record it.", + "promptLinkFactToVuln": "Enter index to link fact \"{{factKey}}\":\n\n{{lines}}", + "invalidIndex": "Invalid index", + "linkFailed": "Link failed", + "linkSuccess": "Linked vulnerability", + "promptConversationIdForVulnCreate": "Conversation ID is required to create vulnerability (can be source conversation):", + "cancelledNoConversationId": "Cancelled: conversation_id not provided", + "createVulnerabilityFailed": "Failed to create vulnerability", + "createVulnerabilityAndLinkSuccess": "Created vulnerability and linked: {{value}}", + "confirmDeprecateFact": "Mark fact {{factKey}} as deprecated?", + "operationFailed": "Operation failed", + "confirmRestoreFact": "Restore fact {{factKey}}? It will re-enter board index with tentative status.", + "noVulnerabilityRecords": "No vulnerability records in this project", + "viewRelatedFactsTitle": "View related facts", + "facts": "Facts", + "loadRelatedFactsFailed": "Failed to load related facts", + "noFactsForVulnerability": "This vulnerability has no related facts yet. Link vulnerability or generate vulnerability draft from fact detail.", + "promptChooseFactByIndex": "This vulnerability is linked to {{count}} facts. Enter index to view:\n{{lines}}", + "enterProjectName": "Please enter project name", + "saveFailed": "Save failed", + "invalidJson": "Invalid JSON format", + "scopeNoteAuthorizedWebOnly": "Authorized for Web application layer testing only", + "invalidScopeJson": "Invalid scope JSON, please fix it first or click Format", + "saved": "Saved", + "confirmArchiveProject": "After archiving, this project is hidden from active list by default. Continue?", + "confirmRestoreProjectActive": "Restore to active?", + "confirmDeleteProject": "Delete this project? Facts will be deleted and conversations unbound.", + "deleteFailed": "Delete failed", + "addFact": "Add fact", + "saveFact": "Save fact", + "editFact": "Edit fact", + "saveChanges": "Save changes", + "customCategoryOption": "{{value}} (custom)", + "selectProjectFirst": "Please select a project first", + "loadFactFailed": "Failed to load fact", + "factKeySummaryRequired": "fact_key and summary are required", + "confirmSaveSparseFact": "This fact is attack-chain/exploit related, but body does not contain reproducible structure (steps, HTTP/command, request/response).\nSave anyway? It's recommended to insert attack-chain template and fill POC first.", + "confirmDeleteFact": "Delete this fact?", + "notUpdatedYet": "Not updated yet", + "clearStaleProjectBindingFailed": "Failed to clear stale project binding", + "noProjectDescription": "No project binding", + "noProjectsClickCreate": "No projects yet, click New project below", + "sharedFactBoard": "Shared fact board", + "loadFailedRetry": "Load failed, please retry later", + "projectBound": "Project bound", + "projectUnbound": "Project unbound", + "updateProjectBindingFailed": "Failed to update project binding", + "basicInfoTitle": "Basic information", + "basicInfoHint": "Name and description are shown in project details", + "settingsIntroTitle": "Project settings", + "settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.", + "pinProject": "Pin project (show first in list)", + "editDescriptionPlaceholder": "Targets, authorization scope, contacts, notes…", + "scopeTitle": "Test scope", + "scopeHint": "JSON format for Agent authorization boundary and target assets", + "formatJson": "Format", + "example": "Example", + "scopeJsonLabel": "Scope JSON", + "scopeFootnote": "Supports targets, exclude, notes and more. Empty means no scope limit.", + "dangerZoneTitle": "Danger zone", + "dangerZoneHint": "Archived projects are hidden unless 'Show archived' is enabled; deletion removes all facts permanently.", + "archiveRestore": "Archive / Restore", + "deleteProject": "Delete project", + "saveChangesHint": "Click save to sync changes to server", + "saveSettings": "Save changes", + "factModalSubtitle": "Summary is indexed on board; body stores attack chain and POC for audit reproduction (separate from vulnerability records).", + "relatedVulnIdLabel": "Related vulnerability ID", + "optional": "Optional", + "factDetails": "Fact details", + "previousVersion": "Previous version", + "currentVersion": "Current version", + "linkVulnerability": "Link vulnerability", + "createVulnerabilityDraft": "Create vulnerability draft", + "generatedFromFact": "Generated from project fact {{factKey}}" + }, "chat": { "newChat": "New chat", "toggleConversationPanel": "Collapse/expand conversation list", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 5f39fd01..8545bbd1 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -58,6 +58,7 @@ "chat": "对话", "infoCollect": "信息收集", "tasks": "任务管理", + "projects": "项目管理", "vulnerabilities": "漏洞管理", "webshell": "WebShell管理", "chatFiles": "文件管理", @@ -211,6 +212,179 @@ "noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里", "startScanBtn": "前往对话发起扫描" }, + "projects": { + "title": "项目管理", + "showArchived": "显示已归档", + "newProjectCta": "+ 新建项目", + "projectList": "项目列表", + "searchProjectsPlaceholder": "搜索项目…", + "selectOrCreateTitle": "选择或创建项目", + "selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。", + "createFirstProject": "创建第一个项目", + "defaultProjectName": "项目", + "statusActive": "进行中", + "statusArchived": "已归档", + "vulnerabilityManagement": "漏洞管理", + "addFactCta": "+ 添加事实", + "tabFacts": "事实黑板", + "tabConversations": "关联对话", + "tabVulns": "关联漏洞", + "tabSettings": "设置", + "factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 body,Agent 通过 get_project_fact 复现", + "searchFactsSr": "搜索事实", + "searchFactsPlaceholder": "搜索 key、摘要、body…", + "category": "分类", + "all": "全部", + "confidence": "置信度", + "confidenceConfirmed": "已确认", + "confidenceTentative": "待确认", + "confidenceDeprecated": "已废弃", + "displayOptions": "显示选项", + "sparseOnly": "仅待补全", + "hideDeprecated": "隐藏废弃", + "summary": "摘要", + "updated": "更新", + "boundConversationsHint": "绑定到本项目的对话;点击可打开会话", + "titleLabel": "标题", + "projectVulnSummaryHint": "本项目下记录的漏洞汇总", + "viewInVulnerabilityManagement": "在漏洞管理中查看", + "severity": "严重度", + "status": "状态", + "modalNewTitle": "新建项目", + "modalNewSubtitle": "创建后可绑定对话,跨会话共享事实黑板", + "projectName": "项目名称", + "projectNamePlaceholder": "例如:某客户 Web 渗透", + "projectDescription": "项目描述", + "projectDescriptionPlaceholder": "测试范围、授权边界、注意事项…", + "createProject": "创建项目", + "newProject": "新建项目", + "chatSelectorButton": "绑定项目后共享事实黑板(跨对话)", + "selectProject": "选择项目", + "noProject": "无项目", + "factBodyEnvTitle": "环境类事实", + "factBodyHasDetail": "有详情", + "factBodySparseTitle": "缺少攻击链/POC 结构", + "factBodySparse": "待补全", + "factBodyReproducibleTitle": "含可复现结构", + "factBodyReproducible": "可复现", + "factHintAttackSparse": "⚠ 攻击链类事实:请填写完整 body(步骤、HTTP/命令、响应现象),勿仅写结论。可点「插入攻击链模板」。", + "factHintAttackReady": "攻击链类:body 将用于审计复现,请保留原始请求/响应与逐步步骤。", + "factHintEnv": "环境认知类:body 建议记录来源证据;发现/利用请改用 finding|chain|exploit|poc 分类。", + "confirmOverwriteBodyTemplate": "将覆盖当前 body 内容为模板,是否继续?", + "loadProjectsFailed": "加载项目失败", + "restoreTitle": "恢复为待确认并重新进入黑板索引", + "restore": "恢复", + "deprecateTitle": "标记为已废弃", + "deprecate": "废弃", + "editTitle": "编辑各字段", + "viewBodyTitle": "查看完整 body", + "details": "详情", + "deleteForeverTitle": "永久删除", + "noProjects": "暂无项目", + "noMatchingProjects": "无匹配项目", + "pinned": "置顶", + "archived": "归档", + "statsFacts": "{{count}} 条事实", + "statsVulns": "{{count}} 个漏洞", + "statsConversations": "{{count}} 个对话", + "statsSparse": "{{count}} 待补全", + "projectNotFound": "项目不存在", + "updatedPrefix": "更新于 {{time}}", + "noMatchingFacts": "无匹配事实,请调整筛选条件", + "noFacts": "暂无事实,点击「添加事实」或由 Agent 自动写入", + "relatedVulnIdTitle": "关联漏洞 ID", + "noBoundConversations": "暂无绑定对话;在对话页选择本项目即可关联", + "untitledConversation": "未命名对话", + "open": "打开", + "unbindProjectTitle": "解除项目绑定", + "unbind": "解绑", + "confirmUnbindConversation": "解除该对话与当前项目的绑定?", + "unbindFailed": "解绑失败", + "factMetaCategory": "分类: {{value}}", + "factMetaConfidence": "置信度: {{value}}", + "factMetaUpdated": "更新: {{time}}", + "factMetaRelatedVuln": "关联漏洞: {{value}}", + "factMetaSourceConversation": "来源对话: {{value}}", + "factMetaHasPrevious": "含上一版本", + "emptyBody": "(无 body)", + "factSparseWarn": "⚠ 该事实属于攻击链/利用类,但 body 缺少可复现结构(攻击链步骤、HTTP/命令、请求响应等)。建议编辑后补全以便审计复现。", + "factPreviousMeta": "归档于 {{time}} · 摘要: {{summary}} · 置信度: {{confidence}}", + "loadVulnerabilityListFailed": "加载漏洞列表失败", + "noVulnerabilitiesInProject": "本项目暂无漏洞,请先创建或让 Agent 记录漏洞", + "promptLinkFactToVuln": "输入序号以关联事实「{{factKey}}」:\n\n{{lines}}", + "invalidIndex": "序号无效", + "linkFailed": "关联失败", + "linkSuccess": "已关联漏洞", + "promptConversationIdForVulnCreate": "创建漏洞需要对话 ID(可与来源会话一致):", + "cancelledNoConversationId": "已取消:未提供 conversation_id", + "createVulnerabilityFailed": "创建漏洞失败", + "createVulnerabilityAndLinkSuccess": "已创建漏洞并关联:{{value}}", + "confirmDeprecateFact": "将事实 {{factKey}} 标记为已废弃?", + "operationFailed": "操作失败", + "confirmRestoreFact": "恢复事实 {{factKey}}?将重新进入黑板索引(状态:待确认)。", + "noVulnerabilityRecords": "本项目暂无漏洞记录", + "viewRelatedFactsTitle": "查看关联事实", + "facts": "事实", + "loadRelatedFactsFailed": "加载关联事实失败", + "noFactsForVulnerability": "该漏洞暂无关联事实,可在事实详情中「关联漏洞」或「生成漏洞草稿」建立链接", + "promptChooseFactByIndex": "该漏洞关联 {{count}} 条事实,输入序号查看:\n{{lines}}", + "enterProjectName": "请输入项目名称", + "saveFailed": "保存失败", + "invalidJson": "JSON 格式无效", + "scopeNoteAuthorizedWebOnly": "仅授权 Web 应用层测试", + "invalidScopeJson": "测试范围 JSON 无效,请先修正或点击「格式化」", + "saved": "已保存", + "confirmArchiveProject": "归档后默认不再出现在活跃列表,是否继续?", + "confirmRestoreProjectActive": "恢复为 active?", + "confirmDeleteProject": "确定删除该项目?事实将一并删除,对话将解除绑定。", + "deleteFailed": "删除失败", + "addFact": "添加事实", + "saveFact": "保存事实", + "editFact": "编辑事实", + "saveChanges": "保存修改", + "customCategoryOption": "{{value}}(自定义)", + "selectProjectFirst": "请先选择项目", + "loadFactFailed": "加载事实失败", + "factKeySummaryRequired": "fact_key 与 summary 必填", + "confirmSaveSparseFact": "该事实属于攻击链/利用类,但 body 尚未包含可复现结构(步骤、HTTP/命令、请求响应等)。\n仍要保存吗?建议先插入攻击链模板并填写 POC。", + "confirmDeleteFact": "删除该事实?", + "notUpdatedYet": "尚未更新", + "clearStaleProjectBindingFailed": "清除失效的项目绑定失败", + "noProjectDescription": "不绑定项目黑板", + "noProjectsClickCreate": "暂无项目,点击下方「新建项目」", + "sharedFactBoard": "共享事实黑板", + "loadFailedRetry": "加载失败,请稍后重试", + "projectBound": "已绑定项目", + "projectUnbound": "已解除项目绑定", + "updateProjectBindingFailed": "更新项目绑定失败", + "basicInfoTitle": "基本信息", + "basicInfoHint": "名称与描述会显示在项目详情中", + "settingsIntroTitle": "项目设置", + "settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。", + "pinProject": "置顶项目(列表优先显示)", + "editDescriptionPlaceholder": "测试目标、授权范围、联系人、注意事项…", + "scopeTitle": "测试范围", + "scopeHint": "JSON 格式,供 Agent 理解授权边界与目标资产", + "formatJson": "格式化", + "example": "示例", + "scopeJsonLabel": "范围 JSON", + "scopeFootnote": "支持 targets、exclude、notes 等字段,留空表示不限制范围。", + "dangerZoneTitle": "危险操作", + "dangerZoneHint": "归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。", + "archiveRestore": "归档 / 恢复", + "deleteProject": "删除项目", + "saveChangesHint": "修改后请点击保存以同步到服务器", + "saveSettings": "保存更改", + "factModalSubtitle": "摘要注入黑板索引;body 沉淀攻击链与 POC,供审计复现(与漏洞记录分工)", + "relatedVulnIdLabel": "关联漏洞 ID", + "optional": "可选", + "factDetails": "事实详情", + "previousVersion": "上一版本", + "currentVersion": "当前版本", + "linkVulnerability": "关联漏洞", + "createVulnerabilityDraft": "生成漏洞草稿", + "generatedFromFact": "由项目事实 {{factKey}} 生成" + }, "chat": { "newChat": "新对话", "toggleConversationPanel": "折叠/展开对话列表", diff --git a/web/static/js/projects.js b/web/static/js/projects.js index 533e6657..600e3ce3 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -11,6 +11,17 @@ let _projectsFetchPromise = null; const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId'; +function tp(key, opts) { + if (typeof window.t === 'function') return window.t(key, opts); + return key; +} + +function tpFmt(key, fallback, opts) { + const text = tp(key, opts); + if (!text || text === key) return fallback; + return text; +} + /** 与后端 internal/project/fact_template.go 对齐 */ const FACT_ATTACK_CHAIN_BODY_TEMPLATE = `## 结论(可验证,一句话) <勿仅写「存在漏洞」;写明类型 + 位置 + 触发条件> @@ -98,12 +109,12 @@ function isSparseFactBody(category, factKey, body) { function formatFactBodyBadge(f) { if (!requiresAttackChainFact(f.category, f.fact_key)) { const hasBody = !!(f.body || '').trim(); - return `${hasBody ? '有详情' : '—'}`; + return `${hasBody ? escapeHtml(tp('projects.factBodyHasDetail')) : '—'}`; } if (isSparseFactBody(f.category, f.fact_key, f.body)) { - return '待补全'; + return `${escapeHtml(tp('projects.factBodySparse'))}`; } - return '可复现'; + return `${escapeHtml(tp('projects.factBodyReproducible'))}`; } function updateFactFormHints() { @@ -115,11 +126,11 @@ function updateFactFormHints() { if (requiresAttackChainFact(cat, key)) { const sparse = isSparseFactBody(cat, key, body); hint.textContent = sparse - ? '⚠ 攻击链类事实:请填写完整 body(步骤、HTTP/命令、响应现象),勿仅写结论。可点「插入攻击链模板」。' - : '攻击链类:body 将用于审计复现,请保留原始请求/响应与逐步步骤。'; + ? tp('projects.factHintAttackSparse') + : tp('projects.factHintAttackReady'); hint.classList.toggle('projects-field-hint--warn', sparse); } else { - hint.textContent = '环境认知类:body 建议记录来源证据;发现/利用请改用 finding|chain|exploit|poc 分类。'; + hint.textContent = tp('projects.factHintEnv'); hint.classList.remove('projects-field-hint--warn'); } } @@ -128,7 +139,7 @@ function insertFactBodyTemplate(kind) { const ta = document.getElementById('fact-modal-body'); if (!ta) return; const tpl = kind === 'env' ? FACT_ENV_BODY_TEMPLATE : FACT_ATTACK_CHAIN_BODY_TEMPLATE; - if (ta.value.trim() && !confirm('将覆盖当前 body 内容为模板,是否继续?')) return; + if (ta.value.trim() && !confirm(tp('projects.confirmOverwriteBodyTemplate'))) return; ta.value = tpl; updateFactFormHints(); ta.focus(); @@ -160,7 +171,7 @@ async function fetchProjectsList(includeArchived) { const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked; const url = showArchived ? '/api/projects?limit=200' : '/api/projects?status=active&limit=200'; const res = await apiFetch(url); - if (!res.ok) throw new Error('加载项目失败'); + if (!res.ok) throw new Error(tp('projects.loadProjectsFailed')); const data = await res.json(); projectsCache = Array.isArray(data) ? data : []; rebuildProjectNameMap(projectsCache); @@ -295,12 +306,12 @@ function formatConfidenceBadge(confidence) { let label = c || '—'; if (c === 'confirmed') { cls = 'projects-confidence--confirmed'; - label = '已确认'; + label = tp('projects.confidenceConfirmed'); } else if (c === 'deprecated') { cls = 'projects-confidence--deprecated'; - label = '已废弃'; + label = tp('projects.confidenceDeprecated'); } else if (c === 'tentative') { - label = '待确认'; + label = tp('projects.confidenceTentative'); } return `${escapeHtml(label)}`; } @@ -308,13 +319,13 @@ function formatConfidenceBadge(confidence) { function renderProjectFactActions(keyEsc, idEsc, confidence) { const isDeprecated = (confidence || '').toLowerCase() === 'deprecated'; const toggleBtn = isDeprecated - ? `` - : ``; + ? `` + : ``; return `
- - + + ${toggleBtn} - +
`; } @@ -342,12 +353,12 @@ function renderProjectsSidebar() { : projectsCache; if (!projectsCache.length) { el.innerHTML = - '
暂无项目
'; + `
${escapeHtml(tp('projects.noProjects'))}
`; updateProjectsDetailVisibility(); return; } if (!list.length) { - el.innerHTML = '
无匹配项目
'; + el.innerHTML = `
${escapeHtml(tp('projects.noMatchingProjects'))}
`; updateProjectsDetailVisibility(); return; } @@ -355,8 +366,8 @@ function renderProjectsSidebar() { const active = p.id === currentProjectId ? ' is-active' : ''; const archived = p.status === 'archived' ? ' is-archived' : ''; const badges = [ - p.pinned ? '置顶' : '', - p.status === 'archived' ? '归档' : '', + p.pinned ? `${escapeHtml(tp('projects.pinned'))}` : '', + p.status === 'archived' ? `${escapeHtml(tp('projects.archived'))}` : '', ].join(''); return `
@@ -372,7 +383,7 @@ function updateProjectStatusPill(status) { const el = document.getElementById('projects-detail-status'); if (!el) return; const archived = status === 'archived'; - el.textContent = archived ? '已归档' : '进行中'; + el.textContent = archived ? tp('projects.statusArchived') : tp('projects.statusActive'); el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active'); } @@ -386,13 +397,13 @@ function updateProjectStats(stats) { const vc = s.vuln_count ?? s.vulnCount ?? 0; const cc = s.conversation_count ?? s.conversationCount ?? 0; const sc = s.sparse_fact_count ?? s.sparseFactCount ?? 0; - if (f) f.textContent = `${fc} 条事实`; - if (v) v.textContent = `${vc} 个漏洞`; - if (c) c.textContent = `${cc} 个对话`; + if (f) f.textContent = tpFmt('projects.statsFacts', `${fc} facts`, { count: fc }); + if (v) v.textContent = tpFmt('projects.statsVulns', `${vc} vulnerabilities`, { count: vc }); + if (c) c.textContent = tpFmt('projects.statsConversations', `${cc} conversations`, { count: cc }); if (sparse) { if (sc > 0) { sparse.hidden = false; - sparse.textContent = `${sc} 待补全`; + sparse.textContent = tpFmt('projects.statsSparse', `${sc} to complete`, { count: sc }); } else { sparse.hidden = true; } @@ -413,10 +424,10 @@ async function selectProject(id) { updateProjectsDetailVisibility(); try { const res = await apiFetch(`/api/projects/${id}`); - if (!res.ok) throw new Error('项目不存在'); + if (!res.ok) throw new Error(tp('projects.projectNotFound')); const p = await res.json(); const titleEl = document.getElementById('projects-detail-title'); - if (titleEl) titleEl.textContent = p.name || '项目'; + if (titleEl) titleEl.textContent = p.name || tp('projects.defaultProjectName'); document.getElementById('project-edit-name').value = p.name || ''; document.getElementById('project-edit-description').value = p.description || ''; document.getElementById('project-edit-scope').value = p.scope_json || ''; @@ -426,7 +437,7 @@ async function selectProject(id) { if (pinEl) pinEl.checked = !!p.pinned; updateProjectStatusPill(p.status || 'active'); const metaEl = document.getElementById('projects-detail-meta'); - if (metaEl) metaEl.textContent = `更新于 ${formatProjectTime(p.updated_at)}`; + if (metaEl) metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${formatProjectTime(p.updated_at)}`, { time: formatProjectTime(p.updated_at) }); const descEl = document.getElementById('projects-detail-desc'); if (descEl) { const desc = (p.description || '').trim(); @@ -486,11 +497,11 @@ function debouncedLoadProjectFacts() { async function loadProjectFacts() { const tbody = document.getElementById('project-facts-tbody'); if (!tbody || !currentProjectId) return; - tbody.innerHTML = '加载中…'; + tbody.innerHTML = `${escapeHtml(tp('common.loading'))}`; const qs = buildProjectFactsQueryParams().toString(); const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`); if (!res.ok) { - tbody.innerHTML = '加载失败'; + tbody.innerHTML = `${escapeHtml(tp('common.loadFailed'))}`; return; } const facts = await res.json(); @@ -501,7 +512,7 @@ async function loadProjectFacts() { document.getElementById('project-facts-filter-confidence')?.value || document.getElementById('project-facts-filter-sparse')?.checked; tbody.innerHTML = `${ - hasFilter ? '无匹配事实,请调整筛选条件' : '暂无事实,点击「添加事实」或由 Agent 自动写入' + hasFilter ? tp('projects.noMatchingFacts') : tp('projects.noFacts') }`; refreshProjectHeaderStats(); return; @@ -510,7 +521,7 @@ async function loadProjectFacts() { const keyEsc = escapeHtml(f.fact_key); const idEsc = escapeHtml(f.id); const vulnLink = f.related_vulnerability_id - ? `${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…` + ? `${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…` : ''; return ` ${keyEsc}${vulnLink} @@ -540,32 +551,31 @@ async function refreshProjectHeaderStats() { async function loadProjectConversations() { const tbody = document.getElementById('project-conversations-tbody'); if (!tbody || !currentProjectId) return; - tbody.innerHTML = '加载中…'; + tbody.innerHTML = `${escapeHtml(tp('common.loading'))}`; const res = await apiFetch(`/api/projects/${currentProjectId}/conversations?limit=100`); if (!res.ok) { - tbody.innerHTML = '加载失败'; + tbody.innerHTML = `${escapeHtml(tp('common.loadFailed'))}`; return; } const data = await res.json(); const items = data.conversations || []; if (!items.length) { - tbody.innerHTML = - '暂无绑定对话;在对话页选择本项目即可关联'; + tbody.innerHTML = `${escapeHtml(tp('projects.noBoundConversations'))}`; return; } tbody.innerHTML = items .map((conv) => { const id = conv.id; const idEsc = escapeHtml(id); - const title = escapeHtml(conv.title || '未命名对话'); + const title = escapeHtml(conv.title || tp('projects.untitledConversation')); const updated = formatProjectTime(conv.updatedAt || conv.updated_at, conv.createdAt || conv.created_at); return ` ${title} ${escapeHtml(updated)}
- - + +
`; @@ -586,13 +596,13 @@ function openProjectConversation(conversationId) { } async function unbindConversationFromProject(conversationId) { - if (!conversationId || !confirm('解除该对话与当前项目的绑定?')) return; + if (!conversationId || !confirm(tp('projects.confirmUnbindConversation'))) return; const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: '' }), }); - if (!res.ok) return alert('解绑失败'); + if (!res.ok) return alert(tp('projects.unbindFailed')); loadProjectConversations(); refreshProjectHeaderStats(); } @@ -603,27 +613,28 @@ let _projectFactsFilterDebounce = null; async function viewProjectFactBody(factKey) { const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`); - if (!res.ok) return alert('加载失败'); + if (!res.ok) return alert(tp('common.loadFailed')); const f = await res.json(); _factDetailKey = f.fact_key; _factDetailFact = f; document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`; const metaParts = [ - `分类: ${f.category}`, - `置信度: ${f.confidence}`, - `更新: ${formatProjectTime(f.updated_at, f.created_at)}`, + tpFmt('projects.factMetaCategory', `Category: ${f.category}`, { value: f.category }), + tpFmt('projects.factMetaConfidence', `Confidence: ${f.confidence}`, { value: f.confidence }), + tpFmt('projects.factMetaUpdated', `Updated: ${formatProjectTime(f.updated_at, f.created_at)}`, { + time: formatProjectTime(f.updated_at, f.created_at), + }), ]; - if (f.related_vulnerability_id) metaParts.push(`关联漏洞: ${f.related_vulnerability_id}`); - if (f.source_conversation_id) metaParts.push(`来源对话: ${f.source_conversation_id}`); - if (f.supersedes_fact_id) metaParts.push('含上一版本'); + if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id })); + if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id })); + if (f.supersedes_fact_id) metaParts.push(tp('projects.factMetaHasPrevious')); document.getElementById('fact-detail-meta').textContent = metaParts.join(' · '); - document.getElementById('fact-detail-body').textContent = f.body || '(无 body)'; + document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody'); const warnEl = document.getElementById('fact-detail-sparse-warn'); if (warnEl) { if (isSparseFactBody(f.category, f.fact_key, f.body)) { warnEl.hidden = false; - warnEl.textContent = - '⚠ 该事实属于攻击链/利用类,但 body 缺少可复现结构(攻击链步骤、HTTP/命令、请求响应等)。建议编辑后补全以便审计复现。'; + warnEl.textContent = tp('projects.factSparseWarn'); } else { warnEl.hidden = true; warnEl.textContent = ''; @@ -640,9 +651,16 @@ async function viewProjectFactBody(factKey) { if (prevRes.ok) { const prev = await prevRes.json(); prevWrap.hidden = false; - document.getElementById('fact-detail-prev-meta').textContent = - `归档于 ${formatProjectTime(prev.archived_at)} · 摘要: ${prev.summary || '—'} · 置信度: ${prev.confidence || '—'}`; - document.getElementById('fact-detail-prev-body').textContent = prev.body || '(无 body)'; + document.getElementById('fact-detail-prev-meta').textContent = tpFmt( + 'projects.factPreviousMeta', + `Archived at ${formatProjectTime(prev.archived_at)} · Summary: ${prev.summary || '—'} · Confidence: ${prev.confidence || '—'}`, + { + time: formatProjectTime(prev.archived_at), + summary: prev.summary || '—', + confidence: prev.confidence || '—', + }, + ); + document.getElementById('fact-detail-prev-body').textContent = prev.body || tp('projects.emptyBody'); } } catch (e) { console.warn(e); @@ -672,15 +690,15 @@ async function linkFactToExistingVulnerability() { const f = _factDetailFact; if (!f || !currentProjectId) return; const res = await apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=50`); - if (!res.ok) return alert('加载漏洞列表失败'); + if (!res.ok) return alert(tp('projects.loadVulnerabilityListFailed')); const data = await res.json(); const items = data.Vulnerabilities || data.vulnerabilities || data.items || []; - if (!items.length) return alert('本项目暂无漏洞,请先创建或让 Agent 记录漏洞'); + if (!items.length) return alert(tp('projects.noVulnerabilitiesInProject')); const lines = items.map((v, i) => `${i + 1}. [${v.severity}] ${v.title} (${v.id})`); - const pick = prompt(`输入序号以关联事实「${f.fact_key}」:\n\n${lines.join('\n')}`); + const pick = prompt(tpFmt('projects.promptLinkFactToVuln', `Enter index to link fact "${f.fact_key}":\n\n${lines.join('\n')}`, { factKey: f.fact_key, lines: lines.join('\n') })); if (pick == null || pick === '') return; const idx = parseInt(pick, 10) - 1; - if (Number.isNaN(idx) || idx < 0 || idx >= items.length) return alert('序号无效'); + if (Number.isNaN(idx) || idx < 0 || idx >= items.length) return alert(tp('projects.invalidIndex')); const vulnId = items[idx].id; const upd = await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, { method: 'PUT', @@ -694,8 +712,8 @@ async function linkFactToExistingVulnerability() { related_vulnerability_id: vulnId, }), }); - if (!upd.ok) return alert('关联失败'); - alert('已关联漏洞'); + if (!upd.ok) return alert(tp('projects.linkFailed')); + alert(tp('projects.linkSuccess')); closeFactDetailModal(); loadProjectFacts(); } @@ -707,15 +725,15 @@ async function createVulnerabilityFromCurrentFact() { (f.source_conversation_id || '').trim() || (typeof window.currentConversationId === 'string' ? window.currentConversationId.trim() : ''); if (!convId) { - convId = prompt('创建漏洞需要对话 ID(可与来源会话一致):', '')?.trim() || ''; + convId = prompt(tp('projects.promptConversationIdForVulnCreate'), '')?.trim() || ''; } - if (!convId) return alert('已取消:未提供 conversation_id'); + if (!convId) return alert(tp('projects.cancelledNoConversationId')); const severity = inferSeverityFromFact(f); const body = { conversation_id: convId, project_id: currentProjectId, title: (f.summary || f.fact_key).slice(0, 200), - description: `由项目事实 ${f.fact_key} 生成`, + description: tpFmt('projects.generatedFromFact', `Generated from project fact ${f.fact_key}`, { factKey: f.fact_key }), severity, status: 'open', type: f.category || 'finding', @@ -731,7 +749,7 @@ async function createVulnerabilityFromCurrentFact() { }); if (!res.ok) { const err = await res.json().catch(() => ({})); - return alert(err.error || '创建漏洞失败'); + return alert(err.error || tp('projects.createVulnerabilityFailed')); } const vuln = await res.json(); await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, { @@ -746,7 +764,7 @@ async function createVulnerabilityFromCurrentFact() { related_vulnerability_id: vuln.id, }), }); - alert(`已创建漏洞并关联:${vuln.title || vuln.id}`); + alert(tpFmt('projects.createVulnerabilityAndLinkSuccess', `Created and linked vulnerability: ${vuln.title || vuln.id}`, { value: vuln.title || vuln.id })); closeFactDetailModal(); loadProjectFacts(); if (currentProjectTab === 'vulns') loadProjectVulnerabilities(); @@ -761,18 +779,18 @@ function inferSeverityFromFact(f) { } async function deprecateProjectFactByKey(factKey) { - if (!confirm(`将事实 ${factKey} 标记为已废弃?`)) return; + if (!confirm(tpFmt('projects.confirmDeprecateFact', `Deprecate fact ${factKey}?`, { factKey }))) return; const res = await apiFetch(`/api/projects/${currentProjectId}/facts/deprecate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fact_key: factKey }), }); - if (!res.ok) return alert('操作失败'); + if (!res.ok) return alert(tp('projects.operationFailed')); loadProjectFacts(); } async function restoreProjectFactByKey(factKey) { - if (!confirm(`恢复事实 ${factKey}?将重新进入黑板索引(状态:待确认)。`)) return; + if (!confirm(tpFmt('projects.confirmRestoreFact', `Restore fact ${factKey}? It will re-enter the board index with tentative status.`, { factKey }))) return; const res = await apiFetch(`/api/projects/${currentProjectId}/facts/restore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -780,7 +798,7 @@ async function restoreProjectFactByKey(factKey) { }); if (!res.ok) { const err = await res.json().catch(() => ({})); - return alert(err.error || '操作失败'); + return alert(err.error || tp('projects.operationFailed')); } loadProjectFacts(); } @@ -801,16 +819,16 @@ function openVulnerabilitiesForProject(projectId) { async function loadProjectVulnerabilities() { const tbody = document.getElementById('project-vulns-tbody'); if (!tbody || !currentProjectId) return; - tbody.innerHTML = '加载中…'; + tbody.innerHTML = `${escapeHtml(tp('common.loading'))}`; const res = await apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=100`); if (!res.ok) { - tbody.innerHTML = '加载失败'; + tbody.innerHTML = `${escapeHtml(tp('common.loadFailed'))}`; return; } const data = await res.json(); const items = data.Vulnerabilities || data.vulnerabilities || data.items || []; if (!items.length) { - tbody.innerHTML = '本项目暂无漏洞记录'; + tbody.innerHTML = `${escapeHtml(tp('projects.noVulnerabilityRecords'))}`; refreshProjectHeaderStats(); return; } @@ -822,8 +840,8 @@ async function loadProjectVulnerabilities() { ${escapeHtml(v.status)}
- - + +
`; @@ -853,10 +871,10 @@ async function viewFactsForVulnerability(vulnId) { if (hideDepEl) hideDepEl.checked = true; const params = new URLSearchParams({ limit: '50', related_vulnerability_id: vulnId }); const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${params}`); - if (!res.ok) return alert('加载关联事实失败'); + if (!res.ok) return alert(tp('projects.loadRelatedFactsFailed')); const facts = await res.json(); if (!facts.length) { - alert('该漏洞暂无关联事实,可在事实详情中「关联漏洞」或「生成漏洞草稿」建立链接'); + alert(tp('projects.noFactsForVulnerability')); loadProjectFacts(); return; } @@ -865,7 +883,11 @@ async function viewFactsForVulnerability(vulnId) { return; } const pick = prompt( - `该漏洞关联 ${facts.length} 条事实,输入序号查看:\n${facts.map((f, i) => `${i + 1}. ${f.fact_key}`).join('\n')}`, + tpFmt( + 'projects.promptChooseFactByIndex', + `This vulnerability is linked to ${facts.length} facts. Enter index to view:\n${facts.map((f, i) => `${i + 1}. ${f.fact_key}`).join('\n')}`, + { count: facts.length, lines: facts.map((f, i) => `${i + 1}. ${f.fact_key}`).join('\n') }, + ), ); if (pick == null || pick === '') { loadProjectFacts(); @@ -895,11 +917,11 @@ function closeProjectsOverlay(id) { } function showNewProjectModal() { - document.getElementById('project-modal-title').textContent = '新建项目'; + document.getElementById('project-modal-title').textContent = tp('projects.modalNewTitle'); const sub = document.getElementById('project-modal-subtitle'); - if (sub) sub.textContent = '创建后可绑定对话,跨会话共享事实黑板'; + if (sub) sub.textContent = tp('projects.modalNewSubtitle'); const submitBtn = document.getElementById('project-modal-submit-btn'); - if (submitBtn) submitBtn.textContent = '创建项目'; + if (submitBtn) submitBtn.textContent = tp('projects.createProject'); document.getElementById('project-modal-name').value = ''; document.getElementById('project-modal-description').value = ''; window._projectModalEditId = null; @@ -915,7 +937,7 @@ function showNewProjectModalFromChat() { async function saveProjectModal() { const name = document.getElementById('project-modal-name').value.trim(); - if (!name) return alert('请输入项目名称'); + if (!name) return alert(tp('projects.enterProjectName')); const body = { name, description: document.getElementById('project-modal-description').value.trim(), @@ -926,7 +948,7 @@ async function saveProjectModal() { : await apiFetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) { const err = await res.json().catch(() => ({})); - alert(err.error || '保存失败'); + alert(err.error || tp('projects.saveFailed')); return; } const fromChat = !!window._projectModalFromChat; @@ -956,7 +978,7 @@ function formatProjectScopeJson() { try { el.value = JSON.stringify(JSON.parse(raw), null, 2); } catch (e) { - alert('JSON 格式无效:' + (e.message || String(e))); + alert(tp('projects.invalidJson') + ': ' + (e.message || String(e))); } } @@ -966,7 +988,7 @@ function insertProjectScopeExample() { const example = { targets: ['https://example.com'], exclude: ['*.cdn.example.com'], - notes: '仅授权 Web 应用层测试', + notes: tp('projects.scopeNoteAuthorizedWebOnly'), }; el.value = JSON.stringify(example, null, 2); el.focus(); @@ -979,7 +1001,7 @@ async function saveProjectSettings() { try { JSON.parse(scopeRaw); } catch (e) { - alert('测试范围 JSON 无效,请先修正或点击「格式化」:' + (e.message || String(e))); + alert(tp('projects.invalidScopeJson') + ': ' + (e.message || String(e))); return; } } @@ -995,10 +1017,10 @@ async function saveProjectSettings() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); - if (!res.ok) return alert('保存失败'); + if (!res.ok) return alert(tp('projects.saveFailed')); await loadProjectsList(); await selectProject(currentProjectId); - alert('已保存'); + alert(tp('projects.saved')); } async function archiveCurrentProject() { @@ -1006,23 +1028,23 @@ async function archiveCurrentProject() { const statusEl = document.getElementById('project-edit-status'); const cur = statusEl?.value || 'active'; const next = cur === 'archived' ? 'active' : 'archived'; - if (!confirm(next === 'archived' ? '归档后默认不再出现在活跃列表,是否继续?' : '恢复为 active?')) return; + if (!confirm(next === 'archived' ? tp('projects.confirmArchiveProject') : tp('projects.confirmRestoreProjectActive'))) return; const res = await apiFetch(`/api/projects/${currentProjectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: next }), }); - if (!res.ok) return alert('操作失败'); + if (!res.ok) return alert(tp('projects.operationFailed')); await loadProjectsList(); await selectProject(currentProjectId); } async function deleteCurrentProject() { - if (!currentProjectId || !confirm('确定删除该项目?事实将一并删除,对话将解除绑定。')) return; + if (!currentProjectId || !confirm(tp('projects.confirmDeleteProject'))) return; const deletedId = currentProjectId; const deletedIndex = projectsCache.findIndex((p) => p.id === deletedId); const res = await apiFetch(`/api/projects/${deletedId}`, { method: 'DELETE' }); - if (!res.ok) return alert('删除失败'); + if (!res.ok) return alert(tp('projects.deleteFailed')); if (getActiveProjectId() === deletedId) setActiveProjectId(''); currentProjectId = null; await loadProjectsList(); @@ -1038,8 +1060,8 @@ function resetFactModalForm() { window._factModalEditId = null; const keyEl = document.getElementById('fact-modal-key'); if (keyEl) keyEl.disabled = false; - document.getElementById('fact-modal-title').textContent = '添加事实'; - document.getElementById('fact-modal-submit-btn').textContent = '保存事实'; + document.getElementById('fact-modal-title').textContent = tp('projects.addFact'); + document.getElementById('fact-modal-submit-btn').textContent = tp('projects.saveFact'); document.getElementById('fact-modal-key').value = ''; document.getElementById('fact-modal-category').value = 'note'; document.getElementById('fact-modal-summary').value = ''; @@ -1052,8 +1074,8 @@ function resetFactModalForm() { function fillFactModalForm(f) { window._factModalEditId = f.id; - document.getElementById('fact-modal-title').textContent = '编辑事实'; - document.getElementById('fact-modal-submit-btn').textContent = '保存修改'; + document.getElementById('fact-modal-title').textContent = tp('projects.editFact'); + document.getElementById('fact-modal-submit-btn').textContent = tp('projects.saveChanges'); document.getElementById('fact-modal-key').value = f.fact_key || ''; const catEl = document.getElementById('fact-modal-category'); const cat = (f.category || 'note').trim().toLowerCase(); @@ -1063,7 +1085,7 @@ function fillFactModalForm(f) { else { const opt = document.createElement('option'); opt.value = f.category; - opt.textContent = `${f.category}(自定义)`; + opt.textContent = tpFmt('projects.customCategoryOption', `${f.category} (custom)`, { value: f.category }); catEl.appendChild(opt); catEl.value = f.category; } @@ -1082,17 +1104,17 @@ function fillFactModalForm(f) { } function showAddFactModal() { - if (!currentProjectId) return alert('请先选择项目'); + if (!currentProjectId) return alert(tp('projects.selectProjectFirst')); resetFactModalForm(); openProjectsOverlay('fact-modal'); } async function showEditFactModal(factKey) { - if (!currentProjectId) return alert('请先选择项目'); + if (!currentProjectId) return alert(tp('projects.selectProjectFirst')); const res = await apiFetch( `/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`, ); - if (!res.ok) return alert('加载事实失败'); + if (!res.ok) return alert(tp('projects.loadFactFailed')); const f = await res.json(); resetFactModalForm(); fillFactModalForm(f); @@ -1109,10 +1131,10 @@ async function saveFactModal() { const summary = document.getElementById('fact-modal-summary').value.trim(); const category = document.getElementById('fact-modal-category').value.trim() || 'note'; const body = document.getElementById('fact-modal-body').value; - if (!fact_key || !summary) return alert('fact_key 与 summary 必填'); + if (!fact_key || !summary) return alert(tp('projects.factKeySummaryRequired')); if (isSparseFactBody(category, fact_key, body)) { const ok = confirm( - '该事实属于攻击链/利用类,但 body 尚未包含可复现结构(步骤、HTTP/命令、请求响应等)。\n仍要保存吗?建议先插入攻击链模板并填写 POC。', + tp('projects.confirmSaveSparseFact'), ); if (!ok) return; } @@ -1138,14 +1160,14 @@ async function saveFactModal() { }); if (!res.ok) { const err = await res.json().catch(() => ({})); - return alert(err.error || '保存失败'); + return alert(err.error || tp('projects.saveFailed')); } closeFactModal(); loadProjectFacts(); } async function deleteProjectFact(id) { - if (!confirm('删除该事实?')) return; + if (!confirm(tp('projects.confirmDeleteFact'))) return; await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' }); loadProjectFacts(); } @@ -1188,13 +1210,13 @@ function parseProjectDate(t) { function formatProjectTime(t, fallback) { const d = parseProjectDate(t) || (fallback != null ? parseProjectDate(fallback) : null); - if (!d) return '尚未更新'; + if (!d) return tp('projects.notUpdatedYet'); const now = Date.now(); const diff = now - d.getTime(); - if (diff < 60000) return '刚刚'; - if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`; - if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`; - if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`; + if (diff < 60000) return tp('common.justNow'); + if (diff < 3600000) return tp('common.minutesAgo', { n: Math.floor(diff / 60000) }); + if (diff < 86400000) return tp('common.hoursAgo', { n: Math.floor(diff / 3600000) }); + if (diff < 604800000) return tp('common.daysAgo', { n: Math.floor(diff / 86400000) }); return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } @@ -1249,7 +1271,7 @@ async function normalizeStaleChatProjectSelection() { body: JSON.stringify({ projectId: '' }), } ); - if (!res.ok) console.warn('清除失效的项目绑定失败'); + if (!res.ok) console.warn(tp('projects.clearStaleProjectBindingFailed')); } catch (e) { console.warn(e); } @@ -1265,7 +1287,7 @@ function updateChatProjectButtonLabel() { const textEl = document.getElementById('chat-project-text'); if (!textEl) return; const id = resolveChatProjectSelection(); - textEl.textContent = id && projectNameById[id] ? projectNameById[id] : '无项目'; + textEl.textContent = id && projectNameById[id] ? projectNameById[id] : tp('projects.noProject'); } function renderChatProjectPanelList() { @@ -1273,9 +1295,9 @@ function renderChatProjectPanelList() { if (!list) return; const selected = resolveChatProjectSelection(); const activeProjects = projectsCache.filter((p) => p.status !== 'archived'); - const items = [{ id: '', name: '无项目', description: '不绑定项目黑板' }, ...activeProjects]; + const items = [{ id: '', name: tp('projects.noProject'), description: tp('projects.noProjectDescription') }, ...activeProjects]; if (!items.length) { - list.innerHTML = '
暂无项目,点击下方「新建项目」
'; + list.innerHTML = `
${escapeHtml(tp('projects.noProjectsClickCreate'))}
`; return; } list.innerHTML = ''; @@ -1284,7 +1306,7 @@ function renderChatProjectPanelList() { const isSelected = isNone ? !selected : selected === p.id; const desc = isNone ? (p.description || '') - : (p.description || '').trim().slice(0, 80) || '共享事实黑板'; + : (p.description || '').trim().slice(0, 80) || tp('projects.sharedFactBoard'); const projectId = p.id || ''; const btn = document.createElement('button'); btn.type = 'button'; @@ -1296,7 +1318,7 @@ function renderChatProjectPanelList() { btn.innerHTML = `
${isNone ? '—' : '📁'}
-
${escapeHtml(p.name || '未命名')}
+
${escapeHtml(p.name || tp('common.untitled'))}
${escapeHtml(desc)}
${isSelected ? '
' : ''} @@ -1308,12 +1330,12 @@ function renderChatProjectPanelList() { async function renderChatProjectPanel() { const list = document.getElementById('chat-project-list'); if (!list) return; - list.innerHTML = '
加载中…
'; + list.innerHTML = `
${escapeHtml(tp('common.loading'))}
`; try { await ensureProjectsLoaded(); } catch (e) { console.warn(e); - list.innerHTML = '
加载失败,请稍后重试
'; + list.innerHTML = `
${escapeHtml(tp('projects.loadFailedRetry'))}
`; return; } renderChatProjectPanelList(); @@ -1373,11 +1395,11 @@ async function applyChatProjectSelection(projectId) { } window._loadedConversationProjectId = projectId; if (typeof showNotification === 'function') { - showNotification(projectId ? '已绑定项目' : '已解除项目绑定', 'success'); + showNotification(projectId ? tp('projects.projectBound') : tp('projects.projectUnbound'), 'success'); } } catch (e) { console.error(e); - alert('更新项目绑定失败: ' + (e.message || e)); + alert(tp('projects.updateProjectBindingFailed') + ': ' + (e.message || e)); updateChatProjectButtonLabel(); return; } @@ -1411,6 +1433,19 @@ async function onChatProjectChange() { function initChatProjectSelector() { if (window._chatProjectSelectorInited) return; window._chatProjectSelectorInited = true; + if (!window._projectsLanguageListenerBound) { + window._projectsLanguageListenerBound = true; + document.addEventListener('languagechange', () => { + renderProjectsSidebar(); + updateChatProjectButtonLabel(); + const panel = document.getElementById('chat-project-panel'); + if (panel && panel.style.display === 'flex') renderChatProjectPanelList(); + if (currentProjectId) { + refreshProjectHeaderStats().catch(() => {}); + switchProjectTab(currentProjectTab || 'facts'); + } + }); + } refreshChatProjectSelector().catch(() => {}); document.addEventListener('click', (e) => { const panel = document.getElementById('chat-project-panel'); diff --git a/web/templates/index.html b/web/templates/index.html index bab5b465..8845e189 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -162,13 +162,13 @@
@@ -4115,30 +4115,30 @@
-

事实详情

+

事实详情

- +
-

当前版本

+

当前版本