From 8bb58be5ff04eaf6773b46a6186209bf7f67d4cc Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 29 Mar 2026 17:12:44 +0800 Subject: [PATCH] feat: add agent governance audit surfaces --- collector-service/app/oneliner_features.py | 100 +++++++ tests/test_main_agent_governance.py | 79 ++++++ web/storyforge-web-v4/assets/app.js | 268 +++++++++++++++++- web/storyforge-web-v4/index.html | 5 + .../tests/workbench-pages.test.mjs | 19 ++ 5 files changed, 469 insertions(+), 2 deletions(-) diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py index ea31733..8d761ac 100644 --- a/collector-service/app/oneliner_features.py +++ b/collector-service/app/oneliner_features.py @@ -1480,6 +1480,49 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: assert row is not None return _policy_audit_payload(row) + def _policy_audit_record_payload(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + payload = _policy_audit_payload(row) or {} + scope_row = legacy.db.fetch_one("SELECT * FROM agent_policy_scopes WHERE id = ?", (row.get("scope_id", ""),)) if row.get("scope_id") else None + version_row = legacy.db.fetch_one("SELECT * FROM agent_policy_versions WHERE id = ?", (row.get("version_id", ""),)) if row.get("version_id") else None + scope_kind = (scope_row or {}).get("scope_kind", "") or (version_row or {}).get("scope_kind", "") + platform = (scope_row or {}).get("platform", "") or (version_row or {}).get("platform", "") + subject_user_id = (scope_row or {}).get("subject_user_id", "") or (version_row or {}).get("subject_user_id", "") + subject_project_id = (scope_row or {}).get("subject_project_id", "") or (version_row or {}).get("subject_project_id", "") + payload.update( + { + "scope_kind": scope_kind, + "subject_user_id": subject_user_id, + "subject_project_id": subject_project_id, + "platform": platform, + "platform_label": legacy.platform_label(platform) if platform else "", + "scope": _policy_scope_payload( + scope_row, + fallback_kind=scope_kind, + fallback_platform=platform, + fallback_user_id=subject_user_id, + fallback_project_id=subject_project_id, + ), + "version": _policy_version_payload(version_row), + } + ) + return payload + + def _fetch_policy_audit_records(where_sql: str, params: tuple[Any, ...], *, limit: int = 20) -> list[dict[str, Any]]: + rows = legacy.db.fetch_all( + f""" + SELECT audit.* + FROM agent_policy_audit_logs audit + JOIN agent_policy_scopes scope ON scope.id = audit.scope_id + {where_sql} + ORDER BY audit.created_at DESC + LIMIT ? + """, + params + (limit,), + ) + return [_policy_audit_record_payload(row) for row in rows if row] + def _create_policy_version( scope_row: dict[str, Any], *, @@ -4496,6 +4539,26 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: items = _list_policy_versions(scope_row) return {"items": items, "count": len(items)} + @app.get("/v2/oneliner/governance/user/audits") + def list_user_policy_audits( + project_id: str | None = Query(default=None), + platform: str = Query(default=""), + limit: int = Query(default=20, ge=1, le=100), + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, project_id or None) + normalized_platform = _normalize_policy_platform(platform) + where_clauses = [ + "WHERE scope.subject_user_id = ?", + "AND (scope.subject_project_id = ? OR scope.subject_project_id = '')", + ] + params: list[Any] = [account["id"], project["id"]] + if normalized_platform: + where_clauses.append("AND (scope.platform = '' OR scope.platform = ?)") + params.append(normalized_platform) + items = _fetch_policy_audit_records(" ".join(where_clauses), tuple(params), limit=limit) + return {"items": items, "count": len(items)} + @app.post("/v2/oneliner/governance/user/global/rollback") def rollback_user_global_policy( request: AgentPolicyRollbackRequest, @@ -4823,6 +4886,43 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: _ = admin return _governance_directory_payload() + @app.get("/v2/admin/oneliner/governance/audits") + def list_admin_governance_audits( + target_user_id: str = Query(default=""), + target_project_id: str = Query(default=""), + platform: str = Query(default=""), + include_system: bool = Query(default=False), + limit: int = Query(default=30, ge=1, le=100), + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + _ = admin + normalized_platform = _normalize_policy_platform(platform) + clauses: list[str] = [] + params: list[Any] = [] + + if target_user_id.strip(): + target_parts = ["(scope.subject_user_id = ?"] + params.append(target_user_id.strip()) + if target_project_id.strip(): + target_parts.append("AND (scope.subject_project_id = ? OR scope.subject_project_id = '')") + params.append(target_project_id.strip()) + if normalized_platform: + target_parts.append("AND (scope.platform = '' OR scope.platform = ?)") + params.append(normalized_platform) + target_parts.append(")") + clauses.append(" ".join(target_parts)) + + if include_system: + if normalized_platform: + clauses.append("(scope.scope_kind = 'system_main' OR (scope.scope_kind = 'system_platform' AND scope.platform = ?))") + params.append(normalized_platform) + else: + clauses.append("(scope.scope_kind IN ('system_main', 'system_platform'))") + + where_sql = "WHERE 1 = 1" if not clauses else "WHERE " + " OR ".join(f"({clause})" for clause in clauses) + items = _fetch_policy_audit_records(where_sql, tuple(params), limit=limit) + return {"items": items, "count": len(items)} + @app.get("/v2/admin/oneliner/governance/overrides") def get_admin_override_policy( target_user_id: str = Query(default=""), diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py index 91b7ae0..c589691 100644 --- a/tests/test_main_agent_governance.py +++ b/tests/test_main_agent_governance.py @@ -509,6 +509,85 @@ class MainAgentGovernanceTests(unittest.TestCase): self.assertEqual(versions_after.status_code, 200, versions_after.text) self.assertEqual(versions_after.json()["count"], 3) + def test_user_policy_audits_include_personal_and_admin_layers_for_project(self) -> None: + self.client.put( + "/v2/oneliner/governance/user/global", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "title": "Global strategy", + "policy": {"tone": {"style": "analytical"}}, + "reason": "personalize defaults", + }, + ) + self.client.post( + "/v2/admin/oneliner/governance/overrides", + headers=self.ctx["admin_headers"], + json={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + "title": "Admin override", + "policy": {"actions": {"max_cards": 4}}, + "reason": "contain drift", + }, + ) + + response = self.client.get( + "/v2/oneliner/governance/user/audits", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"], "platform": "douyin"}, + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertGreaterEqual(payload["count"], 2) + scope_kinds = [item["scope_kind"] for item in payload["items"]] + self.assertIn("user_global", scope_kinds) + self.assertIn("admin_override", scope_kinds) + first_item = payload["items"][0] + self.assertIn("version", first_item) + self.assertIn("scope", first_item) + + def test_admin_policy_audits_include_target_and_system_layers(self) -> None: + self.client.put( + "/v2/admin/oneliner/governance/system/main-agent", + headers=self.ctx["admin_headers"], + json={ + "title": "System main", + "policy": {"homepage": {"focus": "ops"}}, + "reason": "seed system baseline", + }, + ) + self.client.post( + "/v2/admin/oneliner/governance/overrides", + headers=self.ctx["admin_headers"], + json={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + "title": "Admin override", + "policy": {"actions": {"max_cards": 5}}, + "reason": "focus target account", + }, + ) + + response = self.client.get( + "/v2/admin/oneliner/governance/audits", + headers=self.ctx["admin_headers"], + params={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + "include_system": "1", + }, + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertGreaterEqual(payload["count"], 2) + scope_kinds = [item["scope_kind"] for item in payload["items"]] + self.assertIn("system_main", scope_kinds) + self.assertIn("admin_override", scope_kinds) + def test_non_admin_cannot_change_system_defaults(self) -> None: response = self.client.put( "/v2/admin/oneliner/governance/system/main-agent", diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index c76a1a2..df53df4 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -30,6 +30,7 @@ const appState = { dashboardOverviewTab: "project_progress", discoveryDetailTab: "overview", playbookDetailTab: "workspace", + strategyDetailTab: "effective", productionDetailTab: "queue", automationDetailTab: "health", adminWorkbenchTab: "integrations", @@ -57,11 +58,13 @@ const appState = { onelinerGovernanceEffective: null, userGlobalPolicy: null, userCurrentPlatformPolicy: null, + userPolicyAudits: [], adminSystemMainPolicy: null, adminSystemPlatformPolicies: [], adminGovernanceDirectory: null, adminOverrideTarget: null, adminOverridePolicy: null, + adminPolicyAudits: [], tenantQuota: null, tenantUsage: null, adminOpsOverview: null, @@ -877,6 +880,9 @@ function openActionModal(config) { submit.disabled = false; submit.hidden = Boolean(config.hideSubmit); modal.classList.remove("hidden"); + if (typeof config.onOpen === "function") { + config.onOpen({ modal, title, description, fields, message, submit }); + } } function closeActionModal() { @@ -1262,11 +1268,13 @@ async function logoutSession() { appState.onelinerGovernanceEffective = null; appState.userGlobalPolicy = null; appState.userCurrentPlatformPolicy = null; + appState.userPolicyAudits = []; appState.adminSystemMainPolicy = null; appState.adminSystemPlatformPolicies = []; appState.adminGovernanceDirectory = null; appState.adminOverrideTarget = null; appState.adminOverridePolicy = null; + appState.adminPolicyAudits = []; appState.tenantQuota = null; appState.tenantUsage = null; appState.adminOpsOverview = null; @@ -1316,16 +1324,18 @@ async function loadAgentControlSurfaces(projectId = "") { const supportsGovernanceEffective = backendSupports("/v2/oneliner/governance/effective"); const supportsUserGlobalPolicy = backendSupports("/v2/oneliner/governance/user/global"); const supportsUserPlatformPolicy = backendSupports("/v2/oneliner/governance/user/platforms/{platform}"); + const supportsUserPolicyAudits = backendSupports("/v2/oneliner/governance/user/audits"); const supportsAdminSystemMainPolicy = backendSupports("/v2/admin/oneliner/governance/system/main-agent"); const supportsAdminSystemPlatformPolicy = backendSupports("/v2/admin/oneliner/governance/system/platforms/{platform}"); const supportsAdminGovernanceDirectory = backendSupports("/v2/admin/oneliner/governance/directory"); const supportsAdminOverridePolicy = backendSupports("/v2/admin/oneliner/governance/overrides"); + const supportsAdminGovernanceAudits = backendSupports("/v2/admin/oneliner/governance/audits"); const supportsAdminOps = backendSupports("/v2/admin/ops/overview"); const supportsAdminFixRuns = backendSupports("/v2/admin/ops/fix-runs"); const supportsTenantQuota = backendSupports("/v2/tenant/quota"); const supportsTenantUsage = backendSupports("/v2/tenant/usage"); - const [profile, sessionsPayload, actionRegistryPayload, platformAgentsPayload, governanceEffective, userGlobalPolicy, userCurrentPlatformPolicy, adminSystemMainPolicy, adminSystemPlatformPolicies, adminGovernanceDirectory, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ + const [profile, sessionsPayload, actionRegistryPayload, platformAgentsPayload, governanceEffective, userGlobalPolicy, userCurrentPlatformPolicy, userPolicyAuditsPayload, adminSystemMainPolicy, adminSystemPlatformPolicies, adminGovernanceDirectory, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ supportsOneLinerProfile ? storyforgeFetch(`/v2/oneliner/profile?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) : Promise.resolve(null), @@ -1347,6 +1357,9 @@ async function loadAgentControlSurfaces(projectId = "") { supportsUserPlatformPolicy ? storyforgeFetch(`/v2/oneliner/governance/user/platforms/${encodeURIComponent(governancePlatform)}?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) : Promise.resolve(null), + supportsUserPolicyAudits + ? storyforgeFetch(`/v2/oneliner/governance/user/audits?project_id=${encodeURIComponent(normalizedProjectId)}&platform=${encodeURIComponent(governancePlatform)}`).catch(() => ({ items: [] })) + : Promise.resolve({ items: [] }), supportsAdminSystemMainPolicy && isSuperAdmin() ? storyforgeFetch("/v2/admin/oneliner/governance/system/main-agent").catch(() => null) : Promise.resolve(null), @@ -1382,15 +1395,23 @@ async function loadAgentControlSurfaces(projectId = "") { appState.onelinerGovernanceEffective = governanceEffective; appState.userGlobalPolicy = userGlobalPolicy; appState.userCurrentPlatformPolicy = userCurrentPlatformPolicy; + appState.userPolicyAudits = safeArray(userPolicyAuditsPayload?.items || userPolicyAuditsPayload); appState.adminSystemMainPolicy = adminSystemMainPolicy; appState.adminSystemPlatformPolicies = safeArray(adminSystemPlatformPolicies); appState.adminGovernanceDirectory = safeArray(adminGovernanceDirectory?.items || adminGovernanceDirectory); if (isSuperAdmin() && supportsAdminOverridePolicy && appState.adminGovernanceDirectory.length) { const existingTarget = appState.adminOverrideTarget || {}; + const hasExistingProjectTarget = Object.prototype.hasOwnProperty.call(existingTarget, "targetProjectId") + || Object.prototype.hasOwnProperty.call(existingTarget, "target_project_id"); const targetUserId = String(existingTarget.targetUserId || existingTarget.target_user_id || appState.adminGovernanceDirectory[0]?.id || ""); const targetUser = appState.adminGovernanceDirectory.find((item) => item.id === targetUserId) || appState.adminGovernanceDirectory[0] || null; const targetProjects = safeArray(targetUser?.projects); - const targetProjectId = String(existingTarget.targetProjectId || existingTarget.target_project_id || targetProjects[0]?.id || ""); + const requestedProjectId = hasExistingProjectTarget + ? String(existingTarget.targetProjectId ?? existingTarget.target_project_id ?? "") + : String(targetProjects[0]?.id || ""); + const targetProjectId = requestedProjectId === "" + ? "" + : (targetProjects.some((item) => String(item?.id || "") === requestedProjectId) ? requestedProjectId : String(targetProjects[0]?.id || "")); const targetPlatform = normalizePlatformValue(existingTarget.platform || governancePlatform, "douyin"); appState.adminOverrideTarget = { targetUserId, @@ -1398,9 +1419,13 @@ async function loadAgentControlSurfaces(projectId = "") { platform: targetPlatform }; appState.adminOverridePolicy = await storyforgeFetch(`/v2/admin/oneliner/governance/overrides?target_user_id=${encodeURIComponent(targetUserId)}&target_project_id=${encodeURIComponent(targetProjectId)}&platform=${encodeURIComponent(targetPlatform)}`).catch(() => null); + appState.adminPolicyAudits = supportsAdminGovernanceAudits + ? safeArray((await storyforgeFetch(`/v2/admin/oneliner/governance/audits?target_user_id=${encodeURIComponent(targetUserId)}&target_project_id=${encodeURIComponent(targetProjectId)}&platform=${encodeURIComponent(targetPlatform)}&include_system=1`).catch(() => ({ items: [] })))?.items || []) + : []; } else { appState.adminOverrideTarget = null; appState.adminOverridePolicy = null; + appState.adminPolicyAudits = []; } appState.tenantQuota = tenantQuota; appState.tenantUsage = tenantUsage; @@ -3395,6 +3420,52 @@ function renderAdminGovernanceSummaryPanel() { `; } +function renderAdminGovernanceAuditPanel() { + const targetSummary = getAdminOverrideTargetSummary(); + const audits = safeArray(appState.adminPolicyAudits); + return ` +
+
+
+

覆盖与审计

+
按当前目标查看管理员覆盖、系统默认和相关策略变更,避免治理动作只留在弹窗里。
+
+
+ ${escapeHtml(targetSummary.accountLabel)} + ${escapeHtml(targetSummary.projectLabel)} + ${escapeHtml(targetSummary.platformLabel)} + 切换目标 +
+
+
+
+
+

当前管理员覆盖

+

${escapeHtml(appState.adminOverridePolicy?.current_version?.summary || "当前目标还没有管理员覆盖版本。")}

+
+ ${escapeHtml(appState.adminOverridePolicy?.current_version ? `版本 ${formatNumber(appState.adminOverridePolicy.current_version.version_no || 0)}` : "未发布")} + 编辑覆盖 + 历史与回滚 +
+
+
+

治理说明

+

管理员对用户策略的代改不会抹掉用户自己的历史,只会形成更高优先级的覆盖层,并在这里留下审计记录。

+
+
+
+
+

最近策略审计

系统默认、用户策略和管理员覆盖都会在这里形成时间线。
+
+ ${renderPolicyAuditFeed(audits, "当前目标还没有策略审计记录。")} +
+
+
+
+
+ `; +} + function renderPlatformAgentPanel() { const items = safeArray(appState.platformAgents); if (!items.length) { @@ -4142,6 +4213,7 @@ function renderAdminWorkbenchScreen() { { value: "integrations", label: "依赖健康" }, { value: "storage", label: "存储状态" }, { value: "agents", label: "Agent 治理" }, + { value: "governance_audit", label: "覆盖与审计" }, { value: "ops", label: "运维审计" } ]; const activeTab = getActiveDetailTab("adminWorkbenchTab", tabs); @@ -4164,6 +4236,8 @@ function renderAdminWorkbenchScreen() { ? renderStorageStatusPanel() : activeTab === "agents" ? `${renderAdminGovernanceSummaryPanel()}${renderPlatformAgentPanel()}
${renderOneLinerActionRegistryPanel()}
` + : activeTab === "governance_audit" + ? renderAdminGovernanceAuditPanel() : renderAdminOpsPanel()} ` @@ -5230,6 +5304,139 @@ function renderReviewScreen() { ); } +function renderStrategyScreen() { + if (!appState.dashboard) { + if (isAutoConnectionPending()) { + return renderAutoConnectingScreen("我的策略", "工作区就绪后,这里会自动展示你自己的策略层和治理记录。"); + } + return screenShell("我的策略", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("策略未加载", "自动连接成功后,这里会展示你的全局策略、平台策略和治理记录。")); + } + const tabs = [ + { value: "effective", label: "当前生效" }, + { value: "global", label: "全局策略" }, + { value: "platform", label: "当前平台策略" }, + { value: "activity", label: "变更记录" } + ]; + const activeTab = getActiveDetailTab("strategyDetailTab", tabs); + const project = getSelectedProject(); + const platform = appState.onelinerGovernanceEffective?.platform || appState.onelinerProfile?.default_platform || getPreferredPlatform(); + return screenShell( + "我的策略", + "把你和主 Agent 的对话沉淀成可查看、可回滚、可追溯的个人策略层。", + `${button("编辑全局策略", "open-user-global-policy")} ${button("编辑当前平台策略", "open-user-platform-policy", "primary")} ${button("打开 OneLiner", "open-oneliner")}`, + ` +
+

当前策略工作区

+

${escapeHtml(project?.name || "当前项目")} · ${escapeHtml(platformLabel(platform))}。这里展示系统默认、你的个性化策略和管理员覆盖是如何叠加生效的。

+
+
+
+
+

策略治理

+
先看当前生效,再回看你自己的历史和管理员覆盖,不必再通过多个弹窗来回切。
+
+
+ ${renderDetailTabs("strategyDetailTab", tabs)} + ${activeTab === "effective" ? ` +
+
+ ${renderGovernanceSummaryCard({ + title: "当前生效策略", + subtitle: appState.onelinerGovernanceEffective?.summary || "当前主 Agent 会按下面这些层级叠加执行。", + effective: appState.onelinerGovernanceEffective, + actions: [ + { action: "open-user-global-policy", label: "编辑我的全局策略" }, + { action: "open-user-platform-policy", label: "编辑当前平台策略", platform }, + { action: "open-oneliner", label: "交给 OneLiner 调整" } + ] + })} +
+
+
+

当前叠加层

系统默认、用户层和管理员覆盖会按优先级叠加。
+
+ ${safeArray(appState.onelinerGovernanceEffective?.layers).map((layer) => ` +
+

${escapeHtml(policyScopeTagLabel(layer.scope_kind, layer.scope?.platform || platform))}

+

${escapeHtml(layer.current_version?.summary || layer.scope?.summary || "当前层还没有补充摘要。")}

+
+ ${layer.current_version?.version_no ? `版本 ${escapeHtml(formatNumber(layer.current_version.version_no || 0))}` : ""} + ${layer.scope?.platform ? `${escapeHtml(platformLabel(layer.scope.platform))}` : ""} +
+
+ `).join("") || `

还没有策略层

当前会话还没有拉到治理层信息。

`} +
+
+
+
+ ` : activeTab === "global" ? ` +
+
+
+

我的全局策略

这层只影响你自己,会先于平台策略被主 Agent 读取。
+ ${renderPolicyVersionSummary(appState.userGlobalPolicy || {}, "你还没有发布自己的全局策略。")} +
+ 编辑 + 历史与回滚 +
+
+
+
+
+

最近全局版本

你的全局策略回滚不会覆盖旧记录,而是生成一个新的版本。
+
${renderPolicyVersionsHtml(appState.userGlobalPolicy?.versions?.items || appState.userGlobalPolicy?.versions || [], "你的全局策略还没有历史版本。")}
+
+
+
+ ` : activeTab === "platform" ? ` +
+
+
+

${escapeHtml(platformLabel(platform))} 当前平台策略

只影响当前平台,不会连带改动其他平台。
+ ${renderPolicyVersionSummary(appState.userCurrentPlatformPolicy || {}, `你还没有发布 ${platformLabel(platform)} 平台策略。`)} +
+ 编辑 + 历史与回滚 +
+
+
+
+
+

最近平台版本

当前平台的个性化策略会覆盖你的全局策略,但仍然只对你自己生效。
+
${renderPolicyVersionsHtml(appState.userCurrentPlatformPolicy?.versions?.items || appState.userCurrentPlatformPolicy?.versions || [], `${platformLabel(platform)} 还没有历史版本。`)}
+
+
+
+ ` : ` +
+
+
+

最近策略变更

你的发布、回滚,以及管理员对当前项目的覆盖动作都会留痕在这里。
+
${renderPolicyAuditFeed(appState.userPolicyAudits, "当前项目还没有治理记录。")}
+
+
+
+
+

治理提醒

让你快速判断当前是该继续微调,还是该让管理员介入。
+
+
+

用户层不会影响其他人

+

你发布或回滚自己的策略,只会影响你当前账户下的工作方式。

+
+
+

管理员覆盖会明确可见

+

如果管理员对你当前项目施加了覆盖层,这里会出现对应的记录与摘要。

+
+
+
+
+
+ `} +
+ ` + ); +} + function renderCreditsScreen() { if (!appState.dashboard) { if (isAutoConnectionPending()) { @@ -5464,6 +5671,9 @@ function renderAll() { screenMap.automation.innerHTML = renderAutomationScreen(); screenMap.owned.innerHTML = renderOwnedScreen(); screenMap.playbook.innerHTML = renderPlaybookScreen(); + if (screenMap.strategy) { + screenMap.strategy.innerHTML = renderStrategyScreen(); + } screenMap.production.innerHTML = renderProductionScreen(); screenMap.review.innerHTML = renderReviewScreen(); screenMap.credits.innerHTML = renderCreditsScreen(); @@ -6619,6 +6829,34 @@ async function loadPolicyVersions(url) { return { items, count: Number(payload?.count || items.length) }; } +function renderPolicyAuditFeed(items, emptyText = "还没有策略变更记录。") { + const records = safeArray(items); + if (!records.length) { + return `

还没有治理动作

${escapeHtml(emptyText)}

`; + } + return records.slice(0, 10).map((item) => { + const scopeKind = item.scope_kind || item.scope?.scope_kind || ""; + const platform = item.platform || item.scope?.platform || ""; + const version = item.version || {}; + const details = item.details || {}; + const rollbackId = version.rollback_from_version_id || details.rollback_to_version_id || ""; + return ` +
+

${escapeHtml(item.summary || version.title || item.action_key || "策略变更")}

+

${escapeHtml(version.summary || version.reason || "当前记录没有补充摘要。")}

+
+ ${escapeHtml(policyScopeTagLabel(scopeKind, platform))} + ${version.version_no ? `版本 ${escapeHtml(formatNumber(version.version_no || 0))}` : ""} + ${item.action_key ? `${escapeHtml(item.action_key)}` : ""} + ${platform ? `${escapeHtml(platformLabel(platform))}` : ""} + ${rollbackId ? `回滚动作` : ""} + ${item.created_at ? `${escapeHtml(formatDateTime(item.created_at))}` : ""} +
+
+ `; + }).join(""); +} + function buildPolicyVersionOptions(history) { return safeArray(history?.items).map((item) => ({ value: item.id, @@ -6850,6 +7088,14 @@ async function openAdminOverrideTargetAction() { { name: "platform", label: "平台", type: "select", value: current.platform, options: getPlatformOptions() }, { type: "html", label: "目录提示", html: directoryItems.length ? `

可选目标

当前目录里有 ${escapeHtml(formatNumber(directoryItems.length))} 位已审核账号。

` : `

目录为空

后端还没有返回可选账号。

` } ], + onOpen: () => { + const userSelect = document.querySelector('[data-action-field="targetUserId"]'); + if (!(userSelect instanceof HTMLSelectElement)) return; + syncAdminOverrideProjectOptions(userSelect.value, current.targetProjectId); + userSelect.addEventListener("change", () => { + syncAdminOverrideProjectOptions(userSelect.value, ""); + }); + }, onSubmit: async (values) => { appState.adminOverrideTarget = { targetUserId: String(values.targetUserId || ""), @@ -6863,6 +7109,20 @@ async function openAdminOverrideTargetAction() { }); } +function syncAdminOverrideProjectOptions(targetUserId, preferredProjectId = "") { + const projectSelect = document.querySelector('[data-action-field="targetProjectId"]'); + if (!(projectSelect instanceof HTMLSelectElement)) return; + const options = [{ value: "", label: "用户全局" }, ...getAdminGovernanceDirectoryProjectOptions(targetUserId)]; + projectSelect.innerHTML = options.map((option) => ` + + `).join(""); + const normalizedPreferred = String(preferredProjectId ?? ""); + const nextValue = normalizedPreferred === "" || options.some((option) => String(option.value) === normalizedPreferred) + ? normalizedPreferred + : String(options[0]?.value || ""); + projectSelect.value = nextValue; +} + function openAdminOverridePolicyAction() { const target = getAdminOverrideTargetState(); const bundle = appState.adminOverridePolicy || {}; @@ -8689,6 +8949,10 @@ document.addEventListener("click", async (event) => { setScreen("production"); return; } + if (name === "goto-strategy") { + setScreen("strategy"); + return; + } if (name === "goto-review") { setScreen("review"); return; diff --git a/web/storyforge-web-v4/index.html b/web/storyforge-web-v4/index.html index f3bb888..5cb9a1c 100644 --- a/web/storyforge-web-v4/index.html +++ b/web/storyforge-web-v4/index.html @@ -44,6 +44,10 @@ Agent +