diff --git a/README.md b/README.md index 43c712c..9357526 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Business Toolkit -一个独立的 ChatGPT Business 价格与结账工具箱。价格雷达从 `chatgpt.com` 的公开结账价格配置中读取 `currency_config.business.month`,保留官方原币价格并按公开汇率换算;脚本生成器根据优惠码、国家和货币在浏览器本地生成可复制的结账脚本。 +一个独立的 ChatGPT Business 价格与脚本工具箱。价格雷达从 `chatgpt.com` 的公开结账价格配置中读取 `currency_config.business.month`,保留官方原币价格并按公开汇率换算;脚本生成器在浏览器本地生成 Team 优惠、Codex 按量和账单查询脚本。 > 本项目不是 OpenAI 官方产品,与 OpenAI 没有隶属、合作或背书关系。实际价格、税费、付款资格和地区可用性以结账页为准。 @@ -11,7 +11,9 @@ - 显示官方原币价格、人民币/美元估算、含税或未含税口径。 - 支持搜索、筛选、排序以及桌面表格和移动端卡片。 - 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。 -- 脚本生成器支持 39 种货币、实时预览、复制和 `.js` 下载。 +- Team 优惠生成器支持 39 种货币、已有空间 UUID、实时预览、复制和 `.js` 下载。 +- Codex 按量生成器支持空间名称、Credit 数量和国家到货币自动匹配。 +- 账单查询生成器可查询最近 10 条发票、支付方式和账单资料。 - 优惠码和手动提供的 Token 仅在当前浏览器内处理,不写入网址或本地存储。 - GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。 - 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。 @@ -57,11 +59,13 @@ npm run build ## 脚本生成器 -顶部导航可切换到脚本生成器,也可以从价格表或移动端价格卡片直接进入。地区入口会通过 `view=generator&country=XX¤cy=XXX` 预填国家和货币,优惠码不会出现在网址中。 +顶部导航可切换到脚本生成器,也可以从价格表或移动端价格卡片直接进入 Team 优惠工具。生成器内部通过 `tool=checkout|codex|billing` 切换工具;地区入口会预填国家和货币,优惠码、Token 和空间信息不会出现在网址中。 -生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`。Access Token 可以在脚本运行时从登录 Session 自动获取,也可以手动粘贴原始 `accessToken` 或 `/api/auth/session` 返回的完整 JSON;完整 JSON 只会提取其中的 `accessToken` 写入脚本。切回自动获取时,手动输入会立即清空。 +Team 优惠生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`;空间 UUID 留空时创建新空间。Codex 按量生成器默认使用空间名称 `work`、`13` Credit 和美国地区。 -生成器只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。 +三个工具都支持在脚本运行时从登录 Session 自动获取 Access Token,也可以手动粘贴原始 `accessToken` 或 `/api/auth/session` 返回的完整 JSON;完整 JSON 只会提取其中的 `accessToken` 写入脚本。切回自动获取时,手动输入会立即清空。 + +生成器只生成文本,不会在本站请求登录凭证、支付或账单接口,也不会代替用户执行代码。账单结果可能包含敏感付款资料,请勿分享控制台输出。请仅在有权操作的账号中使用,并以实际 ChatGPT 页面结果为准。 ### 发布保护 diff --git a/index.html b/index.html index 9d1c64f..5d2f8ee 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ { await user.click(screen.getByRole("radio", { name: /手动粘贴/ })); expect(screen.getByPlaceholderText("粘贴 accessToken 或完整 Session JSON")).toHaveValue(""); }); + + it("switches between Team, Codex and billing generators through the URL", 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("tab", { name: /Team 优惠/ })).toHaveAttribute("aria-selected", "true"); + + await user.click(screen.getByRole("tab", { name: /Codex 按量/ })); + expect(new URLSearchParams(window.location.search).get("tool")).toBe("codex"); + expect(screen.getByPlaceholderText("填写空间名称")).toHaveValue("work"); + expect(screen.getByLabelText(/Credit 数量/)).toHaveValue(13); + expect(screen.getByLabelText(/国家或地区/)).toHaveValue("US"); + + await user.click(screen.getByRole("tab", { name: /账单查询/ })); + expect(new URLSearchParams(window.location.search).get("tool")).toBe("billing"); + expect(screen.queryByPlaceholderText("例如:XXXXXXXXXXXX")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /复制代码/ })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining("window.__billingResult = result")); + + window.history.pushState({}, "", "/?view=generator&tool=codex"); + window.dispatchEvent(new PopStateEvent("popstate")); + await waitFor(() => expect(screen.getByRole("tab", { name: /Codex 按量/ })).toHaveAttribute("aria-selected", "true")); + }); }); diff --git a/src/App.tsx b/src/App.tsx index 3e69b89..bce1efb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { DEFAULT_CHECKOUT_CURRENCY, isSupportedCheckoutCurrency, } from "./checkout-generator"; +import type { GeneratorTool } from "./script-generators"; import { filterAndSortRows, flagEmoji, formatConverted, formatLocal, relativeTime, taxLabel } from "./lib"; import type { DisplayCurrency, @@ -32,12 +33,15 @@ import type { const currencyStorageKey = "business-price-radar:currency"; type AppView = "prices" | "generator"; -type AppRoute = { view: AppView; country: string; currency: string }; +type AppRoute = { view: AppView; tool: GeneratorTool; country: string; currency: string }; function readRoute(): AppRoute { const params = new URLSearchParams(window.location.search); return { view: params.get("view") === "generator" ? "generator" : "prices", + tool: params.get("tool") === "codex" || params.get("tool") === "billing" + ? params.get("tool") as GeneratorTool + : "checkout", country: params.get("country")?.toUpperCase() || DEFAULT_CHECKOUT_COUNTRY, currency: params.get("currency")?.toUpperCase() || DEFAULT_CHECKOUT_CURRENCY, }; @@ -129,10 +133,15 @@ export default function App() { window.localStorage.setItem(currencyStorageKey, value); }; - const navigate = (nextView: AppView, row?: Pick) => { + const navigate = ( + nextView: AppView, + row?: Pick, + tool: GeneratorTool = "checkout", + ) => { const url = new URL(window.location.href); if (nextView === "generator") { url.searchParams.set("view", "generator"); + url.searchParams.set("tool", tool); if (row) { url.searchParams.set("country", row.countryCode); url.searchParams.set("currency", row.currencyCode); @@ -144,6 +153,7 @@ export default function App() { url.searchParams.delete("view"); url.searchParams.delete("country"); url.searchParams.delete("currency"); + url.searchParams.delete("tool"); } window.history.pushState({}, "", url); setRoute(readRoute()); @@ -321,8 +331,8 @@ export default function App() { {!snapshot && !loadingError ? : null} {snapshot && rows.length ? ( <> - navigate("generator", row)} /> - navigate("generator", row)} /> + navigate("generator", row, "checkout")} /> + navigate("generator", row, "checkout")} /> ) : null} {snapshot && !rows.length ? ( @@ -359,10 +369,12 @@ export default function App() { ) : ( navigate("prices")} + onToolChange={(tool) => navigate("generator", undefined, tool)} /> )} diff --git a/src/CheckoutGenerator.tsx b/src/CheckoutGenerator.tsx index 0d3daba..e560d26 100644 --- a/src/CheckoutGenerator.tsx +++ b/src/CheckoutGenerator.tsx @@ -3,11 +3,14 @@ import { Check, Code2, Copy, + CreditCard, Download, Info, + ReceiptText, RotateCcw, ShieldCheck, Sparkles, + Users, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { @@ -19,40 +22,94 @@ import { isSupportedCheckoutCurrency, normalizeIsoInput, validateCheckoutInput, + type AccessTokenMode, type CheckoutScriptInput, - type CheckoutInputField, - type CheckoutValidationErrors, } from "./checkout-generator"; +import { + CODEX_COUNTRIES, + generateBillingScript, + generateCodexScript, + validateBillingInput, + validateCodexInput, + type BillingScriptInput, + type CodexScriptInput, + type GeneratorTool, +} from "./script-generators"; import type { PriceRow } from "./types"; type CheckoutGeneratorProps = { + initialTool?: GeneratorTool; initialCountry?: string; initialCurrency?: string; countries: PriceRow[]; onBack: () => void; + onToolChange: (tool: GeneratorTool) => void; }; +type FormField = + | "coupon" + | "country" + | "currency" + | "existingWorkspaceId" + | "accessToken" + | "workspaceName" + | "creditQuantity"; +type FormErrors = Partial>; + const commonCurrencies = ["USD", "EUR", "GBP", "SGD", "EGP"]; +const toolMeta: Record = { + checkout: { + title: "Team 优惠长链", + description: "使用优惠码新建 Team 空间,或将优惠应用到已有 Codex 空间。", + filename: "chatgpt-team-checkout.js", + codeTitle: "team-checkout.js", + }, + codex: { + title: "Codex 按量长链", + description: "按空间名称和 Credit 数量生成 Codex usage-based checkout。", + filename: "chatgpt-codex-usage-checkout.js", + codeTitle: "codex-usage-checkout.js", + }, + billing: { + title: "账单查询脚本", + description: "查询账户、最近 10 条发票、支付方式和账单资料。", + filename: "chatgpt-billing-query.js", + codeTitle: "billing-query.js", + }, +}; export default function CheckoutGenerator({ + initialTool = "checkout", initialCountry = DEFAULT_CHECKOUT_COUNTRY, initialCurrency = DEFAULT_CHECKOUT_CURRENCY, countries, onBack, + onToolChange, }: CheckoutGeneratorProps) { + const [activeTool, setActiveTool] = useState(initialTool); 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 [existingWorkspaceId, setExistingWorkspaceId] = useState(""); + const [workspaceName, setWorkspaceName] = useState("work"); + const [creditQuantity, setCreditQuantity] = useState("13"); + const [codexCountry, setCodexCountry] = useState("US"); + const [autoOpen, setAutoOpen] = useState(false); + const [accessTokenMode, setAccessTokenMode] = useState(DEFAULT_ACCESS_TOKEN_MODE); const [accessToken, setAccessToken] = useState(""); - const [errors, setErrors] = 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 workspaceIdRef = useRef(null); + const workspaceNameRef = useRef(null); + const creditQuantityRef = useRef(null); const accessTokenRef = useRef(null); + useEffect(() => setActiveTool(initialTool), [initialTool]); + useEffect(() => { setCountry(initialCountry || DEFAULT_CHECKOUT_COUNTRY); setCurrency(initialCurrency || DEFAULT_CHECKOUT_CURRENCY); @@ -65,52 +122,94 @@ export default function CheckoutGenerator({ return () => window.clearTimeout(timer); }, [notice]); - const normalized: CheckoutScriptInput = { + const tokenInput = { accessTokenMode, accessToken: accessToken.trim() }; + const checkoutInput: CheckoutScriptInput = { coupon: coupon.trim(), country: country.toUpperCase(), currency: currency.toUpperCase(), - accessTokenMode, - accessToken: accessToken.trim(), + existingWorkspaceId: existingWorkspaceId.trim(), + autoOpen, + ...tokenInput, }; - const previewInput: CheckoutScriptInput = { - coupon: normalized.coupon || "XXXXXXXXXXXX", - country: /^[A-Z]{2}$/.test(normalized.country) - ? normalized.country - : DEFAULT_CHECKOUT_COUNTRY, - currency: isSupportedCheckoutCurrency(normalized.currency) - ? normalized.currency - : DEFAULT_CHECKOUT_CURRENCY, - accessTokenMode, - accessToken: accessTokenMode === "manual" - ? normalized.accessToken || "PASTE_ACCESS_TOKEN_HERE" - : "", + const codexInput: CodexScriptInput = { + workspaceName: workspaceName.trim(), + creditQuantity: Number(creditQuantity), + country: codexCountry, + autoOpen, + ...tokenInput, }; - const source = useMemo( - () => generateCheckoutScript(previewInput), - [ - 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 billingInput: BillingScriptInput = tokenInput; - const clearError = (field: CheckoutInputField) => { + const currentErrors = (): FormErrors => { + if (activeTool === "checkout") return validateCheckoutInput(checkoutInput); + if (activeTool === "codex") return validateCodexInput(codexInput); + return validateBillingInput(billingInput); + }; + + const source = useMemo(() => { + const previewToken = accessTokenMode === "manual" + ? accessToken.trim() || "PASTE_ACCESS_TOKEN_HERE" + : ""; + if (activeTool === "billing") { + return generateBillingScript({ accessTokenMode, accessToken: previewToken }); + } + if (activeTool === "codex") { + return generateCodexScript({ + ...codexInput, + workspaceName: codexInput.workspaceName || "work", + creditQuantity: Number.isInteger(codexInput.creditQuantity) && codexInput.creditQuantity > 0 + ? codexInput.creditQuantity + : 13, + accessToken: previewToken, + }); + } + return generateCheckoutScript({ + ...checkoutInput, + coupon: checkoutInput.coupon || "XXXXXXXXXXXX", + country: /^[A-Z]{2}$/.test(checkoutInput.country) ? checkoutInput.country : DEFAULT_CHECKOUT_COUNTRY, + currency: isSupportedCheckoutCurrency(checkoutInput.currency) ? checkoutInput.currency : DEFAULT_CHECKOUT_CURRENCY, + existingWorkspaceId: currentErrors().existingWorkspaceId ? "" : checkoutInput.existingWorkspaceId, + accessToken: previewToken, + }); + }, [ + activeTool, + coupon, + country, + currency, + existingWorkspaceId, + workspaceName, + creditQuantity, + codexCountry, + autoOpen, + accessTokenMode, + accessToken, + ]); + + const isInputValid = Object.keys(currentErrors()).length === 0; + const currencyName = CHECKOUT_CURRENCIES.find(([code]) => code === checkoutInput.currency)?.[1]; + const selectedCodexCountry = CODEX_COUNTRIES.find(([code]) => code === codexCountry) || CODEX_COUNTRIES[0]; + const meta = toolMeta[activeTool]; + + const clearError = (field: FormField) => { if (!errors[field]) return; setErrors((current) => ({ ...current, [field]: undefined })); }; + const focusField = (field: FormField) => { + if (field === "coupon") couponRef.current?.focus(); + if (field === "country") countryRef.current?.focus(); + if (field === "currency") currencyRef.current?.focus(); + if (field === "existingWorkspaceId") workspaceIdRef.current?.focus(); + if (field === "workspaceName") workspaceNameRef.current?.focus(); + if (field === "creditQuantity") creditQuantityRef.current?.focus(); + if (field === "accessToken") accessTokenRef.current?.focus(); + }; + const validateAndFocus = () => { - const nextErrors = validateCheckoutInput(normalized); + const nextErrors = currentErrors(); setErrors(nextErrors); - 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(); + const firstError = Object.keys(nextErrors)[0] as FormField | undefined; + if (firstError) focusField(firstError); return !firstError; }; @@ -121,7 +220,7 @@ export default function CheckoutGenerator({ return; } document.querySelector("#script-result")?.scrollIntoView({ behavior: "smooth", block: "start" }); - setNotice("代码已按当前参数生成"); + setNotice(`${meta.title}已生成`); }; const copyCode = async () => { @@ -156,7 +255,7 @@ export default function CheckoutGenerator({ const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = "chatgpt-team-checkout.js"; + link.download = meta.filename; link.click(); URL.revokeObjectURL(url); setNotice("代码文件已下载"); @@ -166,163 +265,181 @@ export default function CheckoutGenerator({ setCoupon(""); setCountry(DEFAULT_CHECKOUT_COUNTRY); setCurrency(DEFAULT_CHECKOUT_CURRENCY); + setExistingWorkspaceId(""); + setWorkspaceName("work"); + setCreditQuantity("13"); + setCodexCountry("US"); + setAutoOpen(false); setAccessTokenMode(DEFAULT_ACCESS_TOKEN_MODE); setAccessToken(""); setErrors({}); - setNotice("已恢复自动获取与默认值 US / EGP"); - couponRef.current?.focus(); + setNotice("已恢复当前生成器的默认值"); }; + const changeTool = (tool: GeneratorTool) => { + if (tool === activeTool) return; + setActiveTool(tool); + setErrors({}); + setCopied(false); + onToolChange(tool); + }; + + const renderTokenFields = () => ( + <> +
+ Access Token 来源 +
+ + +
+
+ {accessTokenMode === "manual" ? ( + + ) : null} + + ); + return (
- + BUSINESS TOOL · GENERATOR -

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

-

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

+

选择工具,
脚本即刻就绪。

+

生成 Team 优惠、Codex 按量和账单查询脚本。网站只生成文本,不会代替你请求账户或支付接口。

-
本地生成不上传、不保存优惠码或 Access Token,也不会在本站执行脚本。
+
本地生成不上传、不保存优惠码、空间信息或 Access Token。
+
+ + + +
+
-
INPUT PARAMETERS

脚本参数

+
INPUT PARAMETERS

{meta.title}

实时预览
+

{meta.description}

- - -
- Access Token 来源 -
-
-
- - {accessTokenMode === "manual" ? ( - +
+ + +
+
快捷选择{commonCurrencies.map((code) => )}
+ ) : null} -
- + {activeTool === "codex" ? ( + <> + +
+ + +
+ + ) : null} - -
+ {activeTool === "billing" ? ( +
查询内容账户 ID、套餐、最近 10 条发票、支付方式和账单资料;结果保存到 window.__billingResult
+ ) : null} -
- 快捷选择 - {commonCurrencies.map((code) => ( - - ))} -
+ {renderTokenFields()} - + {activeTool !== "billing" ? ( + + ) : null} + +
- 如何使用 -
    -
  1. 选择自动获取,或手动粘贴 Access Token。
  2. -
  3. 登录 ChatGPT 并打开浏览器控制台。
  4. -
  5. 复制生成的脚本,粘贴后执行。
  6. -
  7. 根据控制台输出查看 Stripe 长链接。
  8. -
+ 安全提示 + {activeTool === "billing" ? ( +

查询结果包含发票、支付方式和账单资料。请仅在自己的账户中运行,不要分享控制台输出。

+ ) : ( +

请使用 Personal/Free 个人账户 Token,不要使用 Business/Codex 空间 Token,并在支付页核对最终金额与目标空间。

+ )}
@@ -331,13 +448,11 @@ export default function CheckoutGenerator({
OUTPUT

JavaScript

- +
-
team-checkout.js
+
{meta.codeTitle}
{source}
@@ -347,11 +462,7 @@ export default function CheckoutGenerator({
-
- -

请仅在你有权操作的账号中使用。 第三方接口、促销资格和结账规则可能调整,实际结果以 ChatGPT 结账页面为准。

-
- +

请仅在你有权操作的账号中使用。 第三方接口、促销资格和结账规则可能调整,实际结果以 ChatGPT 页面为准。

{notice ?
{notice}
: null} ); diff --git a/src/checkout-generator.test.ts b/src/checkout-generator.test.ts index c80a375..328ddfd 100644 --- a/src/checkout-generator.test.ts +++ b/src/checkout-generator.test.ts @@ -25,6 +25,8 @@ describe("checkout script generator", () => { coupon: "", country: "USA", currency: "TRY", + existingWorkspaceId: "", + autoOpen: false, accessTokenMode: "auto", accessToken: "", })) @@ -40,6 +42,8 @@ describe("checkout script generator", () => { coupon: 'SAVE "20" & 更多', country: "SG", currency: "SGD", + existingWorkspaceId: "", + autoOpen: false, accessTokenMode: "auto", accessToken: "", }); @@ -72,6 +76,8 @@ describe("checkout script generator", () => { coupon: "SAVE20", country: "US", currency: "EGP", + existingWorkspaceId: "", + autoOpen: false, accessTokenMode: "manual", accessToken: sessionJson, }); @@ -87,8 +93,27 @@ describe("checkout script generator", () => { coupon: "SAVE20", country: "US", currency: "EGP", + existingWorkspaceId: "", + autoOpen: false, accessTokenMode: "manual", accessToken: '{"user":true}', })).toEqual({ accessToken: "未能从输入内容中提取 accessToken" }); }); + + it("adds an existing workspace UUID and optional auto-open behavior", () => { + const workspaceId = "123e4567-e89b-12d3-a456-426614174000"; + const script = generateCheckoutScript({ + coupon: "SAVE20", + country: "US", + currency: "USD", + existingWorkspaceId: workspaceId, + autoOpen: true, + accessTokenMode: "auto", + accessToken: "", + }); + expect(script).toContain(`const EXISTING_WORKSPACE_ID = "${workspaceId}"`); + expect(script).toContain("existing_workspace_id: EXISTING_WORKSPACE_ID"); + expect(script).toContain("const AUTO_OPEN_CHECKOUT = true"); + expect(() => new Function(script)).not.toThrow(); + }); }); diff --git a/src/checkout-generator.ts b/src/checkout-generator.ts index fb66500..8d32183 100644 --- a/src/checkout-generator.ts +++ b/src/checkout-generator.ts @@ -52,11 +52,13 @@ export type CheckoutScriptInput = { coupon: string; country: string; currency: string; + existingWorkspaceId: string; + autoOpen: boolean; accessTokenMode: AccessTokenMode; accessToken: string; }; -export type CheckoutInputField = "coupon" | "country" | "currency" | "accessToken"; +export type CheckoutInputField = "coupon" | "country" | "currency" | "existingWorkspaceId" | "accessToken"; export type CheckoutValidationErrors = Partial>; export function normalizeIsoInput(value: string, maxLength: number): string { @@ -85,12 +87,12 @@ export function extractAccessToken(value: string): string | null { const trimmed = value.trim(); if (!trimmed) return null; if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"')) { - return trimmed; + return trimmed.replace(/^Bearer\s+/i, "").replace(/\s+/g, "") || null; } try { const parsed: unknown = JSON.parse(trimmed); - if (typeof parsed === "string") return parsed.trim() || null; - return findAccessToken(parsed); + const token = typeof parsed === "string" ? parsed : findAccessToken(parsed); + return token?.replace(/^Bearer\s+/i, "").replace(/\s+/g, "") || null; } catch { return null; } @@ -101,6 +103,9 @@ export function validateCheckoutInput(input: CheckoutScriptInput): CheckoutValid if (!input.coupon.trim()) errors.coupon = "请输入优惠码"; if (!/^[A-Z]{2}$/.test(input.country)) errors.country = "请输入 2 位英文字母国家代码"; if (!isSupportedCheckoutCurrency(input.currency)) errors.currency = "请选择支持的货币代码"; + if (input.existingWorkspaceId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.existingWorkspaceId)) { + errors.existingWorkspaceId = "请输入有效 UUID,或留空新建空间"; + } if (input.accessTokenMode === "manual" && !input.accessToken.trim()) { errors.accessToken = "请输入 Access Token 或 Session JSON"; } else if (input.accessTokenMode === "manual" && !extractAccessToken(input.accessToken)) { @@ -113,6 +118,7 @@ export function generateCheckoutScript(input: CheckoutScriptInput): string { const coupon = input.coupon || "XXXXXXXXXXXX"; const country = input.country || DEFAULT_CHECKOUT_COUNTRY; const currency = input.currency || DEFAULT_CHECKOUT_CURRENCY; + const existingWorkspaceId = input.existingWorkspaceId.trim(); const encodedCoupon = encodeURIComponent(coupon); const tokenStatusMessage = input.accessTokenMode === "manual" ? "⏳ 正在使用手动 Access Token..." @@ -129,9 +135,15 @@ export function generateCheckoutScript(input: CheckoutScriptInput): string { : ` // 1. 自动获取登录凭证 let accessToken; try { - const s = await fetch("/api/auth/session").then(r => r.json()); + const s = await fetch("/api/auth/session", { credentials: "include" }).then(r => r.json()); accessToken = s?.accessToken; if (!accessToken) throw new Error("accessToken 为空,请确认已登录 ChatGPT 账号"); + const currentAccount = s.account || {}; + const currentPlan = currentAccount.planType || currentAccount.plan_type; + const currentStructure = currentAccount.structure; + if (currentPlan !== "free" || currentStructure !== "personal") { + throw new Error("当前选中的不是 Personal/Free 个人账户,请先切换个人账户后重试"); + } } catch (e) { console.error("❌ 获取 Token 失败:", e.message); return; @@ -142,9 +154,15 @@ export function generateCheckoutScript(input: CheckoutScriptInput): string { // ================= 配置项 ================= const WORKSPACE_NAME = "xxx"; const COUPON = ${JSON.stringify(coupon)}; // 优惠码 + const EXISTING_WORKSPACE_ID = ${JSON.stringify(existingWorkspaceId)}; // 留空则新建空间 const SEAT_QUANTITY = 2; // 席位数量(Team 最少 2 个) + const AUTO_OPEN_CHECKOUT = ${JSON.stringify(input.autoOpen)}; // ========================================== + if (window.location.origin !== "https://chatgpt.com") { + throw new Error(\`请在 https://chatgpt.com 页面执行脚本,当前来源为 \${window.location.origin}\`); + } + console.log(${JSON.stringify(tokenStatusMessage)}); ${tokenSource} @@ -155,7 +173,8 @@ ${tokenSource} team_plan_data: { workspace_name: WORKSPACE_NAME, price_interval: "month", // month 或 year - seat_quantity: SEAT_QUANTITY + seat_quantity: SEAT_QUANTITY, + ...(EXISTING_WORKSPACE_ID ? { existing_workspace_id: EXISTING_WORKSPACE_ID } : {}) }, billing_details: { country: ${JSON.stringify(country)}, @@ -170,13 +189,14 @@ ${tokenSource} console.log("⏳ 正在请求 Stripe 支付长链接..."); try { const resp = await fetch( - "https://chatgpt.com/backend-api/payments/checkout", + "/backend-api/payments/checkout", { method: "POST", headers: { Authorization: \`Bearer \${accessToken}\`, "Content-Type": "application/json" }, + credentials: "same-origin", body: JSON.stringify(payload) } ); @@ -193,6 +213,10 @@ ${tokenSource} console.warn("⚠️ 未找到长链接,原始响应:", data); return; } + const checkoutUrl = new URL(hostedUrl); + if (checkoutUrl.protocol !== "https:") { + throw new Error("服务端返回了无效的支付链接"); + } // 4. 打印结果 console.log("─".repeat(60)); @@ -206,8 +230,11 @@ ${tokenSource} } console.log("─".repeat(60)); console.log("🔗 Stripe 支付长链接(复制到浏览器打开):"); - console.log(hostedUrl); + console.log(checkoutUrl.href); console.log("─".repeat(60)); + if (AUTO_OPEN_CHECKOUT) { + window.open(checkoutUrl.href, "_blank", "noopener,noreferrer"); + } } catch (e) { console.error("❌ 网络异常或请求失败:", e.message); } diff --git a/src/script-generators.test.ts b/src/script-generators.test.ts new file mode 100644 index 0000000..90b88fe --- /dev/null +++ b/src/script-generators.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + CODEX_COUNTRIES, + generateBillingScript, + generateCodexScript, + validateCodexInput, +} from "./script-generators"; + +describe("additional script generators", () => { + it("generates a Codex usage checkout with mapped currency and credits", () => { + expect(CODEX_COUNTRIES.some(([country, , currency]) => country === "TH" && currency === "THB")).toBe(true); + const script = generateCodexScript({ + workspaceName: "work", + creditQuantity: 13, + country: "TH", + autoOpen: true, + accessTokenMode: "auto", + accessToken: "", + }); + expect(script).toContain('plan_name: "chatgptbusiness_usage_based"'); + expect(script).toContain('const COUNTRY = "TH"'); + expect(script).toContain('const CURRENCY = "THB"'); + expect(script).toContain("const CREDIT_QUANTITY = 13"); + expect(script).toContain("Personal/Free"); + expect(() => new Function(script)).not.toThrow(); + }); + + it("validates Codex workspace and credit quantity", () => { + expect(validateCodexInput({ + workspaceName: "", + creditQuantity: 0, + country: "XX", + autoOpen: false, + accessTokenMode: "auto", + accessToken: "", + })).toEqual({ + workspaceName: "请输入空间名称", + creditQuantity: "Credit 数量必须是大于 0 的整数", + country: "请选择支持的国家或地区", + }); + }); + + it("generates automatic and manual billing query variants", () => { + const automatic = generateBillingScript({ accessTokenMode: "auto", accessToken: "" }); + expect(automatic).toContain('/api/auth/session'); + expect(automatic).toContain('/backend-api/invoices?limit=10'); + expect(automatic).toContain('/backend-api/payments/payment_methods'); + expect(automatic).toContain('/backend-api/payments/billing_info'); + expect(automatic).toContain("window.__billingResult = result"); + expect(() => new Function(automatic)).not.toThrow(); + + const manual = generateBillingScript({ + accessTokenMode: "manual", + accessToken: JSON.stringify({ accessToken: "manual-billing-token", user: { email: "private@example.com" } }), + }); + expect(manual).toContain('const accessToken = "manual-billing-token"'); + expect(manual).not.toContain('/api/auth/session'); + expect(manual).not.toContain("private@example.com"); + expect(() => new Function(manual)).not.toThrow(); + }); +}); diff --git a/src/script-generators.ts b/src/script-generators.ts new file mode 100644 index 0000000..b3a1a13 --- /dev/null +++ b/src/script-generators.ts @@ -0,0 +1,209 @@ +import { extractAccessToken, type AccessTokenMode } from "./checkout-generator"; + +export type GeneratorTool = "checkout" | "codex" | "billing"; + +export const CODEX_COUNTRIES = [ + ["US", "美国", "USD"], + ["SG", "新加坡", "SGD"], + ["AU", "澳大利亚", "AUD"], + ["FR", "法国", "EUR"], + ["DE", "德国", "EUR"], + ["IT", "意大利", "EUR"], + ["MX", "墨西哥", "MXN"], + ["CO", "哥伦比亚", "COP"], + ["GB", "英国", "GBP"], + ["JP", "日本", "JPY"], + ["PH", "菲律宾", "PHP"], + ["NZ", "新西兰", "NZD"], + ["TH", "泰国", "THB"], +] as const; + +type TokenInput = { + accessTokenMode: AccessTokenMode; + accessToken: string; +}; + +export type CodexScriptInput = TokenInput & { + workspaceName: string; + creditQuantity: number; + country: string; + autoOpen: boolean; +}; + +export type BillingScriptInput = TokenInput; + +export type CodexInputField = "workspaceName" | "creditQuantity" | "country" | "accessToken"; +export type BillingInputField = "accessToken"; + +function tokenError(input: TokenInput): string | undefined { + if (input.accessTokenMode !== "manual") return undefined; + if (!input.accessToken.trim()) return "请输入 Access Token 或 Session JSON"; + if (!extractAccessToken(input.accessToken)) return "未能从输入内容中提取 accessToken"; + return undefined; +} + +export function validateCodexInput(input: CodexScriptInput): Partial> { + const errors: Partial> = {}; + if (!input.workspaceName.trim()) errors.workspaceName = "请输入空间名称"; + if (!Number.isInteger(input.creditQuantity) || input.creditQuantity < 1) { + errors.creditQuantity = "Credit 数量必须是大于 0 的整数"; + } + if (!CODEX_COUNTRIES.some(([code]) => code === input.country)) errors.country = "请选择支持的国家或地区"; + const error = tokenError(input); + if (error) errors.accessToken = error; + return errors; +} + +export function validateBillingInput(input: BillingScriptInput): Partial> { + const error = tokenError(input); + return error ? { accessToken: error } : {}; +} + +function personalAccessTokenPrelude(input: TokenInput): string { + if (input.accessTokenMode === "manual") { + return ` const accessToken = ${JSON.stringify(extractAccessToken(input.accessToken) || "PASTE_ACCESS_TOKEN_HERE")}; + if (!accessToken || accessToken === "PASTE_ACCESS_TOKEN_HERE") { + throw new Error("Access Token 为空,请重新生成并填入 Token"); + }`; + } + return ` const sessionResponse = await fetch("/api/auth/session", { credentials: "include" }); + const session = await sessionResponse.json().catch(() => ({})); + if (!sessionResponse.ok || !session.accessToken) { + throw new Error("无法从当前登录会话获取 Access Token,请手动填写 AT 或重新登录 ChatGPT"); + } + const currentAccount = session.account || {}; + const currentPlan = currentAccount.planType || currentAccount.plan_type; + const currentStructure = currentAccount.structure; + if (currentPlan !== "free" || currentStructure !== "personal") { + throw new Error("当前选中的不是 Personal/Free 个人账户,请先切换个人账户后重试"); + } + const accessToken = session.accessToken;`; +} + +export function generateCodexScript(input: CodexScriptInput): string { + const country = CODEX_COUNTRIES.find(([code]) => code === input.country) || CODEX_COUNTRIES[0]; + const currency = country[2]; + return `(async function generateCodexUsageCheckout() { + // ================= 配置项 ================= + const WORKSPACE_NAME = ${JSON.stringify(input.workspaceName.trim() || "work")}; + const CREDIT_QUANTITY = ${JSON.stringify(input.creditQuantity || 13)}; + const COUNTRY = ${JSON.stringify(country[0])}; + const CURRENCY = ${JSON.stringify(currency)}; + const AUTO_OPEN_CHECKOUT = ${JSON.stringify(input.autoOpen)}; + // ========================================== + + if (window.location.origin !== "https://chatgpt.com") { + throw new Error(\`请在 https://chatgpt.com 页面执行脚本,当前来源为 \${window.location.origin}\`); + } + +${personalAccessTokenPrelude(input)} + + const payload = { + plan_name: "chatgptbusiness_usage_based", + entry_point: "team_workspace_purchase_modal", + checkout_ui_mode: "hosted", + billing_details: { country: COUNTRY, currency: CURRENCY }, + usage_based_workspace_credit_purchase_data: { + workspace_name: WORKSPACE_NAME, + quantity: CREDIT_QUANTITY, + unit: "credit" + }, + cancel_url: "https://chatgpt.com/#pricing" + }; + + console.log("⏳ 正在创建 Codex 按量 checkout..."); + const response = await fetch("/backend-api/payments/checkout", { + method: "POST", + headers: { + Authorization: \`Bearer \${accessToken}\`, + "Content-Type": "application/json" + }, + credentials: "same-origin", + body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const detail = data.detail || data.error?.message || data.error || \`HTTP \${response.status}\`; + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + if (!data.url) throw new Error(data.error || "服务端未返回支付链接"); + + const checkoutUrl = new URL(data.url); + if (checkoutUrl.protocol !== "https:") throw new Error("服务端返回了无效的支付链接"); + console.log("✅ Codex 按量支付长链已生成:", checkoutUrl.href); + console.log("请在支付页核对空间名称、Credit 数量和金额。"); + if (AUTO_OPEN_CHECKOUT) window.open(checkoutUrl.href, "_blank", "noopener,noreferrer"); + return checkoutUrl.href; +})().catch((error) => { + console.error("❌ Codex checkout 创建失败:", error.message); +});`; +} + +export function generateBillingScript(input: BillingScriptInput): string { + const sessionAndToken = input.accessTokenMode === "manual" + ? ` const session = {}; + const accessToken = ${JSON.stringify(extractAccessToken(input.accessToken) || "PASTE_ACCESS_TOKEN_HERE")}; + if (!accessToken || accessToken === "PASTE_ACCESS_TOKEN_HERE") { + throw new Error("Access Token 为空,请重新生成并填入 Token"); + }` + : ` const sessionResp = await fetch("/api/auth/session", { credentials: "include" }); + if (!sessionResp.ok) throw new Error("session HTTP " + sessionResp.status); + const session = await sessionResp.json(); + const accessToken = session.accessToken; + if (!accessToken) throw new Error("没有拿到 accessToken,确认已登录 chatgpt.com");`; + + return `(async function queryChatGPTBilling() { + if (window.location.origin !== "https://chatgpt.com") { + throw new Error(\`请在 https://chatgpt.com 页面执行脚本,当前来源为 \${window.location.origin}\`); + } + +${sessionAndToken} + + const headers = { + Authorization: \`Bearer \${accessToken}\`, + Accept: "application/json", + "Content-Type": "application/json", + "oai-language": "zh-Hant", + "oai-device-id": crypto.randomUUID() + }; + + const getJson = async (url) => { + const response = await fetch(url, { headers }); + const text = await response.text(); + if (!response.ok) throw new Error(\`\${url} HTTP \${response.status}: \${text.slice(0, 300)}\`); + return JSON.parse(text); + }; + + const accountCheck = await getJson("/backend-api/accounts/check/v4-2023-04-27"); + const accounts = accountCheck.accounts || {}; + const firstKey = Object.keys(accounts)[0]; + const accountId = + accounts[firstKey]?.account?.account_id || + session.account?.id || + firstKey; + if (!accountId) throw new Error("没有拿到 account_id"); + + const [invoices, paymentMethods, billingInfo] = await Promise.all([ + getJson(\`/backend-api/invoices?limit=10&account_id=\${encodeURIComponent(accountId)}\`), + getJson(\`/backend-api/payments/payment_methods?account_id=\${encodeURIComponent(accountId)}\`), + getJson(\`/backend-api/payments/billing_info?account_id=\${encodeURIComponent(accountId)}\`) + ]); + + const result = { + email: session.user?.email || null, + plan: accounts[firstKey]?.account?.plan_type || session.account?.planType || null, + accountId, + accessTokenStatus: accessToken ? \`exists (\${accessToken.length} chars)\` : "missing", + invoices, + paymentMethods, + billingInfo + }; + + console.log("账单提取结果:", result); + console.log("Stripe/账单管理页:", \`https://chatgpt.com/account/manage?account_id=\${encodeURIComponent(accountId)}\`); + window.__billingResult = result; + return result; +})().catch((error) => { + console.error("❌ 账单查询失败:", error.message); +});`; +} diff --git a/src/styles.css b/src/styles.css index 6999c4f..8583fa1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -153,12 +153,20 @@ tbody tr:hover { background: #f9fbf8; } .generator-promise div { display: flex; flex-direction: column; gap: 6px; } .generator-promise strong { color: var(--ink); font-size: 13px; } .generator-promise span { color: var(--muted); font-size: 11px; line-height: 1.65; } +.generator-tool-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin-bottom: 12px; } +.generator-tool-tabs button { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 13px 15px; border: 1px solid var(--line); border-radius: 13px; background: rgba(255,255,255,.68); color: var(--muted); text-align: left; cursor: pointer; transition: border-color .18s ease, background .18s ease, transform .18s ease; } +.generator-tool-tabs button:hover { border-color: #b9c8bd; transform: translateY(-1px); } +.generator-tool-tabs button.active { border-color: var(--green); background: var(--green); color: white; box-shadow: 0 9px 22px rgba(23,107,77,.14); } +.generator-tool-tabs button > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; } +.generator-tool-tabs strong { font-size: 11px; } +.generator-tool-tabs small { color: inherit; font-size: 8px; opacity: .72; } .generator-workspace { display: grid; grid-template-columns: minmax(370px, .8fr) minmax(0, 1.2fr); border: 1px solid var(--line); border-radius: 24px; background: rgba(255,255,255,.78); box-shadow: 0 24px 70px rgba(21,48,37,.07); overflow: hidden; } .generator-form-panel, .generator-result-panel { min-width: 0; padding: 34px; } .generator-form-panel { border-right: 1px solid var(--line); } .generator-result-panel { background: #f0f3ef; } .generator-section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } .generator-section-head h2 { margin: 7px 0 0; font-family: Georgia, "Noto Serif SC", serif; font-size: 28px; font-weight: 600; letter-spacing: -.03em; } +.tool-description { margin: 10px 0 0; color: var(--muted); font-size: 10px; line-height: 1.65; } .live-preview { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 9px; font-weight: 700; } .live-preview > span { width: 7px; height: 7px; border-radius: 50%; background: #2ca675; box-shadow: 0 0 0 4px rgba(44,166,117,.12); } .generator-form-panel form { margin-top: 28px; } @@ -166,10 +174,11 @@ tbody tr:hover { background: #f9fbf8; } .generator-field > span { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .generator-field > span strong { font-size: 12px; } .generator-field > span small { color: #8a9490; font-size: 9px; font-weight: 700; text-transform: uppercase; } -.generator-field > input, .iso-input { width: 100%; height: 49px; border: 1px solid #d7ddd7; border-radius: 11px; background: white; transition: border-color .18s ease, box-shadow .18s ease; } -.generator-field > input { padding: 0 13px; color: var(--ink); outline: 0; } -.generator-field > input:focus, .iso-input:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(23,107,77,.1); } -.generator-field.has-error > input, .generator-field.has-error .iso-input { border-color: #bd5b4f; } +.generator-field > input, .generator-field > select, .iso-input { width: 100%; height: 49px; border: 1px solid #d7ddd7; border-radius: 11px; background: white; transition: border-color .18s ease, box-shadow .18s ease; } +.generator-field > input, .generator-field > select { padding: 0 13px; color: var(--ink); outline: 0; } +.generator-field > select { cursor: pointer; } +.generator-field > input:focus, .generator-field > select:focus, .iso-input:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(23,107,77,.1); } +.generator-field.has-error > input, .generator-field.has-error > select, .generator-field.has-error .iso-input { border-color: #bd5b4f; } .generator-field > em { display: block; margin-top: 7px; color: #a74338; font-size: 9px; font-style: normal; } .token-source-field { margin: 0 0 18px; padding: 0; border: 0; } .token-source-field legend { margin-bottom: 8px; font-size: 12px; font-weight: 750; } @@ -199,6 +208,15 @@ tbody tr:hover { background: #f9fbf8; } .generator-steps { margin-top: 28px; padding-top: 22px; border-top: 1px solid var(--line); } .generator-steps > span { color: var(--green); font: 800 9px/1 monospace; letter-spacing: .1em; text-transform: uppercase; } .generator-steps ol { margin: 12px 0 0; padding-left: 19px; color: var(--muted); font-size: 10px; line-height: 1.9; } +.generator-steps p { margin: 12px 0 0; color: var(--muted); font-size: 10px; line-height: 1.8; } +.generator-checkbox { display: flex; align-items: center; gap: 8px; margin: 4px 0 0; color: var(--muted); font-size: 10px; cursor: pointer; } +.generator-checkbox input { width: 15px; height: 15px; margin: 0; accent-color: var(--green); } +.billing-scope-note { display: flex; align-items: flex-start; gap: 10px; margin: 20px 0; padding: 14px; border: 1px solid #d8dfd9; border-radius: 11px; background: #f3f6f2; color: var(--green); } +.billing-scope-note > svg { flex: 0 0 auto; } +.billing-scope-note div { display: flex; flex-direction: column; gap: 5px; } +.billing-scope-note strong { color: var(--ink); font-size: 11px; } +.billing-scope-note span { color: var(--muted); font-size: 9px; line-height: 1.6; } +.billing-scope-note code { padding: 1px 4px; border-radius: 4px; background: #e4eae4; color: var(--green-dark); font: 700 8px/1 monospace; } .result-head { align-items: center; } .code-actions { display: flex; align-items: center; gap: 7px; } .code-actions button { display: inline-flex; height: 35px; align-items: center; gap: 6px; padding: 0 10px; border: 1px solid #cbd3cc; border-radius: 9px; background: white; color: var(--ink); font-size: 9px; font-weight: 750; cursor: pointer; } @@ -292,6 +310,8 @@ footer > span:last-child { text-align: right; } .generator-hero h1 { font-size: clamp(43px, 13vw, 62px); } .generator-hero > div:first-child > p { font-size: 13px; } .generator-workspace { grid-template-columns: 1fr; } + .generator-tool-tabs { grid-template-columns: 1fr; } + .generator-tool-tabs button { min-height: 58px; } .generator-form-panel { border-right: 0; border-bottom: 1px solid var(--line); } .generator-field-grid { grid-template-columns: 1fr 1fr; gap: 12px; } .code-preview pre { max-height: 480px; } @@ -316,6 +336,7 @@ footer > span:last-child { text-align: right; } .search-control, .sort-control { grid-column: auto; } .section-heading p { display: none; } .generator-form-panel, .generator-result-panel { padding: 22px 16px; } + .generator-tool-tabs button { padding: 11px 13px; } .generator-field-grid { grid-template-columns: 1fr; gap: 0; } .token-source-options { grid-template-columns: 1fr; } .result-head { align-items: flex-end; }