diff --git a/README.md b/README.md index 1dab6f3..43c712c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - 支持搜索、筛选、排序以及桌面表格和移动端卡片。 - 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。 - 脚本生成器支持 39 种货币、实时预览、复制和 `.js` 下载。 -- 优惠码仅在当前浏览器内处理,不写入网址或本地存储。 +- 优惠码和手动提供的 Token 仅在当前浏览器内处理,不写入网址或本地存储。 - GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。 - 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。 @@ -59,7 +59,9 @@ npm run build 顶部导航可切换到脚本生成器,也可以从价格表或移动端价格卡片直接进入。地区入口会通过 `view=generator&country=XX¤cy=XXX` 预填国家和货币,优惠码不会出现在网址中。 -生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`。它只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。 +生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`。Access Token 可以在脚本运行时从登录 Session 自动获取,也可以手动粘贴原始 `accessToken` 或 `/api/auth/session` 返回的完整 JSON;完整 JSON 只会提取其中的 `accessToken` 写入脚本。切回自动获取时,手动输入会立即清空。 + +生成器只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。 ### 发布保护 diff --git a/src/App.test.tsx b/src/App.test.tsx index ccdb197..b5c472b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import sample from "../public/data/sample-prices.json"; @@ -134,4 +134,38 @@ describe("pricing explorer", () => { expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("US"); expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("EGP"); }); + + it("accepts complete session JSON in manual token mode without storing it", 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: /脚本生成器/ })); + + expect(screen.getByRole("radio", { name: /自动获取/ })).toBeChecked(); + await user.click(screen.getByRole("radio", { name: /手动粘贴/ })); + const tokenInput = screen.getByPlaceholderText("粘贴 accessToken 或完整 Session JSON"); + const sessionJson = JSON.stringify({ + user: { email: "private@example.com" }, + accessToken: "session-access-token", + }); + fireEvent.change(tokenInput, { target: { value: sessionJson } }); + await user.type(screen.getByPlaceholderText("例如:XXXXXXXXXXXX"), "SAVE20"); + await user.click(screen.getByRole("button", { name: /复制代码/ })); + + const copiedScript = writeText.mock.calls[0][0] as string; + expect(copiedScript).toContain('const accessToken = "session-access-token"'); + expect(copiedScript).not.toContain("private@example.com"); + expect(copiedScript).not.toContain("/api/auth/session"); + expect(window.location.search).not.toContain("session-access-token"); + expect(Object.values(window.localStorage)).not.toContain("session-access-token"); + + await user.click(screen.getByRole("radio", { name: /自动获取/ })); + expect(screen.queryByPlaceholderText("粘贴 accessToken 或完整 Session JSON")).not.toBeInTheDocument(); + await user.click(screen.getByRole("radio", { name: /手动粘贴/ })); + expect(screen.getByPlaceholderText("粘贴 accessToken 或完整 Session JSON")).toHaveValue(""); + }); }); diff --git a/src/CheckoutGenerator.tsx b/src/CheckoutGenerator.tsx index a024856..0d3daba 100644 --- a/src/CheckoutGenerator.tsx +++ b/src/CheckoutGenerator.tsx @@ -12,6 +12,7 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import { CHECKOUT_CURRENCIES, + DEFAULT_ACCESS_TOKEN_MODE, DEFAULT_CHECKOUT_COUNTRY, DEFAULT_CHECKOUT_CURRENCY, generateCheckoutScript, @@ -19,6 +20,7 @@ import { normalizeIsoInput, validateCheckoutInput, type CheckoutScriptInput, + type CheckoutInputField, type CheckoutValidationErrors, } from "./checkout-generator"; import type { PriceRow } from "./types"; @@ -41,12 +43,15 @@ export default function CheckoutGenerator({ const [coupon, setCoupon] = useState(""); const [country, setCountry] = useState(initialCountry); const [currency, setCurrency] = useState(initialCurrency); + const [accessTokenMode, setAccessTokenMode] = useState<"auto" | "manual">(DEFAULT_ACCESS_TOKEN_MODE); + const [accessToken, setAccessToken] = useState(""); const [errors, setErrors] = useState({}); const [notice, setNotice] = useState(""); const [copied, setCopied] = useState(false); const couponRef = useRef(null); const countryRef = useRef(null); const currencyRef = useRef(null); + const accessTokenRef = useRef(null); useEffect(() => { setCountry(initialCountry || DEFAULT_CHECKOUT_COUNTRY); @@ -64,6 +69,8 @@ export default function CheckoutGenerator({ coupon: coupon.trim(), country: country.toUpperCase(), currency: currency.toUpperCase(), + accessTokenMode, + accessToken: accessToken.trim(), }; const previewInput: CheckoutScriptInput = { coupon: normalized.coupon || "XXXXXXXXXXXX", @@ -73,15 +80,25 @@ export default function CheckoutGenerator({ currency: isSupportedCheckoutCurrency(normalized.currency) ? normalized.currency : DEFAULT_CHECKOUT_CURRENCY, + accessTokenMode, + accessToken: accessTokenMode === "manual" + ? normalized.accessToken || "PASTE_ACCESS_TOKEN_HERE" + : "", }; const source = useMemo( () => generateCheckoutScript(previewInput), - [previewInput.coupon, previewInput.country, previewInput.currency], + [ + previewInput.coupon, + previewInput.country, + previewInput.currency, + previewInput.accessTokenMode, + previewInput.accessToken, + ], ); const isInputValid = Object.keys(validateCheckoutInput(normalized)).length === 0; const currencyName = CHECKOUT_CURRENCIES.find(([code]) => code === normalized.currency)?.[1]; - const clearError = (field: keyof CheckoutScriptInput) => { + const clearError = (field: CheckoutInputField) => { if (!errors[field]) return; setErrors((current) => ({ ...current, [field]: undefined })); }; @@ -89,10 +106,11 @@ export default function CheckoutGenerator({ const validateAndFocus = () => { const nextErrors = validateCheckoutInput(normalized); setErrors(nextErrors); - const firstError = Object.keys(nextErrors)[0] as keyof CheckoutScriptInput | undefined; + const firstError = Object.keys(nextErrors)[0] as CheckoutInputField | undefined; if (firstError === "coupon") couponRef.current?.focus(); if (firstError === "country") countryRef.current?.focus(); if (firstError === "currency") currencyRef.current?.focus(); + if (firstError === "accessToken") accessTokenRef.current?.focus(); return !firstError; }; @@ -148,8 +166,10 @@ export default function CheckoutGenerator({ setCoupon(""); setCountry(DEFAULT_CHECKOUT_COUNTRY); setCurrency(DEFAULT_CHECKOUT_CURRENCY); + setAccessTokenMode(DEFAULT_ACCESS_TOKEN_MODE); + setAccessToken(""); setErrors({}); - setNotice("已恢复默认值 US / EGP"); + setNotice("已恢复自动获取与默认值 US / EGP"); couponRef.current?.focus(); }; @@ -162,11 +182,11 @@ export default function CheckoutGenerator({ BUSINESS TOOL · GENERATOR

参数填好,
脚本即刻就绪。

-

输入优惠码、国家和货币,生成可复制的 ChatGPT Team 结账脚本。全部处理都在当前浏览器中完成。

+

选择自动获取或手动提供 Access Token,再输入优惠码、国家和货币,生成可复制的 ChatGPT Team 结账脚本。

-
本地生成不上传、不保存优惠码,也不会在本站执行脚本。
+
本地生成不上传、不保存优惠码或 Access Token,也不会在本站执行脚本。
@@ -192,6 +212,56 @@ export default function CheckoutGenerator({ {errors.coupon ? {errors.coupon} : null} +
+ Access Token 来源 +
+ + +
+
+ + {accessTokenMode === "manual" ? ( + + ) : null} +