diff --git a/src/renderer/components/copilot/CopilotPage.tsx b/src/renderer/components/copilot/CopilotPage.tsx index 4129c4e..cfc540b 100644 --- a/src/renderer/components/copilot/CopilotPage.tsx +++ b/src/renderer/components/copilot/CopilotPage.tsx @@ -416,6 +416,16 @@ export function CopilotPage() { () => new Set(session?.supersededSuggestionIds || []), [session], ); + // 每个 run 的排名复核摘要一次算好(O(runs × 快照) 只在 session/快照变化时发生); + // AI 流式逐 token 重渲染时只做 Map 查询,不再每次全量过滤快照。 + const rankRunSummaries = useMemo(() => { + const snapshots = product?.rankSnapshots || []; + const map = new Map(); + for (const run of session?.actionRuns || []) { + map.set(run.id, completedRankRunSummary(run, snapshots)); + } + return map; + }, [session?.actionRuns, product?.rankSnapshots]); const orderedSuggestions = useMemo(() => [...(session?.suggestions || [])] .map((item, index) => ({ item, index })) .sort((a, b) => { @@ -891,7 +901,7 @@ export function CopilotPage() { )} {actionRuns.map((run, runIndex) => { - const rankResult = completedRankRunSummary(run, product.rankSnapshots || []); + const rankResult = rankRunSummaries.get(run.id) ?? null; return (
("keywords"); const pausedPopoverRef = useRef(null); - const deletedPopoverRef = useRef(null); - // Close keyword popovers when clicking anywhere outside them. + // 已暂停气泡:点外部任意处收起。「已删除」是页面级模态,由遮罩点击自行关闭。 useEffect(() => { const onMouseDown = (event: MouseEvent) => { const target = event.target as Node; - const inside = [pausedPopoverRef, deletedPopoverRef].some( - (ref) => ref.current?.contains(target), - ); - if (!inside) { + if (!(pausedPopoverRef.current?.contains(target) ?? false)) { setShowPaused(false); - setShowDeleted(false); } }; document.addEventListener("mousedown", onMouseDown); @@ -154,6 +151,7 @@ export function KeywordsPage() { const urlKeyword = searchParams.get("keyword") || ""; const urlLang = searchParams.get("lang") || ""; const urlScope = searchParams.get("scope") || ""; + const urlTab = searchParams.get("tab") || ""; const languages = product?.supportedLanguages || []; const languageOptions = trackingLanguageOptions(languages); @@ -259,7 +257,25 @@ export function KeywordsPage() { if (urlScope === "paused") { setShowPaused(true); } - }, [product?.id, urlKeyword, urlLang, urlScope]); + // 待处理暂停 / 已删除:一次性打开对应弹层后消费掉 scope 参数, + // 避免同页其他参数变化时重复弹出的循环。 + if (urlScope === "pending") { + void openPendingReview(); + const next = new URLSearchParams(searchParams); + next.delete("scope"); + setSearchParams(next, { replace: true }); + } + if (urlScope === "deleted") { + setShowDeleted(true); + const next = new URLSearchParams(searchParams); + next.delete("scope"); + setSearchParams(next, { replace: true }); + } + // 页签深链:总览等入口可直达 竞品 / 分布 页签。 + if (urlTab === "competitor" || urlTab === "distribution") { + setPageTab(urlTab); + } + }, [product?.id, urlKeyword, urlLang, urlScope, urlTab]); if (!project || !product) { return ; @@ -269,13 +285,22 @@ export function KeywordsPage() { // 每张卡只查自己的语言:全局卡 = en(× 全部商店);语言卡 = 该语言(× 该语言商店)。 // en 全局词不再跟随语言卡查询——它们只在全局卡出现。 const queryLanguages = [currentLang]; - const tracked = (project.trackedKeywords || []).filter((k) => queryLanguages.includes(k.language)); - const trackedActive = tracked.filter((k) => k.status !== "paused"); - const pausedForCurrent = tracked.filter( - (k) => k.status === "paused" || (k.pausedPlatforms || []).includes(product.platform), + // —— 派生链全部 memo 化:勾选/切标签只改轻量状态,不应重扫全量排名快照 —— + const tracked = useMemo( + () => (project.trackedKeywords || []).filter((k) => queryLanguages.includes(k.language)), + [project.trackedKeywords, currentLang], + ); + const trackedActive = useMemo(() => tracked.filter((k) => k.status !== "paused"), [tracked]); + const pausedForCurrent = useMemo( + () => + tracked.filter( + (k) => k.status === "paused" || (k.pausedPlatforms || []).includes(product.platform), + ), + [tracked, product.platform], ); - const pendingForCurrent = tracked.filter((k) => - (k.pendingPausePlatforms || []).includes(product.platform), + const pendingForCurrent = useMemo( + () => tracked.filter((k) => (k.pendingPausePlatforms || []).includes(product.platform)), + [tracked, product.platform], ); const missingTranslationCount = (project.trackedKeywords || []).filter( (k) => @@ -283,7 +308,10 @@ export function KeywordsPage() { k.language !== "zh-Hant" && !(k.translation && String(k.translation).trim()), ).length; - const removedForCurrent = (project.removedKeywords || []).filter((item) => queryLanguages.includes(item.language)); + const removedForCurrent = useMemo( + () => (project.removedKeywords || []).filter((item) => queryLanguages.includes(item.language)), + [project.removedKeywords, currentLang], + ); // 采集预算:任务量 = 活跃关键词 × 语言覆盖的商店数(en 全局词按全部本地化计)。 // 建议采纳 / 候选加入 / 恢复暂停词都会受硬上限约束,软上限起提示作用。 const rankBudget = rankBudgetStatus( @@ -292,22 +320,30 @@ export function KeywordsPage() { project.trackedKeywords || [], ); // 产品全部本地化覆盖的商店(去重):全局卡列序与分布页签共用。 - const productStorefronts = Array.from( - new Set( - (product?.supportedLanguages || []).flatMap((lang) => - storefrontsForLanguage(lang.code), + const productStorefronts = useMemo( + () => + Array.from( + new Set( + (product?.supportedLanguages || []).flatMap((lang) => + storefrontsForLanguage(lang.code), + ), + ), ), - ), + [product], + ); + const enStorefronts = useMemo(() => new Set(storefrontsForLanguage("en")), []); + const storefronts = useMemo( + () => + isGlobalView + ? [ + ...productStorefronts.filter((storefront) => enStorefronts.has(storefront)), + ...productStorefronts.filter((storefront) => !enStorefronts.has(storefront)), + ] + : storefrontsForLanguage(currentLang), + [isGlobalView, currentLang, productStorefronts, enStorefronts], ); - const enStorefronts = new Set(storefrontsForLanguage("en")); - const storefronts = isGlobalView - ? [ - ...productStorefronts.filter((storefront) => enStorefronts.has(storefront)), - ...productStorefronts.filter((storefront) => !enStorefronts.has(storefront)), - ] - : storefrontsForLanguage(currentLang); // 全局卡表头分组(相邻同组合并):英语商店 | 其他语言商店。 - const globalColumnGroups = (() => { + const globalColumnGroups = useMemo(() => { if (!isGlobalView) return []; const groups: { label: string; span: number }[] = []; for (const storefront of storefronts) { @@ -317,13 +353,23 @@ export function KeywordsPage() { else groups.push({ label, span: 1 }); } return groups; - })(); - const rankSnapshots = product.rankSnapshots || []; - const matrixRows = matrixFilterKeywords(trackedActive, currentLang); - const matrixColumns = storefronts.map((storefront) => ({ - storefront, - meta: matrixColumnMeta(rankSnapshots, storefront), - })); + }, [isGlobalView, storefronts, enStorefronts]); + // 快照索引:一次 O(快照数) 预计算每格 cell,渲染期 O(1) 查询(matrixCellState + // 每次全量过滤快照,行×列 次调用在快照上万后是纯卡顿来源)。 + const rankSnapshots = useMemo(() => product.rankSnapshots || [], [product]); + const cellIndex = useMemo(() => buildCellIndex(rankSnapshots), [rankSnapshots]); + const matrixRows = useMemo( + () => matrixFilterKeywords(trackedActive, currentLang), + [trackedActive, currentLang], + ); + const matrixColumns = useMemo( + () => + storefronts.map((storefront) => ({ + storefront, + meta: matrixColumnMeta(rankSnapshots, storefront), + })), + [storefronts, rankSnapshots], + ); const storeGridTemplate = `repeat(${matrixColumns.length}, 88px)`; const RANK_BUCKETS = [ { key: "top10", label: "TOP10", color: "#15803d", opacity: 1 }, @@ -355,10 +401,14 @@ export function KeywordsPage() { // 分布是独立「分布」页签的整体视角:全部语言的关键词 × 全部商店,与当前卡片无关。 // 平台暂停的关键词(pausedPlatforms 含当前产品平台)不计入分布,避免 // 把“未采集”虚构成“未进榜”。仅在该页签激活时计算(逐格查快照较重)。 - const distributionKeywords = (project.trackedKeywords || []).filter( - (k: any) => - k.status !== "paused" && - !(k.pausedPlatforms || []).includes(product.platform), + const distributionKeywords = useMemo( + () => + (project.trackedKeywords || []).filter( + (k: any) => + k.status !== "paused" && + !(k.pausedPlatforms || []).includes(product.platform), + ), + [project.trackedKeywords, product.platform], ); const distributionData: { storefront: string; @@ -367,8 +417,9 @@ export function KeywordsPage() { r51_100: number; r101_200: number; unranked: number; - }[] = pageTab === "distribution" - ? productStorefronts + }[] = useMemo(() => { + if (pageTab !== "distribution") return []; + return productStorefronts .map((storefront) => { const buckets: Record = { top10: 0, @@ -378,7 +429,7 @@ export function KeywordsPage() { unranked: 0, }; for (const row of distributionKeywords) { - const cell = matrixCellState(rankSnapshots, row.keyword, storefront); + const cell = cellIndex(row.keyword, storefront); const rank = cell.rank; if (rank == null || cell.beyond200) buckets.unranked += 1; else if (rank <= 10) buckets.top10 += 1; @@ -399,9 +450,12 @@ export function KeywordsPage() { (a, b) => (b.top10 * 100 + b.r11_50 * 50 + b.r51_100 * 20 + b.r101_200 * 5) - (a.top10 * 100 + a.r11_50 * 50 + a.r51_100 * 20 + a.r101_200 * 5), - ) - : []; - const { ranked, unranked } = matrixRowGroups(matrixRows, matrixColumns, rankSnapshots); + ); + }, [pageTab, productStorefronts, distributionKeywords, cellIndex]); + const { ranked, unranked } = useMemo( + () => matrixRowGroups(matrixRows, matrixColumns, cellIndex), + [matrixRows, matrixColumns, cellIndex], + ); const scopeFilteredRanked = urlScope === "top10" ? ranked.filter((item) => item.bestRank <= 10) : ranked; const showUnrankedRows = @@ -417,32 +471,66 @@ export function KeywordsPage() { ); const allRowsSelected = visibleRows.length > 0 && selectedVisibleRows.length === visibleRows.length; - const chartKeyword = matrixRows.some((keyword) => keyword.keyword === selectedKeyword) - ? selectedKeyword - : (ranked[0]?.row.keyword || trackedActive[0]?.keyword || ""); + // 勾选的词(当前卡片内):按最优名次排序。≥2 个 → 趋势图进入对比模式, + // 每词一条曲线(当日跨店最优名次,与在榜判定同口径)。 + const checkedRows = useMemo(() => { + const bestByKey = new Map( + ranked.map((item) => [`${item.row.language}\u0000${item.row.keyword}`, item.bestRank] as const), + ); + return trackedActive + .filter((k) => rowSelected.has(selKey(k.language, k.keyword))) + .map((k) => ({ + ...k, + bestRank: bestByKey.get(`${k.language}\u0000${k.keyword}`) ?? null, + })) + .sort((a, b) => (a.bestRank ?? Infinity) - (b.bestRank ?? Infinity)); + }, [ranked, trackedActive, rowSelected]); + const compareMode = checkedRows.length >= 2; + // 对比模式最多取最优前 MAX_COMPARE_WORDS 个:调色板 6 色,再多互相覆盖且无法辨色。 + const comparedKeywords = useMemo( + () => checkedRows.slice(0, MAX_COMPARE_WORDS), + [checkedRows], + ); + const chartKeyword = + checkedRows.length === 1 + ? checkedRows[0].keyword + : matrixRows.some((keyword) => keyword.keyword === selectedKeyword) + ? selectedKeyword + : (ranked[0]?.row.keyword || trackedActive[0]?.keyword || ""); const chartKeywordMeta = matrixRows.find( (keyword) => keyword.keyword === chartKeyword, ); - const chartSnapshots = rankSnapshots - .filter( - (snapshot) => - queryLanguages.includes(snapshot.language) && - storefronts.includes(snapshot.storefront) && - snapshot.keyword === chartKeyword, - ) - .sort((a, b) => new Date(a.checkedAt).getTime() - new Date(b.checkedAt).getTime()); - const chartSeriesMeta = Array.from( - new Map(chartSnapshots.map((s) => [s.storefront, s.storefront])).keys(), - ).map((storefront) => ({ storefront, label: storefrontDisplayName(storefront) })); + const chartSnapshots = useMemo( + () => + rankSnapshots + .filter( + (snapshot) => + snapshot.language === currentLang && + storefronts.includes(snapshot.storefront) && + snapshot.keyword === chartKeyword, + ) + .sort((a, b) => new Date(a.checkedAt).getTime() - new Date(b.checkedAt).getTime()), + [rankSnapshots, currentLang, storefronts, chartKeyword], + ); + const chartSeriesMeta = useMemo( + () => + Array.from( + new Map(chartSnapshots.map((s) => [s.storefront, s.storefront])).keys(), + ).map((storefront) => ({ storefront, label: storefrontDisplayName(storefront) })), + [chartSnapshots], + ); // 组关键词 = 当前卡片矩阵里全部可见的激活关键词(全局卡 = en 词;语言卡 = 该语言词)。 // 带按 (语言 × 商店) 分别绘制:每个商店(如 中国大陆/新加坡)各自一条带, // 只统计本卡语言的关键词在该商店的快照;其他语言的关键词与本卡的带无关。 - const groupBandKeywords = Array.from(new Set(matrixRows.map((row) => row.keyword))); + const groupBandKeywords = useMemo( + () => Array.from(new Set(matrixRows.map((row) => row.keyword))), + [matrixRows], + ); // 时间轴 = 按天(与组快照粒度一致):行覆盖「组数据活动日 ∪ 选中词采样日」的连续日期。 // 带挂在固定日期轴上,与选中哪个词无关:切换关键词只改变曲线取值,带的形状保持不变。 // 带 = 该商店整组关键词当天的 P25–P75:每词每天取最后一次在榜快照;未进榜(>200 / null) // 与当天未检查的词不参与;当天在榜词 < GROUP_BAND_MIN_N 视为无组数据,用前后真实日连接。 - const chartData = (() => { + const chartData = useMemo(() => { const groupSet = new Set(groupBandKeywords); const storefrontSet = new Set(storefronts); @@ -451,7 +539,7 @@ export function KeywordsPage() { const latestMs = new Map(); for (const snapshot of rankSnapshots) { if (!groupSet.has(snapshot.keyword)) continue; - if (!queryLanguages.includes(snapshot.language)) continue; + if (snapshot.language !== currentLang) continue; if (!storefrontSet.has(snapshot.storefront)) continue; const key = `${snapshot.storefront}\u0000${localDayKey(snapshot.checkedAt)}\u0000${snapshot.keyword}`; const ms = new Date(snapshot.checkedAt).getTime(); @@ -577,7 +665,7 @@ export function KeywordsPage() { } } return rows; - })(); + }, [chartKeyword, groupBandKeywords, chartSnapshots, storefronts, currentLang, rankSnapshots]); // 覆盖提示:该商店最终没有绘制整组带(整组极少同日有多词进前 200)→ 图例处提示 const noBandStorefronts = chartSeriesMeta.filter( (meta) => !chartData.some((row) => row[`${meta.storefront}:p25`] != null), @@ -594,6 +682,70 @@ export function KeywordsPage() { for (let rank = 1; rank <= chartMaxRank; rank += chartStep) chartTicks.push(rank); if (chartTicks[chartTicks.length - 1] !== chartMaxRank) chartTicks.push(chartMaxRank); + // 多词对比模式数据:每词一条曲线 = 该词当日在这张卡全部商店中的最优名次。 + // 同时预计算「同口径组带」:整卡词池当日跨店最优的 P25–P75——带与线同为 + // 跨店最优口径,对比才有意义(勾选的词相对整卡词池处于什么位置)。 + // 一次快照扫描建 (日 × 词) → 最优名次,日轴连续(无数据日由 connectNulls 连接)。 + const compareChart = useMemo(() => { + if (!compareMode) return { rows: [] as Record[], words: [] as string[] }; + const words = comparedKeywords.map((k) => k.keyword); + const wordSet = new Set(words); + const cardSet = new Set(groupBandKeywords); + const best = new Map>(); + for (const snapshot of rankSnapshots) { + if (snapshot.language !== currentLang) continue; + if (!storefronts.includes(snapshot.storefront)) continue; + if (snapshot.rank == null || snapshot.rank > 200) continue; + if (!wordSet.has(snapshot.keyword) && !cardSet.has(snapshot.keyword)) continue; + const day = localDayKey(snapshot.checkedAt); + let perWord = best.get(day); + if (!perWord) { + perWord = new Map(); + best.set(day, perWord); + } + const prev = perWord.get(snapshot.keyword); + if (prev == null || snapshot.rank < prev) perWord.set(snapshot.keyword, snapshot.rank); + } + if (best.size === 0) return { rows: [] as Record[], words }; + const sortedDays = [...best.keys()].sort(); + const rows: Record[] = []; + const cursor = new Date(`${sortedDays[0]}T00:00:00`); + const last = sortedDays[sortedDays.length - 1]; + for (;;) { + const dayIso = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()).toISOString(); + const day = localDayKey(dayIso); + const row: Record = { time: dayIso }; + const perWord = best.get(day); + for (const word of words) row[word] = perWord?.get(word) ?? null; + // 同口径组带:全卡词当日跨店最优的 P25–P75(当天不足 2 个词有数据则不画) + const values: number[] = []; + if (perWord) { + for (const value of perWord.values()) values.push(value); + } + if (values.length >= GROUP_BAND_MIN_N) { + values.sort((a, b) => a - b); + row.p25 = percentileOf(values, 0.25); + row.p75 = percentileOf(values, 0.75); + } + rows.push(row); + if (day >= last) break; + cursor.setDate(cursor.getDate() + 1); + } + return { rows, words }; + }, [compareMode, comparedKeywords, groupBandKeywords, rankSnapshots, currentLang, storefronts]); + const compareMaxRank = compareChart.rows.reduce((max, row) => { + for (const word of compareChart.words) { + const value = row[word]; + if (typeof value === "number" && value > max) max = value; + } + if (typeof row.p75 === "number" && row.p75 > max) max = row.p75; + return max; + }, 1); + const compareStep = Math.max(1, Math.ceil(compareMaxRank / 5)); + const compareTicks: number[] = []; + for (let rank = 1; rank <= compareMaxRank; rank += compareStep) compareTicks.push(rank); + if (compareTicks[compareTicks.length - 1] !== compareMaxRank) compareTicks.push(compareMaxRank); + const formatColumnTime = (iso: string) => new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); @@ -745,7 +897,7 @@ export function KeywordsPage() { style={{ gridTemplateColumns: storeGridTemplate }} > {matrixColumns.map((column) => { - const cell = matrixCellState(rankSnapshots, keyword.keyword, column.storefront); + const cell = cellIndex(keyword.keyword, column.storefront); return (
{ await clearRemovedKeywords(product.id, queryLanguages); + setShowDeleted(false); }; const renderPageTabs = () => ( @@ -1837,7 +1990,7 @@ export function KeywordsPage() { 全局关键词(英文)× 全部商店
)} -
+
关键词({trackedActive.length}) @@ -1868,7 +2021,8 @@ export function KeywordsPage() {
-
+ {/* 第二行:勾选批量删除 + 补全译文/已删除(「已删除」点击打开页面级模态) */} +
{urlScope === "top10" && ( - {showDeleted && removedForCurrent.length > 0 && ( -
-
-

- 已删除(手动) -

- -
-
- {removedForCurrent.map((item) => ( - - {item.keyword} - - - ))} -
-
- )}
@@ -2032,7 +2156,7 @@ export function KeywordsPage() {
@@ -2055,7 +2179,100 @@ export function KeywordsPage() {
- {chartKeyword && chartData.length > 0 ? ( + {compareMode && compareChart.rows.length > 0 ? ( +
+
+
+

+ 关键词对比趋势({compareChart.words.length} 个词 · 当日跨店最优名次) +

+ {checkedRows.length > compareChart.words.length && ( + + 已勾选 {checkedRows.length} 个词,仅显示最优前 {MAX_COMPARE_WORDS} 个 + + )} +
+
+ + + + { + const dt = new Date(iso); + return `${dt.getMonth() + 1}/${dt.getDate()}`; + }} + tick={{ fontSize: 10 }} + tickLine={false} + axisLine={false} + minTickGap={40} + height={26} + /> + + + + row.p25 != null && row.p75 != null ? [row.p25, row.p75] : null + } + type="monotone" + connectNulls + stroke="none" + fill="#a1a1aa" + fillOpacity={0.18} + isAnimationActive={false} + name="整卡跨店最优 P25–P75" + /> + {compareChart.words.map((word, index) => ( + + ))} + + +
+
+ + + 整卡词池跨店最优 P25–P75 + + {compareChart.words.map((word, index) => ( + + + {word} + + ))} + + 勾选多个词即可对比(当日跨店最优名次);灰带 = 整卡词池同口径分位,词线出带即优于 3/4 的词池;取消勾选恢复单词逐店视图 + +
+
+ +
+ ) : chartKeyword && chartData.length > 0 ? (
@@ -2167,6 +2384,69 @@ export function KeywordsPage() { )} + {showDeleted && ( +
setShowDeleted(false)} + > +
e.stopPropagation()} + > +
+

+ 已删除的关键词(手动)· {languageLabel(currentLang)} +

+
+ + +
+
+
+ {removedForCurrent.length === 0 ? ( +

+ 暂无已删除关键词。 +

+ ) : ( +
+ {removedForCurrent.map((item) => ( + + {item.keyword} + + + ))} +
+ )} +

+ 已删除的词不再参与采集与排名统计;「恢复」会重新过一遍采集预算硬上限。 +

+
+
+
+ )} + {showPendingReview && (
GitHub 活跃 · 近 4 个月

{/* 月份标签:锚在对应周列起点,横向随网格自适应。 */} -
+
{monthLabels.map((label) => ( ))}
-
+
{heat.weeks.map((week) => (
{week.days.map((day) => ( @@ -363,14 +368,14 @@ function GitHubActivityBlock({ /** 四阶段卡的统一外壳:阶段序号 + 名称 + 单行导语 + 可选头部 CTA + 主体。 */ function StageCard({ step, - stepClass, + stepClass = "bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400", title, lead, right, children, }: { - step: string; - stepClass: string; + step?: string; + stepClass?: string; title: ReactNode; lead: string; right?: ReactNode; @@ -380,14 +385,16 @@ function StageCard({

- - {step} - + {step && ( + + {step} + + )} {title} @@ -434,6 +441,8 @@ export function OverviewContent(props: OverviewContentProps) { repoMetrics, competitorSummary, competitorAdvantage, + schedulerStatus, + promotionCampaigns, competitorHref, copilotSummary, LinkComponent = Link, @@ -443,106 +452,185 @@ export function OverviewContent(props: OverviewContentProps) { onOpenSettings, } = props; - if (!project || !product) { - return ; - } - - const languages = product.supportedLanguages || []; - const storeLinks = product.storeLinks || []; - const trackedKeywords = project.trackedKeywords || []; - const trackedActive = trackedKeywords.filter((k) => k.status !== "paused"); - const rankSnapshots = product.rankSnapshots || []; - const rankRows = overviewRankRows(trackedActive, rankSnapshots); - const top10Count = rankRows.filter((row) => row.bestRank <= 10).length; - const bestRankRow = rankRows[0] || null; - const newestSnapshot = rankSnapshots.reduce( - (latest, snapshot) => - !latest || new Date(snapshot.checkedAt).getTime() > new Date(latest.checkedAt).getTime() - ? snapshot - : latest, - null, + // —— 快照派生链(hooks 必须在 early return 之前)—— + // 父组件的每个异步 state 落定都会重渲染本组件;词×店×快照 的扫描只应 + // 在数据变化时发生,而不是每次渲染。格值走 buildCellIndex 预计算索引。 + const trackedKeywords = useMemo(() => project?.trackedKeywords || [], [project]); + const trackedActive = useMemo( + () => trackedKeywords.filter((k) => k.status !== "paused"), + [trackedKeywords], ); - const newestCheckedAt = newestSnapshot?.checkedAt || null; - const dataStale = newestCheckedAt ? Date.now() - new Date(newestCheckedAt).getTime() > STALE_MS : false; - // 全局排名分布(最新快照):全部关键词 × 当前产品全部商店。 - const RANK_BUCKETS = [ - { key: "top10", label: "TOP10", color: "#15803d", opacity: 1 }, - { key: "r11_50", label: "11–50", color: "#22c55e", opacity: 0.9 }, - { key: "r51_100", label: "51–100", color: "#a3e635", opacity: 0.75 }, - { key: "r101_200", label: "101–200", color: "#facc15", opacity: 0.6 }, - { key: "unranked", label: "未进榜", color: "#a1a1aa", opacity: 0.35 }, - ] as const; - const allStorefronts = Array.from( - new Set( - (product.supportedLanguages || []).flatMap((lang: any) => - storefrontsForLanguage(lang.code), + const rankSnapshots = useMemo(() => product?.rankSnapshots || [], [product]); + const cellIndex = useMemo(() => buildCellIndex(rankSnapshots), [rankSnapshots]); + const rankRows = useMemo( + () => overviewRankRows(trackedActive, rankSnapshots), + [trackedActive, rankSnapshots], + ); + const newestCheckedAt = useMemo(() => { + let newest: RankSnapshot | null = null; + for (const snapshot of rankSnapshots) { + if ( + !newest || + new Date(snapshot.checkedAt).getTime() > new Date(newest.checkedAt).getTime() + ) { + newest = snapshot; + } + } + return newest?.checkedAt || null; + }, [rankSnapshots]); + const allStorefronts = useMemo( + () => + Array.from( + new Set( + (product?.supportedLanguages || []).flatMap((lang: any) => + storefrontsForLanguage(lang.code), + ), + ), ), - ), + [product], ); // 分布词源 = 项目级共享关键词池(与排名页「排名分布」同源)。不能取 // product.trackedKeywords:多产品项目里非主产品副本可能为空(DB product_records // 无该平台的词,如 ai-pulse-macos 的 iOS 产品 0 词但快照存在),按产品副本取数 // 会导致 iOS 分布整列空白。平台维度由当前产品的 rankSnapshots/supportedLanguages // 表达;平台暂停的关键词不计入分布(矩阵仍显示,调度不采集)。 - const distributionKeywords = trackedActive.filter( - (k) => !(k.pausedPlatforms || []).includes(product.platform), + const distributionKeywords = useMemo( + () => + product + ? trackedActive.filter((k) => !(k.pausedPlatforms || []).includes(product.platform)) + : [], + [trackedActive, product], + ); + + // 词池健康:待复核(连续未在榜等人处理)、采集预算水位、进榜词数。 + const [expandedMarket, setExpandedMarket] = useState(null); + const pendingReviewCount = useMemo( + () => + trackedKeywords.filter((k) => + (k.pendingPausePlatforms || []).includes(product ? product.platform : ""), + ).length, + [trackedKeywords, product], + ); + const rankedWordCount = useMemo( + () => rankRows.filter((row) => row.bestRank != null && row.bestRank <= 200).length, + [rankRows], ); - const distributionData: { - storefront: string; - top10: number; - r11_50: number; - r51_100: number; - r101_200: number; - unranked: number; - }[] = allStorefronts - .map((storefront) => { - const buckets = { top10: 0, r11_50: 0, r51_100: 0, r101_200: 0, unranked: 0 }; - for (const row of distributionKeywords) { - const cell = matrixCellState(rankSnapshots, row.keyword, storefront); - const rank = cell.rank; - if (rank == null || cell.beyond200) buckets.unranked += 1; - else if (rank <= 10) buckets.top10 += 1; - else if (rank <= 50) buckets.r11_50 += 1; - else if (rank <= 100) buckets.r51_100 += 1; - else buckets.r101_200 += 1; + const rankBudget = useMemo( + () => + rankBudgetStatus( + product?.supportedLanguages || [], + product?.platform || "unknown", + trackedKeywords, + ), + [product, trackedKeywords], + ); + // 市场组聚合进榜率:英文市场与母语外市场的竞争结构天差地别,混合口径 + // 会把「英文市场几乎全灭、其他市场表现好」这一关键信号平均掉。 + const marketPerformance = useMemo(() => { + if (!product) return []; + const enNative = storefrontsForLanguage("en"); + const chinese = ["cn", "sg", "tw", "hk", "mo"]; + const groups = [ + { + label: "英文本土", + list: allStorefronts.filter((storefront) => enNative.includes(storefront)), + }, + { + label: "华人市场", + list: allStorefronts.filter((storefront) => chinese.includes(storefront)), + }, + { + label: "其他市场", + list: allStorefronts.filter( + (storefront) => !enNative.includes(storefront) && !chinese.includes(storefront), + ), + }, + ]; + return groups + .map(({ label, list }) => { + let total = 0; + let ranked = 0; + let top10 = 0; + const stores: { storefront: string; total: number; ranked: number }[] = []; + for (const storefront of list) { + let storeTotal = 0; + let storeRanked = 0; + for (const row of distributionKeywords) { + const cell = cellIndex(row.keyword, storefront); + if (cell.checkedAt == null) continue; // 从未查询的格不计入分母 + total += 1; + storeTotal += 1; + if (cell.rank != null) { + ranked += 1; + storeRanked += 1; + if (cell.rank <= 10) top10 += 1; + } + } + if (storeTotal > 0) stores.push({ storefront, total: storeTotal, ranked: storeRanked }); + } + return { + label, + storefronts: list.length, + total, + ranked, + top10, + rate: total > 0 ? ranked / total : null, + stores, + }; + }) + .filter((group) => group.storefronts > 0); + }, [product, allStorefronts, distributionKeywords, cellIndex]); + + + // 进榜词数 30 天走势:每天统计当日有任意商店进前 200 的去重词数(全语言)。 + const rankedWordsTrend = useMemo(() => { + const cutoffMs = Date.now() - 30 * 86400000; + const byDay = new Map>(); + for (const snapshot of rankSnapshots) { + if (snapshot.rank == null || snapshot.rank > 200) continue; + if (new Date(snapshot.checkedAt).getTime() < cutoffMs) continue; + const day = dayKeyOf(snapshot.checkedAt); + const words = byDay.get(day); + if (words) words.add(snapshot.keyword); + else byDay.set(day, new Set([snapshot.keyword])); + } + return [...byDay.keys()].sort().map((day) => ({ day, count: byDay.get(day)!.size })); + }, [rankSnapshots]); + + // 推广漏斗:最新 campaign 的 X 帖子状态机计数(planned/ready/published/skipped)。 + const promotionFunnel = useMemo(() => { + const campaigns = promotionCampaigns || []; + if (campaigns.length === 0) return null; + const latest = [...campaigns].sort((a, b) => + String(b.storePublishedAt || b.appVersion || "").localeCompare( + String(a.storePublishedAt || a.appVersion || ""), + ), + )[0]; + const counts = { published: 0, ready: 0, planned: 0, skipped: 0 }; + let latestPublishedAt: string | null = null; + for (const item of latest?.seriesItems || []) { + const status = item?.status as keyof typeof counts | undefined; + if (status && status in counts) counts[status] += 1; + if (item?.status === "published" && item.publishedAt) { + if (!latestPublishedAt || item.publishedAt > latestPublishedAt) { + latestPublishedAt = item.publishedAt; + } } - return { - storefront: storefrontDisplayName(storefront), - top10: buckets.top10, - r11_50: buckets.r11_50, - r51_100: buckets.r51_100, - r101_200: buckets.r101_200, - unranked: buckets.unranked, - }; - }) - .sort( - (a, b) => - (b.top10 * 100 + b.r11_50 * 50 + b.r51_100 * 20 + b.r101_200 * 5) - - (a.top10 * 100 + a.r11_50 * 50 + a.r51_100 * 20 + a.r101_200 * 5), - ); - const DistributionTooltip = ({ active, payload, label }: any) => { - if (!active || !payload?.length) return null; - return ( -
-

{label}

- {RANK_BUCKETS.map((bucket) => { - const item = payload.find((p: any) => p.dataKey === bucket.key); - return ( -
- - {bucket.label} - - {item?.value ?? 0} - -
- ); - })} -
- ); - }; + } + return { latest, counts, total: (latest?.seriesItems || []).length, latestPublishedAt }; + }, [promotionCampaigns]); + + if (!project || !product) { + return ; + } + + const languages = product.supportedLanguages || []; + const storeLinks = product.storeLinks || []; + const top10Count = rankRows.filter((row) => row.bestRank <= 10).length; + const bestRankRow = rankRows[0] || null; + const dataStale = newestCheckedAt ? Date.now() - new Date(newestCheckedAt).getTime() > STALE_MS : false; // ── 版本/文案数据(②③ 共用基础)── - const repoGithubUrl = project.repo?.githubUrl || null; const releaseDraft = releaseOverview?.draft ?? null; const submissionDraft = releaseOverview?.submission ?? null; const submissionLanguages = submissionDraft ? localizationList(submissionDraft) : []; @@ -631,9 +719,6 @@ export function OverviewContent(props: OverviewContentProps) { 0, ); const competitorTrackedTotal = competitorSummary?.totalTracked ?? competitorEntries.length; - const gainedEvents = competitorSummary?.gained ?? 0; - const droppedEvents = competitorSummary?.dropped ?? 0; - const staleCompetitors = competitorSummary?.stale ?? 0; const advantage = competitorAdvantage && competitorAdvantage.hasData ? competitorAdvantage : null; const dominantStorefrontsTotal = advantage?.dominantStorefronts.length ?? 0; const dominantStorefronts = advantage?.dominantStorefronts.slice(0, 3) ?? []; @@ -744,48 +829,109 @@ export function OverviewContent(props: OverviewContentProps) {

); - // ④ 顶部「竞品概况」指标条(与 ① 开发卡 MiniMetric 同款等宽小指标:一行五格, - // 每格数值 + 小标签 + title tooltip;零值灰显,保持一行等宽排布)。 - const competitorOverviewBlock = ( -
-

竞品概况

-
- - - 0 ? `+${gainedEvents}` : "0"} - muted={gainedEvents === 0} - title={`竞品词新进榜(≤200)的事件计数${gainedEvents === 0 ? "(0 = 近期无新上榜)" : ""}`} - /> - - -
+ const advantageWordsTotal = advantage?.advantageKeywords.length ?? 0; + const disadvantageWordsTotal = advantage?.disadvantageKeywords.length ?? 0; + + // 词池健康条:散在排名页各处的词池状态收成一眼视图,每格深链到处理入口。 + const budgetTone = + rankBudget.state === "hard" + ? "bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 ring-red-500/40" + : rankBudget.state === "soft" + ? "bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 ring-amber-500/40" + : "bg-zinc-50 dark:bg-zinc-800/60 text-zinc-600 dark:text-zinc-300 ring-zinc-200 dark:border-zinc-700"; + const poolHealthStrip = ( +
+ + 词池健康 + + + 活跃词 {trackedActive.length} + + + 进榜词 {rankedWordCount}/{trackedActive.length} + + 0 + ? "bg-amber-50 text-amber-700 ring-amber-500/40 hover:bg-amber-100 dark:bg-amber-500/10 dark:text-amber-400 dark:hover:bg-amber-500/20" + : "bg-zinc-50 text-zinc-400 ring-zinc-200 dark:bg-zinc-800/60 dark:ring-zinc-700", + )} + title="连续未在榜、等待人工分类(恢复 / 暂停 / 移除)的关键词数" + > + {pendingReviewCount > 0 && } + 待复核 {pendingReviewCount} + + + 已删除 {project.removedKeywords?.length ?? 0} + + + 采集预算 {rankBudget.dailyInstances}/{rankBudget.hardLimit} +
); - const advantageWordsTotal = advantage?.advantageKeywords.length ?? 0; - const disadvantageWordsTotal = advantage?.disadvantageKeywords.length ?? 0; + // L1 分诊区条目:只收「有明确动作」的异常,评估性信息不进这里。 + const attentionItems: { + tone: "amber" | "red"; + text: string; + actionLabel: string; + to: string; + }[] = []; + if (pendingReviewCount > 0) { + attentionItems.push({ + tone: "amber", + text: `${pendingReviewCount} 个关键词连续未在榜,等待复核分类(恢复 / 暂停 / 移除)`, + actionLabel: "去复核", + to: "/keywords?scope=pending", + }); + } + if (dataStale) { + attentionItems.push({ + tone: "amber", + text: "排名数据已超过 36 小时未更新,可能存在采集异常", + actionLabel: "去排名页", + to: "/keywords", + }); + } + if (versionMismatch) { + attentionItems.push({ + tone: versionMismatch.downgrade ? "red" : "amber", + text: `商店已上架 v${versionMismatch.live},草稿目标 v${versionMismatch.target}${ + versionMismatch.downgrade ? "(目标低于当前版本,注意核对)" : "(尚未上架,待提审)" + }`, + actionLabel: "去发布页", + to: releaseCardTo, + }); + } + if (schedulerStatus && schedulerStatus.failed > 0) { + attentionItems.push({ + tone: "red", + text: `调度器有 ${schedulerStatus.failed} 个任务处于失败状态`, + actionLabel: "去任务中心", + to: "/tasks", + }); + } return (
@@ -878,6 +1024,39 @@ export function OverviewContent(props: OverviewContentProps) {
+ + {/* L1 分诊区:需要你注意的事(无异常时收起为一行绿字) */} +
+ {attentionItems.length === 0 ? ( +

+ + 一切正常,没有待处理事项 +

+ ) : ( +
    + {attentionItems.map((item, index) => ( +
  • + + + {item.text} + + + {item.actionLabel} → + +
  • + ))} +
+ )} +
+ {/* Copilot stays visible as a compact project signal; conversation lives on its own page. */}
AI
@@ -897,27 +1076,260 @@ export function OverviewContent(props: OverviewContentProps) {
- {/* 第一行:三张卡(① 开发 / ② 发布 / ③ 上架;md 三列,窄屏堆叠) */} -
+ {/* 仪表盘 Row A:关键词与市场 | 竞品 */} +
+
+ + + 逐店明细 → + + + 去排名页 → + +
+ } + > + {keywordPerformanceBlock} +
+
+ {rankedWordsTrend.length >= 2 && ( +
+ 进榜词数 · 近 30 天 + {(() => { + const w = 150; + const h = 26; + const max = Math.max(...rankedWordsTrend.map((point) => point.count), 1); + const step = w / (rankedWordsTrend.length - 1); + const points = rankedWordsTrend + .map( + (point, index) => + `${(index * step).toFixed(1)},${(h - 2 - (point.count / max) * (h - 4)).toFixed(1)}`, + ) + .join(" "); + const lastPoint = rankedWordsTrend[rankedWordsTrend.length - 1]; + const lastY = h - 2 - (lastPoint.count / max) * (h - 4); + return ( + + + + + ); + })()} + + {rankedWordsTrend[rankedWordsTrend.length - 1].count} + +
+ )} +
+
+ {marketPerformance.map((group) => { + const ratePct = group.rate != null ? Math.round(group.rate * 100) : 0; + const rateColor = + group.rate == null + ? "#d4d4d8" + : group.rate >= 0.6 + ? "#16a34a" + : group.rate >= 0.3 + ? "#d97706" + : "#dc2626"; + const expanded = expandedMarket === group.label; + return ( +
+ + {expanded && ( +
    + {group.stores.map((store) => { + const storePct = Math.round((store.ranked / store.total) * 100); + return ( +
  • + {storefrontDisplayName(store.storefront)} + = 60 ? "text-emerald-600 dark:text-emerald-400" : storePct >= 30 ? "text-amber-600 dark:text-amber-400" : "text-red-500 dark:text-red-400"}> + {store.ranked}/{store.total}({storePct}%) + +
  • + ); + })} +
+ )} +
+ ); + })} +
+
+
{poolHealthStrip}
+ +
+
+ + 去竞品页 → + + ) : undefined + } + > + {hasCompetitorData ? ( +
+
+ {( + [ + { label: "跟踪竞品", value: String(competitorTrackedTotal), title: `当前跟踪的竞品总数;竞品压制我方词合计 ${pressuredTotal} 个` }, + { label: "占优商店", value: String(dominantStorefrontsTotal), title: dominantStorefronts.length > 0 ? `占优商店:${dominantStorefronts.map((s) => storefrontDisplayName(s.storefront)).join("、")}` : "暂无占优商店" }, + { label: "优势词", value: String(advantageWordsTotal), tone: "emerald", title: "相对竞品领先的词数" }, + { label: "劣势词", value: String(disadvantageWordsTotal), tone: "red", title: "相对竞品落后的词数" }, + ] as { label: string; value: string; tone?: string; title?: string }[] + ).map((tile) => ( +
+

{tile.label}

+

+ {tile.value} +

+
+ ))} +
+
+

TOP 竞品(按指数)

+
    + {competitorEntries.slice(0, 3).map((entry, index) => ( +
  • + + {index + 1} + + + {entry.name} + + {entry.pressuredCount > 0 && ( + + 压 {entry.pressuredCount} 词 + + )} +
  • + ))} + {competitorEntries.length === 0 && ( +
  • 暂无跟踪竞品
  • + )} +
+
+ {(advantageWords.length > 0 || disadvantageWords.length > 0) && ( +
+ {advantageWords.length > 0 && ( +
+ 优势 + {advantageWords.map((word) => ( + + {word.keyword} + + ))} + {advantageWordsTotal > advantageWords.length && ( + +{advantageWordsTotal - advantageWords.length} + )} +
+ )} + {disadvantageWords.length > 0 && ( +
+ 劣势 + {disadvantageWords.map((word) => ( + + {word.keyword} + + ))} + {disadvantageWordsTotal > disadvantageWords.length && ( + +{disadvantageWordsTotal - disadvantageWords.length} + )} +
+ )} +
+ )} +
+ ) : ( +
+

尚未查看竞品。添加竞品后,这里会展示对标状态与优劣势词。

+ {competitorHref && ( + + 去竞品页 → + + )} +
+ )} +
+
+
+ + {/* 仪表盘 Row B:工程 | 版本与发布(原 ②③ 合并为同列) */} +
+
{/* ① 开发:四指标 + GitHub 活跃热力图 + repo 状态(卡底) */} - -
- ) : undefined - } >
{/* 指标卡:提交 / PR / 开放 Issue / 上次提交(等宽四列) */} @@ -982,28 +1394,63 @@ export function OverviewContent(props: OverviewContentProps) { {/* repo 分支/工作区(卡底一行小字,无分隔横线;sha 已在上次提交指标 副注/tooltip,GitHub 凭证就绪/去设置已在标题行右侧,避免重复) */} -
- {project.repo ? ( -
- - {project.repo.branch && project.repo.branch !== "HEAD" - ? project.repo.branch - : project.repo.remoteUrl || "—"} - - {project.repo.dirty && } -
- ) : ( -

- 未配置本地仓库路径 -

+
+ 系统 + + + 数据截至 {newestCheckedAt ? formatHumanTime(newestCheckedAt) : "—"} + + 0 ? `,${schedulerStatus.due} 个任务待执行` : ""}` + : "调度器已停止(可在任务中心启动)" + : "调度器状态未知" + } + > + 调度器 {schedulerStatus ? (schedulerStatus.enabled ? "运行中" : "已停止") : "未知"} + {schedulerStatus && schedulerStatus.due > 0 ? ` · 待执行 ${schedulerStatus.due}` : ""} + + {schedulerStatus && schedulerStatus.failed > 0 && ( + + 失败 {schedulerStatus.failed} + + )} + + 采集预算 {rankBudget.dailyInstances}/{rankBudget.hardLimit} + {repoMetricError && (

{/* ② 发布:每个「文案」一条(更新时间倒序)+ 顶部一句话小结 */} +

+
+ {/* 发布管线节点灯:提交 → 文案 → 提审 → 上架(状态可视化,替代文字罗列) */} +
+ {( + [ + { + label: "提交", + value: pendingCommits != null ? `${pendingCommits}` : "—", + state: pendingCommits ? "done" : "idle", + title: "自上次发布以来的新提交数(① 开发卡同源)", + }, + { + label: "文案", + value: submissionDraft ? `v${submissionDraft.appVersion}` : "—", + state: submissionDraft ? "done" : "idle", + title: submissionDraft ? "最新商店文案已生成" : "尚未生成商店文案", + }, + { + label: "提审", + value: effectiveVersionStatus?.label ?? "—", + state: storeLiveVersion ? "done" : effectiveVersionStatus ? "current" : "idle", + title: effectiveVersionStatus ? `审核/提审状态:${effectiveVersionStatus.label}` : "尚未提审", + }, + { + label: "上架", + value: storeLiveVersion ? `v${storeLiveVersion}` : "—", + state: storeLiveVersion ? "done" : "idle", + title: storeLiveVersion ? `App Store 当前上架 ${storeLiveVersion}` : "商店尚未上架该版本", + }, + ] as { label: string; value: string; state: "done" | "current" | "idle"; title: string }[] + ).map((node, index) => ( +
+ {index > 0 && } + + + {node.label} + + + {node.value} + +
+ ))} +
{latestCopyNote && (

+ {/* ③ 上架:版本不一致提示 + 一行式紧凑小格(商店版本/目标审核/构建/商店链接/更新于) */} -

- - {/* 第二行:④ 竞品与表现独占一整行(宽卡,卡内 md 两栏分区) */} - - 去竞品页 → - - ) : undefined - } - > - {hasCompetitorData ? ( -
- {/* 顶部:关键词表现 / 竞品概况 并排状态条(一眼总数) */} -
- {keywordPerformanceBlock} - {competitorOverviewBlock} -
- - {/* 中部:TOP3 竞品(列表式)| 我方占优商店(领先 n/m 列表) */} -
-
-
-

TOP3 竞品

- {competitorEntries.length > 3 && ( - - 另有 {competitorEntries.length - 3} 个 - - )} -
-
    - {competitorEntries.slice(0, 3).map((entry, index) => ( -
  • - - {index + 1} - - - {entry.name} - - - 指数 {entry.index} - - {entry.pressuredCount > 0 && ( - - 压 {entry.pressuredCount} 词 - - )} -
  • - ))} - {competitorEntries.length === 0 && ( -
  • 暂无跟踪竞品
  • - )} -
-
-
-
-

我方占优商店

- {dominantStorefrontsTotal > dominantStorefronts.length && ( - storefrontDisplayName(s.storefront)) - .join("、")}`} - > - +还有 {dominantStorefrontsTotal - dominantStorefronts.length} 个 - - )} -
- {dominantStorefronts.length > 0 ? ( -
    - {dominantStorefronts.map((item) => ( -
  • - - {storefrontDisplayName(item.storefront)} - - - 领先 {item.leading}/{item.compared} - -
  • - ))} -
- ) : ( -

- 暂无占优商店(该平台未见领先名次) -

- )} -
-
- - {/* 下部:优势 / 劣势关键词 左右对比(悬停看领先/被压竞品数) */} - {(advantageWords.length > 0 || disadvantageWords.length > 0) && ( -
- {advantageWords.length > 0 && ( -
-
-

优势关键词

- {advantageWordsTotal > advantageWords.length && ( - - 共 {advantageWordsTotal} 词 - - )} -
-
    - {advantageWords.map((word) => ( -
  • - {word.keyword} -
  • - ))} -
-
- )} - {disadvantageWords.length > 0 && ( -
-
-

劣势关键词

- {disadvantageWordsTotal > disadvantageWords.length && ( - - 共 {disadvantageWordsTotal} 词 - - )} -
-
    - {disadvantageWords.map((word) => ( -
  • - {word.keyword} -
  • - ))} -
-
- )} -
- )} -
- ) : ( -
-
{keywordPerformanceBlock}
-
-

竞品

-

尚未查看竞品

- {competitorHref ? ( - - 去竞品页 → - - ) : ( - - 去竞品页 - - )} -
-
- )} -
+
+
- {/* 排名分布(全局,最新快照 × 全部商店) */} -
+ {/* 推广:最新版本 campaign 的 X 帖子发布漏斗(planned/ready/published/skipped) */} +
-

排名分布

-
- - {newestCheckedAt ? ( - - 数据截至 {formatHumanTime(newestCheckedAt)} - - ) : ( - "暂无数据" - )} - +
+

推广

+ {promotionFunnel && promotionFunnel.counts.ready > 0 && ( + + + {promotionFunnel.counts.ready} 条待发布 + + )}
+ + 管理推广 → +
- {trackedActive.length === 0 || distributionData.length === 0 ? ( -
-

- {trackedActive.length === 0 - ? "还没有跟踪关键词" - : "暂无排名数据"} + {!promotionFunnel ? ( +

+

+ 尚无推广活动。版本上架后,可在推广页基于发布内容生成 X 系列帖子。

- - {trackedActive.length === 0 ? "去生成关键词" : "去排名页"} -
) : ( -
- - - - - - } /> - {RANK_BUCKETS.map((bucket) => ( - +
+ + v{promotionFunnel.latest.appVersion} X 系列 + + {promotionFunnel.total} 帖 + + + + {promotionFunnel.latestPublishedAt + ? `最近发布于 ${formatHumanTime(promotionFunnel.latestPublishedAt)}` + : "尚未发布"} + +
+
+ {( + [ + { key: "published" as const, color: "#10b981", label: "已发布" }, + { key: "ready" as const, color: "#f59e0b", label: "待发布" }, + { key: "planned" as const, color: "#a1a1aa", label: "规划中" }, + { key: "skipped" as const, color: "#e4e4e7", label: "跳过" }, + ] + ).map((segment) => { + const value = promotionFunnel.counts[segment.key]; + if (!value) return null; + return ( +
- ))} - - -
- {RANK_BUCKETS.map((bucket) => ( - - - {bucket.label} + ); + })} +
+
+ + + 已发布 {promotionFunnel.counts.published} + + + + 待发布 {promotionFunnel.counts.ready} + + + + 规划中 {promotionFunnel.counts.planned} + + {promotionFunnel.counts.skipped > 0 && ( + + + 跳过 {promotionFunnel.counts.skipped} - ))} + )}
)}
+ +
); } diff --git a/src/renderer/components/overview/OverviewPage.tsx b/src/renderer/components/overview/OverviewPage.tsx index 3c87312..37d3849 100644 --- a/src/renderer/components/overview/OverviewPage.tsx +++ b/src/renderer/components/overview/OverviewPage.tsx @@ -69,6 +69,35 @@ export function OverviewPage() { completed: number; generatedAt: string | null; } | null>(null); + // L0 系统脉搏条:调度器状态(60s 轮询 + data-changed 时机由任务中心维护)。 + const [schedulerStatus, setSchedulerStatus] = useState<{ + enabled: boolean; + total: number; + due: number; + failed: number; + nextDueAt: string | null; + } | null>(null); + // 推广卡:推广活动列表(X 系列帖子状态机),与推广页同源 IPC。 + const [promotionCampaigns, setPromotionCampaigns] = useState(null); + + useEffect(() => { + const refresh = () => { + (window as any).appilot?.scheduler?.status() + .then((status: any) => setSchedulerStatus(status || null)) + .catch(() => undefined); + }; + refresh(); + const timer = window.setInterval(refresh, 60_000); + return () => window.clearInterval(timer); + }, [project?.id]); + + useEffect(() => { + setPromotionCampaigns(null); + if (!project?.id || !product?.id) return; + (window as any).appilot?.promotion?.list(project.id, product.id) + .then((result: any) => setPromotionCampaigns(result?.campaigns || [])) + .catch(() => setPromotionCampaigns([])); + }, [project?.id, product?.id]); useEffect(() => { setCopilotSummary(null); @@ -331,7 +360,9 @@ export function OverviewPage() { repoMetrics={repoMetrics} competitorSummary={competitorSummary} competitorAdvantage={competitorAdvantage} - competitorHref="/keywords" + schedulerStatus={schedulerStatus} + promotionCampaigns={promotionCampaigns} + competitorHref="/keywords?tab=competitor" copilotSummary={copilotSummary} onSelectProduct={selectProduct} onOpenExternal={(url) => (window as any).appilot?.openExternal(url)} diff --git a/src/renderer/components/tasks/TaskCenterPage.tsx b/src/renderer/components/tasks/TaskCenterPage.tsx index 9cf136e..ece0c42 100644 --- a/src/renderer/components/tasks/TaskCenterPage.tsx +++ b/src/renderer/components/tasks/TaskCenterPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { storefrontDisplayName } from "@appilot-labs/appilot-core/storefronts"; import { useProject } from "../../stores/project"; @@ -378,39 +378,59 @@ export function TaskCenterPage() { // 不同类型任务适用的筛选条件不同:排名任务才有语言维度;GitHub/数据同步 // 是项目级任务(无平台/语言);构建状态是产品级(有平台、无语言)。 - const typeTasks = - typeFilter === "all" - ? data?.tasks || [] - : (data?.tasks || []).filter((task: any) => task.kind === typeFilter); - const projectOptions = Array.from( - new Set( - typeTasks - .map((task: any) => task.projectName) - .filter((name: string) => name && name !== "已删除项目"), - ), - ).sort(); - const platformOptions = Array.from( - new Set(typeTasks.map((task: any) => task.platform).filter(Boolean)), - ).sort(); - const languageOptions = Array.from( - new Set( - typeTasks - .map((task: any) => task.queryLanguage) - .filter((lang: string) => Boolean(lang)), - ), - ).sort(); + // 派生链 memo 化:加速模式期间倒计时每秒触发整页重渲染,筛选/排序 + // 只应在任务数据或筛选条件变化时重算,而不是每秒。 + const allTasks = useMemo(() => data?.tasks || [], [data]); + const typeTasks = useMemo( + () => + typeFilter === "all" + ? allTasks + : allTasks.filter((task: any) => task.kind === typeFilter), + [allTasks, typeFilter], + ); + const projectOptions = useMemo( + () => + Array.from( + new Set( + typeTasks + .map((task: any) => task.projectName) + .filter((name: string) => name && name !== "已删除项目"), + ), + ).sort(), + [typeTasks], + ); + const platformOptions = useMemo( + () => + Array.from(new Set(typeTasks.map((task: any) => task.platform).filter(Boolean))).sort(), + [typeTasks], + ); + const languageOptions = useMemo( + () => + Array.from( + new Set( + typeTasks + .map((task: any) => task.queryLanguage) + .filter((lang: string) => Boolean(lang)), + ), + ).sort(), + [typeTasks], + ); const typeSupportsPlatform = typeFilter === "all" || typeFilter === "rank" || typeFilter === "build-status"; const typeSupportsLanguage = typeFilter === "all" || typeFilter === "rank"; - const tasks = (data?.tasks || []) - .filter((task) => projectFilter === "all" || task.projectName === projectFilter) - .filter((task) => platformFilter === "all" || task.platform === platformFilter) - .filter((task) => languageFilter === "all" || task.queryLanguage === languageFilter) - .filter((task) => typeFilter === "all" || task.kind === typeFilter); - const pending = tasks.filter((task) => task.enabled); - const failed = tasks.filter((task) => task.lastStatus === "failed"); + const tasks = useMemo( + () => + allTasks + .filter((task) => projectFilter === "all" || task.projectName === projectFilter) + .filter((task) => platformFilter === "all" || task.platform === platformFilter) + .filter((task) => languageFilter === "all" || task.queryLanguage === languageFilter) + .filter((task) => typeFilter === "all" || task.kind === typeFilter), + [allTasks, projectFilter, platformFilter, languageFilter, typeFilter], + ); + const pending = useMemo(() => tasks.filter((task) => task.enabled), [tasks]); + const failed = useMemo(() => tasks.filter((task) => task.lastStatus === "failed"), [tasks]); const pendingGroups = groupTasks(pending); const failedGroups = groupTasks(failed); diff --git a/src/renderer/lib/matrix.ts b/src/renderer/lib/matrix.ts index b416094..d1f461b 100644 --- a/src/renderer/lib/matrix.ts +++ b/src/renderer/lib/matrix.ts @@ -63,16 +63,24 @@ export function matrixCellState( const list = snapshots .filter((snapshot) => snapshot.keyword === keyword && snapshot.storefront === storefront) .sort((a, b) => new Date(a.checkedAt).getTime() - new Date(b.checkedAt).getTime()); + return cellStateFromList(list); +} + +/** 无任何快照时的空格子(共享只读对象,调用方只读不写)。 */ +const EMPTY_CELL: MatrixCell = { + rank: null, + beyond200: false, + delta: null, + trend: "none", + checkedAt: null, + totalResults: null, +}; + +/** 由按时间升序的同格快照列表计算 cell(含与上一次的 delta/trend)。 */ +function cellStateFromList(list: MatrixSnapshot[]): MatrixCell { const latest = list[list.length - 1]; if (!latest) { - return { - rank: null, - beyond200: false, - delta: null, - trend: "none", - checkedAt: null, - totalResults: null, - }; + return { ...EMPTY_CELL }; } const previous = list[list.length - 2]; let delta: number | null = null; @@ -94,6 +102,30 @@ export function matrixCellState( }; } +/** + * O(快照数) 一次性索引:每个 (keyword, storefront) 格的最终 cell 预先算好, + * 查询 O(1)。矩阵视图每次重渲染要读 行×列 个格子,直接调 matrixCellState + * 会变成 O(行×列×快照数)(勾选一次都卡),渲染期必须走这个索引。 + */ +export type CellLookup = (keyword: string, storefront: string) => MatrixCell; + +export function buildCellIndex(snapshots: MatrixSnapshot[]): CellLookup { + const byKey = new Map(); + for (const snapshot of snapshots) { + const key = `${snapshot.keyword}\u0000${snapshot.storefront}`; + const list = byKey.get(key); + if (list) list.push(snapshot); + else byKey.set(key, [snapshot]); + } + const cells = new Map(); + for (const [key, list] of byKey) { + list.sort((a, b) => new Date(a.checkedAt).getTime() - new Date(b.checkedAt).getTime()); + cells.set(key, cellStateFromList(list)); + } + return (keyword: string, storefront: string) => + cells.get(`${keyword}\u0000${storefront}`) ?? EMPTY_CELL; +} + export function matrixColumnMeta( snapshots: { storefront: string; checkedAt: string }[], storefront: string, @@ -112,15 +144,15 @@ export function matrixColumnMeta( export function matrixRowGroups( rows: T[], columns: { storefront: string }[], - snapshots: MatrixSnapshot[], + cell: CellLookup, ): { ranked: { row: T; bestRank: number }[]; unranked: T[] } { const ranked: { row: T; bestRank: number }[] = []; const unranked: T[] = []; for (const row of rows) { let best = Number.POSITIVE_INFINITY; for (const column of columns) { - const cell = matrixCellState(snapshots, row.keyword, column.storefront); - if (cell.rank != null && cell.rank < best) best = cell.rank; + const state = cell(row.keyword, column.storefront); + if (state.rank != null && state.rank < best) best = state.rank; } if (best === Number.POSITIVE_INFINITY) unranked.push(row); else ranked.push({ row, bestRank: best }); diff --git a/tests/matrix.test.ts b/tests/matrix.test.ts index e8f07ea..166020f 100644 --- a/tests/matrix.test.ts +++ b/tests/matrix.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { + buildCellIndex, matrixCellState, matrixColumnMeta, matrixFilterKeywords, @@ -69,15 +70,29 @@ const metaStale = matrixColumnMeta([{ storefront: "us", checkedAt: staleTime }], assert.equal(metaStale.stale, true); assert.equal(metaStale.lastCheckedAt, staleTime); +console.log("✅ PASS: buildCellIndex O(1) lookup matches matrixCellState"); +const indexSnapshots = [ + { keyword: "night walk", storefront: "us", rank: 5, totalResults: 200, checkedAt: "2026-08-18T10:00:00.000Z" }, + { keyword: "night walk", storefront: "us", rank: 3, totalResults: 200, checkedAt: "2026-08-19T10:00:00.000Z" }, + { keyword: "night walk", storefront: "cn", rank: 40, totalResults: 200, checkedAt: "2026-08-19T10:00:00.000Z" }, +]; +const index = buildCellIndex(indexSnapshots); +const indexed = index("night walk", "us"); +assert.equal(indexed.rank, 3, "index returns latest rank"); +assert.equal(indexed.delta, 2, "index keeps delta vs previous"); +assert.equal(indexed.trend, "up"); +assert.equal(index("night walk", "gb").rank, null, "missing cell falls back to empty"); +assert.deepEqual(index("night walk", "us"), matrixCellState(indexSnapshots, "night walk", "us"), "index agrees with matrixCellState"); + console.log("✅ PASS: matrixRowGroups splits ranked (best first) and unranked"); const groups = matrixRowGroups( [{ keyword: "deep link" }, { keyword: "记账" }, { keyword: "night walk" }], [{ storefront: "us" }, { storefront: "cn" }], - [ + buildCellIndex([ { keyword: "night walk", storefront: "us", rank: 5, totalResults: 200, checkedAt: "2026-08-19T10:00:00.000Z" }, { keyword: "记账", storefront: "cn", rank: 2, totalResults: 200, checkedAt: "2026-08-19T10:00:00.000Z" }, { keyword: "deep link", storefront: "us", rank: null, totalResults: 200, checkedAt: "2026-08-19T10:00:00.000Z" }, - ], + ]), ); assert.deepEqual( groups.ranked.map((item) => item.row.keyword),