From ab77a0e76c26e7ee2e25fe6ca054e551958494f5 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 21 Sep 2026 00:51:02 +0800 Subject: [PATCH] feat(site): show plugin install counts on marketplace cards --- deploy/plugin-stats-worker/README.md | 9 ++++-- deploy/plugin-stats-worker/worker.ts | 22 +++++++++++++- docs/app/[lang]/plugins/PluginsClient.tsx | 18 ++++++++++- docs/app/[lang]/plugins/page.tsx | 5 ++- docs/lib/pluginStats.ts | 37 +++++++++++++++++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 docs/lib/pluginStats.ts diff --git a/deploy/plugin-stats-worker/README.md b/deploy/plugin-stats-worker/README.md index 3cebfc851..b2e2a5cd9 100644 --- a/deploy/plugin-stats-worker/README.md +++ b/deploy/plugin-stats-worker/README.md @@ -18,6 +18,10 @@ directory; OAuth login required). optional and absent on legacy app versions). Decorative statistics only: no auth, no PII — `clientId` is a purely random per-installation id the app generates locally, not a hardware or user fingerprint. +- `dbxio.com/api/plugins/stats` — `GET` public display counters for the + website: `{"installs": {"": }}` read from the archive + summary's `inst` section only (dl/updt and unique counts stay internal). + Cached for 5 minutes. ## Storage — Workers Analytics Engine @@ -65,8 +69,9 @@ curl -s -X POST https://dbxio.com/api/plugins/archive -H "x-archive-token: = { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Max-Age": "86400", }; +// Served from the archive summary's `inst` section: the agreed public display +// number (fresh installs; the pre-2026-09-19 mixed install+update base). Raw +// dl/updt and unique counters stay internal. +async function handleStatsRequest(env: Env): Promise { + let installs: Record = {}; + try { + const summary = JSON.parse((await env.PLUGIN_ARCHIVE.get(ARCHIVE_SUMMARY_KEY)) ?? "{}") as Record>; + installs = summary.inst ?? {}; + } catch (error) { + console.error("plugin-stats summary read failed", error); + } + return Response.json({ installs }, { headers: { ...CORS_HEADERS, "Cache-Control": "public, max-age=300" } }); +} + // Truncated HMAC of (salt, ip|utc-day): lets aggregations count distinct // downloaders per group without storing IPs. Anti-inflation only — a // determined attacker rotating IPs can still inflate; that is acceptable for @@ -236,6 +252,10 @@ async function handleInstallBeacon(request: Request, env: Env): Promise `${formatPluginInstalls(count, "en")} installs`, submitTitle: "Built a plugin for DBX?", submitDesc: "Package it as a .dbxp, submit a PR to dbx-store, and it will appear here and in the in-app plugin center after review.", submitAction: "Submit to dbx-store", @@ -30,6 +32,7 @@ const i18n = { title: "插件中心", viewPlugin: "查看插件", refreshError: "目录刷新失败,当前展示构建时的快照。", + installCount: (count: number) => `${formatPluginInstalls(count, "cn")} 次安装`, submitTitle: "为 DBX 开发了插件?", submitDesc: "打包为 .dbxp,向 dbx-store 提交 PR,审核通过后会同时出现在本页面和客户端插件中心。", submitAction: "提交到 dbx-store", @@ -46,8 +49,17 @@ function mergePlugins(snapshot: MarketplacePlugin[], live: MarketplacePlugin[]): return [...byId.values()]; } -export function PluginsClient({ lang, initialPlugins }: { lang: DocsLang; initialPlugins: MarketplacePlugin[] }) { +export function PluginsClient({ + lang, + initialPlugins, + initialInstalls, +}: { + lang: DocsLang; + initialPlugins: MarketplacePlugin[]; + initialInstalls: PluginInstalls | null; +}) { const [plugins, setPlugins] = useState(initialPlugins); + const [installs, setInstalls] = useState(initialInstalls); const [refreshFailed, setRefreshFailed] = useState(false); const t = i18n[lang]; @@ -62,6 +74,9 @@ export function PluginsClient({ lang, initialPlugins }: { lang: DocsLang; initia .catch(() => { if (!cancelled) setRefreshFailed(true); }); + fetchPluginInstalls({ cache: "no-cache" }).then((data) => { + if (!cancelled && data) setInstalls(data); + }); return () => { cancelled = true; }; @@ -112,6 +127,7 @@ export function PluginsClient({ lang, initialPlugins }: { lang: DocsLang; initia

{plugin.publisher} {plugin.license ? ` · ${plugin.license}` : ""} + {installs?.[plugin.id] ? ` · ${t.installCount(installs[plugin.id])}` : ""}

diff --git a/docs/app/[lang]/plugins/page.tsx b/docs/app/[lang]/plugins/page.tsx index b555e445a..8bf70a66d 100644 --- a/docs/app/[lang]/plugins/page.tsx +++ b/docs/app/[lang]/plugins/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { fetchPluginCatalog } from "@/lib/pluginCatalog"; +import { fetchPluginInstalls } from "@/lib/pluginStats"; import { buildMetadata } from "@/lib/metadata"; import { resolveLang } from "@/lib/i18n"; import { PluginsClient } from "./PluginsClient"; @@ -34,6 +35,8 @@ export default async function PluginsPage({ params }: { params: Promise<{ lang: const l = resolveLang(lang); // Build-time snapshot for instant paint and SEO; the client refreshes it from R2 on mount. const catalog = await fetchPluginCatalog({ cache: "force-cache" }); + // Install counters are decorative: a null snapshot just renders cards without counts. + const installs = await fetchPluginInstalls({ cache: "force-cache" }); - return ; + return ; } diff --git a/docs/lib/pluginStats.ts b/docs/lib/pluginStats.ts new file mode 100644 index 000000000..8956f9b52 --- /dev/null +++ b/docs/lib/pluginStats.ts @@ -0,0 +1,37 @@ +import type { DocsLang } from "@/lib/i18n"; + +// Display counters for marketplace cards. Absolute URL so local dev/preview +// also hits the live endpoint (it answers with permissive CORS); only the +// public installs number is exposed — dl/updt and unique counts stay internal. +export const PLUGIN_STATS_URL = "https://dbxio.com/api/plugins/stats"; + +const PLUGIN_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/; + +export type PluginInstalls = Record; + +// Failure is decorative, not fatal: cards simply render without a count. +export async function fetchPluginInstalls(init?: RequestInit): Promise { + try { + const response = await fetch(PLUGIN_STATS_URL, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(8000), + ...init, + }); + if (!response.ok) throw new Error(`Plugin stats request failed with ${response.status}`); + const payload = (await response.json()) as { installs?: unknown }; + if (typeof payload !== "object" || payload === null || typeof payload.installs !== "object" || payload.installs === null) { + throw new Error("Plugin stats payload malformed"); + } + const installs: PluginInstalls = {}; + for (const [id, value] of Object.entries(payload.installs as Record)) { + if (PLUGIN_ID_PATTERN.test(id) && typeof value === "number" && Number.isFinite(value) && value > 0) installs[id] = value; + } + return installs; + } catch { + return null; + } +} + +export function formatPluginInstalls(count: number, lang: DocsLang): string { + return new Intl.NumberFormat(lang === "cn" ? "zh-CN" : "en-US").format(count); +}