Files

1857 lines
69 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 支付页面 JavaScript
* 支付页面:半自动 + 第三方自动绑卡 + 全自动绑卡任务管理 + 用户完成后自动验订阅
*/
const COUNTRY_CURRENCY_MAP = {
US: "USD",
GB: "GBP",
CA: "CAD",
AU: "AUD",
SG: "SGD",
HK: "HKD",
JP: "JPY",
TR: "TRY",
IN: "INR",
BR: "BRL",
MX: "MXN",
DE: "EUR",
FR: "EUR",
IT: "EUR",
ES: "EUR",
EU: "EUR",
};
const BILLING_STORAGE_KEY = "payment.billing_profile_non_sensitive";
const BILLING_TEMPLATE_STORAGE_KEY = "payment.billing_templates_v1";
const THIRD_PARTY_BIND_URL_STORAGE_KEY = "payment.third_party_bind_api_url";
const BIND_MODE_STORAGE_KEY = "payment.bind_mode";
const THIRD_PARTY_BIND_DEFAULT_URL = "https://twilight-river-f148.482091502.workers.dev/";
const BILLING_TEMPLATE_MAX = 200;
const BILLING_COUNTRY_CURRENCY_MAP = {
US: "USD",
GB: "GBP",
CA: "CAD",
AU: "AUD",
SG: "SGD",
HK: "HKD",
JP: "JPY",
DE: "EUR",
FR: "EUR",
IT: "EUR",
ES: "EUR",
};
const COUNTRY_ALIAS_MAP = {
us: { code: "US", currency: "USD" },
usa: { code: "US", currency: "USD" },
"united states": { code: "US", currency: "USD" },
美国: { code: "US", currency: "USD" },
uk: { code: "GB", currency: "GBP" },
gb: { code: "GB", currency: "GBP" },
england: { code: "GB", currency: "GBP" },
"united kingdom": { code: "GB", currency: "GBP" },
英国: { code: "GB", currency: "GBP" },
ca: { code: "CA", currency: "CAD" },
canada: { code: "CA", currency: "CAD" },
加拿大: { code: "CA", currency: "CAD" },
au: { code: "AU", currency: "AUD" },
australia: { code: "AU", currency: "AUD" },
澳大利亚: { code: "AU", currency: "AUD" },
sg: { code: "SG", currency: "SGD" },
singapore: { code: "SG", currency: "SGD" },
新加坡: { code: "SG", currency: "SGD" },
hk: { code: "HK", currency: "HKD" },
"hong kong": { code: "HK", currency: "HKD" },
香港: { code: "HK", currency: "HKD" },
jp: { code: "JP", currency: "JPY" },
japan: { code: "JP", currency: "JPY" },
日本: { code: "JP", currency: "JPY" },
de: { code: "DE", currency: "EUR" },
germany: { code: "DE", currency: "EUR" },
德国: { code: "DE", currency: "EUR" },
fr: { code: "FR", currency: "EUR" },
france: { code: "FR", currency: "EUR" },
法国: { code: "FR", currency: "EUR" },
it: { code: "IT", currency: "EUR" },
italy: { code: "IT", currency: "EUR" },
意大利: { code: "IT", currency: "EUR" },
es: { code: "ES", currency: "EUR" },
spain: { code: "ES", currency: "EUR" },
西班牙: { code: "ES", currency: "EUR" },
};
let selectedPlan = "plus";
let generatedLink = "";
let isGeneratingCheckoutLink = false;
let paymentAccounts = [];
const bindTaskState = {
page: 1,
pageSize: 50,
status: "",
search: "",
};
let bindTaskAutoRefreshTimer = null;
let billingBatchProfiles = [];
function formatErrorMessage(error) {
if (!error) return "未知错误";
if (typeof error === "string") return error;
// ApiClient 会把后端错误挂在 error.data 上
const detail = error?.data?.detail ?? error?.message;
if (typeof detail === "string" && detail && detail !== "[object Object]") {
return detail;
}
try {
return JSON.stringify(detail || error);
} catch {
return String(detail || error);
}
}
function escapeHtml(value) {
const div = document.createElement("div");
div.textContent = String(value ?? "");
return div.innerHTML;
}
function yesNo(value) {
return value ? "是" : "否";
}
function showSessionDiagnosticPanel(text) {
const panel = document.getElementById("session-diagnostic-panel");
const pre = document.getElementById("session-diagnostic-text");
if (!panel || !pre) return;
pre.textContent = String(text || "");
panel.classList.add("show");
}
function clearSessionDiagnosticPanel() {
const panel = document.getElementById("session-diagnostic-panel");
const pre = document.getElementById("session-diagnostic-text");
if (!panel || !pre) return;
pre.textContent = "";
panel.classList.remove("show");
}
function formatSessionDiagnosticPayload(payload) {
if (!payload || typeof payload !== "object") {
return "诊断结果为空";
}
const token = payload.token_state || {};
const cookie = payload.cookie_state || {};
const bootstrap = payload.bootstrap_capability || {};
const probe = payload.probe || null;
const notes = Array.isArray(payload.notes) ? payload.notes : [];
const lines = [
`账号: ${payload.email || "-"} (ID=${payload.account_id || "-"})`,
`Access Token: ${yesNo(token.has_access_token)} | len=${token.access_token_len || 0} | ${token.access_token_preview || "-"}`,
`Refresh Token: ${yesNo(token.has_refresh_token)} | len=${token.refresh_token_len || 0}`,
`Session(DB): ${yesNo(token.has_session_token_db)} | len=${token.session_token_db_len || 0} | ${token.session_token_db_preview || "-"}`,
`Session(Cookie): ${yesNo(token.has_session_token_cookie)} | len=${token.session_token_cookie_len || 0} | ${token.session_token_cookie_preview || "-"}`,
`Session(Resolved): len=${token.resolved_session_token_len || 0} | ${token.resolved_session_token_preview || "-"}`,
`Cookies: ${yesNo(cookie.has_cookies)} | len=${cookie.cookies_len || 0}`,
`oai-did: ${yesNo(cookie.has_oai_did)} | ${cookie.resolved_oai_did || "-"}`,
`Session 分片: count=${cookie.session_chunk_count || 0} | [${(cookie.session_chunk_indices || []).join(", ")}]`,
`自动补会话能力: ${yesNo(bootstrap.can_login_bootstrap)} | has_password=${yesNo(bootstrap.has_password)} | email_service=${bootstrap.email_service_type || "-"}`,
];
if (probe) {
lines.push(
`实时探测: ok=${yesNo(probe.ok)} | http=${probe.http_status ?? "-"} | session=${yesNo(probe.session_token_found)} | session_json_access=${yesNo(probe.access_token_in_session_json)}`
);
if (probe.session_token_preview) {
lines.push(`探测 session 预览: ${probe.session_token_preview}`);
}
if (probe.access_token_preview) {
lines.push(`探测 access 预览: ${probe.access_token_preview}`);
}
if (probe.error) {
lines.push(`探测错误: ${probe.error}`);
}
}
if (notes.length) {
lines.push("诊断备注:");
notes.forEach((n) => lines.push(`- ${n}`));
}
if (payload.recommendation) {
lines.push(`建议: ${payload.recommendation}`);
}
if (payload.checked_at) {
lines.push(`检查时间: ${payload.checked_at}`);
}
return lines.join("\n");
}
async function runSessionDiagnostic() {
const accountId = Number(document.getElementById("account-select")?.value || 0);
if (!accountId) {
toast.warning("请先选择账号");
return;
}
setButtonLoading("session-diagnostic-btn", "诊断中...", true);
showSessionDiagnosticPanel("正在诊断会话上下文,请稍候...");
try {
const data = await api.get(`/payment/accounts/${accountId}/session-diagnostic?probe=1`);
const diag = data?.diagnostic || {};
showSessionDiagnosticPanel(formatSessionDiagnosticPayload(diag));
toast.success("会话诊断完成");
} catch (error) {
const message = formatErrorMessage(error);
showSessionDiagnosticPanel(`会话诊断失败: ${message}`);
toast.error(`会话诊断失败: ${message}`);
} finally {
setButtonLoading("session-diagnostic-btn", "诊断中...", false);
}
}
async function runSessionBootstrap() {
const accountId = Number(document.getElementById("account-select")?.value || 0);
if (!accountId) {
toast.warning("请先选择账号");
return;
}
setButtonLoading("session-bootstrap-btn", "补全中...", true);
showSessionDiagnosticPanel("正在执行会话补全,请稍候(可能需要等待邮箱验证码)...");
try {
const data = await api.post(`/payment/accounts/${accountId}/session-bootstrap`, {});
if (data?.success) {
toast.success(`会话补全成功(len=${data?.session_token_len || 0})`);
} else {
toast.warning(data?.message || "会话补全未命中");
}
await runSessionDiagnostic();
} catch (error) {
const message = formatErrorMessage(error);
showSessionDiagnosticPanel(`会话补全失败: ${message}`);
toast.error(`会话补全失败: ${message}`);
} finally {
setButtonLoading("session-bootstrap-btn", "补全中...", false);
}
}
async function saveManualSessionToken() {
const accountId = Number(document.getElementById("account-select")?.value || 0);
if (!accountId) {
toast.warning("请先选择账号");
return;
}
const sessionToken = String(document.getElementById("manual-session-token-input")?.value || "").trim();
if (!sessionToken) {
toast.warning("请先粘贴 session_token");
return;
}
setButtonLoading("save-session-token-btn", "保存中...", true);
try {
const data = await api.post(`/payment/accounts/${accountId}/session-token`, {
session_token: sessionToken,
merge_cookie: true,
});
if (data?.success) {
toast.success(`Session Token 已保存(len=${data?.session_token_len || 0})`);
await runSessionDiagnostic();
return;
}
toast.warning(data?.message || "保存完成,但未返回 success");
} catch (error) {
toast.error(`保存 Session Token 失败: ${formatErrorMessage(error)}`);
} finally {
setButtonLoading("save-session-token-btn", "保存中...", false);
}
}
function getInputValue(id) {
return (document.getElementById(id)?.value || "").trim();
}
function setInputValue(id, value) {
const el = document.getElementById(id);
if (!el) return;
el.value = value ?? "";
}
function maskCardNumber(cardNumber) {
const digits = String(cardNumber || "").replace(/\D/g, "");
if (!digits) return "-";
if (digits.length <= 8) return `${digits.slice(0, 2)}****${digits.slice(-2)}`;
return `${digits.slice(0, 4)}****${digits.slice(-4)}`;
}
function resolveCountryAlias(raw) {
const key = String(raw || "").trim();
if (!key) return null;
const normalized = key.toLowerCase();
if (COUNTRY_ALIAS_MAP[normalized]) {
return COUNTRY_ALIAS_MAP[normalized];
}
const upper = key.toUpperCase();
if (BILLING_COUNTRY_CURRENCY_MAP[upper]) {
return {
code: upper,
currency: BILLING_COUNTRY_CURRENCY_MAP[upper],
};
}
return null;
}
function normalizeMonth(value) {
const digits = String(value || "").replace(/\D/g, "");
if (!digits) return "";
return digits.slice(0, 2).padStart(2, "0");
}
function normalizeYear(value) {
const digits = String(value || "").replace(/\D/g, "");
if (!digits) return "";
if (digits.length === 2) return `20${digits}`;
return digits.slice(0, 4);
}
function formatExpiryInput(month, year) {
const mm = normalizeMonth(month);
const rawYear = String(year || "").replace(/\D/g, "");
const yy = rawYear ? rawYear.slice(-2) : "";
if (!mm && !yy) return "";
return yy ? `${mm}/${yy}` : mm;
}
function parseExpiryInput(value) {
const raw = String(value || "").trim();
if (!raw) {
return { exp_month: "", exp_year: "" };
}
const slashMatch = raw.match(/^(\d{1,2})\s*\/\s*(\d{1,4})$/);
if (slashMatch) {
return {
exp_month: normalizeMonth(slashMatch[1]),
exp_year: normalizeYear(slashMatch[2]),
};
}
const digits = raw.replace(/\D/g, "");
if (!digits) {
return { exp_month: "", exp_year: "" };
}
if (digits.length <= 2) {
return { exp_month: normalizeMonth(digits), exp_year: "" };
}
return {
exp_month: normalizeMonth(digits.slice(0, 2)),
exp_year: normalizeYear(digits.slice(2)),
};
}
function normalizeExpiryInputForTyping(value) {
const digits = String(value || "").replace(/\D/g, "").slice(0, 6);
if (!digits) return "";
if (digits.length <= 2) return digits;
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
function parseCardText(rawText) {
const text = String(rawText || "").trim();
if (!text) return {};
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const kv = {};
for (const line of lines) {
const match = line.match(/^(.+?)\s*[::]\s*(.+)$/);
if (match) {
kv[match[1].trim().toLowerCase()] = match[2].trim();
}
}
const result = {};
// 卡号
const cardKeyCandidates = [
"卡号",
"card number",
"card",
"card_number",
];
for (const key of cardKeyCandidates) {
if (!kv[key]) continue;
const digits = kv[key].replace(/\D/g, "");
if (digits.length >= 13 && digits.length <= 19) {
result.card_number = digits;
break;
}
}
if (!result.card_number) {
for (const line of lines) {
const m = line.replace(/-/g, " ").match(/^(\d{13,19})\s+(0[1-9]|1[0-2])\s+(\d{2,4})\s+(\d{3,4})$/);
if (m) {
result.card_number = m[1];
result.exp_month = normalizeMonth(m[2]);
result.exp_year = normalizeYear(m[3]);
result.cvv = m[4];
break;
}
}
}
if (!result.card_number) {
for (const line of lines) {
const digits = line.replace(/\D/g, "");
if (digits.length >= 13 && digits.length <= 19) {
result.card_number = digits;
break;
}
}
}
// 有效期
const expiryKeyCandidates = ["有效期", "exp", "expiry", "expiration", "exp_date"];
for (const key of expiryKeyCandidates) {
if (!kv[key]) continue;
const value = kv[key];
let m = value.match(/(0[1-9]|1[0-2])\s*\/\s*(\d{2,4})/);
if (m) {
result.exp_month = normalizeMonth(m[1]);
result.exp_year = normalizeYear(m[2]);
break;
}
m = value.match(/^(0[1-9]|1[0-2])(\d{2,4})$/);
if (m) {
result.exp_month = normalizeMonth(m[1]);
result.exp_year = normalizeYear(m[2]);
break;
}
}
if (!result.exp_month || !result.exp_year) {
for (const line of lines) {
const m = line.match(/\b(0[1-9]|1[0-2])\s*\/\s*(\d{2,4})\b/);
if (m) {
result.exp_month = normalizeMonth(m[1]);
result.exp_year = normalizeYear(m[2]);
break;
}
}
}
// CVV
const cvvKeyCandidates = ["cvv", "cvc", "安全码"];
for (const key of cvvKeyCandidates) {
if (!kv[key]) continue;
const m = kv[key].match(/\b(\d{3,4})\b/);
if (m) {
result.cvv = m[1];
break;
}
}
if (!result.cvv) {
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (!/(cvv|cvc|安全码)/i.test(line)) continue;
const direct = line.match(/\b(\d{3,4})\b/);
if (direct) {
result.cvv = direct[1];
break;
}
const next = lines[i + 1] || "";
const m = next.match(/\b(\d{3,4})\b/);
if (m) {
result.cvv = m[1];
break;
}
}
}
// 姓名
const nameKeyCandidates = ["姓名", "name", "cardholder", "持卡人"];
for (const key of nameKeyCandidates) {
if (kv[key]) {
result.billing_name = kv[key];
break;
}
}
if (!result.billing_name) {
for (const line of lines) {
if (/^[A-Z][a-z]+(\s+[A-Z][a-z]+){0,4}$/.test(line)) {
result.billing_name = line;
break;
}
}
}
// 地址字段
const addressLine = kv["地址"] || kv["address"] || kv["address_line1"] || "";
const city = kv["城市"] || kv["city"] || "";
const state = kv["州"] || kv["state"] || kv["省"] || "";
const postal = kv["邮编"] || kv["postal_code"] || kv["zip"] || kv["zipcode"] || kv["zip_code"] || "";
const countryRaw = kv["国家"] || kv["country"] || kv["地区"] || "";
if (addressLine) result.address_line1 = addressLine;
if (city) result.address_city = city;
if (state) result.address_state = state;
if (postal) result.postal_code = postal;
if (countryRaw) {
const country = resolveCountryAlias(countryRaw);
if (country) {
result.country_code = country.code;
result.currency = country.currency;
}
}
// 账单地址单行模式
if (!result.address_line1) {
let addressCandidate = "";
for (const line of lines) {
if (/(账单地址|billing\s*address)/i.test(line)) {
addressCandidate = line.replace(/^.*?(账单地址|billing\s*address)\s*[::]?\s*/i, "").trim();
if (addressCandidate) break;
}
}
if (!addressCandidate) {
addressCandidate = lines.find((line) => line.includes(",") && /\d/.test(line)) || "";
}
if (addressCandidate) {
result.raw_address = addressCandidate;
const parts = addressCandidate.split(",").map((item) => item.trim()).filter(Boolean);
if (parts.length) {
result.address_line1 = parts[0];
}
if (!result.postal_code) {
const zip = addressCandidate.match(/\b(\d{5}(?:-\d{4})?)\b/);
if (zip) result.postal_code = zip[1];
}
if (!result.address_state) {
const stateCode = addressCandidate.match(/\b([A-Z]{2})\b/);
if (stateCode) result.address_state = stateCode[1];
}
const suffix = parts[parts.length - 1];
if (!result.country_code && suffix) {
const country = resolveCountryAlias(suffix);
if (country) {
result.country_code = country.code;
result.currency = country.currency;
}
}
}
}
return result;
}
function buildParsedSummary(parsed) {
const parts = [];
if (parsed.card_number) parts.push(`卡号: ${maskCardNumber(parsed.card_number)}`);
if (parsed.exp_month || parsed.exp_year) {
parts.push(`有效期: ${formatExpiryInput(parsed.exp_month, parsed.exp_year) || "--/--"}`);
}
if (parsed.cvv) parts.push("CVV: ***");
if (parsed.billing_name) parts.push(`姓名: ${parsed.billing_name}`);
if (parsed.address_line1 || parsed.raw_address) {
parts.push(`地址: ${parsed.raw_address || parsed.address_line1}`);
}
if (parsed.country_code) parts.push(`国家: ${parsed.country_code}`);
return parts;
}
function fillBillingForm(parsed) {
if (!parsed || typeof parsed !== "object") return;
if (parsed.card_number) setInputValue("card-number-input", parsed.card_number);
if (parsed.exp_month || parsed.exp_year) {
setInputValue("card-expiry-input", formatExpiryInput(parsed.exp_month, parsed.exp_year));
}
if (parsed.cvc || parsed.cvv) setInputValue("card-cvc-input", String(parsed.cvc || parsed.cvv || ""));
if (parsed.billing_name) setInputValue("billing-name-input", parsed.billing_name);
if (parsed.address_line1) setInputValue("billing-line1-input", parsed.address_line1);
if (parsed.address_city) setInputValue("billing-city-input", parsed.address_city);
if (parsed.address_state) setInputValue("billing-state-input", parsed.address_state);
if (parsed.postal_code) setInputValue("billing-postal-input", parsed.postal_code);
if (parsed.country_code) {
const countryEl = document.getElementById("billing-country-input");
if (countryEl) countryEl.value = parsed.country_code;
}
if (parsed.currency) {
setInputValue("billing-currency-input", parsed.currency);
} else {
onBillingCountryChanged();
}
persistBillingProfileNonSensitive();
}
function onBillingCountryChanged() {
const country = (document.getElementById("billing-country-input")?.value || "US").toUpperCase();
const currencyEl = document.getElementById("billing-currency-input");
if (!currencyEl) return;
if (!currencyEl.value || currencyEl.value === "USD" || currencyEl.value.length < 3) {
currencyEl.value = BILLING_COUNTRY_CURRENCY_MAP[country] || "USD";
}
}
function setRandomBillingHint(message, mode = "info") {
const hintEl = document.getElementById("random-billing-hint");
if (!hintEl) return;
hintEl.textContent = String(message || "");
if (mode === "success") {
hintEl.style.color = "var(--success-color)";
return;
}
if (mode === "error") {
hintEl.style.color = "var(--danger-color)";
return;
}
hintEl.style.color = "var(--text-secondary)";
}
async function randomBillingByCountry() {
const country = String(getInputValue("billing-country-input") || "US").toUpperCase();
setButtonLoading("random-billing-btn", "生成中...", true);
setRandomBillingHint("正在获取随机账单资料...", "info");
try {
const data = await api.get(`/payment/random-billing?country=${encodeURIComponent(country)}`);
const profile = data?.profile || {};
if (!profile || typeof profile !== "object") {
throw new Error("返回的账单资料格式无效");
}
fillBillingForm({
billing_name: profile.billing_name || "",
country_code: profile.country_code || country,
currency: profile.currency || "",
address_line1: profile.address_line1 || "",
address_city: profile.address_city || "",
address_state: profile.address_state || "",
postal_code: profile.postal_code || "",
});
const source = String(profile.source || "unknown");
let sourceLabel = "本地兜底";
if (source === "meiguodizhi") sourceLabel = "meiguodizhi";
if (source === "local_geo") sourceLabel = "本地生成";
if (source === "local_geo_fallback") sourceLabel = "本地兜底";
const fallbackReason = String(profile.fallback_reason || "").trim();
const city = String(profile.address_city || "-");
const state = String(profile.address_state || "-");
const postal = String(profile.postal_code || "-");
if ((source === "local_fallback" || source === "local_geo_fallback") && fallbackReason) {
setRandomBillingHint(`来源: ${sourceLabel} | ${city}, ${state}, ${postal} | 外部失败: ${fallbackReason}`, "error");
toast.warning(`外部地址源不可用,已切换本地兜底:${fallbackReason}`);
} else {
setRandomBillingHint(`来源: ${sourceLabel} | ${city}, ${state}, ${postal}`, "success");
toast.success(`已按 ${country} 随机填充账单资料(${sourceLabel})`);
}
} catch (error) {
setRandomBillingHint(`随机失败: ${formatErrorMessage(error)}`, "error");
toast.error(`随机账单资料失败: ${formatErrorMessage(error)}`);
} finally {
setButtonLoading("random-billing-btn", "生成中...", false);
}
}
function collectBillingFormData() {
const expiry = parseExpiryInput(getInputValue("card-expiry-input"));
return {
card_number: getInputValue("card-number-input").replace(/\D/g, ""),
exp_month: expiry.exp_month,
exp_year: expiry.exp_year,
cvc: getInputValue("card-cvc-input").replace(/\D/g, ""),
billing_name: getInputValue("billing-name-input"),
country_code: getInputValue("billing-country-input").toUpperCase(),
currency: getInputValue("billing-currency-input").toUpperCase(),
address_line1: getInputValue("billing-line1-input"),
address_city: getInputValue("billing-city-input"),
address_state: getInputValue("billing-state-input"),
postal_code: getInputValue("billing-postal-input"),
};
}
function persistBillingProfileNonSensitive() {
const data = collectBillingFormData();
storage.set(BILLING_STORAGE_KEY, {
billing_name: data.billing_name,
country_code: data.country_code,
currency: data.currency,
address_line1: data.address_line1,
address_city: data.address_city,
address_state: data.address_state,
postal_code: data.postal_code,
});
}
function restoreBillingProfileNonSensitive() {
const saved = storage.get(BILLING_STORAGE_KEY, null);
if (!saved || typeof saved !== "object") {
setInputValue("billing-country-input", "US");
setInputValue("billing-currency-input", "USD");
return;
}
if (saved.billing_name) setInputValue("billing-name-input", saved.billing_name);
if (saved.country_code) setInputValue("billing-country-input", saved.country_code);
if (saved.currency) setInputValue("billing-currency-input", saved.currency);
if (saved.address_line1) setInputValue("billing-line1-input", saved.address_line1);
if (saved.address_city) setInputValue("billing-city-input", saved.address_city);
if (saved.address_state) setInputValue("billing-state-input", saved.address_state);
if (saved.postal_code) setInputValue("billing-postal-input", saved.postal_code);
onBillingCountryChanged();
}
function getBillingTemplates() {
const raw = storage.get(BILLING_TEMPLATE_STORAGE_KEY, []);
if (!Array.isArray(raw)) return [];
return raw
.filter((item) => item && typeof item === "object" && item.id && item.name && item.data)
.slice(0, BILLING_TEMPLATE_MAX);
}
function saveBillingTemplates(list) {
const safeList = Array.isArray(list) ? list.slice(0, BILLING_TEMPLATE_MAX) : [];
storage.set(BILLING_TEMPLATE_STORAGE_KEY, safeList);
}
function normalizeTemplateData(data) {
const source = data && typeof data === "object" ? data : {};
return {
card_number: String(source.card_number || "").replace(/\D/g, ""),
exp_month: normalizeMonth(source.exp_month),
exp_year: normalizeYear(source.exp_year),
cvc: String(source.cvc || source.cvv || "").replace(/\D/g, "").slice(0, 4),
billing_name: String(source.billing_name || "").trim(),
country_code: String(source.country_code || "US").trim().toUpperCase() || "US",
currency: String(source.currency || "").trim().toUpperCase(),
address_line1: String(source.address_line1 || "").trim(),
address_city: String(source.address_city || "").trim(),
address_state: String(source.address_state || "").trim(),
postal_code: String(source.postal_code || "").trim(),
};
}
function refreshBillingTemplateSelect(selectedId = "") {
const selectEl = document.getElementById("billing-template-select");
if (!selectEl) return;
const templates = getBillingTemplates();
const options = ['<option value="">-- 选择已保存模板 --</option>'];
templates.forEach((tpl) => {
options.push(`<option value="${escapeHtml(tpl.id)}">${escapeHtml(tpl.name)}</option>`);
});
selectEl.innerHTML = options.join("");
if (selectedId && templates.some((tpl) => tpl.id === selectedId)) {
selectEl.value = selectedId;
}
}
function saveCurrentAsTemplate() {
const name = getInputValue("billing-template-name");
if (!name) {
toast.warning("请先填写模板名称");
return;
}
const form = normalizeTemplateData(collectBillingFormData());
const hasValue = Boolean(
form.card_number ||
(form.exp_month && form.exp_year) ||
form.cvc ||
form.billing_name ||
form.address_line1
);
if (!hasValue) {
toast.warning("当前表单为空,无法保存模板");
return;
}
const templates = getBillingTemplates();
const normalizedName = name.toLowerCase();
const existing = templates.find((tpl) => String(tpl.name || "").toLowerCase() === normalizedName);
const nowIso = new Date().toISOString();
if (existing) {
existing.data = form;
existing.updated_at = nowIso;
saveBillingTemplates(templates);
refreshBillingTemplateSelect(existing.id);
toast.success(`模板已更新: ${name}`);
return;
}
const newTemplate = {
id: `tpl_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
name,
data: form,
created_at: nowIso,
updated_at: nowIso,
};
templates.unshift(newTemplate);
saveBillingTemplates(templates);
refreshBillingTemplateSelect(newTemplate.id);
toast.success(`模板已保存: ${name}`);
}
function applySelectedTemplate() {
const selectEl = document.getElementById("billing-template-select");
if (!selectEl || !selectEl.value) {
toast.warning("请先选择模板");
return;
}
const templates = getBillingTemplates();
const selected = templates.find((tpl) => tpl.id === selectEl.value);
if (!selected) {
toast.warning("模板不存在或已删除");
refreshBillingTemplateSelect();
return;
}
fillBillingForm(selected.data || {});
setInputValue("billing-template-name", selected.name || "");
toast.success(`已应用模板: ${selected.name}`);
}
async function deleteSelectedTemplate() {
const selectEl = document.getElementById("billing-template-select");
if (!selectEl || !selectEl.value) {
toast.warning("请先选择模板");
return;
}
const templates = getBillingTemplates();
const selected = templates.find((tpl) => tpl.id === selectEl.value);
if (!selected) {
toast.warning("模板不存在或已删除");
refreshBillingTemplateSelect();
return;
}
const ok = await confirm(`确认删除模板「${selected.name}」吗?`, "删除模板");
if (!ok) return;
const next = templates.filter((tpl) => tpl.id !== selected.id);
saveBillingTemplates(next);
refreshBillingTemplateSelect();
setInputValue("billing-template-name", "");
toast.success(`模板已删除: ${selected.name}`);
}
function saveBatchProfilesAsTemplates() {
if (!billingBatchProfiles.length) {
toast.warning("请先批量识别文本");
return;
}
const templates = getBillingTemplates();
const now = new Date();
let saved = 0;
billingBatchProfiles.forEach((item, idx) => {
const parsed = item?.parsed || {};
const data = normalizeTemplateData({
card_number: parsed.card_number,
exp_month: parsed.exp_month,
exp_year: parsed.exp_year,
cvc: parsed.cvv,
billing_name: parsed.billing_name,
country_code: parsed.country_code,
currency: parsed.currency,
address_line1: parsed.address_line1,
address_city: parsed.address_city,
address_state: parsed.address_state,
postal_code: parsed.postal_code,
});
const hasValue = Boolean(
data.card_number ||
(data.exp_month && data.exp_year) ||
data.cvc ||
data.billing_name ||
data.address_line1
);
if (!hasValue) return;
const suffix = data.card_number ? data.card_number.slice(-4) : String(idx + 1).padStart(2, "0");
const name = `批量模板-${now.toISOString().slice(0, 10)}-${suffix}`;
templates.unshift({
id: `tpl_${Date.now()}_${Math.random().toString(16).slice(2, 8)}_${idx}`,
name,
data,
created_at: now.toISOString(),
updated_at: now.toISOString(),
});
saved += 1;
});
if (!saved) {
toast.warning("批量记录里没有可保存的模板");
return;
}
saveBillingTemplates(templates);
refreshBillingTemplateSelect();
toast.success(`已保存 ${saved} 个模板`);
}
function setParseResult(message, type = "info") {
const resultEl = document.getElementById("billing-parse-result");
if (!resultEl) return;
resultEl.textContent = message || "";
if (type === "error") {
resultEl.style.color = "var(--danger-color)";
} else if (type === "success") {
resultEl.style.color = "var(--success-color)";
} else {
resultEl.style.color = "var(--text-secondary)";
}
}
function parseSingleBillingText() {
const text = getInputValue("billing-paste-text");
if (!text) {
setParseResult("请先粘贴文本", "error");
return;
}
const parsed = parseCardText(text);
const summary = buildParsedSummary(parsed);
if (!summary.length) {
setParseResult("未识别到可用信息,请检查文本格式", "error");
return;
}
fillBillingForm(parsed);
setParseResult(`识别成功: ${summary.join(" | ")}`, "success");
}
function splitBatchBlocks(rawText) {
const blocks = String(rawText || "")
.split(/\n\s*\n+/)
.map((part) => part.trim())
.filter(Boolean);
if (blocks.length > 1) return blocks;
return String(rawText || "")
.split(/(?:^-{3,}|^={3,})/m)
.map((part) => part.trim())
.filter(Boolean);
}
function renderBatchProfiles() {
const wrap = document.getElementById("billing-batch-wrap");
const summary = document.getElementById("billing-batch-summary");
const tbody = document.getElementById("billing-batch-table");
if (!wrap || !summary || !tbody) return;
if (!billingBatchProfiles.length) {
wrap.style.display = "none";
tbody.innerHTML = "";
summary.textContent = "";
return;
}
wrap.style.display = "";
summary.textContent = `已识别 ${billingBatchProfiles.length} 条资料,点击“填充”可写入下方表单。`;
tbody.innerHTML = billingBatchProfiles.map((item, index) => {
const parsed = item.parsed || {};
const address = parsed.raw_address || [
parsed.address_line1,
parsed.address_city,
parsed.address_state,
parsed.postal_code,
].filter(Boolean).join(", ");
return `
<tr>
<td>${index + 1}</td>
<td class="bind-mask">${escapeHtml(maskCardNumber(parsed.card_number))}</td>
<td>${escapeHtml(formatExpiryInput(parsed.exp_month, parsed.exp_year) || "-")}</td>
<td>${escapeHtml(parsed.cvv ? "***" : "-")}</td>
<td>${escapeHtml(parsed.country_code || "-")}</td>
<td>${escapeHtml(address || "-")}</td>
<td><button type="button" class="btn btn-secondary btn-sm" onclick="fillFromBatchProfile(${index})">填充</button></td>
</tr>
`;
}).join("");
}
function parseBatchBillingText() {
const text = getInputValue("billing-paste-text");
if (!text) {
setParseResult("请先粘贴文本", "error");
return;
}
const blocks = splitBatchBlocks(text);
if (!blocks.length) {
setParseResult("未检测到可解析的文本块", "error");
return;
}
billingBatchProfiles = blocks
.map((blockText) => ({ raw: blockText, parsed: parseCardText(blockText) }))
.filter((item) => {
const parsed = item.parsed || {};
return Boolean(
parsed.card_number ||
parsed.exp_month ||
parsed.exp_year ||
parsed.cvv ||
parsed.address_line1 ||
parsed.raw_address
);
});
renderBatchProfiles();
if (!billingBatchProfiles.length) {
setParseResult("批量识别完成,但没有可用记录", "error");
return;
}
setParseResult(`批量识别成功:共 ${billingBatchProfiles.length} 条`, "success");
}
function fillFromBatchProfile(index) {
const item = billingBatchProfiles[index];
if (!item) return;
fillBillingForm(item.parsed || {});
const summary = buildParsedSummary(item.parsed || {});
setParseResult(`已填充第 ${index + 1} 条:${summary.join(" | ")}`, "success");
}
function clearBillingText() {
setInputValue("billing-paste-text", "");
billingBatchProfiles = [];
renderBatchProfiles();
setParseResult("", "info");
}
function bindBillingEvents() {
document.getElementById("parse-billing-btn")?.addEventListener("click", parseSingleBillingText);
document.getElementById("parse-batch-btn")?.addEventListener("click", parseBatchBillingText);
document.getElementById("clear-billing-btn")?.addEventListener("click", clearBillingText);
document.getElementById("save-billing-template-btn")?.addEventListener("click", saveCurrentAsTemplate);
document.getElementById("apply-billing-template-btn")?.addEventListener("click", applySelectedTemplate);
document.getElementById("delete-billing-template-btn")?.addEventListener("click", deleteSelectedTemplate);
document.getElementById("save-batch-template-btn")?.addEventListener("click", saveBatchProfilesAsTemplates);
document.getElementById("billing-template-select")?.addEventListener("change", (event) => {
const selectValue = event?.target?.value || "";
if (!selectValue) {
setInputValue("billing-template-name", "");
return;
}
const selected = getBillingTemplates().find((tpl) => tpl.id === selectValue);
if (selected) {
setInputValue("billing-template-name", selected.name || "");
}
});
document.getElementById("billing-country-input")?.addEventListener("change", () => {
onBillingCountryChanged();
persistBillingProfileNonSensitive();
setRandomBillingHint("");
});
[
"card-number-input",
"card-expiry-input",
"card-cvc-input",
"billing-name-input",
"billing-country-input",
"billing-currency-input",
"billing-line1-input",
"billing-city-input",
"billing-state-input",
"billing-postal-input",
].forEach((id) => {
const node = document.getElementById(id);
node?.addEventListener("input", debounce(() => {
persistBillingProfileNonSensitive();
resetGenerateLinkButtonState();
}, 200));
node?.addEventListener("change", resetGenerateLinkButtonState);
});
document.getElementById("card-expiry-input")?.addEventListener("input", (event) => {
const el = event.target;
if (!el) return;
const next = normalizeExpiryInputForTyping(el.value);
if (el.value !== next) {
el.value = next;
}
});
document.getElementById("card-number-input")?.addEventListener("input", (event) => {
const el = event.target;
if (!el) return;
const digits = String(el.value || "").replace(/\D/g, "").slice(0, 19);
const grouped = digits.replace(/(\d{4})(?=\d)/g, "$1 ").trim();
if (el.value !== grouped) {
el.value = grouped;
}
});
document.getElementById("card-cvc-input")?.addEventListener("input", (event) => {
const el = event.target;
if (!el) return;
const digits = String(el.value || "").replace(/\D/g, "").slice(0, 4);
if (el.value !== digits) {
el.value = digits;
}
});
}
function getTaskStatusText(status) {
const mapping = {
link_ready: "待打开",
opened: "已打开",
waiting_user_action: "待用户完成",
paid_pending_sync: "已支付待同步",
verifying: "验证中",
completed: "已完成",
failed: "失败",
};
return mapping[status] || status || "-";
}
function startBindTaskAutoRefresh() {
stopBindTaskAutoRefresh();
bindTaskAutoRefreshTimer = setInterval(() => {
const bindTaskTab = document.getElementById("tab-content-bind-task");
if (!bindTaskTab?.classList.contains("active")) return;
loadBindCardTasks(true);
}, 20000);
}
function stopBindTaskAutoRefresh() {
if (!bindTaskAutoRefreshTimer) return;
clearInterval(bindTaskAutoRefreshTimer);
bindTaskAutoRefreshTimer = null;
}
function setButtonLoading(buttonId, loadingText, isLoading) {
const btn = document.getElementById(buttonId);
if (!btn) return;
if (isLoading) {
if (!btn.dataset.originalText) {
btn.dataset.originalText = btn.textContent || "";
}
btn.disabled = true;
btn.textContent = loadingText;
return;
}
btn.disabled = false;
btn.textContent = btn.dataset.originalText || btn.textContent;
}
function resetGenerateLinkButtonState() {
const btn = document.getElementById("generate-link-btn");
if (!btn) return;
btn.disabled = false;
if (btn.dataset.originalText) {
btn.textContent = btn.dataset.originalText;
}
}
function getBindMode() {
return (document.getElementById("bind-mode-select")?.value || "semi_auto").trim() || "semi_auto";
}
function updateSemiAutoActionsVisibility(mode) {
const isSemiAuto = (mode || getBindMode()) === "semi_auto";
const loginBtn = document.getElementById("semi-login-gpt-btn");
const emailBtn = document.getElementById("semi-account-email-btn");
if (loginBtn) {
loginBtn.style.display = isSemiAuto ? "" : "none";
}
if (emailBtn) {
emailBtn.style.display = isSemiAuto ? "" : "none";
}
}
function getSelectedAccountEmail() {
const accountId = Number(document.getElementById("account-select")?.value || 0);
if (!accountId) return "";
const matched = (paymentAccounts || []).find((acc) => Number(acc?.id || 0) === accountId);
return String(matched?.email || "").trim();
}
function updateSelectedAccountEmailLabel() {
const emailBtn = document.getElementById("semi-account-email-btn");
if (!emailBtn) return;
const email = getSelectedAccountEmail();
emailBtn.textContent = "邮箱";
emailBtn.title = email ? `当前账号: ${email}(点击查询最新验证码)` : "请先选择账号";
}
async function openGptOfficialLogin() {
const accountId = Number(document.getElementById("account-select")?.value || 0);
if (!accountId) {
toast.warning("请先选择账号");
return;
}
const loginUrl = "https://chatgpt.com/auth/login";
try {
const data = await api.post("/payment/open-incognito", {
url: loginUrl,
account_id: accountId,
});
if (data?.success) {
toast.success("已打开 GPT 官方登录页(无痕)");
return;
}
window.open(loginUrl, "_blank", "noopener,noreferrer");
toast.warning(data?.message || "未找到可用浏览器,已在当前浏览器打开");
} catch (error) {
window.open(loginUrl, "_blank", "noopener,noreferrer");
toast.warning(`打开无痕失败,已在当前浏览器打开: ${formatErrorMessage(error)}`);
}
}
async function fetchSelectedAccountInbox() {
const accountId = Number(document.getElementById("account-select")?.value || 0);
const email = getSelectedAccountEmail();
if (!accountId || !email) {
toast.warning("请先选择账号");
return;
}
toast.info(`正在查询 ${email} 收件箱...`);
try {
const result = await api.post(`/accounts/${accountId}/inbox-code`, {});
if (result?.success && result?.code) {
const code = String(result.code).trim();
copyToClipboard(code);
toast.success(`${email} 最新验证码: ${code}(已复制)`, 8000);
return;
}
toast.error(`查询失败: ${result?.error || "未收到验证码"}`);
} catch (error) {
toast.error(`查询失败: ${formatErrorMessage(error)}`);
}
}
function onBindModeChange() {
const mode = getBindMode();
const thirdPartyPanel = document.getElementById("third-party-config");
if (thirdPartyPanel) {
thirdPartyPanel.style.display = mode === "third_party" ? "" : "none";
}
const actionBtn = document.getElementById("create-bind-task-btn");
if (actionBtn) {
if (mode === "third_party") {
actionBtn.textContent = "创建并执行第三方自动绑卡";
} else if (mode === "local_auto") {
actionBtn.textContent = "创建并执行全自动绑卡";
} else {
actionBtn.textContent = "生成并加入绑卡任务(半自动)";
}
}
updateSemiAutoActionsVisibility(mode);
updateSelectedAccountEmailLabel();
storage.set(BIND_MODE_STORAGE_KEY, mode);
}
function collectThirdPartyConfig() {
const apiUrl = getInputValue("third-party-api-url");
const apiKey = getInputValue("third-party-api-key");
return { api_url: apiUrl, api_key: apiKey };
}
function restoreBindModeConfig() {
const modeSelect = document.getElementById("bind-mode-select");
const savedMode = String(storage.get(BIND_MODE_STORAGE_KEY, "semi_auto") || "semi_auto");
if (modeSelect) {
modeSelect.value = ["semi_auto", "third_party", "local_auto"].includes(savedMode) ? savedMode : "semi_auto";
}
const savedApiUrl = String(storage.get(THIRD_PARTY_BIND_URL_STORAGE_KEY, "") || "").trim();
const initialApiUrl = savedApiUrl || THIRD_PARTY_BIND_DEFAULT_URL;
setInputValue("third-party-api-url", initialApiUrl);
if (!savedApiUrl) {
storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, initialApiUrl);
}
onBindModeChange();
}
function switchPaymentTab(tab) {
const isLink = tab === "link";
document.getElementById("tab-btn-link")?.classList.toggle("active", isLink);
document.getElementById("tab-btn-bind-task")?.classList.toggle("active", !isLink);
document.getElementById("tab-content-link")?.classList.toggle("active", isLink);
document.getElementById("tab-content-bind-task")?.classList.toggle("active", !isLink);
if (!isLink) {
loadBindCardTasks(true);
}
}
function getCheckoutPayload() {
const accountId = Number(document.getElementById("account-select")?.value || 0);
if (!accountId) {
throw new Error("请先选择账号");
}
const payload = {
account_id: accountId,
plan_type: selectedPlan,
country: (document.getElementById("country-select")?.value || "US").toUpperCase(),
currency: (document.getElementById("currency-display")?.value || "USD").toUpperCase(),
};
if (selectedPlan === "team") {
payload.workspace_name = document.getElementById("workspace-name")?.value || "MyTeam";
payload.seat_quantity = Number(document.getElementById("seat-quantity")?.value || 5) || 5;
payload.price_interval = document.getElementById("price-interval")?.value || "month";
}
return payload;
}
function showGeneratedLink(data) {
generatedLink = data.link || "";
const linkText = document.getElementById("link-text");
const linkBox = document.getElementById("link-box");
const statusEl = document.getElementById("open-status");
if (!linkText || !linkBox || !statusEl) return;
linkText.value = generatedLink;
linkBox.classList.add("show");
const source = data.source ? `来源: ${data.source}` : "";
const routeHint = data.is_official_checkout
? "已拿到官方 checkout 链接,可直接绑卡"
: "当前是中转链接,点击“直接打开支付页”继续跳转";
statusEl.textContent = [source, routeHint].filter(Boolean).join(" | ");
}
document.addEventListener("DOMContentLoaded", () => {
const countrySelect = document.getElementById("country-select");
const currencyDisplay = document.getElementById("currency-display");
if (countrySelect) {
countrySelect.value = countrySelect.value || "US";
}
if (currencyDisplay) {
currencyDisplay.value = currencyDisplay.value || "USD";
}
const searchInput = document.getElementById("bind-task-search");
if (searchInput) {
searchInput.addEventListener(
"input",
debounce(() => {
bindTaskState.search = (searchInput.value || "").trim();
bindTaskState.page = 1;
loadBindCardTasks();
}, 250)
);
}
const statusSelect = document.getElementById("bind-task-status");
if (statusSelect) {
statusSelect.addEventListener("change", () => {
bindTaskState.status = statusSelect.value || "";
bindTaskState.page = 1;
loadBindCardTasks();
});
}
document.getElementById("account-select")?.addEventListener("change", () => {
resetGenerateLinkButtonState();
updateSelectedAccountEmailLabel();
});
bindBillingEvents();
restoreBillingProfileNonSensitive();
refreshBillingTemplateSelect();
restoreBindModeConfig();
document.getElementById("third-party-api-url")?.addEventListener(
"input",
debounce(() => {
const apiUrl = getInputValue("third-party-api-url");
storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, apiUrl);
}, 200)
);
document.getElementById("bind-mode-select")?.addEventListener("change", onBindModeChange);
loadAccounts();
onCountryChange();
loadBindCardTasks();
startBindTaskAutoRefresh();
switchPaymentTab("link");
window.addEventListener("beforeunload", stopBindTaskAutoRefresh);
});
// 加载账号列表
async function loadAccounts() {
try {
// 后端 page_size 最大为 100,超限会返回 422。
// 这里读取账号管理列表,不按状态硬过滤,避免“有账号但选不到”。
const data = await api.get("/accounts?page=1&page_size=100");
const sel = document.getElementById("account-select");
if (!sel) return;
sel.innerHTML = '<option value="">-- 请选择账号 --</option>';
paymentAccounts = Array.isArray(data.accounts) ? data.accounts : [];
(data.accounts || []).forEach((acc) => {
const opt = document.createElement("option");
opt.value = acc.id;
const subText = acc.subscription_type ? ` (${String(acc.subscription_type).toUpperCase()})` : "";
opt.textContent = `${acc.email}${subText}`;
sel.appendChild(opt);
});
updateSelectedAccountEmailLabel();
updateSemiAutoActionsVisibility(getBindMode());
} catch (e) {
toast.error(`加载账号失败: ${formatErrorMessage(e)}`);
}
}
// 国家切换
function onCountryChange() {
const country = document.getElementById("country-select")?.value || "US";
const currency = COUNTRY_CURRENCY_MAP[country] || "USD";
const currencyEl = document.getElementById("currency-display");
if (currencyEl) {
currencyEl.value = currency;
}
}
// 选择套餐
function selectPlan(plan) {
selectedPlan = plan;
document.getElementById("plan-plus")?.classList.toggle("selected", plan === "plus");
document.getElementById("plan-team")?.classList.toggle("selected", plan === "team");
document.getElementById("team-options")?.classList.toggle("show", plan === "team");
// 切换套餐时隐藏已有链接,避免误用旧链接。
document.getElementById("link-box")?.classList.remove("show");
generatedLink = "";
resetGenerateLinkButtonState();
}
// 生成支付链接
async function generateLink() {
if (isGeneratingCheckoutLink) {
return;
}
let payload;
try {
payload = getCheckoutPayload();
} catch (err) {
toast.warning(err.message || "参数不完整");
return;
}
isGeneratingCheckoutLink = true;
setButtonLoading("generate-link-btn", "生成中...", true);
try {
const data = await api.post("/payment/generate-link", payload);
if (!data?.success || !data?.link) {
throw new Error(data?.detail || "生成链接失败");
}
showGeneratedLink(data);
toast.success("支付链接生成成功");
} catch (e) {
toast.error(`生成链接失败: ${formatErrorMessage(e)}`);
} finally {
isGeneratingCheckoutLink = false;
setButtonLoading("generate-link-btn", "生成中...", false);
resetGenerateLinkButtonState();
}
}
async function submitThirdPartyAutoBind(task, bindData) {
const thirdParty = collectThirdPartyConfig();
const apiUrl = String(thirdParty.api_url || "").trim();
const apiKey = String(thirdParty.api_key || "").trim();
if (apiUrl) {
storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, apiUrl);
}
const expYear = String(bindData.exp_year || "").replace(/\D/g, "");
const payload = {
api_url: apiUrl || undefined,
api_key: apiKey || undefined,
timeout_seconds: 180,
interval_seconds: 10,
card: {
number: String(bindData.card_number || "").replace(/\D/g, ""),
exp_month: String(bindData.exp_month || "").replace(/\D/g, "").padStart(2, "0").slice(0, 2),
exp_year: (expYear.slice(-2) || expYear || "").padStart(2, "0"),
cvc: String(bindData.cvc || "").replace(/\D/g, "").slice(0, 4),
},
profile: {
name: String(bindData.billing_name || "").trim(),
email: String(task?.account_email || "").trim() || undefined,
country: String(bindData.country_code || "US").toUpperCase(),
line1: String(bindData.address_line1 || "").trim(),
city: String(bindData.address_city || "").trim(),
state: String(bindData.address_state || "").trim(),
postal: String(bindData.postal_code || "").trim(),
},
};
return api.post(`/payment/bind-card/tasks/${task.id}/auto-bind-third-party`, payload);
}
async function submitLocalAutoBind(task, bindData) {
const expYear = String(bindData.exp_year || "").replace(/\D/g, "");
const payload = {
browser_timeout_seconds: 220,
post_submit_wait_seconds: 90,
verify_timeout_seconds: 180,
verify_interval_seconds: 10,
headless: false,
card: {
number: String(bindData.card_number || "").replace(/\D/g, ""),
exp_month: String(bindData.exp_month || "").replace(/\D/g, "").padStart(2, "0").slice(0, 2),
exp_year: (expYear.slice(-2) || expYear || "").padStart(2, "0"),
cvc: String(bindData.cvc || "").replace(/\D/g, "").slice(0, 4),
},
profile: {
name: String(bindData.billing_name || "").trim(),
email: String(task?.account_email || "").trim() || undefined,
country: String(bindData.country_code || "US").toUpperCase(),
line1: String(bindData.address_line1 || "").trim(),
city: String(bindData.address_city || "").trim(),
state: String(bindData.address_state || "").trim(),
postal: String(bindData.postal_code || "").trim(),
},
};
return api.post(`/payment/bind-card/tasks/${task.id}/auto-bind-local`, payload);
}
async function runLocalAutoBindInBackground(task, bindData) {
try {
const autoResult = await submitLocalAutoBind(task, bindData);
if (autoResult?.verified) {
toast.success(`任务 #${task.id} 全自动绑卡完成: ${String(autoResult.subscription_type || "").toUpperCase()}`);
} else if (autoResult?.paid_confirmed) {
toast.success(`任务 #${task.id} 已确认支付,等待订阅同步(可点“同步订阅”)`, 7000);
} else if (autoResult?.pending || autoResult?.need_user_action) {
const stage = String(autoResult?.local_auto?.stage || autoResult?.local_auto?.error || "challenge").toUpperCase();
toast.warning(
`任务 #${task.id} 全自动绑卡已提交(${stage}),请在支付页完成验证后点击“我已完成支付”或“同步订阅”。`,
9000
);
} else {
const sub = String(autoResult?.subscription_type || "free").toUpperCase();
toast.warning(`任务 #${task.id} 全自动绑卡执行完成,但当前订阅为 ${sub},请稍后再同步`, 7000);
}
} catch (autoErr) {
toast.error(`任务 #${task.id} 全自动绑卡失败: ${formatErrorMessage(autoErr)}`);
} finally {
try {
await loadBindCardTasks();
} catch (_) {
// 忽略刷新异常,避免覆盖前面的业务提示
}
}
}
// 生成并创建绑卡任务
async function createBindCardTask() {
let payload;
try {
payload = getCheckoutPayload();
} catch (err) {
toast.warning(err.message || "参数不完整");
return;
}
const bindMode = getBindMode();
const bindData = collectBillingFormData();
const missing = [];
if (!bindData.card_number) missing.push("卡号");
if (!bindData.exp_month || !bindData.exp_year) missing.push("有效期");
if (!bindData.cvc) missing.push("CVC");
if (!bindData.billing_name) missing.push("姓名");
if (!bindData.address_line1) missing.push("地址");
if (!bindData.postal_code) missing.push("邮编");
if (missing.length && bindMode === "semi_auto") {
toast.warning(`绑卡资料未完整:${missing.join("、")}(本次仅创建半自动任务,不会阻断)`, 5000);
}
if (missing.length && (bindMode === "third_party" || bindMode === "local_auto")) {
const modeText = bindMode === "third_party" ? "第三方自动绑卡" : "全自动绑卡";
toast.warning(`${modeText}需要完整资料:${missing.join("、")}`, 5000);
return;
}
payload.auto_open = Boolean(document.getElementById("bind-auto-open")?.checked);
payload.bind_mode = bindMode;
setButtonLoading("create-bind-task-btn", "创建中...", true);
try {
const data = await api.post("/payment/bind-card/tasks", payload);
if (!data?.success || !data?.task) {
throw new Error(data?.detail || "创建绑卡任务失败");
}
if (data.link) {
showGeneratedLink({
link: data.link,
source: data.source,
is_official_checkout: data.is_official_checkout,
});
}
if (bindMode === "third_party") {
toast.info(`任务 #${data.task.id} 已创建,正在调用第三方自动绑卡...`, 3000);
try {
const autoResult = await submitThirdPartyAutoBind(data.task, bindData);
if (autoResult?.verified) {
toast.success(`任务 #${data.task.id} 自动绑卡完成: ${String(autoResult.subscription_type || "").toUpperCase()}`);
} else if (autoResult?.paid_confirmed) {
toast.success(`任务 #${data.task.id} 已确认支付,等待订阅同步(可点“同步订阅”)`, 7000);
} else if (autoResult?.pending || autoResult?.need_user_action) {
const tp = autoResult?.third_party || {};
const assess = tp?.assessment || {};
const snapshot = assess?.snapshot || {};
const paymentStatus = String(snapshot?.payment_status || "").toUpperCase() || "UNKNOWN";
toast.warning(
`任务 #${data.task.id} 第三方已受理(payment_status=${paymentStatus}),可能需要 challenge;请在支付页完成后点“我已完成支付”或“同步订阅”。`,
9000
);
} else {
const sub = String(autoResult?.subscription_type || "free").toUpperCase();
toast.warning(`任务 #${data.task.id} 第三方提交成功,但当前订阅为 ${sub},请稍后再同步`, 7000);
}
} catch (autoErr) {
toast.error(`任务 #${data.task.id} 第三方自动绑卡失败: ${formatErrorMessage(autoErr)}`);
}
} else if (bindMode === "local_auto") {
toast.info(`任务 #${data.task.id} 已创建,已在后台执行全自动绑卡,可继续修改参数并创建新任务`, 5000);
runLocalAutoBindInBackground(data.task, { ...bindData });
} else {
toast.success(`绑卡任务已创建 #${data.task.id}${data.auto_opened ? ",浏览器已打开" : ""}`);
}
switchPaymentTab("bind-task");
await loadBindCardTasks();
} catch (e) {
toast.error(`创建绑卡任务失败: ${formatErrorMessage(e)}`);
} finally {
setButtonLoading("create-bind-task-btn", "创建中...", false);
}
}
function copyLink() {
if (!generatedLink) {
toast.warning("请先生成链接");
return;
}
copyToClipboard(generatedLink);
}
// 在当前浏览器直接打开支付页(适合 Docker/远程部署场景)
function openCheckout() {
if (!generatedLink) {
toast.warning("请先生成链接");
return;
}
const w = window.open(generatedLink, "_blank", "noopener,noreferrer");
if (!w) {
window.location.href = generatedLink;
}
}
// 无痕打开浏览器(携带账号 cookie)
async function openIncognito() {
if (!generatedLink) {
toast.warning("请先生成链接");
return;
}
const accountId = Number(document.getElementById("account-select")?.value || 0);
const statusEl = document.getElementById("open-status");
if (statusEl) {
statusEl.textContent = "正在打开...";
}
try {
const body = { url: generatedLink };
if (accountId) body.account_id = accountId;
const data = await api.post("/payment/open-incognito", body);
if (data?.success) {
if (statusEl) statusEl.textContent = "已在无痕模式打开浏览器";
toast.success("无痕浏览器已打开");
} else {
if (statusEl) statusEl.textContent = data?.message || "未找到可用浏览器,请手动复制链接";
toast.warning(data?.message || "未找到可用浏览器");
}
} catch (e) {
if (statusEl) statusEl.textContent = `请求失败: ${formatErrorMessage(e)}`;
toast.error(`请求失败: ${formatErrorMessage(e)}`);
}
}
async function loadBindCardTasks(silent = false) {
const tbody = document.getElementById("bind-card-task-table");
if (!tbody) return;
if (!silent) {
setButtonLoading("refresh-bind-task-btn", "刷新中...", true);
}
try {
const params = new URLSearchParams({
page: String(bindTaskState.page),
page_size: String(bindTaskState.pageSize),
});
if (bindTaskState.status) params.set("status", bindTaskState.status);
if (bindTaskState.search) params.set("search", bindTaskState.search);
const data = await api.get(`/payment/bind-card/tasks?${params.toString()}`);
const tasks = data?.tasks || [];
if (!tasks.length) {
tbody.innerHTML = `
<tr>
<td colspan="8"><div class="empty-state">暂无绑卡任务</div></td>
</tr>
`;
return;
}
tbody.innerHTML = tasks.map((task) => `
<tr>
<td>${task.id}</td>
<td>${escapeHtml(task.account_email || "-")}</td>
<td>${String(task.plan_type || "-").toUpperCase()}</td>
<td><span class="bind-task-badge ${escapeHtml(task.status || "")}">${escapeHtml(getTaskStatusText(task.status))}</span></td>
<td>
<a class="bind-task-url" href="${escapeHtml(task.checkout_url || "#")}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(task.checkout_url || "")}">
${escapeHtml(task.checkout_url || "-")}
</a>
</td>
<td>${escapeHtml(task.checkout_source || "-")}</td>
<td>${format.date(task.created_at)}</td>
<td>
<div class="bind-task-actions">
<button class="btn btn-primary bind-task-action-btn" onclick="openBindCardTask(${task.id})">打开</button>
<button class="btn btn-primary bind-task-action-btn" onclick="markBindCardTaskUserAction(${task.id})">我已完成支付</button>
<button class="btn btn-secondary bind-task-action-btn" onclick="syncBindCardTask(${task.id})">同步订阅</button>
<button class="btn btn-danger bind-task-action-btn" onclick="deleteBindCardTask(${task.id})">删除</button>
</div>
${task.last_error ? `<div class="hint" style="margin-top:6px;color:var(--danger-color);" title="${escapeHtml(task.last_error)}">${escapeHtml(task.last_error)}</div>` : ""}
</td>
</tr>
`).join("");
} catch (e) {
tbody.innerHTML = `
<tr>
<td colspan="8"><div class="empty-state">加载失败: ${escapeHtml(formatErrorMessage(e))}</div></td>
</tr>
`;
} finally {
if (!silent) {
setButtonLoading("refresh-bind-task-btn", "刷新中...", false);
}
}
}
async function openBindCardTask(taskId) {
try {
const data = await api.post(`/payment/bind-card/tasks/${taskId}/open`, {});
if (data?.success) {
toast.success(`任务 #${taskId} 已尝试打开`);
await loadBindCardTasks();
return;
}
throw new Error(data?.detail || "打开失败");
} catch (e) {
toast.error(`打开任务失败: ${formatErrorMessage(e)}`);
}
}
async function markBindCardTaskUserAction(taskId) {
try {
toast.info(`任务 #${taskId} 正在验证订阅,最多等待 180 秒...`, 3000);
const data = await api.post(`/payment/bind-card/tasks/${taskId}/mark-user-action`, {
timeout_seconds: 180,
interval_seconds: 10,
});
if (data?.verified) {
toast.success(`任务 #${taskId} 验证成功: ${String(data.subscription_type || "").toUpperCase()}`);
} else {
const sub = String(data?.subscription_type || "free").toUpperCase();
const source = String(data?.detail?.source || "unknown");
const confidence = String(data?.detail?.confidence || "unknown");
const note = String(data?.detail?.note || "");
const suffix = note ? `, note=${note}` : "";
toast.warning(
`任务 #${taskId} 暂未检测到订阅(当前 ${sub}, source=${source}, confidence=${confidence}${suffix}),已切回待用户完成`,
7000
);
}
await loadBindCardTasks();
} catch (e) {
// 兼容旧后端:如果 mark-user-action 尚未部署,自动降级到 sync-subscription。
const detail = String(e?.data?.detail || "").toLowerCase();
const isRouteNotFound = e?.response?.status === 404 && detail === "not found";
if (isRouteNotFound) {
try {
const fallback = await api.post(`/payment/bind-card/tasks/${taskId}/sync-subscription`, {});
const sub = String(fallback?.subscription_type || "free").toUpperCase();
if (sub === "PLUS" || sub === "TEAM") {
toast.success(`任务 #${taskId} 已通过兼容模式同步成功: ${sub}`);
} else {
toast.warning(`任务 #${taskId} 兼容同步完成,但当前仍是 ${sub}`, 5000);
}
await loadBindCardTasks();
return;
} catch (fallbackErr) {
toast.error(`验证订阅失败(兼容模式也失败): ${formatErrorMessage(fallbackErr)}`);
return;
}
}
toast.error(`验证订阅失败: ${formatErrorMessage(e)}`);
}
}
async function syncBindCardTask(taskId) {
try {
const data = await api.post(`/payment/bind-card/tasks/${taskId}/sync-subscription`, {});
const sub = String(data?.subscription_type || "free").toUpperCase();
const source = String(data?.detail?.source || "unknown");
const confidence = String(data?.detail?.confidence || "unknown");
const note = String(data?.detail?.note || "");
const suffix = note ? `, note=${note}` : "";
const msg = `同步完成: ${sub} (source=${source}, confidence=${confidence}${suffix})`;
if (sub === "PLUS" || sub === "TEAM") {
toast.success(msg);
} else {
toast.warning(msg, 7000);
}
await loadBindCardTasks();
} catch (e) {
toast.error(`同步订阅失败: ${formatErrorMessage(e)}`);
}
}
async function deleteBindCardTask(taskId) {
const ok = await confirm(`确认删除绑卡任务 #${taskId} 吗?`, "删除任务");
if (!ok) return;
try {
await api.delete(`/payment/bind-card/tasks/${taskId}`);
toast.success(`任务 #${taskId} 已删除`);
await loadBindCardTasks();
} catch (e) {
toast.error(`删除任务失败: ${formatErrorMessage(e)}`);
}
}
window.selectPlan = selectPlan;
window.generateLink = generateLink;
window.createBindCardTask = createBindCardTask;
window.copyLink = copyLink;
window.openCheckout = openCheckout;
window.openIncognito = openIncognito;
window.onBindModeChange = onBindModeChange;
window.switchPaymentTab = switchPaymentTab;
window.loadBindCardTasks = loadBindCardTasks;
window.openBindCardTask = openBindCardTask;
window.markBindCardTaskUserAction = markBindCardTaskUserAction;
window.syncBindCardTask = syncBindCardTask;
window.deleteBindCardTask = deleteBindCardTask;
window.fillFromBatchProfile = fillFromBatchProfile;
window.randomBillingByCountry = randomBillingByCountry;