This commit is contained in:
Kaihua
2026-07-30 23:48:01 +08:00
parent 17d8dc20eb
commit 0f8e21a1d8
10 changed files with 693 additions and 195 deletions
+9 -5
View File
@@ -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&currency=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 页面结果为准。
### 发布保护
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#f5f7f3" />
<meta
name="description"
content="查询 ChatGPT Business 全球月付价格,并按国家、货币和优惠码生成结账脚本。"
content="查询 ChatGPT Business 全球月付价格,并生成 Team 优惠、Codex 按量和账单查询脚本。"
/>
<meta property="og:title" content="Business Toolkit|全球月付价格与脚本生成器" />
<meta
+28
View File
@@ -168,4 +168,32 @@ describe("pricing explorer", () => {
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(<App />);
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"));
});
});
+16 -4
View File
@@ -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<PriceRow, "countryCode" | "currencyCode">) => {
const navigate = (
nextView: AppView,
row?: Pick<PriceRow, "countryCode" | "currencyCode">,
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 ? <LoadingRows /> : null}
{snapshot && rows.length ? (
<>
<PriceTable rows={rows} currency={currency} onGenerate={(row) => navigate("generator", row)} />
<PriceCards rows={rows} currency={currency} onGenerate={(row) => navigate("generator", row)} />
<PriceTable rows={rows} currency={currency} onGenerate={(row) => navigate("generator", row, "checkout")} />
<PriceCards rows={rows} currency={currency} onGenerate={(row) => navigate("generator", row, "checkout")} />
</>
) : null}
{snapshot && !rows.length ? (
@@ -359,10 +369,12 @@ export default function App() {
</>
) : (
<CheckoutGenerator
initialTool={route.tool}
initialCountry={route.country}
initialCurrency={route.currency}
countries={snapshot?.rows ?? []}
onBack={() => navigate("prices")}
onToolChange={(tool) => navigate("generator", undefined, tool)}
/>
)}
</main>
+252 -141
View File
@@ -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<Record<FormField, string>>;
const commonCurrencies = ["USD", "EUR", "GBP", "SGD", "EGP"];
const toolMeta: Record<GeneratorTool, { title: string; description: string; filename: string; codeTitle: string }> = {
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<GeneratorTool>(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<AccessTokenMode>(DEFAULT_ACCESS_TOKEN_MODE);
const [accessToken, setAccessToken] = useState("");
const [errors, setErrors] = useState<CheckoutValidationErrors>({});
const [errors, setErrors] = useState<FormErrors>({});
const [notice, setNotice] = useState("");
const [copied, setCopied] = useState(false);
const couponRef = useRef<HTMLInputElement>(null);
const countryRef = useRef<HTMLInputElement>(null);
const currencyRef = useRef<HTMLInputElement>(null);
const workspaceIdRef = useRef<HTMLInputElement>(null);
const workspaceNameRef = useRef<HTMLInputElement>(null);
const creditQuantityRef = useRef<HTMLInputElement>(null);
const accessTokenRef = useRef<HTMLInputElement>(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,52 +265,27 @@ 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("已恢复当前生成器的默认值");
};
return (
<div className="generator-page">
<section className="generator-hero">
<div>
<button className="back-link" type="button" onClick={onBack}>
<ArrowLeft size={15} />
</button>
<span className="eyebrow"><Code2 size={15} /> BUSINESS TOOL · GENERATOR</span>
<h1><br /><em></em></h1>
<p> Access Token ChatGPT Team </p>
</div>
<div className="generator-promise">
<ShieldCheck size={22} />
<div><strong></strong><span> Access Token</span></div>
</div>
</section>
<section className="generator-workspace" aria-labelledby="generator-title">
<div className="generator-form-panel">
<div className="generator-section-head">
<div><span className="section-kicker">INPUT PARAMETERS</span><h2 id="generator-title"></h2></div>
<span className="live-preview"><span /> </span>
</div>
<form onSubmit={handleGenerate} noValidate>
<label className={`generator-field ${errors.coupon ? "has-error" : ""}`}>
<span><strong></strong><small></small></span>
<input
ref={couponRef}
value={coupon}
onChange={(event) => { setCoupon(event.target.value); clearError("coupon"); }}
placeholder="例如:XXXXXXXXXXXX"
autoComplete="off"
spellCheck={false}
aria-invalid={Boolean(errors.coupon)}
/>
{errors.coupon ? <em role="alert">{errors.coupon}</em> : null}
</label>
const changeTool = (tool: GeneratorTool) => {
if (tool === activeTool) return;
setActiveTool(tool);
setErrors({});
setCopied(false);
onToolChange(tool);
};
const renderTokenFields = () => (
<>
<fieldset className="token-source-field">
<legend>Access Token </legend>
<div className="token-source-options">
@@ -235,16 +309,12 @@ export default function CheckoutGenerator({
name="access-token-mode"
value="manual"
checked={accessTokenMode === "manual"}
onChange={() => {
setAccessTokenMode("manual");
clearError("accessToken");
}}
onChange={() => { setAccessTokenMode("manual"); clearError("accessToken"); }}
/>
<span><strong></strong><small> Token Session JSON</small></span>
</label>
</div>
</fieldset>
{accessTokenMode === "manual" ? (
<label className={`generator-field token-value-field ${errors.accessToken ? "has-error" : ""}`}>
<span><strong>Access Token / Session JSON</strong><small></small></span>
@@ -258,71 +328,118 @@ export default function CheckoutGenerator({
spellCheck={false}
aria-invalid={Boolean(errors.accessToken)}
/>
{errors.accessToken ? <em role="alert">{errors.accessToken}</em> : <small className="field-note">Session JSON accessToken</small>}
{errors.accessToken
? <em role="alert">{errors.accessToken}</em>
: <small className="field-note"> accessToken</small>}
</label>
) : null}
</>
);
return (
<div className="generator-page">
<section className="generator-hero">
<div>
<button className="back-link" type="button" onClick={onBack}><ArrowLeft size={15} /> </button>
<span className="eyebrow"><Code2 size={15} /> BUSINESS TOOL · GENERATOR</span>
<h1><br /><em></em></h1>
<p> Team Codex </p>
</div>
<div className="generator-promise">
<ShieldCheck size={22} />
<div><strong></strong><span> Access Token</span></div>
</div>
</section>
<div className="generator-tool-tabs" role="tablist" aria-label="脚本类型">
<button role="tab" aria-selected={activeTool === "checkout"} className={activeTool === "checkout" ? "active" : ""} onClick={() => changeTool("checkout")}><Users size={16} /><span><strong>Team </strong><small></small></span></button>
<button role="tab" aria-selected={activeTool === "codex"} className={activeTool === "codex" ? "active" : ""} onClick={() => changeTool("codex")}><CreditCard size={16} /><span><strong>Codex </strong><small> Workspace Credit</small></span></button>
<button role="tab" aria-selected={activeTool === "billing"} className={activeTool === "billing" ? "active" : ""} onClick={() => changeTool("billing")}><ReceiptText size={16} /><span><strong></strong><small></small></span></button>
</div>
<section className="generator-workspace" aria-labelledby="generator-title">
<div className="generator-form-panel">
<div className="generator-section-head">
<div><span className="section-kicker">INPUT PARAMETERS</span><h2 id="generator-title">{meta.title}</h2></div>
<span className="live-preview"><span /> </span>
</div>
<p className="tool-description">{meta.description}</p>
<form onSubmit={handleGenerate} noValidate>
{activeTool === "checkout" ? (
<>
<label className={`generator-field ${errors.coupon ? "has-error" : ""}`}>
<span><strong></strong><small></small></span>
<input ref={couponRef} value={coupon} onChange={(event) => { setCoupon(event.target.value); clearError("coupon"); }} placeholder="例如:XXXXXXXXXXXX" autoComplete="off" spellCheck={false} aria-invalid={Boolean(errors.coupon)} />
{errors.coupon ? <em role="alert">{errors.coupon}</em> : null}
</label>
<label className={`generator-field ${errors.existingWorkspaceId ? "has-error" : ""}`}>
<span><strong> Codex ID</strong><small></small></span>
<input ref={workspaceIdRef} value={existingWorkspaceId} onChange={(event) => { setExistingWorkspaceId(event.target.value.trim()); clearError("existingWorkspaceId"); }} placeholder="填写 UUID 则应用到已有空间;留空则新建" autoComplete="off" spellCheck={false} aria-invalid={Boolean(errors.existingWorkspaceId)} />
{errors.existingWorkspaceId ? <em role="alert">{errors.existingWorkspaceId}</em> : <small className="field-note"> 0.52 Codex </small>}
</label>
<div className="generator-field-grid">
<label className={`generator-field ${errors.country ? "has-error" : ""}`}>
<span><strong> ISO </strong><small>2 </small></span>
<div className="iso-input"><b>ISO</b><input
ref={countryRef}
value={country}
onChange={(event) => { setCountry(normalizeIsoInput(event.target.value, 2)); clearError("country"); }}
maxLength={2}
list="checkout-country-list"
autoComplete="country"
spellCheck={false}
aria-invalid={Boolean(errors.country)}
/></div>
<datalist id="checkout-country-list">
{countries.map((row) => <option key={row.countryCode} value={row.countryCode}>{row.countryName}</option>)}
</datalist>
<div className="iso-input"><b>ISO</b><input ref={countryRef} value={country} onChange={(event) => { setCountry(normalizeIsoInput(event.target.value, 2)); clearError("country"); }} maxLength={2} list="checkout-country-list" autoComplete="country" spellCheck={false} aria-invalid={Boolean(errors.country)} /></div>
<datalist id="checkout-country-list">{countries.map((row) => <option key={row.countryCode} value={row.countryCode}>{row.countryName}</option>)}</datalist>
{errors.country ? <em role="alert">{errors.country}</em> : <small className="field-note"> CNUSKE</small>}
</label>
<label className={`generator-field ${errors.currency ? "has-error" : ""}`}>
<span><strong> ISO </strong><small>39 </small></span>
<div className="iso-input"><b>ISO</b><input
ref={currencyRef}
value={currency}
onChange={(event) => { setCurrency(normalizeIsoInput(event.target.value, 3)); clearError("currency"); }}
maxLength={3}
list="checkout-currency-list"
autoComplete="off"
spellCheck={false}
aria-invalid={Boolean(errors.currency)}
/></div>
<datalist id="checkout-currency-list">
{CHECKOUT_CURRENCIES.map(([code, name]) => <option key={code} value={code}>{name}</option>)}
</datalist>
{errors.currency ? <em role="alert">{errors.currency}</em> : <small className="field-note">{currencyName ? `${normalized.currency} · ${currencyName}` : "输入或选择货币代码"}</small>}
<div className="iso-input"><b>ISO</b><input ref={currencyRef} value={currency} onChange={(event) => { setCurrency(normalizeIsoInput(event.target.value, 3)); clearError("currency"); }} maxLength={3} list="checkout-currency-list" autoComplete="off" spellCheck={false} aria-invalid={Boolean(errors.currency)} /></div>
<datalist id="checkout-currency-list">{CHECKOUT_CURRENCIES.map(([code, name]) => <option key={code} value={code}>{name}</option>)}</datalist>
{errors.currency ? <em role="alert">{errors.currency}</em> : <small className="field-note">{currencyName ? `${checkoutInput.currency} · ${currencyName}` : "输入或选择货币代码"}</small>}
</label>
</div>
<div className="quick-currency-row" aria-label="常用货币快捷选择"><span></span>{commonCurrencies.map((code) => <button key={code} type="button" className={checkoutInput.currency === code ? "active" : ""} onClick={() => { setCurrency(code); clearError("currency"); }}>{code}</button>)}</div>
</>
) : null}
<div className="quick-currency-row" aria-label="常用货币快捷选择">
<span></span>
{commonCurrencies.map((code) => (
<button
key={code}
type="button"
className={normalized.currency === code ? "active" : ""}
onClick={() => { setCurrency(code); clearError("currency"); }}
>{code}</button>
))}
{activeTool === "codex" ? (
<>
<label className={`generator-field ${errors.workspaceName ? "has-error" : ""}`}>
<span><strong></strong><small></small></span>
<input ref={workspaceNameRef} value={workspaceName} onChange={(event) => { setWorkspaceName(event.target.value); clearError("workspaceName"); }} placeholder="填写空间名称" autoComplete="off" aria-invalid={Boolean(errors.workspaceName)} />
{errors.workspaceName ? <em role="alert">{errors.workspaceName}</em> : null}
</label>
<div className="generator-field-grid">
<label className={`generator-field ${errors.creditQuantity ? "has-error" : ""}`}>
<span><strong>Credit </strong><small> 0 </small></span>
<input ref={creditQuantityRef} type="number" min="1" step="1" value={creditQuantity} onChange={(event) => { setCreditQuantity(event.target.value); clearError("creditQuantity"); }} aria-invalid={Boolean(errors.creditQuantity)} />
{errors.creditQuantity ? <em role="alert">{errors.creditQuantity}</em> : <small className="field-note"> 13 Credit</small>}
</label>
<label className={`generator-field ${errors.country ? "has-error" : ""}`}>
<span><strong></strong><small></small></span>
<select value={codexCountry} onChange={(event) => { setCodexCountry(event.target.value); clearError("country"); }} aria-invalid={Boolean(errors.country)}>
{CODEX_COUNTRIES.map(([code, name, mappedCurrency]) => <option key={code} value={code}>{name} ({mappedCurrency})</option>)}
</select>
<small className="field-note">{selectedCodexCountry[0]} · {selectedCodexCountry[2]}</small>
</label>
</div>
</>
) : null}
<button className="generate-button" type="submit"><Sparkles size={17} /> <span></span></button>
{activeTool === "billing" ? (
<div className="billing-scope-note"><ReceiptText size={18} /><div><strong></strong><span> ID 10 <code>window.__billingResult</code></span></div></div>
) : null}
{renderTokenFields()}
{activeTool !== "billing" ? (
<label className="generator-checkbox"><input type="checkbox" checked={autoOpen} onChange={(event) => setAutoOpen(event.target.checked)} /><span></span></label>
) : null}
<button className="generate-button" type="submit"><Sparkles size={17} /> {meta.title} <span></span></button>
</form>
<div className="generator-steps">
<span>使</span>
<ol>
<li> Access Token</li>
<li> ChatGPT </li>
<li></li>
<li> Stripe </li>
</ol>
<span></span>
{activeTool === "billing" ? (
<p></p>
) : (
<p>使 Personal/Free Token使 Business/Codex Token</p>
)}
</div>
</div>
@@ -331,13 +448,11 @@ export default function CheckoutGenerator({
<div><span className="section-kicker">OUTPUT</span><h2>JavaScript</h2></div>
<div className="code-actions">
<button type="button" onClick={downloadCode} aria-disabled={!isInputValid}><Download size={14} /> </button>
<button className="copy-code" type="button" onClick={copyCode} aria-disabled={!isInputValid}>
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? "已复制" : "复制代码"}
</button>
<button className="copy-code" type="button" onClick={copyCode} aria-disabled={!isInputValid}>{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? "已复制" : "复制代码"}</button>
</div>
</div>
<div className="code-preview">
<div className="code-titlebar"><span /><span /><span /><small>team-checkout.js</small></div>
<div className="code-titlebar"><span /><span /><span /><small>{meta.codeTitle}</small></div>
<pre><code>{source}</code></pre>
</div>
<div className="result-meta">
@@ -347,11 +462,7 @@ export default function CheckoutGenerator({
</div>
</section>
<section className="generator-disclaimer">
<Info size={18} />
<p><strong>使</strong> ChatGPT </p>
</section>
<section className="generator-disclaimer"><Info size={18} /><p><strong>使</strong> ChatGPT </p></section>
{notice ? <div className="generator-toast" role="status">{notice}</div> : null}
</div>
);
+25
View File
@@ -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();
});
});
+35 -8
View File
@@ -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<Record<CheckoutInputField, string>>;
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);
}
+61
View File
@@ -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();
});
});
+209
View File
@@ -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<Record<CodexInputField, string>> {
const errors: Partial<Record<CodexInputField, string>> = {};
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<Record<BillingInputField, string>> {
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);
});`;
}
+25 -4
View File
@@ -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; }