diff --git a/README.md b/README.md
index b0102c0..1dab6f3 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# Business Price Radar
+# Business Toolkit
-一个独立的 ChatGPT Business 全球月付地区价格查询工具。项目从 `chatgpt.com` 的公开结账价格配置中读取 `currency_config.business.month`,保留官方原币价格,并按公开汇率换算为人民币或美元。
+一个独立的 ChatGPT Business 价格与结账工具箱。价格雷达从 `chatgpt.com` 的公开结账价格配置中读取 `currency_config.business.month`,保留官方原币价格并按公开汇率换算;脚本生成器根据优惠码、国家和货币在浏览器本地生成可复制的结账脚本。
> 本项目不是 OpenAI 官方产品,与 OpenAI 没有隶属、合作或背书关系。实际价格、税费、付款资格和地区可用性以结账页为准。
@@ -10,6 +10,9 @@
- 仅展示标准 ChatGPT Business 月付价格,不包含年付或非营利套餐。
- 显示官方原币价格、人民币/美元估算、含税或未含税口径。
- 支持搜索、筛选、排序以及桌面表格和移动端卡片。
+- 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。
+- 脚本生成器支持 39 种货币、实时预览、复制和 `.js` 下载。
+- 优惠码仅在当前浏览器内处理,不写入网址或本地存储。
- GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。
- 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。
@@ -52,6 +55,12 @@ npm run build
采集请求不使用 Cookie、设备 ID、会话 ID、登录凭据或浏览器指纹头。
+## 脚本生成器
+
+顶部导航可切换到脚本生成器,也可以从价格表或移动端价格卡片直接进入。地区入口会通过 `view=generator&country=XX¤cy=XXX` 预填国家和货币,优惠码不会出现在网址中。
+
+生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`。它只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。
+
### 发布保护
- 首次快照必须包含新鲜的 US、至少一个非 USD 地区、CNY 汇率和至少 20 个有效地区。
diff --git a/index.html b/index.html
index 31a022b..9d1c64f 100644
--- a/index.html
+++ b/index.html
@@ -6,15 +6,23 @@
-
+
-
Business Price Radar|全球月付价格
+
+
+
+
+
+
+
+
+ Business Toolkit|价格与结账工具
diff --git a/public/og.png b/public/og.png
new file mode 100644
index 0000000..b6543ea
Binary files /dev/null and b/public/og.png differ
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 57ad49c..ccdb197 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -6,6 +6,7 @@ import App from "./App";
describe("pricing explorer", () => {
beforeEach(() => {
+ window.history.replaceState({}, "", "/");
window.localStorage.clear();
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce({ ok: false, status: 404 })
@@ -52,4 +53,85 @@ describe("pricing explorer", () => {
expect(screen.getAllByText("德国").length).toBeGreaterThan(0);
expect(screen.queryByText("新加坡")).not.toBeInTheDocument();
});
+
+ it("opens the generator with US and EGP defaults and responds to URL navigation", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: /脚本生成器/ }));
+ expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("US");
+ expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("EGP");
+ expect(new URLSearchParams(window.location.search).get("view")).toBe("generator");
+
+ window.history.pushState({}, "", "/?view=generator&country=JP¤cy=JPY");
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ await waitFor(() => expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("JP"));
+ expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("JPY");
+ });
+
+ it("prefills a region from the radar and preserves the price filters on return", async () => {
+ const user = userEvent.setup();
+ render();
+ await screen.findAllByText("新加坡");
+
+ const search = screen.getByPlaceholderText("搜索国家、代码或币种");
+ await user.type(search, "新加坡");
+ await user.click(screen.getAllByRole("button", { name: "为新加坡生成脚本" })[0]);
+
+ expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("SG");
+ expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("SGD");
+ expect(new URLSearchParams(window.location.search).get("country")).toBe("SG");
+ expect(new URLSearchParams(window.location.search).get("currency")).toBe("SGD");
+
+ await user.click(screen.getByRole("button", { name: /返回价格雷达/ }));
+ expect(screen.getByPlaceholderText("搜索国家、代码或币种")).toHaveValue("新加坡");
+ });
+
+ it("blocks an empty coupon, then copies valid generated code without storing the coupon", async () => {
+ const user = userEvent.setup();
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+ render();
+ await user.click(screen.getByRole("button", { name: /脚本生成器/ }));
+
+ const copyButton = screen.getByRole("button", { name: /复制代码/ });
+ expect(copyButton).toHaveAttribute("aria-disabled", "true");
+ await user.click(copyButton);
+ const coupon = screen.getByPlaceholderText("例如:XXXXXXXXXXXX");
+ expect(coupon).toHaveFocus();
+ expect(screen.getByText("请输入优惠码")).toBeInTheDocument();
+
+ await user.type(coupon, "SAVE20");
+ expect(copyButton).toHaveAttribute("aria-disabled", "false");
+ await user.click(screen.getByRole("button", { name: /复制代码/ }));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('const COUPON = "SAVE20"'));
+ expect(window.location.search).not.toContain("SAVE20");
+ expect(window.localStorage.getItem("coupon")).toBeNull();
+ });
+
+ it("downloads valid code and resets to US and EGP", async () => {
+ const user = userEvent.setup();
+ const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+ const createObjectURL = vi.fn(() => "blob:checkout-script");
+ const revokeObjectURL = vi.fn();
+ Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectURL });
+ Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectURL });
+ render();
+ await user.click(screen.getByRole("button", { name: /脚本生成器/ }));
+
+ await user.type(screen.getByPlaceholderText("例如:XXXXXXXXXXXX"), "SAVE20");
+ await user.clear(screen.getByLabelText(/国家 ISO 缩写/));
+ await user.type(screen.getByLabelText(/国家 ISO 缩写/), "sg");
+ await user.click(screen.getByRole("button", { name: /下载/ }));
+ expect(createObjectURL).toHaveBeenCalled();
+ expect(click).toHaveBeenCalled();
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:checkout-script");
+
+ await user.click(screen.getByRole("button", { name: /恢复默认值/ }));
+ expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("US");
+ expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("EGP");
+ });
});
diff --git a/src/App.tsx b/src/App.tsx
index e96d9b5..3e69b89 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,6 +4,7 @@ import {
CalendarClock,
CheckCircle2,
CircleDollarSign,
+ Code2,
Database,
Github,
Info,
@@ -12,6 +13,12 @@ import {
Users,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
+import CheckoutGenerator from "./CheckoutGenerator";
+import {
+ DEFAULT_CHECKOUT_COUNTRY,
+ DEFAULT_CHECKOUT_CURRENCY,
+ isSupportedCheckoutCurrency,
+} from "./checkout-generator";
import { filterAndSortRows, flagEmoji, formatConverted, formatLocal, relativeTime, taxLabel } from "./lib";
import type {
DisplayCurrency,
@@ -24,6 +31,18 @@ import type {
const currencyStorageKey = "business-price-radar:currency";
+type AppView = "prices" | "generator";
+type AppRoute = { view: AppView; country: string; currency: string };
+
+function readRoute(): AppRoute {
+ const params = new URLSearchParams(window.location.search);
+ return {
+ view: params.get("view") === "generator" ? "generator" : "prices",
+ country: params.get("country")?.toUpperCase() || DEFAULT_CHECKOUT_COUNTRY,
+ currency: params.get("currency")?.toUpperCase() || DEFAULT_CHECKOUT_CURRENCY,
+ };
+}
+
async function loadSnapshot(): Promise {
const liveUrl = new URL("data/prices.json", document.baseURI);
const sampleUrl = new URL("data/sample-prices.json", document.baseURI);
@@ -51,6 +70,7 @@ function initialCurrency(): DisplayCurrency {
}
export default function App() {
+ const [route, setRoute] = useState(readRoute);
const [snapshot, setSnapshot] = useState(null);
const [loadingError, setLoadingError] = useState(null);
const [currency, setCurrency] = useState(initialCurrency);
@@ -73,6 +93,12 @@ export default function App() {
};
}, []);
+ useEffect(() => {
+ const handlePopState = () => setRoute(readRoute());
+ window.addEventListener("popstate", handlePopState);
+ return () => window.removeEventListener("popstate", handlePopState);
+ }, []);
+
useEffect(() => {
window.localStorage.setItem(currencyStorageKey, currency);
}, [currency]);
@@ -103,22 +129,49 @@ export default function App() {
window.localStorage.setItem(currencyStorageKey, value);
};
+ const navigate = (nextView: AppView, row?: Pick) => {
+ const url = new URL(window.location.href);
+ if (nextView === "generator") {
+ url.searchParams.set("view", "generator");
+ if (row) {
+ url.searchParams.set("country", row.countryCode);
+ url.searchParams.set("currency", row.currencyCode);
+ } else {
+ url.searchParams.delete("country");
+ url.searchParams.delete("currency");
+ }
+ } else {
+ url.searchParams.delete("view");
+ url.searchParams.delete("country");
+ url.searchParams.delete("currency");
+ }
+ window.history.pushState({}, "", url);
+ setRoute(readRoute());
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ };
+
return (
+ {route.view === "prices" ? (
+ <>
CHATGPT BUSINESS · MONTHLY
@@ -266,8 +321,8 @@ export default function App() {
{!snapshot && !loadingError ?
: null}
{snapshot && rows.length ? (
<>
-
-
+
navigate("generator", row)} />
+ navigate("generator", row)} />
>
) : null}
{snapshot && !rows.length ? (
@@ -279,7 +334,7 @@ export default function App() {
01
价格从哪里来?
- 报价来自 chatgpt.com 的公开结账价格配置接口。本站只读取 Business 月付字段,不参与购买。
+ 报价来自 chatgpt.com 的公开结账价格配置接口。价格雷达只读取 Business 月付字段。
02
@@ -296,17 +351,26 @@ export default function App() {
- 独立信息工具,非 OpenAI 官方产品。
- 实际价格、税费、付款资格与地区可用性以结账页为准。ChatGPT Business 按用户计费,标准席位至少购买 2 个。
+ 独立工具,非 OpenAI 官方产品。
+ 价格数据来自公开配置;脚本仅在浏览器本地生成,不会由本站执行。实际价格、税费、付款资格与地区可用性以结账页为准。
查看官方账单说明
+ >
+ ) : (
+ navigate("prices")}
+ />
+ )}