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 ? (
}