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 @@
-
@@ -956,16 +956,16 @@
@@ -4048,11 +4048,11 @@
@@ -4115,30 +4115,30 @@