fix:优化当前的项目
This commit is contained in:
35
.env.example
35
.env.example
@@ -43,3 +43,38 @@ AUTH_SESSION_TTL_SEC=86400
|
||||
AUTH_REMEMBER_SESSION_TTL_SEC=604800
|
||||
# 忘记密码重置码(建议自定义;为空时将使用默认值 x2ws-reset-2026)
|
||||
AUTH_PASSWORD_RESET_KEY=x2ws-reset-2026
|
||||
|
||||
# --- VIP 平台模型配置(用户开启 VIP 后优先使用)---
|
||||
# 平台文本模型
|
||||
PLATFORM_OPENAI_API_KEY=
|
||||
# PLATFORM_OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
PLATFORM_OPENAI_MODEL=qwen-plus
|
||||
# 平台生图模型
|
||||
PLATFORM_OPENAI_IMAGE_MODEL=wanx2.0-t2i-turbo
|
||||
PLATFORM_OPENAI_TIMEOUT=120
|
||||
PLATFORM_OPENAI_MAX_OUTPUT_TOKENS=8192
|
||||
PLATFORM_OPENAI_MAX_RETRIES=0
|
||||
# 新用户免费试用 Credits
|
||||
VIP_TRIAL_TOKENS=500
|
||||
# 标准坐席每月额度
|
||||
CREDITS_SEAT_MONTHLY_QUOTA=1500
|
||||
# 文本计费:100万 token 的人民币价格
|
||||
CREDITS_TOKEN_PRICE_PER_MILLION_CNY=7.9
|
||||
# 图片计费:160 张图 = 0.75 元
|
||||
CREDITS_IMAGE_PRICE_PACKAGE_CNY=0.75
|
||||
CREDITS_IMAGE_PRICE_PACKAGE_IMAGES=160
|
||||
# 兼容字段(旧版):单张图人民币价格
|
||||
CREDITS_IMAGE_PRICE_PER_IMAGE_CNY=0.04
|
||||
# 兼容字段(旧版):可保留默认,不再作为首选换算规则
|
||||
CREDITS_PER_MILLION_TOKENS=1500
|
||||
CREDITS_PER_120_IMAGES=1500
|
||||
# 标准加油包:19.9 元 = 1500 Credits
|
||||
CREDITS_RECHARGE_PACKAGE_AMOUNT=19.9
|
||||
CREDITS_RECHARGE_PACKAGE_CREDITS=1500
|
||||
|
||||
# 购物系统打通(可选)
|
||||
# SHOP_BACKEND_CREATE_ORDER_URL=https://shop.example.com/api/order/create
|
||||
# 微信支付回调路径(本项目已提供):
|
||||
# 下单入口:POST https://你的域名/api/pay/wechat/
|
||||
# 回调入口:POST https://你的域名/api/pay/wechat/backcall
|
||||
SHOP_BACKEND_CALLBACK_TOKEN=
|
||||
|
||||
@@ -75,27 +75,87 @@ class Settings(BaseSettings):
|
||||
auth_password_reset_key: str | None = Field(default="x2ws-reset-2026", alias="AUTH_PASSWORD_RESET_KEY")
|
||||
|
||||
vip_trial_tokens: int = Field(
|
||||
default=20000,
|
||||
default=500,
|
||||
alias="VIP_TRIAL_TOKENS",
|
||||
description="新用户试用赠送 token",
|
||||
description="新用户试用赠送 Credits",
|
||||
)
|
||||
credits_seat_monthly_quota: int = Field(
|
||||
default=1500,
|
||||
alias="CREDITS_SEAT_MONTHLY_QUOTA",
|
||||
description="标准坐席每月 Credits 额度",
|
||||
)
|
||||
credits_standard_seat_price_cny: float = Field(
|
||||
default=198.0,
|
||||
alias="CREDITS_STANDARD_SEAT_PRICE_CNY",
|
||||
description="标准坐席月费(人民币)",
|
||||
)
|
||||
vip_rewrite_token_per_1k_chars: int = Field(
|
||||
default=1200,
|
||||
default=1500,
|
||||
alias="VIP_REWRITE_TOKEN_PER_1K_CHARS",
|
||||
description="改写按千字计费 token 单价",
|
||||
description="兼容字段:改写计费参数(建议使用 Credits 规则字段)",
|
||||
)
|
||||
vip_image_token_per_image: int = Field(
|
||||
default=1800,
|
||||
default=1500,
|
||||
alias="VIP_IMAGE_TOKEN_PER_IMAGE",
|
||||
description="文生图单张扣减 token",
|
||||
description="兼容字段:生图计费参数(建议使用 Credits 规则字段)",
|
||||
)
|
||||
credits_per_million_tokens: int = Field(
|
||||
default=1500,
|
||||
alias="CREDITS_PER_MILLION_TOKENS",
|
||||
description="兼容字段:100万 token 对应的 Credits 抵扣(建议使用人民币折算字段)",
|
||||
)
|
||||
credits_per_120_images: int = Field(
|
||||
default=1500,
|
||||
alias="CREDITS_PER_120_IMAGES",
|
||||
description="兼容字段:120 张图片对应的 Credits 抵扣(建议使用人民币折算字段)",
|
||||
)
|
||||
credits_token_price_per_million_cny: float = Field(
|
||||
default=7.9,
|
||||
alias="CREDITS_TOKEN_PRICE_PER_MILLION_CNY",
|
||||
description="文本计费:100万 token 的人民币价格",
|
||||
)
|
||||
credits_image_price_per_image_cny: float = Field(
|
||||
default=0.04,
|
||||
alias="CREDITS_IMAGE_PRICE_PER_IMAGE_CNY",
|
||||
description="兼容字段:生图计费单张价格(建议使用整包折算字段)",
|
||||
)
|
||||
credits_image_price_package_cny: float = Field(
|
||||
default=0.75,
|
||||
alias="CREDITS_IMAGE_PRICE_PACKAGE_CNY",
|
||||
description="生图计费:图片整包人民币价格",
|
||||
)
|
||||
credits_image_price_package_images: int = Field(
|
||||
default=160,
|
||||
alias="CREDITS_IMAGE_PRICE_PACKAGE_IMAGES",
|
||||
description="生图计费:整包包含图片张数",
|
||||
)
|
||||
credits_recharge_package_amount: float = Field(
|
||||
default=19.9,
|
||||
alias="CREDITS_RECHARGE_PACKAGE_AMOUNT",
|
||||
description="标准加油包价格(元)",
|
||||
)
|
||||
credits_recharge_package_credits: int = Field(
|
||||
default=1500,
|
||||
alias="CREDITS_RECHARGE_PACKAGE_CREDITS",
|
||||
description="标准加油包 Credits 数量",
|
||||
)
|
||||
platform_openai_api_key: str | None = Field(default=None, alias="PLATFORM_OPENAI_API_KEY")
|
||||
platform_openai_base_url: str | None = Field(default=None, alias="PLATFORM_OPENAI_BASE_URL")
|
||||
platform_openai_model: str = Field(default="gpt-4.1-mini", alias="PLATFORM_OPENAI_MODEL")
|
||||
platform_openai_image_model: str = Field(default="gpt-image-1", alias="PLATFORM_OPENAI_IMAGE_MODEL")
|
||||
platform_openai_model: str = Field(default="qwen-plus", alias="PLATFORM_OPENAI_MODEL")
|
||||
platform_openai_image_model: str = Field(default="wanx2.0-t2i-turbo", alias="PLATFORM_OPENAI_IMAGE_MODEL")
|
||||
platform_openai_text_model_options: str = Field(
|
||||
default="gpt-4.1-mini,gpt-4.1,gpt-4o-mini,qwen-plus,qwen-max",
|
||||
alias="PLATFORM_OPENAI_TEXT_MODEL_OPTIONS",
|
||||
)
|
||||
platform_openai_image_model_options: str = Field(
|
||||
default="wanx2.0-t2i-turbo,wanx2.1-t2i-plus,wanx2.1-t2i-turbo,gpt-image-1,dall-e-3",
|
||||
alias="PLATFORM_OPENAI_IMAGE_MODEL_OPTIONS",
|
||||
)
|
||||
platform_openai_timeout: float = Field(default=120.0, alias="PLATFORM_OPENAI_TIMEOUT")
|
||||
platform_openai_max_output_tokens: int = Field(default=8192, alias="PLATFORM_OPENAI_MAX_OUTPUT_TOKENS")
|
||||
platform_openai_max_retries: int = Field(default=0, alias="PLATFORM_OPENAI_MAX_RETRIES")
|
||||
shop_backend_create_order_url: str | None = Field(default=None, alias="SHOP_BACKEND_CREATE_ORDER_URL")
|
||||
shop_backend_callback_token: str = Field(default="", alias="SHOP_BACKEND_CALLBACK_TOKEN")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
619
app/main.py
619
app/main.py
@@ -1,6 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -16,8 +21,12 @@ from app.middleware import RequestContextMiddleware
|
||||
from app.schemas import (
|
||||
AIModelCreateRequest,
|
||||
AIModelDeleteRequest,
|
||||
AIImageModelUpdateRequest,
|
||||
AIModelSwitchRequest,
|
||||
AuthCredentialRequest,
|
||||
BillingRechargeCreateRequest,
|
||||
BillingPayNowRequest,
|
||||
BillingRechargeNotifyRequest,
|
||||
ChangePasswordRequest,
|
||||
DeleteAccountRequest,
|
||||
ForgotPasswordResetRequest,
|
||||
@@ -30,6 +39,7 @@ from app.schemas import (
|
||||
WechatBindingRequest,
|
||||
WechatPublishRequest,
|
||||
WechatSwitchRequest,
|
||||
UserProfileUpdateRequest,
|
||||
VipRechargeRequest,
|
||||
VipToggleRequest,
|
||||
)
|
||||
@@ -64,6 +74,11 @@ wechat = WechatPublisher()
|
||||
poster_material = PosterMaterialService(wechat)
|
||||
im = IMPublisher()
|
||||
users = UserStore(settings.auth_db_path)
|
||||
_register_rate: dict[str, list[float]] = {}
|
||||
_login_rate: dict[str, list[float]] = {}
|
||||
_challenge_pool: dict[str, dict] = {}
|
||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{4,24}$")
|
||||
PASSWORD_STRONG_RE = re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])\S{10,64}$")
|
||||
|
||||
|
||||
def _session_ttl(remember_me: bool) -> int:
|
||||
@@ -84,6 +99,67 @@ def _require_user(request: Request) -> dict | None:
|
||||
return u
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
xff = (request.headers.get("x-forwarded-for") or "").strip()
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return (request.client.host if request.client else "") or "unknown"
|
||||
|
||||
|
||||
def _hit_limit(bucket: dict[str, list[float]], key: str, limit: int, window_sec: int) -> bool:
|
||||
now = time.time()
|
||||
points = bucket.get(key, [])
|
||||
points = [p for p in points if now - p <= window_sec]
|
||||
points.append(now)
|
||||
bucket[key] = points
|
||||
return len(points) > limit
|
||||
|
||||
|
||||
def _create_challenge() -> dict:
|
||||
a = secrets.randbelow(8) + 2
|
||||
b = secrets.randbelow(9) + 1
|
||||
op = "+" if secrets.randbelow(2) == 0 else "-"
|
||||
if op == "-":
|
||||
a, b = max(a, b) + 5, min(a, b)
|
||||
answer = str(a + b) if op == "+" else str(a - b)
|
||||
cid = secrets.token_urlsafe(16)
|
||||
_challenge_pool[cid] = {"answer": answer, "created_at": time.time(), "used": False}
|
||||
return {"challenge_id": cid, "question": f"{a} {op} {b} = ?"}
|
||||
|
||||
|
||||
def _verify_challenge(req: AuthCredentialRequest) -> tuple[bool, str]:
|
||||
cid = (req.challenge_id or "").strip()
|
||||
ans = (req.challenge_answer or "").strip()
|
||||
if req.honeypot:
|
||||
return False, "请求被拒绝"
|
||||
if not cid or not ans:
|
||||
return False, "请先完成人机校验"
|
||||
item = _challenge_pool.get(cid)
|
||||
if not item:
|
||||
return False, "校验已失效,请刷新后重试"
|
||||
if item.get("used"):
|
||||
return False, "校验已使用,请刷新后重试"
|
||||
age = time.time() - float(item.get("created_at") or 0)
|
||||
if age < 2:
|
||||
return False, "提交过快,请稍后重试"
|
||||
if age > 300:
|
||||
return False, "校验已过期,请刷新后重试"
|
||||
if ans != str(item.get("answer") or ""):
|
||||
return False, "人机校验答案错误"
|
||||
item["used"] = True
|
||||
return True, ""
|
||||
|
||||
|
||||
def _validate_username_password(username: str, password: str) -> tuple[bool, str]:
|
||||
if not USERNAME_RE.match(username):
|
||||
return False, "用户名需为 4-24 位,仅支持字母/数字/下划线"
|
||||
if not PASSWORD_STRONG_RE.match(password):
|
||||
return False, "密码需 10-64 位,且包含大小写字母、数字和特殊字符"
|
||||
if username.lower() in password.lower():
|
||||
return False, "密码不能包含用户名"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _platform_model_cfg() -> dict:
|
||||
return {
|
||||
"api_key": settings.platform_openai_api_key or "",
|
||||
@@ -99,7 +175,9 @@ def _platform_model_cfg() -> dict:
|
||||
|
||||
def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | None, str]:
|
||||
vip = users.get_vip_status(user_id)
|
||||
if prefer_vip and vip.get("vip_enabled") and int(vip.get("token_balance") or 0) > 0:
|
||||
if prefer_vip and vip.get("vip_enabled"):
|
||||
if int(vip.get("total_available_credits") or 0) <= 0:
|
||||
return None, "vip_empty"
|
||||
cfg = _platform_model_cfg()
|
||||
if cfg.get("api_key"):
|
||||
return cfg, "vip"
|
||||
@@ -107,12 +185,114 @@ def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | Non
|
||||
return cfg, "user"
|
||||
|
||||
|
||||
def _quota_detail() -> str:
|
||||
return "Credits 额度已用完(席位额度+共享加油包),请充值或等待下个计费周期"
|
||||
|
||||
|
||||
def _credits_from_cny(amount_cny: float) -> int:
|
||||
package_credits = max(1, int(settings.credits_recharge_package_credits))
|
||||
package_amount = max(0.01, float(settings.credits_recharge_package_amount))
|
||||
cny = max(0.0, float(amount_cny))
|
||||
if cny <= 0:
|
||||
return 0
|
||||
return max(1, int(math.ceil((cny * package_credits) / package_amount)))
|
||||
|
||||
|
||||
def _estimate_rewrite_cost(req: RewriteRequest, result) -> int:
|
||||
trace = getattr(result, "trace", None) or {}
|
||||
usage = trace.get("usage") if isinstance(trace, dict) else {}
|
||||
usage_total_tokens = int((usage or {}).get("total_tokens") or 0)
|
||||
if usage_total_tokens > 0:
|
||||
token_price_cny = max(0.0, float(settings.credits_token_price_per_million_cny))
|
||||
return _credits_from_cny((usage_total_tokens / 1_000_000.0) * token_price_cny)
|
||||
src_chars = len((req.source_text or "").strip())
|
||||
out_chars = len((result.body_markdown or "").strip()) + len((result.title or "").strip()) + len((result.summary or "").strip())
|
||||
total_chars = max(1, src_chars + out_chars)
|
||||
blocks = (total_chars + 999) // 1000
|
||||
return int(blocks * max(1, int(settings.vip_rewrite_token_per_1k_chars)))
|
||||
estimated_tokens = int(total_chars * 1.8)
|
||||
token_price_cny = max(0.0, float(settings.credits_token_price_per_million_cny))
|
||||
return _credits_from_cny((max(1, estimated_tokens) / 1_000_000.0) * token_price_cny)
|
||||
|
||||
|
||||
def _estimate_image_cost(image_count: int) -> int:
|
||||
cnt = max(0, int(image_count))
|
||||
if cnt <= 0:
|
||||
return 0
|
||||
pkg_images = max(1, int(settings.credits_image_price_package_images or 160))
|
||||
pkg_cny = max(0.0, float(settings.credits_image_price_package_cny or 0.75))
|
||||
return _credits_from_cny((cnt / pkg_images) * pkg_cny)
|
||||
|
||||
|
||||
def _new_order_no() -> str:
|
||||
return f"RC{time.strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:10].upper()}"
|
||||
|
||||
|
||||
async def _create_recharge_order_with_pay_url(
|
||||
user_id: int,
|
||||
username: str,
|
||||
credits: int,
|
||||
callback_url: str,
|
||||
order_meta: dict | None = None,
|
||||
channel: str = "wechat",
|
||||
) -> tuple[dict, str]:
|
||||
package_credits = max(1, int(settings.credits_recharge_package_credits))
|
||||
package_amount = float(settings.credits_recharge_package_amount)
|
||||
amount_cny = round((int(credits) / package_credits) * package_amount, 2)
|
||||
order_no = _new_order_no()
|
||||
order = users.create_recharge_order(
|
||||
user_id=user_id,
|
||||
order_no=order_no,
|
||||
channel=(channel or "wechat").strip() or "wechat",
|
||||
token_amount=int(credits),
|
||||
amount_cny=amount_cny,
|
||||
meta={"username": username, **(order_meta or {})},
|
||||
)
|
||||
pay_payload = {
|
||||
"order_no": order_no,
|
||||
"user_id": int(user_id),
|
||||
"username": username,
|
||||
"callback_url": callback_url,
|
||||
**order,
|
||||
}
|
||||
pay_url = ""
|
||||
if settings.shop_backend_create_order_url:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(
|
||||
settings.shop_backend_create_order_url,
|
||||
json=pay_payload,
|
||||
headers={"X-Shop-Token": settings.shop_backend_callback_token or ""},
|
||||
)
|
||||
body = r.json() if r.content else {}
|
||||
if r.status_code < 400 and isinstance(body, dict):
|
||||
pay_url = str(body.get("pay_url") or "")
|
||||
except Exception as exc:
|
||||
logger.warning("shop_create_order_failed order=%s err=%s", order_no, exc)
|
||||
return order, pay_url
|
||||
|
||||
|
||||
async def _fetch_pay_url_for_order(order: dict, user_id: int, username: str, callback_url: str) -> str:
|
||||
pay_payload = {
|
||||
"order_no": order.get("order_no", ""),
|
||||
"user_id": int(user_id),
|
||||
"username": username,
|
||||
"callback_url": callback_url,
|
||||
**order,
|
||||
}
|
||||
pay_url = ""
|
||||
if settings.shop_backend_create_order_url:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(
|
||||
settings.shop_backend_create_order_url,
|
||||
json=pay_payload,
|
||||
headers={"X-Shop-Token": settings.shop_backend_callback_token or ""},
|
||||
)
|
||||
body = r.json() if r.content else {}
|
||||
if r.status_code < 400 and isinstance(body, dict):
|
||||
pay_url = str(body.get("pay_url") or "")
|
||||
except Exception as exc:
|
||||
logger.warning("shop_repay_order_failed order=%s err=%s", order.get("order_no", ""), exc)
|
||||
return pay_url
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@@ -140,6 +320,48 @@ async def settings_page(request: Request):
|
||||
return templates.TemplateResponse("settings.html", {"request": request, "app_name": settings.app_name})
|
||||
|
||||
|
||||
@app.get("/billing", response_class=HTMLResponse)
|
||||
async def billing_page(request: Request):
|
||||
if not _current_user(request):
|
||||
return RedirectResponse(url="/auth?next=/billing", status_code=302)
|
||||
return templates.TemplateResponse(
|
||||
"billing.html",
|
||||
{
|
||||
"request": request,
|
||||
"app_name": settings.app_name,
|
||||
"package_amount": settings.credits_recharge_package_amount,
|
||||
"package_credits": settings.credits_recharge_package_credits,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/upgrade", response_class=HTMLResponse)
|
||||
async def upgrade_page(request: Request):
|
||||
if not _current_user(request):
|
||||
return RedirectResponse(url="/auth?next=/upgrade", status_code=302)
|
||||
return templates.TemplateResponse(
|
||||
"upgrade.html",
|
||||
{
|
||||
"request": request,
|
||||
"app_name": settings.app_name,
|
||||
"trial_tokens": settings.vip_trial_tokens,
|
||||
"rewrite_cost": settings.credits_per_million_tokens,
|
||||
"image_cost": settings.credits_per_120_images,
|
||||
"seat_price": settings.credits_standard_seat_price_cny,
|
||||
"seat_quota": settings.credits_seat_monthly_quota,
|
||||
"package_amount": settings.credits_recharge_package_amount,
|
||||
"package_credits": settings.credits_recharge_package_credits,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/profile", response_class=HTMLResponse)
|
||||
async def profile_page(request: Request):
|
||||
if not _current_user(request):
|
||||
return RedirectResponse(url="/auth?next=/profile", status_code=302)
|
||||
return templates.TemplateResponse("profile.html", {"request": request, "app_name": settings.app_name})
|
||||
|
||||
|
||||
@app.get("/guide", response_class=HTMLResponse)
|
||||
async def guide_page(request: Request):
|
||||
if not _current_user(request):
|
||||
@@ -197,14 +419,48 @@ async def auth_me(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/profile")
|
||||
async def api_profile(request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
return {"ok": True, "profile": users.get_user_profile(user["id"])}
|
||||
|
||||
|
||||
@app.post("/api/profile")
|
||||
async def api_profile_update(req: UserProfileUpdateRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
profile = users.save_user_profile(
|
||||
user["id"],
|
||||
subscriber_name=(req.subscriber_name or "").strip(),
|
||||
subscriber_phone=(req.subscriber_phone or "").strip(),
|
||||
shipping_address=(req.shipping_address or "").strip(),
|
||||
)
|
||||
return {"ok": True, "detail": "个人信息已保存", "profile": profile}
|
||||
|
||||
|
||||
@app.get("/api/auth/challenge")
|
||||
async def auth_challenge():
|
||||
return {"ok": True, **_create_challenge()}
|
||||
|
||||
|
||||
@app.post("/api/auth/register")
|
||||
async def auth_register(req: AuthCredentialRequest, response: Response):
|
||||
async def auth_register(req: AuthCredentialRequest, request: Request, response: Response):
|
||||
ip = _client_ip(request)
|
||||
if _hit_limit(_register_rate, f"ip:{ip}", limit=8, window_sec=600):
|
||||
return {"ok": False, "detail": "请求过于频繁,请稍后再试"}
|
||||
if _hit_limit(_register_rate, f"user:{(req.username or '').strip().lower()}", limit=6, window_sec=600):
|
||||
return {"ok": False, "detail": "该用户名操作过于频繁,请稍后再试"}
|
||||
username = (req.username or "").strip()
|
||||
password = req.password or ""
|
||||
if len(username) < 2:
|
||||
return {"ok": False, "detail": "用户名至少 2 个字符"}
|
||||
if len(password) < 6:
|
||||
return {"ok": False, "detail": "密码至少 6 个字符"}
|
||||
ok, msg = _validate_username_password(username, password)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": msg}
|
||||
ok_challenge, challenge_msg = _verify_challenge(req)
|
||||
if not ok_challenge:
|
||||
return {"ok": False, "detail": challenge_msg}
|
||||
try:
|
||||
user = users.create_user(username, password)
|
||||
except Exception as exc:
|
||||
@@ -225,14 +481,19 @@ async def auth_register(req: AuthCredentialRequest, response: Response):
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": "注册并登录成功,已赠送试用 token,请保存重置码",
|
||||
"detail": "注册并登录成功,已赠送试用 Credits,请保存重置码",
|
||||
"user": {"id": user["id"], "username": user["username"]},
|
||||
"reset_code": user.get("reset_code", ""),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_login(req: AuthCredentialRequest, response: Response):
|
||||
async def auth_login(req: AuthCredentialRequest, request: Request, response: Response):
|
||||
ip = _client_ip(request)
|
||||
if _hit_limit(_login_rate, f"ip:{ip}", limit=20, window_sec=600):
|
||||
return {"ok": False, "detail": "登录过于频繁,请稍后再试"}
|
||||
if _hit_limit(_login_rate, f"user:{(req.username or '').strip().lower()}", limit=12, window_sec=600):
|
||||
return {"ok": False, "detail": "该账户登录尝试过多,请稍后再试"}
|
||||
try:
|
||||
user = users.verify_user((req.username or "").strip(), req.password or "")
|
||||
except Exception as exc:
|
||||
@@ -335,6 +596,217 @@ async def auth_delete_account(req: DeleteAccountRequest, request: Request, respo
|
||||
return {"ok": True, "detail": "账号已注销,关联数据已清空"}
|
||||
|
||||
|
||||
@app.get("/api/auth/vip/status")
|
||||
async def auth_vip_status(request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
return {"ok": True, "vip": users.get_vip_status(user["id"])}
|
||||
|
||||
|
||||
@app.post("/api/auth/vip/toggle")
|
||||
async def auth_vip_toggle(req: VipToggleRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
vip = users.set_vip_enabled(user["id"], bool(req.enabled))
|
||||
return {"ok": True, "detail": "VIP 模式已更新", "vip": vip}
|
||||
|
||||
|
||||
@app.post("/api/auth/vip/recharge")
|
||||
async def auth_vip_recharge(req: VipRechargeRequest, request: Request):
|
||||
token = (request.headers.get("X-Shop-Token") or "").strip()
|
||||
if not settings.shop_backend_callback_token or token != settings.shop_backend_callback_token:
|
||||
raise HTTPException(status_code=403, detail="直充接口已禁用,请走支付订单接口")
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
vip = users.recharge_tokens(
|
||||
user["id"],
|
||||
int(req.tokens),
|
||||
kind="admin_direct_recharge",
|
||||
ref_type="admin",
|
||||
ref_id="",
|
||||
detail={"source": "legacy_api"},
|
||||
)
|
||||
return {"ok": True, "detail": "充值成功", "vip": vip}
|
||||
|
||||
|
||||
@app.get("/api/billing/overview")
|
||||
async def billing_overview(request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
return {
|
||||
"ok": True,
|
||||
"vip": users.get_vip_status(user["id"]),
|
||||
"recharge_records": users.list_recharge_orders(user["id"], limit=30),
|
||||
"consume_records": users.list_token_ledger(user["id"], limit=100),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/billing/recharge/create")
|
||||
async def billing_recharge_create(req: BillingRechargeCreateRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
order, pay_url = await _create_recharge_order_with_pay_url(
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
credits=int(req.tokens),
|
||||
callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall",
|
||||
order_meta={
|
||||
"subscriber_name": (req.subscriber_name or "").strip(),
|
||||
"subscriber_phone": (req.subscriber_phone or "").strip(),
|
||||
"shipping_address": (req.shipping_address or "").strip(),
|
||||
},
|
||||
channel=(req.channel or "wechat").strip() or "wechat",
|
||||
)
|
||||
return {"ok": True, "detail": "充值订单已创建", "order": order, "pay_url": pay_url}
|
||||
|
||||
|
||||
@app.post("/api/billing/recharge/pay-now")
|
||||
async def billing_recharge_pay_now(req: BillingPayNowRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
order_no = (req.order_no or "").strip()
|
||||
if not order_no:
|
||||
return {"ok": False, "detail": "订单号不能为空"}
|
||||
order = users.get_recharge_order(user["id"], order_no)
|
||||
if not order:
|
||||
return {"ok": False, "detail": "订单不存在"}
|
||||
status = (order.get("status") or "").lower()
|
||||
if status in {"paid", "success"}:
|
||||
return {"ok": False, "detail": "订单已支付,无需重复支付", "order": order}
|
||||
if status in {"cancelled", "closed"}:
|
||||
return {"ok": False, "detail": "订单已取消,请重新创建订单", "order": order}
|
||||
pay_url = await _fetch_pay_url_for_order(
|
||||
order=order,
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall",
|
||||
)
|
||||
return {"ok": True, "detail": "支付链接已生成", "order": order, "pay_url": pay_url}
|
||||
|
||||
|
||||
@app.get("/api/pay/wechat/")
|
||||
async def pay_wechat_ready():
|
||||
return {"ok": True, "detail": "wechat pay api ready"}
|
||||
|
||||
|
||||
@app.post("/api/pay/wechat/")
|
||||
async def pay_wechat_create(req: BillingRechargeCreateRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
order, pay_url = await _create_recharge_order_with_pay_url(
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
credits=int(req.tokens),
|
||||
callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall",
|
||||
order_meta={
|
||||
"subscriber_name": (req.subscriber_name or "").strip(),
|
||||
"subscriber_phone": (req.subscriber_phone or "").strip(),
|
||||
"shipping_address": (req.shipping_address or "").strip(),
|
||||
},
|
||||
channel=(req.channel or "wechat").strip() or "wechat",
|
||||
)
|
||||
return {"ok": True, "detail": "支付订单已创建", "order": order, "pay_url": pay_url}
|
||||
|
||||
|
||||
@app.get("/api/pay/address/suggest")
|
||||
async def pay_address_suggest(request: Request):
|
||||
ip = _client_ip(request)
|
||||
if not ip or ip in {"unknown", "127.0.0.1", "::1"}:
|
||||
return {"ok": True, "detail": "本地环境,建议手动填写地址", "ip": ip or "unknown", "address": ""}
|
||||
url = f"https://ip-api.com/json/{ip}?lang=zh-CN"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
r = await client.get(url)
|
||||
body = r.json() if r.content else {}
|
||||
if r.status_code >= 400 or not isinstance(body, dict):
|
||||
return {"ok": True, "detail": "IP地址解析失败,已填入IP信息,可手动修改", "ip": ip, "address": f"IP:{ip}"}
|
||||
if body.get("status") != "success":
|
||||
return {"ok": True, "detail": "IP地址解析失败,已填入IP信息,可手动修改", "ip": ip, "address": f"IP:{ip}"}
|
||||
country = str(body.get("country") or "").strip()
|
||||
region = str(body.get("regionName") or "").strip()
|
||||
city = str(body.get("city") or "").strip()
|
||||
isp = str(body.get("isp") or "").strip()
|
||||
addr = "".join([x for x in [country, region, city] if x])
|
||||
if isp:
|
||||
addr = f"{addr}({isp})" if addr else isp
|
||||
if not addr:
|
||||
addr = f"IP:{ip}"
|
||||
return {"ok": True, "detail": "已根据IP自动识别地址并填充,可手动修改", "ip": ip, "address": addr}
|
||||
except Exception:
|
||||
return {"ok": True, "detail": "IP地址解析失败,已填入IP信息,可手动修改", "ip": ip, "address": f"IP:{ip}"}
|
||||
|
||||
|
||||
@app.post("/api/billing/recharge/notify")
|
||||
async def billing_recharge_notify(req: BillingRechargeNotifyRequest, request: Request):
|
||||
token = (request.headers.get("X-Shop-Token") or "").strip()
|
||||
if not settings.shop_backend_callback_token or token != settings.shop_backend_callback_token:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
user = _require_user(request)
|
||||
uid = user["id"] if user else 0
|
||||
# 回调优先根据登录态用户确认;若无登录态,可通过订单所属用户在 DB 内判断
|
||||
if uid <= 0:
|
||||
# 无用户态时,先查订单归属(通过 mark 接口会二次校验)
|
||||
uid = -1
|
||||
if (req.status or "").lower() not in {"paid", "success"}:
|
||||
return {"ok": True, "detail": "ignored"}
|
||||
if uid > 0:
|
||||
ok, msg = users.mark_recharge_order_paid(
|
||||
uid,
|
||||
req.order_no,
|
||||
paid_amount_cny=float(req.paid_amount_cny or 0.0),
|
||||
external_txn_id=(req.external_txn_id or "").strip(),
|
||||
meta={"status": req.status},
|
||||
)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": msg}
|
||||
return {"ok": True, "detail": msg}
|
||||
# no session callback path: iterate known users is unavailable; use direct sql in store by probing order owner
|
||||
# fallback: let store resolve user by order internally via new helper-like behavior
|
||||
owner_id = users.get_recharge_order_user_id(req.order_no)
|
||||
if not owner_id:
|
||||
return {"ok": False, "detail": "订单不存在"}
|
||||
ok, msg = users.mark_recharge_order_paid(
|
||||
int(owner_id),
|
||||
req.order_no,
|
||||
paid_amount_cny=float(req.paid_amount_cny or 0.0),
|
||||
external_txn_id=(req.external_txn_id or "").strip(),
|
||||
meta={"status": req.status},
|
||||
)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": msg}
|
||||
return {"ok": True, "detail": msg}
|
||||
|
||||
|
||||
@app.post("/api/pay/wechat/backcall")
|
||||
async def pay_wechat_backcall(req: BillingRechargeNotifyRequest, request: Request):
|
||||
configured = (settings.shop_backend_callback_token or "").strip()
|
||||
provided = (request.headers.get("X-Shop-Token") or request.query_params.get("token") or "").strip()
|
||||
if configured and provided != configured:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
if (req.status or "").lower() not in {"paid", "success"}:
|
||||
return {"ok": True, "detail": "ignored"}
|
||||
owner_id = users.get_recharge_order_user_id(req.order_no)
|
||||
if not owner_id:
|
||||
return {"ok": False, "detail": "订单不存在"}
|
||||
ok, msg = users.mark_recharge_order_paid(
|
||||
int(owner_id),
|
||||
req.order_no,
|
||||
paid_amount_cny=float(req.paid_amount_cny or 0.0),
|
||||
external_txn_id=(req.external_txn_id or "").strip(),
|
||||
meta={"status": req.status, "source": "wechat_backcall"},
|
||||
)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": msg}
|
||||
return {"ok": True, "detail": msg}
|
||||
|
||||
|
||||
@app.post("/api/auth/wechat/bind")
|
||||
async def auth_wechat_bind(req: WechatBindingRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
@@ -425,6 +897,20 @@ async def auth_ai_model_delete(req: AIModelDeleteRequest, request: Request):
|
||||
return {"ok": True, "detail": "模型配置已删除"}
|
||||
|
||||
|
||||
@app.post("/api/auth/ai-models/image-model/update")
|
||||
async def auth_ai_image_model_update(req: AIImageModelUpdateRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
image_model = (req.image_model or "").strip()
|
||||
if not image_model:
|
||||
return {"ok": False, "detail": "文生图模型不能为空"}
|
||||
ok = users.update_active_ai_image_model(user["id"], image_model)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": "保存失败:请先配置文本模型"}
|
||||
return {"ok": True, "detail": "文生图模型已更新"}
|
||||
|
||||
|
||||
@app.post("/api/rewrite")
|
||||
async def rewrite(req: RewriteRequest, request: Request):
|
||||
rid = getattr(request.state, "request_id", "")
|
||||
@@ -444,7 +930,9 @@ async def rewrite(req: RewriteRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="请先登录")
|
||||
model_cfg = users.get_active_ai_model(user["id"])
|
||||
model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True)
|
||||
if model_source == "vip_empty":
|
||||
raise HTTPException(status_code=402, detail=_quota_detail())
|
||||
if not model_cfg:
|
||||
raise HTTPException(status_code=400, detail="请先在设置页配置 AI 模型")
|
||||
backup = {
|
||||
@@ -472,6 +960,50 @@ async def rewrite(req: RewriteRequest, request: Request):
|
||||
settings.openai_max_output_tokens = backup["openai_max_output_tokens"]
|
||||
settings.openai_max_retries = backup["openai_max_retries"]
|
||||
settings.openai_image_model = backup["openai_image_model"]
|
||||
usage = ((result.trace or {}).get("usage") or {}) if isinstance(result.trace, dict) else {}
|
||||
prompt_tokens = int(usage.get("prompt_tokens") or 0)
|
||||
completion_tokens = int(usage.get("completion_tokens") or 0)
|
||||
total_tokens = int(usage.get("total_tokens") or 0)
|
||||
billed_basis = "usage_tokens" if total_tokens > 0 else "char_estimate"
|
||||
token_cost = _estimate_rewrite_cost(req, result)
|
||||
vip_status = users.get_vip_status(user["id"])
|
||||
should_consume = bool(vip_status.get("vip_enabled"))
|
||||
if should_consume:
|
||||
ok_cost, balance = users.consume_tokens(
|
||||
user["id"],
|
||||
token_cost,
|
||||
kind="rewrite",
|
||||
ref_type="request",
|
||||
ref_id=rid,
|
||||
detail={
|
||||
"source_chars": len((req.source_text or "").strip()),
|
||||
"target_body_chars": int(req.target_body_chars or 0),
|
||||
"title_chars": len((result.title or "").strip()),
|
||||
"summary_chars": len((result.summary or "").strip()),
|
||||
"body_chars": len((result.body_markdown or "").strip()),
|
||||
"model": model_cfg.get("model") if model_cfg else "",
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"credits_rule": (
|
||||
f"1000000 tokens={float(settings.credits_token_price_per_million_cny):.2f}元,"
|
||||
f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元,"
|
||||
f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}元"
|
||||
),
|
||||
"billed_basis": billed_basis,
|
||||
},
|
||||
)
|
||||
if not ok_cost:
|
||||
raise HTTPException(status_code=402, detail=_quota_detail())
|
||||
if result.trace is None:
|
||||
result.trace = {}
|
||||
result.trace["vip"] = {
|
||||
"model_source": "platform" if model_source == "vip" else "user",
|
||||
"credits_cost": token_cost,
|
||||
"credits_balance": balance,
|
||||
"billed_basis": billed_basis,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
tr = result.trace or {}
|
||||
logger.info(
|
||||
"api_rewrite_out rid=%s mode=%s duration_ms=%s quality_notes=%d trace_steps=%s soft_accept=%s",
|
||||
@@ -599,7 +1131,10 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques
|
||||
len(req.summary or ""),
|
||||
req.upload_to_wechat,
|
||||
)
|
||||
model_cfg = users.get_active_ai_model(user["id"])
|
||||
model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True)
|
||||
if model_source == "vip_empty":
|
||||
return {"ok": False, "detail": _quota_detail(), "upgrade_required": True}
|
||||
image_model_override = (req.image_model or "").strip()
|
||||
backup = {
|
||||
"openai_api_key": settings.openai_api_key,
|
||||
"openai_base_url": settings.openai_base_url,
|
||||
@@ -626,6 +1161,8 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques
|
||||
settings.openai_max_output_tokens = 8192
|
||||
settings.openai_max_retries = 0
|
||||
settings.openai_image_model = backup["openai_image_model"]
|
||||
if image_model_override:
|
||||
settings.openai_image_model = image_model_override
|
||||
out = await PosterMaterialService(wechat).generate_cover(req, request_id=rid, account=binding)
|
||||
finally:
|
||||
settings.openai_api_key = backup["openai_api_key"]
|
||||
@@ -643,6 +1180,31 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques
|
||||
out.note,
|
||||
len(out.warnings),
|
||||
)
|
||||
if out.ok and model_source == "vip":
|
||||
token_cost = _estimate_image_cost(1)
|
||||
ok_cost, balance = users.consume_tokens(
|
||||
user["id"],
|
||||
token_cost,
|
||||
kind="cover_generate",
|
||||
ref_type="request",
|
||||
ref_id=rid,
|
||||
detail={
|
||||
"title": (req.title or "")[:120],
|
||||
"style_hint": (req.style_hint or "")[:120],
|
||||
"image_count": 1,
|
||||
"image_model": image_model_override or settings.openai_image_model,
|
||||
"image_price_package_cny": float(settings.credits_image_price_package_cny),
|
||||
"image_price_package_images": int(settings.credits_image_price_package_images),
|
||||
"credits_rule": (
|
||||
f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元,"
|
||||
f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}元"
|
||||
),
|
||||
},
|
||||
)
|
||||
if not ok_cost:
|
||||
return {"ok": False, "detail": _quota_detail(), "upgrade_required": True}
|
||||
out.warnings = list(out.warnings or [])
|
||||
out.warnings.append(f"已扣减 {token_cost} Credits,可用余额 {balance}")
|
||||
return out
|
||||
|
||||
|
||||
@@ -684,7 +1246,10 @@ async def generate_posters(req: PosterGenerateRequest, request: Request):
|
||||
req.upload_to_wechat,
|
||||
int(req.max_images or 0),
|
||||
)
|
||||
model_cfg = users.get_active_ai_model(user["id"])
|
||||
model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True)
|
||||
if model_source == "vip_empty":
|
||||
return {"ok": False, "detail": _quota_detail(), "upgrade_required": True}
|
||||
image_model_override = (req.image_model or "").strip()
|
||||
backup = {
|
||||
"openai_api_key": settings.openai_api_key,
|
||||
"openai_base_url": settings.openai_base_url,
|
||||
@@ -711,6 +1276,8 @@ async def generate_posters(req: PosterGenerateRequest, request: Request):
|
||||
settings.openai_max_output_tokens = 8192
|
||||
settings.openai_max_retries = 0
|
||||
settings.openai_image_model = backup["openai_image_model"]
|
||||
if image_model_override:
|
||||
settings.openai_image_model = image_model_override
|
||||
out = await PosterMaterialService(wechat).generate(req, request_id=rid, account=binding)
|
||||
finally:
|
||||
settings.openai_api_key = backup["openai_api_key"]
|
||||
@@ -727,6 +1294,32 @@ async def generate_posters(req: PosterGenerateRequest, request: Request):
|
||||
len(out.posters),
|
||||
len(out.warnings),
|
||||
)
|
||||
if out.ok and model_source == "vip":
|
||||
image_count = max(0, len(out.posters or []))
|
||||
token_cost = _estimate_image_cost(image_count)
|
||||
ok_cost, balance = users.consume_tokens(
|
||||
user["id"],
|
||||
token_cost,
|
||||
kind="poster_generate",
|
||||
ref_type="request",
|
||||
ref_id=rid,
|
||||
detail={
|
||||
"image_count": image_count,
|
||||
"body_chars": len((req.body_markdown or "").strip()),
|
||||
"max_images": int(req.max_images or 0),
|
||||
"image_model": image_model_override or settings.openai_image_model,
|
||||
"image_price_package_cny": float(settings.credits_image_price_package_cny),
|
||||
"image_price_package_images": int(settings.credits_image_price_package_images),
|
||||
"credits_rule": (
|
||||
f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元,"
|
||||
f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}元"
|
||||
),
|
||||
},
|
||||
)
|
||||
if not ok_cost:
|
||||
return {"ok": False, "detail": _quota_detail(), "upgrade_required": True}
|
||||
out.warnings = list(out.warnings or [])
|
||||
out.warnings.append(f"已扣减 {token_cost} Credits,可用余额 {balance}")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ class AuthCredentialRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
remember_me: bool = False
|
||||
challenge_id: str = ""
|
||||
challenge_answer: str = ""
|
||||
honeypot: str = ""
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
@@ -97,6 +100,7 @@ class WechatCoverGenerateRequest(BaseModel):
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
style_hint: str = ""
|
||||
image_model: str = ""
|
||||
upload_to_wechat: bool = True
|
||||
|
||||
|
||||
@@ -119,12 +123,42 @@ class AIModelDeleteRequest(BaseModel):
|
||||
model_id: int
|
||||
|
||||
|
||||
class AIImageModelUpdateRequest(BaseModel):
|
||||
image_model: str
|
||||
|
||||
|
||||
class VipToggleRequest(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class VipRechargeRequest(BaseModel):
|
||||
tokens: int = Field(default=10000, ge=1, le=10_000_000)
|
||||
tokens: int = Field(default=1500, ge=1, le=10_000_000)
|
||||
|
||||
|
||||
class BillingRechargeCreateRequest(BaseModel):
|
||||
tokens: int = Field(default=1500, ge=1, le=10_000_000)
|
||||
amount_cny: float = Field(default=19.9, ge=0.01, le=999999)
|
||||
channel: str = "wechat"
|
||||
subscriber_name: str = ""
|
||||
subscriber_phone: str = ""
|
||||
shipping_address: str = ""
|
||||
|
||||
|
||||
class BillingRechargeNotifyRequest(BaseModel):
|
||||
order_no: str
|
||||
paid_amount_cny: float = Field(default=0.0, ge=0.0)
|
||||
external_txn_id: str = ""
|
||||
status: str = "paid"
|
||||
|
||||
|
||||
class BillingPayNowRequest(BaseModel):
|
||||
order_no: str
|
||||
|
||||
|
||||
class UserProfileUpdateRequest(BaseModel):
|
||||
subscriber_name: str = ""
|
||||
subscriber_phone: str = ""
|
||||
shipping_address: str = ""
|
||||
|
||||
|
||||
class PosterGenerateRequest(BaseModel):
|
||||
@@ -132,6 +166,7 @@ class PosterGenerateRequest(BaseModel):
|
||||
summary: str = ""
|
||||
body_markdown: str = Field(..., min_length=20)
|
||||
style_hint: str = ""
|
||||
image_model: str = ""
|
||||
upload_to_wechat: bool = True
|
||||
max_images: int = Field(default=6, ge=1, le=12)
|
||||
|
||||
|
||||
@@ -108,6 +108,10 @@ class AIRewriter:
|
||||
def __init__(self) -> None:
|
||||
self._client = None
|
||||
self._prefer_chat_first = False
|
||||
self._usage_prompt_tokens = 0
|
||||
self._usage_completion_tokens = 0
|
||||
self._usage_total_tokens = 0
|
||||
self._usage_calls = 0
|
||||
if settings.openai_api_key:
|
||||
base_url = settings.openai_base_url or ""
|
||||
self._prefer_chat_first = "dashscope.aliyuncs.com" in base_url
|
||||
@@ -128,6 +132,22 @@ class AIRewriter:
|
||||
else:
|
||||
logger.warning("AIRewriter_init openai_key_missing=1 rewrite_will_use_fallback_only=1")
|
||||
|
||||
def _accumulate_usage(self, usage: Any) -> None:
|
||||
if usage is None:
|
||||
return
|
||||
data = usage.model_dump() if hasattr(usage, "model_dump") else usage
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
prompt = int(data.get("prompt_tokens") or data.get("input_tokens") or 0)
|
||||
completion = int(data.get("completion_tokens") or data.get("output_tokens") or 0)
|
||||
total = int(data.get("total_tokens") or 0)
|
||||
if total <= 0:
|
||||
total = max(0, prompt + completion)
|
||||
self._usage_prompt_tokens += max(0, prompt)
|
||||
self._usage_completion_tokens += max(0, completion)
|
||||
self._usage_total_tokens += max(0, total)
|
||||
self._usage_calls += 1
|
||||
|
||||
def rewrite(self, req: RewriteRequest, request_id: str = "") -> RewriteResponse:
|
||||
cleaned_source = self._clean_source(req.source_text)
|
||||
started = time.monotonic()
|
||||
@@ -256,6 +276,12 @@ class AIRewriter:
|
||||
)
|
||||
trace["quality_issues_final"] = final_issues
|
||||
if not final_issues:
|
||||
trace["usage"] = {
|
||||
"prompt_tokens": int(self._usage_prompt_tokens),
|
||||
"completion_tokens": int(self._usage_completion_tokens),
|
||||
"total_tokens": int(self._usage_total_tokens),
|
||||
"model_calls": int(self._usage_calls),
|
||||
}
|
||||
trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1)
|
||||
trace["mode"] = "ai"
|
||||
logger.info(
|
||||
@@ -266,6 +292,12 @@ class AIRewriter:
|
||||
return RewriteResponse(**normalized, mode="ai", quality_notes=[], trace=trace)
|
||||
# 模型已返回有效 JSON:默认「软接受」——仍视为 AI 洗稿,质检问题写入 quality_notes,避免误用模板稿
|
||||
if settings.ai_soft_accept and self._model_output_usable(normalized):
|
||||
trace["usage"] = {
|
||||
"prompt_tokens": int(self._usage_prompt_tokens),
|
||||
"completion_tokens": int(self._usage_completion_tokens),
|
||||
"total_tokens": int(self._usage_total_tokens),
|
||||
"model_calls": int(self._usage_calls),
|
||||
}
|
||||
trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1)
|
||||
trace["mode"] = "ai"
|
||||
trace["quality_soft_accept"] = True
|
||||
@@ -669,6 +701,7 @@ class AIRewriter:
|
||||
msg = (choice.message.content if choice else "") or ""
|
||||
fr = getattr(choice, "finish_reason", None) if choice else None
|
||||
usage = getattr(completion, "usage", None)
|
||||
self._accumulate_usage(usage)
|
||||
udump = (
|
||||
usage.model_dump()
|
||||
if usage is not None and hasattr(usage, "model_dump")
|
||||
@@ -755,6 +788,7 @@ class AIRewriter:
|
||||
text={"format": {"type": "json_object"}},
|
||||
timeout=timeout_sec,
|
||||
)
|
||||
self._accumulate_usage(getattr(completion, "usage", None))
|
||||
output_text = completion.output_text or ""
|
||||
ms = (time.monotonic() - t0) * 1000
|
||||
logger.info(
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
@@ -69,6 +70,12 @@ class UserStore:
|
||||
pref_cols = self._table_columns(c, "user_prefs")
|
||||
if "active_ai_model_id" not in pref_cols:
|
||||
c.execute("ALTER TABLE user_prefs ADD COLUMN active_ai_model_id INTEGER")
|
||||
if "subscriber_name" not in pref_cols:
|
||||
c.execute("ALTER TABLE user_prefs ADD COLUMN subscriber_name TEXT NOT NULL DEFAULT ''")
|
||||
if "subscriber_phone" not in pref_cols:
|
||||
c.execute("ALTER TABLE user_prefs ADD COLUMN subscriber_phone TEXT NOT NULL DEFAULT ''")
|
||||
if "shipping_address" not in pref_cols:
|
||||
c.execute("ALTER TABLE user_prefs ADD COLUMN shipping_address TEXT NOT NULL DEFAULT ''")
|
||||
c.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ai_models (
|
||||
@@ -95,11 +102,62 @@ class UserStore:
|
||||
vip_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
token_balance INTEGER NOT NULL DEFAULT 0,
|
||||
total_consumed_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
seat_quota_credits INTEGER NOT NULL DEFAULT 1500,
|
||||
seat_used_credits INTEGER NOT NULL DEFAULT 0,
|
||||
seat_cycle TEXT NOT NULL DEFAULT '',
|
||||
cycle_started_at INTEGER NOT NULL DEFAULT 0,
|
||||
cycle_expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
wallet_cols = self._table_columns(c, "user_wallets")
|
||||
if "seat_quota_credits" not in wallet_cols:
|
||||
c.execute("ALTER TABLE user_wallets ADD COLUMN seat_quota_credits INTEGER NOT NULL DEFAULT 25000")
|
||||
if "seat_used_credits" not in wallet_cols:
|
||||
c.execute("ALTER TABLE user_wallets ADD COLUMN seat_used_credits INTEGER NOT NULL DEFAULT 0")
|
||||
if "seat_cycle" not in wallet_cols:
|
||||
c.execute("ALTER TABLE user_wallets ADD COLUMN seat_cycle TEXT NOT NULL DEFAULT ''")
|
||||
if "cycle_started_at" not in wallet_cols:
|
||||
c.execute("ALTER TABLE user_wallets ADD COLUMN cycle_started_at INTEGER NOT NULL DEFAULT 0")
|
||||
if "cycle_expires_at" not in wallet_cols:
|
||||
c.execute("ALTER TABLE user_wallets ADD COLUMN cycle_expires_at INTEGER NOT NULL DEFAULT 0")
|
||||
c.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS recharge_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
order_no TEXT NOT NULL UNIQUE,
|
||||
channel TEXT NOT NULL DEFAULT '',
|
||||
token_amount INTEGER NOT NULL DEFAULT 0,
|
||||
amount_cny REAL NOT NULL DEFAULT 0.0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
external_txn_id TEXT NOT NULL DEFAULT '',
|
||||
meta_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
paid_at INTEGER,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
c.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS token_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
token_change INTEGER NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT '',
|
||||
ref_type TEXT NOT NULL DEFAULT '',
|
||||
ref_id TEXT NOT NULL DEFAULT '',
|
||||
detail_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
ai_cols = self._table_columns(c, "ai_models")
|
||||
if "image_model" not in ai_cols:
|
||||
c.execute("ALTER TABLE ai_models ADD COLUMN image_model TEXT NOT NULL DEFAULT ''")
|
||||
@@ -438,6 +496,57 @@ class UserStore:
|
||||
return None
|
||||
return {"id": int(row["id"]), "username": row["username"]}
|
||||
|
||||
def get_user_profile(self, user_id: int) -> dict:
|
||||
with self._conn() as c:
|
||||
row = c.execute(
|
||||
"""
|
||||
SELECT subscriber_name, subscriber_phone, shipping_address
|
||||
FROM user_prefs
|
||||
WHERE user_id=?
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"subscriber_name": "", "subscriber_phone": "", "shipping_address": ""}
|
||||
return {
|
||||
"subscriber_name": (row["subscriber_name"] or "").strip(),
|
||||
"subscriber_phone": (row["subscriber_phone"] or "").strip(),
|
||||
"shipping_address": (row["shipping_address"] or "").strip(),
|
||||
}
|
||||
|
||||
def save_user_profile(
|
||||
self,
|
||||
user_id: int,
|
||||
*,
|
||||
subscriber_name: str,
|
||||
subscriber_phone: str,
|
||||
shipping_address: str,
|
||||
) -> dict:
|
||||
now = int(time.time())
|
||||
with self._conn() as c:
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO user_prefs(
|
||||
user_id, active_wechat_account_id, active_ai_model_id,
|
||||
subscriber_name, subscriber_phone, shipping_address, updated_at
|
||||
) VALUES (?, NULL, NULL, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
subscriber_name=excluded.subscriber_name,
|
||||
subscriber_phone=excluded.subscriber_phone,
|
||||
shipping_address=excluded.shipping_address,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
(subscriber_name or "").strip(),
|
||||
(subscriber_phone or "").strip(),
|
||||
(shipping_address or "").strip(),
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_user_profile(user_id)
|
||||
|
||||
def delete_user_logically(self, user_id: int, password: str, reset_code: str) -> bool:
|
||||
now = int(time.time())
|
||||
with self._conn() as c:
|
||||
@@ -464,6 +573,8 @@ class UserStore:
|
||||
c.execute("DELETE FROM user_prefs WHERE user_id=?", (user_id,))
|
||||
c.execute("DELETE FROM wechat_bindings WHERE user_id=?", (user_id,))
|
||||
c.execute("DELETE FROM user_wallets WHERE user_id=?", (user_id,))
|
||||
c.execute("DELETE FROM recharge_orders WHERE user_id=?", (user_id,))
|
||||
c.execute("DELETE FROM token_ledger WHERE user_id=?", (user_id,))
|
||||
c.execute(
|
||||
"UPDATE users SET deleted_at=?, username=username || '#deleted' || ? WHERE id=?",
|
||||
(now, str(now), user_id),
|
||||
@@ -472,12 +583,79 @@ class UserStore:
|
||||
|
||||
def _ensure_wallet_row(self, c: sqlite3.Connection, user_id: int) -> None:
|
||||
now = int(time.time())
|
||||
cycle = time.strftime("%Y-%m", time.localtime(now))
|
||||
c.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO user_wallets(user_id, vip_enabled, token_balance, total_consumed_tokens, updated_at)
|
||||
VALUES (?, 0, 0, 0, ?)
|
||||
INSERT OR IGNORE INTO user_wallets(
|
||||
user_id, vip_enabled, token_balance, total_consumed_tokens,
|
||||
seat_quota_credits, seat_used_credits, seat_cycle, cycle_started_at, cycle_expires_at, updated_at
|
||||
)
|
||||
VALUES (?, 0, 0, 0, 1500, 0, ?, 0, 0, ?)
|
||||
""",
|
||||
(user_id, now),
|
||||
(user_id, cycle, now),
|
||||
)
|
||||
|
||||
def _refresh_billing_cycle(self, c: sqlite3.Connection, user_id: int) -> None:
|
||||
now = int(time.time())
|
||||
row = c.execute(
|
||||
"SELECT seat_cycle, cycle_expires_at, token_balance, seat_used_credits FROM user_wallets WHERE user_id=?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
current_cycle = (row["seat_cycle"] or "") if row else ""
|
||||
expires_at = int(row["cycle_expires_at"] or 0) if row else 0
|
||||
if expires_at > 0 and now >= expires_at:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE user_wallets
|
||||
SET seat_used_credits=0, token_balance=0, seat_cycle='', cycle_started_at=0, cycle_expires_at=0, updated_at=?
|
||||
WHERE user_id=?
|
||||
""",
|
||||
(now, user_id),
|
||||
)
|
||||
return
|
||||
# 兼容历史按自然月 seat_cycle 的老数据:若没有新周期字段,保留原行为
|
||||
if expires_at <= 0:
|
||||
paid = c.execute(
|
||||
"""
|
||||
SELECT paid_at
|
||||
FROM recharge_orders
|
||||
WHERE user_id=? AND status='paid' AND paid_at IS NOT NULL
|
||||
ORDER BY paid_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if paid and int(paid["paid_at"] or 0) > 0:
|
||||
start_at = int(paid["paid_at"])
|
||||
new_expires = start_at + 30 * 24 * 3600
|
||||
if now >= new_expires:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE user_wallets
|
||||
SET seat_used_credits=0, token_balance=0, seat_cycle='', cycle_started_at=0, cycle_expires_at=0, updated_at=?
|
||||
WHERE user_id=?
|
||||
""",
|
||||
(now, user_id),
|
||||
)
|
||||
else:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE user_wallets
|
||||
SET seat_cycle=?, cycle_started_at=?, cycle_expires_at=?, updated_at=?
|
||||
WHERE user_id=?
|
||||
""",
|
||||
(time.strftime("%Y-%m", time.localtime(start_at)), start_at, new_expires, now, user_id),
|
||||
)
|
||||
return
|
||||
cycle = time.strftime("%Y-%m", time.localtime(now))
|
||||
if current_cycle != cycle:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE user_wallets
|
||||
SET seat_used_credits=0, seat_cycle=?, updated_at=?
|
||||
WHERE user_id=?
|
||||
""",
|
||||
(cycle, now, user_id),
|
||||
)
|
||||
|
||||
def ensure_trial_tokens(self, user_id: int, trial_tokens: int) -> dict:
|
||||
@@ -491,31 +669,63 @@ class UserStore:
|
||||
).fetchone()
|
||||
current = int(row["token_balance"] or 0) if row else 0
|
||||
if current <= 0 and amount > 0:
|
||||
new_balance = amount
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE user_wallets
|
||||
SET vip_enabled=1, token_balance=?, updated_at=?
|
||||
WHERE user_id=?
|
||||
""",
|
||||
(amount, now, user_id),
|
||||
(new_balance, now, user_id),
|
||||
)
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO token_ledger(
|
||||
user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at
|
||||
) VALUES (?, 'in', ?, ?, 'trial_grant', 'system', '', '{}', ?)
|
||||
""",
|
||||
(user_id, amount, new_balance, now),
|
||||
)
|
||||
return self.get_vip_status(user_id)
|
||||
|
||||
def get_vip_status(self, user_id: int) -> dict:
|
||||
with self._conn() as c:
|
||||
self._ensure_wallet_row(c, user_id)
|
||||
self._refresh_billing_cycle(c, user_id)
|
||||
row = c.execute(
|
||||
"""
|
||||
SELECT vip_enabled, token_balance, total_consumed_tokens, updated_at
|
||||
SELECT
|
||||
vip_enabled, token_balance, total_consumed_tokens,
|
||||
seat_quota_credits, seat_used_credits, seat_cycle, cycle_started_at, cycle_expires_at, updated_at
|
||||
FROM user_wallets
|
||||
WHERE user_id=?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
seat_quota = int(row["seat_quota_credits"] or 0) if row else 0
|
||||
seat_used = int(row["seat_used_credits"] or 0) if row else 0
|
||||
seat_remaining = max(0, seat_quota - seat_used)
|
||||
shared_credits = int(row["token_balance"] or 0) if row else 0
|
||||
cycle_started_at = int(row["cycle_started_at"] or 0) if row else 0
|
||||
cycle_expires_at = int(row["cycle_expires_at"] or 0) if row else 0
|
||||
now = int(time.time())
|
||||
cycle_active = cycle_expires_at > now if cycle_expires_at > 0 else True
|
||||
if not cycle_active:
|
||||
seat_remaining = 0
|
||||
shared_credits = 0
|
||||
return {
|
||||
"vip_enabled": bool(int(row["vip_enabled"] or 0)) if row else False,
|
||||
"token_balance": int(row["token_balance"] or 0) if row else 0,
|
||||
"token_balance": shared_credits,
|
||||
"total_consumed_tokens": int(row["total_consumed_tokens"] or 0) if row else 0,
|
||||
"seat_quota_credits": seat_quota,
|
||||
"seat_used_credits": seat_used,
|
||||
"seat_remaining_credits": seat_remaining,
|
||||
"shared_credits": shared_credits,
|
||||
"total_available_credits": seat_remaining + shared_credits,
|
||||
"seat_cycle": (row["seat_cycle"] or "") if row else "",
|
||||
"cycle_started_at": cycle_started_at,
|
||||
"cycle_expires_at": cycle_expires_at,
|
||||
"cycle_active": cycle_active,
|
||||
"updated_at": int(row["updated_at"] or 0) if row else 0,
|
||||
}
|
||||
|
||||
@@ -529,11 +739,38 @@ class UserStore:
|
||||
)
|
||||
return self.get_vip_status(user_id)
|
||||
|
||||
def recharge_tokens(self, user_id: int, tokens: int) -> dict:
|
||||
def recharge_tokens(
|
||||
self,
|
||||
user_id: int,
|
||||
tokens: int,
|
||||
*,
|
||||
kind: str = "manual_recharge",
|
||||
ref_type: str = "",
|
||||
ref_id: str = "",
|
||||
detail: dict | None = None,
|
||||
cycle_start_at: int | None = None,
|
||||
cycle_days: int = 30,
|
||||
) -> dict:
|
||||
add = max(0, int(tokens))
|
||||
now = int(time.time())
|
||||
with self._conn() as c:
|
||||
self._ensure_wallet_row(c, user_id)
|
||||
self._refresh_billing_cycle(c, user_id)
|
||||
row = c.execute("SELECT token_balance FROM user_wallets WHERE user_id=?", (user_id,)).fetchone()
|
||||
prev = int(row["token_balance"] or 0) if row else 0
|
||||
new_balance = prev + add
|
||||
start_at = int(cycle_start_at or 0)
|
||||
if start_at > 0:
|
||||
expires_at = start_at + max(1, int(cycle_days)) * 24 * 3600
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE user_wallets
|
||||
SET token_balance=token_balance + ?, vip_enabled=1, seat_used_credits=0, seat_cycle=?, cycle_started_at=?, cycle_expires_at=?, updated_at=?
|
||||
WHERE user_id=?
|
||||
""",
|
||||
(add, time.strftime("%Y-%m", time.localtime(start_at)), start_at, expires_at, now, user_id),
|
||||
)
|
||||
else:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE user_wallets
|
||||
@@ -542,32 +779,288 @@ class UserStore:
|
||||
""",
|
||||
(add, now, user_id),
|
||||
)
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO token_ledger(
|
||||
user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at
|
||||
) VALUES (?, 'in', ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
add,
|
||||
new_balance,
|
||||
kind,
|
||||
ref_type,
|
||||
ref_id or "",
|
||||
json.dumps(detail or {}, ensure_ascii=True),
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_vip_status(user_id)
|
||||
|
||||
def consume_tokens(self, user_id: int, tokens: int) -> tuple[bool, int]:
|
||||
def consume_tokens(
|
||||
self,
|
||||
user_id: int,
|
||||
tokens: int,
|
||||
*,
|
||||
kind: str = "usage",
|
||||
ref_type: str = "",
|
||||
ref_id: str = "",
|
||||
detail: dict | None = None,
|
||||
) -> tuple[bool, int]:
|
||||
cost = max(0, int(tokens))
|
||||
now = int(time.time())
|
||||
with self._conn() as c:
|
||||
self._ensure_wallet_row(c, user_id)
|
||||
self._refresh_billing_cycle(c, user_id)
|
||||
row = c.execute(
|
||||
"SELECT token_balance FROM user_wallets WHERE user_id=?",
|
||||
"SELECT token_balance, seat_quota_credits, seat_used_credits FROM user_wallets WHERE user_id=?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
balance = int(row["token_balance"] or 0) if row else 0
|
||||
shared_balance = int(row["token_balance"] or 0) if row else 0
|
||||
seat_quota = int(row["seat_quota_credits"] or 0) if row else 0
|
||||
seat_used = int(row["seat_used_credits"] or 0) if row else 0
|
||||
seat_remaining = max(0, seat_quota - seat_used)
|
||||
if cost <= 0:
|
||||
return True, balance
|
||||
if balance < cost:
|
||||
return False, balance
|
||||
new_balance = balance - cost
|
||||
return True, seat_remaining + shared_balance
|
||||
use_from_seat = min(seat_remaining, cost)
|
||||
need_shared = cost - use_from_seat
|
||||
if shared_balance < need_shared:
|
||||
return False, seat_remaining + shared_balance
|
||||
new_shared = shared_balance - need_shared
|
||||
new_seat_used = seat_used + use_from_seat
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE user_wallets
|
||||
SET token_balance=?, total_consumed_tokens=total_consumed_tokens + ?, updated_at=?
|
||||
SET token_balance=?, seat_used_credits=?, total_consumed_tokens=total_consumed_tokens + ?, updated_at=?
|
||||
WHERE user_id=?
|
||||
""",
|
||||
(new_balance, cost, now, user_id),
|
||||
(new_shared, new_seat_used, cost, now, user_id),
|
||||
)
|
||||
return True, new_balance
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO token_ledger(
|
||||
user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at
|
||||
) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
cost,
|
||||
max(0, seat_quota - new_seat_used) + new_shared,
|
||||
kind,
|
||||
ref_type,
|
||||
ref_id or "",
|
||||
json.dumps(
|
||||
{
|
||||
**(detail or {}),
|
||||
"credit_source": {"seat": use_from_seat, "shared": need_shared},
|
||||
},
|
||||
ensure_ascii=True,
|
||||
),
|
||||
now,
|
||||
),
|
||||
)
|
||||
return True, max(0, seat_quota - new_seat_used) + new_shared
|
||||
|
||||
def create_recharge_order(
|
||||
self,
|
||||
user_id: int,
|
||||
order_no: str,
|
||||
channel: str,
|
||||
token_amount: int,
|
||||
amount_cny: float,
|
||||
meta: dict | None = None,
|
||||
) -> dict:
|
||||
now = int(time.time())
|
||||
with self._conn() as c:
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO recharge_orders(
|
||||
user_id, order_no, channel, token_amount, amount_cny, status, external_txn_id, meta_json, created_at, paid_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', '', ?, ?, NULL)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
order_no,
|
||||
channel or "",
|
||||
int(token_amount),
|
||||
float(amount_cny),
|
||||
json.dumps(meta or {}, ensure_ascii=True),
|
||||
now,
|
||||
),
|
||||
)
|
||||
return {
|
||||
"order_no": order_no,
|
||||
"channel": channel,
|
||||
"token_amount": int(token_amount),
|
||||
"amount_cny": float(amount_cny),
|
||||
"status": "pending",
|
||||
"created_at": now,
|
||||
}
|
||||
|
||||
def mark_recharge_order_paid(
|
||||
self,
|
||||
user_id: int,
|
||||
order_no: str,
|
||||
paid_amount_cny: float,
|
||||
external_txn_id: str = "",
|
||||
meta: dict | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
now = int(time.time())
|
||||
with self._conn() as c:
|
||||
row = c.execute(
|
||||
"""
|
||||
SELECT user_id, token_amount, amount_cny, status
|
||||
FROM recharge_orders
|
||||
WHERE order_no=?
|
||||
""",
|
||||
(order_no,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False, "订单不存在"
|
||||
if int(row["user_id"]) != int(user_id):
|
||||
return False, "订单无权限"
|
||||
if (row["status"] or "") == "paid":
|
||||
return True, "already_paid"
|
||||
if float(paid_amount_cny or 0.0) + 1e-9 < float(row["amount_cny"] or 0.0):
|
||||
return False, "支付金额不足"
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE recharge_orders
|
||||
SET status='paid', external_txn_id=?, paid_at=?, meta_json=?
|
||||
WHERE order_no=?
|
||||
""",
|
||||
(
|
||||
external_txn_id or "",
|
||||
now,
|
||||
json.dumps(meta or {}, ensure_ascii=True),
|
||||
order_no,
|
||||
),
|
||||
)
|
||||
self.recharge_tokens(
|
||||
user_id,
|
||||
int(row["token_amount"] or 0),
|
||||
kind="paid_recharge",
|
||||
ref_type="order",
|
||||
ref_id=order_no,
|
||||
detail={"paid_amount_cny": float(paid_amount_cny or 0.0), "external_txn_id": external_txn_id or ""},
|
||||
cycle_start_at=now,
|
||||
cycle_days=30,
|
||||
)
|
||||
return True, "ok"
|
||||
|
||||
def list_recharge_orders(self, user_id: int, limit: int = 50) -> list[dict]:
|
||||
with self._conn() as c:
|
||||
now = int(time.time())
|
||||
expire_before = now - 15 * 60
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE recharge_orders
|
||||
SET status='cancelled'
|
||||
WHERE user_id=? AND status='pending' AND created_at<=?
|
||||
""",
|
||||
(user_id, expire_before),
|
||||
)
|
||||
rows = c.execute(
|
||||
"""
|
||||
SELECT order_no, channel, token_amount, amount_cny, status, external_txn_id, created_at, paid_at, meta_json
|
||||
FROM recharge_orders
|
||||
WHERE user_id=?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id, max(1, min(int(limit), 200))),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"order_no": r["order_no"] or "",
|
||||
"channel": r["channel"] or "",
|
||||
"token_amount": int(r["token_amount"] or 0),
|
||||
"amount_cny": float(r["amount_cny"] or 0.0),
|
||||
"status": r["status"] or "",
|
||||
"external_txn_id": r["external_txn_id"] or "",
|
||||
"created_at": int(r["created_at"] or 0),
|
||||
"paid_at": int(r["paid_at"] or 0) if r["paid_at"] else None,
|
||||
"meta": json.loads(r["meta_json"] or "{}"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def get_recharge_order(self, user_id: int, order_no: str) -> dict | None:
|
||||
now = int(time.time())
|
||||
with self._conn() as c:
|
||||
expire_before = now - 15 * 60
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE recharge_orders
|
||||
SET status='cancelled'
|
||||
WHERE user_id=? AND status='pending' AND created_at<=?
|
||||
""",
|
||||
(user_id, expire_before),
|
||||
)
|
||||
row = c.execute(
|
||||
"""
|
||||
SELECT order_no, channel, token_amount, amount_cny, status, external_txn_id, created_at, paid_at, meta_json
|
||||
FROM recharge_orders
|
||||
WHERE user_id=? AND order_no=?
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, order_no),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
meta = json.loads(row["meta_json"] or "{}")
|
||||
except Exception:
|
||||
meta = {}
|
||||
return {
|
||||
"order_no": row["order_no"] or "",
|
||||
"channel": row["channel"] or "",
|
||||
"token_amount": int(row["token_amount"] or 0),
|
||||
"amount_cny": float(row["amount_cny"] or 0.0),
|
||||
"status": row["status"] or "",
|
||||
"external_txn_id": row["external_txn_id"] or "",
|
||||
"created_at": int(row["created_at"] or 0),
|
||||
"paid_at": int(row["paid_at"] or 0) if row["paid_at"] else None,
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
def get_recharge_order_user_id(self, order_no: str) -> int | None:
|
||||
with self._conn() as c:
|
||||
row = c.execute("SELECT user_id FROM recharge_orders WHERE order_no=?", (order_no,)).fetchone()
|
||||
return int(row["user_id"]) if row and row["user_id"] else None
|
||||
|
||||
def list_token_ledger(self, user_id: int, limit: int = 100) -> list[dict]:
|
||||
with self._conn() as c:
|
||||
rows = c.execute(
|
||||
"""
|
||||
SELECT direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at
|
||||
FROM token_ledger
|
||||
WHERE user_id=?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id, max(1, min(int(limit), 500))),
|
||||
).fetchall()
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
try:
|
||||
detail = json.loads(r["detail_json"] or "{}")
|
||||
except Exception:
|
||||
detail = {}
|
||||
out.append(
|
||||
{
|
||||
"direction": r["direction"] or "",
|
||||
"token_change": int(r["token_change"] or 0),
|
||||
"balance_after": int(r["balance_after"] or 0),
|
||||
"kind": r["kind"] or "",
|
||||
"ref_type": r["ref_type"] or "",
|
||||
"ref_id": r["ref_id"] or "",
|
||||
"detail": detail,
|
||||
"created_at": int(r["created_at"] or 0),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def save_wechat_binding(
|
||||
self,
|
||||
@@ -887,6 +1380,41 @@ class UserStore:
|
||||
)
|
||||
return True
|
||||
|
||||
def update_active_ai_image_model(self, user_id: int, image_model: str) -> bool:
|
||||
now = int(time.time())
|
||||
name = (image_model or "").strip()
|
||||
with self._conn() as c:
|
||||
pref = c.execute(
|
||||
"SELECT active_ai_model_id FROM user_prefs WHERE user_id=?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
aid = int(pref["active_ai_model_id"]) if pref and pref["active_ai_model_id"] else None
|
||||
if not aid:
|
||||
row = c.execute(
|
||||
"SELECT id FROM ai_models WHERE user_id=? ORDER BY updated_at DESC, id DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
aid = int(row["id"]) if row else None
|
||||
if not aid:
|
||||
return False
|
||||
c.execute(
|
||||
"UPDATE ai_models SET image_model=?, updated_at=? WHERE id=? AND user_id=?",
|
||||
(name, now, aid, user_id),
|
||||
)
|
||||
if c.total_changes <= 0:
|
||||
return False
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
active_ai_model_id=excluded.active_ai_model_id,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(user_id, aid, now),
|
||||
)
|
||||
return True
|
||||
|
||||
def get_active_ai_model(self, user_id: int) -> dict | None:
|
||||
with self._conn() as c:
|
||||
pref = c.execute(
|
||||
|
||||
@@ -18,6 +18,8 @@ const imBtn = $("imBtn");
|
||||
const coverUploadBtn = $("coverUploadBtn");
|
||||
const coverUrlUploadBtn = $("coverUrlUploadBtn");
|
||||
const coverGenerateBtn = $("coverGenerateBtn");
|
||||
const saveCoverImageModelBtn = $("saveCoverImageModelBtn");
|
||||
const coverImageModelInput = $("coverImageModel");
|
||||
const coverModeManualBtn = $("coverModeManualBtn");
|
||||
const coverModeAiBtn = $("coverModeAiBtn");
|
||||
const coverManualSection = $("coverManualSection");
|
||||
@@ -26,11 +28,13 @@ const coverAutoAfterRewrite = $("coverAutoAfterRewrite");
|
||||
const coverPreview = $("coverPreview");
|
||||
const coverPreviewWrap = $("coverPreviewWrap");
|
||||
const logoutBtn = $("logoutBtn");
|
||||
const clearDraftBtn = $("clearDraftBtn");
|
||||
const targetBodyCharsInput = $("targetBodyChars");
|
||||
const posterGenerateBtn = $("posterGenerateBtn");
|
||||
const posterPreviewList = $("posterPreviewList");
|
||||
const posterHint = $("posterHint");
|
||||
const posterAutoInclude = $("posterAutoInclude");
|
||||
const DRAFT_STORAGE_KEY = "aifagao:index:draft:v1";
|
||||
|
||||
let posterState = {
|
||||
signature: "",
|
||||
@@ -125,6 +129,123 @@ function setStatus(msg, danger = false) {
|
||||
statusEl.textContent = msg;
|
||||
}
|
||||
|
||||
function saveDraftState() {
|
||||
try {
|
||||
const data = {
|
||||
sourceText: ($("sourceText") && $("sourceText").value) || "",
|
||||
titleHint: ($("titleHint") && $("titleHint").value) || "",
|
||||
audienceExtra: ($("audienceExtra") && $("audienceExtra").value) || "",
|
||||
toneExtra: ($("toneExtra") && $("toneExtra").value) || "",
|
||||
avoidWords: ($("avoidWords") && $("avoidWords").value) || "",
|
||||
keepPoints: ($("keepPoints") && $("keepPoints").value) || "",
|
||||
targetBodyChars: ($("targetBodyChars") && $("targetBodyChars").value) || "500",
|
||||
title: ($("title") && $("title").value) || "",
|
||||
summary: ($("summary") && $("summary").value) || "",
|
||||
body: ($("body") && $("body").value) || "",
|
||||
thumbMediaId: ($("thumbMediaId") && $("thumbMediaId").value) || "",
|
||||
coverStyleHint: ($("coverStyleHint") && $("coverStyleHint").value) || "",
|
||||
coverImageModel: (coverImageModelInput && coverImageModelInput.value) || "",
|
||||
coverAutoAfterRewrite: Boolean(coverAutoAfterRewrite && coverAutoAfterRewrite.checked),
|
||||
posterAutoInclude: Boolean(posterAutoInclude && posterAutoInclude.checked),
|
||||
audienceChipValues: Array.from(document.querySelectorAll('input[name="audienceChip"]:checked')).map((n) => n.value),
|
||||
toneChipValues: Array.from(document.querySelectorAll('input[name="toneChip"]:checked')).map((n) => n.value),
|
||||
};
|
||||
window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDraftState() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const data = JSON.parse(raw);
|
||||
if (!data || typeof data !== "object") return;
|
||||
const setVal = (id, val) => {
|
||||
const el = $(id);
|
||||
if (!el || typeof val !== "string") return;
|
||||
el.value = val;
|
||||
};
|
||||
setVal("sourceText", data.sourceText || "");
|
||||
setVal("titleHint", data.titleHint || "");
|
||||
setVal("audienceExtra", data.audienceExtra || "");
|
||||
setVal("toneExtra", data.toneExtra || "");
|
||||
setVal("avoidWords", data.avoidWords || "");
|
||||
setVal("keepPoints", data.keepPoints || "");
|
||||
setVal("targetBodyChars", String(data.targetBodyChars || "500"));
|
||||
setVal("title", data.title || "");
|
||||
setVal("summary", data.summary || "");
|
||||
setVal("body", data.body || "");
|
||||
setVal("thumbMediaId", data.thumbMediaId || "");
|
||||
setVal("coverStyleHint", data.coverStyleHint || "");
|
||||
if (coverImageModelInput && typeof data.coverImageModel === "string" && data.coverImageModel.trim()) {
|
||||
coverImageModelInput.value = data.coverImageModel;
|
||||
}
|
||||
if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = Boolean(data.coverAutoAfterRewrite);
|
||||
if (posterAutoInclude) posterAutoInclude.checked = Boolean(data.posterAutoInclude);
|
||||
const audienceSet = new Set(Array.isArray(data.audienceChipValues) ? data.audienceChipValues : []);
|
||||
document.querySelectorAll('input[name="audienceChip"]').forEach((el) => {
|
||||
el.checked = audienceSet.size ? audienceSet.has(el.value) : el.checked;
|
||||
});
|
||||
const toneSet = new Set(Array.isArray(data.toneChipValues) ? data.toneChipValues : []);
|
||||
document.querySelectorAll('input[name="toneChip"]').forEach((el) => {
|
||||
el.checked = toneSet.size ? toneSet.has(el.value) : el.checked;
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraftState() {
|
||||
try {
|
||||
window.localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const clearIds = [
|
||||
"sourceText",
|
||||
"titleHint",
|
||||
"audienceExtra",
|
||||
"toneExtra",
|
||||
"avoidWords",
|
||||
"keepPoints",
|
||||
"title",
|
||||
"summary",
|
||||
"body",
|
||||
"thumbMediaId",
|
||||
"coverStyleHint",
|
||||
"coverUrl",
|
||||
];
|
||||
clearIds.forEach((id) => {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.value = "";
|
||||
});
|
||||
if ($("targetBodyChars")) $("targetBodyChars").value = "500";
|
||||
if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = false;
|
||||
if (posterAutoInclude) posterAutoInclude.checked = false;
|
||||
if (coverImageModelInput) coverImageModelInput.value = "";
|
||||
document.querySelectorAll('input[name="audienceChip"]').forEach((el) => {
|
||||
el.checked = false;
|
||||
});
|
||||
document.querySelectorAll('input[name="toneChip"]').forEach((el) => {
|
||||
el.checked = false;
|
||||
});
|
||||
if (coverPreviewWrap) coverPreviewWrap.hidden = true;
|
||||
if (coverPreview) coverPreview.src = "";
|
||||
if ($("coverFile")) $("coverFile").value = "";
|
||||
|
||||
posterState = { signature: "", bodyMarkdownWithPosters: "", posters: [] };
|
||||
renderPosterPreview([]);
|
||||
updateCounters();
|
||||
syncTargetCharChips();
|
||||
initMultiDropdowns();
|
||||
initImageModelStatus();
|
||||
if (posterHint) posterHint.textContent = "默认不生成海报,点击“生成段落海报”后再插入发布。";
|
||||
setStatus("草稿已清除。");
|
||||
}
|
||||
|
||||
function buildPosterSignature() {
|
||||
const title = ($("title") && $("title").value.trim()) || "";
|
||||
const summary = ($("summary") && $("summary").value.trim()) || "";
|
||||
@@ -183,7 +304,7 @@ function renderPosterPreview(posters) {
|
||||
function markPosterStaleIfNeeded() {
|
||||
if (!posterState.signature || !posterHint) return;
|
||||
if (posterState.signature !== buildPosterSignature()) {
|
||||
posterHint.textContent = "正文已修改,发布前会自动重建段落海报。";
|
||||
posterHint.textContent = "正文已修改,如需海报请手动点击“生成段落海报”。";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,11 +315,26 @@ async function postJSON(url, body) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.detail || "请求失败");
|
||||
err.payload = data;
|
||||
throw err;
|
||||
}
|
||||
data._requestId = res.headers.get("X-Request-ID") || "";
|
||||
return data;
|
||||
}
|
||||
|
||||
function handleUpgradeRequired(err) {
|
||||
const data = (err && err.payload) || {};
|
||||
const msg = (err && err.message) || data.detail || "";
|
||||
if (!data.upgrade_required && !msg.includes("免费额度已用完") && !msg.includes("余额不足")) return false;
|
||||
setStatus("免费额度已用完,请前往升级页充值或升级 VIP 用户。", true);
|
||||
window.setTimeout(() => {
|
||||
window.location.href = "/upgrade";
|
||||
}, 800);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function generatePosterMaterials({ silent = false } = {}) {
|
||||
const bodyMarkdown = (($("body") && $("body").value) || "").trim();
|
||||
if (bodyMarkdown.length < 20) {
|
||||
@@ -212,9 +348,14 @@ async function generatePosterMaterials({ silent = false } = {}) {
|
||||
title: $("title").value,
|
||||
summary: $("summary").value,
|
||||
body_markdown: $("body").value,
|
||||
image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "",
|
||||
upload_to_wechat: true,
|
||||
});
|
||||
if (!data.ok) throw new Error(data.detail || "海报生成失败");
|
||||
if (!data.ok) {
|
||||
const err = new Error(data.detail || "海报生成失败");
|
||||
err.payload = data;
|
||||
throw err;
|
||||
}
|
||||
|
||||
posterState = {
|
||||
signature: buildPosterSignature(),
|
||||
@@ -252,9 +393,14 @@ async function generateWechatCover({ silent = false } = {}) {
|
||||
title,
|
||||
summary: (($("summary") && $("summary").value) || "").trim(),
|
||||
style_hint: (($("coverStyleHint") && $("coverStyleHint").value) || "").trim(),
|
||||
image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "",
|
||||
upload_to_wechat: true,
|
||||
});
|
||||
if (!data.ok) throw new Error(data.detail || "封面生成失败");
|
||||
if (!data.ok) {
|
||||
const err = new Error(data.detail || "封面生成失败");
|
||||
err.payload = data;
|
||||
throw err;
|
||||
}
|
||||
const mid = data.thumb_media_id || "";
|
||||
if (mid && $("thumbMediaId")) $("thumbMediaId").value = mid;
|
||||
if (data.preview_data_url && coverPreview && coverPreviewWrap) {
|
||||
@@ -353,6 +499,20 @@ async function initWechatAccountSwitch() {
|
||||
if (me) renderWechatAccountSelect(me);
|
||||
}
|
||||
|
||||
async function initImageModelStatus() {
|
||||
try {
|
||||
const me = await fetch("/api/auth/me").then((r) => r.json());
|
||||
const active = me && me.active_ai_model ? me.active_ai_model : null;
|
||||
const imageModel = active && active.image_model ? String(active.image_model).trim() : "";
|
||||
if (coverImageModelInput) {
|
||||
const current = (coverImageModelInput.value || "").trim();
|
||||
if (!current) coverImageModelInput.value = imageModel || "wanx2.0-t2i-turbo";
|
||||
}
|
||||
} catch {
|
||||
if (coverImageModelInput && !(coverImageModelInput.value || "").trim()) coverImageModelInput.value = "wanx2.0-t2i-turbo";
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutAndGoAuth() {
|
||||
try {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
@@ -369,6 +529,12 @@ if (logoutBtn) {
|
||||
});
|
||||
}
|
||||
|
||||
if (clearDraftBtn) {
|
||||
clearDraftBtn.addEventListener("click", () => {
|
||||
clearDraftState();
|
||||
});
|
||||
}
|
||||
|
||||
if (coverModeManualBtn) {
|
||||
coverModeManualBtn.addEventListener("click", () => setCoverMode("manual"));
|
||||
}
|
||||
@@ -419,6 +585,7 @@ $("rewriteBtn").addEventListener("click", async () => {
|
||||
$("summary").value = data.summary || "";
|
||||
$("body").value = data.body_markdown || "";
|
||||
updateCounters();
|
||||
saveDraftState();
|
||||
const tr = data.trace || {};
|
||||
if (data.mode === "fallback") {
|
||||
const note = (data.quality_notes || [])[0] || "当前为保底改写稿";
|
||||
@@ -429,23 +596,19 @@ $("rewriteBtn").addEventListener("click", async () => {
|
||||
} else {
|
||||
setStatus("改写完成。");
|
||||
}
|
||||
try {
|
||||
setStatus("改写完成,正在生成段落海报...");
|
||||
await generatePosterMaterials({ silent: true });
|
||||
setStatus("改写与段落海报生成完成。");
|
||||
} catch (posterErr) {
|
||||
setStatus(`改写完成,段落海报未生成:${posterErr.message}`, true);
|
||||
}
|
||||
if (posterHint) posterHint.textContent = "改写完成。默认不自动生成海报,可手动点击“生成段落海报”。";
|
||||
if (coverAutoAfterRewrite && coverAutoAfterRewrite.checked) {
|
||||
try {
|
||||
setStatus("改写完成,正在按输出标题生成封面...");
|
||||
await generateWechatCover({ silent: true });
|
||||
setStatus("改写、封面与段落海报生成完成。");
|
||||
} catch (coverErr) {
|
||||
if (handleUpgradeRequired(coverErr)) return;
|
||||
setStatus(`改写完成,封面未生成:${coverErr.message}`, true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (handleUpgradeRequired(e)) return;
|
||||
setStatus(`改写失败: ${e.message}`, true);
|
||||
} finally {
|
||||
setLoading(rewriteBtn, false, "改写并排版", "改写中...");
|
||||
@@ -460,15 +623,10 @@ $("wechatBtn").addEventListener("click", async () => {
|
||||
const autoInclude = Boolean(posterAutoInclude && posterAutoInclude.checked);
|
||||
if (autoInclude) {
|
||||
const stale = posterState.signature !== buildPosterSignature() || !posterState.bodyMarkdownWithPosters;
|
||||
if (stale) {
|
||||
try {
|
||||
await generatePosterMaterials({ silent: true });
|
||||
} catch (posterErr) {
|
||||
setStatus(`海报生成失败,本次仅发布文字:${posterErr.message}`, true);
|
||||
}
|
||||
}
|
||||
if (posterState.bodyMarkdownWithPosters) {
|
||||
if (!stale && posterState.bodyMarkdownWithPosters) {
|
||||
bodyForPublish = posterState.bodyMarkdownWithPosters;
|
||||
} else {
|
||||
setStatus("未检测到可用海报,本次仅发布文字;如需海报请先手动生成。", true);
|
||||
}
|
||||
}
|
||||
const data = await postJSON("/api/publish/wechat", {
|
||||
@@ -495,7 +653,9 @@ if (coverGenerateBtn) {
|
||||
coverGenerateBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await generateWechatCover({ silent: false });
|
||||
saveDraftState();
|
||||
} catch (e) {
|
||||
if (handleUpgradeRequired(e)) return;
|
||||
const hint = $("coverHint");
|
||||
if (hint) hint.textContent = "AI 封面生成失败,请检查标题、模型或公众号配置。";
|
||||
setStatus(`封面生成失败: ${e.message}`, true);
|
||||
@@ -508,6 +668,30 @@ if (coverGenerateBtn) {
|
||||
});
|
||||
}
|
||||
|
||||
if (saveCoverImageModelBtn) {
|
||||
saveCoverImageModelBtn.addEventListener("click", async () => {
|
||||
const value = (coverImageModelInput && coverImageModelInput.value.trim()) || "";
|
||||
if (!value) {
|
||||
setStatus("请先填写文生图模型", true);
|
||||
return;
|
||||
}
|
||||
setLoading(saveCoverImageModelBtn, true, "保存模型", "保存中...");
|
||||
try {
|
||||
const out = await postJSON("/api/auth/ai-models/image-model/update", { image_model: value });
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "保存失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("文生图模型已保存。");
|
||||
saveDraftState();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "保存失败", true);
|
||||
} finally {
|
||||
setLoading(saveCoverImageModelBtn, false, "保存模型", "保存中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (coverUploadBtn) {
|
||||
coverUploadBtn.addEventListener("click", async () => {
|
||||
const fileInput = $("coverFile");
|
||||
@@ -529,6 +713,7 @@ if (coverUploadBtn) {
|
||||
if ($("thumbMediaId")) $("thumbMediaId").value = mid;
|
||||
if (hint) hint.textContent = `封面上传成功,已绑定 media_id:${mid}`;
|
||||
setStatus("封面上传成功,发布时将优先使用该封面。");
|
||||
saveDraftState();
|
||||
} catch (e) {
|
||||
if (hint) hint.textContent = "封面上传失败,请看状态提示。";
|
||||
setStatus(`封面上传失败: ${e.message}`, true);
|
||||
@@ -561,6 +746,7 @@ if (coverUrlUploadBtn) {
|
||||
if (hint) hint.textContent = `URL 封面上传成功,已绑定 media_id:${mid}`;
|
||||
setStatus("URL 封面上传成功,发布时将优先使用该封面。");
|
||||
if ($("coverUrl")) $("coverUrl").value = "";
|
||||
saveDraftState();
|
||||
} catch (e) {
|
||||
if (hint) hint.textContent = "URL 封面上传失败,请看状态提示。";
|
||||
setStatus(`URL 封面上传失败: ${e.message}`, true);
|
||||
@@ -579,7 +765,9 @@ if (posterGenerateBtn) {
|
||||
posterGenerateBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await generatePosterMaterials({ silent: false });
|
||||
saveDraftState();
|
||||
} catch (e) {
|
||||
if (handleUpgradeRequired(e)) return;
|
||||
setStatus(`海报生成失败: ${e.message}`, true);
|
||||
if (posterHint) posterHint.textContent = "海报生成失败,请检查配置后重试。";
|
||||
if ((e.message || "").includes("请先登录")) {
|
||||
@@ -610,13 +798,30 @@ $("imBtn").addEventListener("click", async () => {
|
||||
|
||||
["sourceText", "title", "summary", "body"].forEach((id) => {
|
||||
$(id).addEventListener("input", updateCounters);
|
||||
$(id).addEventListener("input", saveDraftState);
|
||||
if (id !== "sourceText") $(id).addEventListener("input", markPosterStaleIfNeeded);
|
||||
});
|
||||
|
||||
["titleHint", "audienceExtra", "toneExtra", "avoidWords", "keepPoints", "targetBodyChars", "thumbMediaId", "coverStyleHint"].forEach(
|
||||
(id) => {
|
||||
const el = $(id);
|
||||
if (el) el.addEventListener("input", saveDraftState);
|
||||
},
|
||||
);
|
||||
document.querySelectorAll('input[name="audienceChip"],input[name="toneChip"]').forEach((el) => {
|
||||
el.addEventListener("change", saveDraftState);
|
||||
});
|
||||
if (coverAutoAfterRewrite) coverAutoAfterRewrite.addEventListener("change", saveDraftState);
|
||||
if (posterAutoInclude) posterAutoInclude.addEventListener("change", saveDraftState);
|
||||
if (coverImageModelInput) coverImageModelInput.addEventListener("input", saveDraftState);
|
||||
|
||||
restoreDraftState();
|
||||
updateCounters();
|
||||
initMultiDropdowns();
|
||||
initWechatAccountSwitch();
|
||||
syncTargetCharChips();
|
||||
renderPosterPreview([]);
|
||||
setCoverMode("manual");
|
||||
initImageModelStatus();
|
||||
window.addEventListener("beforeunload", saveDraftState);
|
||||
window.addEventListener("load", () => setCoverMode("manual"));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let challengeId = "";
|
||||
|
||||
function setStatus(msg, danger = false) {
|
||||
const el = $("status");
|
||||
@@ -24,6 +25,21 @@ async function postJSON(url, body) {
|
||||
return data;
|
||||
}
|
||||
|
||||
async function refreshChallenge() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/challenge");
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.detail || "校验题加载失败");
|
||||
challengeId = (data.challenge_id || "").trim();
|
||||
const label = $("challengeLabel");
|
||||
if (label) label.textContent = `人机校验:${data.question || ""}`;
|
||||
const ans = $("challengeAnswer");
|
||||
if (ans) ans.value = "";
|
||||
} catch {
|
||||
setStatus("校验题加载失败,请刷新页面重试", true);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPath() {
|
||||
const nxt = (window.__NEXT_PATH__ || "/").trim();
|
||||
if (!nxt.startsWith("/")) return "/";
|
||||
@@ -35,6 +51,9 @@ function fields() {
|
||||
username: ($("username") && $("username").value.trim()) || "",
|
||||
password: ($("password") && $("password").value) || "",
|
||||
remember_me: Boolean($("rememberMe") && $("rememberMe").checked),
|
||||
challenge_id: challengeId,
|
||||
challenge_answer: ($("challengeAnswer") && $("challengeAnswer").value.trim()) || "",
|
||||
honeypot: ($("botTrap") && $("botTrap").value) || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,6 +76,7 @@ async function authAction(url, button, idleText, loadingText, okMessage) {
|
||||
|
||||
const loginBtn = $("loginBtn");
|
||||
const registerBtn = $("registerBtn");
|
||||
const refreshChallengeBtn = $("refreshChallengeBtn");
|
||||
|
||||
if (loginBtn) {
|
||||
loginBtn.addEventListener("click", async () => {
|
||||
@@ -78,7 +98,7 @@ if (registerBtn) {
|
||||
const msg =
|
||||
`注册成功!请务必保存你的重置码(找回密码唯一凭证):\n\n${code}\n\n` +
|
||||
"请立即复制并妥善保管,点击“确定”后继续进入系统。";
|
||||
window.alert(msg);
|
||||
await window.uiAlert(msg, "注册成功");
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
@@ -91,8 +111,19 @@ if (registerBtn) {
|
||||
window.location.href = nextPath();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "请求异常", true);
|
||||
await refreshChallenge();
|
||||
} finally {
|
||||
setLoading(registerBtn, false, "注册", "注册中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshChallengeBtn) {
|
||||
refreshChallengeBtn.addEventListener("click", async () => {
|
||||
setLoading(refreshChallengeBtn, true, "刷新题目", "刷新中...");
|
||||
await refreshChallenge();
|
||||
setLoading(refreshChallengeBtn, false, "刷新题目", "刷新中...");
|
||||
});
|
||||
}
|
||||
|
||||
refreshChallenge();
|
||||
|
||||
340
app/static/billing.js
Normal file
340
app/static/billing.js
Normal file
@@ -0,0 +1,340 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function setStatus(msg, danger = false) {
|
||||
const el = $("status");
|
||||
if (!el) return;
|
||||
el.style.color = danger ? "#b42318" : "#0f5f3d";
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
function setLoading(button, loading, idleText, loadingText) {
|
||||
if (!button) return;
|
||||
button.disabled = loading;
|
||||
button.textContent = loading ? loadingText : idleText;
|
||||
}
|
||||
|
||||
function importantNotice(message, title = "提示") {
|
||||
if (typeof window.uiAlert === "function") {
|
||||
void window.uiAlert(message, title);
|
||||
return;
|
||||
}
|
||||
setStatus(message, true);
|
||||
}
|
||||
|
||||
function openPayLink(url) {
|
||||
const payUrl = String(url || "").trim();
|
||||
if (!payUrl) return false;
|
||||
const tab = window.open(payUrl, "_blank", "noopener");
|
||||
if (tab) return true;
|
||||
window.location.href = payUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function postJSON(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function authMe() {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
if (!data.logged_in) {
|
||||
window.location.href = "/auth?next=/billing";
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
const n = Number(ts || 0);
|
||||
if (!n) return "-";
|
||||
return new Date(n * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function mapOrderStatus(status) {
|
||||
const s = String(status || "").toLowerCase();
|
||||
if (s === "paid" || s === "success") return "已支付";
|
||||
if (s === "pending") return "待支付";
|
||||
if (s === "failed") return "支付失败";
|
||||
if (s === "cancelled") return "已取消";
|
||||
if (s === "closed") return "已关闭";
|
||||
return status || "-";
|
||||
}
|
||||
|
||||
function mapChannel(channel) {
|
||||
const c = String(channel || "").toLowerCase();
|
||||
if (c === "wechat") return "微信支付";
|
||||
if (c === "alipay") return "支付宝";
|
||||
if (c === "stripe") return "Stripe";
|
||||
return channel || "-";
|
||||
}
|
||||
|
||||
function formatDetail(detail) {
|
||||
if (!detail || typeof detail !== "object") return "-";
|
||||
const rows = [];
|
||||
if (detail.source_chars) rows.push(`原文${detail.source_chars}字`);
|
||||
if (detail.body_chars) rows.push(`正文${detail.body_chars}字`);
|
||||
if (detail.title_chars) rows.push(`标题${detail.title_chars}字`);
|
||||
if (detail.summary_chars) rows.push(`摘要${detail.summary_chars}字`);
|
||||
if (detail.image_count) rows.push(`图片${detail.image_count}张`);
|
||||
if (detail.prompt_tokens) rows.push(`输入Token:${detail.prompt_tokens}`);
|
||||
if (detail.completion_tokens) rows.push(`输出Token:${detail.completion_tokens}`);
|
||||
if (detail.total_tokens) rows.push(`总Token:${detail.total_tokens}`);
|
||||
if (detail.model) rows.push(`模型:${detail.model}`);
|
||||
if (detail.image_model) rows.push(`生图模型:${detail.image_model}`);
|
||||
if (detail.image_price_package_images && detail.image_price_package_cny) {
|
||||
rows.push(`图片计价:${detail.image_price_package_images}张=${Number(detail.image_price_package_cny).toFixed(2)}元`);
|
||||
}
|
||||
if (detail.credits_rule) rows.push(`规则:${detail.credits_rule}`);
|
||||
if (detail.billed_basis === "usage_tokens") rows.push("按真实Token计费");
|
||||
if (detail.billed_basis === "char_estimate") rows.push("按字符估算计费");
|
||||
if (detail.paid_amount_cny) rows.push(`实付¥${Number(detail.paid_amount_cny).toFixed(2)}`);
|
||||
if (detail.external_txn_id) rows.push(`交易号:${detail.external_txn_id}`);
|
||||
if (detail.credit_source && typeof detail.credit_source === "object") {
|
||||
const seat = Number(detail.credit_source.seat || 0);
|
||||
const shared = Number(detail.credit_source.shared || 0);
|
||||
rows.push(`抵扣来源:席位${seat}/共享${shared}`);
|
||||
}
|
||||
return rows.length ? rows.join(" | ") : "-";
|
||||
}
|
||||
|
||||
function renderRechargeRecords(records) {
|
||||
const el = $("rechargeRecords");
|
||||
if (!el) return;
|
||||
const list = Array.isArray(records) ? records : [];
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<p class="muted small">暂无充值记录</p>';
|
||||
return;
|
||||
}
|
||||
const rows = list
|
||||
.map((r) => {
|
||||
const statusText = mapOrderStatus(r.status);
|
||||
const statusClass =
|
||||
statusText === "已支付"
|
||||
? "paid"
|
||||
: statusText === "待支付"
|
||||
? "pending"
|
||||
: statusText === "支付失败"
|
||||
? "failed"
|
||||
: "closed";
|
||||
return `<tr>
|
||||
<td class="mono">${r.order_no || "-"}</td>
|
||||
<td><span class="billing-badge ${statusClass}">${statusText}</span></td>
|
||||
<td>${mapChannel(r.channel)}</td>
|
||||
<td>${Number(r.token_amount || 0)}</td>
|
||||
<td>¥${Number(r.amount_cny || 0).toFixed(2)}</td>
|
||||
<td>${fmtTime(r.created_at)}</td>
|
||||
<td>${r.paid_at ? fmtTime(r.paid_at) : "-"}</td>
|
||||
<td>${
|
||||
statusText === "待支付"
|
||||
? `<button class="primary pay-now-btn" data-order-no="${r.order_no || ""}" type="button">立即支付</button>`
|
||||
: "-"
|
||||
}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
el.innerHTML = `<table class="billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>状态</th>
|
||||
<th>渠道</th>
|
||||
<th>Credits</th>
|
||||
<th>金额</th>
|
||||
<th>创建时间</th>
|
||||
<th>支付时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function renderConsumeRecords(records) {
|
||||
const el = $("consumeRecords");
|
||||
if (!el) return;
|
||||
const list = Array.isArray(records) ? records : [];
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<p class="muted small">暂无消费记录</p>';
|
||||
return;
|
||||
}
|
||||
const kindTextMap = {
|
||||
trial_grant: "试用赠送",
|
||||
paid_recharge: "充值到账",
|
||||
manual_recharge: "手动充值",
|
||||
rewrite: "AI改写",
|
||||
cover_generate: "封面生成",
|
||||
poster_generate: "段落海报",
|
||||
usage: "模型调用",
|
||||
};
|
||||
const rows = list
|
||||
.map((r) => {
|
||||
const kindText = kindTextMap[r.kind] || r.kind || "-";
|
||||
const delta = `${r.direction === "out" ? "-" : "+"}${Number(r.token_change || 0)}`;
|
||||
const ref = `${r.ref_type || "-"} ${r.ref_id || ""}`.trim();
|
||||
const detail = formatDetail(r.detail);
|
||||
return `<tr>
|
||||
<td>${kindText}</td>
|
||||
<td class="${r.direction === "out" ? "out" : "in"}">${delta}</td>
|
||||
<td>${Number(r.balance_after || 0)}</td>
|
||||
<td>${ref}</td>
|
||||
<td>${detail}</td>
|
||||
<td>${fmtTime(r.created_at)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
el.innerHTML = `<table class="billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>Credits变动</th>
|
||||
<th>余额</th>
|
||||
<th>关联</th>
|
||||
<th>明细</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function refreshBilling() {
|
||||
const data = await fetch("/api/billing/overview").then((r) => r.json());
|
||||
if (!data.ok) throw new Error(data.detail || "账单加载失败");
|
||||
renderRechargeRecords(data.recharge_records || []);
|
||||
renderConsumeRecords(data.consume_records || []);
|
||||
}
|
||||
|
||||
const createRechargeOrderBtn = $("createRechargeOrderBtn");
|
||||
const refreshBillingBtn = $("refreshBillingBtn");
|
||||
const logoutBtn = $("logoutBtn");
|
||||
const billingRechargeTokensInput = $("billingRechargeTokens");
|
||||
const billingRechargeAmountInput = $("billingRechargeAmount");
|
||||
|
||||
function packageRate() {
|
||||
const credits = Number((billingRechargeTokensInput && billingRechargeTokensInput.dataset.packageCredits) || "1500");
|
||||
const amount = Number((billingRechargeAmountInput && billingRechargeAmountInput.dataset.packageAmount) || "19.9");
|
||||
return {
|
||||
packageCredits: Number.isFinite(credits) && credits > 0 ? credits : 1500,
|
||||
packageAmount: Number.isFinite(amount) && amount > 0 ? amount : 19.9,
|
||||
};
|
||||
}
|
||||
|
||||
function syncBillingAmount() {
|
||||
if (!billingRechargeTokensInput || !billingRechargeAmountInput) return;
|
||||
const credits = Number((billingRechargeTokensInput.value || "0").trim());
|
||||
if (!Number.isFinite(credits) || credits <= 0) return;
|
||||
const { packageCredits, packageAmount } = packageRate();
|
||||
const amount = (credits / packageCredits) * packageAmount;
|
||||
billingRechargeAmountInput.value = amount.toFixed(2);
|
||||
}
|
||||
|
||||
if (billingRechargeTokensInput) billingRechargeTokensInput.addEventListener("input", syncBillingAmount);
|
||||
if (billingRechargeAmountInput) billingRechargeAmountInput.readOnly = true;
|
||||
|
||||
if (createRechargeOrderBtn) {
|
||||
createRechargeOrderBtn.addEventListener("click", async () => {
|
||||
setLoading(createRechargeOrderBtn, true, "创建充值订单", "创建中...");
|
||||
try {
|
||||
const tokens = Number((($("billingRechargeTokens") && $("billingRechargeTokens").value) || "0").trim());
|
||||
const amount = Number((($("billingRechargeAmount") && $("billingRechargeAmount").value) || "0").trim());
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) {
|
||||
setStatus("请输入正确的充值 Credits 数量", true);
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setStatus("请输入正确的支付金额", true);
|
||||
return;
|
||||
}
|
||||
const out = await postJSON("/api/pay/wechat/", {
|
||||
tokens: Math.round(tokens),
|
||||
amount_cny: Number(amount.toFixed(2)),
|
||||
channel: "wechat",
|
||||
});
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "创建订单失败", true);
|
||||
return;
|
||||
}
|
||||
const orderNo = out.order && out.order.order_no ? out.order.order_no : "";
|
||||
if (out.pay_url) {
|
||||
openPayLink(out.pay_url);
|
||||
} else {
|
||||
importantNotice("订单已创建,但未获取到支付链接,请检查支付网关配置。", "支付链接缺失");
|
||||
}
|
||||
setStatus(`订单已创建:${orderNo}`);
|
||||
await refreshBilling();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "创建订单失败", true);
|
||||
} finally {
|
||||
setLoading(createRechargeOrderBtn, false, "创建充值订单", "创建中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rechargeRecordsWrap = $("rechargeRecords");
|
||||
if (rechargeRecordsWrap) {
|
||||
rechargeRecordsWrap.addEventListener("click", async (evt) => {
|
||||
const btn = evt.target && evt.target.closest ? evt.target.closest(".pay-now-btn") : null;
|
||||
if (!btn) return;
|
||||
const orderNo = (btn.getAttribute("data-order-no") || "").trim();
|
||||
if (!orderNo) return;
|
||||
setLoading(btn, true, "立即支付", "拉起中...");
|
||||
try {
|
||||
const out = await postJSON("/api/billing/recharge/pay-now", { order_no: orderNo });
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "拉起支付失败", true);
|
||||
await refreshBilling();
|
||||
return;
|
||||
}
|
||||
if (out.pay_url) {
|
||||
openPayLink(out.pay_url);
|
||||
} else {
|
||||
importantNotice("未获取到支付链接,请检查支付网关配置。", "立即支付失败");
|
||||
}
|
||||
setStatus(`订单 ${orderNo} 已拉起支付。`);
|
||||
await refreshBilling();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "拉起支付失败", true);
|
||||
await refreshBilling();
|
||||
} finally {
|
||||
setLoading(btn, false, "立即支付", "拉起中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshBillingBtn) {
|
||||
refreshBillingBtn.addEventListener("click", async () => {
|
||||
setLoading(refreshBillingBtn, true, "刷新账单记录", "刷新中...");
|
||||
try {
|
||||
await refreshBilling();
|
||||
setStatus("账单已刷新。");
|
||||
} catch (e) {
|
||||
setStatus(e.message || "账单刷新失败", true);
|
||||
} finally {
|
||||
setLoading(refreshBillingBtn, false, "刷新账单记录", "刷新中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
} finally {
|
||||
window.location.href = "/auth?next=/";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const me = await authMe();
|
||||
if (!me) return;
|
||||
syncBillingAmount();
|
||||
await refreshBilling();
|
||||
})();
|
||||
97
app/static/profile.js
Normal file
97
app/static/profile.js
Normal file
@@ -0,0 +1,97 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function setStatus(msg, danger = false) {
|
||||
const el = $("status");
|
||||
if (!el) return;
|
||||
el.style.color = danger ? "#b42318" : "#0f5f3d";
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
function setLoading(button, loading, idleText, loadingText) {
|
||||
if (!button) return;
|
||||
button.disabled = loading;
|
||||
button.textContent = loading ? loadingText : idleText;
|
||||
}
|
||||
|
||||
async function postJSON(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function authMe() {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
if (!data.logged_in) {
|
||||
window.location.href = "/auth?next=/profile";
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
const res = await fetch("/api/profile");
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.detail || "个人信息加载失败");
|
||||
const profile = data.profile || {};
|
||||
if ($("profileSubscriberName")) $("profileSubscriberName").value = profile.subscriber_name || "";
|
||||
if ($("profileSubscriberPhone")) $("profileSubscriberPhone").value = profile.subscriber_phone || "";
|
||||
if ($("profileShippingAddress")) $("profileShippingAddress").value = profile.shipping_address || "";
|
||||
}
|
||||
|
||||
const saveProfileBtn = $("saveProfileBtn");
|
||||
const logoutBtn = $("logoutBtn");
|
||||
|
||||
if (saveProfileBtn) {
|
||||
saveProfileBtn.addEventListener("click", async () => {
|
||||
setLoading(saveProfileBtn, true, "保存个人信息", "保存中...");
|
||||
try {
|
||||
const subscriberName = (($("profileSubscriberName") && $("profileSubscriberName").value) || "").trim();
|
||||
const subscriberPhone = (($("profileSubscriberPhone") && $("profileSubscriberPhone").value) || "").trim();
|
||||
const shippingAddress = (($("profileShippingAddress") && $("profileShippingAddress").value) || "").trim();
|
||||
if (!subscriberName) {
|
||||
setStatus("请填写订阅人姓名", true);
|
||||
return;
|
||||
}
|
||||
if (!shippingAddress) {
|
||||
setStatus("请填写收货地址", true);
|
||||
return;
|
||||
}
|
||||
const out = await postJSON("/api/profile", {
|
||||
subscriber_name: subscriberName,
|
||||
subscriber_phone: subscriberPhone,
|
||||
shipping_address: shippingAddress,
|
||||
});
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "保存失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("个人信息已保存。");
|
||||
} catch (e) {
|
||||
setStatus(e.message || "保存失败", true);
|
||||
} finally {
|
||||
setLoading(saveProfileBtn, false, "保存个人信息", "保存中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
} finally {
|
||||
window.location.href = "/auth?next=/";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const me = await authMe();
|
||||
if (!me) return;
|
||||
await loadProfile();
|
||||
})();
|
||||
@@ -72,18 +72,28 @@ function renderModels(me) {
|
||||
list.forEach((m) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(m.id);
|
||||
const imageModel = (m.image_model || "").trim();
|
||||
opt.textContent = imageModel ? `${m.model_name} (${m.model} / 图:${imageModel})` : `${m.model_name} (${m.model})`;
|
||||
opt.textContent = `${m.model_name} (${m.model})`;
|
||||
if ((active && m.id === active) || m.active) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function renderVip(me) {
|
||||
const vip = me && me.vip ? me.vip : {};
|
||||
const enabledSelect = $("vipEnabledSelect");
|
||||
const tokenBalance = $("vipTokenBalance");
|
||||
const totalConsumed = $("vipTotalConsumed");
|
||||
if (enabledSelect) enabledSelect.value = vip.vip_enabled ? "1" : "0";
|
||||
if (tokenBalance) tokenBalance.value = String(Number(vip.token_balance || 0));
|
||||
if (totalConsumed) totalConsumed.value = String(Number(vip.total_consumed_tokens || 0));
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const me = await authMe();
|
||||
if (!me) return;
|
||||
renderAccounts(me);
|
||||
renderModels(me);
|
||||
renderVip(me);
|
||||
}
|
||||
|
||||
const accountSelect = $("accountSelect");
|
||||
@@ -95,6 +105,8 @@ const deleteAccountBtn = $("deleteAccountBtn");
|
||||
const modelSelect = $("modelSelect");
|
||||
const saveModelBtn = $("saveModelBtn");
|
||||
const deleteModelBtn = $("deleteModelBtn");
|
||||
const saveVipBtn = $("saveVipBtn");
|
||||
const vipRechargeBtn = $("vipRechargeBtn");
|
||||
|
||||
if (accountSelect) {
|
||||
accountSelect.addEventListener("change", async () => {
|
||||
@@ -121,7 +133,7 @@ if (deleteWechatBtn) {
|
||||
setStatus("请先选择要删除的公众号", true);
|
||||
return;
|
||||
}
|
||||
const sure = window.confirm("确定删除当前公众号绑定吗?删除后不可恢复。");
|
||||
const sure = await window.uiConfirm("确定删除当前公众号绑定吗?删除后不可恢复。", "删除公众号");
|
||||
if (!sure) return;
|
||||
setLoading(deleteWechatBtn, true, "删除当前公众号", "删除中...");
|
||||
try {
|
||||
@@ -195,7 +207,7 @@ if (saveModelBtn) {
|
||||
api_key: ($("apiKey") && $("apiKey").value.trim()) || "",
|
||||
base_url: ($("baseUrl") && $("baseUrl").value.trim()) || "",
|
||||
model: ($("modelValue") && $("modelValue").value.trim()) || "",
|
||||
image_model: ($("imageModelValue") && $("imageModelValue").value.trim()) || "",
|
||||
image_model: "",
|
||||
timeout_sec: Number((($("timeoutSec") && $("timeoutSec").value) || "120").trim()),
|
||||
max_output_tokens: Number((($("maxOutputTokens") && $("maxOutputTokens").value) || "8192").trim()),
|
||||
max_retries: Number((($("maxRetries") && $("maxRetries").value) || "0").trim()),
|
||||
@@ -207,7 +219,6 @@ if (saveModelBtn) {
|
||||
setStatus("模型配置已保存并设为当前。");
|
||||
if ($("apiKey")) $("apiKey").value = "";
|
||||
if ($("modelName")) $("modelName").value = "";
|
||||
if ($("imageModelValue")) $("imageModelValue").value = "";
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "模型保存失败", true);
|
||||
@@ -224,7 +235,7 @@ if (deleteModelBtn) {
|
||||
setStatus("请先选择要删除的模型", true);
|
||||
return;
|
||||
}
|
||||
const sure = window.confirm("确定删除当前模型配置吗?删除后不可恢复。");
|
||||
const sure = await window.uiConfirm("确定删除当前模型配置吗?删除后不可恢复。", "删除模型");
|
||||
if (!sure) return;
|
||||
setLoading(deleteModelBtn, true, "删除当前模型", "删除中...");
|
||||
try {
|
||||
@@ -243,6 +254,61 @@ if (deleteModelBtn) {
|
||||
});
|
||||
}
|
||||
|
||||
if (saveVipBtn) {
|
||||
saveVipBtn.addEventListener("click", async () => {
|
||||
setLoading(saveVipBtn, true, "保存 VIP 设置", "保存中...");
|
||||
try {
|
||||
const enabled = (($("vipEnabledSelect") && $("vipEnabledSelect").value) || "0") === "1";
|
||||
const out = await postJSON("/api/auth/vip/toggle", { enabled });
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "VIP 设置保存失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("VIP 设置已更新。");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "VIP 设置保存失败", true);
|
||||
} finally {
|
||||
setLoading(saveVipBtn, false, "保存 VIP 设置", "保存中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (vipRechargeBtn) {
|
||||
vipRechargeBtn.addEventListener("click", async () => {
|
||||
setLoading(vipRechargeBtn, true, "充值 Token", "创建订单中...");
|
||||
try {
|
||||
const tokens = Number((($("vipRechargeTokens") && $("vipRechargeTokens").value) || "0").trim());
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) {
|
||||
setStatus("请输入正确的充值数量", true);
|
||||
return;
|
||||
}
|
||||
const out = await postJSON("/api/pay/wechat/", {
|
||||
tokens: Math.round(tokens),
|
||||
amount_cny: Number((((Number(tokens) / 10000) * 9.9) || 9.9).toFixed(2)),
|
||||
channel: "wechat",
|
||||
});
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "充值失败", true);
|
||||
return;
|
||||
}
|
||||
if (out.pay_url) {
|
||||
window.open(out.pay_url, "_blank", "noopener");
|
||||
setStatus("订单已创建,请在新窗口完成支付。");
|
||||
} else {
|
||||
setStatus("订单已创建,但未获取到支付链接,请联系管理员配置购物系统。", true);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
window.location.href = "/billing";
|
||||
}, 400);
|
||||
} catch (e) {
|
||||
setStatus(e.message || "充值失败", true);
|
||||
} finally {
|
||||
setLoading(vipRechargeBtn, false, "充值 Token", "创建订单中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
setLoading(logoutBtn, true, "退出登录", "退出中...");
|
||||
@@ -292,9 +358,14 @@ if (deleteAccountBtn) {
|
||||
setStatus("请输入注销校验重置码", true);
|
||||
return;
|
||||
}
|
||||
const sure = window.confirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。");
|
||||
const sure = await window.uiConfirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。", "注销账户");
|
||||
if (!sure) return;
|
||||
const confirmText = window.prompt("为防止误删,请输入「注销账户」后确认:", "");
|
||||
const confirmText = await window.uiPrompt(
|
||||
"为防止误删,请输入「注销账户」后确认:",
|
||||
"二次确认",
|
||||
"",
|
||||
"请输入:注销账户",
|
||||
);
|
||||
if ((confirmText || "").trim() !== "注销账户") {
|
||||
setStatus("二次确认未通过,已取消注销。", true);
|
||||
return;
|
||||
|
||||
@@ -257,6 +257,17 @@ a {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
padding-right: 20px;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #8a6f58 50%),
|
||||
linear-gradient(135deg, #8a6f58 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 12px) calc(50% - 2px),
|
||||
calc(100% - 7px) calc(50% - 2px);
|
||||
background-size:
|
||||
5px 5px,
|
||||
5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.topbar-select:focus {
|
||||
@@ -441,6 +452,45 @@ button {
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
padding-right: 34px;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #8a6f58 50%),
|
||||
linear-gradient(135deg, #8a6f58 50%, transparent 50%),
|
||||
linear-gradient(180deg, #ffffff, #ffffff);
|
||||
background-position:
|
||||
calc(100% - 16px) calc(50% - 2px),
|
||||
calc(100% - 11px) calc(50% - 2px),
|
||||
0 0;
|
||||
background-size:
|
||||
6px 6px,
|
||||
6px 6px,
|
||||
100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
color: var(--faint);
|
||||
border-color: var(--line);
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #c4af98 50%),
|
||||
linear-gradient(135deg, #c4af98 50%, transparent 50%),
|
||||
linear-gradient(180deg, #f8f3ec, #f8f3ec);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-select {
|
||||
background-color: #fffdf8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
@@ -770,6 +820,49 @@ button.target-char-chip {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-model-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 122, 26, 0.24);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, #fff8ed, #fff0dc);
|
||||
}
|
||||
|
||||
.image-model-banner div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.image-model-banner strong {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.image-model-banner span {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-model-banner a {
|
||||
flex: 0 0 auto;
|
||||
color: var(--accent-2);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-model-banner a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cover-mode-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -929,6 +1022,92 @@ button.target-char-chip {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.billing-list {
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.billing-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.billing-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 760px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.billing-table th,
|
||||
.billing-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.billing-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #fff8ed;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.billing-table tbody tr:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.billing-table .mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.billing-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 64px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.billing-badge.pending {
|
||||
color: #b54708;
|
||||
background: #fffaeb;
|
||||
}
|
||||
|
||||
.billing-badge.paid {
|
||||
color: #027a48;
|
||||
background: #ecfdf3;
|
||||
}
|
||||
|
||||
.billing-badge.failed {
|
||||
color: #b42318;
|
||||
background: #fef3f2;
|
||||
}
|
||||
|
||||
.billing-badge.closed {
|
||||
color: #475467;
|
||||
background: #f2f4f7;
|
||||
}
|
||||
|
||||
.billing-table td.in {
|
||||
color: #027a48;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.billing-table td.out {
|
||||
color: #b42318;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.poster-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
@@ -1093,6 +1272,33 @@ button.target-char-chip {
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.model-image-config {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 122, 26, 0.28);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, #fffaf3, #fff0dc);
|
||||
}
|
||||
|
||||
.model-image-config strong,
|
||||
.model-image-config span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.model-image-config strong {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.model-image-config span {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
@@ -1318,6 +1524,525 @@ button.target-char-chip {
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.upgrade-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.upgrade-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.upgrade-scroll {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.upgrade-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, #fff, #fff0dc);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.upgrade-hero h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.upgrade-hero p {
|
||||
max-width: 620px;
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.55;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upgrade-balance-card {
|
||||
min-width: 160px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #ffb23f, #ff6b16);
|
||||
color: #fff;
|
||||
box-shadow: 0 18px 40px rgba(255, 107, 22, 0.22);
|
||||
}
|
||||
|
||||
.upgrade-balance-card span,
|
||||
.upgrade-balance-card small {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upgrade-balance-card strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.upgrade-grid {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.8fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upgrade-plans-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upgrade-plan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.upgrade-tabbar {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fffdf9;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.upgrade-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upgrade-tab.is-active {
|
||||
border-color: #ffca97;
|
||||
background: #fff0de;
|
||||
color: #9b3f00;
|
||||
}
|
||||
|
||||
.upgrade-plan {
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 122, 26, 0.42);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(180deg, #fff, #fff8ed);
|
||||
box-shadow: 0 10px 24px rgba(255, 107, 22, 0.18);
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted {
|
||||
border-color: #4a2f1f;
|
||||
background: linear-gradient(180deg, #3c2619, #2b1a12);
|
||||
box-shadow: 0 14px 30px rgba(38, 20, 12, 0.3);
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted .plan-head h3,
|
||||
.upgrade-plan.is-highlighted p,
|
||||
.upgrade-plan.is-highlighted ul {
|
||||
color: #f8e9da;
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted .plan-head span {
|
||||
background: rgba(255, 173, 66, 0.2);
|
||||
color: #ffd39f;
|
||||
}
|
||||
|
||||
.plan-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-head h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.plan-head span {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-2);
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upgrade-plan p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
justify-self: start;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: baseline;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #fff2de, #ffe2bf);
|
||||
color: #b43f00;
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2px;
|
||||
box-shadow: 0 10px 24px rgba(255, 107, 22, 0.18);
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted .plan-price {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #ffcf90, #ffb766);
|
||||
color: #5a2a05;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0.2px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted .upgrade-toggle-row label {
|
||||
color: #f8e9da;
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted .ui-select {
|
||||
border-color: #7a553c;
|
||||
background-color: #f8e9da;
|
||||
color: #2d1f17;
|
||||
}
|
||||
|
||||
.upgrade-plan ul {
|
||||
margin: 0 0 0 16px;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upgrade-toggle-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.upgrade-toggle-row label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upgrade-wallet {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.upgrade-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upgrade-stats div {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.upgrade-stats span,
|
||||
.upgrade-stats strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upgrade-stats span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upgrade-stats strong {
|
||||
margin-top: 4px;
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.upgrade-recharge {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.upgrade-recharge button {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.upgrade-plan button.secondary,
|
||||
.upgrade-recharge button.secondary {
|
||||
border-color: #ffb57a;
|
||||
background: #fff6ec;
|
||||
color: #9b3f00;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.upgrade-plan button.secondary:hover,
|
||||
.upgrade-recharge button.secondary:hover {
|
||||
background: #ffe9d3;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card {
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-soft);
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card > *:not(.purchase-head) {
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.purchase-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.purchase-head {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card .muted.small {
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.purchase-row {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.purchase-row span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.purchase-row strong {
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.purchase-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.purchase-qty {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.purchase-qty > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.purchase-qty .tiny {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.purchase-info {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.purchase-static-text {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.purchase-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) 42px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.purchase-stepper button {
|
||||
margin-top: 0;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.purchase-stepper input {
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pay-channel-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pay-channel-option {
|
||||
margin: 0;
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pay-channel-option[data-channel="wechat"] {
|
||||
border-color: #b7e8c9;
|
||||
color: #1f9d55;
|
||||
background: #f4fdf7;
|
||||
}
|
||||
|
||||
.pay-channel-option[data-channel="alipay"] {
|
||||
border-color: #b8d8ff;
|
||||
color: #1677ff;
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.pay-channel-option[data-channel="wechat"].is-active {
|
||||
border-color: #07c160;
|
||||
color: #056a35;
|
||||
background: #eafaf1;
|
||||
box-shadow: 0 8px 18px rgba(7, 193, 96, 0.2);
|
||||
}
|
||||
|
||||
.pay-channel-option[data-channel="alipay"].is-active {
|
||||
border-color: #1677ff;
|
||||
color: #0d4fb8;
|
||||
background: #ebf3ff;
|
||||
box-shadow: 0 8px 18px rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
.purchase-summary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.purchase-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.purchase-summary span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.purchase-summary strong {
|
||||
color: var(--accent-2);
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.purchase-action {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card #vipRechargeBtn {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
min-height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.upgrade-hero {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.upgrade-balance-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upgrade-grid,
|
||||
.upgrade-stats,
|
||||
.upgrade-recharge {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.upgrade-plan-grid,
|
||||
.pay-channel-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.purchase-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.upgrade-recharge button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
@@ -1804,3 +2529,60 @@ button.target-char-chip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-dialog-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.ui-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ui-dialog {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.ui-dialog-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ui-dialog-message {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ui-dialog-input {
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-dialog-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ui-dialog-btn {
|
||||
width: auto;
|
||||
min-width: 92px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
101
app/static/ui-dialog.js
Normal file
101
app/static/ui-dialog.js
Normal file
@@ -0,0 +1,101 @@
|
||||
(() => {
|
||||
function ensureRoot() {
|
||||
let root = document.getElementById("uiDialogRoot");
|
||||
if (root) return root;
|
||||
root = document.createElement("div");
|
||||
root.id = "uiDialogRoot";
|
||||
root.className = "ui-dialog-root";
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function closeDialog(resolve, value, overlay) {
|
||||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
function createDialog({ title, message, mode = "alert", confirmText = "确定", cancelText = "取消", placeholder = "", defaultValue = "" }) {
|
||||
return new Promise((resolve) => {
|
||||
const root = ensureRoot();
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "ui-dialog-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="ui-dialog" role="dialog" aria-modal="true">
|
||||
<h3 class="ui-dialog-title"></h3>
|
||||
<div class="ui-dialog-message"></div>
|
||||
<input class="ui-dialog-input" type="text" />
|
||||
<div class="ui-dialog-actions">
|
||||
<button class="ui-dialog-btn secondary ui-dialog-cancel" type="button">${cancelText}</button>
|
||||
<button class="ui-dialog-btn primary ui-dialog-confirm" type="button">${confirmText}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.appendChild(overlay);
|
||||
|
||||
const titleEl = overlay.querySelector(".ui-dialog-title");
|
||||
const msgEl = overlay.querySelector(".ui-dialog-message");
|
||||
const inputEl = overlay.querySelector(".ui-dialog-input");
|
||||
const cancelBtn = overlay.querySelector(".ui-dialog-cancel");
|
||||
const okBtn = overlay.querySelector(".ui-dialog-confirm");
|
||||
if (titleEl) titleEl.textContent = title || "提示";
|
||||
if (msgEl) msgEl.textContent = message || "";
|
||||
if (inputEl) {
|
||||
inputEl.placeholder = placeholder || "";
|
||||
inputEl.value = defaultValue || "";
|
||||
}
|
||||
|
||||
if (mode === "alert") {
|
||||
if (cancelBtn) cancelBtn.style.display = "none";
|
||||
} else if (mode === "confirm") {
|
||||
if (inputEl) inputEl.style.display = "none";
|
||||
} else if (mode === "prompt") {
|
||||
if (inputEl) inputEl.style.display = "block";
|
||||
}
|
||||
|
||||
const onEsc = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
document.removeEventListener("keydown", onEsc);
|
||||
if (mode === "alert") closeDialog(resolve, true, overlay);
|
||||
else closeDialog(resolve, null, overlay);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onEsc);
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
document.removeEventListener("keydown", onEsc);
|
||||
if (mode === "confirm") closeDialog(resolve, false, overlay);
|
||||
else closeDialog(resolve, null, overlay);
|
||||
});
|
||||
}
|
||||
if (okBtn) {
|
||||
okBtn.addEventListener("click", () => {
|
||||
document.removeEventListener("keydown", onEsc);
|
||||
if (mode === "prompt") closeDialog(resolve, inputEl ? inputEl.value : "", overlay);
|
||||
else if (mode === "confirm") closeDialog(resolve, true, overlay);
|
||||
else closeDialog(resolve, true, overlay);
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "prompt" && inputEl) {
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
} else if (okBtn) {
|
||||
window.setTimeout(() => okBtn.focus(), 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.uiAlert = async (message, title = "提示") => createDialog({ title, message, mode: "alert", confirmText: "我知道了" });
|
||||
window.uiConfirm = async (message, title = "请确认") =>
|
||||
createDialog({ title, message, mode: "confirm", confirmText: "确认", cancelText: "取消" });
|
||||
window.uiPrompt = async (message, title = "请输入", defaultValue = "", placeholder = "") =>
|
||||
createDialog({
|
||||
title,
|
||||
message,
|
||||
mode: "prompt",
|
||||
confirmText: "确认",
|
||||
cancelText: "取消",
|
||||
defaultValue,
|
||||
placeholder,
|
||||
});
|
||||
})();
|
||||
292
app/static/upgrade.js
Normal file
292
app/static/upgrade.js
Normal file
@@ -0,0 +1,292 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let pendingOrderNo = "";
|
||||
let pendingPollTimer = null;
|
||||
let pendingPollCount = 0;
|
||||
|
||||
function setStatus(msg, danger = false) {
|
||||
const el = $("status");
|
||||
if (!el) return;
|
||||
el.style.color = danger ? "#b42318" : "#0f5f3d";
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
function setLoading(button, loading, idleText, loadingText) {
|
||||
if (!button) return;
|
||||
button.disabled = loading;
|
||||
button.textContent = loading ? loadingText : idleText;
|
||||
}
|
||||
|
||||
function importantNotice(message, title = "提示") {
|
||||
if (typeof window.uiAlert === "function") {
|
||||
void window.uiAlert(message, title);
|
||||
return;
|
||||
}
|
||||
setStatus(message);
|
||||
}
|
||||
|
||||
function openPayLink(url) {
|
||||
const payUrl = String(url || "").trim();
|
||||
if (!payUrl) return false;
|
||||
const tab = window.open(payUrl, "_blank", "noopener");
|
||||
if (tab) return true;
|
||||
window.location.href = payUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function postJSON(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
if (!data.logged_in) {
|
||||
window.location.href = "/auth?next=/upgrade";
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function formatDateTime(tsSec) {
|
||||
const n = Number(tsSec || 0);
|
||||
if (!n) return "-";
|
||||
return new Date(n * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function formatDate(tsSec) {
|
||||
const n = Number(tsSec || 0);
|
||||
if (!n) return "-";
|
||||
const d = new Date(n * 1000);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function refreshPurchaseCycleText() {
|
||||
const el = $("purchaseCycleText");
|
||||
if (!el) return;
|
||||
const start = Math.floor(Date.now() / 1000);
|
||||
const end = start + 30 * 24 * 3600;
|
||||
el.textContent = `时长:1个月(到期:${formatDate(end)})`;
|
||||
}
|
||||
|
||||
function renderVip(vip) {
|
||||
const shared = Number(vip.shared_credits || vip.token_balance || 0);
|
||||
const seatRemaining = Number(vip.seat_remaining_credits || 0);
|
||||
const totalAvailable = Number(vip.total_available_credits || seatRemaining + shared);
|
||||
const enabled = Boolean(vip.vip_enabled);
|
||||
const cycleStart = Number(vip.cycle_started_at || 0);
|
||||
const cycleEnd = Number(vip.cycle_expires_at || 0);
|
||||
if ($("upgradeTokenBalance")) $("upgradeTokenBalance").textContent = String(totalAvailable);
|
||||
if ($("upgradeTokenBalanceHero")) $("upgradeTokenBalanceHero").textContent = String(totalAvailable);
|
||||
if ($("vipTokenBalance")) $("vipTokenBalance").textContent = String(shared);
|
||||
if ($("vipSeatRemaining")) $("vipSeatRemaining").textContent = String(seatRemaining);
|
||||
if ($("vipTotalConsumed")) $("vipTotalConsumed").textContent = String(Number(vip.total_consumed_tokens || 0));
|
||||
if ($("vipEnabledSelect")) $("vipEnabledSelect").value = enabled ? "1" : "0";
|
||||
if ($("vipStateText")) {
|
||||
$("vipStateText").textContent = totalAvailable <= 0 ? "额度已用完" : enabled ? "平台模型已开启" : "平台模型已关闭";
|
||||
}
|
||||
if ($("vipCycleHint")) {
|
||||
if (cycleStart > 0 && cycleEnd > 0) {
|
||||
const startText = formatDateTime(cycleStart);
|
||||
const endText = formatDateTime(cycleEnd);
|
||||
$("vipCycleHint").textContent = `当前周期:${startText} - ${endText}(到期自动清零)`;
|
||||
} else {
|
||||
$("vipCycleHint").textContent = "当前未开始月周期,首次支付成功后开始计时。";
|
||||
}
|
||||
}
|
||||
if (totalAvailable <= 0) {
|
||||
setStatus("Credits 额度已用完,请充值共享加油包或等待下个计费周期。", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const me = await fetchMe();
|
||||
if (me && me.vip) renderVip(me.vip);
|
||||
}
|
||||
|
||||
async function fetchBillingOverview() {
|
||||
const res = await fetch("/api/billing/overview");
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.detail || "账单读取失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
function stopPendingPoll() {
|
||||
if (pendingPollTimer) {
|
||||
window.clearInterval(pendingPollTimer);
|
||||
pendingPollTimer = null;
|
||||
}
|
||||
pendingPollCount = 0;
|
||||
}
|
||||
|
||||
function startPendingPoll(orderNo, showCreatedNotice = true) {
|
||||
if (!orderNo) return;
|
||||
pendingOrderNo = orderNo;
|
||||
stopPendingPoll();
|
||||
const msg = `订单 ${orderNo} 已创建,请完成支付,系统将自动刷新余额。`;
|
||||
setStatus(msg);
|
||||
if (showCreatedNotice) {
|
||||
importantNotice(msg, "订单已创建");
|
||||
}
|
||||
pendingPollTimer = window.setInterval(async () => {
|
||||
pendingPollCount += 1;
|
||||
try {
|
||||
const data = await fetchBillingOverview();
|
||||
const records = Array.isArray(data.recharge_records) ? data.recharge_records : [];
|
||||
const current = records.find((r) => (r.order_no || "") === pendingOrderNo);
|
||||
if (current && (current.status || "") === "paid") {
|
||||
stopPendingPoll();
|
||||
pendingOrderNo = "";
|
||||
setStatus("支付成功,余额已自动刷新。");
|
||||
await refresh();
|
||||
return;
|
||||
}
|
||||
if (current && ["cancelled", "closed"].includes(String(current.status || "").toLowerCase())) {
|
||||
stopPendingPoll();
|
||||
pendingOrderNo = "";
|
||||
setStatus("订单超过15分钟未支付,已自动取消。", true);
|
||||
await refresh();
|
||||
return;
|
||||
}
|
||||
if (pendingPollCount >= 120) {
|
||||
stopPendingPoll();
|
||||
setStatus("订单仍未支付,可支付后点击刷新查看余额。", true);
|
||||
}
|
||||
} catch {
|
||||
if (pendingPollCount >= 120) {
|
||||
stopPendingPoll();
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const saveVipBtn = $("saveVipBtn");
|
||||
const vipRechargeBtn = $("vipRechargeBtn");
|
||||
const vipRechargeTokensInput = $("vipRechargeTokens");
|
||||
const vipRechargeAmountInput = $("vipRechargeAmount");
|
||||
const payChannelOptions = Array.from(document.querySelectorAll(".pay-channel-option"));
|
||||
let selectedPayChannel = "wechat";
|
||||
|
||||
function bindPayChannelOptions() {
|
||||
if (!payChannelOptions.length) return;
|
||||
payChannelOptions.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
selectedPayChannel = (btn.dataset.channel || "wechat").trim() || "wechat";
|
||||
payChannelOptions.forEach((item) => {
|
||||
const active = item === btn;
|
||||
item.classList.toggle("is-active", active);
|
||||
item.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function packageRate() {
|
||||
const credits = Number((vipRechargeTokensInput && vipRechargeTokensInput.dataset.packageCredits) || "1500");
|
||||
const amount = Number((vipRechargeAmountInput && vipRechargeAmountInput.dataset.packageAmount) || "19.9");
|
||||
return {
|
||||
packageCredits: Number.isFinite(credits) && credits > 0 ? credits : 1500,
|
||||
packageAmount: Number.isFinite(amount) && amount > 0 ? amount : 19.9,
|
||||
};
|
||||
}
|
||||
|
||||
function syncRechargeAmount() {
|
||||
const { packageCredits, packageAmount } = packageRate();
|
||||
const credits = packageCredits;
|
||||
const amount = packageAmount;
|
||||
if (vipRechargeTokensInput) vipRechargeTokensInput.value = String(credits);
|
||||
vipRechargeAmountInput.value = amount.toFixed(2);
|
||||
if ($("purchaseCredits")) $("purchaseCredits").textContent = String(credits);
|
||||
if ($("purchaseAmount")) $("purchaseAmount").textContent = `¥${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
if (vipRechargeAmountInput) vipRechargeAmountInput.readOnly = true;
|
||||
syncRechargeAmount();
|
||||
refreshPurchaseCycleText();
|
||||
bindPayChannelOptions();
|
||||
|
||||
if (saveVipBtn) {
|
||||
saveVipBtn.addEventListener("click", async () => {
|
||||
setLoading(saveVipBtn, true, "保存升级设置", "保存中...");
|
||||
try {
|
||||
const enabled = (($("vipEnabledSelect") && $("vipEnabledSelect").value) || "0") === "1";
|
||||
const out = await postJSON("/api/auth/vip/toggle", { enabled });
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "VIP 设置保存失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("升级设置已保存。");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "VIP 设置保存失败", true);
|
||||
} finally {
|
||||
setLoading(saveVipBtn, false, "保存升级设置", "保存中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (vipRechargeBtn) {
|
||||
vipRechargeBtn.addEventListener("click", async () => {
|
||||
setLoading(vipRechargeBtn, true, "订阅", "创建订单中...");
|
||||
try {
|
||||
const tokens = Number((($("vipRechargeTokens") && $("vipRechargeTokens").value) || "0").trim());
|
||||
const amount = Number((($("vipRechargeAmount") && $("vipRechargeAmount").value) || "0").trim());
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) {
|
||||
setStatus("请输入正确的 Credits 数量", true);
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setStatus("请输入正确的支付金额", true);
|
||||
return;
|
||||
}
|
||||
const out = await postJSON("/api/pay/wechat/", {
|
||||
tokens: Math.round(tokens),
|
||||
amount_cny: Number(amount.toFixed(2)),
|
||||
channel: selectedPayChannel || "wechat",
|
||||
subscriber_name: "",
|
||||
subscriber_phone: "",
|
||||
shipping_address: "",
|
||||
});
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "充值失败", true);
|
||||
return;
|
||||
}
|
||||
const orderNo = out.order && out.order.order_no ? out.order.order_no : "";
|
||||
if (orderNo) startPendingPoll(orderNo, true);
|
||||
if (out.pay_url) {
|
||||
openPayLink(out.pay_url);
|
||||
return;
|
||||
}
|
||||
const tip = "订单已创建,但未获取到支付链接,请检查支付网关配置。";
|
||||
setStatus(tip, true);
|
||||
importantNotice(tip, "支付链接缺失");
|
||||
} catch (e) {
|
||||
setStatus(e.message || "充值失败", true);
|
||||
} finally {
|
||||
setLoading(vipRechargeBtn, false, "订阅", "创建订单中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
(async () => {
|
||||
await fetchMe();
|
||||
try {
|
||||
const data = await fetchBillingOverview();
|
||||
const records = Array.isArray(data.recharge_records) ? data.recharge_records : [];
|
||||
const pending = records.find((r) => (r.status || "") === "pending");
|
||||
if (pending && pending.order_no) startPendingPoll(pending.order_no, false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
window.setInterval(refreshPurchaseCycleText, 60000);
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_name }} - 登录注册</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
|
||||
</head>
|
||||
<body class="simple-page auth-page">
|
||||
<main class="auth-shell">
|
||||
@@ -66,11 +66,19 @@
|
||||
<div class="auth-form">
|
||||
<div>
|
||||
<label>用户名</label>
|
||||
<input id="username" type="text" placeholder="请输入用户名" autocomplete="username" />
|
||||
<input id="username" type="text" placeholder="4-24 位,字母数字下划线" autocomplete="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label>密码</label>
|
||||
<input id="password" type="password" placeholder="请输入密码(至少 6 位)" autocomplete="current-password" />
|
||||
<input id="password" type="password" placeholder="10-64 位,含大小写/数字/特殊字符" autocomplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label id="challengeLabel">人机校验</label>
|
||||
<div class="cover-tools">
|
||||
<input id="challengeAnswer" type="text" placeholder="请输入答案" autocomplete="off" />
|
||||
<button id="refreshChallengeBtn" class="subtle-btn" type="button">刷新题目</button>
|
||||
</div>
|
||||
<input id="botTrap" type="text" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px;opacity:0;" />
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label class="check-label">
|
||||
@@ -94,6 +102,7 @@
|
||||
<script>
|
||||
window.__NEXT_PATH__ = {{ next|tojson }};
|
||||
</script>
|
||||
<script src="/static/auth.js?v=20260410a"></script>
|
||||
<script src="/static/ui-dialog.js?v=20260428a"></script>
|
||||
<script src="/static/auth.js?v=20260428l"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
100
app/templates/billing.html
Normal file
100
app/templates/billing.html
Normal file
@@ -0,0 +1,100 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_name }} - 账单中心</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="product-shell">
|
||||
<aside class="side-nav" aria-label="主导航">
|
||||
<div class="side-brand">
|
||||
<div class="brand-lockup">
|
||||
<img class="logo-mark" src="/static/favicon.svg?v=20260428h" alt="" />
|
||||
<h1>{{ app_name }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-group">
|
||||
<div class="nav-label">工作台</div>
|
||||
<a class="nav-item" href="/">内容生产</a>
|
||||
<a class="nav-item" href="/settings">账号与模型</a>
|
||||
<a class="nav-item is-active" href="/billing">账单中心</a>
|
||||
<a class="nav-item" href="/upgrade">升级</a>
|
||||
<a class="nav-item" href="/profile">个人中心</a>
|
||||
<a class="nav-item" href="/guide">新手引导</a>
|
||||
</nav>
|
||||
<div class="side-footer">充值订单 · Credits 明细</div>
|
||||
</aside>
|
||||
|
||||
<div class="workspace">
|
||||
<header class="topbar topbar-compact">
|
||||
<div class="topbar-actions">
|
||||
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台">⌂</a>
|
||||
<a class="icon-btn" href="/settings" aria-label="账号与模型设置" title="账号与模型设置">⚙</a>
|
||||
<a class="icon-btn" href="/upgrade" aria-label="升级" title="升级">★</a>
|
||||
<a class="icon-btn" href="/profile" aria-label="个人中心" title="个人中心">☺</a>
|
||||
<button id="logoutBtn" class="icon-btn topbar-btn" type="button" aria-label="退出登录" title="退出登录">⎋</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout settings-layout">
|
||||
<section class="panel settings-panel">
|
||||
<div class="panel-scroll settings-panel-scroll">
|
||||
<div class="settings-content">
|
||||
<section class="settings-section settings-card">
|
||||
<h3 class="section-title">创建充值订单</h3>
|
||||
<p class="muted small">
|
||||
统一 Credits 计费:免费 500 Credits,标准加油包按比例换算(¥{{ package_amount }} / {{ package_credits }} Credits)。
|
||||
</p>
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label>充值 Credits 数量</label>
|
||||
<input
|
||||
id="billingRechargeTokens"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value="{{ package_credits }}"
|
||||
data-package-credits="{{ package_credits }}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>支付金额(元)</label>
|
||||
<input
|
||||
id="billingRechargeAmount"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value="{{ package_amount }}"
|
||||
data-package-amount="{{ package_amount }}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="createRechargeOrderBtn" class="primary" type="button">创建充值订单</button>
|
||||
<button id="refreshBillingBtn" class="secondary" type="button">刷新账单记录</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section settings-card">
|
||||
<h3 class="section-title">充值订单记录</h3>
|
||||
<div id="rechargeRecords" class="billing-table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section settings-card">
|
||||
<h3 class="section-title">Credits 消费明细</h3>
|
||||
<div id="consumeRecords" class="billing-table-wrap"></div>
|
||||
<p id="status" class="status"></p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/ui-dialog.js?v=20260428a"></script>
|
||||
<script src="/static/billing.js?v=20260428w"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_name }} - 忘记密码</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
|
||||
</head>
|
||||
<body class="simple-page">
|
||||
<main class="auth-card">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_name }} - 新手引导</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="product-shell">
|
||||
@@ -20,6 +20,9 @@
|
||||
<div class="nav-label">工作台</div>
|
||||
<a class="nav-item" href="/">内容生产</a>
|
||||
<a class="nav-item" href="/settings">账号与模型</a>
|
||||
<a class="nav-item" href="/billing">账单中心</a>
|
||||
<a class="nav-item" href="/upgrade">升级</a>
|
||||
<a class="nav-item" href="/profile">个人中心</a>
|
||||
<a class="nav-item is-active" href="/guide">新手引导</a>
|
||||
</nav>
|
||||
<div class="side-footer">首次配置 · 三分钟跑通</div>
|
||||
@@ -29,6 +32,8 @@
|
||||
<header class="topbar topbar-compact">
|
||||
<div class="topbar-actions">
|
||||
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台">⌂</a>
|
||||
<a class="icon-btn" href="/upgrade" aria-label="升级" title="升级">★</a>
|
||||
<a class="icon-btn" href="/profile" aria-label="个人中心" title="个人中心">☺</a>
|
||||
<a class="icon-btn" href="/settings" aria-label="账号与模型设置" title="账号与模型设置">⚙</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_name }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="product-shell">
|
||||
@@ -20,6 +20,9 @@
|
||||
<div class="nav-label">工作台</div>
|
||||
<a class="nav-item is-active" href="/">内容生产</a>
|
||||
<a class="nav-item" href="/settings">账号与模型</a>
|
||||
<a class="nav-item" href="/billing">账单中心</a>
|
||||
<a class="nav-item" href="/upgrade">升级</a>
|
||||
<a class="nav-item" href="/profile">个人中心</a>
|
||||
<a class="nav-item" href="/guide">新手引导</a>
|
||||
</nav>
|
||||
<div class="side-footer">生产环境 · 内容工作流</div>
|
||||
@@ -33,8 +36,11 @@
|
||||
<select id="wechatAccountSelect" class="topbar-select" aria-label="切换公众号"></select>
|
||||
<span id="wechatAccountStatus" class="muted small wechat-account-status" aria-live="polite"></span>
|
||||
</div>
|
||||
<a class="icon-btn" href="/upgrade" aria-label="升级" title="升级">★</a>
|
||||
<a class="icon-btn" href="/profile" aria-label="个人中心" title="个人中心">☺</a>
|
||||
<a class="icon-btn" href="/guide" aria-label="新手引导" title="新手引导">?</a>
|
||||
<a class="icon-btn" href="/settings" aria-label="设置" title="设置">⚙</a>
|
||||
<button id="clearDraftBtn" class="icon-btn topbar-btn" type="button" aria-label="清除草稿" title="清除草稿">⌫</button>
|
||||
<button id="logoutBtn" class="icon-btn topbar-btn" type="button" aria-label="退出登录" title="退出登录">⎋</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -168,6 +174,10 @@
|
||||
<strong>AI 自动生成封面</strong>
|
||||
<span>按标题生成并自动上传绑定。</span>
|
||||
</div>
|
||||
<div class="cover-tools">
|
||||
<input id="coverImageModel" type="text" placeholder="文生图模型,如 wanx2.0-t2i-turbo" />
|
||||
<button id="saveCoverImageModelBtn" class="subtle-btn" type="button">保存模型</button>
|
||||
</div>
|
||||
<div class="cover-tools">
|
||||
<input id="coverStyleHint" type="text" placeholder="可选:封面风格" />
|
||||
<button id="coverGenerateBtn" class="primary" type="button">按标题生成封面</button>
|
||||
@@ -220,10 +230,10 @@
|
||||
<div class="poster-actions-row">
|
||||
<button id="posterGenerateBtn" class="subtle-btn" type="button">生成段落海报</button>
|
||||
<label class="check-label poster-auto-check"
|
||||
><input id="posterAutoInclude" type="checkbox" checked />发布时自动插入海报</label
|
||||
><input id="posterAutoInclude" type="checkbox" />发布时自动插入海报</label
|
||||
>
|
||||
</div>
|
||||
<p id="posterHint" class="muted small">改写后可生成段落海报。</p>
|
||||
<p id="posterHint" class="muted small">默认不生成海报,点击“生成段落海报”后再插入发布。</p>
|
||||
<div id="posterPreviewList" class="poster-preview-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -239,6 +249,6 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="/static/app.js?v=20260428h"></script>
|
||||
<script src="/static/app.js?v=20260428s"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
75
app/templates/profile.html
Normal file
75
app/templates/profile.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_name }} - 个人中心</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="product-shell">
|
||||
<aside class="side-nav" aria-label="主导航">
|
||||
<div class="side-brand">
|
||||
<div class="brand-lockup">
|
||||
<img class="logo-mark" src="/static/favicon.svg?v=20260428h" alt="" />
|
||||
<h1>{{ app_name }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-group">
|
||||
<div class="nav-label">工作台</div>
|
||||
<a class="nav-item" href="/">内容生产</a>
|
||||
<a class="nav-item" href="/settings">账号与模型</a>
|
||||
<a class="nav-item" href="/billing">账单中心</a>
|
||||
<a class="nav-item" href="/upgrade">升级</a>
|
||||
<a class="nav-item is-active" href="/profile">个人中心</a>
|
||||
<a class="nav-item" href="/guide">新手引导</a>
|
||||
</nav>
|
||||
<div class="side-footer">订阅信息 · 地址管理</div>
|
||||
</aside>
|
||||
|
||||
<div class="workspace">
|
||||
<header class="topbar topbar-compact">
|
||||
<div class="topbar-actions">
|
||||
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台">⌂</a>
|
||||
<a class="icon-btn" href="/upgrade" aria-label="升级" title="升级">★</a>
|
||||
<a class="icon-btn" href="/settings" aria-label="账号与模型设置" title="账号与模型设置">⚙</a>
|
||||
<button id="logoutBtn" class="icon-btn topbar-btn" type="button" aria-label="退出登录" title="退出登录">⎋</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout settings-layout">
|
||||
<section class="panel settings-panel">
|
||||
<div class="panel-scroll settings-panel-scroll">
|
||||
<div class="settings-content">
|
||||
<section class="settings-section settings-card">
|
||||
<h3 class="section-title">个人中心</h3>
|
||||
<p class="muted small">这里保存默认订阅人信息,升级页会自动带入,避免重复输入。</p>
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label>订阅人姓名</label>
|
||||
<input id="profileSubscriberName" type="text" placeholder="请输入姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<label>手机号(可选)</label>
|
||||
<input id="profileSubscriberPhone" type="text" placeholder="请输入手机号" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>收货地址</label>
|
||||
<textarea id="profileShippingAddress" rows="3" placeholder="请输入详细地址"></textarea>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="saveProfileBtn" class="primary" type="button">保存个人信息</button>
|
||||
</div>
|
||||
<p id="status" class="status"></p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/profile.js?v=20260428a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_name }} - 账号与模型设置</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="product-shell">
|
||||
@@ -20,6 +20,9 @@
|
||||
<div class="nav-label">工作台</div>
|
||||
<a class="nav-item" href="/">内容生产</a>
|
||||
<a class="nav-item is-active" href="/settings">账号与模型</a>
|
||||
<a class="nav-item" href="/billing">账单中心</a>
|
||||
<a class="nav-item" href="/upgrade">升级</a>
|
||||
<a class="nav-item" href="/profile">个人中心</a>
|
||||
<a class="nav-item" href="/guide">新手引导</a>
|
||||
</nav>
|
||||
<div class="side-footer">生产环境 · 内容工作流</div>
|
||||
@@ -29,6 +32,8 @@
|
||||
<header class="topbar topbar-compact">
|
||||
<div class="topbar-actions">
|
||||
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台">⌂</a>
|
||||
<a class="icon-btn" href="/upgrade" aria-label="升级" title="升级">★</a>
|
||||
<a class="icon-btn" href="/profile" aria-label="个人中心" title="个人中心">☺</a>
|
||||
<a class="icon-btn" href="/guide" aria-label="新手引导" title="新手引导">?</a>
|
||||
<button id="logoutBtn" class="icon-btn topbar-btn" type="button" aria-label="退出登录" title="退出登录">⎋</button>
|
||||
</div>
|
||||
@@ -41,7 +46,7 @@
|
||||
<section class="settings-section settings-card">
|
||||
<div>
|
||||
<label>当前账号</label>
|
||||
<select id="accountSelect"></select>
|
||||
<select id="accountSelect" class="ui-select"></select>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">新增公众号</h3>
|
||||
@@ -70,7 +75,7 @@
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label>当前模型</label>
|
||||
<select id="modelSelect"></select>
|
||||
<select id="modelSelect" class="ui-select"></select>
|
||||
</div>
|
||||
<div class="actions-inline">
|
||||
<button id="deleteModelBtn" class="secondary topbar-btn" type="button">删除当前模型</button>
|
||||
@@ -86,10 +91,6 @@
|
||||
<input id="modelValue" type="text" placeholder="如:gpt-4.1-mini / qwen-max" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>文生图模型名</label>
|
||||
<input id="imageModelValue" type="text" placeholder="如:gpt-image-1 / wanx2.1-t2i-plus(用于封面和段落海报)" />
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label>Base URL(可选)</label>
|
||||
@@ -152,6 +153,7 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/settings.js?v=20260428i"></script>
|
||||
<script src="/static/ui-dialog.js?v=20260428a"></script>
|
||||
<script src="/static/settings.js?v=20260428q"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
177
app/templates/upgrade.html
Normal file
177
app/templates/upgrade.html
Normal file
@@ -0,0 +1,177 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_name }} - 升级</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260428za" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="product-shell">
|
||||
<aside class="side-nav" aria-label="主导航">
|
||||
<div class="side-brand">
|
||||
<div class="brand-lockup">
|
||||
<img class="logo-mark" src="/static/favicon.svg?v=20260428h" alt="" />
|
||||
<h1>{{ app_name }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-group">
|
||||
<div class="nav-label">工作台</div>
|
||||
<a class="nav-item" href="/">内容生产</a>
|
||||
<a class="nav-item" href="/settings">账号与模型</a>
|
||||
<a class="nav-item" href="/billing">账单中心</a>
|
||||
<a class="nav-item is-active" href="/upgrade">升级</a>
|
||||
<a class="nav-item" href="/profile">个人中心</a>
|
||||
<a class="nav-item" href="/guide">新手引导</a>
|
||||
</nav>
|
||||
<div class="side-footer">免费额度 · 平台模型</div>
|
||||
</aside>
|
||||
|
||||
<div class="workspace">
|
||||
<header class="topbar topbar-compact">
|
||||
<div class="topbar-actions">
|
||||
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台">⌂</a>
|
||||
<a class="icon-btn" href="/settings" aria-label="账号与模型设置" title="账号与模型设置">⚙</a>
|
||||
<a class="icon-btn" href="/profile" aria-label="个人中心" title="个人中心">☺</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout upgrade-layout">
|
||||
<section class="panel upgrade-panel">
|
||||
<div class="panel-scroll upgrade-scroll">
|
||||
<section class="upgrade-hero">
|
||||
<div>
|
||||
<p class="guide-eyebrow">VIP 平台模型</p>
|
||||
<h2>Token Plan 订阅</h2>
|
||||
<p class="muted">新用户免费 {{ trial_tokens }} Credits。按支付成功时间起算月周期,到期席位与加油包额度清零。</p>
|
||||
</div>
|
||||
<div class="upgrade-balance-card">
|
||||
<span>当前可用</span>
|
||||
<strong id="upgradeTokenBalanceHero">0</strong>
|
||||
<small>Credits</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="upgrade-grid">
|
||||
<div class="upgrade-plans-stack">
|
||||
<div class="upgrade-plan-grid">
|
||||
<article class="upgrade-plan is-highlighted">
|
||||
<div class="plan-head">
|
||||
<span>推荐</span>
|
||||
<h3>标准坐席</h3>
|
||||
</div>
|
||||
<p>适合轻度使用 AI 辅助写作与生图,采用 Credits 统一抵扣。</p>
|
||||
<div class="plan-price">19.9/月</div>
|
||||
<ul>
|
||||
<li>{{ seat_quota }} Credits /月(优先抵扣)</li>
|
||||
<li>席位额度用尽后,从共享加油包继续抵扣</li>
|
||||
</ul>
|
||||
<div class="upgrade-toggle-row">
|
||||
<label>平台模型</label>
|
||||
<select id="vipEnabledSelect" class="ui-select">
|
||||
<option value="1">开启</option>
|
||||
<option value="0">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="saveVipBtn" class="secondary" type="button">保存升级设置</button>
|
||||
</article>
|
||||
|
||||
<article class="upgrade-plan">
|
||||
<div class="plan-head">
|
||||
<span>自定义</span>
|
||||
<h3>自备模型</h3>
|
||||
</div>
|
||||
<p>继续使用你在设置页配置的文本模型和文生图模型。</p>
|
||||
<div class="plan-price">自有额度</div>
|
||||
<ul>
|
||||
<li>适合已有 API Key 的团队</li>
|
||||
<li>模型、Base URL 可自行维护</li>
|
||||
<li>平台 Credits 不参与扣减</li>
|
||||
</ul>
|
||||
<a class="subtle-link" href="/settings#model-settings">配置自定义模型</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="upgrade-purchase-card">
|
||||
<div class="purchase-head">
|
||||
<h3>标准坐席</h3>
|
||||
<p class="muted small">席位:1 席</p>
|
||||
<p id="purchaseCycleText" class="muted small">时长:按支付成功时间起算 30 天(到期清零)</p>
|
||||
</div>
|
||||
<div class="purchase-section purchase-meta-grid">
|
||||
<div class="purchase-row">
|
||||
<span>套餐单价</span>
|
||||
<strong>¥{{ package_amount }} / {{ package_credits }} Credits</strong>
|
||||
</div>
|
||||
<div class="purchase-row">
|
||||
<span>座位数量</span>
|
||||
<strong>1</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="purchase-section purchase-summary">
|
||||
<div class="purchase-summary-row"><span>合计 Credits</span><strong id="purchaseCredits">0</strong></div>
|
||||
<div class="purchase-summary-row"><span>应付金额</span><strong id="purchaseAmount">¥0.00</strong></div>
|
||||
</div>
|
||||
<div class="purchase-section purchase-qty">
|
||||
<p class="muted tiny">支付方式</p>
|
||||
<div class="pay-channel-group" role="radiogroup" aria-label="支付方式">
|
||||
<button class="pay-channel-option is-active" type="button" data-channel="wechat" aria-pressed="true">微信支付</button>
|
||||
<button class="pay-channel-option" type="button" data-channel="alipay" aria-pressed="false">支付宝</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
id="vipRechargeTokens"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value="{{ package_credits }}"
|
||||
data-package-credits="{{ package_credits }}"
|
||||
hidden
|
||||
/>
|
||||
<input
|
||||
id="vipRechargeAmount"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value="{{ package_amount }}"
|
||||
data-package-amount="{{ package_amount }}"
|
||||
hidden
|
||||
/>
|
||||
<div class="purchase-section purchase-action">
|
||||
<button id="vipRechargeBtn" class="primary" type="button">订阅</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="settings-card upgrade-wallet">
|
||||
<div class="guide-section-head">
|
||||
<h3>额度与充值</h3>
|
||||
</div>
|
||||
<div class="upgrade-stats">
|
||||
<div>
|
||||
<span>席位剩余额度</span>
|
||||
<strong id="vipSeatRemaining">0</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>共享加油包</span>
|
||||
<strong id="vipTokenBalance">0</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>总可用 Credits</span>
|
||||
<strong id="upgradeTokenBalance">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p id="vipCycleHint" class="muted small">当前未开始月周期。</p>
|
||||
<p id="status" class="status"></p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/ui-dialog.js?v=20260428a"></script>
|
||||
<script src="/static/upgrade.js?v=20260428ae"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user