mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-05-06 20:02:51 +08:00
feat(accounts): 添加账号cookies存储及支付链接国家选择功能
- 在账号详情页添加cookies编辑与保存功能,用于支付请求 - 支付页面新增国家选择下拉框,支持多国货币计费 - 优化无痕打开浏览器功能,支持注入账号cookies - 更新数据库模型、API路由及前端界面
This commit is contained in:
@@ -21,6 +21,9 @@ dev = [
|
|||||||
"pytest>=7.0.0",
|
"pytest>=7.0.0",
|
||||||
"httpx>=0.24.0",
|
"httpx>=0.24.0",
|
||||||
]
|
]
|
||||||
|
payment = [
|
||||||
|
"playwright>=1.40.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
codex-webui = "webui:main"
|
codex-webui = "webui:main"
|
||||||
|
|||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@@ -23,62 +23,101 @@ def _build_proxies(proxy: Optional[str]) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def generate_plus_link(account: Account, proxy: Optional[str] = None) -> str:
|
_COUNTRY_CURRENCY_MAP = {
|
||||||
"""生成 Plus 支付链接"""
|
"SG": "SGD",
|
||||||
if not account.access_token:
|
"US": "USD",
|
||||||
raise ValueError("账号缺少 access_token")
|
"TR": "TRY",
|
||||||
|
"JP": "JPY",
|
||||||
headers = {
|
"HK": "HKD",
|
||||||
"Authorization": f"Bearer {account.access_token}",
|
"GB": "GBP",
|
||||||
"Content-Type": "application/json",
|
"EU": "EUR",
|
||||||
}
|
"AU": "AUD",
|
||||||
payload = {
|
"CA": "CAD",
|
||||||
"plan_type": "plus",
|
"IN": "INR",
|
||||||
"checkout_ui_mode": "hosted",
|
"BR": "BRL",
|
||||||
"cancel_url": "https://chatgpt.com/",
|
"MX": "MXN",
|
||||||
"success_url": "https://chatgpt.com/",
|
}
|
||||||
}
|
|
||||||
|
|
||||||
resp = cffi_requests.post(
|
|
||||||
PAYMENT_CHECKOUT_URL,
|
|
||||||
headers=headers,
|
|
||||||
json=payload,
|
|
||||||
proxies=_build_proxies(proxy),
|
|
||||||
timeout=30,
|
|
||||||
impersonate="chrome110",
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
if "url" in data:
|
|
||||||
return data["url"]
|
|
||||||
raise ValueError(data.get("detail", "API 未返回支付链接"))
|
|
||||||
|
|
||||||
|
|
||||||
def generate_team_link(
|
def _extract_oai_did(cookies_str: str) -> Optional[str]:
|
||||||
|
"""从 cookie 字符串中提取 oai-device-id"""
|
||||||
|
for part in cookies_str.split(";"):
|
||||||
|
part = part.strip()
|
||||||
|
if part.startswith("oai-did="):
|
||||||
|
return part[len("oai-did="):].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cookie_str(cookies_str: str, domain: str) -> list:
|
||||||
|
"""将 'key=val; key2=val2' 格式解析为 Playwright cookie 列表"""
|
||||||
|
cookies = []
|
||||||
|
for part in cookies_str.split(";"):
|
||||||
|
part = part.strip()
|
||||||
|
if "=" not in part:
|
||||||
|
continue
|
||||||
|
name, _, value = part.partition("=")
|
||||||
|
cookies.append({
|
||||||
|
"name": name.strip(),
|
||||||
|
"value": value.strip(),
|
||||||
|
"domain": domain,
|
||||||
|
"path": "/",
|
||||||
|
})
|
||||||
|
return cookies
|
||||||
|
|
||||||
|
|
||||||
|
def _open_url_system_browser(url: str) -> bool:
|
||||||
|
"""回退方案:调用系统浏览器以无痕模式打开"""
|
||||||
|
platform = sys.platform
|
||||||
|
try:
|
||||||
|
if platform == "win32":
|
||||||
|
for browser, flag in [("chrome", "--incognito"), ("msedge", "--inprivate")]:
|
||||||
|
try:
|
||||||
|
subprocess.Popen(f'start {browser} {flag} "{url}"', shell=True)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
elif platform == "darwin":
|
||||||
|
subprocess.Popen(["open", "-a", "Google Chrome", "--args", "--incognito", url])
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
for binary in ["google-chrome", "chromium-browser", "chromium"]:
|
||||||
|
try:
|
||||||
|
subprocess.Popen([binary, "--incognito", url])
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"系统浏览器无痕打开失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_plus_link(
|
||||||
account: Account,
|
account: Account,
|
||||||
workspace_name: str = "MyTeam",
|
|
||||||
price_interval: str = "month",
|
|
||||||
seat_quantity: int = 5,
|
|
||||||
proxy: Optional[str] = None,
|
proxy: Optional[str] = None,
|
||||||
|
country: str = "SG",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""生成 Team 支付链接"""
|
"""生成 Plus 支付链接(后端携带账号 cookie 发请求)"""
|
||||||
if not account.access_token:
|
if not account.access_token:
|
||||||
raise ValueError("账号缺少 access_token")
|
raise ValueError("账号缺少 access_token")
|
||||||
|
|
||||||
|
currency = _COUNTRY_CURRENCY_MAP.get(country, "USD")
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {account.access_token}",
|
"Authorization": f"Bearer {account.access_token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"oai-language": "zh-CN",
|
||||||
}
|
}
|
||||||
|
if account.cookies:
|
||||||
|
headers["cookie"] = account.cookies
|
||||||
|
oai_did = _extract_oai_did(account.cookies)
|
||||||
|
if oai_did:
|
||||||
|
headers["oai-device-id"] = oai_did
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"plan_name": "chatgptteamplan",
|
"plan_name": "chatgptplusplan",
|
||||||
"team_plan_data": {
|
"billing_details": {"country": country, "currency": currency},
|
||||||
"workspace_name": workspace_name,
|
|
||||||
"price_interval": price_interval,
|
|
||||||
"seat_quantity": seat_quantity,
|
|
||||||
},
|
|
||||||
"promo_campaign": {
|
"promo_campaign": {
|
||||||
"promo_campaign_id": "team-1-month-free",
|
"promo_campaign_id": "plus-1-month-free",
|
||||||
"is_coupon_from_query_param": True,
|
"is_coupon_from_query_param": False,
|
||||||
},
|
},
|
||||||
"checkout_ui_mode": "custom",
|
"checkout_ui_mode": "custom",
|
||||||
}
|
}
|
||||||
@@ -98,36 +137,86 @@ def generate_team_link(
|
|||||||
raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
|
raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
|
||||||
|
|
||||||
|
|
||||||
def open_url_incognito(url: str) -> bool:
|
def generate_team_link(
|
||||||
"""调用本机浏览器以无痕模式打开 URL"""
|
account: Account,
|
||||||
platform = sys.platform
|
workspace_name: str = "MyTeam",
|
||||||
|
price_interval: str = "month",
|
||||||
|
seat_quantity: int = 5,
|
||||||
|
proxy: Optional[str] = None,
|
||||||
|
country: str = "SG",
|
||||||
|
) -> str:
|
||||||
|
"""生成 Team 支付链接(后端携带账号 cookie 发请求)"""
|
||||||
|
if not account.access_token:
|
||||||
|
raise ValueError("账号缺少 access_token")
|
||||||
|
|
||||||
|
currency = _COUNTRY_CURRENCY_MAP.get(country, "USD")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {account.access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"oai-language": "zh-CN",
|
||||||
|
}
|
||||||
|
if account.cookies:
|
||||||
|
headers["cookie"] = account.cookies
|
||||||
|
oai_did = _extract_oai_did(account.cookies)
|
||||||
|
if oai_did:
|
||||||
|
headers["oai-device-id"] = oai_did
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"plan_name": "chatgptteamplan",
|
||||||
|
"team_plan_data": {
|
||||||
|
"workspace_name": workspace_name,
|
||||||
|
"price_interval": price_interval,
|
||||||
|
"seat_quantity": seat_quantity,
|
||||||
|
},
|
||||||
|
"billing_details": {"country": country, "currency": currency},
|
||||||
|
"promo_campaign": {
|
||||||
|
"promo_campaign_id": "team-1-month-free",
|
||||||
|
"is_coupon_from_query_param": True,
|
||||||
|
},
|
||||||
|
"cancel_url": "https://chatgpt.com/#pricing",
|
||||||
|
"checkout_ui_mode": "custom",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = cffi_requests.post(
|
||||||
|
PAYMENT_CHECKOUT_URL,
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
proxies=_build_proxies(proxy),
|
||||||
|
timeout=30,
|
||||||
|
impersonate="chrome110",
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if "checkout_session_id" in data:
|
||||||
|
return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"]
|
||||||
|
raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
|
||||||
|
|
||||||
|
|
||||||
|
def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool:
|
||||||
|
"""用 Playwright 以无痕模式打开 URL,可注入 cookie"""
|
||||||
|
import threading
|
||||||
try:
|
try:
|
||||||
if platform == "win32":
|
from playwright.sync_api import sync_playwright
|
||||||
# 依次尝试 Chrome、Edge
|
except ImportError:
|
||||||
for browser, flag in [("chrome", "--incognito"), ("msedge", "--inprivate")]:
|
logger.warning("playwright 未安装,回退到系统浏览器")
|
||||||
try:
|
return _open_url_system_browser(url)
|
||||||
subprocess.Popen(
|
|
||||||
f'start {browser} {flag} "{url}"',
|
def _launch():
|
||||||
shell=True,
|
try:
|
||||||
)
|
with sync_playwright() as p:
|
||||||
return True
|
browser = p.chromium.launch(headless=False, args=["--incognito"])
|
||||||
except Exception:
|
ctx = browser.new_context()
|
||||||
continue
|
if cookies_str:
|
||||||
elif platform == "darwin":
|
ctx.add_cookies(_parse_cookie_str(cookies_str, "chatgpt.com"))
|
||||||
subprocess.Popen(
|
page = ctx.new_page()
|
||||||
["open", "-a", "Google Chrome", "--args", "--incognito", url]
|
page.goto(url)
|
||||||
)
|
# 保持窗口打开直到用户关闭
|
||||||
return True
|
page.wait_for_timeout(300_000) # 最多等待 5 分钟
|
||||||
else:
|
except Exception as e:
|
||||||
for binary in ["google-chrome", "chromium-browser", "chromium"]:
|
logger.warning(f"Playwright 无痕打开失败: {e}")
|
||||||
try:
|
|
||||||
subprocess.Popen([binary, "--incognito", url])
|
threading.Thread(target=_launch, daemon=True).start()
|
||||||
return True
|
return True
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"无痕打开浏览器失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str:
|
def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str:
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class Account(Base):
|
|||||||
source = Column(String(20), default='register') # 'register' 或 'login',区分账号来源
|
source = Column(String(20), default='register') # 'register' 或 'login',区分账号来源
|
||||||
subscription_type = Column(String(20)) # None / 'plus' / 'team'
|
subscription_type = Column(String(20)) # None / 'plus' / 'team'
|
||||||
subscription_at = Column(DateTime) # 订阅开通时间
|
subscription_at = Column(DateTime) # 订阅开通时间
|
||||||
|
cookies = Column(Text) # 完整 cookie 字符串,用于支付请求
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class DatabaseSessionManager:
|
|||||||
("accounts", "source", "VARCHAR(20) DEFAULT 'register'"),
|
("accounts", "source", "VARCHAR(20) DEFAULT 'register'"),
|
||||||
("accounts", "subscription_type", "VARCHAR(20)"),
|
("accounts", "subscription_type", "VARCHAR(20)"),
|
||||||
("accounts", "subscription_at", "DATETIME"),
|
("accounts", "subscription_at", "DATETIME"),
|
||||||
|
("accounts", "cookies", "TEXT"),
|
||||||
]
|
]
|
||||||
|
|
||||||
with self.engine.connect() as conn:
|
with self.engine.connect() as conn:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class AccountResponse(BaseModel):
|
|||||||
proxy_used: Optional[str] = None
|
proxy_used: Optional[str] = None
|
||||||
cpa_uploaded: bool = False
|
cpa_uploaded: bool = False
|
||||||
cpa_uploaded_at: Optional[str] = None
|
cpa_uploaded_at: Optional[str] = None
|
||||||
|
cookies: Optional[str] = None
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
updated_at: Optional[str] = None
|
updated_at: Optional[str] = None
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ class AccountUpdateRequest(BaseModel):
|
|||||||
"""账号更新请求"""
|
"""账号更新请求"""
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
metadata: Optional[dict] = None
|
metadata: Optional[dict] = None
|
||||||
|
cookies: Optional[str] = None # 完整 cookie 字符串,用于支付请求
|
||||||
|
|
||||||
|
|
||||||
class BatchDeleteRequest(BaseModel):
|
class BatchDeleteRequest(BaseModel):
|
||||||
@@ -116,6 +118,7 @@ def account_to_response(account: Account) -> AccountResponse:
|
|||||||
proxy_used=account.proxy_used,
|
proxy_used=account.proxy_used,
|
||||||
cpa_uploaded=account.cpa_uploaded or False,
|
cpa_uploaded=account.cpa_uploaded or False,
|
||||||
cpa_uploaded_at=account.cpa_uploaded_at.isoformat() if account.cpa_uploaded_at else None,
|
cpa_uploaded_at=account.cpa_uploaded_at.isoformat() if account.cpa_uploaded_at else None,
|
||||||
|
cookies=account.cookies,
|
||||||
created_at=account.created_at.isoformat() if account.created_at else None,
|
created_at=account.created_at.isoformat() if account.created_at else None,
|
||||||
updated_at=account.updated_at.isoformat() if account.updated_at else None,
|
updated_at=account.updated_at.isoformat() if account.updated_at else None,
|
||||||
)
|
)
|
||||||
@@ -216,10 +219,24 @@ async def update_account(account_id: int, request: AccountUpdateRequest):
|
|||||||
current_metadata.update(request.metadata)
|
current_metadata.update(request.metadata)
|
||||||
update_data["metadata"] = current_metadata
|
update_data["metadata"] = current_metadata
|
||||||
|
|
||||||
|
if request.cookies is not None:
|
||||||
|
# 留空则清空,非空则更新
|
||||||
|
update_data["cookies"] = request.cookies or None
|
||||||
|
|
||||||
account = crud.update_account(db, account_id, **update_data)
|
account = crud.update_account(db, account_id, **update_data)
|
||||||
return account_to_response(account)
|
return account_to_response(account)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{account_id}/cookies")
|
||||||
|
async def get_account_cookies(account_id: int):
|
||||||
|
"""获取账号的 cookie 字符串(仅供支付使用)"""
|
||||||
|
with get_db() as db:
|
||||||
|
account = crud.get_account_by_id(db, account_id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(status_code=404, detail="账号不存在")
|
||||||
|
return {"account_id": account_id, "cookies": account.cookies or ""}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{account_id}")
|
@router.delete("/{account_id}")
|
||||||
async def delete_account(account_id: int):
|
async def delete_account(account_id: int):
|
||||||
"""删除单个账号"""
|
"""删除单个账号"""
|
||||||
|
|||||||
@@ -38,10 +38,12 @@ class GenerateLinkRequest(BaseModel):
|
|||||||
seat_quantity: int = 5
|
seat_quantity: int = 5
|
||||||
proxy: Optional[str] = None
|
proxy: Optional[str] = None
|
||||||
auto_open: bool = False # 生成后是否自动无痕打开
|
auto_open: bool = False # 生成后是否自动无痕打开
|
||||||
|
country: str = "SG" # 计费国家,决定货币 # 生成后是否自动无痕打开
|
||||||
|
|
||||||
|
|
||||||
class OpenIncognitoRequest(BaseModel):
|
class OpenIncognitoRequest(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
|
account_id: Optional[int] = None # 可选,用于注入账号 cookie
|
||||||
|
|
||||||
|
|
||||||
class MarkSubscriptionRequest(BaseModel):
|
class MarkSubscriptionRequest(BaseModel):
|
||||||
@@ -83,7 +85,7 @@ def generate_payment_link(request: GenerateLinkRequest):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if request.plan_type == "plus":
|
if request.plan_type == "plus":
|
||||||
link = generate_plus_link(account, proxy)
|
link = generate_plus_link(account, proxy, country=request.country)
|
||||||
elif request.plan_type == "team":
|
elif request.plan_type == "team":
|
||||||
link = generate_team_link(
|
link = generate_team_link(
|
||||||
account,
|
account,
|
||||||
@@ -91,6 +93,7 @@ def generate_payment_link(request: GenerateLinkRequest):
|
|||||||
price_interval=request.price_interval,
|
price_interval=request.price_interval,
|
||||||
seat_quantity=request.seat_quantity,
|
seat_quantity=request.seat_quantity,
|
||||||
proxy=proxy,
|
proxy=proxy,
|
||||||
|
country=request.country,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="plan_type 必须为 plus 或 team")
|
raise HTTPException(status_code=400, detail="plan_type 必须为 plus 或 team")
|
||||||
@@ -102,7 +105,8 @@ def generate_payment_link(request: GenerateLinkRequest):
|
|||||||
|
|
||||||
opened = False
|
opened = False
|
||||||
if request.auto_open and link:
|
if request.auto_open and link:
|
||||||
opened = open_url_incognito(link)
|
cookies_str = account.cookies if account else None
|
||||||
|
opened = open_url_incognito(link, cookies_str)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -114,13 +118,21 @@ def generate_payment_link(request: GenerateLinkRequest):
|
|||||||
|
|
||||||
@router.post("/open-incognito")
|
@router.post("/open-incognito")
|
||||||
def open_browser_incognito(request: OpenIncognitoRequest):
|
def open_browser_incognito(request: OpenIncognitoRequest):
|
||||||
"""后端命令行以无痕模式打开指定 URL"""
|
"""后端以无痕模式打开指定 URL,可注入账号 cookie"""
|
||||||
if not request.url:
|
if not request.url:
|
||||||
raise HTTPException(status_code=400, detail="URL 不能为空")
|
raise HTTPException(status_code=400, detail="URL 不能为空")
|
||||||
success = open_url_incognito(request.url)
|
|
||||||
|
cookies_str = None
|
||||||
|
if request.account_id:
|
||||||
|
with get_db() as db:
|
||||||
|
account = db.query(Account).filter(Account.id == request.account_id).first()
|
||||||
|
if account:
|
||||||
|
cookies_str = account.cookies
|
||||||
|
|
||||||
|
success = open_url_incognito(request.url, cookies_str)
|
||||||
if success:
|
if success:
|
||||||
return {"success": True, "message": "已在无痕模式打开浏览器"}
|
return {"success": True, "message": "已在无痕模式打开浏览器"}
|
||||||
return {"success": False, "message": "未找到可用的 Chrome/Edge,请手动复制链接"}
|
return {"success": False, "message": "未找到可用的浏览器,请手动复制链接"}
|
||||||
|
|
||||||
|
|
||||||
# ============== 订阅状态 ==============
|
# ============== 订阅状态 ==============
|
||||||
|
|||||||
@@ -613,6 +613,17 @@ async function viewAccount(id) {
|
|||||||
${tokens.refresh_token ? `<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('${escapeHtml(tokens.refresh_token)}')" style="margin-left: 8px;">📋</button>` : ''}
|
${tokens.refresh_token ? `<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('${escapeHtml(tokens.refresh_token)}')" style="margin-left: 8px;">📋</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info-item" style="grid-column: span 2;">
|
||||||
|
<span class="label">Cookies(支付用)</span>
|
||||||
|
<div class="value">
|
||||||
|
<textarea id="cookies-input-${id}" rows="3"
|
||||||
|
style="width:100%;font-size:0.7rem;font-family:var(--font-mono);background:var(--surface-hover);border:1px solid var(--border);border-radius:4px;padding:6px;color:var(--text-primary);resize:vertical;"
|
||||||
|
placeholder="粘贴完整 cookie 字符串,留空则清除">${escapeHtml(account.cookies || '')}</textarea>
|
||||||
|
<button class="btn btn-secondary btn-sm" style="margin-top:4px" onclick="saveCookies(${id})">
|
||||||
|
保存 Cookies
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: var(--spacing-lg); display: flex; gap: var(--spacing-sm);">
|
<div style="margin-top: var(--spacing-lg); display: flex; gap: var(--spacing-sm);">
|
||||||
<button class="btn btn-primary" onclick="refreshToken(${id}); elements.detailModal.classList.remove('active');">
|
<button class="btn btn-primary" onclick="refreshToken(${id}); elements.detailModal.classList.remove('active');">
|
||||||
@@ -861,3 +872,16 @@ async function handleBatchUploadTm() {
|
|||||||
updateBatchButtons();
|
updateBatchButtons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存账号 Cookies
|
||||||
|
async function saveCookies(id) {
|
||||||
|
const textarea = document.getElementById(`cookies-input-${id}`);
|
||||||
|
if (!textarea) return;
|
||||||
|
const cookiesValue = textarea.value.trim();
|
||||||
|
try {
|
||||||
|
await api.patch(`/accounts/${id}`, { cookies: cookiesValue });
|
||||||
|
toast.success('Cookies 已保存');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('保存 Cookies 失败: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
* 支付页面 JavaScript
|
* 支付页面 JavaScript
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const COUNTRY_CURRENCY_MAP = {
|
||||||
|
SG: 'SGD', US: 'USD', TR: 'TRY', JP: 'JPY',
|
||||||
|
HK: 'HKD', GB: 'GBP', EU: 'EUR', AU: 'AUD',
|
||||||
|
CA: 'CAD', IN: 'INR', BR: 'BRL', MX: 'MXN',
|
||||||
|
};
|
||||||
|
|
||||||
let selectedPlan = 'plus';
|
let selectedPlan = 'plus';
|
||||||
let generatedLink = '';
|
let generatedLink = '';
|
||||||
|
|
||||||
@@ -28,6 +34,13 @@ async function loadAccounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 国家切换
|
||||||
|
function onCountryChange() {
|
||||||
|
const country = document.getElementById('country-select').value;
|
||||||
|
const currency = COUNTRY_CURRENCY_MAP[country] || 'USD';
|
||||||
|
document.getElementById('currency-display').value = currency;
|
||||||
|
}
|
||||||
|
|
||||||
// 选择套餐
|
// 选择套餐
|
||||||
function selectPlan(plan) {
|
function selectPlan(plan) {
|
||||||
selectedPlan = plan;
|
selectedPlan = plan;
|
||||||
@@ -47,9 +60,12 @@ async function generateLink() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const country = document.getElementById('country-select').value || 'SG';
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
account_id: parseInt(accountId),
|
account_id: parseInt(accountId),
|
||||||
plan_type: selectedPlan,
|
plan_type: selectedPlan,
|
||||||
|
country: country,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (selectedPlan === 'team') {
|
if (selectedPlan === 'team') {
|
||||||
@@ -58,6 +74,9 @@ async function generateLink() {
|
|||||||
body.price_interval = document.getElementById('price-interval').value;
|
body.price_interval = document.getElementById('price-interval').value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const btn = document.querySelector('.form-actions .btn-primary');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = '生成中...'; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/payment/generate-link', {
|
const resp = await fetch('/api/payment/generate-link', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -76,6 +95,8 @@ async function generateLink() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.showToast('请求失败: ' + e.message, 'error');
|
ui.showToast('请求失败: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '生成支付链接'; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +106,6 @@ function copyLink() {
|
|||||||
navigator.clipboard.writeText(generatedLink).then(() => {
|
navigator.clipboard.writeText(generatedLink).then(() => {
|
||||||
ui.showToast('已复制到剪贴板', 'success');
|
ui.showToast('已复制到剪贴板', 'success');
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// 降级方案
|
|
||||||
const ta = document.getElementById('link-text');
|
const ta = document.getElementById('link-text');
|
||||||
ta.select();
|
ta.select();
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
@@ -93,19 +113,23 @@ function copyLink() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无痕打开浏览器
|
// 无痕打开浏览器(携带账号 cookie)
|
||||||
async function openIncognito() {
|
async function openIncognito() {
|
||||||
if (!generatedLink) {
|
if (!generatedLink) {
|
||||||
ui.showToast('请先生成链接', 'warning');
|
ui.showToast('请先生成链接', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const accountId = document.getElementById('account-select').value;
|
||||||
const statusEl = document.getElementById('open-status');
|
const statusEl = document.getElementById('open-status');
|
||||||
statusEl.textContent = '正在打开...';
|
statusEl.textContent = '正在打开...';
|
||||||
try {
|
try {
|
||||||
|
const body = { url: generatedLink };
|
||||||
|
if (accountId) body.account_id = parseInt(accountId);
|
||||||
|
|
||||||
const resp = await fetch('/api/payment/open-incognito', {
|
const resp = await fetch('/api/payment/open-incognito', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url: generatedLink }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
|||||||
@@ -100,6 +100,30 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 国家选择 -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="country-select">计费国家</label>
|
||||||
|
<select id="country-select" onchange="onCountryChange()">
|
||||||
|
<option value="SG">新加坡 (SGD)</option>
|
||||||
|
<option value="US">美国 (USD)</option>
|
||||||
|
<option value="TR">土耳其 (TRY)</option>
|
||||||
|
<option value="JP">日本 (JPY)</option>
|
||||||
|
<option value="HK">香港 (HKD)</option>
|
||||||
|
<option value="GB">英国 (GBP)</option>
|
||||||
|
<option value="AU">澳大利亚 (AUD)</option>
|
||||||
|
<option value="CA">加拿大 (CAD)</option>
|
||||||
|
<option value="IN">印度 (INR)</option>
|
||||||
|
<option value="BR">巴西 (BRL)</option>
|
||||||
|
<option value="MX">墨西哥 (MXN)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>对应货币</label>
|
||||||
|
<input type="text" id="currency-display" value="SGD" readonly style="background:var(--surface-hover);cursor:default">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Team 额外参数 -->
|
<!-- Team 额外参数 -->
|
||||||
<div class="team-options" id="team-options">
|
<div class="team-options" id="team-options">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
Reference in New Issue
Block a user