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