up
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
- 支持搜索、筛选、排序以及桌面表格和移动端卡片。
|
- 支持搜索、筛选、排序以及桌面表格和移动端卡片。
|
||||||
- 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。
|
- 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。
|
||||||
- 脚本生成器支持 39 种货币、实时预览、复制和 `.js` 下载。
|
- 脚本生成器支持 39 种货币、实时预览、复制和 `.js` 下载。
|
||||||
- 优惠码仅在当前浏览器内处理,不写入网址或本地存储。
|
- 优惠码和手动提供的 Token 仅在当前浏览器内处理,不写入网址或本地存储。
|
||||||
- GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。
|
- GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。
|
||||||
- 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。
|
- 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。
|
||||||
|
|
||||||
@@ -59,7 +59,9 @@ npm run build
|
|||||||
|
|
||||||
顶部导航可切换到脚本生成器,也可以从价格表或移动端价格卡片直接进入。地区入口会通过 `view=generator&country=XX¤cy=XXX` 预填国家和货币,优惠码不会出现在网址中。
|
顶部导航可切换到脚本生成器,也可以从价格表或移动端价格卡片直接进入。地区入口会通过 `view=generator&country=XX¤cy=XXX` 预填国家和货币,优惠码不会出现在网址中。
|
||||||
|
|
||||||
生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`。它只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。
|
生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`。Access Token 可以在脚本运行时从登录 Session 自动获取,也可以手动粘贴原始 `accessToken` 或 `/api/auth/session` 返回的完整 JSON;完整 JSON 只会提取其中的 `accessToken` 写入脚本。切回自动获取时,手动输入会立即清空。
|
||||||
|
|
||||||
|
生成器只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。
|
||||||
|
|
||||||
### 发布保护
|
### 发布保护
|
||||||
|
|
||||||
|
|||||||
+35
-1
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import sample from "../public/data/sample-prices.json";
|
import sample from "../public/data/sample-prices.json";
|
||||||
@@ -134,4 +134,38 @@ describe("pricing explorer", () => {
|
|||||||
expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("US");
|
expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("US");
|
||||||
expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("EGP");
|
expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("EGP");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts complete session JSON in manual token mode without storing it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText },
|
||||||
|
});
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: /脚本生成器/ }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("radio", { name: /自动获取/ })).toBeChecked();
|
||||||
|
await user.click(screen.getByRole("radio", { name: /手动粘贴/ }));
|
||||||
|
const tokenInput = screen.getByPlaceholderText("粘贴 accessToken 或完整 Session JSON");
|
||||||
|
const sessionJson = JSON.stringify({
|
||||||
|
user: { email: "private@example.com" },
|
||||||
|
accessToken: "session-access-token",
|
||||||
|
});
|
||||||
|
fireEvent.change(tokenInput, { target: { value: sessionJson } });
|
||||||
|
await user.type(screen.getByPlaceholderText("例如:XXXXXXXXXXXX"), "SAVE20");
|
||||||
|
await user.click(screen.getByRole("button", { name: /复制代码/ }));
|
||||||
|
|
||||||
|
const copiedScript = writeText.mock.calls[0][0] as string;
|
||||||
|
expect(copiedScript).toContain('const accessToken = "session-access-token"');
|
||||||
|
expect(copiedScript).not.toContain("private@example.com");
|
||||||
|
expect(copiedScript).not.toContain("/api/auth/session");
|
||||||
|
expect(window.location.search).not.toContain("session-access-token");
|
||||||
|
expect(Object.values(window.localStorage)).not.toContain("session-access-token");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("radio", { name: /自动获取/ }));
|
||||||
|
expect(screen.queryByPlaceholderText("粘贴 accessToken 或完整 Session JSON")).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("radio", { name: /手动粘贴/ }));
|
||||||
|
expect(screen.getByPlaceholderText("粘贴 accessToken 或完整 Session JSON")).toHaveValue("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CHECKOUT_CURRENCIES,
|
CHECKOUT_CURRENCIES,
|
||||||
|
DEFAULT_ACCESS_TOKEN_MODE,
|
||||||
DEFAULT_CHECKOUT_COUNTRY,
|
DEFAULT_CHECKOUT_COUNTRY,
|
||||||
DEFAULT_CHECKOUT_CURRENCY,
|
DEFAULT_CHECKOUT_CURRENCY,
|
||||||
generateCheckoutScript,
|
generateCheckoutScript,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
normalizeIsoInput,
|
normalizeIsoInput,
|
||||||
validateCheckoutInput,
|
validateCheckoutInput,
|
||||||
type CheckoutScriptInput,
|
type CheckoutScriptInput,
|
||||||
|
type CheckoutInputField,
|
||||||
type CheckoutValidationErrors,
|
type CheckoutValidationErrors,
|
||||||
} from "./checkout-generator";
|
} from "./checkout-generator";
|
||||||
import type { PriceRow } from "./types";
|
import type { PriceRow } from "./types";
|
||||||
@@ -41,12 +43,15 @@ export default function CheckoutGenerator({
|
|||||||
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 [accessToken, setAccessToken] = useState("");
|
||||||
const [errors, setErrors] = useState<CheckoutValidationErrors>({});
|
const [errors, setErrors] = useState<CheckoutValidationErrors>({});
|
||||||
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 accessTokenRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCountry(initialCountry || DEFAULT_CHECKOUT_COUNTRY);
|
setCountry(initialCountry || DEFAULT_CHECKOUT_COUNTRY);
|
||||||
@@ -64,6 +69,8 @@ export default function CheckoutGenerator({
|
|||||||
coupon: coupon.trim(),
|
coupon: coupon.trim(),
|
||||||
country: country.toUpperCase(),
|
country: country.toUpperCase(),
|
||||||
currency: currency.toUpperCase(),
|
currency: currency.toUpperCase(),
|
||||||
|
accessTokenMode,
|
||||||
|
accessToken: accessToken.trim(),
|
||||||
};
|
};
|
||||||
const previewInput: CheckoutScriptInput = {
|
const previewInput: CheckoutScriptInput = {
|
||||||
coupon: normalized.coupon || "XXXXXXXXXXXX",
|
coupon: normalized.coupon || "XXXXXXXXXXXX",
|
||||||
@@ -73,15 +80,25 @@ export default function CheckoutGenerator({
|
|||||||
currency: isSupportedCheckoutCurrency(normalized.currency)
|
currency: isSupportedCheckoutCurrency(normalized.currency)
|
||||||
? normalized.currency
|
? normalized.currency
|
||||||
: DEFAULT_CHECKOUT_CURRENCY,
|
: DEFAULT_CHECKOUT_CURRENCY,
|
||||||
|
accessTokenMode,
|
||||||
|
accessToken: accessTokenMode === "manual"
|
||||||
|
? normalized.accessToken || "PASTE_ACCESS_TOKEN_HERE"
|
||||||
|
: "",
|
||||||
};
|
};
|
||||||
const source = useMemo(
|
const source = useMemo(
|
||||||
() => generateCheckoutScript(previewInput),
|
() => generateCheckoutScript(previewInput),
|
||||||
[previewInput.coupon, previewInput.country, previewInput.currency],
|
[
|
||||||
|
previewInput.coupon,
|
||||||
|
previewInput.country,
|
||||||
|
previewInput.currency,
|
||||||
|
previewInput.accessTokenMode,
|
||||||
|
previewInput.accessToken,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const isInputValid = Object.keys(validateCheckoutInput(normalized)).length === 0;
|
const isInputValid = Object.keys(validateCheckoutInput(normalized)).length === 0;
|
||||||
const currencyName = CHECKOUT_CURRENCIES.find(([code]) => code === normalized.currency)?.[1];
|
const currencyName = CHECKOUT_CURRENCIES.find(([code]) => code === normalized.currency)?.[1];
|
||||||
|
|
||||||
const clearError = (field: keyof CheckoutScriptInput) => {
|
const clearError = (field: CheckoutInputField) => {
|
||||||
if (!errors[field]) return;
|
if (!errors[field]) return;
|
||||||
setErrors((current) => ({ ...current, [field]: undefined }));
|
setErrors((current) => ({ ...current, [field]: undefined }));
|
||||||
};
|
};
|
||||||
@@ -89,10 +106,11 @@ export default function CheckoutGenerator({
|
|||||||
const validateAndFocus = () => {
|
const validateAndFocus = () => {
|
||||||
const nextErrors = validateCheckoutInput(normalized);
|
const nextErrors = validateCheckoutInput(normalized);
|
||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
const firstError = Object.keys(nextErrors)[0] as keyof CheckoutScriptInput | undefined;
|
const firstError = Object.keys(nextErrors)[0] as CheckoutInputField | undefined;
|
||||||
if (firstError === "coupon") couponRef.current?.focus();
|
if (firstError === "coupon") couponRef.current?.focus();
|
||||||
if (firstError === "country") countryRef.current?.focus();
|
if (firstError === "country") countryRef.current?.focus();
|
||||||
if (firstError === "currency") currencyRef.current?.focus();
|
if (firstError === "currency") currencyRef.current?.focus();
|
||||||
|
if (firstError === "accessToken") accessTokenRef.current?.focus();
|
||||||
return !firstError;
|
return !firstError;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,8 +166,10 @@ export default function CheckoutGenerator({
|
|||||||
setCoupon("");
|
setCoupon("");
|
||||||
setCountry(DEFAULT_CHECKOUT_COUNTRY);
|
setCountry(DEFAULT_CHECKOUT_COUNTRY);
|
||||||
setCurrency(DEFAULT_CHECKOUT_CURRENCY);
|
setCurrency(DEFAULT_CHECKOUT_CURRENCY);
|
||||||
|
setAccessTokenMode(DEFAULT_ACCESS_TOKEN_MODE);
|
||||||
|
setAccessToken("");
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setNotice("已恢复默认值 US / EGP");
|
setNotice("已恢复自动获取与默认值 US / EGP");
|
||||||
couponRef.current?.focus();
|
couponRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,11 +182,11 @@ export default function CheckoutGenerator({
|
|||||||
</button>
|
</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>输入优惠码、国家和货币,生成可复制的 ChatGPT Team 结账脚本。全部处理都在当前浏览器中完成。</p>
|
<p>选择自动获取或手动提供 Access Token,再输入优惠码、国家和货币,生成可复制的 ChatGPT Team 结账脚本。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="generator-promise">
|
<div className="generator-promise">
|
||||||
<ShieldCheck size={22} />
|
<ShieldCheck size={22} />
|
||||||
<div><strong>本地生成</strong><span>不上传、不保存优惠码,也不会在本站执行脚本。</span></div>
|
<div><strong>本地生成</strong><span>不上传、不保存优惠码或 Access Token,也不会在本站执行脚本。</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -192,6 +212,56 @@ export default function CheckoutGenerator({
|
|||||||
{errors.coupon ? <em role="alert">{errors.coupon}</em> : null}
|
{errors.coupon ? <em role="alert">{errors.coupon}</em> : null}
|
||||||
</label>
|
</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 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">Session JSON 会自动提取 accessToken;切换回自动获取时立即清空</small>}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="generator-field-grid">
|
<div className="generator-field-grid">
|
||||||
<label className={`generator-field ${errors.country ? "has-error" : ""}`}>
|
<label className={`generator-field ${errors.country ? "has-error" : ""}`}>
|
||||||
<span><strong>国家 ISO 缩写</strong><small>2 位字母</small></span>
|
<span><strong>国家 ISO 缩写</strong><small>2 位字母</small></span>
|
||||||
@@ -248,6 +318,7 @@ export default function CheckoutGenerator({
|
|||||||
<div className="generator-steps">
|
<div className="generator-steps">
|
||||||
<span>如何使用</span>
|
<span>如何使用</span>
|
||||||
<ol>
|
<ol>
|
||||||
|
<li>选择自动获取,或手动粘贴 Access Token。</li>
|
||||||
<li>登录 ChatGPT 并打开浏览器控制台。</li>
|
<li>登录 ChatGPT 并打开浏览器控制台。</li>
|
||||||
<li>复制生成的脚本,粘贴后执行。</li>
|
<li>复制生成的脚本,粘贴后执行。</li>
|
||||||
<li>根据控制台输出查看 Stripe 长链接。</li>
|
<li>根据控制台输出查看 Stripe 长链接。</li>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CHECKOUT_CURRENCIES,
|
CHECKOUT_CURRENCIES,
|
||||||
DEFAULT_CHECKOUT_COUNTRY,
|
DEFAULT_CHECKOUT_COUNTRY,
|
||||||
DEFAULT_CHECKOUT_CURRENCY,
|
DEFAULT_CHECKOUT_CURRENCY,
|
||||||
|
extractAccessToken,
|
||||||
generateCheckoutScript,
|
generateCheckoutScript,
|
||||||
isSupportedCheckoutCurrency,
|
isSupportedCheckoutCurrency,
|
||||||
normalizeIsoInput,
|
normalizeIsoInput,
|
||||||
@@ -20,7 +21,13 @@ describe("checkout script generator", () => {
|
|||||||
|
|
||||||
it("normalizes ISO input and validates every field", () => {
|
it("normalizes ISO input and validates every field", () => {
|
||||||
expect(normalizeIsoInput(" u-s1 ", 2)).toBe("US");
|
expect(normalizeIsoInput(" u-s1 ", 2)).toBe("US");
|
||||||
expect(validateCheckoutInput({ coupon: "", country: "USA", currency: "TRY" }))
|
expect(validateCheckoutInput({
|
||||||
|
coupon: "",
|
||||||
|
country: "USA",
|
||||||
|
currency: "TRY",
|
||||||
|
accessTokenMode: "auto",
|
||||||
|
accessToken: "",
|
||||||
|
}))
|
||||||
.toEqual({
|
.toEqual({
|
||||||
coupon: "请输入优惠码",
|
coupon: "请输入优惠码",
|
||||||
country: "请输入 2 位英文字母国家代码",
|
country: "请输入 2 位英文字母国家代码",
|
||||||
@@ -33,6 +40,8 @@ describe("checkout script generator", () => {
|
|||||||
coupon: 'SAVE "20" & 更多',
|
coupon: 'SAVE "20" & 更多',
|
||||||
country: "SG",
|
country: "SG",
|
||||||
currency: "SGD",
|
currency: "SGD",
|
||||||
|
accessTokenMode: "auto",
|
||||||
|
accessToken: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(script).toContain('const COUPON = "SAVE \\"20\\" & 更多"');
|
expect(script).toContain('const COUPON = "SAVE \\"20\\" & 更多"');
|
||||||
@@ -42,4 +51,44 @@ describe("checkout script generator", () => {
|
|||||||
expect(script).toContain("promoCode=SAVE%20%2220%22%20%26%20%E6%9B%B4%E5%A4%9A");
|
expect(script).toContain("promoCode=SAVE%20%2220%22%20%26%20%E6%9B%B4%E5%A4%9A");
|
||||||
expect(() => new Function(script)).not.toThrow();
|
expect(() => new Function(script)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a raw access token or extracts one from complete session JSON", () => {
|
||||||
|
expect(extractAccessToken(" eyJraw.token ")).toBe("eyJraw.token");
|
||||||
|
expect(extractAccessToken(JSON.stringify({ user: { name: "Kai" }, accessToken: "eyJsession.token" })))
|
||||||
|
.toBe("eyJsession.token");
|
||||||
|
expect(extractAccessToken(JSON.stringify({ session: { access_token: "nested-token" } })))
|
||||||
|
.toBe("nested-token");
|
||||||
|
expect(extractAccessToken('{"user":true}')).toBeNull();
|
||||||
|
expect(extractAccessToken("{")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes only the extracted token in manual mode and removes the session fetch", () => {
|
||||||
|
const sessionJson = JSON.stringify({
|
||||||
|
user: { email: "private@example.com" },
|
||||||
|
accessToken: 'eyJmanual."token"',
|
||||||
|
expires: "2099-01-01",
|
||||||
|
});
|
||||||
|
const script = generateCheckoutScript({
|
||||||
|
coupon: "SAVE20",
|
||||||
|
country: "US",
|
||||||
|
currency: "EGP",
|
||||||
|
accessTokenMode: "manual",
|
||||||
|
accessToken: sessionJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(script).toContain('const accessToken = "eyJmanual.\\"token\\""');
|
||||||
|
expect(script).not.toContain("/api/auth/session");
|
||||||
|
expect(script).not.toContain("private@example.com");
|
||||||
|
expect(() => new Function(script)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an extractable token in manual mode", () => {
|
||||||
|
expect(validateCheckoutInput({
|
||||||
|
coupon: "SAVE20",
|
||||||
|
country: "US",
|
||||||
|
currency: "EGP",
|
||||||
|
accessTokenMode: "manual",
|
||||||
|
accessToken: '{"user":true}',
|
||||||
|
})).toEqual({ accessToken: "未能从输入内容中提取 accessToken" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+66
-13
@@ -42,16 +42,22 @@ export const CHECKOUT_CURRENCIES = [
|
|||||||
|
|
||||||
export const DEFAULT_CHECKOUT_COUNTRY = "US";
|
export const DEFAULT_CHECKOUT_COUNTRY = "US";
|
||||||
export const DEFAULT_CHECKOUT_CURRENCY = "EGP";
|
export const DEFAULT_CHECKOUT_CURRENCY = "EGP";
|
||||||
|
export const DEFAULT_ACCESS_TOKEN_MODE = "auto" as const;
|
||||||
|
|
||||||
const currencyCodes = new Set<string>(CHECKOUT_CURRENCIES.map(([code]) => code));
|
const currencyCodes = new Set<string>(CHECKOUT_CURRENCIES.map(([code]) => code));
|
||||||
|
|
||||||
|
export type AccessTokenMode = "auto" | "manual";
|
||||||
|
|
||||||
export type CheckoutScriptInput = {
|
export type CheckoutScriptInput = {
|
||||||
coupon: string;
|
coupon: string;
|
||||||
country: string;
|
country: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
accessTokenMode: AccessTokenMode;
|
||||||
|
accessToken: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CheckoutValidationErrors = Partial<Record<keyof CheckoutScriptInput, string>>;
|
export type CheckoutInputField = "coupon" | "country" | "currency" | "accessToken";
|
||||||
|
export type CheckoutValidationErrors = Partial<Record<CheckoutInputField, string>>;
|
||||||
|
|
||||||
export function normalizeIsoInput(value: string, maxLength: number): string {
|
export function normalizeIsoInput(value: string, maxLength: number): string {
|
||||||
return value.toUpperCase().replace(/[^A-Z]/g, "").slice(0, maxLength);
|
return value.toUpperCase().replace(/[^A-Z]/g, "").slice(0, maxLength);
|
||||||
@@ -61,11 +67,45 @@ export function isSupportedCheckoutCurrency(value: string): boolean {
|
|||||||
return currencyCodes.has(value.toUpperCase());
|
return currencyCodes.has(value.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findAccessToken(value: unknown, depth = 0): string | null {
|
||||||
|
if (depth > 4 || value == null || typeof value !== "object") return null;
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
if ((key === "accessToken" || key === "access_token") && typeof child === "string" && child.trim()) {
|
||||||
|
return child.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of Object.values(value)) {
|
||||||
|
const token = findAccessToken(child, depth + 1);
|
||||||
|
if (token) return token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAccessToken(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(trimmed);
|
||||||
|
if (typeof parsed === "string") return parsed.trim() || null;
|
||||||
|
return findAccessToken(parsed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function validateCheckoutInput(input: CheckoutScriptInput): CheckoutValidationErrors {
|
export function validateCheckoutInput(input: CheckoutScriptInput): CheckoutValidationErrors {
|
||||||
const errors: CheckoutValidationErrors = {};
|
const errors: CheckoutValidationErrors = {};
|
||||||
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.accessTokenMode === "manual" && !input.accessToken.trim()) {
|
||||||
|
errors.accessToken = "请输入 Access Token 或 Session JSON";
|
||||||
|
} else if (input.accessTokenMode === "manual" && !extractAccessToken(input.accessToken)) {
|
||||||
|
errors.accessToken = "未能从输入内容中提取 accessToken";
|
||||||
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,17 +114,19 @@ export function generateCheckoutScript(input: CheckoutScriptInput): string {
|
|||||||
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 encodedCoupon = encodeURIComponent(coupon);
|
const encodedCoupon = encodeURIComponent(coupon);
|
||||||
|
const tokenStatusMessage = input.accessTokenMode === "manual"
|
||||||
return `(async function generateAUTeamLink() {
|
? "⏳ 正在使用手动 Access Token..."
|
||||||
// ================= 配置项 =================
|
: "⏳ 正在获取 ChatGPT Session Token...";
|
||||||
const WORKSPACE_NAME = "xxx";
|
const manualAccessToken = extractAccessToken(input.accessToken);
|
||||||
const COUPON = ${JSON.stringify(coupon)}; // 优惠码
|
const tokenSource = input.accessTokenMode === "manual"
|
||||||
const SEAT_QUANTITY = 2; // 席位数量(Team 最少 2 个)
|
? ` // 1. 使用手动提供的登录凭证
|
||||||
// ==========================================
|
const accessToken = ${JSON.stringify(manualAccessToken || "PASTE_ACCESS_TOKEN_HERE")};
|
||||||
|
if (!accessToken || accessToken === "PASTE_ACCESS_TOKEN_HERE") {
|
||||||
console.log("⏳ 正在获取 ChatGPT Session Token...");
|
console.error("❌ Access Token 为空,请重新生成并填入 Token");
|
||||||
|
return;
|
||||||
// 1. 自动获取登录凭证
|
}
|
||||||
|
console.log("✅ 已使用手动 Access Token");`
|
||||||
|
: ` // 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").then(r => r.json());
|
||||||
@@ -94,7 +136,18 @@ export function generateCheckoutScript(input: CheckoutScriptInput): string {
|
|||||||
console.error("❌ 获取 Token 失败:", e.message);
|
console.error("❌ 获取 Token 失败:", e.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log("✅ Token 获取成功");
|
console.log("✅ Token 获取成功");`;
|
||||||
|
|
||||||
|
return `(async function generateAUTeamLink() {
|
||||||
|
// ================= 配置项 =================
|
||||||
|
const WORKSPACE_NAME = "xxx";
|
||||||
|
const COUPON = ${JSON.stringify(coupon)}; // 优惠码
|
||||||
|
const SEAT_QUANTITY = 2; // 席位数量(Team 最少 2 个)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
console.log(${JSON.stringify(tokenStatusMessage)});
|
||||||
|
|
||||||
|
${tokenSource}
|
||||||
|
|
||||||
// 2. 构建请求 Payload
|
// 2. 构建请求 Payload
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@@ -171,6 +171,19 @@ tbody tr:hover { background: #f9fbf8; }
|
|||||||
.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 > 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.has-error > input, .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 legend { margin-bottom: 8px; font-size: 12px; font-weight: 750; }
|
||||||
|
.token-source-options { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.token-source-options label { position: relative; display: flex; min-height: 61px; align-items: center; padding: 11px 12px; border: 1px solid #d7ddd7; border-radius: 11px; background: white; cursor: pointer; transition: border-color .18s ease, background .18s ease, box-shadow .18s ease; }
|
||||||
|
.token-source-options label.active { border-color: var(--green); background: #f1f7f1; box-shadow: 0 0 0 2px rgba(23,107,77,.07); }
|
||||||
|
.token-source-options input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||||
|
.token-source-options label::before { width: 13px; height: 13px; flex: 0 0 auto; margin-right: 9px; border: 1px solid #aab5ae; border-radius: 50%; background: white; content: ""; box-shadow: inset 0 0 0 3px white; }
|
||||||
|
.token-source-options label.active::before { border-color: var(--green); background: var(--green); }
|
||||||
|
.token-source-options span { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||||
|
.token-source-options strong { font-size: 10px; }
|
||||||
|
.token-source-options small { color: #87918d; font-size: 8px; line-height: 1.4; }
|
||||||
|
.token-value-field { margin-top: -2px; }
|
||||||
|
.token-value-field > input { font: 600 11px/1.4 "SFMono-Regular", Consolas, monospace; }
|
||||||
.generator-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
.generator-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
.iso-input { display: grid; grid-template-columns: auto 1fr; align-items: center; }
|
.iso-input { display: grid; grid-template-columns: auto 1fr; align-items: center; }
|
||||||
.iso-input b { padding-left: 12px; color: #87918d; font: 700 9px/1 monospace; }
|
.iso-input b { padding-left: 12px; color: #87918d; font: 700 9px/1 monospace; }
|
||||||
@@ -304,6 +317,7 @@ footer > span:last-child { text-align: right; }
|
|||||||
.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-field-grid { grid-template-columns: 1fr; gap: 0; }
|
.generator-field-grid { grid-template-columns: 1fr; gap: 0; }
|
||||||
|
.token-source-options { grid-template-columns: 1fr; }
|
||||||
.result-head { align-items: flex-end; }
|
.result-head { align-items: flex-end; }
|
||||||
.code-actions button:first-child { display: none; }
|
.code-actions button:first-child { display: none; }
|
||||||
.code-preview pre { padding: 16px; }
|
.code-preview pre { padding: 16px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user