fix:优化当前的项目

This commit is contained in:
Daniel
2026-04-28 18:36:38 +08:00
parent 04f26bdaaf
commit f47453a656
22 changed files with 3671 additions and 89 deletions

View File

@@ -43,3 +43,38 @@ AUTH_SESSION_TTL_SEC=86400
AUTH_REMEMBER_SESSION_TTL_SEC=604800 AUTH_REMEMBER_SESSION_TTL_SEC=604800
# 忘记密码重置码(建议自定义;为空时将使用默认值 x2ws-reset-2026 # 忘记密码重置码(建议自定义;为空时将使用默认值 x2ws-reset-2026
AUTH_PASSWORD_RESET_KEY=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=

View File

@@ -75,27 +75,87 @@ class Settings(BaseSettings):
auth_password_reset_key: str | None = Field(default="x2ws-reset-2026", alias="AUTH_PASSWORD_RESET_KEY") auth_password_reset_key: str | None = Field(default="x2ws-reset-2026", alias="AUTH_PASSWORD_RESET_KEY")
vip_trial_tokens: int = Field( vip_trial_tokens: int = Field(
default=20000, default=500,
alias="VIP_TRIAL_TOKENS", 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( vip_rewrite_token_per_1k_chars: int = Field(
default=1200, default=1500,
alias="VIP_REWRITE_TOKEN_PER_1K_CHARS", alias="VIP_REWRITE_TOKEN_PER_1K_CHARS",
description="改写按千字计费 token 单价", description="兼容字段:改写计费参数(建议使用 Credits 规则字段)",
) )
vip_image_token_per_image: int = Field( vip_image_token_per_image: int = Field(
default=1800, default=1500,
alias="VIP_IMAGE_TOKEN_PER_IMAGE", 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_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_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_model: str = Field(default="qwen-plus", alias="PLATFORM_OPENAI_MODEL")
platform_openai_image_model: str = Field(default="gpt-image-1", alias="PLATFORM_OPENAI_IMAGE_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_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_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") 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() settings = Settings()

View File

@@ -1,6 +1,11 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import re
import secrets
import time
import uuid
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -16,8 +21,12 @@ from app.middleware import RequestContextMiddleware
from app.schemas import ( from app.schemas import (
AIModelCreateRequest, AIModelCreateRequest,
AIModelDeleteRequest, AIModelDeleteRequest,
AIImageModelUpdateRequest,
AIModelSwitchRequest, AIModelSwitchRequest,
AuthCredentialRequest, AuthCredentialRequest,
BillingRechargeCreateRequest,
BillingPayNowRequest,
BillingRechargeNotifyRequest,
ChangePasswordRequest, ChangePasswordRequest,
DeleteAccountRequest, DeleteAccountRequest,
ForgotPasswordResetRequest, ForgotPasswordResetRequest,
@@ -30,6 +39,7 @@ from app.schemas import (
WechatBindingRequest, WechatBindingRequest,
WechatPublishRequest, WechatPublishRequest,
WechatSwitchRequest, WechatSwitchRequest,
UserProfileUpdateRequest,
VipRechargeRequest, VipRechargeRequest,
VipToggleRequest, VipToggleRequest,
) )
@@ -64,6 +74,11 @@ wechat = WechatPublisher()
poster_material = PosterMaterialService(wechat) poster_material = PosterMaterialService(wechat)
im = IMPublisher() im = IMPublisher()
users = UserStore(settings.auth_db_path) 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: def _session_ttl(remember_me: bool) -> int:
@@ -84,6 +99,67 @@ def _require_user(request: Request) -> dict | None:
return u 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: def _platform_model_cfg() -> dict:
return { return {
"api_key": settings.platform_openai_api_key or "", "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]: def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | None, str]:
vip = users.get_vip_status(user_id) 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() cfg = _platform_model_cfg()
if cfg.get("api_key"): if cfg.get("api_key"):
return cfg, "vip" return cfg, "vip"
@@ -107,12 +185,114 @@ def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | Non
return cfg, "user" 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: 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()) 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()) 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) total_chars = max(1, src_chars + out_chars)
blocks = (total_chars + 999) // 1000 estimated_tokens = int(total_chars * 1.8)
return int(blocks * max(1, int(settings.vip_rewrite_token_per_1k_chars))) 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) @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}) 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) @app.get("/guide", response_class=HTMLResponse)
async def guide_page(request: Request): async def guide_page(request: Request):
if not _current_user(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") @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() username = (req.username or "").strip()
password = req.password or "" password = req.password or ""
if len(username) < 2: ok, msg = _validate_username_password(username, password)
return {"ok": False, "detail": "用户名至少 2 个字符"} if not ok:
if len(password) < 6: return {"ok": False, "detail": msg}
return {"ok": False, "detail": "密码至少 6 个字符"} ok_challenge, challenge_msg = _verify_challenge(req)
if not ok_challenge:
return {"ok": False, "detail": challenge_msg}
try: try:
user = users.create_user(username, password) user = users.create_user(username, password)
except Exception as exc: except Exception as exc:
@@ -225,14 +481,19 @@ async def auth_register(req: AuthCredentialRequest, response: Response):
) )
return { return {
"ok": True, "ok": True,
"detail": "注册并登录成功,已赠送试用 token,请保存重置码", "detail": "注册并登录成功,已赠送试用 Credits,请保存重置码",
"user": {"id": user["id"], "username": user["username"]}, "user": {"id": user["id"], "username": user["username"]},
"reset_code": user.get("reset_code", ""), "reset_code": user.get("reset_code", ""),
} }
@app.post("/api/auth/login") @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: try:
user = users.verify_user((req.username or "").strip(), req.password or "") user = users.verify_user((req.username or "").strip(), req.password or "")
except Exception as exc: except Exception as exc:
@@ -335,6 +596,217 @@ async def auth_delete_account(req: DeleteAccountRequest, request: Request, respo
return {"ok": True, "detail": "账号已注销,关联数据已清空"} 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") @app.post("/api/auth/wechat/bind")
async def auth_wechat_bind(req: WechatBindingRequest, request: Request): async def auth_wechat_bind(req: WechatBindingRequest, request: Request):
user = _require_user(request) user = _require_user(request)
@@ -425,6 +897,20 @@ async def auth_ai_model_delete(req: AIModelDeleteRequest, request: Request):
return {"ok": True, "detail": "模型配置已删除"} 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") @app.post("/api/rewrite")
async def rewrite(req: RewriteRequest, request: Request): async def rewrite(req: RewriteRequest, request: Request):
rid = getattr(request.state, "request_id", "") rid = getattr(request.state, "request_id", "")
@@ -444,7 +930,9 @@ async def rewrite(req: RewriteRequest, request: Request):
user = _require_user(request) user = _require_user(request)
if not user: if not user:
raise HTTPException(status_code=401, detail="请先登录") 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: if not model_cfg:
raise HTTPException(status_code=400, detail="请先在设置页配置 AI 模型") raise HTTPException(status_code=400, detail="请先在设置页配置 AI 模型")
backup = { 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_output_tokens = backup["openai_max_output_tokens"]
settings.openai_max_retries = backup["openai_max_retries"] settings.openai_max_retries = backup["openai_max_retries"]
settings.openai_image_model = backup["openai_image_model"] 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 {} tr = result.trace or {}
logger.info( logger.info(
"api_rewrite_out rid=%s mode=%s duration_ms=%s quality_notes=%d trace_steps=%s soft_accept=%s", "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 ""), len(req.summary or ""),
req.upload_to_wechat, 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 = { backup = {
"openai_api_key": settings.openai_api_key, "openai_api_key": settings.openai_api_key,
"openai_base_url": settings.openai_base_url, "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_output_tokens = 8192
settings.openai_max_retries = 0 settings.openai_max_retries = 0
settings.openai_image_model = backup["openai_image_model"] 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) out = await PosterMaterialService(wechat).generate_cover(req, request_id=rid, account=binding)
finally: finally:
settings.openai_api_key = backup["openai_api_key"] settings.openai_api_key = backup["openai_api_key"]
@@ -643,6 +1180,31 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques
out.note, out.note,
len(out.warnings), 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 return out
@@ -684,7 +1246,10 @@ async def generate_posters(req: PosterGenerateRequest, request: Request):
req.upload_to_wechat, req.upload_to_wechat,
int(req.max_images or 0), 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 = { backup = {
"openai_api_key": settings.openai_api_key, "openai_api_key": settings.openai_api_key,
"openai_base_url": settings.openai_base_url, "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_output_tokens = 8192
settings.openai_max_retries = 0 settings.openai_max_retries = 0
settings.openai_image_model = backup["openai_image_model"] 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) out = await PosterMaterialService(wechat).generate(req, request_id=rid, account=binding)
finally: finally:
settings.openai_api_key = backup["openai_api_key"] 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.posters),
len(out.warnings), 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 return out

View File

@@ -54,6 +54,9 @@ class AuthCredentialRequest(BaseModel):
username: str username: str
password: str password: str
remember_me: bool = False remember_me: bool = False
challenge_id: str = ""
challenge_answer: str = ""
honeypot: str = ""
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
@@ -97,6 +100,7 @@ class WechatCoverGenerateRequest(BaseModel):
title: str = "" title: str = ""
summary: str = "" summary: str = ""
style_hint: str = "" style_hint: str = ""
image_model: str = ""
upload_to_wechat: bool = True upload_to_wechat: bool = True
@@ -119,12 +123,42 @@ class AIModelDeleteRequest(BaseModel):
model_id: int model_id: int
class AIImageModelUpdateRequest(BaseModel):
image_model: str
class VipToggleRequest(BaseModel): class VipToggleRequest(BaseModel):
enabled: bool = True enabled: bool = True
class VipRechargeRequest(BaseModel): 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): class PosterGenerateRequest(BaseModel):
@@ -132,6 +166,7 @@ class PosterGenerateRequest(BaseModel):
summary: str = "" summary: str = ""
body_markdown: str = Field(..., min_length=20) body_markdown: str = Field(..., min_length=20)
style_hint: str = "" style_hint: str = ""
image_model: str = ""
upload_to_wechat: bool = True upload_to_wechat: bool = True
max_images: int = Field(default=6, ge=1, le=12) max_images: int = Field(default=6, ge=1, le=12)

View File

@@ -108,6 +108,10 @@ class AIRewriter:
def __init__(self) -> None: def __init__(self) -> None:
self._client = None self._client = None
self._prefer_chat_first = False 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: if settings.openai_api_key:
base_url = settings.openai_base_url or "" base_url = settings.openai_base_url or ""
self._prefer_chat_first = "dashscope.aliyuncs.com" in base_url self._prefer_chat_first = "dashscope.aliyuncs.com" in base_url
@@ -128,6 +132,22 @@ class AIRewriter:
else: else:
logger.warning("AIRewriter_init openai_key_missing=1 rewrite_will_use_fallback_only=1") 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: def rewrite(self, req: RewriteRequest, request_id: str = "") -> RewriteResponse:
cleaned_source = self._clean_source(req.source_text) cleaned_source = self._clean_source(req.source_text)
started = time.monotonic() started = time.monotonic()
@@ -256,6 +276,12 @@ class AIRewriter:
) )
trace["quality_issues_final"] = final_issues trace["quality_issues_final"] = final_issues
if not 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["duration_ms"] = round((time.monotonic() - started) * 1000, 1)
trace["mode"] = "ai" trace["mode"] = "ai"
logger.info( logger.info(
@@ -266,6 +292,12 @@ class AIRewriter:
return RewriteResponse(**normalized, mode="ai", quality_notes=[], trace=trace) return RewriteResponse(**normalized, mode="ai", quality_notes=[], trace=trace)
# 模型已返回有效 JSON默认「软接受」——仍视为 AI 洗稿,质检问题写入 quality_notes避免误用模板稿 # 模型已返回有效 JSON默认「软接受」——仍视为 AI 洗稿,质检问题写入 quality_notes避免误用模板稿
if settings.ai_soft_accept and self._model_output_usable(normalized): 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["duration_ms"] = round((time.monotonic() - started) * 1000, 1)
trace["mode"] = "ai" trace["mode"] = "ai"
trace["quality_soft_accept"] = True trace["quality_soft_accept"] = True
@@ -669,6 +701,7 @@ class AIRewriter:
msg = (choice.message.content if choice else "") or "" msg = (choice.message.content if choice else "") or ""
fr = getattr(choice, "finish_reason", None) if choice else None fr = getattr(choice, "finish_reason", None) if choice else None
usage = getattr(completion, "usage", None) usage = getattr(completion, "usage", None)
self._accumulate_usage(usage)
udump = ( udump = (
usage.model_dump() usage.model_dump()
if usage is not None and hasattr(usage, "model_dump") if usage is not None and hasattr(usage, "model_dump")
@@ -755,6 +788,7 @@ class AIRewriter:
text={"format": {"type": "json_object"}}, text={"format": {"type": "json_object"}},
timeout=timeout_sec, timeout=timeout_sec,
) )
self._accumulate_usage(getattr(completion, "usage", None))
output_text = completion.output_text or "" output_text = completion.output_text or ""
ms = (time.monotonic() - t0) * 1000 ms = (time.monotonic() - t0) * 1000
logger.info( logger.info(

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib import hashlib
import hmac import hmac
import json
import secrets import secrets
import sqlite3 import sqlite3
import time import time
@@ -69,6 +70,12 @@ class UserStore:
pref_cols = self._table_columns(c, "user_prefs") pref_cols = self._table_columns(c, "user_prefs")
if "active_ai_model_id" not in pref_cols: if "active_ai_model_id" not in pref_cols:
c.execute("ALTER TABLE user_prefs ADD COLUMN active_ai_model_id INTEGER") 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( c.execute(
""" """
CREATE TABLE IF NOT EXISTS ai_models ( CREATE TABLE IF NOT EXISTS ai_models (
@@ -95,11 +102,62 @@ class UserStore:
vip_enabled INTEGER NOT NULL DEFAULT 0, vip_enabled INTEGER NOT NULL DEFAULT 0,
token_balance INTEGER NOT NULL DEFAULT 0, token_balance INTEGER NOT NULL DEFAULT 0,
total_consumed_tokens 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, updated_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) 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") ai_cols = self._table_columns(c, "ai_models")
if "image_model" not in ai_cols: if "image_model" not in ai_cols:
c.execute("ALTER TABLE ai_models ADD COLUMN image_model TEXT NOT NULL DEFAULT ''") c.execute("ALTER TABLE ai_models ADD COLUMN image_model TEXT NOT NULL DEFAULT ''")
@@ -438,6 +496,57 @@ class UserStore:
return None return None
return {"id": int(row["id"]), "username": row["username"]} 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: def delete_user_logically(self, user_id: int, password: str, reset_code: str) -> bool:
now = int(time.time()) now = int(time.time())
with self._conn() as c: 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 user_prefs WHERE user_id=?", (user_id,))
c.execute("DELETE FROM wechat_bindings 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 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( c.execute(
"UPDATE users SET deleted_at=?, username=username || '#deleted' || ? WHERE id=?", "UPDATE users SET deleted_at=?, username=username || '#deleted' || ? WHERE id=?",
(now, str(now), user_id), (now, str(now), user_id),
@@ -472,14 +583,81 @@ class UserStore:
def _ensure_wallet_row(self, c: sqlite3.Connection, user_id: int) -> None: def _ensure_wallet_row(self, c: sqlite3.Connection, user_id: int) -> None:
now = int(time.time()) now = int(time.time())
cycle = time.strftime("%Y-%m", time.localtime(now))
c.execute( c.execute(
""" """
INSERT OR IGNORE INTO user_wallets(user_id, vip_enabled, token_balance, total_consumed_tokens, updated_at) INSERT OR IGNORE INTO user_wallets(
VALUES (?, 0, 0, 0, ?) 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: def ensure_trial_tokens(self, user_id: int, trial_tokens: int) -> dict:
amount = max(0, int(trial_tokens)) amount = max(0, int(trial_tokens))
now = int(time.time()) now = int(time.time())
@@ -491,31 +669,63 @@ class UserStore:
).fetchone() ).fetchone()
current = int(row["token_balance"] or 0) if row else 0 current = int(row["token_balance"] or 0) if row else 0
if current <= 0 and amount > 0: if current <= 0 and amount > 0:
new_balance = amount
c.execute( c.execute(
""" """
UPDATE user_wallets UPDATE user_wallets
SET vip_enabled=1, token_balance=?, updated_at=? SET vip_enabled=1, token_balance=?, updated_at=?
WHERE user_id=? 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) return self.get_vip_status(user_id)
def get_vip_status(self, user_id: int) -> dict: def get_vip_status(self, user_id: int) -> dict:
with self._conn() as c: with self._conn() as c:
self._ensure_wallet_row(c, user_id) self._ensure_wallet_row(c, user_id)
self._refresh_billing_cycle(c, user_id)
row = c.execute( 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 FROM user_wallets
WHERE user_id=? WHERE user_id=?
""", """,
(user_id,), (user_id,),
).fetchone() ).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 { return {
"vip_enabled": bool(int(row["vip_enabled"] or 0)) if row else False, "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, "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, "updated_at": int(row["updated_at"] or 0) if row else 0,
} }
@@ -529,45 +739,328 @@ class UserStore:
) )
return self.get_vip_status(user_id) 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)) add = max(0, int(tokens))
now = int(time.time()) now = int(time.time())
with self._conn() as c: with self._conn() as c:
self._ensure_wallet_row(c, user_id) 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
SET token_balance=token_balance + ?, vip_enabled=1, updated_at=?
WHERE user_id=?
""",
(add, now, user_id),
)
c.execute( c.execute(
""" """
UPDATE user_wallets INSERT INTO token_ledger(
SET token_balance=token_balance + ?, vip_enabled=1, updated_at=? user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at
WHERE user_id=? ) VALUES (?, 'in', ?, ?, ?, ?, ?, ?, ?)
""", """,
(add, now, user_id), (
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) 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)) cost = max(0, int(tokens))
now = int(time.time()) now = int(time.time())
with self._conn() as c: with self._conn() as c:
self._ensure_wallet_row(c, user_id) self._ensure_wallet_row(c, user_id)
self._refresh_billing_cycle(c, user_id)
row = c.execute( 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,), (user_id,),
).fetchone() ).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: if cost <= 0:
return True, balance return True, seat_remaining + shared_balance
if balance < cost: use_from_seat = min(seat_remaining, cost)
return False, balance need_shared = cost - use_from_seat
new_balance = balance - cost 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( c.execute(
""" """
UPDATE user_wallets 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=? 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( def save_wechat_binding(
self, self,
@@ -887,6 +1380,41 @@ class UserStore:
) )
return True 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: def get_active_ai_model(self, user_id: int) -> dict | None:
with self._conn() as c: with self._conn() as c:
pref = c.execute( pref = c.execute(

View File

@@ -18,6 +18,8 @@ const imBtn = $("imBtn");
const coverUploadBtn = $("coverUploadBtn"); const coverUploadBtn = $("coverUploadBtn");
const coverUrlUploadBtn = $("coverUrlUploadBtn"); const coverUrlUploadBtn = $("coverUrlUploadBtn");
const coverGenerateBtn = $("coverGenerateBtn"); const coverGenerateBtn = $("coverGenerateBtn");
const saveCoverImageModelBtn = $("saveCoverImageModelBtn");
const coverImageModelInput = $("coverImageModel");
const coverModeManualBtn = $("coverModeManualBtn"); const coverModeManualBtn = $("coverModeManualBtn");
const coverModeAiBtn = $("coverModeAiBtn"); const coverModeAiBtn = $("coverModeAiBtn");
const coverManualSection = $("coverManualSection"); const coverManualSection = $("coverManualSection");
@@ -26,11 +28,13 @@ const coverAutoAfterRewrite = $("coverAutoAfterRewrite");
const coverPreview = $("coverPreview"); const coverPreview = $("coverPreview");
const coverPreviewWrap = $("coverPreviewWrap"); const coverPreviewWrap = $("coverPreviewWrap");
const logoutBtn = $("logoutBtn"); const logoutBtn = $("logoutBtn");
const clearDraftBtn = $("clearDraftBtn");
const targetBodyCharsInput = $("targetBodyChars"); const targetBodyCharsInput = $("targetBodyChars");
const posterGenerateBtn = $("posterGenerateBtn"); const posterGenerateBtn = $("posterGenerateBtn");
const posterPreviewList = $("posterPreviewList"); const posterPreviewList = $("posterPreviewList");
const posterHint = $("posterHint"); const posterHint = $("posterHint");
const posterAutoInclude = $("posterAutoInclude"); const posterAutoInclude = $("posterAutoInclude");
const DRAFT_STORAGE_KEY = "aifagao:index:draft:v1";
let posterState = { let posterState = {
signature: "", signature: "",
@@ -125,6 +129,123 @@ function setStatus(msg, danger = false) {
statusEl.textContent = msg; 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() { function buildPosterSignature() {
const title = ($("title") && $("title").value.trim()) || ""; const title = ($("title") && $("title").value.trim()) || "";
const summary = ($("summary") && $("summary").value.trim()) || ""; const summary = ($("summary") && $("summary").value.trim()) || "";
@@ -183,7 +304,7 @@ function renderPosterPreview(posters) {
function markPosterStaleIfNeeded() { function markPosterStaleIfNeeded() {
if (!posterState.signature || !posterHint) return; if (!posterState.signature || !posterHint) return;
if (posterState.signature !== buildPosterSignature()) { if (posterState.signature !== buildPosterSignature()) {
posterHint.textContent = "正文已修改,发布前会自动重建段落海报。"; posterHint.textContent = "正文已修改,如需海报请手动点击“生成段落海报。";
} }
} }
@@ -194,11 +315,26 @@ async function postJSON(url, body) {
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = await res.json(); 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") || ""; data._requestId = res.headers.get("X-Request-ID") || "";
return data; 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 } = {}) { async function generatePosterMaterials({ silent = false } = {}) {
const bodyMarkdown = (($("body") && $("body").value) || "").trim(); const bodyMarkdown = (($("body") && $("body").value) || "").trim();
if (bodyMarkdown.length < 20) { if (bodyMarkdown.length < 20) {
@@ -212,9 +348,14 @@ async function generatePosterMaterials({ silent = false } = {}) {
title: $("title").value, title: $("title").value,
summary: $("summary").value, summary: $("summary").value,
body_markdown: $("body").value, body_markdown: $("body").value,
image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "",
upload_to_wechat: true, 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 = { posterState = {
signature: buildPosterSignature(), signature: buildPosterSignature(),
@@ -252,9 +393,14 @@ async function generateWechatCover({ silent = false } = {}) {
title, title,
summary: (($("summary") && $("summary").value) || "").trim(), summary: (($("summary") && $("summary").value) || "").trim(),
style_hint: (($("coverStyleHint") && $("coverStyleHint").value) || "").trim(), style_hint: (($("coverStyleHint") && $("coverStyleHint").value) || "").trim(),
image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "",
upload_to_wechat: true, 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 || ""; const mid = data.thumb_media_id || "";
if (mid && $("thumbMediaId")) $("thumbMediaId").value = mid; if (mid && $("thumbMediaId")) $("thumbMediaId").value = mid;
if (data.preview_data_url && coverPreview && coverPreviewWrap) { if (data.preview_data_url && coverPreview && coverPreviewWrap) {
@@ -353,6 +499,20 @@ async function initWechatAccountSwitch() {
if (me) renderWechatAccountSelect(me); 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() { async function logoutAndGoAuth() {
try { try {
await postJSON("/api/auth/logout", {}); await postJSON("/api/auth/logout", {});
@@ -369,6 +529,12 @@ if (logoutBtn) {
}); });
} }
if (clearDraftBtn) {
clearDraftBtn.addEventListener("click", () => {
clearDraftState();
});
}
if (coverModeManualBtn) { if (coverModeManualBtn) {
coverModeManualBtn.addEventListener("click", () => setCoverMode("manual")); coverModeManualBtn.addEventListener("click", () => setCoverMode("manual"));
} }
@@ -419,6 +585,7 @@ $("rewriteBtn").addEventListener("click", async () => {
$("summary").value = data.summary || ""; $("summary").value = data.summary || "";
$("body").value = data.body_markdown || ""; $("body").value = data.body_markdown || "";
updateCounters(); updateCounters();
saveDraftState();
const tr = data.trace || {}; const tr = data.trace || {};
if (data.mode === "fallback") { if (data.mode === "fallback") {
const note = (data.quality_notes || [])[0] || "当前为保底改写稿"; const note = (data.quality_notes || [])[0] || "当前为保底改写稿";
@@ -429,23 +596,19 @@ $("rewriteBtn").addEventListener("click", async () => {
} else { } else {
setStatus("改写完成。"); setStatus("改写完成。");
} }
try { if (posterHint) posterHint.textContent = "改写完成。默认不自动生成海报,可手动点击“生成段落海报”。";
setStatus("改写完成,正在生成段落海报...");
await generatePosterMaterials({ silent: true });
setStatus("改写与段落海报生成完成。");
} catch (posterErr) {
setStatus(`改写完成,段落海报未生成:${posterErr.message}`, true);
}
if (coverAutoAfterRewrite && coverAutoAfterRewrite.checked) { if (coverAutoAfterRewrite && coverAutoAfterRewrite.checked) {
try { try {
setStatus("改写完成,正在按输出标题生成封面..."); setStatus("改写完成,正在按输出标题生成封面...");
await generateWechatCover({ silent: true }); await generateWechatCover({ silent: true });
setStatus("改写、封面与段落海报生成完成。"); setStatus("改写、封面与段落海报生成完成。");
} catch (coverErr) { } catch (coverErr) {
if (handleUpgradeRequired(coverErr)) return;
setStatus(`改写完成,封面未生成:${coverErr.message}`, true); setStatus(`改写完成,封面未生成:${coverErr.message}`, true);
} }
} }
} catch (e) { } catch (e) {
if (handleUpgradeRequired(e)) return;
setStatus(`改写失败: ${e.message}`, true); setStatus(`改写失败: ${e.message}`, true);
} finally { } finally {
setLoading(rewriteBtn, false, "改写并排版", "改写中..."); setLoading(rewriteBtn, false, "改写并排版", "改写中...");
@@ -460,15 +623,10 @@ $("wechatBtn").addEventListener("click", async () => {
const autoInclude = Boolean(posterAutoInclude && posterAutoInclude.checked); const autoInclude = Boolean(posterAutoInclude && posterAutoInclude.checked);
if (autoInclude) { if (autoInclude) {
const stale = posterState.signature !== buildPosterSignature() || !posterState.bodyMarkdownWithPosters; const stale = posterState.signature !== buildPosterSignature() || !posterState.bodyMarkdownWithPosters;
if (stale) { if (!stale && posterState.bodyMarkdownWithPosters) {
try {
await generatePosterMaterials({ silent: true });
} catch (posterErr) {
setStatus(`海报生成失败,本次仅发布文字:${posterErr.message}`, true);
}
}
if (posterState.bodyMarkdownWithPosters) {
bodyForPublish = posterState.bodyMarkdownWithPosters; bodyForPublish = posterState.bodyMarkdownWithPosters;
} else {
setStatus("未检测到可用海报,本次仅发布文字;如需海报请先手动生成。", true);
} }
} }
const data = await postJSON("/api/publish/wechat", { const data = await postJSON("/api/publish/wechat", {
@@ -495,7 +653,9 @@ if (coverGenerateBtn) {
coverGenerateBtn.addEventListener("click", async () => { coverGenerateBtn.addEventListener("click", async () => {
try { try {
await generateWechatCover({ silent: false }); await generateWechatCover({ silent: false });
saveDraftState();
} catch (e) { } catch (e) {
if (handleUpgradeRequired(e)) return;
const hint = $("coverHint"); const hint = $("coverHint");
if (hint) hint.textContent = "AI 封面生成失败,请检查标题、模型或公众号配置。"; if (hint) hint.textContent = "AI 封面生成失败,请检查标题、模型或公众号配置。";
setStatus(`封面生成失败: ${e.message}`, true); 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) { if (coverUploadBtn) {
coverUploadBtn.addEventListener("click", async () => { coverUploadBtn.addEventListener("click", async () => {
const fileInput = $("coverFile"); const fileInput = $("coverFile");
@@ -529,6 +713,7 @@ if (coverUploadBtn) {
if ($("thumbMediaId")) $("thumbMediaId").value = mid; if ($("thumbMediaId")) $("thumbMediaId").value = mid;
if (hint) hint.textContent = `封面上传成功,已绑定 media_id${mid}`; if (hint) hint.textContent = `封面上传成功,已绑定 media_id${mid}`;
setStatus("封面上传成功,发布时将优先使用该封面。"); setStatus("封面上传成功,发布时将优先使用该封面。");
saveDraftState();
} catch (e) { } catch (e) {
if (hint) hint.textContent = "封面上传失败,请看状态提示。"; if (hint) hint.textContent = "封面上传失败,请看状态提示。";
setStatus(`封面上传失败: ${e.message}`, true); setStatus(`封面上传失败: ${e.message}`, true);
@@ -561,6 +746,7 @@ if (coverUrlUploadBtn) {
if (hint) hint.textContent = `URL 封面上传成功,已绑定 media_id${mid}`; if (hint) hint.textContent = `URL 封面上传成功,已绑定 media_id${mid}`;
setStatus("URL 封面上传成功,发布时将优先使用该封面。"); setStatus("URL 封面上传成功,发布时将优先使用该封面。");
if ($("coverUrl")) $("coverUrl").value = ""; if ($("coverUrl")) $("coverUrl").value = "";
saveDraftState();
} catch (e) { } catch (e) {
if (hint) hint.textContent = "URL 封面上传失败,请看状态提示。"; if (hint) hint.textContent = "URL 封面上传失败,请看状态提示。";
setStatus(`URL 封面上传失败: ${e.message}`, true); setStatus(`URL 封面上传失败: ${e.message}`, true);
@@ -579,7 +765,9 @@ if (posterGenerateBtn) {
posterGenerateBtn.addEventListener("click", async () => { posterGenerateBtn.addEventListener("click", async () => {
try { try {
await generatePosterMaterials({ silent: false }); await generatePosterMaterials({ silent: false });
saveDraftState();
} catch (e) { } catch (e) {
if (handleUpgradeRequired(e)) return;
setStatus(`海报生成失败: ${e.message}`, true); setStatus(`海报生成失败: ${e.message}`, true);
if (posterHint) posterHint.textContent = "海报生成失败,请检查配置后重试。"; if (posterHint) posterHint.textContent = "海报生成失败,请检查配置后重试。";
if ((e.message || "").includes("请先登录")) { if ((e.message || "").includes("请先登录")) {
@@ -610,13 +798,30 @@ $("imBtn").addEventListener("click", async () => {
["sourceText", "title", "summary", "body"].forEach((id) => { ["sourceText", "title", "summary", "body"].forEach((id) => {
$(id).addEventListener("input", updateCounters); $(id).addEventListener("input", updateCounters);
$(id).addEventListener("input", saveDraftState);
if (id !== "sourceText") $(id).addEventListener("input", markPosterStaleIfNeeded); 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(); updateCounters();
initMultiDropdowns(); initMultiDropdowns();
initWechatAccountSwitch(); initWechatAccountSwitch();
syncTargetCharChips(); syncTargetCharChips();
renderPosterPreview([]); renderPosterPreview([]);
setCoverMode("manual"); setCoverMode("manual");
initImageModelStatus();
window.addEventListener("beforeunload", saveDraftState);
window.addEventListener("load", () => setCoverMode("manual")); window.addEventListener("load", () => setCoverMode("manual"));

View File

@@ -1,4 +1,5 @@
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
let challengeId = "";
function setStatus(msg, danger = false) { function setStatus(msg, danger = false) {
const el = $("status"); const el = $("status");
@@ -24,6 +25,21 @@ async function postJSON(url, body) {
return data; 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() { function nextPath() {
const nxt = (window.__NEXT_PATH__ || "/").trim(); const nxt = (window.__NEXT_PATH__ || "/").trim();
if (!nxt.startsWith("/")) return "/"; if (!nxt.startsWith("/")) return "/";
@@ -35,6 +51,9 @@ function fields() {
username: ($("username") && $("username").value.trim()) || "", username: ($("username") && $("username").value.trim()) || "",
password: ($("password") && $("password").value) || "", password: ($("password") && $("password").value) || "",
remember_me: Boolean($("rememberMe") && $("rememberMe").checked), 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 loginBtn = $("loginBtn");
const registerBtn = $("registerBtn"); const registerBtn = $("registerBtn");
const refreshChallengeBtn = $("refreshChallengeBtn");
if (loginBtn) { if (loginBtn) {
loginBtn.addEventListener("click", async () => { loginBtn.addEventListener("click", async () => {
@@ -78,7 +98,7 @@ if (registerBtn) {
const msg = const msg =
`注册成功!请务必保存你的重置码(找回密码唯一凭证):\n\n${code}\n\n` + `注册成功!请务必保存你的重置码(找回密码唯一凭证):\n\n${code}\n\n` +
"请立即复制并妥善保管,点击“确定”后继续进入系统。"; "请立即复制并妥善保管,点击“确定”后继续进入系统。";
window.alert(msg); await window.uiAlert(msg, "注册成功");
try { try {
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
@@ -91,8 +111,19 @@ if (registerBtn) {
window.location.href = nextPath(); window.location.href = nextPath();
} catch (e) { } catch (e) {
setStatus(e.message || "请求异常", true); setStatus(e.message || "请求异常", true);
await refreshChallenge();
} finally { } finally {
setLoading(registerBtn, false, "注册", "注册中..."); 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
View 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
View 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();
})();

View File

@@ -72,18 +72,28 @@ function renderModels(me) {
list.forEach((m) => { list.forEach((m) => {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = String(m.id); opt.value = String(m.id);
const imageModel = (m.image_model || "").trim(); opt.textContent = `${m.model_name} (${m.model})`;
opt.textContent = imageModel ? `${m.model_name} (${m.model} / 图:${imageModel})` : `${m.model_name} (${m.model})`;
if ((active && m.id === active) || m.active) opt.selected = true; if ((active && m.id === active) || m.active) opt.selected = true;
sel.appendChild(opt); 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() { async function refresh() {
const me = await authMe(); const me = await authMe();
if (!me) return; if (!me) return;
renderAccounts(me); renderAccounts(me);
renderModels(me); renderModels(me);
renderVip(me);
} }
const accountSelect = $("accountSelect"); const accountSelect = $("accountSelect");
@@ -95,6 +105,8 @@ const deleteAccountBtn = $("deleteAccountBtn");
const modelSelect = $("modelSelect"); const modelSelect = $("modelSelect");
const saveModelBtn = $("saveModelBtn"); const saveModelBtn = $("saveModelBtn");
const deleteModelBtn = $("deleteModelBtn"); const deleteModelBtn = $("deleteModelBtn");
const saveVipBtn = $("saveVipBtn");
const vipRechargeBtn = $("vipRechargeBtn");
if (accountSelect) { if (accountSelect) {
accountSelect.addEventListener("change", async () => { accountSelect.addEventListener("change", async () => {
@@ -121,7 +133,7 @@ if (deleteWechatBtn) {
setStatus("请先选择要删除的公众号", true); setStatus("请先选择要删除的公众号", true);
return; return;
} }
const sure = window.confirm("确定删除当前公众号绑定吗?删除后不可恢复。"); const sure = await window.uiConfirm("确定删除当前公众号绑定吗?删除后不可恢复。", "删除公众号");
if (!sure) return; if (!sure) return;
setLoading(deleteWechatBtn, true, "删除当前公众号", "删除中..."); setLoading(deleteWechatBtn, true, "删除当前公众号", "删除中...");
try { try {
@@ -195,7 +207,7 @@ if (saveModelBtn) {
api_key: ($("apiKey") && $("apiKey").value.trim()) || "", api_key: ($("apiKey") && $("apiKey").value.trim()) || "",
base_url: ($("baseUrl") && $("baseUrl").value.trim()) || "", base_url: ($("baseUrl") && $("baseUrl").value.trim()) || "",
model: ($("modelValue") && $("modelValue").value.trim()) || "", model: ($("modelValue") && $("modelValue").value.trim()) || "",
image_model: ($("imageModelValue") && $("imageModelValue").value.trim()) || "", image_model: "",
timeout_sec: Number((($("timeoutSec") && $("timeoutSec").value) || "120").trim()), timeout_sec: Number((($("timeoutSec") && $("timeoutSec").value) || "120").trim()),
max_output_tokens: Number((($("maxOutputTokens") && $("maxOutputTokens").value) || "8192").trim()), max_output_tokens: Number((($("maxOutputTokens") && $("maxOutputTokens").value) || "8192").trim()),
max_retries: Number((($("maxRetries") && $("maxRetries").value) || "0").trim()), max_retries: Number((($("maxRetries") && $("maxRetries").value) || "0").trim()),
@@ -207,7 +219,6 @@ if (saveModelBtn) {
setStatus("模型配置已保存并设为当前。"); setStatus("模型配置已保存并设为当前。");
if ($("apiKey")) $("apiKey").value = ""; if ($("apiKey")) $("apiKey").value = "";
if ($("modelName")) $("modelName").value = ""; if ($("modelName")) $("modelName").value = "";
if ($("imageModelValue")) $("imageModelValue").value = "";
await refresh(); await refresh();
} catch (e) { } catch (e) {
setStatus(e.message || "模型保存失败", true); setStatus(e.message || "模型保存失败", true);
@@ -224,7 +235,7 @@ if (deleteModelBtn) {
setStatus("请先选择要删除的模型", true); setStatus("请先选择要删除的模型", true);
return; return;
} }
const sure = window.confirm("确定删除当前模型配置吗?删除后不可恢复。"); const sure = await window.uiConfirm("确定删除当前模型配置吗?删除后不可恢复。", "删除模型");
if (!sure) return; if (!sure) return;
setLoading(deleteModelBtn, true, "删除当前模型", "删除中..."); setLoading(deleteModelBtn, true, "删除当前模型", "删除中...");
try { 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) { if (logoutBtn) {
logoutBtn.addEventListener("click", async () => { logoutBtn.addEventListener("click", async () => {
setLoading(logoutBtn, true, "退出登录", "退出中..."); setLoading(logoutBtn, true, "退出登录", "退出中...");
@@ -292,9 +358,14 @@ if (deleteAccountBtn) {
setStatus("请输入注销校验重置码", true); setStatus("请输入注销校验重置码", true);
return; return;
} }
const sure = window.confirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。"); const sure = await window.uiConfirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。", "注销账户");
if (!sure) return; if (!sure) return;
const confirmText = window.prompt("为防止误删,请输入「注销账户」后确认:", ""); const confirmText = await window.uiPrompt(
"为防止误删,请输入「注销账户」后确认:",
"二次确认",
"",
"请输入:注销账户",
);
if ((confirmText || "").trim() !== "注销账户") { if ((confirmText || "").trim() !== "注销账户") {
setStatus("二次确认未通过,已取消注销。", true); setStatus("二次确认未通过,已取消注销。", true);
return; return;

View File

@@ -257,6 +257,17 @@ a {
background: transparent; background: transparent;
font-size: 13px; font-size: 13px;
font-weight: 750; 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 { .topbar-select:focus {
@@ -441,6 +452,45 @@ button {
transform 0.18s ease; 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 { textarea {
resize: vertical; resize: vertical;
line-height: 1.6; line-height: 1.6;
@@ -770,6 +820,49 @@ button.target-char-chip {
align-items: center; 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 { .cover-mode-switch {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -929,6 +1022,92 @@ button.target-char-chip {
padding-right: 2px; 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 { .poster-card {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
@@ -1093,6 +1272,33 @@ button.target-char-chip {
box-shadow: var(--shadow-soft); 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 { .settings-layout {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
@@ -1318,6 +1524,525 @@ button.target-char-chip {
line-height: 1.65; 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 { .section-title {
margin: 0 0 12px; margin: 0 0 12px;
font-size: 16px; font-size: 16px;
@@ -1804,3 +2529,60 @@ button.target-char-chip {
grid-template-columns: 1fr; 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
View 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
View 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);

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 登录注册</title> <title>{{ app_name }} - 登录注册</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" /> <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> </head>
<body class="simple-page auth-page"> <body class="simple-page auth-page">
<main class="auth-shell"> <main class="auth-shell">
@@ -66,11 +66,19 @@
<div class="auth-form"> <div class="auth-form">
<div> <div>
<label>用户名</label> <label>用户名</label>
<input id="username" type="text" placeholder="请输入用户名" autocomplete="username" /> <input id="username" type="text" placeholder="4-24 位,字母数字下划线" autocomplete="username" />
</div> </div>
<div> <div>
<label>密码</label> <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>
<div class="check-row"> <div class="check-row">
<label class="check-label"> <label class="check-label">
@@ -94,6 +102,7 @@
<script> <script>
window.__NEXT_PATH__ = {{ next|tojson }}; window.__NEXT_PATH__ = {{ next|tojson }};
</script> </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> </body>
</html> </html>

100
app/templates/billing.html Normal file
View 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>

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 忘记密码</title> <title>{{ app_name }} - 忘记密码</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" /> <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> </head>
<body class="simple-page"> <body class="simple-page">
<main class="auth-card"> <main class="auth-card">

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 新手引导</title> <title>{{ app_name }} - 新手引导</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" /> <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> </head>
<body> <body>
<div class="product-shell"> <div class="product-shell">
@@ -20,6 +20,9 @@
<div class="nav-label">工作台</div> <div class="nav-label">工作台</div>
<a class="nav-item" href="/">内容生产</a> <a class="nav-item" href="/">内容生产</a>
<a class="nav-item" href="/settings">账号与模型</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> <a class="nav-item is-active" href="/guide">新手引导</a>
</nav> </nav>
<div class="side-footer">首次配置 · 三分钟跑通</div> <div class="side-footer">首次配置 · 三分钟跑通</div>
@@ -29,6 +32,8 @@
<header class="topbar topbar-compact"> <header class="topbar topbar-compact">
<div class="topbar-actions"> <div class="topbar-actions">
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台"></a> <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> <a class="icon-btn" href="/settings" aria-label="账号与模型设置" title="账号与模型设置"></a>
</div> </div>
</header> </header>

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }}</title> <title>{{ app_name }}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" /> <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> </head>
<body> <body>
<div class="product-shell"> <div class="product-shell">
@@ -20,6 +20,9 @@
<div class="nav-label">工作台</div> <div class="nav-label">工作台</div>
<a class="nav-item is-active" href="/">内容生产</a> <a class="nav-item is-active" href="/">内容生产</a>
<a class="nav-item" href="/settings">账号与模型</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> <a class="nav-item" href="/guide">新手引导</a>
</nav> </nav>
<div class="side-footer">生产环境 · 内容工作流</div> <div class="side-footer">生产环境 · 内容工作流</div>
@@ -33,8 +36,11 @@
<select id="wechatAccountSelect" class="topbar-select" aria-label="切换公众号"></select> <select id="wechatAccountSelect" class="topbar-select" aria-label="切换公众号"></select>
<span id="wechatAccountStatus" class="muted small wechat-account-status" aria-live="polite"></span> <span id="wechatAccountStatus" class="muted small wechat-account-status" aria-live="polite"></span>
</div> </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="/guide" aria-label="新手引导" title="新手引导">?</a>
<a class="icon-btn" href="/settings" 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> <button id="logoutBtn" class="icon-btn topbar-btn" type="button" aria-label="退出登录" title="退出登录"></button>
</div> </div>
</header> </header>
@@ -168,6 +174,10 @@
<strong>AI 自动生成封面</strong> <strong>AI 自动生成封面</strong>
<span>按标题生成并自动上传绑定。</span> <span>按标题生成并自动上传绑定。</span>
</div> </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"> <div class="cover-tools">
<input id="coverStyleHint" type="text" placeholder="可选:封面风格" /> <input id="coverStyleHint" type="text" placeholder="可选:封面风格" />
<button id="coverGenerateBtn" class="primary" type="button">按标题生成封面</button> <button id="coverGenerateBtn" class="primary" type="button">按标题生成封面</button>
@@ -220,10 +230,10 @@
<div class="poster-actions-row"> <div class="poster-actions-row">
<button id="posterGenerateBtn" class="subtle-btn" type="button">生成段落海报</button> <button id="posterGenerateBtn" class="subtle-btn" type="button">生成段落海报</button>
<label class="check-label poster-auto-check" <label class="check-label poster-auto-check"
><input id="posterAutoInclude" type="checkbox" checked />发布时自动插入海报</label ><input id="posterAutoInclude" type="checkbox" />发布时自动插入海报</label
> >
</div> </div>
<p id="posterHint" class="muted small">改写后可生成段落海报</p> <p id="posterHint" class="muted small">默认不生成海报,点击“生成段落海报”后再插入发布</p>
<div id="posterPreviewList" class="poster-preview-list"></div> <div id="posterPreviewList" class="poster-preview-list"></div>
</div> </div>
</section> </section>
@@ -239,6 +249,6 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <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> </body>
</html> </html>

View 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>

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 账号与模型设置</title> <title>{{ app_name }} - 账号与模型设置</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" /> <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> </head>
<body> <body>
<div class="product-shell"> <div class="product-shell">
@@ -20,6 +20,9 @@
<div class="nav-label">工作台</div> <div class="nav-label">工作台</div>
<a class="nav-item" href="/">内容生产</a> <a class="nav-item" href="/">内容生产</a>
<a class="nav-item is-active" href="/settings">账号与模型</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> <a class="nav-item" href="/guide">新手引导</a>
</nav> </nav>
<div class="side-footer">生产环境 · 内容工作流</div> <div class="side-footer">生产环境 · 内容工作流</div>
@@ -29,6 +32,8 @@
<header class="topbar topbar-compact"> <header class="topbar topbar-compact">
<div class="topbar-actions"> <div class="topbar-actions">
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台"></a> <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> <a class="icon-btn" href="/guide" aria-label="新手引导" title="新手引导">?</a>
<button id="logoutBtn" 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> </div>
@@ -41,7 +46,7 @@
<section class="settings-section settings-card"> <section class="settings-section settings-card">
<div> <div>
<label>当前账号</label> <label>当前账号</label>
<select id="accountSelect"></select> <select id="accountSelect" class="ui-select"></select>
</div> </div>
<h3 class="section-title">新增公众号</h3> <h3 class="section-title">新增公众号</h3>
@@ -70,7 +75,7 @@
<div class="grid2"> <div class="grid2">
<div> <div>
<label>当前模型</label> <label>当前模型</label>
<select id="modelSelect"></select> <select id="modelSelect" class="ui-select"></select>
</div> </div>
<div class="actions-inline"> <div class="actions-inline">
<button id="deleteModelBtn" class="secondary topbar-btn" type="button">删除当前模型</button> <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" /> <input id="modelValue" type="text" placeholder="如gpt-4.1-mini / qwen-max" />
</div> </div>
</div> </div>
<div>
<label>文生图模型名</label>
<input id="imageModelValue" type="text" placeholder="如gpt-image-1 / wanx2.1-t2i-plus用于封面和段落海报" />
</div>
<div class="grid2"> <div class="grid2">
<div> <div>
<label>Base URL可选</label> <label>Base URL可选</label>
@@ -152,6 +153,7 @@
</main> </main>
</div> </div>
</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> </body>
</html> </html>

177
app/templates/upgrade.html Normal file
View 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>