From 0c106c4899b5e1f23f55b05d5f74ddf3b33c1b71 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:52:02 +0800 Subject: [PATCH] Guidance: a get-started checklist and next-step hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First run: the Overview's "never used" empty state becomes a three-step checklist (add an upstream, connect a client, send a first request). Each step is ticked from the facts — how many upstreams there are, which clients are connected (adopted, or set up by hand with a key, or whose probes the gateway already answered) — not from which buttons were pressed, so an upstream added elsewhere counts too. Only the current step offers its button; the last one shows that it is waiting for a request. The checklist gives way to the usage figures when the first real request arrives. During use, one-line hints at the moment they apply, each with the button for the next step and "Don't show again": - Overview: the first request went through (only for people who saw the checklist; upgraders are not told about a "first" request), with a button that opens it. - Upstreams: there are upstreams but no client is connected yet. - Clients: no upstream yet; or clients are connected but no request has arrived, naming them. - Traffic: what opening a request shows. - Security: how many protections are in Observe, and that they can be set to Enforce once the log shows no false positives. Hints disappear on their own when their condition stops holding. What was dismissed is kept on this computer (localStorage), not in config.yaml, and Settings › General gains "Guidance › Show again". The checklist and hints use the existing Banner (one lightbulb icon for hints only) and the "接管" terminology. Co-Authored-By: Claude Opus 5.5 --- src/clients/ClientsPage.tsx | 2 + src/guide/FirstRequestHint.tsx | 39 +++++++++ src/guide/Hint.tsx | 56 ++++++++++++ src/guide/PageHints.tsx | 106 +++++++++++++++++++++++ src/guide/SetupGuide.tsx | 148 ++++++++++++++++++++++++++++++++ src/guide/guide.i18n.ts | 116 +++++++++++++++++++++++++ src/guide/hints.test.ts | 93 ++++++++++++++++++++ src/guide/hints.ts | 109 +++++++++++++++++++++++ src/guide/useFirstRequest.ts | 34 ++++++++ src/overview/OverviewPage.tsx | 43 ++-------- src/overview/overview.i18n.ts | 19 ---- src/security/SecurityPage.tsx | 4 + src/settings/GeneralSection.tsx | 35 ++++++++ src/traffic/TrafficPage.tsx | 3 + src/upstreams/UpstreamsPage.tsx | 2 + 15 files changed, 756 insertions(+), 53 deletions(-) create mode 100644 src/guide/FirstRequestHint.tsx create mode 100644 src/guide/Hint.tsx create mode 100644 src/guide/PageHints.tsx create mode 100644 src/guide/SetupGuide.tsx create mode 100644 src/guide/guide.i18n.ts create mode 100644 src/guide/hints.test.ts create mode 100644 src/guide/hints.ts create mode 100644 src/guide/useFirstRequest.ts diff --git a/src/clients/ClientsPage.tsx b/src/clients/ClientsPage.tsx index 58c27b4d..7f72a85b 100644 --- a/src/clients/ClientsPage.tsx +++ b/src/clients/ClientsPage.tsx @@ -36,6 +36,7 @@ import { PlanDialog } from "./PlanDialog"; import { RestoreAllDialog } from "./RestoreAllDialog"; import { MirroredDialog, RestartWslDialog } from "./WslDialogs"; import { hostOf, isLoopback, manualStatusOf, statusOf, type ClientState, type Status, type WslPlace } from "./status"; +import { ClientsPageHints } from "@/guide/PageHints"; /** `env`:在哪个 WSL 发行版里(发行版的名字);这台电脑上的不带 */ type DialogState = @@ -330,6 +331,7 @@ export default function ClientsPage({ } /> + {remote && data && ( } className="mb-3"> {rt.clientsNote(remote.name, hostOf(data.gateway_base))} diff --git a/src/guide/FirstRequestHint.tsx b/src/guide/FirstRequestHint.tsx new file mode 100644 index 00000000..5f96eae4 --- /dev/null +++ b/src/guide/FirstRequestHint.tsx @@ -0,0 +1,39 @@ +import { useText } from "@/i18n"; +import { useNav } from "@/nav"; +import { Button } from "@/ui/button"; +import { guideText } from "./guide.i18n"; +import { Hint } from "./Hint"; +import { useSetupSeen } from "./hints"; +import { useFirstRequest } from "./useFirstRequest"; + +/** + * 「开始使用」走完的那一刻:第一条请求到了,指给人去看它。 + * + * **只对看过「开始使用」的人说**(见 `hints.ts` 的 `setupSeen`)。按钮打开的是最新 + * 那一条 —— 说完这句之前又来了几条也无妨,要看的就是一条真实的请求长什么样。 + */ +export function FirstRequestHint({ when, className }: { when: boolean; className?: string }) { + const t = useText(guideText); + const nav = useNav(); + const seen = useSetupSeen(); + const { latestId } = useFirstRequest(); + return ( + latestId !== null && nav.open("requests", { request: latestId })} + > + {t.viewRequest} + + } + > + {t.firstRequestBody} + + ); +} diff --git a/src/guide/Hint.tsx b/src/guide/Hint.tsx new file mode 100644 index 00000000..b077f2d7 --- /dev/null +++ b/src/guide/Hint.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { LightbulbIcon } from "lucide-react"; +import { useText } from "@/i18n"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { guideText } from "./guide.i18n"; +import { useHint, type HintId } from "./hints"; + +/** + * 一条引导提示:页内的一块(`Banner` 的 inline 摆法),灯泡图标,右边是下一步的按钮 + * 和「不再显示」。 + * + * **灯泡只用在这里**:一个图标一种意思,看到它就知道这是一句提示、不是故障。 + * 「不再显示」写成字,不用 ×(× 只管关闭)。条件不再成立时它自己收起,点过「不再显示」 + * 就不再出现(见 `hints.ts`)。 + */ +export function Hint({ + id, + when, + title, + children, + action, + className, +}: { + id: HintId; + /** 此刻该不该说:数据没到时传 false */ + when: boolean; + title: ReactNode; + children?: ReactNode; + /** 下一步的那个按钮(`size="sm" variant="outline"`) */ + action?: ReactNode; + className?: string; +}) { + const t = useText(guideText); + const { show, dismiss } = useHint(id, when); + return ( + } + title={title} + className={className} + actions={ + <> + {action} + + + } + > + {children} + + ); +} diff --git a/src/guide/PageHints.tsx b/src/guide/PageHints.tsx new file mode 100644 index 00000000..1c682687 --- /dev/null +++ b/src/guide/PageHints.tsx @@ -0,0 +1,106 @@ +import { useClients } from "@/clients/data"; +import { useText } from "@/i18n"; +import { useNav } from "@/nav"; +import { Button } from "@/ui/button"; +import { guideText } from "./guide.i18n"; +import { Hint } from "./Hint"; +import { useFirstRequest } from "./useFirstRequest"; + +/** + * 各页的下一步提示。**条件都是此刻的事实**,事实一变(接进来了、请求到了)就自己收起; + * 每一条点过「不再显示」就不再出现。放在各页的 PageHeader 下面、内容上面。 + * + * 「接进来了」的口径和概览的「开始使用」一致:接管的、按说明手动配了密钥的都算。 + */ +function useConnected() { + const clients = useClients(); + const d = clients.data; + if (d === undefined) return { known: false, adopted: [] as { name: string }[], any: false }; + const adopted = d.clients.filter((c) => c.adopted_at_ms != null); + return { known: true, adopted, any: adopted.length > 0 || d.manual.some((m) => m.key != null) }; +} + +/** 上游页:有了上游,还没有客户端接进来,也还没有请求 */ +export function NextClientsHint({ upstreams, className }: { upstreams: number; className?: string }) { + const t = useText(guideText); + const nav = useNav(); + const connected = useConnected(); + const { used } = useFirstRequest(); + return ( + 0 && connected.known && !connected.any && used === false} + title={t.nextClientsTitle} + className={className} + action={ + + } + > + {t.nextClientsBody} + + ); +} + +/** + * 客户端页的两条,一次只说一条:还没有上游时先说上游;有了上游、接进来了、还没有 + * 请求时,说去客户端里发一条消息。 + */ +export function ClientsPageHints({ upstreams, className }: { upstreams: number | undefined; className?: string }) { + const t = useText(guideText); + const nav = useNav(); + const connected = useConnected(); + const { used } = useFirstRequest(); + const names = connected.adopted.map((c) => c.name).join(t.listSep); + return ( + <> + nav.open("upstreams", { create: "upstream" })}> + {t.newUpstream} + + } + > + {t.nextUpstreamBody} + + 0 && connected.adopted.length > 0 && used === false} + title={t.nextRequestTitle} + className={className} + action={ + + } + > + {t.nextRequestBody(names)} + + + ); +} + +/** 流量页:表里有请求了,说一句点开能看到什么 */ +export function OpenRowHint({ when, className }: { when: boolean; className?: string }) { + const t = useText(guideText); + return ( + + {t.openRowBody} + + ); +} + +/** 安全页:有防护停在「观察」。`count` 是此刻停在那儿的项数 */ +export function ObserveHint({ count, className }: { count: number; className?: string }) { + const t = useText(guideText); + return ( + 0} title={t.observeTitle(count)} className={className}> + {t.observeBody} + + ); +} diff --git a/src/guide/SetupGuide.tsx b/src/guide/SetupGuide.tsx new file mode 100644 index 00000000..effe0ac2 --- /dev/null +++ b/src/guide/SetupGuide.tsx @@ -0,0 +1,148 @@ +import { useEffect, type ReactNode } from "react"; +import { CheckIcon } from "lucide-react"; +import { useClients } from "@/clients/data"; +import { useText } from "@/i18n"; +import { cn } from "@/lib/utils"; +import { useNav } from "@/nav"; +import { Button } from "@/ui/button"; +import { ClientLogo } from "@/ui/logos"; +import { StatusLabel } from "@/ui/status-dot"; +import { guideText } from "./guide.i18n"; +import { markSetupSeen } from "./hints"; + +type StepState = "done" | "current" | "later"; + +/** + * 概览上的「开始使用」:从装好到第一条请求经过网关的三步。 + * + * 只在「从没用过」时出现(和原来的空状态同一个条件),第一条真正的请求一到就整块 + * 让位给用量数字。**每一步的勾都按此刻的事实打**:上游有几个、哪些客户端接进来了, + * 不靠「点过那个按钮」—— 在别处加的上游、在客户端页接的客户端同样算数。 + * + * 当前那一步(第一个没做完的)给按钮,后面的灰着不给:一次只指一个方向。第三步 + * 没有按钮可按,它等的是客户端里的一条消息,给一个在等的状态。 + */ +export function SetupGuide({ upstreams, probes }: { upstreams: number; probes: number }) { + const t = useText(guideText); + const nav = useNav(); + const clients = useClients(); + + useEffect(() => markSetupSeen(), []); + + const list = clients.data?.clients ?? []; + const adopted = list.filter((c) => c.adopted_at_ms != null); + const found = list.filter((c) => c.installed && c.adopted_at_ms == null && !c.managed); + const manual = (clients.data?.manual ?? []).filter((m) => m.key != null); + const names = (xs: { name: string }[]) => xs.map((x) => x.name).join(t.listSep); + + const hasUpstream = upstreams > 0; + // 手动配的(Cursor 之类)也算接进来了;客户端的探测被网关答过,说明它也连上了 + const hasClient = adopted.length > 0 || manual.length > 0 || probes > 0; + const done = [hasUpstream, hasClient, false]; + const current = done.indexOf(false); + const state = (i: number): StepState => (done[i] ? "done" : i === current ? "current" : "later"); + + return ( +
+
+

+ {t.setupTitle} +

+ + {t.setupProgress(done.filter(Boolean).length, done.length)} + +
+
    + nav.open("upstreams", { create: "upstream" })}> + {t.newUpstream} + + } + /> + 0 ? ( + t.stepClientDone(names(adopted)) + ) : manual.length > 0 ? ( + t.stepClientManual + ) : found.length > 0 ? ( + + + {found.slice(0, 4).map((c) => ( + + ))} + + {t.stepClientFound(names(found))} + + ) : clients.data !== undefined ? ( + t.stepClientNone + ) : null + } + action={ + + } + /> + + {t.stepRequestTodo} + {probes > 0 && {t.probes(probes)}} + + } + action={{t.waiting}} + /> +
+
+ ); +} + +function Step({ + n, + state, + title, + detail, + action, +}: { + n: number; + state: StepState; + title: string; + detail: ReactNode; + /** 只在当前那一步出现 */ + action: ReactNode; +}) { + return ( +
  • + + {state === "done" ? : n} + +
    +

    {title}

    + {detail &&
    {detail}
    } +
    + {state === "current" &&
    {action}
    } +
  • + ); +} diff --git a/src/guide/guide.i18n.ts b/src/guide/guide.i18n.ts new file mode 100644 index 00000000..0c1ab0dd --- /dev/null +++ b/src/guide/guide.i18n.ts @@ -0,0 +1,116 @@ +import { messages } from "@/i18n"; + +/** + * 引导的文案:概览上的「开始使用」三步,和各页的下一步提示。 + * + * **只说下一步做什么,不讲道理**:用户要的是往前走一步的那个按钮,不是网关怎么工作。 + */ +export const guideText = messages( + { + /** 关掉一条提示,以后也不再出现 */ + hide: "不再显示", + + // 概览:开始使用 + setupTitle: "开始使用", + /** 三步做完了几步 */ + setupProgress: (done: number, total: number) => `${done}/${total}`, + stepUpstream: "添加上游", + stepUpstreamTodo: "API 密钥、中转服务、本机模型,或 ChatGPT、Z.ai 账号。", + stepUpstreamDone: (n: number) => `已添加 ${n} 个上游`, + newUpstream: "新建上游…", + stepClient: "接管客户端", + /** 检测到了、还没接的那几个,用顿号连起来 */ + stepClientFound: (names: string) => `检测到 ${names}。接管前会先列出要改动的配置。`, + stepClientNone: "未检测到可自动接管的客户端,可按说明手动配置。", + stepClientDone: (names: string) => `已接管 ${names}`, + /** 只配了手动的(Cursor 之类),没有接管的 */ + stepClientManual: "已按说明手动配置", + goClients: "前往客户端", + stepRequest: "发出第一条请求", + stepRequestTodo: "在已接管的客户端中发一条消息。", + waiting: "等待请求", + /** 客户端的探测由网关直接答了:说明它连上了 */ + probes: (n: number) => `已直接应答 ${n} 次客户端探测,客户端已连接网关。`, + /** 名字之间的分隔 */ + listSep: "、", + + // 上游页 + nextClientsTitle: "下一步:接管客户端", + nextClientsBody: "接管后,客户端的请求经网关发往上游。", + + // 客户端页 + nextUpstreamTitle: "尚未添加上游", + nextUpstreamBody: "接管的客户端发出的请求,需要有上游才能送达。", + nextRequestTitle: "下一步:发出第一条请求", + nextRequestBody: (names: string) => `在 ${names} 中发一条消息,流量页即会显示这条请求。`, + goTraffic: "前往流量", + + // 概览:第一条请求 + firstRequestTitle: "第一条请求已经过网关", + firstRequestBody: "它的路由、尝试过的上游、用量与费用已记录在流量页。", + viewRequest: "查看这条请求", + + // 流量页 + openRowTitle: "点开一条请求查看详情", + openRowBody: "命中的路由规则、尝试过的上游、用量与费用,以及被替换的敏感值,都在详情中。", + + // 安全页 + /** 此刻停在「观察」的有几项 */ + observeTitle: (n: number) => `${n} 项防护处于「观察」`, + observeBody: "命中时只记录,不拦截。在日志中确认没有误报后,可将其改为「拦截」。", + + // 设置 + hintsLabel: "引导提示", + hintsHint: "设为「不再显示」的提示将重新出现。", + hintsReset: "重新显示", + hintsResetDone: "引导提示将重新显示", + }, + { + hide: "Don’t show again", + + setupTitle: "Get started", + setupProgress: (done: number, total: number) => `${done}/${total}`, + stepUpstream: "Add an upstream", + stepUpstreamTodo: "An API key, a relay service, a local model, or a ChatGPT or Z.ai account.", + stepUpstreamDone: (n: number) => (n === 1 ? "1 upstream added" : `${n} upstreams added`), + newUpstream: "New upstream…", + stepClient: "Connect a client", + stepClientFound: (names: string) => `Found ${names}. The configuration changes are listed before anything is written.`, + stepClientNone: "No client that can be connected automatically was found; manual setup instructions are available.", + stepClientDone: (names: string) => `Connected: ${names}`, + stepClientManual: "Set up manually", + goClients: "Go to Clients", + stepRequest: "Send a first request", + stepRequestTodo: "Send a message from a connected client.", + waiting: "Waiting for a request", + probes: (n: number) => + n === 1 + ? "1 client probe was answered directly; a client is connected to the gateway." + : `${n} client probes were answered directly; a client is connected to the gateway.`, + listSep: ", ", + + nextClientsTitle: "Next: connect a client", + nextClientsBody: "Once connected, a client's requests go through the gateway to the upstreams.", + + nextUpstreamTitle: "No upstream yet", + nextUpstreamBody: "Requests from connected clients need an upstream to reach a model.", + nextRequestTitle: "Next: send a first request", + nextRequestBody: (names: string) => `Send a message from ${names}; the request then appears on the Traffic page.`, + goTraffic: "Go to Traffic", + + firstRequestTitle: "The first request went through the gateway", + firstRequestBody: "Its route, the upstreams it tried, its usage and cost are recorded on the Traffic page.", + viewRequest: "View request", + + openRowTitle: "Open a request for its details", + openRowBody: "The routing rule it matched, the upstreams it tried, its usage and cost, and any redacted values.", + + observeTitle: (n: number) => (n === 1 ? "1 protection is set to Observe" : `${n} protections are set to Observe`), + observeBody: "Matches are recorded, not blocked. Once the log shows no false positives, a protection can be set to Enforce.", + + hintsLabel: "Guidance", + hintsHint: "Hints set to “Don’t show again” reappear.", + hintsReset: "Show again", + hintsResetDone: "Hints will show again", + }, +); diff --git a/src/guide/hints.test.ts b/src/guide/hints.test.ts new file mode 100644 index 00000000..d3c2ff57 --- /dev/null +++ b/src/guide/hints.test.ts @@ -0,0 +1,93 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** 内存里的 localStorage:每个用例一份新的,模块也重新载入(= 重新打开应用) */ +function memoryStorage(seed?: string) { + const data = new Map(seed === undefined ? [] : [["tw-guide", seed]]); + return { + getItem: (k: string) => data.get(k) ?? null, + setItem: (k: string, v: string) => void data.set(k, v), + removeItem: (k: string) => void data.delete(k), + raw: () => data.get("tw-guide"), + }; +} + +async function load(storage: ReturnType) { + vi.stubGlobal("window", { localStorage: storage }); + vi.resetModules(); + return import("./hints"); +} + +/** 画一个用 useHint 的组件,看它此刻说不说 */ +function shown(mod: Awaited>, id: Parameters[0], when: boolean) { + const Probe = () => (mod.useHint(id, when).show ? "on" : "off"); + return renderToStaticMarkup(createElement(Probe)); +} + +describe("引导提示", () => { + beforeEach(() => vi.unstubAllGlobals()); + afterEach(() => vi.unstubAllGlobals()); + + it("条件成立、没点过「不再显示」时才出现", async () => { + const mod = await load(memoryStorage()); + expect(shown(mod, "traffic-open-row", true)).toBe("on"); + expect(shown(mod, "traffic-open-row", false)).toBe("off"); + }); + + it("点过「不再显示」之后不再出现,重新打开应用也一样", async () => { + const storage = memoryStorage(); + const mod = await load(storage); + // 点击发生在组件里:拿到 dismiss 再调 + let dismiss = () => {}; + const Grab = () => { + dismiss = mod.useHint("security-observe", true).dismiss; + return null; + }; + renderToStaticMarkup(createElement(Grab)); + dismiss(); + expect(shown(mod, "security-observe", true)).toBe("off"); + // 别的提示不受影响 + expect(shown(mod, "traffic-open-row", true)).toBe("on"); + const again = await load(memoryStorage(storage.raw())); + expect(shown(again, "security-observe", true)).toBe("off"); + }); + + it("「重新显示」让关掉的全部回来,「开始使用」看过的记录留着", async () => { + const storage = memoryStorage(JSON.stringify({ dismissed: ["next-clients", "first-request"], setupSeen: true })); + const mod = await load(storage); + expect(shown(mod, "next-clients", true)).toBe("off"); + mod.resetHints(); + expect(shown(mod, "next-clients", true)).toBe("on"); + expect(shown(mod, "first-request", true)).toBe("on"); + expect(JSON.parse(storage.raw()!)).toEqual({ dismissed: [], setupSeen: true }); + }); + + it("「开始使用」出现过才记一笔,只记一次", async () => { + const storage = memoryStorage(); + const mod = await load(storage); + const Seen = () => (mod.useSetupSeen() ? "seen" : "new"); + expect(renderToStaticMarkup(createElement(Seen))).toBe("new"); + mod.markSetupSeen(); + mod.markSetupSeen(); + expect(renderToStaticMarkup(createElement(Seen))).toBe("seen"); + expect(JSON.parse(storage.raw()!).setupSeen).toBe(true); + }); + + it("存的东西读不出来就当没看过", async () => { + const mod = await load(memoryStorage("{not json")); + expect(shown(mod, "next-upstream", true)).toBe("on"); + const odd = await load(memoryStorage(JSON.stringify({ dismissed: "next-upstream", setupSeen: "yes" }))); + expect(shown(odd, "next-upstream", true)).toBe("on"); + expect(renderToStaticMarkup(createElement(() => (odd.useSetupSeen() ? "seen" : "new")))).toBe("new"); + }); + + it("存不进去时这一次运行里照样不再出现", async () => { + const broken = { ...memoryStorage(), setItem: () => { throw new Error("quota"); } }; + const mod = await load(broken as unknown as ReturnType); + let dismiss = () => {}; + renderToStaticMarkup(createElement(() => ((dismiss = mod.useHint("next-request", true).dismiss), null))); + expect(() => dismiss()).not.toThrow(); + expect(shown(mod, "next-request", true)).toBe("off"); + }); +}); diff --git a/src/guide/hints.ts b/src/guide/hints.ts new file mode 100644 index 00000000..b5e3b433 --- /dev/null +++ b/src/guide/hints.ts @@ -0,0 +1,109 @@ +import { useSyncExternalStore } from "react"; + +/** + * 引导提示:在用到某处的那一刻说一句下一步,点「不再显示」之后不再出现。 + * + * **记在这台电脑上(localStorage),不进 config.yaml。**这是「这个人看过没有」, + * 不是网关的配置:连到远程 core 时也不该跟着服务器走,换一台电脑重新看一遍也无妨。 + * 读写失败(隐私模式、存储被清)就当没看过 —— 多提示一次的代价远小于少一次。 + */ +export type HintId = + /** 上游页:有了上游,还没有客户端接进来 */ + | "next-clients" + /** 客户端页:还没有上游 */ + | "next-upstream" + /** 客户端页:接进来了,还没有请求 */ + | "next-request" + /** 概览:第一条请求到了 */ + | "first-request" + /** 流量页:点开一条请求能看到什么 */ + | "traffic-open-row" + /** 安全页:防护出厂是观察 */ + | "security-observe"; + +const KEY = "tw-guide"; + +interface Stored { + /** 点过「不再显示」的 */ + dismissed: string[]; + /** + * 概览上的「开始使用」出现过。**第一条请求的那句只说给走过这几步的人**:升级上来 + * 的老用户早就有请求了,对他们说「第一条请求已经过网关」是胡话 + */ + setupSeen: boolean; +} + +function read(): Stored { + try { + const raw = window.localStorage.getItem(KEY); + if (raw) { + const v = JSON.parse(raw) as Partial; + return { + dismissed: Array.isArray(v.dismissed) ? v.dismissed.filter((x) => typeof x === "string") : [], + setupSeen: v.setupSeen === true, + }; + } + } catch { + // 读不出来就当没看过 + } + return { dismissed: [], setupSeen: false }; +} + +let state: Stored = read(); +const listeners = new Set<() => void>(); + +function commit(next: Stored) { + state = next; + try { + window.localStorage.setItem(KEY, JSON.stringify(next)); + } catch { + // 存不进去:这一次运行里照样不再出现,下次启动再提示一遍 + } + for (const l of listeners) l(); +} + +function subscribe(l: () => void) { + listeners.add(l); + return () => listeners.delete(l); +} + +// 快照:同一份状态返回同一个值,React 才不会每次都当成变了。第三个参数(服务端快照) +// 给的是同一个函数 —— 测试用 renderToStaticMarkup 画组件时要它 +const getDismissed = () => state.dismissed; +const getSetupSeen = () => state.setupSeen; +const getAnyDismissed = () => state.dismissed.length > 0; + +/** + * 一条提示此刻该不该出现:条件成立、而且没点过「不再显示」。 + * + * `when` 是那一刻的事实(还没有上游、还没有请求……),由调用方算;**数据没取到时 + * 传 false**,不要在数据到之前先闪一下。 + */ +export function useHint(id: HintId, when: boolean): { show: boolean; dismiss: () => void } { + const dismissed = useSyncExternalStore(subscribe, getDismissed, getDismissed); + return { + show: when && !dismissed.includes(id), + dismiss: () => { + if (!state.dismissed.includes(id)) commit({ ...state, dismissed: [...state.dismissed, id] }); + }, + }; +} + +/** 「开始使用」出现过没有(见 `Stored.setupSeen`) */ +export function useSetupSeen(): boolean { + return useSyncExternalStore(subscribe, getSetupSeen, getSetupSeen); +} + +export function markSetupSeen() { + if (!state.setupSeen) commit({ ...state, setupSeen: true }); +} + +/** 设置里的「重新显示」:点过「不再显示」的全部再出现。「开始使用」看过的记录不动 */ +export function resetHints() { + commit({ ...state, dismissed: [] }); +} + +/** 有没有点过「不再显示」的 —— 设置里那一行据此决定按钮能不能按 */ +export function useAnyDismissed(): boolean { + return useSyncExternalStore(subscribe, getAnyDismissed, getAnyDismissed); +} diff --git a/src/guide/useFirstRequest.ts b/src/guide/useFirstRequest.ts new file mode 100644 index 00000000..b60a4d17 --- /dev/null +++ b/src/guide/useFirstRequest.ts @@ -0,0 +1,34 @@ +import { call } from "@/control"; +import { useResource } from "@/lib/resource"; + +/** + * 有没有一条真正经过网关的请求,以及最新那条是哪一条。 + * + * **网关自己答的不算**(`local`):Claude Code 一连上就会发几个探测(数 token、 + * 取配额),网关在本地就答了 —— 那说明客户端接进来了,却还不是「请求经过网关发往 + * 上游」。概览判断「从没用过」用的是同一个口径。 + * + * 只看最近的几十条:要回答的是「有没有」和「最新一条是谁」,用了一段时间的人最近 + * 几十条里不可能全是探测。 + */ +export interface FirstRequest { + /** `null` = 还没取到 */ + used: boolean | null; + /** 最新一条真正的请求的 id,「查看这条请求」打开它 */ + latestId: number | null; +} + +const PEEK = 50; + +export function useFirstRequest(): FirstRequest { + const r = useResource( + "guide:first-request", + async () => { + const rows = await call("History", { limit: PEEK }); + return rows.find((row) => !row.local)?.id ?? null; + }, + { events: ["request_finished", "request_failed", "request_cancelled"] }, + ); + if (r.data === undefined) return { used: null, latestId: null }; + return { used: r.data !== null, latestId: r.data }; +} diff --git a/src/overview/OverviewPage.tsx b/src/overview/OverviewPage.tsx index 5b8ae314..19cbbcab 100644 --- a/src/overview/OverviewPage.tsx +++ b/src/overview/OverviewPage.tsx @@ -2,14 +2,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Page, PageHeader } from "@/ui/page"; import { Banner } from "@/ui/banner"; -import { Button } from "@/ui/button"; -import { EmptyState, ErrorState } from "@/ui/states"; +import { ErrorState } from "@/ui/states"; import { RangePicker, useRange } from "@/ui/range"; -import { IconDashboard } from "@/ui/icons"; import { rangeText } from "@/ui/range.i18n"; -import { useNav } from "@/nav"; import type { Dashboard, Overview } from "@/types"; import { useText } from "@/i18n"; +import { FirstRequestHint } from "@/guide/FirstRequestHint"; +import { SetupGuide } from "@/guide/SetupGuide"; import { useOverview, type Shown } from "./useOverview"; import { OverviewStatus } from "./OverviewStatus"; import { OverviewSkeleton } from "./OverviewSkeleton"; @@ -84,7 +83,6 @@ export default function OverviewPage({ }) { const t = useText(overviewText); const rt = useText(rangeText); - const nav = useNav(); const [range, setRange] = useRange(); const [by, setBy] = useMetric(); const o = useOverview(range, tick); @@ -114,10 +112,6 @@ export default function OverviewPage({ onBy={setBy} switching={o.switching} day={rt.preset["1d"]} - // 按钮写的是「添加上游」:直接打开上游页的新建对话框,不是只把页面打开 - onSetUp={() => - ov && ov.providers.length === 0 ? nav.open("upstreams", { create: "upstream" }) : nav.open("clients") - } /> )} @@ -131,7 +125,6 @@ function Body({ onBy, switching, day, - onSetUp, }: { shown: Shown; ov: Overview | null; @@ -141,7 +134,6 @@ function Body({ switching: boolean; /** 「24 小时」:实时档下不跟着图走的那几节按它算 */ day: string; - onSetUp: () => void; }) { const t = useText(overviewText); const { data: d, range, id } = shown; @@ -149,7 +141,7 @@ function Body({ // 实时档下,不跟着图走的那几节标出它们的口径 const scoped = live ? day : undefined; const recording = d.storage === null || d.storage.recording; - const noUpstreams = ov !== null && ov.providers.length === 0; + const fresh = neverUsed(d); return (
    - {neverUsed(d) ? ( - } - title={t.emptyTitle} - description={ - <> - {noUpstreams ? t.emptyHintNoUpstream : t.emptyHint} - {d.summary.locally_answered > 0 && ( - <> -
    - {t.probesAnswered(d.summary.locally_answered)} - - )} - - } - action={ - - } - /> + {/* 第一条请求到了:指给人去看它。只对走过「开始使用」的人说 */} + + + {fresh ? ( + ) : ( <> diff --git a/src/overview/overview.i18n.ts b/src/overview/overview.i18n.ts index 80fd26d6..21d46acd 100644 --- a/src/overview/overview.i18n.ts +++ b/src/overview/overview.i18n.ts @@ -165,14 +165,6 @@ export const overviewText = messages( // 请求记录没起来。正常时不显示 recordingUnavailable: "请求记录未能启动", forwardingUnaffected: "转发不受影响。", - - // 还没有任何请求时 - emptyTitle: "尚无请求记录", - emptyHint: "将客户端指向本机网关后,用量与费用将在此处显示。", - emptyHintNoUpstream: "添加上游并将客户端指向本机网关后,用量与费用将在此处显示。", - probesAnswered: (n: number) => `已本地应答 ${n} 次客户端探测,客户端已连接网关。`, - setUpClients: "设置客户端", - addUpstream: "添加上游", }, { loadFailed: "Could not load the overview", @@ -312,16 +304,5 @@ export const overviewText = messages( recordingUnavailable: "Request recording could not start", forwardingUnaffected: "Forwarding is not affected.", - - emptyTitle: "No requests yet", - emptyHint: "Usage and cost appear here once clients point to the local gateway.", - emptyHintNoUpstream: - "Usage and cost appear here once an upstream is added and clients point to the local gateway.", - probesAnswered: (n: number) => - n === 1 - ? "1 client probe was answered locally; a client is already connected to the gateway." - : `${n} client probes were answered locally; a client is already connected to the gateway.`, - setUpClients: "Set up clients", - addUpstream: "Add upstream", }, ); diff --git a/src/security/SecurityPage.tsx b/src/security/SecurityPage.tsx index 069a613d..6701671b 100644 --- a/src/security/SecurityPage.tsx +++ b/src/security/SecurityPage.tsx @@ -27,6 +27,7 @@ import { OutputLimitTab } from "./OutputLimitTab"; import { BuiltinRuleDialog, DeleteRuleDialog, patternOf, RuleDialog, TestDialog, type RuleSeed } from "./RuleDialog"; import { securityPageText } from "./SecurityPage.i18n"; import { useSecurityLog, type SecurityLog } from "./useSecurityLog"; +import { ObserveHint } from "@/guide/PageHints"; export type SecurityTab = "log" | Guard; @@ -275,6 +276,9 @@ export default function SecurityPage({ } /> + {/* 有防护停在「观察」:说一句确认没有误报之后可以改为拦截 */} + d[g].mode === "observe").length : 0} className="mt-4" /> + diff --git a/src/settings/GeneralSection.tsx b/src/settings/GeneralSection.tsx index c8ba5607..441a3abe 100644 --- a/src/settings/GeneralSection.tsx +++ b/src/settings/GeneralSection.tsx @@ -3,6 +3,10 @@ import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { Reveal } from "@/ui/motion"; import { Segmented } from "@/ui/segmented"; import { Switch } from "@/ui/switch"; +import { Button } from "@/ui/button"; +import { notify } from "@/ui/notify"; +import { guideText } from "@/guide/guide.i18n"; +import { resetHints, useAnyDismissed } from "@/guide/hints"; import { useResource } from "@/lib/resource"; import { LANG_NAMES, setLang, useText, type Lang } from "@/i18n"; import { isMac } from "@/platform"; @@ -38,6 +42,7 @@ export function GeneralSection() { {isMac && } + ); @@ -239,3 +244,33 @@ function NoticesRow() { /> ); } + +/** + * 引导提示:点过「不再显示」的那几条重新显示。**只动这台电脑上的记录**(见 + * `guide/hints.ts`),不写配置文件,所以和这一节别的行一样点一下就生效。一条都没 + * 关过时按钮置灰 —— 按了也不会有任何变化。 + */ +function GuideRow() { + const t = useText(guideText); + const any = useAnyDismissed(); + return ( + { + resetHints(); + notify.success(t.hintsResetDone); + }} + > + {t.hintsReset} + + } + /> + ); +} diff --git a/src/traffic/TrafficPage.tsx b/src/traffic/TrafficPage.tsx index 6f7caf98..1517f4ed 100644 --- a/src/traffic/TrafficPage.tsx +++ b/src/traffic/TrafficPage.tsx @@ -27,6 +27,7 @@ import { SessionSheet } from "./SessionPanel"; import { TrafficSummary } from "./TrafficSummary"; import { trafficText } from "./Traffic.i18n"; import type { TrafficView } from "./view"; +import { OpenRowHint } from "@/guide/PageHints"; const DAY_MS = 24 * 3_600_000; @@ -409,6 +410,8 @@ export default function TrafficPage({ } /> + {/* 有请求了:说一句点开能看到什么 */} + 0} className="mb-3" />
    {failedEmpty ? ( diff --git a/src/upstreams/UpstreamsPage.tsx b/src/upstreams/UpstreamsPage.tsx index 45a62b55..dfb6bc17 100644 --- a/src/upstreams/UpstreamsPage.tsx +++ b/src/upstreams/UpstreamsPage.tsx @@ -35,6 +35,7 @@ import { upstreamsPageText } from "./UpstreamsPage.i18n"; import { CostFigure } from "@/CostFigure"; import { UpstreamTable, problemsOf } from "./UpstreamTable"; import { ZaiLoginDialog } from "./ZaiLoginDialog"; +import { NextClientsHint } from "@/guide/PageHints"; export type UpstreamTab = "upstreams" | "proxies" | "pricing"; @@ -372,6 +373,7 @@ export default function UpstreamsPage({ > {stats.error !== undefined ? errorText(stats.error) : null} + {providers.length === 0 ? ( }