From 5db6f71f68fb74cabf5bb5eaf27c9f822419f43f Mon Sep 17 00:00:00 2001 From: Albert Date: Sun, 26 Apr 2026 14:14:13 +0800 Subject: [PATCH] feat(space): surface iteration draft attention --- frontend/src/api/space.ts | 84 ++++++++++++++--- .../personal-space/PersonalSpacePage.test.tsx | 32 +++++-- .../personal-space/PersonalSpacePage.tsx | 89 +++++++++++++++++++ 3 files changed, 186 insertions(+), 19 deletions(-) diff --git a/frontend/src/api/space.ts b/frontend/src/api/space.ts index a7f63688..caf15276 100644 --- a/frontend/src/api/space.ts +++ b/frontend/src/api/space.ts @@ -69,7 +69,7 @@ export interface SpaceFeedbackAttentionItem { id: string; projectId: string; projectTitle: string; - source: "feedback" | "revenue" | "roi" | "compliance" | "iteration"; + source: "feedback" | "revenue" | "roi" | "compliance"; sourceLabel: string; title: string; summary: string; @@ -79,6 +79,20 @@ export interface SpaceFeedbackAttentionItem { href: string; } +export interface SpaceIterationDraftAttentionItem { + id: string; + projectId: string; + projectTitle: string; + source: "changeset_draft" | "release_recommendation"; + sourceLabel: string; + title: string; + summary: string; + statusLabel: string; + severity: "low" | "medium" | "high"; + updatedAt: string; + href: string; +} + export interface SpaceDraftItem { id: string; projectId: string; @@ -105,6 +119,7 @@ export interface SpaceWorkbench { aiProgress: SpaceAIProgressItem[]; releaseAttention: SpaceReleaseAttentionItem[]; feedbackAttention: SpaceFeedbackAttentionItem[]; + iterationDraftAttention: SpaceIterationDraftAttentionItem[]; savedIdeas: SpaceSavedIdea[]; drafts: SpaceDraftItem[]; recentProjects: SpaceWorkbenchProject[]; @@ -502,7 +517,6 @@ function feedbackSignalSource(signal: MetricSignal): SpaceFeedbackAttentionItem[ if (text.includes("compliance") || text.includes("合规")) return "compliance"; if (signal.id.startsWith("revenue-") || text.includes("收入") || text.includes("revenue")) return "revenue"; if (signal.id.startsWith("roi-") || text.includes("roi")) return "roi"; - if (text.includes("iteration") || text.includes("recommendation") || text.includes("下一轮") || text.includes("建议")) return "iteration"; if (signal.id.startsWith("feedback-") || text.includes("feedback") || text.includes("反馈")) return "feedback"; return null; } @@ -513,22 +527,20 @@ function feedbackSourceLabel(source: SpaceFeedbackAttentionItem["source"]): stri revenue: "收入信号", roi: "ROI 信号", compliance: "合规检查", - iteration: "下一轮建议", }; return labels[source]; } -function feedbackAttentionTitle(source: SpaceFeedbackAttentionItem["source"], signal?: MetricSignal): string { +function feedbackAttentionTitle(source: SpaceFeedbackAttentionItem["source"]): string { if (source === "feedback") return "新反馈信号"; if (source === "revenue") return "收入信号更新"; if (source === "roi") return "ROI 信号更新"; - if (source === "compliance") return "合规阻塞关注"; - return signal?.title || "下一轮建议待处理"; + return "合规阻塞关注"; } function feedbackAttentionSeverity(source: SpaceFeedbackAttentionItem["source"], signal?: MetricSignal): SpaceFeedbackAttentionItem["severity"] { if (source === "compliance") return "high"; - if (source === "iteration" || signal?.direction === "down") return "medium"; + if (signal?.direction === "down") return "medium"; return "low"; } @@ -548,7 +560,7 @@ function deriveFeedbackSignalAttention(snapshot: ProjectSnapshot): SpaceFeedback projectTitle, source, sourceLabel: feedbackSourceLabel(source), - title: feedbackAttentionTitle(source, signal), + title: feedbackAttentionTitle(source), summary: signal.summary || signal.title || "发布后信号已同步,等待在 Release Growth 查看。", value: signal.value, severity: feedbackAttentionSeverity(source, signal), @@ -573,7 +585,15 @@ function isIterationChangeItem(item: ChangeItem): boolean { ); } -function deriveIterationAttention(snapshot: ProjectSnapshot): SpaceFeedbackAttentionItem[] { +function recommendationTitle(recommendation: Record): string { + return objectTitle(recommendation, "下一轮迭代建议待确认"); +} + +function recommendationSummary(recommendation: Record): string { + return objectSummary(recommendation, "发布后反馈已形成下一轮迭代建议,等待进入主流程确认。"); +} + +function deriveIterationDraftFromChangeItems(snapshot: ProjectSnapshot): SpaceIterationDraftAttentionItem[] { const { project, truth } = snapshot; if (!truth) return []; const projectId = String(project.id); @@ -585,19 +605,54 @@ function deriveIterationAttention(snapshot: ProjectSnapshot): SpaceFeedbackAtten id: `feedback-iteration-${projectId}-${item.id}`, projectId, projectTitle, - source: "iteration" as const, - sourceLabel: "下一轮建议", - title: "下一轮建议待处理", + source: "changeset_draft" as const, + sourceLabel: "ChangeSet 草案", + title: "下一轮迭代建议待确认", summary: item.summary || item.diff || "发布后反馈已形成待确认建议,需要回到 Change Review 处理。", - value: null, + statusLabel: "待确认", severity: item.riskLevel === "high" ? "high" : "medium", updatedAt: truth.lastWritebackAt || project.updated_at, href: reviewHref(projectId, item.changeSetId), })); } +function deriveIterationDraftFromReleaseSignals(snapshot: ProjectSnapshot): SpaceIterationDraftAttentionItem[] { + const { project, truth, execution } = snapshot; + const recommendations = execution?.releaseSignals?.recommendations ?? []; + const pendingChangeItemsByText = new Set(deriveIterationDraftFromChangeItems(snapshot).map((item) => item.summary)); + if (!recommendations.length) return []; + const projectId = String(project.id); + const projectTitle = project.title || truth?.project.name || `项目 ${projectId}`; + + return recommendations + .filter((item) => !item.changeSetCreated) + .map((item): SpaceIterationDraftAttentionItem | null => { + const title = recommendationTitle(item.recommendationSummary); + const summary = recommendationSummary(item.recommendationSummary); + if (pendingChangeItemsByText.has(summary)) return null; + return { + id: `release-recommendation-${projectId}-${item.id}`, + projectId, + projectTitle, + source: "release_recommendation" as const, + sourceLabel: "发布后建议", + title, + summary, + statusLabel: "待生成草案", + severity: item.priority === "high" ? "high" : "medium", + updatedAt: item.updatedAt || item.createdAt || project.updated_at, + href: releaseGrowthHref(projectId), + }; + }) + .filter((item): item is SpaceIterationDraftAttentionItem => Boolean(item)); +} + +function deriveIterationDraftAttention(snapshot: ProjectSnapshot): SpaceIterationDraftAttentionItem[] { + return [...deriveIterationDraftFromChangeItems(snapshot), ...deriveIterationDraftFromReleaseSignals(snapshot)]; +} + function deriveFeedbackAttention(snapshot: ProjectSnapshot): SpaceFeedbackAttentionItem[] { - return [...deriveFeedbackSignalAttention(snapshot), ...deriveIterationAttention(snapshot)]; + return deriveFeedbackSignalAttention(snapshot); } function deriveDrafts(snapshot: ProjectSnapshot): SpaceDraftItem[] { @@ -668,6 +723,7 @@ export const spaceApi = { aiProgress: sortByDate(snapshots.flatMap(deriveAIProgress)).slice(0, 8), releaseAttention: sortByDate(snapshots.flatMap(deriveReleaseAttention)).slice(0, 8), feedbackAttention: sortByDate(snapshots.flatMap(deriveFeedbackAttention)).slice(0, 8), + iterationDraftAttention: sortByDate(snapshots.flatMap(deriveIterationDraftAttention)).slice(0, 8), savedIdeas: [], drafts: sortByDate(snapshots.flatMap(deriveDrafts)).slice(0, 8), recentProjects: sortByDate(workbenchProjects), diff --git a/frontend/src/features/personal-space/PersonalSpacePage.test.tsx b/frontend/src/features/personal-space/PersonalSpacePage.test.tsx index b7443bcb..74c6e5b1 100644 --- a/frontend/src/features/personal-space/PersonalSpacePage.test.tsx +++ b/frontend/src/features/personal-space/PersonalSpacePage.test.tsx @@ -24,6 +24,7 @@ const emptyWorkbench: SpaceWorkbench = { aiProgress: [], releaseAttention: [], feedbackAttention: [], + iterationDraftAttention: [], savedIdeas: [], drafts: [], recentProjects: [], @@ -45,6 +46,8 @@ describe("PersonalSpacePage", () => { expect(screen.getAllByText("暂无发布验证记录")).not.toHaveLength(0); expect(screen.getAllByText("发布后反馈关注")).not.toHaveLength(0); expect(screen.getAllByText("暂无发布后反馈")).not.toHaveLength(0); + expect(screen.getAllByText("下一轮迭代建议")).not.toHaveLength(0); + expect(screen.getAllByText("暂无下一轮迭代建议")).not.toHaveLength(0); expect(screen.getAllByText("AI 正在推进")).not.toHaveLength(0); expect(screen.getAllByText("暂存想法")).not.toHaveLength(0); expect(screen.getAllByText("草案箱")).not.toHaveLength(0); @@ -187,19 +190,34 @@ describe("PersonalSpacePage", () => { updatedAt: "2026-04-26T08:00:00.000Z", href: "/release-growth?projectId=17", }, + ], + iterationDraftAttention: [ { id: "feedback-iteration-17-change-92", projectId: "17", projectTitle: "真实项目", - source: "iteration", - sourceLabel: "下一轮建议", - title: "下一轮建议待处理", + source: "changeset_draft", + sourceLabel: "ChangeSet 草案", + title: "下一轮迭代建议待确认", summary: "基于发布后反馈生成下一轮优化建议。", - value: null, + statusLabel: "待确认", severity: "medium", updatedAt: "2026-04-26T08:00:00.000Z", href: "/change-review?projectId=17&changeSetId=92", }, + { + id: "release-recommendation-17-rec-1", + projectId: "17", + projectTitle: "真实项目", + source: "release_recommendation", + sourceLabel: "发布后建议", + title: "优化 onboarding 上传入口", + summary: "发布后反馈显示上传入口难找,建议下一轮优化。", + statusLabel: "待生成草案", + severity: "medium", + updatedAt: "2026-04-26T08:00:00.000Z", + href: "/release-growth?projectId=17", + }, ], drafts: [ { @@ -270,10 +288,14 @@ describe("PersonalSpacePage", () => { "href", "/release-growth?projectId=17" ); - expect(screen.getAllByRole("link", { name: /下一轮建议待处理/ })[0]).toHaveAttribute( + expect(screen.getAllByRole("link", { name: /下一轮迭代建议待确认/ })[0]).toHaveAttribute( "href", "/change-review?projectId=17&changeSetId=92" ); + expect(screen.getAllByRole("link", { name: /优化 onboarding 上传入口/ })[0]).toHaveAttribute( + "href", + "/release-growth?projectId=17" + ); expect(screen.getAllByText("测试失败: typecheck 未通过")).not.toHaveLength(0); expect(screen.queryByRole("button", { name: /创建发布验证|运行发布验证|重新验证/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /创建反馈|写入反馈|记录收入|运行合规/ })).not.toBeInTheDocument(); diff --git a/frontend/src/features/personal-space/PersonalSpacePage.tsx b/frontend/src/features/personal-space/PersonalSpacePage.tsx index b07992f2..7ee83865 100644 --- a/frontend/src/features/personal-space/PersonalSpacePage.tsx +++ b/frontend/src/features/personal-space/PersonalSpacePage.tsx @@ -29,6 +29,7 @@ import { type SpaceAIProgressItem, type SpaceDraftItem, type SpaceFeedbackAttentionItem, + type SpaceIterationDraftAttentionItem, type SpacePendingConfirmation, type SpaceReleaseAttentionItem, type SpaceSavedIdea, @@ -145,6 +146,8 @@ function DesktopSpace({ + +
@@ -256,6 +259,16 @@ function MobileSpace({
+ +
+ {data.iterationDraftAttention.length ? ( + data.iterationDraftAttention.slice(0, 4).map((item) => ) + ) : ( + + )} +
+
+
{data.aiProgress.length ? ( @@ -534,6 +547,50 @@ function FeedbackAttentionRow({ item }: { item: SpaceFeedbackAttentionItem }) { ); } +function IterationDraftAttentionPanel({ items }: { items: SpaceIterationDraftAttentionItem[] }) { + return ( +
+ +
+ {items.length ? ( + items.slice(0, 5).map((item) => ) + ) : ( + + )} +
+
+ ); +} + +function IterationDraftAttentionRow({ item }: { item: SpaceIterationDraftAttentionItem }) { + const tone = iterationDraftAttentionTone(item); + return ( + + + + + + + {item.title} + {item.statusLabel} + + {item.summary} + + {item.projectTitle} · {item.sourceLabel} · {formatRelativeTime(item.updatedAt)} + + + + {item.href.startsWith("/change-review") ? "去确认" : "看建议"} + + + ); +} + function SavedIdeasBox({ ideas }: { ideas: SpaceSavedIdea[] }) { return (
@@ -804,6 +861,25 @@ function MobileFeedbackAttention({ item }: { item: SpaceFeedbackAttentionItem }) ); } +function MobileIterationDraftAttention({ item }: { item: SpaceIterationDraftAttentionItem }) { + const tone = iterationDraftAttentionTone(item); + return ( + +
+ + + + + {item.title} + {item.summary} + {item.projectTitle} · {item.sourceLabel} + + {item.statusLabel} +
+ + ); +} + function MobileRecentProjectRow({ project }: { project: SpaceWorkbenchProject }) { return ( @@ -962,6 +1038,19 @@ function feedbackAttentionTone(item: SpaceFeedbackAttentionItem): { iconClass: s }; } +function iterationDraftAttentionTone(item: SpaceIterationDraftAttentionItem): { iconClass: string; badgeClass: string } { + if (item.severity === "high") { + return { + iconClass: "bg-rose-50 text-rose-600", + badgeClass: "bg-rose-50 text-rose-600", + }; + } + return { + iconClass: "bg-orange-50 text-orange-600", + badgeClass: "bg-orange-50 text-orange-600", + }; +} + function formatRelativeTime(value?: string): string { if (!value) return "未记录时间"; const timestamp = new Date(value).getTime();