/** * 支付页面 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 = ['']; templates.forEach((tpl) => { options.push(``); }); 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 `