up
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Business Toolkit
|
# Business Toolkit
|
||||||
|
|
||||||
一个独立的 ChatGPT Business 价格与结账工具箱。价格雷达从 `chatgpt.com` 的公开结账价格配置中读取 `currency_config.business.month`,保留官方原币价格并按公开汇率换算;脚本生成器根据优惠码、国家和货币在浏览器本地生成可复制的结账脚本。
|
一个独立的 ChatGPT Business 价格与脚本工具箱。价格雷达从 `chatgpt.com` 的公开结账价格配置中读取 `currency_config.business.month`,保留官方原币价格并按公开汇率换算;脚本生成器在浏览器本地生成 Team 优惠、Codex 按量和账单查询脚本。
|
||||||
|
|
||||||
> 本项目不是 OpenAI 官方产品,与 OpenAI 没有隶属、合作或背书关系。实际价格、税费、付款资格和地区可用性以结账页为准。
|
> 本项目不是 OpenAI 官方产品,与 OpenAI 没有隶属、合作或背书关系。实际价格、税费、付款资格和地区可用性以结账页为准。
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@
|
|||||||
- 显示官方原币价格、人民币/美元估算、含税或未含税口径。
|
- 显示官方原币价格、人民币/美元估算、含税或未含税口径。
|
||||||
- 支持搜索、筛选、排序以及桌面表格和移动端卡片。
|
- 支持搜索、筛选、排序以及桌面表格和移动端卡片。
|
||||||
- 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。
|
- 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。
|
||||||
- 脚本生成器支持 39 种货币、实时预览、复制和 `.js` 下载。
|
- Team 优惠生成器支持 39 种货币、已有空间 UUID、实时预览、复制和 `.js` 下载。
|
||||||
|
- Codex 按量生成器支持空间名称、Credit 数量和国家到货币自动匹配。
|
||||||
|
- 账单查询生成器可查询最近 10 条发票、支付方式和账单资料。
|
||||||
- 优惠码和手动提供的 Token 仅在当前浏览器内处理,不写入网址或本地存储。
|
- 优惠码和手动提供的 Token 仅在当前浏览器内处理,不写入网址或本地存储。
|
||||||
- GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。
|
- GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。
|
||||||
- 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。
|
- 单个地区暂时失败时,最多沿用 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 页面结果为准。
|
||||||
|
|
||||||
### 发布保护
|
### 发布保护
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<meta name="theme-color" content="#f5f7f3" />
|
<meta name="theme-color" content="#f5f7f3" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="查询 ChatGPT Business 全球月付价格,并按国家、货币和优惠码生成结账脚本。"
|
content="查询 ChatGPT Business 全球月付价格,并生成 Team 优惠、Codex 按量和账单查询脚本。"
|
||||||
/>
|
/>
|
||||||
<meta property="og:title" content="Business Toolkit|全球月付价格与脚本生成器" />
|
<meta property="og:title" content="Business Toolkit|全球月付价格与脚本生成器" />
|
||||||
<meta
|
<meta
|
||||||
|
|||||||
@@ -168,4 +168,32 @@ describe("pricing explorer", () => {
|
|||||||
await user.click(screen.getByRole("radio", { name: /手动粘贴/ }));
|
await user.click(screen.getByRole("radio", { name: /手动粘贴/ }));
|
||||||
expect(screen.getByPlaceholderText("粘贴 accessToken 或完整 Session JSON")).toHaveValue("");
|
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
@@ -19,6 +19,7 @@ import {
|
|||||||
DEFAULT_CHECKOUT_CURRENCY,
|
DEFAULT_CHECKOUT_CURRENCY,
|
||||||
isSupportedCheckoutCurrency,
|
isSupportedCheckoutCurrency,
|
||||||
} from "./checkout-generator";
|
} from "./checkout-generator";
|
||||||
|
import type { GeneratorTool } from "./script-generators";
|
||||||
import { filterAndSortRows, flagEmoji, formatConverted, formatLocal, relativeTime, taxLabel } from "./lib";
|
import { filterAndSortRows, flagEmoji, formatConverted, formatLocal, relativeTime, taxLabel } from "./lib";
|
||||||
import type {
|
import type {
|
||||||
DisplayCurrency,
|
DisplayCurrency,
|
||||||
@@ -32,12 +33,15 @@ import type {
|
|||||||
const currencyStorageKey = "business-price-radar:currency";
|
const currencyStorageKey = "business-price-radar:currency";
|
||||||
|
|
||||||
type AppView = "prices" | "generator";
|
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 {
|
function readRoute(): AppRoute {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
return {
|
return {
|
||||||
view: params.get("view") === "generator" ? "generator" : "prices",
|
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,
|
country: params.get("country")?.toUpperCase() || DEFAULT_CHECKOUT_COUNTRY,
|
||||||
currency: params.get("currency")?.toUpperCase() || DEFAULT_CHECKOUT_CURRENCY,
|
currency: params.get("currency")?.toUpperCase() || DEFAULT_CHECKOUT_CURRENCY,
|
||||||
};
|
};
|
||||||
@@ -129,10 +133,15 @@ export default function App() {
|
|||||||
window.localStorage.setItem(currencyStorageKey, value);
|
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);
|
const url = new URL(window.location.href);
|
||||||
if (nextView === "generator") {
|
if (nextView === "generator") {
|
||||||
url.searchParams.set("view", "generator");
|
url.searchParams.set("view", "generator");
|
||||||
|
url.searchParams.set("tool", tool);
|
||||||
if (row) {
|
if (row) {
|
||||||
url.searchParams.set("country", row.countryCode);
|
url.searchParams.set("country", row.countryCode);
|
||||||
url.searchParams.set("currency", row.currencyCode);
|
url.searchParams.set("currency", row.currencyCode);
|
||||||
@@ -144,6 +153,7 @@ export default function App() {
|
|||||||
url.searchParams.delete("view");
|
url.searchParams.delete("view");
|
||||||
url.searchParams.delete("country");
|
url.searchParams.delete("country");
|
||||||
url.searchParams.delete("currency");
|
url.searchParams.delete("currency");
|
||||||
|
url.searchParams.delete("tool");
|
||||||
}
|
}
|
||||||
window.history.pushState({}, "", url);
|
window.history.pushState({}, "", url);
|
||||||
setRoute(readRoute());
|
setRoute(readRoute());
|
||||||
@@ -321,8 +331,8 @@ export default function App() {
|
|||||||
{!snapshot && !loadingError ? <LoadingRows /> : null}
|
{!snapshot && !loadingError ? <LoadingRows /> : null}
|
||||||
{snapshot && rows.length ? (
|
{snapshot && rows.length ? (
|
||||||
<>
|
<>
|
||||||
<PriceTable 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)} />
|
<PriceCards rows={rows} currency={currency} onGenerate={(row) => navigate("generator", row, "checkout")} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{snapshot && !rows.length ? (
|
{snapshot && !rows.length ? (
|
||||||
@@ -359,10 +369,12 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<CheckoutGenerator
|
<CheckoutGenerator
|
||||||
|
initialTool={route.tool}
|
||||||
initialCountry={route.country}
|
initialCountry={route.country}
|
||||||
initialCurrency={route.currency}
|
initialCurrency={route.currency}
|
||||||
countries={snapshot?.rows ?? []}
|
countries={snapshot?.rows ?? []}
|
||||||
onBack={() => navigate("prices")}
|
onBack={() => navigate("prices")}
|
||||||
|
onToolChange={(tool) => navigate("generator", undefined, tool)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+284
-173
@@ -3,11 +3,14 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Code2,
|
Code2,
|
||||||
Copy,
|
Copy,
|
||||||
|
CreditCard,
|
||||||
Download,
|
Download,
|
||||||
Info,
|
Info,
|
||||||
|
ReceiptText,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -19,40 +22,94 @@ import {
|
|||||||
isSupportedCheckoutCurrency,
|
isSupportedCheckoutCurrency,
|
||||||
normalizeIsoInput,
|
normalizeIsoInput,
|
||||||
validateCheckoutInput,
|
validateCheckoutInput,
|
||||||
|
type AccessTokenMode,
|
||||||
type CheckoutScriptInput,
|
type CheckoutScriptInput,
|
||||||
type CheckoutInputField,
|
|
||||||
type CheckoutValidationErrors,
|
|
||||||
} from "./checkout-generator";
|
} from "./checkout-generator";
|
||||||
|
import {
|
||||||
|
CODEX_COUNTRIES,
|
||||||
|
generateBillingScript,
|
||||||
|
generateCodexScript,
|
||||||
|
validateBillingInput,
|
||||||
|
validateCodexInput,
|
||||||
|
type BillingScriptInput,
|
||||||
|
type CodexScriptInput,
|
||||||
|
type GeneratorTool,
|
||||||
|
} from "./script-generators";
|
||||||
import type { PriceRow } from "./types";
|
import type { PriceRow } from "./types";
|
||||||
|
|
||||||
type CheckoutGeneratorProps = {
|
type CheckoutGeneratorProps = {
|
||||||
|
initialTool?: GeneratorTool;
|
||||||
initialCountry?: string;
|
initialCountry?: string;
|
||||||
initialCurrency?: string;
|
initialCurrency?: string;
|
||||||
countries: PriceRow[];
|
countries: PriceRow[];
|
||||||
onBack: () => void;
|
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 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({
|
export default function CheckoutGenerator({
|
||||||
|
initialTool = "checkout",
|
||||||
initialCountry = DEFAULT_CHECKOUT_COUNTRY,
|
initialCountry = DEFAULT_CHECKOUT_COUNTRY,
|
||||||
initialCurrency = DEFAULT_CHECKOUT_CURRENCY,
|
initialCurrency = DEFAULT_CHECKOUT_CURRENCY,
|
||||||
countries,
|
countries,
|
||||||
onBack,
|
onBack,
|
||||||
|
onToolChange,
|
||||||
}: CheckoutGeneratorProps) {
|
}: CheckoutGeneratorProps) {
|
||||||
|
const [activeTool, setActiveTool] = useState<GeneratorTool>(initialTool);
|
||||||
const [coupon, setCoupon] = useState("");
|
const [coupon, setCoupon] = useState("");
|
||||||
const [country, setCountry] = useState(initialCountry);
|
const [country, setCountry] = useState(initialCountry);
|
||||||
const [currency, setCurrency] = useState(initialCurrency);
|
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 [accessToken, setAccessToken] = useState("");
|
||||||
const [errors, setErrors] = useState<CheckoutValidationErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const couponRef = useRef<HTMLInputElement>(null);
|
const couponRef = useRef<HTMLInputElement>(null);
|
||||||
const countryRef = useRef<HTMLInputElement>(null);
|
const countryRef = useRef<HTMLInputElement>(null);
|
||||||
const currencyRef = 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);
|
const accessTokenRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => setActiveTool(initialTool), [initialTool]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCountry(initialCountry || DEFAULT_CHECKOUT_COUNTRY);
|
setCountry(initialCountry || DEFAULT_CHECKOUT_COUNTRY);
|
||||||
setCurrency(initialCurrency || DEFAULT_CHECKOUT_CURRENCY);
|
setCurrency(initialCurrency || DEFAULT_CHECKOUT_CURRENCY);
|
||||||
@@ -65,52 +122,94 @@ export default function CheckoutGenerator({
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [notice]);
|
}, [notice]);
|
||||||
|
|
||||||
const normalized: CheckoutScriptInput = {
|
const tokenInput = { accessTokenMode, accessToken: accessToken.trim() };
|
||||||
|
const checkoutInput: CheckoutScriptInput = {
|
||||||
coupon: coupon.trim(),
|
coupon: coupon.trim(),
|
||||||
country: country.toUpperCase(),
|
country: country.toUpperCase(),
|
||||||
currency: currency.toUpperCase(),
|
currency: currency.toUpperCase(),
|
||||||
accessTokenMode,
|
existingWorkspaceId: existingWorkspaceId.trim(),
|
||||||
accessToken: accessToken.trim(),
|
autoOpen,
|
||||||
|
...tokenInput,
|
||||||
};
|
};
|
||||||
const previewInput: CheckoutScriptInput = {
|
const codexInput: CodexScriptInput = {
|
||||||
coupon: normalized.coupon || "XXXXXXXXXXXX",
|
workspaceName: workspaceName.trim(),
|
||||||
country: /^[A-Z]{2}$/.test(normalized.country)
|
creditQuantity: Number(creditQuantity),
|
||||||
? normalized.country
|
country: codexCountry,
|
||||||
: DEFAULT_CHECKOUT_COUNTRY,
|
autoOpen,
|
||||||
currency: isSupportedCheckoutCurrency(normalized.currency)
|
...tokenInput,
|
||||||
? normalized.currency
|
|
||||||
: DEFAULT_CHECKOUT_CURRENCY,
|
|
||||||
accessTokenMode,
|
|
||||||
accessToken: accessTokenMode === "manual"
|
|
||||||
? normalized.accessToken || "PASTE_ACCESS_TOKEN_HERE"
|
|
||||||
: "",
|
|
||||||
};
|
};
|
||||||
const source = useMemo(
|
const billingInput: BillingScriptInput = tokenInput;
|
||||||
() => 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 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;
|
if (!errors[field]) return;
|
||||||
setErrors((current) => ({ ...current, [field]: undefined }));
|
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 validateAndFocus = () => {
|
||||||
const nextErrors = validateCheckoutInput(normalized);
|
const nextErrors = currentErrors();
|
||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
const firstError = Object.keys(nextErrors)[0] as CheckoutInputField | undefined;
|
const firstError = Object.keys(nextErrors)[0] as FormField | undefined;
|
||||||
if (firstError === "coupon") couponRef.current?.focus();
|
if (firstError) focusField(firstError);
|
||||||
if (firstError === "country") countryRef.current?.focus();
|
|
||||||
if (firstError === "currency") currencyRef.current?.focus();
|
|
||||||
if (firstError === "accessToken") accessTokenRef.current?.focus();
|
|
||||||
return !firstError;
|
return !firstError;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,7 +220,7 @@ export default function CheckoutGenerator({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.querySelector("#script-result")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
document.querySelector("#script-result")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
setNotice("代码已按当前参数生成");
|
setNotice(`${meta.title}已生成`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyCode = async () => {
|
const copyCode = async () => {
|
||||||
@@ -156,7 +255,7 @@ export default function CheckoutGenerator({
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = "chatgpt-team-checkout.js";
|
link.download = meta.filename;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
setNotice("代码文件已下载");
|
setNotice("代码文件已下载");
|
||||||
@@ -166,163 +265,181 @@ export default function CheckoutGenerator({
|
|||||||
setCoupon("");
|
setCoupon("");
|
||||||
setCountry(DEFAULT_CHECKOUT_COUNTRY);
|
setCountry(DEFAULT_CHECKOUT_COUNTRY);
|
||||||
setCurrency(DEFAULT_CHECKOUT_CURRENCY);
|
setCurrency(DEFAULT_CHECKOUT_CURRENCY);
|
||||||
|
setExistingWorkspaceId("");
|
||||||
|
setWorkspaceName("work");
|
||||||
|
setCreditQuantity("13");
|
||||||
|
setCodexCountry("US");
|
||||||
|
setAutoOpen(false);
|
||||||
setAccessTokenMode(DEFAULT_ACCESS_TOKEN_MODE);
|
setAccessTokenMode(DEFAULT_ACCESS_TOKEN_MODE);
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setNotice("已恢复自动获取与默认值 US / EGP");
|
setNotice("已恢复当前生成器的默认值");
|
||||||
couponRef.current?.focus();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<label className={accessTokenMode === "auto" ? "active" : ""}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="access-token-mode"
|
||||||
|
value="auto"
|
||||||
|
checked={accessTokenMode === "auto"}
|
||||||
|
onChange={() => {
|
||||||
|
setAccessTokenMode("auto");
|
||||||
|
setAccessToken("");
|
||||||
|
clearError("accessToken");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span><strong>自动获取</strong><small>运行时读取登录 Session</small></span>
|
||||||
|
</label>
|
||||||
|
<label className={accessTokenMode === "manual" ? "active" : ""}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="access-token-mode"
|
||||||
|
value="manual"
|
||||||
|
checked={accessTokenMode === "manual"}
|
||||||
|
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>
|
||||||
|
<input
|
||||||
|
ref={accessTokenRef}
|
||||||
|
type="password"
|
||||||
|
value={accessToken}
|
||||||
|
onChange={(event) => { setAccessToken(event.target.value); clearError("accessToken"); }}
|
||||||
|
placeholder="粘贴 accessToken 或完整 Session JSON"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
aria-invalid={Boolean(errors.accessToken)}
|
||||||
|
/>
|
||||||
|
{errors.accessToken
|
||||||
|
? <em role="alert">{errors.accessToken}</em>
|
||||||
|
: <small className="field-note">只提取 accessToken;切换回自动获取时立即清空</small>}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="generator-page">
|
<div className="generator-page">
|
||||||
<section className="generator-hero">
|
<section className="generator-hero">
|
||||||
<div>
|
<div>
|
||||||
<button className="back-link" type="button" onClick={onBack}>
|
<button className="back-link" type="button" onClick={onBack}><ArrowLeft size={15} /> 返回价格雷达</button>
|
||||||
<ArrowLeft size={15} /> 返回价格雷达
|
|
||||||
</button>
|
|
||||||
<span className="eyebrow"><Code2 size={15} /> BUSINESS TOOL · GENERATOR</span>
|
<span className="eyebrow"><Code2 size={15} /> BUSINESS TOOL · GENERATOR</span>
|
||||||
<h1>参数填好,<br /><em>脚本即刻就绪。</em></h1>
|
<h1>选择工具,<br /><em>脚本即刻就绪。</em></h1>
|
||||||
<p>选择自动获取或手动提供 Access Token,再输入优惠码、国家和货币,生成可复制的 ChatGPT Team 结账脚本。</p>
|
<p>生成 Team 优惠、Codex 按量和账单查询脚本。网站只生成文本,不会代替你请求账户或支付接口。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="generator-promise">
|
<div className="generator-promise">
|
||||||
<ShieldCheck size={22} />
|
<ShieldCheck size={22} />
|
||||||
<div><strong>本地生成</strong><span>不上传、不保存优惠码或 Access Token,也不会在本站执行脚本。</span></div>
|
<div><strong>本地生成</strong><span>不上传、不保存优惠码、空间信息或 Access Token。</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="generator-workspace" aria-labelledby="generator-title">
|
||||||
<div className="generator-form-panel">
|
<div className="generator-form-panel">
|
||||||
<div className="generator-section-head">
|
<div className="generator-section-head">
|
||||||
<div><span className="section-kicker">INPUT PARAMETERS</span><h2 id="generator-title">脚本参数</h2></div>
|
<div><span className="section-kicker">INPUT PARAMETERS</span><h2 id="generator-title">{meta.title}</h2></div>
|
||||||
<span className="live-preview"><span /> 实时预览</span>
|
<span className="live-preview"><span /> 实时预览</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="tool-description">{meta.description}</p>
|
||||||
|
|
||||||
<form onSubmit={handleGenerate} noValidate>
|
<form onSubmit={handleGenerate} noValidate>
|
||||||
<label className={`generator-field ${errors.coupon ? "has-error" : ""}`}>
|
{activeTool === "checkout" ? (
|
||||||
<span><strong>优惠码</strong><small>必填</small></span>
|
<>
|
||||||
<input
|
<label className={`generator-field ${errors.coupon ? "has-error" : ""}`}>
|
||||||
ref={couponRef}
|
<span><strong>优惠码</strong><small>必填</small></span>
|
||||||
value={coupon}
|
<input ref={couponRef} value={coupon} onChange={(event) => { setCoupon(event.target.value); clearError("coupon"); }} placeholder="例如:XXXXXXXXXXXX" autoComplete="off" spellCheck={false} aria-invalid={Boolean(errors.coupon)} />
|
||||||
onChange={(event) => { setCoupon(event.target.value); clearError("coupon"); }}
|
{errors.coupon ? <em role="alert">{errors.coupon}</em> : null}
|
||||||
placeholder="例如:XXXXXXXXXXXX"
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
aria-invalid={Boolean(errors.coupon)}
|
|
||||||
/>
|
|
||||||
{errors.coupon ? <em role="alert">{errors.coupon}</em> : null}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<fieldset className="token-source-field">
|
|
||||||
<legend>Access Token 来源</legend>
|
|
||||||
<div className="token-source-options">
|
|
||||||
<label className={accessTokenMode === "auto" ? "active" : ""}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="access-token-mode"
|
|
||||||
value="auto"
|
|
||||||
checked={accessTokenMode === "auto"}
|
|
||||||
onChange={() => {
|
|
||||||
setAccessTokenMode("auto");
|
|
||||||
setAccessToken("");
|
|
||||||
clearError("accessToken");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span><strong>自动获取</strong><small>运行时读取登录 Session</small></span>
|
|
||||||
</label>
|
</label>
|
||||||
<label className={accessTokenMode === "manual" ? "active" : ""}>
|
<label className={`generator-field ${errors.existingWorkspaceId ? "has-error" : ""}`}>
|
||||||
<input
|
<span><strong>已有 Codex 空间 ID</strong><small>可选</small></span>
|
||||||
type="radio"
|
<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)} />
|
||||||
name="access-token-mode"
|
{errors.existingWorkspaceId ? <em role="alert">{errors.existingWorkspaceId}</em> : <small className="field-note">适用于已有 0.52 Codex 空间</small>}
|
||||||
value="manual"
|
|
||||||
checked={accessTokenMode === "manual"}
|
|
||||||
onChange={() => {
|
|
||||||
setAccessTokenMode("manual");
|
|
||||||
clearError("accessToken");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span><strong>手动粘贴</strong><small>支持 Token 或 Session JSON</small></span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<div className="generator-field-grid">
|
||||||
</fieldset>
|
<label className={`generator-field ${errors.country ? "has-error" : ""}`}>
|
||||||
|
<span><strong>国家 ISO 缩写</strong><small>2 位字母</small></span>
|
||||||
{accessTokenMode === "manual" ? (
|
<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>
|
||||||
<label className={`generator-field token-value-field ${errors.accessToken ? "has-error" : ""}`}>
|
<datalist id="checkout-country-list">{countries.map((row) => <option key={row.countryCode} value={row.countryCode}>{row.countryName}</option>)}</datalist>
|
||||||
<span><strong>Access Token / Session JSON</strong><small>仅用于当前生成结果</small></span>
|
{errors.country ? <em role="alert">{errors.country}</em> : <small className="field-note">例如 CN、US、KE</small>}
|
||||||
<input
|
</label>
|
||||||
ref={accessTokenRef}
|
<label className={`generator-field ${errors.currency ? "has-error" : ""}`}>
|
||||||
type="password"
|
<span><strong>货币 ISO 缩写</strong><small>39 种可选</small></span>
|
||||||
value={accessToken}
|
<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>
|
||||||
onChange={(event) => { setAccessToken(event.target.value); clearError("accessToken"); }}
|
<datalist id="checkout-currency-list">{CHECKOUT_CURRENCIES.map(([code, name]) => <option key={code} value={code}>{name}</option>)}</datalist>
|
||||||
placeholder="粘贴 accessToken 或完整 Session JSON"
|
{errors.currency ? <em role="alert">{errors.currency}</em> : <small className="field-note">{currencyName ? `${checkoutInput.currency} · ${currencyName}` : "输入或选择货币代码"}</small>}
|
||||||
autoComplete="off"
|
</label>
|
||||||
spellCheck={false}
|
</div>
|
||||||
aria-invalid={Boolean(errors.accessToken)}
|
<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>
|
||||||
/>
|
</>
|
||||||
{errors.accessToken ? <em role="alert">{errors.accessToken}</em> : <small className="field-note">Session JSON 会自动提取 accessToken;切换回自动获取时立即清空</small>}
|
|
||||||
</label>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="generator-field-grid">
|
{activeTool === "codex" ? (
|
||||||
<label className={`generator-field ${errors.country ? "has-error" : ""}`}>
|
<>
|
||||||
<span><strong>国家 ISO 缩写</strong><small>2 位字母</small></span>
|
<label className={`generator-field ${errors.workspaceName ? "has-error" : ""}`}>
|
||||||
<div className="iso-input"><b>ISO</b><input
|
<span><strong>空间名称</strong><small>必填</small></span>
|
||||||
ref={countryRef}
|
<input ref={workspaceNameRef} value={workspaceName} onChange={(event) => { setWorkspaceName(event.target.value); clearError("workspaceName"); }} placeholder="填写空间名称" autoComplete="off" aria-invalid={Boolean(errors.workspaceName)} />
|
||||||
value={country}
|
{errors.workspaceName ? <em role="alert">{errors.workspaceName}</em> : null}
|
||||||
onChange={(event) => { setCountry(normalizeIsoInput(event.target.value, 2)); clearError("country"); }}
|
</label>
|
||||||
maxLength={2}
|
<div className="generator-field-grid">
|
||||||
list="checkout-country-list"
|
<label className={`generator-field ${errors.creditQuantity ? "has-error" : ""}`}>
|
||||||
autoComplete="country"
|
<span><strong>Credit 数量</strong><small>大于 0 的整数</small></span>
|
||||||
spellCheck={false}
|
<input ref={creditQuantityRef} type="number" min="1" step="1" value={creditQuantity} onChange={(event) => { setCreditQuantity(event.target.value); clearError("creditQuantity"); }} aria-invalid={Boolean(errors.creditQuantity)} />
|
||||||
aria-invalid={Boolean(errors.country)}
|
{errors.creditQuantity ? <em role="alert">{errors.creditQuantity}</em> : <small className="field-note">默认 13 Credit</small>}
|
||||||
/></div>
|
</label>
|
||||||
<datalist id="checkout-country-list">
|
<label className={`generator-field ${errors.country ? "has-error" : ""}`}>
|
||||||
{countries.map((row) => <option key={row.countryCode} value={row.countryCode}>{row.countryName}</option>)}
|
<span><strong>国家或地区</strong><small>自动匹配货币</small></span>
|
||||||
</datalist>
|
<select value={codexCountry} onChange={(event) => { setCodexCountry(event.target.value); clearError("country"); }} aria-invalid={Boolean(errors.country)}>
|
||||||
{errors.country ? <em role="alert">{errors.country}</em> : <small className="field-note">例如 CN、US、KE</small>}
|
{CODEX_COUNTRIES.map(([code, name, mappedCurrency]) => <option key={code} value={code}>{name} ({mappedCurrency})</option>)}
|
||||||
</label>
|
</select>
|
||||||
|
<small className="field-note">{selectedCodexCountry[0]} · {selectedCodexCountry[2]}</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<label className={`generator-field ${errors.currency ? "has-error" : ""}`}>
|
{activeTool === "billing" ? (
|
||||||
<span><strong>货币 ISO 缩写</strong><small>39 种可选</small></span>
|
<div className="billing-scope-note"><ReceiptText size={18} /><div><strong>查询内容</strong><span>账户 ID、套餐、最近 10 条发票、支付方式和账单资料;结果保存到 <code>window.__billingResult</code>。</span></div></div>
|
||||||
<div className="iso-input"><b>ISO</b><input
|
) : null}
|
||||||
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>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="quick-currency-row" aria-label="常用货币快捷选择">
|
{renderTokenFields()}
|
||||||
<span>快捷选择</span>
|
|
||||||
{commonCurrencies.map((code) => (
|
|
||||||
<button
|
|
||||||
key={code}
|
|
||||||
type="button"
|
|
||||||
className={normalized.currency === code ? "active" : ""}
|
|
||||||
onClick={() => { setCurrency(code); clearError("currency"); }}
|
|
||||||
>{code}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="generate-button" type="submit"><Sparkles size={17} /> 生成完整代码 <span>→</span></button>
|
{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>
|
</form>
|
||||||
|
|
||||||
<div className="generator-steps">
|
<div className="generator-steps">
|
||||||
<span>如何使用</span>
|
<span>安全提示</span>
|
||||||
<ol>
|
{activeTool === "billing" ? (
|
||||||
<li>选择自动获取,或手动粘贴 Access Token。</li>
|
<p>查询结果包含发票、支付方式和账单资料。请仅在自己的账户中运行,不要分享控制台输出。</p>
|
||||||
<li>登录 ChatGPT 并打开浏览器控制台。</li>
|
) : (
|
||||||
<li>复制生成的脚本,粘贴后执行。</li>
|
<p>请使用 Personal/Free 个人账户 Token,不要使用 Business/Codex 空间 Token,并在支付页核对最终金额与目标空间。</p>
|
||||||
<li>根据控制台输出查看 Stripe 长链接。</li>
|
)}
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -331,13 +448,11 @@ export default function CheckoutGenerator({
|
|||||||
<div><span className="section-kicker">OUTPUT</span><h2>JavaScript</h2></div>
|
<div><span className="section-kicker">OUTPUT</span><h2>JavaScript</h2></div>
|
||||||
<div className="code-actions">
|
<div className="code-actions">
|
||||||
<button type="button" onClick={downloadCode} aria-disabled={!isInputValid}><Download size={14} /> 下载</button>
|
<button type="button" onClick={downloadCode} aria-disabled={!isInputValid}><Download size={14} /> 下载</button>
|
||||||
<button className="copy-code" type="button" onClick={copyCode} aria-disabled={!isInputValid}>
|
<button className="copy-code" type="button" onClick={copyCode} aria-disabled={!isInputValid}>{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? "已复制" : "复制代码"}</button>
|
||||||
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? "已复制" : "复制代码"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="code-preview">
|
<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>
|
<pre><code>{source}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<div className="result-meta">
|
<div className="result-meta">
|
||||||
@@ -347,11 +462,7 @@ export default function CheckoutGenerator({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="generator-disclaimer">
|
<section className="generator-disclaimer"><Info size={18} /><p><strong>请仅在你有权操作的账号中使用。</strong> 第三方接口、促销资格和结账规则可能调整,实际结果以 ChatGPT 页面为准。</p></section>
|
||||||
<Info size={18} />
|
|
||||||
<p><strong>请仅在你有权操作的账号中使用。</strong> 第三方接口、促销资格和结账规则可能调整,实际结果以 ChatGPT 结账页面为准。</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{notice ? <div className="generator-toast" role="status">{notice}</div> : null}
|
{notice ? <div className="generator-toast" role="status">{notice}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ describe("checkout script generator", () => {
|
|||||||
coupon: "",
|
coupon: "",
|
||||||
country: "USA",
|
country: "USA",
|
||||||
currency: "TRY",
|
currency: "TRY",
|
||||||
|
existingWorkspaceId: "",
|
||||||
|
autoOpen: false,
|
||||||
accessTokenMode: "auto",
|
accessTokenMode: "auto",
|
||||||
accessToken: "",
|
accessToken: "",
|
||||||
}))
|
}))
|
||||||
@@ -40,6 +42,8 @@ describe("checkout script generator", () => {
|
|||||||
coupon: 'SAVE "20" & 更多',
|
coupon: 'SAVE "20" & 更多',
|
||||||
country: "SG",
|
country: "SG",
|
||||||
currency: "SGD",
|
currency: "SGD",
|
||||||
|
existingWorkspaceId: "",
|
||||||
|
autoOpen: false,
|
||||||
accessTokenMode: "auto",
|
accessTokenMode: "auto",
|
||||||
accessToken: "",
|
accessToken: "",
|
||||||
});
|
});
|
||||||
@@ -72,6 +76,8 @@ describe("checkout script generator", () => {
|
|||||||
coupon: "SAVE20",
|
coupon: "SAVE20",
|
||||||
country: "US",
|
country: "US",
|
||||||
currency: "EGP",
|
currency: "EGP",
|
||||||
|
existingWorkspaceId: "",
|
||||||
|
autoOpen: false,
|
||||||
accessTokenMode: "manual",
|
accessTokenMode: "manual",
|
||||||
accessToken: sessionJson,
|
accessToken: sessionJson,
|
||||||
});
|
});
|
||||||
@@ -87,8 +93,27 @@ describe("checkout script generator", () => {
|
|||||||
coupon: "SAVE20",
|
coupon: "SAVE20",
|
||||||
country: "US",
|
country: "US",
|
||||||
currency: "EGP",
|
currency: "EGP",
|
||||||
|
existingWorkspaceId: "",
|
||||||
|
autoOpen: false,
|
||||||
accessTokenMode: "manual",
|
accessTokenMode: "manual",
|
||||||
accessToken: '{"user":true}',
|
accessToken: '{"user":true}',
|
||||||
})).toEqual({ accessToken: "未能从输入内容中提取 accessToken" });
|
})).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,11 +52,13 @@ export type CheckoutScriptInput = {
|
|||||||
coupon: string;
|
coupon: string;
|
||||||
country: string;
|
country: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
existingWorkspaceId: string;
|
||||||
|
autoOpen: boolean;
|
||||||
accessTokenMode: AccessTokenMode;
|
accessTokenMode: AccessTokenMode;
|
||||||
accessToken: string;
|
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 type CheckoutValidationErrors = Partial<Record<CheckoutInputField, string>>;
|
||||||
|
|
||||||
export function normalizeIsoInput(value: string, maxLength: number): string {
|
export function normalizeIsoInput(value: string, maxLength: number): string {
|
||||||
@@ -85,12 +87,12 @@ export function extractAccessToken(value: string): string | null {
|
|||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"')) {
|
if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"')) {
|
||||||
return trimmed;
|
return trimmed.replace(/^Bearer\s+/i, "").replace(/\s+/g, "") || null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(trimmed);
|
const parsed: unknown = JSON.parse(trimmed);
|
||||||
if (typeof parsed === "string") return parsed.trim() || null;
|
const token = typeof parsed === "string" ? parsed : findAccessToken(parsed);
|
||||||
return findAccessToken(parsed);
|
return token?.replace(/^Bearer\s+/i, "").replace(/\s+/g, "") || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -101,6 +103,9 @@ export function validateCheckoutInput(input: CheckoutScriptInput): CheckoutValid
|
|||||||
if (!input.coupon.trim()) errors.coupon = "请输入优惠码";
|
if (!input.coupon.trim()) errors.coupon = "请输入优惠码";
|
||||||
if (!/^[A-Z]{2}$/.test(input.country)) errors.country = "请输入 2 位英文字母国家代码";
|
if (!/^[A-Z]{2}$/.test(input.country)) errors.country = "请输入 2 位英文字母国家代码";
|
||||||
if (!isSupportedCheckoutCurrency(input.currency)) errors.currency = "请选择支持的货币代码";
|
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()) {
|
if (input.accessTokenMode === "manual" && !input.accessToken.trim()) {
|
||||||
errors.accessToken = "请输入 Access Token 或 Session JSON";
|
errors.accessToken = "请输入 Access Token 或 Session JSON";
|
||||||
} else if (input.accessTokenMode === "manual" && !extractAccessToken(input.accessToken)) {
|
} else if (input.accessTokenMode === "manual" && !extractAccessToken(input.accessToken)) {
|
||||||
@@ -113,6 +118,7 @@ export function generateCheckoutScript(input: CheckoutScriptInput): string {
|
|||||||
const coupon = input.coupon || "XXXXXXXXXXXX";
|
const coupon = input.coupon || "XXXXXXXXXXXX";
|
||||||
const country = input.country || DEFAULT_CHECKOUT_COUNTRY;
|
const country = input.country || DEFAULT_CHECKOUT_COUNTRY;
|
||||||
const currency = input.currency || DEFAULT_CHECKOUT_CURRENCY;
|
const currency = input.currency || DEFAULT_CHECKOUT_CURRENCY;
|
||||||
|
const existingWorkspaceId = input.existingWorkspaceId.trim();
|
||||||
const encodedCoupon = encodeURIComponent(coupon);
|
const encodedCoupon = encodeURIComponent(coupon);
|
||||||
const tokenStatusMessage = input.accessTokenMode === "manual"
|
const tokenStatusMessage = input.accessTokenMode === "manual"
|
||||||
? "⏳ 正在使用手动 Access Token..."
|
? "⏳ 正在使用手动 Access Token..."
|
||||||
@@ -129,9 +135,15 @@ export function generateCheckoutScript(input: CheckoutScriptInput): string {
|
|||||||
: ` // 1. 自动获取登录凭证
|
: ` // 1. 自动获取登录凭证
|
||||||
let accessToken;
|
let accessToken;
|
||||||
try {
|
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;
|
accessToken = s?.accessToken;
|
||||||
if (!accessToken) throw new Error("accessToken 为空,请确认已登录 ChatGPT 账号");
|
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) {
|
} catch (e) {
|
||||||
console.error("❌ 获取 Token 失败:", e.message);
|
console.error("❌ 获取 Token 失败:", e.message);
|
||||||
return;
|
return;
|
||||||
@@ -142,9 +154,15 @@ export function generateCheckoutScript(input: CheckoutScriptInput): string {
|
|||||||
// ================= 配置项 =================
|
// ================= 配置项 =================
|
||||||
const WORKSPACE_NAME = "xxx";
|
const WORKSPACE_NAME = "xxx";
|
||||||
const COUPON = ${JSON.stringify(coupon)}; // 优惠码
|
const COUPON = ${JSON.stringify(coupon)}; // 优惠码
|
||||||
|
const EXISTING_WORKSPACE_ID = ${JSON.stringify(existingWorkspaceId)}; // 留空则新建空间
|
||||||
const SEAT_QUANTITY = 2; // 席位数量(Team 最少 2 个)
|
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)});
|
console.log(${JSON.stringify(tokenStatusMessage)});
|
||||||
|
|
||||||
${tokenSource}
|
${tokenSource}
|
||||||
@@ -155,7 +173,8 @@ ${tokenSource}
|
|||||||
team_plan_data: {
|
team_plan_data: {
|
||||||
workspace_name: WORKSPACE_NAME,
|
workspace_name: WORKSPACE_NAME,
|
||||||
price_interval: "month", // month 或 year
|
price_interval: "month", // month 或 year
|
||||||
seat_quantity: SEAT_QUANTITY
|
seat_quantity: SEAT_QUANTITY,
|
||||||
|
...(EXISTING_WORKSPACE_ID ? { existing_workspace_id: EXISTING_WORKSPACE_ID } : {})
|
||||||
},
|
},
|
||||||
billing_details: {
|
billing_details: {
|
||||||
country: ${JSON.stringify(country)},
|
country: ${JSON.stringify(country)},
|
||||||
@@ -170,13 +189,14 @@ ${tokenSource}
|
|||||||
console.log("⏳ 正在请求 Stripe 支付长链接...");
|
console.log("⏳ 正在请求 Stripe 支付长链接...");
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
"https://chatgpt.com/backend-api/payments/checkout",
|
"/backend-api/payments/checkout",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: \`Bearer \${accessToken}\`,
|
Authorization: \`Bearer \${accessToken}\`,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -193,6 +213,10 @@ ${tokenSource}
|
|||||||
console.warn("⚠️ 未找到长链接,原始响应:", data);
|
console.warn("⚠️ 未找到长链接,原始响应:", data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const checkoutUrl = new URL(hostedUrl);
|
||||||
|
if (checkoutUrl.protocol !== "https:") {
|
||||||
|
throw new Error("服务端返回了无效的支付链接");
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 打印结果
|
// 4. 打印结果
|
||||||
console.log("─".repeat(60));
|
console.log("─".repeat(60));
|
||||||
@@ -206,8 +230,11 @@ ${tokenSource}
|
|||||||
}
|
}
|
||||||
console.log("─".repeat(60));
|
console.log("─".repeat(60));
|
||||||
console.log("🔗 Stripe 支付长链接(复制到浏览器打开):");
|
console.log("🔗 Stripe 支付长链接(复制到浏览器打开):");
|
||||||
console.log(hostedUrl);
|
console.log(checkoutUrl.href);
|
||||||
console.log("─".repeat(60));
|
console.log("─".repeat(60));
|
||||||
|
if (AUTO_OPEN_CHECKOUT) {
|
||||||
|
window.open(checkoutUrl.href, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("❌ 网络异常或请求失败:", e.message);
|
console.error("❌ 网络异常或请求失败:", e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -153,12 +153,20 @@ tbody tr:hover { background: #f9fbf8; }
|
|||||||
.generator-promise div { display: flex; flex-direction: column; gap: 6px; }
|
.generator-promise div { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.generator-promise strong { color: var(--ink); font-size: 13px; }
|
.generator-promise strong { color: var(--ink); font-size: 13px; }
|
||||||
.generator-promise span { color: var(--muted); font-size: 11px; line-height: 1.65; }
|
.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-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, .generator-result-panel { min-width: 0; padding: 34px; }
|
||||||
.generator-form-panel { border-right: 1px solid var(--line); }
|
.generator-form-panel { border-right: 1px solid var(--line); }
|
||||||
.generator-result-panel { background: #f0f3ef; }
|
.generator-result-panel { background: #f0f3ef; }
|
||||||
.generator-section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
.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; }
|
.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 { 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); }
|
.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; }
|
.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 { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||||
.generator-field > span strong { font-size: 12px; }
|
.generator-field > span strong { font-size: 12px; }
|
||||||
.generator-field > span small { color: #8a9490; font-size: 9px; font-weight: 700; text-transform: uppercase; }
|
.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, .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 { padding: 0 13px; color: var(--ink); outline: 0; }
|
.generator-field > input, .generator-field > select { 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 > select { cursor: pointer; }
|
||||||
.generator-field.has-error > input, .generator-field.has-error .iso-input { border-color: #bd5b4f; }
|
.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; }
|
.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 { margin: 0 0 18px; padding: 0; border: 0; }
|
||||||
.token-source-field legend { margin-bottom: 8px; font-size: 12px; font-weight: 750; }
|
.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 { 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 > 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 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; }
|
.result-head { align-items: center; }
|
||||||
.code-actions { display: flex; align-items: center; gap: 7px; }
|
.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; }
|
.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 h1 { font-size: clamp(43px, 13vw, 62px); }
|
||||||
.generator-hero > div:first-child > p { font-size: 13px; }
|
.generator-hero > div:first-child > p { font-size: 13px; }
|
||||||
.generator-workspace { grid-template-columns: 1fr; }
|
.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-form-panel { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
.generator-field-grid { grid-template-columns: 1fr 1fr; gap: 12px; }
|
.generator-field-grid { grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
.code-preview pre { max-height: 480px; }
|
.code-preview pre { max-height: 480px; }
|
||||||
@@ -316,6 +336,7 @@ footer > span:last-child { text-align: right; }
|
|||||||
.search-control, .sort-control { grid-column: auto; }
|
.search-control, .sort-control { grid-column: auto; }
|
||||||
.section-heading p { display: none; }
|
.section-heading p { display: none; }
|
||||||
.generator-form-panel, .generator-result-panel { padding: 22px 16px; }
|
.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; }
|
.generator-field-grid { grid-template-columns: 1fr; gap: 0; }
|
||||||
.token-source-options { grid-template-columns: 1fr; }
|
.token-source-options { grid-template-columns: 1fr; }
|
||||||
.result-head { align-items: flex-end; }
|
.result-head { align-items: flex-end; }
|
||||||
|
|||||||
Reference in New Issue
Block a user