diff --git a/frontend/src/api/space.ts b/frontend/src/api/space.ts index 7b1f23a8..a7f63688 100644 --- a/frontend/src/api/space.ts +++ b/frontend/src/api/space.ts @@ -2,6 +2,7 @@ import { getPMFlowExecutionSummary, getPMFlowTruthState, type PMFlowExecutionSum import { projectsApi, type ProjectSummary } from "./projects"; import type { ChangeItem, + MetricSignal, PMFlowExecutionStatus, PMFlowObjectStatus, Release, @@ -64,6 +65,20 @@ export interface SpaceReleaseAttentionItem { href: string; } +export interface SpaceFeedbackAttentionItem { + id: string; + projectId: string; + projectTitle: string; + source: "feedback" | "revenue" | "roi" | "compliance" | "iteration"; + sourceLabel: string; + title: string; + summary: string; + value?: string | null; + severity: "low" | "medium" | "high"; + updatedAt: string; + href: string; +} + export interface SpaceDraftItem { id: string; projectId: string; @@ -89,6 +104,7 @@ export interface SpaceWorkbench { pendingConfirmations: SpacePendingConfirmation[]; aiProgress: SpaceAIProgressItem[]; releaseAttention: SpaceReleaseAttentionItem[]; + feedbackAttention: SpaceFeedbackAttentionItem[]; savedIdeas: SpaceSavedIdea[]; drafts: SpaceDraftItem[]; recentProjects: SpaceWorkbenchProject[]; @@ -481,6 +497,109 @@ function deriveReleaseAttention(snapshot: ProjectSnapshot): SpaceReleaseAttentio return deriveGateAttention(snapshot); } +function feedbackSignalSource(signal: MetricSignal): SpaceFeedbackAttentionItem["source"] | null { + const text = `${signal.id} ${signal.title} ${signal.summary}`.toLowerCase(); + 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; +} + +function feedbackSourceLabel(source: SpaceFeedbackAttentionItem["source"]): string { + const labels: Record = { + feedback: "用户反馈", + revenue: "收入信号", + roi: "ROI 信号", + compliance: "合规检查", + iteration: "下一轮建议", + }; + return labels[source]; +} + +function feedbackAttentionTitle(source: SpaceFeedbackAttentionItem["source"], signal?: MetricSignal): string { + if (source === "feedback") return "新反馈信号"; + if (source === "revenue") return "收入信号更新"; + if (source === "roi") return "ROI 信号更新"; + if (source === "compliance") return "合规阻塞关注"; + return signal?.title || "下一轮建议待处理"; +} + +function feedbackAttentionSeverity(source: SpaceFeedbackAttentionItem["source"], signal?: MetricSignal): SpaceFeedbackAttentionItem["severity"] { + if (source === "compliance") return "high"; + if (source === "iteration" || signal?.direction === "down") return "medium"; + return "low"; +} + +function deriveFeedbackSignalAttention(snapshot: ProjectSnapshot): SpaceFeedbackAttentionItem[] { + const { project, truth } = snapshot; + if (!truth) return []; + const projectId = String(project.id); + const projectTitle = project.title || truth.project.name || `项目 ${projectId}`; + + return truth.metricSignals + .map((signal): SpaceFeedbackAttentionItem | null => { + const source = feedbackSignalSource(signal); + if (!source) return null; + return { + id: `feedback-signal-${projectId}-${signal.id}`, + projectId, + projectTitle, + source, + sourceLabel: feedbackSourceLabel(source), + title: feedbackAttentionTitle(source, signal), + summary: signal.summary || signal.title || "发布后信号已同步,等待在 Release Growth 查看。", + value: signal.value, + severity: feedbackAttentionSeverity(source, signal), + updatedAt: project.updated_at, + href: releaseGrowthHref(projectId), + }; + }) + .filter((item): item is SpaceFeedbackAttentionItem => Boolean(item)); +} + +function isIterationChangeItem(item: ChangeItem): boolean { + const text = `${item.objectType} ${item.title} ${item.summary} ${item.diff}`.toLowerCase(); + return ( + item.status === "pending_review" && + (text.includes("feedback") || + text.includes("反馈") || + text.includes("iteration") || + text.includes("recommendation") || + text.includes("下一轮") || + text.includes("增长") || + text.includes("release")) + ); +} + +function deriveIterationAttention(snapshot: ProjectSnapshot): SpaceFeedbackAttentionItem[] { + const { project, truth } = snapshot; + if (!truth) return []; + const projectId = String(project.id); + const projectTitle = project.title || truth.project.name || `项目 ${projectId}`; + + return pendingChangeItems(truth) + .filter(isIterationChangeItem) + .map((item) => ({ + id: `feedback-iteration-${projectId}-${item.id}`, + projectId, + projectTitle, + source: "iteration" as const, + sourceLabel: "下一轮建议", + title: "下一轮建议待处理", + summary: item.summary || item.diff || "发布后反馈已形成待确认建议,需要回到 Change Review 处理。", + value: null, + severity: item.riskLevel === "high" ? "high" : "medium", + updatedAt: truth.lastWritebackAt || project.updated_at, + href: reviewHref(projectId, item.changeSetId), + })); +} + +function deriveFeedbackAttention(snapshot: ProjectSnapshot): SpaceFeedbackAttentionItem[] { + return [...deriveFeedbackSignalAttention(snapshot), ...deriveIterationAttention(snapshot)]; +} + function deriveDrafts(snapshot: ProjectSnapshot): SpaceDraftItem[] { const { project, truth } = snapshot; if (!truth) return []; @@ -548,6 +667,7 @@ export const spaceApi = { pendingConfirmations: sortByDate(snapshots.flatMap(derivePending)).slice(0, 8), aiProgress: sortByDate(snapshots.flatMap(deriveAIProgress)).slice(0, 8), releaseAttention: sortByDate(snapshots.flatMap(deriveReleaseAttention)).slice(0, 8), + feedbackAttention: sortByDate(snapshots.flatMap(deriveFeedbackAttention)).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 6f398f0b..b7443bcb 100644 --- a/frontend/src/features/personal-space/PersonalSpacePage.test.tsx +++ b/frontend/src/features/personal-space/PersonalSpacePage.test.tsx @@ -23,6 +23,7 @@ const emptyWorkbench: SpaceWorkbench = { pendingConfirmations: [], aiProgress: [], releaseAttention: [], + feedbackAttention: [], savedIdeas: [], drafts: [], recentProjects: [], @@ -42,6 +43,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); @@ -144,6 +147,60 @@ describe("PersonalSpacePage", () => { href: "/release-growth?projectId=19", }, ], + feedbackAttention: [ + { + id: "feedback-signal-17-feedback-1", + projectId: "17", + projectTitle: "真实项目", + source: "feedback", + sourceLabel: "用户反馈", + title: "新反馈信号", + summary: "用户反馈集中在 onboarding 卡顿。", + value: "12", + severity: "medium", + updatedAt: "2026-04-26T08:00:00.000Z", + href: "/release-growth?projectId=17", + }, + { + id: "feedback-signal-17-revenue-1", + projectId: "17", + projectTitle: "真实项目", + source: "revenue", + sourceLabel: "收入信号", + title: "收入信号更新", + summary: "付费转化新增。", + value: "USD 1200", + severity: "low", + updatedAt: "2026-04-26T08:00:00.000Z", + href: "/release-growth?projectId=17", + }, + { + id: "feedback-signal-17-compliance-1", + projectId: "17", + projectTitle: "真实项目", + source: "compliance", + sourceLabel: "合规检查", + title: "合规阻塞关注", + summary: "隐私文案未通过。", + value: "blocked", + severity: "high", + updatedAt: "2026-04-26T08:00:00.000Z", + href: "/release-growth?projectId=17", + }, + { + id: "feedback-iteration-17-change-92", + projectId: "17", + projectTitle: "真实项目", + source: "iteration", + sourceLabel: "下一轮建议", + title: "下一轮建议待处理", + summary: "基于发布后反馈生成下一轮优化建议。", + value: null, + severity: "medium", + updatedAt: "2026-04-26T08:00:00.000Z", + href: "/change-review?projectId=17&changeSetId=92", + }, + ], drafts: [ { id: "17-需求-draft-1", @@ -201,7 +258,24 @@ describe("PersonalSpacePage", () => { "href", "/release-growth?projectId=19" ); + expect(screen.getAllByRole("link", { name: /新反馈信号/ })[0]).toHaveAttribute( + "href", + "/release-growth?projectId=17" + ); + expect(screen.getAllByRole("link", { name: /收入信号更新/ })[0]).toHaveAttribute( + "href", + "/release-growth?projectId=17" + ); + expect(screen.getAllByRole("link", { name: /合规阻塞关注/ })[0]).toHaveAttribute( + "href", + "/release-growth?projectId=17" + ); + expect(screen.getAllByRole("link", { name: /下一轮建议待处理/ })[0]).toHaveAttribute( + "href", + "/change-review?projectId=17&changeSetId=92" + ); 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 deaea6f4..b07992f2 100644 --- a/frontend/src/features/personal-space/PersonalSpacePage.tsx +++ b/frontend/src/features/personal-space/PersonalSpacePage.tsx @@ -28,6 +28,7 @@ import { spaceApi, type SpaceAIProgressItem, type SpaceDraftItem, + type SpaceFeedbackAttentionItem, type SpacePendingConfirmation, type SpaceReleaseAttentionItem, type SpaceSavedIdea, @@ -107,6 +108,7 @@ function DesktopSpace({ pendingCount={data?.pendingConfirmations.length ?? 0} aiCount={data?.aiProgress.length ?? 0} releaseCount={data?.releaseAttention.length ?? 0} + feedbackCount={data?.feedbackAttention.length ?? 0} /> {isLoading ? : error ? : null} @@ -141,6 +143,8 @@ function DesktopSpace({ + +
@@ -200,7 +204,7 @@ function MobileSpace({
- +
@@ -242,6 +246,16 @@ function MobileSpace({
+ +
+ {data.feedbackAttention.length ? ( + data.feedbackAttention.slice(0, 4).map((item) => ) + ) : ( + + )} +
+
+
{data.aiProgress.length ? ( @@ -297,6 +311,7 @@ function WorkbenchHeader({ pendingCount, aiCount, releaseCount, + feedbackCount, }: { generatedAt?: string; isFetching: boolean; @@ -304,6 +319,7 @@ function WorkbenchHeader({ pendingCount: number; aiCount: number; releaseCount: number; + feedbackCount: number; }) { return (
@@ -318,10 +334,11 @@ function WorkbenchHeader({ 聚合继续推进、待确认、执行中、暂存想法、草案箱和最近项目;点击项目后回到主流程处理。

-
+
+
@@ -473,6 +490,50 @@ function ReleaseAttentionRow({ item }: { item: SpaceReleaseAttentionItem }) { ); } +function FeedbackAttentionPanel({ items }: { items: SpaceFeedbackAttentionItem[] }) { + return ( +
+ +
+ {items.length ? ( + items.slice(0, 5).map((item) => ) + ) : ( + + )} +
+
+ ); +} + +function FeedbackAttentionRow({ item }: { item: SpaceFeedbackAttentionItem }) { + const tone = feedbackAttentionTone(item); + return ( + + + + + + + {item.title} + {item.sourceLabel} + + {item.summary} + + {item.projectTitle} · {item.value || "无数值"} · {formatRelativeTime(item.updatedAt)} + + + + {item.href.startsWith("/change-review") ? "去确认" : "看反馈"} + + + ); +} + function SavedIdeasBox({ ideas }: { ideas: SpaceSavedIdea[] }) { return (
@@ -618,8 +679,9 @@ function PanelHeader({ ); } -function MetricTile({ label, value, tone = "slate" }: { label: string; value: number; tone?: "slate" | "orange" | "blue" | "rose" }) { - const toneClass = tone === "orange" ? "text-orange-500" : tone === "blue" ? "text-[#2463EB]" : tone === "rose" ? "text-rose-600" : "text-slate-950"; +function MetricTile({ label, value, tone = "slate" }: { label: string; value: number; tone?: "slate" | "orange" | "blue" | "rose" | "emerald" }) { + const toneClass = + tone === "orange" ? "text-orange-500" : tone === "blue" ? "text-[#2463EB]" : tone === "rose" ? "text-rose-600" : tone === "emerald" ? "text-emerald-600" : "text-slate-950"; return (
{label}
@@ -723,6 +785,25 @@ function MobileReleaseAttention({ item }: { item: SpaceReleaseAttentionItem }) { ); } +function MobileFeedbackAttention({ item }: { item: SpaceFeedbackAttentionItem }) { + const tone = feedbackAttentionTone(item); + return ( + +
+ + + + + {item.title} + {item.summary} + {item.projectTitle} · {item.value || "无数值"} + + {item.sourceLabel} +
+ + ); +} + function MobileRecentProjectRow({ project }: { project: SpaceWorkbenchProject }) { return ( @@ -862,6 +943,25 @@ function releaseAttentionTone(item: SpaceReleaseAttentionItem): { iconClass: str }; } +function feedbackAttentionTone(item: SpaceFeedbackAttentionItem): { iconClass: string; badgeClass: string } { + if (item.severity === "high") { + return { + iconClass: "bg-rose-50 text-rose-600", + badgeClass: "bg-rose-50 text-rose-600", + }; + } + if (item.severity === "medium") { + return { + iconClass: "bg-orange-50 text-orange-600", + badgeClass: "bg-orange-50 text-orange-600", + }; + } + return { + iconClass: "bg-emerald-50 text-emerald-600", + badgeClass: "bg-emerald-50 text-emerald-600", + }; +} + function formatRelativeTime(value?: string): string { if (!value) return "未记录时间"; const timestamp = new Date(value).getTime();