feat(space): surface iteration draft attention
This commit is contained in:
parent
8594d8eef7
commit
5db6f71f68
|
|
@ -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, unknown>): string {
|
||||
return objectTitle(recommendation, "下一轮迭代建议待确认");
|
||||
}
|
||||
|
||||
function recommendationSummary(recommendation: Record<string, unknown>): 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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
|
||||
<FeedbackAttentionPanel items={data.feedbackAttention} />
|
||||
|
||||
<IterationDraftAttentionPanel items={data.iterationDraftAttention} />
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
<SavedIdeasBox ideas={data.savedIdeas} />
|
||||
<DraftBox drafts={data.drafts} />
|
||||
|
|
@ -256,6 +259,16 @@ function MobileSpace({
|
|||
</div>
|
||||
</MobileSection>
|
||||
|
||||
<MobileSection title="下一轮迭代建议" badge={data.iterationDraftAttention.length}>
|
||||
<div className="space-y-3">
|
||||
{data.iterationDraftAttention.length ? (
|
||||
data.iterationDraftAttention.slice(0, 4).map((item) => <MobileIterationDraftAttention key={item.id} item={item} />)
|
||||
) : (
|
||||
<InlineEmpty title="暂无下一轮迭代建议" description="建议进入 ChangeSet 草案或发布后建议后会显示;不会使用 mock 冒充。" />
|
||||
)}
|
||||
</div>
|
||||
</MobileSection>
|
||||
|
||||
<MobileSection title="AI 正在推进" badge={data.aiProgress.length}>
|
||||
<div className="space-y-3">
|
||||
{data.aiProgress.length ? (
|
||||
|
|
@ -534,6 +547,50 @@ function FeedbackAttentionRow({ item }: { item: SpaceFeedbackAttentionItem }) {
|
|||
);
|
||||
}
|
||||
|
||||
function IterationDraftAttentionPanel({ items }: { items: SpaceIterationDraftAttentionItem[] }) {
|
||||
return (
|
||||
<section className="rounded-[20px] border border-[#dde6f2] bg-white">
|
||||
<PanelHeader
|
||||
title="下一轮迭代建议"
|
||||
description="只读聚合发布后建议和 ChangeSet 草案;处理动作回到 Change Review 或 Release Growth。"
|
||||
badge={items.length}
|
||||
className="px-5 pt-5"
|
||||
/>
|
||||
<div className="mt-4 divide-y divide-[#eef3f8]">
|
||||
{items.length ? (
|
||||
items.slice(0, 5).map((item) => <IterationDraftAttentionRow key={item.id} item={item} />)
|
||||
) : (
|
||||
<InlineEmpty title="暂无下一轮迭代建议" description="建议进入 ChangeSet 草案或发布后建议后会显示;不会使用 mock 冒充。" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function IterationDraftAttentionRow({ item }: { item: SpaceIterationDraftAttentionItem }) {
|
||||
const tone = iterationDraftAttentionTone(item);
|
||||
return (
|
||||
<Link to={item.href} className="grid grid-cols-[44px_minmax(0,1fr)_120px] items-center gap-4 px-5 py-4 transition hover:bg-[#f8fbff]">
|
||||
<span className={cn("flex h-10 w-10 items-center justify-center rounded-xl", tone.iconClass)}>
|
||||
<FileText className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{item.title}</span>
|
||||
<span className={cn("shrink-0 rounded-md px-2 py-0.5 text-xs font-semibold", tone.badgeClass)}>{item.statusLabel}</span>
|
||||
</span>
|
||||
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{item.summary}</span>
|
||||
<span className="mt-1 block truncate text-xs text-slate-500">
|
||||
{item.projectTitle} · {item.sourceLabel} · {formatRelativeTime(item.updatedAt)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="justify-self-end rounded-lg border border-[#cfe0ff] px-3 py-1.5 text-xs font-semibold text-[#2463EB]">
|
||||
{item.href.startsWith("/change-review") ? "去确认" : "看建议"}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedIdeasBox({ ideas }: { ideas: SpaceSavedIdea[] }) {
|
||||
return (
|
||||
<section className="rounded-[20px] border border-[#dbe5f2] bg-white p-5">
|
||||
|
|
@ -804,6 +861,25 @@ function MobileFeedbackAttention({ item }: { item: SpaceFeedbackAttentionItem })
|
|||
);
|
||||
}
|
||||
|
||||
function MobileIterationDraftAttention({ item }: { item: SpaceIterationDraftAttentionItem }) {
|
||||
const tone = iterationDraftAttentionTone(item);
|
||||
return (
|
||||
<Link to={item.href} className="block rounded-[18px] border border-[#dbe5f2] bg-white p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-xl", tone.iconClass)}>
|
||||
<FileText className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-slate-950">{item.title}</span>
|
||||
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{item.summary}</span>
|
||||
<span className="mt-2 block truncate text-xs text-slate-500">{item.projectTitle} · {item.sourceLabel}</span>
|
||||
</span>
|
||||
<span className={cn("shrink-0 rounded-md px-2 py-1 text-xs font-semibold", tone.badgeClass)}>{item.statusLabel}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileRecentProjectRow({ project }: { project: SpaceWorkbenchProject }) {
|
||||
return (
|
||||
<Link to={project.href} className="flex items-center justify-between gap-3 py-3 first:pt-3 last:pb-3">
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue